package apierrors_test import ( "encoding/json" "errors" "fmt" "math" "strings" "sync" "testing" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" ) func templateCatalog() apierrors.Catalog { return apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{ "TEXT_TOO_LONG": {Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}}, "FIXED": {Code: 400003, Message: "literal {unchanged}"}, }} } func TestMessageParamsIsolationAndResolution(t *testing.T) { catalog := templateCatalog() r := apierrors.MustRegistry(catalog) catalog.Definitions["TEXT_TOO_LONG"].Params[0].Type = "bool" r.Catalog().Definitions["TEXT_TOO_LONG"].Params[0].Name = "changed" private := errors.New("private cause") values := map[string]any{"label": "企业名称", "max": 100} reference := apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(private) values["label"] = "changed" first := r.Resolve(fmt.Errorf("wrapped: %w", reference)) for n := 0; n < 3; n++ { first = r.Resolve(first) if first.Message != "企业名称不能超过100个字符" || first.Status != 200 || first.BusinessCode != int64(400002) || !errors.Is(first, private) { t.Fatal(first) } } second := r.Resolve(first.WithParams(map[string]any{"label": "备注", "max": 500})) if first.Message != "企业名称不能超过100个字符" || second.Message != "备注不能超过500个字符" { t.Fatal(first, second) } if r.Example("TEXT_TOO_LONG").Message != first.Message { t.Fatal("example and runtime differ") } if r.Resolve(apierrors.ByCode("FIXED")).Message != "literal {unchanged}" { t.Fatal("legacy braces changed") } var wg sync.WaitGroup for n := 0; n < 80; n++ { wg.Add(1) go func(n int) { defer wg.Done() label, max := "企业名称", 100 if n%2 == 1 { label, max = "备注", 500 } got := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": label, "max": max}).WithCause(private)) if got.Message != fmt.Sprintf("%s不能超过%d个字符", label, max) || !errors.Is(got, private) { t.Error(got) } }(n) } wg.Wait() } func TestInvalidTemplateParamsFailClosed(t *testing.T) { r := apierrors.MustRegistry(templateCatalog()) cause := errors.New("private") for _, values := range []map[string]any{ nil, {"label": "x"}, {"label": "x", "max": "100"}, {"label": 100, "max": 100}, {"label": "x", "max": 100.0}, {"label": "x", "max": 100, "extra": "private-value"}, {"label": "x", "max": nil}, {"label": map[string]any{"secret": "private-value"}, "max": 100}, } { result := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(cause)) var diagnostic *apierrors.ParamError if result.Code != "INTERNAL_ERROR" || result.Status != 500 || strings.Contains(result.Message, "{") || !errors.As(result, &diagnostic) || !errors.Is(result, cause) { t.Fatal(result) } if strings.Contains(diagnostic.Error(), "private-value") { t.Fatal("diagnostic leaked value") } } // A parameterized fallback cannot recurse or expose its template. c := templateCatalog() c.Definitions["INTERNAL_ERROR"] = c.Definitions["TEXT_TOO_LONG"] if result := apierrors.MustRegistry(c).Resolve(errors.New("private")); result.Message != "internal server error" || result.Status != 500 { t.Fatal(result) } for _, change := range []func(*apierrors.Definition){ func(d *apierrors.Definition) { d.Message = "{label}{missing}" }, func(d *apierrors.Definition) { d.Message = "{label}" }, func(d *apierrors.Definition) { d.Message = "{label}{max" }, func(d *apierrors.Definition) { d.Message = "{label}{max}}" }, func(d *apierrors.Definition) { d.Params[1].Name = "label" }, func(d *apierrors.Definition) { d.Params[1].Name = "type" }, func(d *apierrors.Definition) { d.Params[1].Type = "interface{}" }, func(d *apierrors.Definition) { d.Params[1].Example = "100" }, func(d *apierrors.Definition) { d.Params = []apierrors.Parameter{} }, } { c := templateCatalog() d := c.Definitions["TEXT_TOO_LONG"] change(&d) c.Definitions["TEXT_TOO_LONG"] = d if _, err := apierrors.NewRegistry(c); err == nil { t.Fatal("accepted invalid template", d) } } } func TestParamTypesEscapingAndSnapshot(t *testing.T) { c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"T": {Code: "t", Message: "{{literal}} {value} {value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}}}} r := apierrors.MustRegistry(c) got := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": "{other}"})) if got.Message != "{literal} {other} {other}" { t.Fatal("recursive substitution", got) } for _, test := range []struct { kind string good, bad any want string }{ {"int32", int64(math.MaxInt32), int64(math.MaxInt32) + 1, "2147483647"}, {"int64", json.Number("9223372036854775807"), json.Number("9223372036854775808"), "9223372036854775807"}, {"bool", true, "true", "true"}, {"float64", float64(1.25), math.Inf(1), "1.25"}, } { d := c.Definitions["T"] d.Params = []apierrors.Parameter{{Name: "value", Type: test.kind}} c.Definitions["T"] = d r = apierrors.MustRegistry(c) if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.good})); x.Message != "{literal} "+test.want+" "+test.want { t.Fatal(test.kind, x) } if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.bad})); x.Status != 500 { t.Fatal(test.kind, x) } } original := apierrors.New(200, "T", "complete").WithCause(errors.New("private")) original.BusinessCode = int64(math.MaxInt64) raw, err := json.Marshal(original.Snapshot()) if err != nil { t.Fatal(err) } if strings.Contains(string(raw), "private") || strings.Contains(string(raw), "params") { t.Fatal(string(raw)) } // Simulate a store rewriting its envelope with ordinary JSON numbers. var storage map[string]any if err = json.Unmarshal(raw, &storage); err != nil { t.Fatal(err) } raw, err = json.Marshal(storage) if err != nil { t.Fatal(err) } var snapshot apierrors.Snapshot if err = json.Unmarshal(raw, &snapshot); err != nil { t.Fatal(err) } restored := snapshot.ErrorValue() if restored.Cause != nil || restored.Message != "complete" || restored.BusinessCode != int64(math.MaxInt64) { t.Fatal(restored) } }