diff --git a/boardwalk/boardwalk_test.go b/boardwalk/boardwalk_test.go index e635911..ff51bc3 100644 --- a/boardwalk/boardwalk_test.go +++ b/boardwalk/boardwalk_test.go @@ -315,3 +315,141 @@ func TestSecurityHeadersOnEveryResponse(t *testing.T) { } } } + +// TestPhase10BoardwalkServing covers the remaining serving branches: HEAD, +// query strings, encoded traversal, the index requested by name, a nested +// prefix, deep client routes, MIME fallback for unknown extensions and the +// constructor's error paths. +func TestPhase10BoardwalkServing(t *testing.T) { + h, spy := newTestHandler(t) + + t.Run("HEAD answers headers without a body", func(t *testing.T) { + for _, target := range []string{testPrefix, testPrefix + "/acme/demo/widgets"} { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodHead, target, nil)) + if rec.Code != http.StatusOK || rec.Body.Len() != 0 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { + t.Fatalf("HEAD %s: status=%d len=%d type=%q", target, rec.Code, rec.Body.Len(), rec.Header().Get("Content-Type")) + } + if rec.Header().Get("Content-Length") == "" || rec.Header().Get("X-Frame-Options") != "DENY" { + t.Fatalf("HEAD %s headers = %v", target, rec.Header()) + } + } + }) + + t.Run("query strings do not change what is served", func(t *testing.T) { + root, err := Dist() + if err != nil { + t.Fatal(err) + } + entries, err := fs.ReadDir(root, "assets") + if err != nil || len(entries) == 0 { + t.Fatalf("assets: %v", err) + } + asset := get(h, testPrefix+"/assets/"+entries[0].Name()+"?v=2") + if asset.Code != http.StatusOK || asset.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" { + t.Fatalf("asset with query: status=%d headers=%v", asset.Code, asset.Header()) + } + route := get(h, testPrefix+"/acme/demo/widgets?search=blue&page=2") + if route.Code != http.StatusOK || !strings.Contains(route.Body.String(), "summer-admin-base") { + t.Fatalf("client route with query: status=%d", route.Code) + } + before := spy.calls + if rec := get(h, testPrefix+"/api/v1/nope?x=1"); rec.Code != http.StatusNotFound || spy.calls != before+1 { + t.Fatalf("api with query not delegated: %d", rec.Code) + } + }) + + t.Run("encoded traversal never escapes the build", func(t *testing.T) { + for _, target := range []string{ + testPrefix + "/%2e%2e/%2e%2e/go.mod", + testPrefix + "/assets/..%2f..%2fboardwalk.go", + testPrefix + "/%2E%2E%2F%2E%2E%2Fgo.sum", + } { + rec := get(h, target) + body := rec.Body.String() + if rec.Code != http.StatusNotFound || strings.Contains(body, "module ") || strings.Contains(body, "package boardwalk") { + t.Fatalf("%s status=%d body=%.80s", target, rec.Code, body) + } + } + }) + + t.Run("index.html by name is the rewritten index", func(t *testing.T) { + byName := get(h, testPrefix+"/index.html") + root := get(h, testPrefix+"/") + if byName.Code != http.StatusOK || byName.Body.String() != root.Body.String() { + t.Fatalf("index.html differs from the root index") + } + if strings.Contains(byName.Body.String(), BaseToken) || byName.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("index.html served raw or cacheable: %v", byName.Header()) + } + }) + + t.Run("nested prefix with a trailing slash", func(t *testing.T) { + nested, err := Handler("/ops/admin/", &apiSpy{}) + if err != nil { + t.Fatal(err) + } + rec := get(nested, "/ops/admin/acme/demo/widgets/12") + body := rec.Body.String() + if rec.Code != http.StatusOK || !strings.Contains(body, `content="/ops/admin"`) || !strings.Contains(body, `src="/ops/admin/assets/`) { + t.Fatalf("nested prefix index: %d %s", rec.Code, body) + } + if rec := get(nested, "/ops/admin/api/v1/x"); rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "`)}, + "robots.txt": &fstest.MapFile{Data: []byte("User-agent: *\n")}, + "assets/logo.png": &fstest.MapFile{Data: []byte("\x89PNG")}, + "assets/blob.zzzext": &fstest.MapFile{Data: []byte("x")}, + } + mapped, err := newHandler(root, testPrefix, &apiSpy{}) + if err != nil { + t.Fatal(err) + } + for target, want := range map[string]string{ + "/robots.txt": "text/plain; charset=utf-8", + "/assets/logo.png": "image/png", + "/assets/blob.zzzext": "application/octet-stream", + } { + rec := get(mapped, testPrefix+target) + if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != want { + t.Fatalf("%s type=%q, want %q", target, rec.Header().Get("Content-Type"), want) + } + } + if cc := get(mapped, testPrefix+"/robots.txt").Header().Get("Cache-Control"); cc != "no-cache" { + t.Fatalf("non-asset file Cache-Control=%q, want no-cache", cc) + } + if got := contentType("UPPER.JS"); got != "text/javascript; charset=utf-8" { + t.Fatalf("extension case: %q", got) + } + }) + + t.Run("constructor rejects a nil API handler, a relative prefix and a build without index", func(t *testing.T) { + if _, err := Handler(testPrefix, nil); err == nil { + t.Fatal("nil notFoundAPI accepted") + } + if _, err := Handler("backend", &apiSpy{}); err == nil { + t.Fatal("relative prefix accepted") + } + if _, err := newHandler(fstest.MapFS{}, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), "index.html") { + t.Fatalf("missing index: %v", err) + } + stale := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("")}} + if _, err := newHandler(stale, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), BaseToken) { + t.Fatalf("stale index: %v", err) + } + }) +} diff --git a/bouncer/cookie_guard_test.go b/bouncer/cookie_guard_test.go new file mode 100644 index 0000000..b716e56 --- /dev/null +++ b/bouncer/cookie_guard_test.go @@ -0,0 +1,132 @@ +package bouncer + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestPhase10CookieGuard covers the backend guard's summer_admin cookie +// transport (D-19): the cookie is read only when no Bearer header is sent, +// Bearer wins when both are present, an empty cookie is unauthenticated, and +// the cookie carries no weaker token than the header (audience, blacklist). +func TestPhase10CookieGuard(t *testing.T) { + const ( + cookie = "summer_admin" + issuer = "https://app.test/backend" + ) + users := memUsers{byID: map[uint]*Principal{ + 2: {ID: 2, Backend: true}, + 3: {ID: 3, Backend: true}, + }} + withCookie := func(value string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/backend/api/v1/auth/me", nil) + r.AddCookie(&http.Cookie{Name: cookie, Value: value}) + return r + } + mint := func(sub, audience string) (string, string) { + t.Helper() + tok, jti, err := MintAudience(secret, sub, issuer, time.Hour, audience) + if err != nil { + t.Fatal(err) + } + return tok, jti + } + guard := NewBackendJWTGuard(secret, users, nil, nil, cookie) + + t.Run("cookie without bearer", func(t *testing.T) { + tok, _ := mint("2", AudienceBackend) + principal, err := guard.Authenticate(withCookie(tok)) + if err != nil || principal == nil || principal.ID != 2 { + t.Fatalf("cookie token rejected: %v %+v", err, principal) + } + }) + + t.Run("cookie value is trimmed", func(t *testing.T) { + tok, _ := mint("2", AudienceBackend) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Cookie", cookie+"= "+tok+" ") + if principal, err := guard.Authenticate(r); err != nil || principal == nil { + t.Fatalf("padded cookie rejected: %v", err) + } + }) + + t.Run("bearer wins over cookie", func(t *testing.T) { + bearerTok, _ := mint("3", AudienceBackend) + cookieTok, _ := mint("2", AudienceBackend) + r := withCookie(cookieTok) + r.Header.Set("Authorization", "Bearer "+bearerTok) + principal, err := guard.Authenticate(r) + if err != nil || principal == nil || principal.ID != 3 { + t.Fatalf("bearer did not win: %v %+v", err, principal) + } + // A bad Bearer is not rescued by a good cookie. + bad := withCookie(cookieTok) + bad.Header.Set("Authorization", "Bearer not-a-token") + if principal, err := guard.Authenticate(bad); err == nil || principal != nil { + t.Fatal("an invalid bearer fell back to the cookie") + } + }) + + t.Run("empty or missing cookie is unauthenticated", func(t *testing.T) { + for name, r := range map[string]*http.Request{ + "empty": withCookie(""), + "blank": withCookie(" "), + "missing": httptest.NewRequest(http.MethodGet, "/", nil), + } { + principal, err := guard.Authenticate(r) + if err == nil || principal != nil || err.Error() != msgTokenNotProvided { + t.Fatalf("%s cookie: principal=%+v err=%v, want %q", name, principal, err, msgTokenNotProvided) + } + } + other := httptest.NewRequest(http.MethodGet, "/", nil) + tok, _ := mint("2", AudienceBackend) + other.AddCookie(&http.Cookie{Name: "summer_other", Value: tok}) + if _, err := guard.Authenticate(other); err == nil { + t.Fatal("a token under another cookie name was accepted") + } + }) + + t.Run("frontend audience in the cookie is rejected", func(t *testing.T) { + tok, _ := mint("2", AudienceUser) + if principal, err := guard.Authenticate(withCookie(tok)); err == nil || principal != nil { + t.Fatal("backend guard accepted a frontend-audience cookie") + } + }) + + t.Run("blacklisted jti in the cookie is rejected", func(t *testing.T) { + bl := NewMemoryBlacklist() + blocking := NewBackendJWTGuard(secret, users, bl, nil, cookie) + tok, jti := mint("2", AudienceBackend) + if _, err := blocking.Authenticate(withCookie(tok)); err != nil { + t.Fatalf("fresh cookie rejected: %v", err) + } + if err := bl.Add(context.Background(), jti, time.Now().Add(time.Hour), time.Now().Add(-time.Second)); err != nil { + t.Fatal(err) + } + principal, err := blocking.Authenticate(withCookie(tok)) + if err == nil || principal != nil || err.Error() != msgBadSignature { + t.Fatalf("blacklisted cookie: principal=%+v err=%v", principal, err) + } + }) + + t.Run("bearer-only guard ignores the cookie", func(t *testing.T) { + tok, _ := mint("2", AudienceBackend) + bearerOnly := NewBackendJWTGuard(secret, users, nil, nil) + if principal, err := bearerOnly.Authenticate(withCookie(tok)); err == nil || principal != nil { + t.Fatal("a guard without cookie names read the cookie") + } + }) + + t.Run("second cookie name is tried after an empty first", func(t *testing.T) { + tok, _ := mint("2", AudienceBackend) + two := NewBackendJWTGuard(secret, users, nil, nil, "legacy_admin", cookie) + r := withCookie(tok) + r.AddCookie(&http.Cookie{Name: "legacy_admin", Value: ""}) + if principal, err := two.Authenticate(r); err != nil || principal == nil { + t.Fatalf("second cookie name not tried: %v", err) + } + }) +} diff --git a/bouncer/registry_test.go b/bouncer/registry_test.go index 0d9703f..4ad8c1d 100644 --- a/bouncer/registry_test.go +++ b/bouncer/registry_test.go @@ -47,7 +47,7 @@ func TestDuplicateGuardNameFailsWithPluginAndName(t *testing.T) { if err := reg.Register("golem15.user", "jwt", writerGuard{}); err != nil { t.Fatal(err) } - err := reg.Register("golem15.fonoteka", "jwt", writerGuard{}) + err := reg.Register("golem15.acme", "jwt", writerGuard{}) if err == nil || !strings.Contains(err.Error(), "golem15.user") || !strings.Contains(err.Error(), "jwt") { t.Fatalf("want plugin and guard name in error, got %v", err) } @@ -102,7 +102,7 @@ func TestWriterGuardFailureWritesOwnResponse(t *testing.T) { func TestCredentialGuardSoftFailAndSuccess(t *testing.T) { reg := NewRegistry() failing := credOnlyGuard{err: errors.New("bad token")} - if err := reg.Register("golem15.fonoteka", "inv_token", failing); err != nil { + if err := reg.Register("golem15.acme", "inv_token", failing); err != nil { t.Fatal(err) } mw, err := reg.Middleware("inv_token") @@ -135,7 +135,7 @@ func TestCredentialGuardSoftFailAndSuccess(t *testing.T) { okReg := NewRegistry() token := &struct{ ID uint }{ID: 9} principal := &Principal{ID: 3} - if err := okReg.Register("golem15.fonoteka", "inv_token", credOnlyGuard{ + if err := okReg.Register("golem15.acme", "inv_token", credOnlyGuard{ principal: principal, cred: token, }); err != nil { diff --git a/cabana/form_schema_test.go b/cabana/form_schema_test.go index 35d72ab..869fcaf 100644 --- a/cabana/form_schema_test.go +++ b/cabana/form_schema_test.go @@ -229,7 +229,7 @@ func TestFormSchemaRejects(t *testing.T) { { name: "partial path", config: formConfig, - fields: "fields:\n editors:\n type: partial\n path: $/golem15/fonoteka/controllers/collections/_editors.htm\n", + fields: "fields:\n editors:\n type: partial\n path: $/golem15/acme/controllers/collections/_editors.htm\n", want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "path"}, }, { @@ -382,7 +382,7 @@ func TestFormSchemaDropdownOptions(t *testing.T) { schema := mustCompileForm(t, schemaController{model: "Widget"}, formConfig, optionMapYAML) tr := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"}) got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil) - vinyl := strings.Index(got, `{"value":"vinyl","label":"Winyl"}`) + vinyl := strings.Index(got, `{"value":"vinyl","label":"Stal"}`) two := strings.Index(got, `{"value":2,"label":"Dwa"}`) yes := strings.Index(got, `{"value":true,"label":"Yes"}`) if vinyl < 0 || two < 0 || yes < 0 || !(vinyl < two && two < yes) { @@ -394,7 +394,7 @@ func TestFormSchemaDropdownOptions(t *testing.T) { methodSchema := mustCompileForm(t, providerController{schemaController: schemaController{model: "Widget"}}, formConfig, methodFieldsYAML) methodJSON := localizeJSON(t, methodSchema, towel.WithLocale(context.Background(), "pl"), tr, providerController{}) - if !strings.Contains(methodJSON, `{"value":"vinyl","label":"Winyl"}`) || !strings.Contains(methodJSON, `{"value":"cd","label":"Płyta"}`) { + if !strings.Contains(methodJSON, `{"value":"vinyl","label":"Stal"}`) || !strings.Contains(methodJSON, `{"value":"cd","label":"Drewno"}`) { t.Fatalf("method options = %s", methodJSON) } if strings.Contains(methodJSON, "getFormatOptions") { @@ -407,7 +407,7 @@ func TestFormSchemaDropdownOptions(t *testing.T) { modelSchema := mustCompileForm(t, sourceController{schemaController: schemaController{model: "Widget"}, rec: modelOptions{}}, formConfig, "fields:\n format:\n type: dropdown\n options: getFormatOptions\n") modelJSON := localizeJSON(t, modelSchema, towel.WithLocale(context.Background(), "pl"), tr, modelOptions{}) - if !strings.Contains(modelJSON, `{"value":"lp","label":"Winyl"}`) { + if !strings.Contains(modelJSON, `{"value":"lp","label":"Stal"}`) { t.Fatalf("model provider options = %s", modelJSON) } @@ -492,7 +492,7 @@ func formCatalog(t *testing.T) *phrasebook.Catalog { cat := phrasebook.NewCatalog() err := cat.Load("acme.demo", fstest.MapFS{ "lang/en/lang.yaml": &fstest.MapFile{Data: []byte("form: Form\ntitle: Title\ncomment: Comment\ntab: Tab\nempty: None\nvinyl: Vinyl\ncd: CD\ntwo: Two\n")}, - "lang/pl/lang.yaml": &fstest.MapFile{Data: []byte("form: Formularz\ntitle: Tytuł\ncomment: Komentarz\ntab: Zakładka\nempty: Brak\nvinyl: Winyl\ncd: Płyta\ntwo: Dwa\n")}, + "lang/pl/lang.yaml": &fstest.MapFile{Data: []byte("form: Formularz\ntitle: Tytuł\ncomment: Komentarz\ntab: Zakładka\nempty: Brak\nvinyl: Stal\ncd: Drewno\ntwo: Dwa\n")}, }) if err != nil { t.Fatalf("catalog: %v", err) diff --git a/cabana/list_schema_test.go b/cabana/list_schema_test.go index e1849de..02e489d 100644 --- a/cabana/list_schema_test.go +++ b/cabana/list_schema_test.go @@ -64,10 +64,10 @@ func TestListSchemaCompile(t *testing.T) { } t.Run("winter genre list still compiles", func(t *testing.T) { - const config = `list: ~/plugins/golem15/fonoteka/models/genre/columns.yaml -modelClass: Golem15\Fonoteka\Models\Genre -title: golem15.fonoteka::lang.genre.label_plural -recordUrl: golem15/fonoteka/genres/update/:id + const config = `list: ~/plugins/golem15/acme/models/genre/columns.yaml +modelClass: Golem15\Acme\Models\Genre +title: golem15.acme::lang.genre.label_plural +recordUrl: golem15/acme/genres/update/:id noRecordsMessage: backend::lang.list.no_records recordsPerPage: 20 showCheckboxes: true @@ -78,19 +78,19 @@ toolbar: ` const cols = `columns: name: - label: golem15.fonoteka::lang.genre.name + label: golem15.acme::lang.genre.name searchable: true slug: - label: golem15.fonoteka::lang.genre.slug + label: golem15.acme::lang.genre.slug searchable: true ` - ctl := schemaController{model: `Golem15\Fonoteka\Models\Genre`} + ctl := schemaController{model: `Golem15\Acme\Models\Genre`} fsys := fstest.MapFS{ "controllers/genres/config_list.yaml": &fstest.MapFile{Data: []byte(config)}, "models/genre/columns.yaml": &fstest.MapFile{Data: []byte(cols)}, } genre := genreConfigController{schemaController: ctl} - if _, err := CompileList("golem15.fonoteka", genre, fsys); err != nil { + if _, err := CompileList("golem15.acme", genre, fsys); err != nil { t.Fatalf("genre list: %v", err) } }) @@ -267,7 +267,7 @@ func TestListSchemaRejects(t *testing.T) { type genreConfigController struct{ schemaController } func (c genreConfigController) ConfigDir() string { return "controllers/genres" } -func (c genreConfigController) ID() string { return "golem15.fonoteka.genres" } +func (c genreConfigController) ID() string { return "golem15.acme.genres" } func listFS(config, columns string) fstest.MapFS { return fstest.MapFS{ diff --git a/cabana/phase10_coverage_test.go b/cabana/phase10_coverage_test.go new file mode 100644 index 0000000..3c0e4f2 --- /dev/null +++ b/cabana/phase10_coverage_test.go @@ -0,0 +1,366 @@ +package cabana + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/compass" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/phrasebook" + "github.com/golang-jwt/jwt/v5" + "gorm.io/gorm" +) + +// emptyOptionsRow is a filter source whose scope has no choices yet; +// literalOptionsRow's choice label is a literal rather than a phrase key. +type emptyOptionsRow struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Active bool `gorm:"column:active"` +} + +func (emptyOptionsRow) FilterScopes() []string { return []string{"filterByGroup"} } +func (emptyOptionsRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB { return db } +func (emptyOptionsRow) FilterOptions(string) []pact.Option { return []pact.Option{} } + +type literalOptionsRow struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Active bool `gorm:"column:active"` +} + +func (literalOptionsRow) FilterScopes() []string { return []string{"filterByGroup"} } +func (literalOptionsRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB { return db } +func (literalOptionsRow) FilterOptions(string) []pact.Option { + return []pact.Option{{Value: "x", Label: "Literal label"}} +} + +// TestPhase10Coverage fills the Phase 10 cabana branches the feature tests +// left without a named case: the mounted route table against the CSRF walk, +// relation and filter option edges, read-only relation labels, relation +// message defaults, the bundle's configured fallback and a cookie refresh of +// an expired access token inside the refresh window. +func TestPhase10Coverage(t *testing.T) { + t.Run("every unsafe mounted route is CSRF-walked", func(t *testing.T) { + router := &handlerRouter{handlers: map[string]http.HandlerFunc{}} + svc := phase09DeniedService() + svc.mount(router) + api := adminAPI("") + var unsafe, safe []string + for _, key := range router.order { + method, path, _ := strings.Cut(key, " ") + rel := strings.TrimPrefix(path, api) + switch method { + case http.MethodGet: + safe = append(safe, rel) + case http.MethodPost, http.MethodPut, http.MethodDelete: + unsafe = append(unsafe, method+" "+rel) + default: + t.Fatalf("unexpected method mounted: %s", key) + } + } + sort.Strings(unsafe) + want := []string{ + "DELETE /{vendor}/{plugin}/{controller}/{id}", + "POST /auth/login", + "POST /auth/logout", + "POST /auth/refresh", + "POST /{vendor}/{plugin}/{controller}", + "POST /{vendor}/{plugin}/{controller}/bulk-delete", + "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", + "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", + "PUT /settings/{code}", + "PUT /{vendor}/{plugin}/{controller}/{id}", + } + if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { + t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 9 besides login):\n%s", strings.Join(unsafe, "\n")) + } + // The routes added in Phase 10 are safe reads: GET /lang and the shared + // nested pattern serving field options, filter options and relation lists. + for _, need := range []string{"/lang", "/{vendor}/{plugin}/{controller}/{id}/{segment}/{name}"} { + found := false + for _, rel := range safe { + found = found || rel == need + } + if !found { + t.Fatalf("GET %s is not mounted: %v", need, safe) + } + } + // A safe read with only the cookie is never refused by the CSRF check. + req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil) + req.AddCookie(&http.Cookie{Name: AdminCookieName, Value: "cookie-only"}) + rec := httptest.NewRecorder() + router.handlers[http.MethodGet+" "+adminAPI("/lang")](rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cookie-only GET /lang status=%d", rec.Code) + } + }) + + t.Run("relation option edges", func(t *testing.T) { + svc, _, _ := p10Fixture(t) + decode := func(rec *httptest.ResponseRecorder) ([]RelationOption, ListMeta) { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data []RelationOption `json:"data"` + Meta ListMeta `json:"meta"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Data == nil { + t.Fatalf("data is null: %s", rec.Body.String()) + } + return body.Data, body.Meta + } + all, allMeta := decode(p10Options(svc, "tags", "", p10Principal(true))) + for _, query := range []string{"search=", "search=%20%20"} { + rows, meta := decode(p10Options(svc, "tags", query, p10Principal(true))) + if len(rows) != len(all) || meta.Total != allMeta.Total { + t.Fatalf("%s is not a no-op search: %d rows, total %d", query, len(rows), meta.Total) + } + } + beyond, meta := decode(p10Options(svc, "tags", "page=9", p10Principal(true))) + if len(beyond) != 0 || meta.Page != 9 || meta.Total != allMeta.Total || meta.LastPage != allMeta.LastPage { + t.Fatalf("page beyond last: rows=%d meta=%+v", len(beyond), meta) + } + capped, cappedMeta := decode(p10Options(svc, "tags", "per_page=100", p10Principal(true))) + if cappedMeta.PerPage != 100 || len(capped) != len(all) { + t.Fatalf("per_page at the cap: %+v", cappedMeta) + } + above := p10Options(svc, "tags", "per_page=1000", p10Principal(true)) + if above.Code != http.StatusUnprocessableEntity || !strings.Contains(above.Body.String(), `"per_page"`) { + t.Fatalf("per_page above the cap status=%d body=%s", above.Code, above.Body.String()) + } + none, noneMeta := decode(p10Options(svc, "tags", "search=no-such-tag", p10Principal(true))) + if len(none) != 0 || noneMeta.Total != 0 || noneMeta.LastPage < 1 { + t.Fatalf("empty result: %d rows meta=%+v", len(none), noneMeta) + } + }) + + t.Run("filter option edges", func(t *testing.T) { + granted := &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"acme.demo.access": true}} + empty := filterOptionsCall(filterOptionsService(t, &emptyOptionsRow{}, []string{"acme.demo.access"}, nil), "grouped", "", granted) + if empty.Code != http.StatusOK || !strings.Contains(empty.Body.String(), `"data":[]`) { + t.Fatalf("no choices status=%d body=%s", empty.Code, empty.Body.String()) + } + literal := filterOptionsCall(filterOptionsService(t, &literalOptionsRow{}, []string{"acme.demo.access"}, nil), "grouped", "pl", granted) + if literal.Code != http.StatusOK || !strings.Contains(literal.Body.String(), `{"value":"x","label":"Literal label"}`) { + t.Fatalf("literal label status=%d body=%s", literal.Code, literal.Body.String()) + } + }) + + t.Run("read-only relation label outside the options scope", func(t *testing.T) { + svc, db, seed := p10Fixture(t) + created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{"name": "labelled"}), http.StatusCreated) + id := fmt.Sprintf("%d", p10ID(created.Data["id"])) + // The options hook hides every person (1 = 0), yet the label shows. + shown := p10Decode(t, p10Save(svc, http.MethodGet, id, nil), http.StatusOK) + if p10ID(shown.Data["person"]) != seed.person || len(shown.Meta.Labels["person"]) != 1 || shown.Meta.Labels["person"][0].Label != "admin@acme.test" { + t.Fatalf("read-only label data=%v labels=%+v", shown.Data["person"], shown.Meta.Labels["person"]) + } + // A dangling foreign key keeps the value and gets no label. + if err := db.Delete(&p10Person{}, seed.person).Error; err != nil { + t.Fatal(err) + } + dangling := p10Decode(t, p10Save(svc, http.MethodGet, id, nil), http.StatusOK) + if labels, ok := dangling.Meta.Labels["person"]; !ok || len(labels) != 0 { + t.Fatalf("dangling person labels=%+v", dangling.Meta.Labels) + } + // Saving never writes the read-only key. + p10Decode(t, p10Save(svc, http.MethodPut, id, map[string]any{"name": "again", "person": 42}), http.StatusOK) + if stored := p10Stored(t, db, p10ID(created.Data["id"])); stored.UserID != seed.person { + t.Fatalf("read-only foreign key written: %+v", stored) + } + }) + + t.Run("relation messages default every key", func(t *testing.T) { + tr := messagesTranslator(t) + ctl := relationTestController{} + fsys := relationTestFS(validRelationYAML) + form, err := CompileForm("acme.demo", ctl, fsys) + if err != nil { + t.Fatal(err) + } + relations, err := compileRelations("acme.demo", ctl, fsys, form) + if err != nil { + t.Fatal(err) + } + for _, locale := range []string{"pl", "en"} { + view := relations["editors"].Schema.Localize(localeCtx(locale), tr) + raw, err := json.Marshal(view.Messages) + if err != nil { + t.Fatal(err) + } + var messages map[string]map[string]string + if err := json.Unmarshal(raw, &messages); err != nil { + t.Fatal(err) + } + for _, key := range []string{"link", "linkHint", "candidateSearch", "linked", "unlinkSelected", "unlinkConfirm", "unlinked", "empty"} { + if messages[key]["other"] == "" || strings.Contains(messages[key]["other"], "backend::") { + t.Fatalf("%s %s default = %v", locale, key, messages[key]) + } + } + if locale == "pl" && len(messages["linked"]) != 4 { + t.Fatalf("pl linked is not plural: %v", messages["linked"]) + } + } + if err := validateMessageKeys(&Registry{byID: map[string]*CompiledController{ + "acme.demo.owners": {Controller: ctl, PluginID: "acme.demo", Relations: relations}, + }}, tr); err != nil { + t.Fatalf("framework defaults failed validation: %v", err) + } + }) + + t.Run("form-less controller drops create from any toolbar", func(t *testing.T) { + config := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\ntoolbar:\n buttons: [create]\n" + reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(config, "columns: {}\n")}, ctl: schemaController{}}}) + if err != nil { + t.Fatal(err) + } + cc, _ := reg.Get("acme.demo.widgets") + if len(cc.List.ToolbarButtons) != 0 || cc.List.ToolbarButtons == nil { + t.Fatalf("toolbar=%#v, want an empty list", cc.List.ToolbarButtons) + } + }) + + t.Run("bundle falls back to app.fallback_locale", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: coverage\nlocale: pl\nfallback_locale: pl\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}}) + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + if err := phrasebook.Activate(app, []langPlugin{}); err != nil { + t.Fatal(err) + } + svc := &service{app: app} + for _, requested := range []string{"de", ""} { + req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil) + if requested != "" { + req = req.WithContext(localeCtx(requested)) + } + rec := httptest.NewRecorder() + svc.langBundle(rec, req) + var body struct { + Data map[string]map[string]string `json:"data"` + Meta struct { + Locale string `json:"locale"` + } `json:"meta"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Meta.Locale != "pl" || body.Data["backend::lang.form.save"]["other"] != "Zapisz" { + t.Fatalf("requested %q: locale=%q save=%v", requested, body.Meta.Locale, body.Data["backend::lang.form.save"]) + } + } + }) + + t.Run("cookie refresh of an expired access token inside the refresh window", func(t *testing.T) { + const secret = "phase10-coverage-secret" + svc := &service{ + secret: secret, + ttl: 15 * time.Minute, + refreshTTL: 2 * time.Hour, + issuer: "https://app.test" + DefaultAdminPrefix, + bl: bouncer.NewMemoryBlacklist(), + } + sign := func(iat, exp time.Time, jti string) string { + t.Helper() + tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "5", "aud": bouncer.AudienceBackend, "iss": svc.issuer, "jti": jti, + "iat": iat.Unix(), "nbf": iat.Unix(), "exp": exp.Unix(), + }).SignedString([]byte(secret)) + if err != nil { + t.Fatal(err) + } + return tok + } + call := func(token string, cookie bool) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/refresh"), nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + if cookie { + req.AddCookie(&http.Cookie{Name: AdminCookieName, Value: token}) + } else { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + requireAjax(svc.refresh)(rec, req) + return rec + } + now := time.Now() + expired := sign(now.Add(-30*time.Minute), now.Add(-15*time.Minute), "expired-in-window") + if _, _, _, _, err := bouncer.VerifyClaimsAudience(expired, secret, bouncer.AudienceBackend); err == nil { + t.Fatal("fixture token is not expired") + } + + rec := call(expired, true) + if rec.Code != http.StatusOK { + t.Fatalf("cookie refresh in window status=%d body=%s", rec.Code, rec.Body.String()) + } + var next *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == AdminCookieName { + next = c + } + } + if next == nil || next.Value == "" || next.Value == expired || !next.HttpOnly || !next.Secure || next.SameSite != http.SameSiteStrictMode || + next.Path != DefaultAdminPrefix || next.MaxAge != int((2*time.Hour)/time.Second) { + t.Fatalf("refreshed cookie = %+v", next) + } + if strings.Contains(rec.Body.String(), "access_token") || strings.Contains(rec.Body.String(), next.Value) { + t.Fatalf("cookie refresh put the token in the body: %s", rec.Body.String()) + } + if _, _, _, _, err := bouncer.VerifyClaimsAudience(next.Value, secret, bouncer.AudienceBackend); err != nil { + t.Fatalf("refreshed cookie does not verify: %v", err) + } + // The old token is blacklisted by the rotation: a second refresh fails. + if again := call(expired, true); again.Code != http.StatusUnauthorized { + t.Fatalf("replayed refresh status=%d", again.Code) + } + + bearer := call(sign(now.Add(-30*time.Minute), now.Add(-15*time.Minute), "bearer-in-window"), false) + if bearer.Code != http.StatusOK || !strings.Contains(bearer.Body.String(), `"access_token"`) || len(bearer.Result().Cookies()) != 0 { + t.Fatalf("bearer refresh status=%d body=%s", bearer.Code, bearer.Body.String()) + } + + stale := call(sign(now.Add(-3*time.Hour), now.Add(-170*time.Minute), "outside-window"), true) + if stale.Code != http.StatusUnauthorized || len(stale.Result().Cookies()) != 0 { + t.Fatalf("refresh outside the window status=%d cookies=%v", stale.Code, stale.Result().Cookies()) + } + missing := call("", true) + if missing.Code != http.StatusUnauthorized { + t.Fatalf("refresh without a token status=%d", missing.Code) + } + frontend := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "5", "aud": bouncer.AudienceUser, "jti": "frontend", "iat": now.Unix(), "exp": now.Add(time.Minute).Unix(), + }) + frontendTok, err := frontend.SignedString([]byte(secret)) + if err != nil { + t.Fatal(err) + } + if cross := call(frontendTok, true); cross.Code != http.StatusUnauthorized { + t.Fatalf("frontend token refreshed through the admin cookie: %d", cross.Code) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"token_type"`)) { + t.Fatalf("cookie refresh body=%s", rec.Body.String()) + } + }) +} diff --git a/internal/tools/swagger2openapi/main_test.go b/internal/tools/swagger2openapi/main_test.go new file mode 100644 index 0000000..4b32b9b --- /dev/null +++ b/internal/tools/swagger2openapi/main_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" +) + +func decode(t *testing.T, raw string) map[string]any { + t.Helper() + var doc map[string]any + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + t.Fatal(err) + } + return doc +} + +// TestUnionRewrite proves the two opaque cabana types become the documented +// unions (so openapi-typescript emits exact TS types) and that every other +// component is left untouched. +func TestUnionRewrite(t *testing.T) { + doc := decode(t, `{ + "swagger": "2.0", + "definitions": { + "cabana.jsonScalar": {"type": "object"}, + "cabana.fieldContext": {"type": "object"}, + "cabana.FormField": { + "type": "object", + "properties": { + "default": {"$ref": "#/definitions/cabana.jsonScalar"}, + "context": {"$ref": "#/definitions/cabana.fieldContext"}, + "name": {"type": "string"} + } + }, + "acme.Other": {"type": "object", "properties": {"x": {"type": "integer"}}} + } + }`) + untouchedOther := decode(t, `{"type": "object", "properties": {"x": {"type": "integer"}}}`) + + out := swagger2openapi(doc) + schemas := out["components"].(map[string]any)["schemas"].(map[string]any) + + wantScalar := decode(t, `{"nullable": true, "oneOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}]}`) + if got := schemas["cabana.jsonScalar"]; !reflect.DeepEqual(got, wantScalar) { + t.Fatalf("jsonScalar = %#v", got) + } + wantContext := decode(t, `{"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}`) + if got := schemas["cabana.fieldContext"]; !reflect.DeepEqual(got, wantContext) { + t.Fatalf("fieldContext = %#v", got) + } + if got := schemas["acme.Other"]; !reflect.DeepEqual(got, untouchedOther) { + t.Fatalf("an unrelated component changed: %#v", got) + } + props := schemas["cabana.FormField"].(map[string]any)["properties"].(map[string]any) + if ref := props["default"].(map[string]any)["$ref"]; ref != "#/components/schemas/cabana.jsonScalar" { + t.Fatalf("ref not rewritten: %v", ref) + } + if props["name"].(map[string]any)["type"] != "string" { + t.Fatalf("plain property changed: %v", props["name"]) + } + + // Documents without the opaque types are not given them. + bare := swagger2openapi(decode(t, `{"definitions": {"acme.Other": {"type": "object"}}}`)) + if _, ok := bare["components"].(map[string]any)["schemas"].(map[string]any)["cabana.jsonScalar"]; ok { + t.Fatal("rewriteOpaque added a union the document did not declare") + } +} + +func TestConvertOperations(t *testing.T) { + doc := decode(t, `{ + "swagger": "2.0", + "info": {"title": "Admin", "version": "1"}, + "tags": [{"name": "admin"}], + "basePath": "/", + "paths": { + "/{vendor}/items": { + "parameters": [], + "get": { + "parameters": [ + {"name": "vendor", "in": "path", "required": true, "type": "string"}, + {"name": "page", "in": "query", "type": "integer", "minimum": 1}, + {"name": "filter", "in": "query", "type": "object"} + ], + "responses": {"200": {"description": "OK", "schema": {"$ref": "#/definitions/acme.List"}}, "204": {"description": "empty"}} + }, + "post": { + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [{"name": "body", "in": "body", "required": true, "description": "record", "schema": {"$ref": "#/definitions/acme.Rec"}}], + "responses": {"201": {"description": "Created", "schema": {"type": "object"}}} + }, + "x-note": "kept" + } + }, + "definitions": {"acme.List": {"type": "object"}, "acme.Rec": {"type": "object"}}, + "securityDefinitions": { + "bearer": {"type": "apiKey", "name": "Authorization", "in": "header"}, + "oauth": {"type": "oauth2", "flow": "accessCode", "authorizationUrl": "https://a", "tokenUrl": "https://t", "scopes": {"read": "r"}} + }, + "security": [{"bearer": []}] + }`) + out := swagger2openapi(doc) + if out["openapi"] != "3.0.3" || out["info"].(map[string]any)["title"] != "Admin" || out["tags"] == nil || out["security"] == nil { + t.Fatalf("top level = %#v", out) + } + if _, ok := out["servers"]; ok { + t.Fatal("a root basePath without host produced servers") + } + item := out["paths"].(map[string]any)["/{vendor}/items"].(map[string]any) + if item["x-note"] != "kept" { + t.Fatal("extension key dropped") + } + get := item["get"].(map[string]any) + params := get["parameters"].([]any) + page := params[1].(map[string]any) + if page["schema"].(map[string]any)["minimum"] != float64(1) || page["minimum"] != nil { + t.Fatalf("schema keys not moved: %#v", page) + } + filter := params[2].(map[string]any) + if filter["style"] != "deepObject" || filter["explode"] != true || + !reflect.DeepEqual(filter["schema"], map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}) { + t.Fatalf("object query param = %#v", filter) + } + ok200 := get["responses"].(map[string]any)["200"].(map[string]any) + ref := ok200["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["$ref"] + if ref != "#/components/schemas/acme.List" { + t.Fatalf("response ref = %v", ref) + } + if _, ok := get["responses"].(map[string]any)["204"].(map[string]any)["content"]; ok { + t.Fatal("a response without schema got content") + } + post := item["post"].(map[string]any) + body := post["requestBody"].(map[string]any) + if body["required"] != true || body["description"] != "record" || post["parameters"] != nil { + t.Fatalf("request body = %#v params=%v", body, post["parameters"]) + } + flows := out["components"].(map[string]any)["securitySchemes"].(map[string]any)["oauth"].(map[string]any)["flows"].(map[string]any) + code := flows["authorizationCode"].(map[string]any) + if code["tokenUrl"] != "https://t" || code["authorizationUrl"] != "https://a" { + t.Fatalf("oauth flows = %#v", flows) + } +} + +func TestConvertServers(t *testing.T) { + cases := []struct { + doc string + want []any + }{ + {`{}`, nil}, + {`{"host": "api.test"}`, []any{map[string]any{"url": "https://api.test"}}}, + {`{"host": "api.test", "basePath": "/v1", "schemes": ["http", "https"]}`, []any{ + map[string]any{"url": "http://api.test/v1"}, map[string]any{"url": "https://api.test/v1"}, + }}, + {`{"basePath": "/v1"}`, []any{map[string]any{"url": "/v1"}}}, + } + for _, tc := range cases { + if got := convertServers(decode(t, tc.doc)); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("%s servers = %#v, want %#v", tc.doc, got, tc.want) + } + } +} + +func TestOAuthFlowNames(t *testing.T) { + for flow, want := range map[string]string{ + "implicit": "implicit", + "password": "password", + "application": "clientCredentials", + "accessCode": "authorizationCode", + } { + out := convertSecurity(map[string]any{"o": map[string]any{"type": "oauth2", "flow": flow, "scopes": map[string]any{}}}) + flows := out["o"].(map[string]any)["flows"].(map[string]any) + if _, ok := flows[want]; !ok || len(flows) != 1 { + t.Fatalf("%s -> %#v", flow, flows) + } + } + if out := convertSecurity(map[string]any{"raw": "x"}); out["raw"] != "x" { + t.Fatal("non-object security scheme not kept") + } +} + +func TestMalformedShapesPassThrough(t *testing.T) { + paths := convertPaths(map[string]any{"/a": "raw", "/b": map[string]any{"get": "raw", "produces": []any{"text/plain", 1, ""}}}) + if paths["/a"] != "raw" || paths["/b"].(map[string]any)["get"] != "raw" { + t.Fatalf("malformed paths = %#v", paths) + } + op := convertOperation(map[string]any{"responses": "raw", "parameters": "raw", "summary": "s"}, nil, nil) + if op["responses"] != "raw" || op["summary"] != "s" { + t.Fatalf("malformed op = %#v", op) + } + params, body := splitParameters([]any{"loose"}, []string{"application/json"}) + if len(params) != 1 || body != nil { + t.Fatalf("loose parameter = %#v %#v", params, body) + } + if res := convertResponses(map[string]any{"200": "raw"}, nil); res["200"] != "raw" { + t.Fatal("raw response not kept") + } + if got := stringList([]any{"a", 2, "", "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("stringList = %v", got) + } + if got := rewriteRefs(map[string]any{"$ref": "#/other/x"}); got.(map[string]any)["$ref"] != "#/other/x" { + t.Fatalf("foreign ref rewritten: %v", got) + } +} diff --git a/phrasebook/phase10_test.go b/phrasebook/phase10_test.go index 5bb3933..785119f 100644 --- a/phrasebook/phase10_test.go +++ b/phrasebook/phase10_test.go @@ -197,3 +197,137 @@ func TestPhase10SPAKeysResolve(t *testing.T) { } } } + +// TestPhase10LangPrecedence pins the D-20 layering: an override beats the +// plugin's own strings and the framework's backend strings, a later plugin's +// override beats an earlier one, and an override can add a whole locale. +func TestPhase10LangPrecedence(t *testing.T) { + demoLang := fstest.MapFS{ + "lang/en/lang.yaml": {Data: []byte("title: Demo\nsubtitle: Widgets\n")}, + "lang/pl/lang.yaml": {Data: []byte("title: Demo PL\n")}, + } + first := overridePlugin{id: "acme.demo", lang: demoLang, overrides: fstest.MapFS{ + "lang/pl/backend/lang.yaml": {Data: []byte("form:\n save: Pierwszy\n cancel: Pierwsze anuluj\n")}, + "lang/pl/acme.demo/lang.yaml": {Data: []byte("title: Demo nadpisane\n")}, + }} + second := overridePlugin{id: "acme.site", overrides: fstest.MapFS{ + "lang/pl/backend/lang.yaml": {Data: []byte("form:\n save: Drugi\n")}, + "lang/fr/backend/lang.yaml": {Data: []byte("form:\n save: Enregistrer\n")}, + "lang/fr/acme.demo/lang.yaml": {Data: []byte("title: Démo\n")}, + }} + tr, err := activateWith(t, first, second) + if err != nil { + t.Fatalf("activate: %v", err) + } + for _, tc := range []struct{ locale, key, want string }{ + // Framework string untouched by any override. + {"pl", "backend::lang.form.delete", "Usuń"}, + // Override beats framework; the later plugin's override wins. + {"pl", "backend::lang.form.save", "Drugi"}, + {"pl", "backend::lang.form.cancel", "Pierwsze anuluj"}, + // Override beats the plugin's own string; the plugin beats its fallback. + {"pl", "acme.demo::lang.title", "Demo nadpisane"}, + {"en", "acme.demo::lang.title", "Demo"}, + {"pl", "acme.demo::lang.subtitle", "Widgets"}, + // A locale added only through overrides. + {"fr", "backend::lang.form.save", "Enregistrer"}, + {"fr", "acme.demo::lang.title", "Démo"}, + {"fr", "backend::lang.form.delete", "Delete"}, + } { + if got := tr.GetIn(tc.locale, tc.key, nil); got != tc.want { + t.Fatalf("%s %s = %q want %q", tc.locale, tc.key, got, tc.want) + } + } + if got := tr.Resolved("fr", "backend::lang."); got != "fr" { + t.Fatalf("Resolved(fr) = %q", got) + } + if got := tr.Resolved("de", "backend::lang."); got != "en" { + t.Fatalf("Resolved(de) = %q, want the en fallback", got) + } +} + +// TestPhase10BundleMerge proves Bundle layers the requested locale over its +// region parent and the fallback, filters by prefix and skips keys that do +// not convert to CLDR forms. +func TestPhase10BundleMerge(t *testing.T) { + cat := NewCatalog() + if err := cat.Load("acme.demo", fstest.MapFS{ + "lang/en/lang.yaml": {Data: []byte("a: A en\nb: B en\nc: C en\nexact: \"{0} none|[1,*] :count\"\n")}, + "lang/pt/lang.yaml": {Data: []byte("a: A pt\nb: B pt\n")}, + "lang/pt-BR/lang.yaml": {Data: []byte("a: A pt-BR\n")}, + }); err != nil { + t.Fatal(err) + } + if err := cat.Load("acme.other", fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("a: other\n")}}); err != nil { + t.Fatal(err) + } + tr := NewTranslator(cat, Options{Locale: "en", Fallback: "en"}) + bundle := tr.Bundle("pt-BR", "acme.demo::") + want := map[string]string{ + "acme.demo::lang.a": "A pt-BR", + "acme.demo::lang.b": "B pt", + "acme.demo::lang.c": "C en", + } + if len(bundle) != len(want) { + t.Fatalf("bundle = %v", bundle) + } + for key, text := range want { + if bundle[key]["other"] != text { + t.Fatalf("%s = %v want %q", key, bundle[key], text) + } + } + if _, ok := bundle["acme.demo::lang.exact"]; ok { + t.Fatal("an exact/range pipe entered the bundle") + } + if got := tr.Resolved("pt-BR", "acme.demo::"); got != "pt-BR" { + t.Fatalf("Resolved = %q", got) + } + var nilTr *Translator + if b := nilTr.Bundle("en", ""); len(b) != 0 { + t.Fatalf("nil translator bundle = %v", b) + } + if _, ok := nilTr.Forms("en", "x"); ok { + t.Fatal("nil translator produced forms") + } +} + +// TestPhase10FormsShapes converts every entry shape the catalog stores: plain +// text, a YAML plural map, a category pipe, and pipes that cannot convert +// (exact, range, or without an other form). +func TestPhase10FormsShapes(t *testing.T) { + for _, tc := range []struct { + name string + e entry + want map[string]string + ok bool + }{ + {"plain", entry{text: "Hi"}, map[string]string{"other": "Hi"}, true}, + {"empty plain", entry{}, map[string]string{"other": ""}, true}, + {"plural map", entry{plurals: map[string]string{"one": "1", "other": "n"}}, map[string]string{"one": "1", "other": "n"}, true}, + {"category pipe", entry{pipes: []pipePart{{cat: "one", text: "1"}, {cat: "other", text: "n"}}}, map[string]string{"one": "1", "other": "n"}, true}, + {"pipe without other", entry{pipes: []pipePart{{cat: "one", text: "1"}}}, nil, false}, + {"pipe with an uncategorised part", entry{pipes: []pipePart{{text: "x"}, {cat: "other", text: "n"}}}, nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := entryForms(tc.e) + if ok != tc.ok { + t.Fatalf("ok=%v want %v (%v)", ok, tc.ok, got) + } + if len(got) != len(tc.want) { + t.Fatalf("forms=%v want %v", got, tc.want) + } + for cat, text := range tc.want { + if got[cat] != text { + t.Fatalf("forms=%v want %v", got, tc.want) + } + } + }) + } + one, two := 0.0, 1.0 + if _, ok := entryForms(entry{pipes: []pipePart{{exact: &one, text: "none"}, {cat: "other", text: "n"}}}); ok { + t.Fatal("exact pipe converted") + } + if _, ok := entryForms(entry{pipes: []pipePart{{lo: &two, text: "some"}, {cat: "other", text: "n"}}}); ok { + t.Fatal("range pipe converted") + } +} diff --git a/phrasebook/translator_test.go b/phrasebook/translator_test.go index ea36eba..d73f608 100644 --- a/phrasebook/translator_test.go +++ b/phrasebook/translator_test.go @@ -113,7 +113,7 @@ func TestPluralSmoke(t *testing.T) { "helloName: \"hi :name :Name :NAME\"\n" + "raw:\n other: \"{{.Count}} bottles\"\n")}, "lang/pl/lang.yaml": {Data: []byte("" + - "albums:\n one: \":count album\"\n few: \":count albumy\"\n many: \":count albumów\"\n other: \":count albumu\"\n" + + "albums:\n one: \":count plik\"\n few: \":count pliki\"\n many: \":count plików\"\n other: \":count pliku\"\n" + "posts: \":count wpis|:count wpisy|:count wpisów|:count wpisu\"\n")}, } cat := NewCatalog() @@ -122,7 +122,7 @@ func TestPluralSmoke(t *testing.T) { } tr := NewTranslator(cat, Options{}) - pl := map[any]string{1: "1 album", 2: "2 albumy", 5: "5 albumów", 22: "22 albumy", 0: "0 albumów", 1.5: "1.5 albumu"} + pl := map[any]string{1: "1 plik", 2: "2 pliki", 5: "5 plików", 22: "22 pliki", 0: "0 plików", 1.5: "1.5 pliku"} for n, want := range pl { if got := tr.ChoiceIn("pl", "golem15.hello::lang.albums", n, nil); got != want { t.Fatalf("pl albums %v = %q, want %q", n, got, want) diff --git a/surf/admin_prefix_test.go b/surf/admin_prefix_test.go index b729074..c7d615d 100644 --- a/surf/admin_prefix_test.go +++ b/surf/admin_prefix_test.go @@ -28,6 +28,8 @@ func TestPhase10AdminPrefixCollision(t *testing.T) { {"exact prefix", http.MethodGet, "/acme-admin", false}, {"under prefix", http.MethodPost, "/acme-admin/hook", false}, {"raw under api", http.MethodGet, "/acme-admin/api/v1/extra", true}, + {"deeper path", http.MethodGet, "/acme-admin/a/b/c", false}, + {"exact prefix raw", http.MethodPost, "/acme-admin", true}, } { t.Run(tc.name, func(t *testing.T) { app := adminPrefixApp(t) @@ -57,9 +59,52 @@ func TestPhase10AdminPrefixCollision(t *testing.T) { t.Fatalf("sibling path rejected: %v", err) } }) + + t.Run("default /backend prefix", func(t *testing.T) { + for _, tc := range []struct { + path string + collide bool + }{ + {"/backend", true}, + {"/backend/deep/hook", true}, + {"/backendx", false}, + {"/back", false}, + {"/api/backend", false}, + } { + app := adminPrefixAppWith(t, "") + plugins := []party.Plugin{ + adminPrefixPlugin{}, + prefixRoutePlugin{id: "acme.intruder", method: http.MethodGet, path: tc.path}, + } + _, err := BuildRouter(app, plugins) + if tc.collide && (err == nil || !strings.Contains(err.Error(), "/backend")) { + t.Fatalf("%s under the default prefix: err=%v", tc.path, err) + } + if !tc.collide && err != nil { + t.Fatalf("%s rejected next to the default prefix: %v", tc.path, err) + } + } + }) + + t.Run("no admin means no prefix check", func(t *testing.T) { + app := adminPrefixApp(t) + plugins := []party.Plugin{prefixRoutePlugin{id: "acme.site", method: http.MethodGet, path: "/acme-admin"}} + if _, err := BuildRouter(app, plugins); err != nil { + t.Fatalf("a route at the prefix without admin controllers was rejected: %v", err) + } + if err := (&Router{}).checkAdminPrefix(""); err != nil { + t.Fatalf("empty prefix: %v", err) + } + }) } func adminPrefixApp(t *testing.T) *backpack.App { + t.Helper() + return adminPrefixAppWith(t, "/acme-admin") +} + +// adminPrefixAppWith configures backend.uri; an empty uri keeps the default. +func adminPrefixAppWith(t *testing.T, uri string) *backpack.App { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: admin-prefix\n"), 0o644); err != nil { @@ -68,8 +113,10 @@ func adminPrefixApp(t *testing.T) *backpack.App { if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "backend.yaml"), []byte("uri: /acme-admin\n"), 0o644); err != nil { - t.Fatal(err) + if uri != "" { + if err := os.WriteFile(filepath.Join(dir, "backend.yaml"), []byte("uri: "+uri+"\n"), 0o644); err != nil { + t.Fatal(err) + } } cfg, err := compass.Open(compass.Options{ Dir: dir, diff --git a/surf/cors_coverage_test.go b/surf/cors_coverage_test.go index 215adc8..1f6fd6a 100644 --- a/surf/cors_coverage_test.go +++ b/surf/cors_coverage_test.go @@ -7,10 +7,10 @@ import ( ) // Gap (d): pathScopedCORS with a path matching NONE of the configured -// globs. TestCORSPathScopedHeaders already covers the two named fonoteka +// globs. TestCORSPathScopedHeaders already covers the two named acme // groups; this fixture is framework-only (/healthz vs api/*). -func TestCORSPathScopedNoMatchIndependentOfFonoteka(t *testing.T) { +func TestCORSPathScopedNoMatchIndependentOfAcme(t *testing.T) { cfg := CORSConfig{ Paths: []string{"api/*", "oauth/mcp/*"}, AllowedMethods: []string{"*"}, diff --git a/surf/cors_test.go b/surf/cors_test.go index b9fdc6f..1480c26 100644 --- a/surf/cors_test.go +++ b/surf/cors_test.go @@ -14,11 +14,11 @@ import ( func TestCORSLaravelGlobMatchesNestedPaths(t *testing.T) { re := compileLaravelGlob("api/*") - if re == nil || !re.MatchString("api/v1/fonoteka/genres") { - t.Fatal("api/* must match api/v1/fonoteka/genres (Laravel Str::is, not Go path.Match)") + if re == nil || !re.MatchString("api/v1/acme/genres") { + t.Fatal("api/* must match api/v1/acme/genres (Laravel Str::is, not Go path.Match)") } - if re.MatchString("_fonoteka/api/v1/genres") { - t.Fatal("api/* must not match _fonoteka/api/v1/genres") + if re.MatchString("_acme/api/v1/genres") { + t.Fatal("api/* must not match _acme/api/v1/genres") } mcp := compileLaravelGlob("oauth/mcp/*") if mcp == nil || !mcp.MatchString("oauth/mcp/token") { @@ -34,22 +34,22 @@ func TestCORSPathScopedHeaders(t *testing.T) { AllowedHeaders: []string{"*"}, } mux := http.NewServeMux() - mux.HandleFunc("GET /api/v1/fonoteka/genres", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("GET /api/v1/acme/genres", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - mux.HandleFunc("GET /_fonoteka/api/v1/genres", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("GET /_acme/api/v1/genres", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) h := pathScopedCORS(cfg, mux) rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/fonoteka/genres", nil)) + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/acme/genres", nil)) if rec.Header().Get("Access-Control-Allow-Origin") != "*" { t.Fatalf("token group ACAO = %q", rec.Header().Get("Access-Control-Allow-Origin")) } rec = httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/genres", nil)) + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_acme/api/v1/genres", nil)) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("JWT group ACAO = %q, want empty", got) } diff --git a/surf/middleware_test.go b/surf/middleware_test.go index 84b6543..c535412 100644 --- a/surf/middleware_test.go +++ b/surf/middleware_test.go @@ -101,7 +101,7 @@ func TestPipelineOrderRecoverCORSLocaleAuthPasswordOrgRateHandler(t *testing.T) if err := r.RegisterMiddleware("golem15.user", "jwt.auth", record("jwt.auth")); err != nil { t.Fatal(err) } - if err := r.RegisterMiddleware("golem15.fonoteka", "inv.must-change-password", record("inv.must-change-password")); err != nil { + if err := r.RegisterMiddleware("golem15.acme", "inv.must-change-password", record("inv.must-change-password")); err != nil { t.Fatal(err) } r.BindPlugin("golem15.demo") diff --git a/surf/router_test.go b/surf/router_test.go index 8edd078..bdaaf9a 100644 --- a/surf/router_test.go +++ b/surf/router_test.go @@ -436,7 +436,7 @@ func TestMiddlewareFactoryReceivesParam(t *testing.T) { r := New(nil) var gotParam string ran := false - if err := r.RegisterMiddlewareFactory("golem15.fonoteka", "inv.scope", func(param string) pact.Middleware { + if err := r.RegisterMiddlewareFactory("golem15.acme", "inv.scope", func(param string) pact.Middleware { gotParam = param return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { @@ -472,11 +472,11 @@ func TestDuplicateMiddlewareFactoryNamesPluginAndName(t *testing.T) { fn := func(string) pact.Middleware { return func(next http.Handler) http.Handler { return next } } - if err := r.RegisterMiddlewareFactory("golem15.fonoteka", "inv.scope", fn); err != nil { + if err := r.RegisterMiddlewareFactory("golem15.acme", "inv.scope", fn); err != nil { t.Fatal(err) } err := r.RegisterMiddlewareFactory("golem15.other", "inv.scope", fn) - if err == nil || !strings.Contains(err.Error(), "golem15.fonoteka") || !strings.Contains(err.Error(), "inv.scope") { + if err == nil || !strings.Contains(err.Error(), "golem15.acme") || !strings.Contains(err.Error(), "inv.scope") { t.Fatalf("want plugin and factory name in error, got %v", err) } }