From 83c78a470cda9b99d7f947b0f36beeffff20e699 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Fri, 16 Aug 2024 11:26:15 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20add=20information=20about=20target=20res?= =?UTF-8?q?ponse=20into=20error=20message=20if=20inte=E2=80=A6=20(#8281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Execution responses with HTTP StatusCode not equal to 200 interrupt the client request silently. # How the Problems Are Solved Adds information about the recieved StatusCode and Body into the error if StatusCode not 200. # Additional Context Closes #8177 --------- Co-authored-by: Elio Bischof Co-authored-by: Livio Spring --- docs/docs/apis/actionsv2/introduction.md | 17 +- .../grpc/user/v2/phone_integration_test.go | 4 +- internal/api/http/error.go | 33 ++++ internal/api/http/error_test.go | 151 +++++++++++++++++ internal/execution/execution.go | 32 +++- internal/execution/execution_test.go | 153 ++++++++++++++++++ internal/static/i18n/bg.yaml | 1 + internal/static/i18n/cs.yaml | 1 + internal/static/i18n/de.yaml | 1 + internal/static/i18n/en.yaml | 2 +- internal/static/i18n/es.yaml | 1 + internal/static/i18n/fr.yaml | 1 + internal/static/i18n/it.yaml | 1 + internal/static/i18n/ja.yaml | 1 + internal/static/i18n/mk.yaml | 1 + internal/static/i18n/nl.yaml | 1 + internal/static/i18n/pl.yaml | 1 + internal/static/i18n/pt.yaml | 1 + internal/static/i18n/ru.yaml | 1 + internal/static/i18n/sv.yaml | 1 + internal/static/i18n/zh.yaml | 1 + 21 files changed, 400 insertions(+), 6 deletions(-) diff --git a/docs/docs/apis/actionsv2/introduction.md b/docs/docs/apis/actionsv2/introduction.md index ace2fca32..c3e3b2144 100644 --- a/docs/docs/apis/actionsv2/introduction.md +++ b/docs/docs/apis/actionsv2/introduction.md @@ -169,4 +169,19 @@ For event there are 3 levels the condition can be defined: - Group, handling a specific group of events - All, handling any event in ZITADEL -The concept of events can be found under [Events](/concepts/architecture/software#events) \ No newline at end of file +The concept of events can be found under [Events](/concepts/architecture/software#events) + +### Error forwarding + +If you want to forward a specific error from the Target through ZITADEL, you can provide a response from the Target with status code 200 and a JSON in the following format: + +```json +{ + "forwardedStatusCode": 403, + "forwardedErrorMessage": "Call is forbidden through the IP AllowList definition" +} +``` + +Only values from 400 to 499 will be forwarded through ZITADEL, other StatusCodes will end in a PreconditionFailed error. + +If the Target returns any other status code than >= 200 and < 299, the execution is looked at as failed, and a PreconditionFailed error is logged. diff --git a/internal/api/grpc/user/v2/phone_integration_test.go b/internal/api/grpc/user/v2/phone_integration_test.go index d67b59d0b..ab88268ee 100644 --- a/internal/api/grpc/user/v2/phone_integration_test.go +++ b/internal/api/grpc/user/v2/phone_integration_test.go @@ -263,7 +263,7 @@ func TestServer_RemovePhone(t *testing.T) { req *user.RemovePhoneRequest want *user.RemovePhoneResponse wantErr bool - dep func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) + dep func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) }{ { name: "remove phone", @@ -303,7 +303,7 @@ func TestServer_RemovePhone(t *testing.T) { dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { return Client.RemovePhone(ctx, &user.RemovePhoneRequest{ UserId: doubleRemoveUser.GetUserId(), - }); + }) }, }, { diff --git a/internal/api/http/error.go b/internal/api/http/error.go index 1469ab010..01813d026 100644 --- a/internal/api/http/error.go +++ b/internal/api/http/error.go @@ -45,3 +45,36 @@ func ZitadelErrorToHTTPStatusCode(err error) (statusCode int, ok bool) { return http.StatusInternalServerError, false } } + +func HTTPStatusCodeToZitadelError(parent error, statusCode int, id string, message string) error { + if statusCode == http.StatusOK { + return nil + } + var errorFunc func(parent error, id, message string) error + switch statusCode { + case http.StatusConflict: + errorFunc = zerrors.ThrowAlreadyExists + case http.StatusGatewayTimeout: + errorFunc = zerrors.ThrowDeadlineExceeded + case http.StatusInternalServerError: + errorFunc = zerrors.ThrowInternal + case http.StatusBadRequest: + errorFunc = zerrors.ThrowInvalidArgument + case http.StatusNotFound: + errorFunc = zerrors.ThrowNotFound + case http.StatusForbidden: + errorFunc = zerrors.ThrowPermissionDenied + case http.StatusUnauthorized: + errorFunc = zerrors.ThrowUnauthenticated + case http.StatusServiceUnavailable: + errorFunc = zerrors.ThrowUnavailable + case http.StatusNotImplemented: + errorFunc = zerrors.ThrowUnimplemented + case http.StatusTooManyRequests: + errorFunc = zerrors.ThrowResourceExhausted + default: + errorFunc = zerrors.ThrowUnknown + } + + return errorFunc(parent, id, message) +} diff --git a/internal/api/http/error_test.go b/internal/api/http/error_test.go index d44c206f8..d912e9f34 100644 --- a/internal/api/http/error_test.go +++ b/internal/api/http/error_test.go @@ -6,6 +6,8 @@ import ( "net/http" "testing" + "github.com/stretchr/testify/assert" + "github.com/zitadel/zitadel/internal/zerrors" ) @@ -136,3 +138,152 @@ func TestZitadelErrorToHTTPStatusCode(t *testing.T) { }) } } + +func TestHTTPStatusCodeToZitadelError(t *testing.T) { + type args struct { + statusCode int + id string + message string + parent error + } + tests := []struct { + name string + args args + wantErr error + }{ + { + name: "StatusOK", + args: args{ + statusCode: http.StatusOK, + }, + wantErr: nil, + }, + { + name: "StatusConflict", + args: args{ + statusCode: http.StatusConflict, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowAlreadyExists(nil, "id", "message"), + }, + { + name: "StatusGatewayTimeout", + args: args{ + statusCode: http.StatusGatewayTimeout, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowDeadlineExceeded(nil, "id", "message"), + }, + { + name: "StatusInternalServerError", + args: args{ + statusCode: http.StatusInternalServerError, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowInternal(nil, "id", "message"), + }, + { + name: "StatusBadRequest", + args: args{ + statusCode: http.StatusBadRequest, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "id", "message"), + }, + { + name: "StatusNotFound", + args: args{ + statusCode: http.StatusNotFound, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowNotFound(nil, "id", "message"), + }, + { + name: "StatusForbidden", + args: args{ + statusCode: http.StatusForbidden, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "id", "message"), + }, + { + name: "StatusUnauthorized", + args: args{ + statusCode: http.StatusUnauthorized, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowUnauthenticated(nil, "id", "message"), + }, + { + name: "StatusServiceUnavailable", + args: args{ + statusCode: http.StatusServiceUnavailable, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowUnavailable(nil, "id", "message"), + }, + { + name: "StatusNotImplemented", + args: args{ + statusCode: http.StatusNotImplemented, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowUnimplemented(nil, "id", "message"), + }, + { + name: "StatusTooManyRequests", + args: args{ + statusCode: http.StatusTooManyRequests, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowResourceExhausted(nil, "id", "message"), + }, + { + name: "Unknown", + args: args{ + statusCode: 1000, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowUnknown(nil, "id", "message"), + }, + { + name: "Unknown, test for statuscode", + args: args{ + statusCode: 1000, + id: "id", + message: "message", + }, + wantErr: zerrors.ThrowError(nil, "id", "message"), + }, + { + name: "Unknown with parent", + args: args{ + statusCode: 1000, + id: "id", + message: "message", + parent: errors.New("parent error"), + }, + wantErr: zerrors.ThrowUnknown(nil, "id", "message"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := HTTPStatusCodeToZitadelError(tt.args.parent, tt.args.statusCode, tt.args.id, tt.args.message) + assert.ErrorIs(t, err, tt.wantErr) + if tt.args.parent != nil { + assert.ErrorIs(t, err, tt.args.parent) + } + }) + } +} diff --git a/internal/execution/execution.go b/internal/execution/execution.go index c493673e9..c4756b86a 100644 --- a/internal/execution/execution.go +++ b/internal/execution/execution.go @@ -3,12 +3,14 @@ package execution import ( "bytes" "context" + "encoding/json" "io" "net/http" "time" "github.com/zitadel/logging" + zhttp "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" @@ -114,9 +116,35 @@ func Call(ctx context.Context, url string, timeout time.Duration, body []byte) ( } defer resp.Body.Close() + return HandleResponse(resp) +} + +func HandleResponse(resp *http.Response) ([]byte, error) { + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } // Check for success between 200 and 299, redirect 300 to 399 is handled by the client, return error with statusCode >= 400 if resp.StatusCode >= 200 && resp.StatusCode <= 299 { - return io.ReadAll(resp.Body) + var errorBody ErrorBody + if err := json.Unmarshal(data, &errorBody); err != nil { + // if json unmarshal fails, body has no ErrorBody information, so will be taken as successful response + return data, nil + } + if errorBody.ForwardedStatusCode != 0 || errorBody.ForwardedErrorMessage != "" { + if errorBody.ForwardedStatusCode >= 400 && errorBody.ForwardedStatusCode < 500 { + return nil, zhttp.HTTPStatusCodeToZitadelError(nil, errorBody.ForwardedStatusCode, "EXEC-reUaUZCzCp", errorBody.ForwardedErrorMessage) + } + return nil, zerrors.ThrowPreconditionFailed(nil, "EXEC-bmhNhpcqpF", errorBody.ForwardedErrorMessage) + } + // no ErrorBody filled in response, so will be taken as successful response + return data, nil } - return nil, zerrors.ThrowUnknown(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed") + + return nil, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed") +} + +type ErrorBody struct { + ForwardedStatusCode int `json:"forwardedStatusCode,omitempty"` + ForwardedErrorMessage string `json:"forwardedErrorMessage,omitempty"` } diff --git a/internal/execution/execution_test.go b/internal/execution/execution_test.go index 4a68ff5ac..c2c6198ed 100644 --- a/internal/execution/execution_test.go +++ b/internal/execution/execution_test.go @@ -1,7 +1,10 @@ package execution_test import ( + "bytes" "context" + "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/execution" + "github.com/zitadel/zitadel/internal/zerrors" ) func Test_Call(t *testing.T) { @@ -513,3 +517,152 @@ var requestContextInfoBody2 = []byte("{\"request\":{\"request\":\"content2\"}}") type request struct { Request string `json:"request"` } + +func testErrorBody(code int, message string) []byte { + body := &execution.ErrorBody{ForwardedStatusCode: code, ForwardedErrorMessage: message} + data, _ := json.Marshal(body) + return data +} + +func Test_handleResponse(t *testing.T) { + type args struct { + resp *http.Response + } + type res struct { + data []byte + wantErr func(error) bool + } + tests := []struct { + name string + args args + res res + }{ + { + "response, statuscode unknown and body", + args{ + resp: &http.Response{ + StatusCode: 1000, + Body: io.NopCloser(bytes.NewReader([]byte(""))), + }, + }, + res{ + wantErr: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed")) + }, + }, + }, + { + "response, statuscode >= 400 and no body", + args{ + resp: &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(bytes.NewReader([]byte(""))), + }, + }, + res{ + wantErr: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed")) + }, + }, + }, + { + "response, statuscode >= 400 and body", + args{ + resp: &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(bytes.NewReader([]byte("body"))), + }, + }, + res{ + wantErr: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed")) + }}, + }, + { + "response, statuscode = 200 and body", + args{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte("body"))), + }, + }, + res{ + data: []byte("body"), + wantErr: nil, + }, + }, + { + "response, statuscode = 200 no body", + args{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(""))), + }, + }, + res{ + data: []byte(""), + wantErr: nil, + }, + }, + { + "response, statuscode = 200, error body >= 400 < 500", + args{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(testErrorBody(http.StatusForbidden, "forbidden"))), + }, + }, + res{ + wantErr: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "EXEC-reUaUZCzCp", "forbidden")) + }, + }, + }, + { + "response, statuscode = 200, error body >= 500", + args{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(testErrorBody(http.StatusInternalServerError, "internal"))), + }, + }, + res{ + wantErr: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-bmhNhpcqpF", "internal")) + }, + }, + }, + { + "response, statuscode = 308, no body, should not happen", + args{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(testErrorBody(http.StatusPermanentRedirect, "redirect"))), + }, + }, + res{ + wantErr: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-bmhNhpcqpF", "redirect")) + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + respBody, err := execution.HandleResponse( + tt.args.resp, + ) + + if tt.res.wantErr == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.wantErr(err) { + t.Errorf("got wrong err: %v", err) + return + } + assert.Equal(t, tt.res.data, respBody) + }) + } + +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 7f868b3c3..291cfb044 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -581,6 +581,7 @@ Errors: NotFound: Изпълнението не е намерено IncludeNotFound: Включването не е намерено NoTargets: Няма определени цели + Failed: неуспешно изпълнение ResponseIsNotValidJSON: Отговорът не е валиден JSON UserSchema: NotEnabled: Функцията „Потребителска схема“ не е активирана diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 2baf69411..17e15f91d 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -562,6 +562,7 @@ Errors: NotFound: Provedení nenalezeno IncludeNotFound: Zahrnout nenalezeno NoTargets: Nejsou definovány žádné cíle + Failed: Provedení se nezdařilo ResponseIsNotValidJSON: Odpověď není platný JSON UserSchema: NotEnabled: Funkce "Uživatelské schéma" není povolena diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index fb9ab8e21..de1ad3b0a 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -564,6 +564,7 @@ Errors: NotFound: Ausführung nicht gefunden IncludeNotFound: Einschließen nicht gefunden NoTargets: Keine Ziele definiert + Failed: Ausführung fehlgeschlagen ResponseIsNotValidJSON: Antwort ist kein gültiges JSON UserSchema: NotEnabled: Funktion Benutzerschema ist nicht aktiviert diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 0b1908fc8..5d13ac234 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -564,6 +564,7 @@ Errors: NotFound: Execution not found IncludeNotFound: Include not found NoTargets: No targets defined + Failed: Execution failed ResponseIsNotValidJSON: Response is not valid JSON UserSchema: NotEnabled: Feature "User Schema" is not enabled @@ -594,7 +595,6 @@ Errors: NoActive: No active web key found NotFound: Web key not found - AggregateTypes: action: Action instance: Instance diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 5ab5ee454..cbd75d259 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -564,6 +564,7 @@ Errors: NotFound: Ejecución no encontrada IncludeNotFound: Incluir no encontrado NoTargets: No hay objetivos definidos + Failed: Ejecución fallida ResponseIsNotValidJSON: La respuesta no es un JSON válido UserSchema: NotEnabled: La función "Esquema de usuario" no está habilitada diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 79f133395..1a17c3cfb 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -564,6 +564,7 @@ Errors: NotFound: Exécution introuvable IncludeNotFound: Inclure introuvable NoTargets: Aucune cible définie + Failed: Exécution échouée ResponseIsNotValidJSON: La réponse n'est pas un JSON valide UserSchema: NotEnabled: La fonctionnalité "Schéma utilisateur" n'est pas activée diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 2df6792a7..a8d80780e 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -564,6 +564,7 @@ Errors: NotFound: Esecuzione non trovata IncludeNotFound: Includi non trovato NoTargets: Nessun obiettivo definito + Failed: Esecuzione fallita ResponseIsNotValidJSON: La risposta non è un JSON valido UserSchema: NotEnabled: La funzionalità "Schema utente" non è abilitata diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 32e1c645f..13352e6c3 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -553,6 +553,7 @@ Errors: NotFound: 実行が見つかりませんでした IncludeNotFound: 見つからないものを含める NoTargets: ターゲットが定義されていません + Failed: 実行に失敗しました ResponseIsNotValidJSON: 応答は有効な JSON ではありません UserSchema: NotEnabled: 機能「ユーザースキーマ」が有効になっていません diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 627cdabd7..9abd478fa 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -563,6 +563,7 @@ Errors: NotFound: Извршувањето не е пронајдено IncludeNotFound: Вклучете не е пронајден NoTargets: Не се дефинирани цели + Failed: Извршувањето не успеа ResponseIsNotValidJSON: Одговорот не е валиден JSON UserSchema: NotEnabled: Функцијата „Корисничка шема“ не е овозможена diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index b9f29169e..3e13c1bf7 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -564,6 +564,7 @@ Errors: NotFound: Uitvoering niet gevonden IncludeNotFound: Inclusief niet gevonden NoTargets: Geen doelstellingen gedefinieerd + Failed: Uitvoering mislukt ResponseIsNotValidJSON: Reactie is geen geldige JSON UserSchema: NotEnabled: Functie "Gebruikersschema" is niet ingeschakeld diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 36da9b177..90b31e486 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -564,6 +564,7 @@ Errors: NotFound: Nie znaleziono wykonania IncludeNotFound: Nie znaleziono uwzględnienia NoTargets: Nie zdefiniowano celów + Failed: Wykonanie nie powiodło się ResponseIsNotValidJSON: Odpowiedź nie jest prawidłowym JSON-em UserSchema: NotEnabled: Funkcja „Schemat użytkownika” nie jest włączona diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index cb6e01382..dd7cb4a86 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -559,6 +559,7 @@ Errors: NotFound: Execução não encontrada IncludeNotFound: Incluir não encontrado NoTargets: Nenhuma meta definida + Failed: Falha na execução ResponseIsNotValidJSON: A resposta não é um JSON válido UserSchema: NotEnabled: O recurso "Esquema do usuário" não está habilitado diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index df7015bc0..8b992dba0 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -553,6 +553,7 @@ Errors: NotFound: Исполнение не найдено IncludeNotFound: Включить не найдено NoTargets: Цели не определены + Failed: Выполнение не удалось ResponseIsNotValidJSON: Ответ не является допустимым JSON UserSchema: NotEnabled: Функция «Пользовательская схема» не включена diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index 45326841e..f6013bd18 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -564,6 +564,7 @@ Errors: NotFound: Exekveringen hittades inte IncludeNotFound: Inkluderingen hittades inte NoTargets: Inga mål definierade + Failed: Utförande misslyckades ResponseIsNotValidJSON: Svaret är inte giltigt JSON UserSchema: NotEnabled: Funktionen "Användarschema" är inte aktiverad diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 05fa70324..79f06555f 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -564,6 +564,7 @@ Errors: NotFound: 未找到执行 IncludeNotFound: 包括未找到的内容 NoTargets: 没有定义目标 + Failed: 执行失败 ResponseIsNotValidJSON: 响应不是有效的 JSON UserSchema: NotEnabled: 未启用“用户架构”功能