test(10-05): cover every Phase 10 Go change with branch-level tests

- bouncer TestPhase10CookieGuard: cookie read without Bearer, Bearer wins,
  empty cookie, frontend audience and blacklisted jti rejected
- boardwalk TestPhase10BoardwalkServing: HEAD, query strings, encoded
  traversal, index by name, nested prefix, MIME fallback, constructor errors
- cabana TestPhase10Coverage: mounted unsafe routes vs the CSRF walk, option
  and filter edges, read-only labels, relation message defaults, bundle
  fallback locale, cookie refresh of an expired token in the refresh window
- phrasebook override precedence, new locale, Bundle merge order, Forms shapes
- surf prefix collision for deeper paths and the default /backend prefix
- swagger2openapi TestUnionRewrite and converter branch tests
- framework tests no longer name the application (acme fixtures instead)
This commit is contained in:
Jakub Zych
2026-09-27 18:05:28 +02:00
parent 1c2a66df45
commit ef448da1cc
14 changed files with 1055 additions and 35 deletions

View File

@@ -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(), "<html") {
t.Fatalf("nested prefix api not delegated: %d", rec.Code)
}
})
t.Run("deep extensionless paths are client routes", func(t *testing.T) {
for _, target := range []string{testPrefix + "/a/b/c/d/e/f", testPrefix + "/settings/mail", testPrefix + "/assets"} {
rec := get(h, target)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "summer-admin-base") {
t.Fatalf("%s status=%d", target, rec.Code)
}
}
})
t.Run("unknown extensions fall back to the mime table, then octet-stream", func(t *testing.T) {
root := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(`<meta content="` + BaseToken + `">`)},
"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("<html></html>")}}
if _, err := newHandler(stale, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), BaseToken) {
t.Fatalf("stale index: %v", err)
}
})
}

View File

@@ -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)
}
})
}

View File

@@ -47,7 +47,7 @@ func TestDuplicateGuardNameFailsWithPluginAndName(t *testing.T) {
if err := reg.Register("golem15.user", "jwt", writerGuard{}); err != nil { if err := reg.Register("golem15.user", "jwt", writerGuard{}); err != nil {
t.Fatal(err) 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") { 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) 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) { func TestCredentialGuardSoftFailAndSuccess(t *testing.T) {
reg := NewRegistry() reg := NewRegistry()
failing := credOnlyGuard{err: errors.New("bad token")} 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) t.Fatal(err)
} }
mw, err := reg.Middleware("inv_token") mw, err := reg.Middleware("inv_token")
@@ -135,7 +135,7 @@ func TestCredentialGuardSoftFailAndSuccess(t *testing.T) {
okReg := NewRegistry() okReg := NewRegistry()
token := &struct{ ID uint }{ID: 9} token := &struct{ ID uint }{ID: 9}
principal := &Principal{ID: 3} principal := &Principal{ID: 3}
if err := okReg.Register("golem15.fonoteka", "inv_token", credOnlyGuard{ if err := okReg.Register("golem15.acme", "inv_token", credOnlyGuard{
principal: principal, principal: principal,
cred: token, cred: token,
}); err != nil { }); err != nil {

View File

@@ -229,7 +229,7 @@ func TestFormSchemaRejects(t *testing.T) {
{ {
name: "partial path", name: "partial path",
config: formConfig, 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"}, 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) schema := mustCompileForm(t, schemaController{model: "Widget"}, formConfig, optionMapYAML)
tr := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"}) tr := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"})
got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil) 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"}`) two := strings.Index(got, `{"value":2,"label":"Dwa"}`)
yes := strings.Index(got, `{"value":true,"label":"Yes"}`) yes := strings.Index(got, `{"value":true,"label":"Yes"}`)
if vinyl < 0 || two < 0 || yes < 0 || !(vinyl < two && two < 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) methodSchema := mustCompileForm(t, providerController{schemaController: schemaController{model: "Widget"}}, formConfig, methodFieldsYAML)
methodJSON := localizeJSON(t, methodSchema, towel.WithLocale(context.Background(), "pl"), tr, providerController{}) 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) t.Fatalf("method options = %s", methodJSON)
} }
if strings.Contains(methodJSON, "getFormatOptions") { 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") 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{}) 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) t.Fatalf("model provider options = %s", modelJSON)
} }
@@ -492,7 +492,7 @@ func formCatalog(t *testing.T) *phrasebook.Catalog {
cat := phrasebook.NewCatalog() cat := phrasebook.NewCatalog()
err := cat.Load("acme.demo", fstest.MapFS{ 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/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 { if err != nil {
t.Fatalf("catalog: %v", err) t.Fatalf("catalog: %v", err)

View File

@@ -64,10 +64,10 @@ func TestListSchemaCompile(t *testing.T) {
} }
t.Run("winter genre list still compiles", func(t *testing.T) { t.Run("winter genre list still compiles", func(t *testing.T) {
const config = `list: ~/plugins/golem15/fonoteka/models/genre/columns.yaml const config = `list: ~/plugins/golem15/acme/models/genre/columns.yaml
modelClass: Golem15\Fonoteka\Models\Genre modelClass: Golem15\Acme\Models\Genre
title: golem15.fonoteka::lang.genre.label_plural title: golem15.acme::lang.genre.label_plural
recordUrl: golem15/fonoteka/genres/update/:id recordUrl: golem15/acme/genres/update/:id
noRecordsMessage: backend::lang.list.no_records noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 20 recordsPerPage: 20
showCheckboxes: true showCheckboxes: true
@@ -78,19 +78,19 @@ toolbar:
` `
const cols = `columns: const cols = `columns:
name: name:
label: golem15.fonoteka::lang.genre.name label: golem15.acme::lang.genre.name
searchable: true searchable: true
slug: slug:
label: golem15.fonoteka::lang.genre.slug label: golem15.acme::lang.genre.slug
searchable: true searchable: true
` `
ctl := schemaController{model: `Golem15\Fonoteka\Models\Genre`} ctl := schemaController{model: `Golem15\Acme\Models\Genre`}
fsys := fstest.MapFS{ fsys := fstest.MapFS{
"controllers/genres/config_list.yaml": &fstest.MapFile{Data: []byte(config)}, "controllers/genres/config_list.yaml": &fstest.MapFile{Data: []byte(config)},
"models/genre/columns.yaml": &fstest.MapFile{Data: []byte(cols)}, "models/genre/columns.yaml": &fstest.MapFile{Data: []byte(cols)},
} }
genre := genreConfigController{schemaController: ctl} 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) t.Fatalf("genre list: %v", err)
} }
}) })
@@ -267,7 +267,7 @@ func TestListSchemaRejects(t *testing.T) {
type genreConfigController struct{ schemaController } type genreConfigController struct{ schemaController }
func (c genreConfigController) ConfigDir() string { return "controllers/genres" } 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 { func listFS(config, columns string) fstest.MapFS {
return fstest.MapFS{ return fstest.MapFS{

View File

@@ -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())
}
})
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -113,7 +113,7 @@ func TestPluralSmoke(t *testing.T) {
"helloName: \"hi :name :Name :NAME\"\n" + "helloName: \"hi :name :Name :NAME\"\n" +
"raw:\n other: \"{{.Count}} bottles\"\n")}, "raw:\n other: \"{{.Count}} bottles\"\n")},
"lang/pl/lang.yaml": {Data: []byte("" + "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")}, "posts: \":count wpis|:count wpisy|:count wpisów|:count wpisu\"\n")},
} }
cat := NewCatalog() cat := NewCatalog()
@@ -122,7 +122,7 @@ func TestPluralSmoke(t *testing.T) {
} }
tr := NewTranslator(cat, Options{}) 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 { for n, want := range pl {
if got := tr.ChoiceIn("pl", "golem15.hello::lang.albums", n, nil); got != want { if got := tr.ChoiceIn("pl", "golem15.hello::lang.albums", n, nil); got != want {
t.Fatalf("pl albums %v = %q, want %q", n, got, want) t.Fatalf("pl albums %v = %q, want %q", n, got, want)

View File

@@ -28,6 +28,8 @@ func TestPhase10AdminPrefixCollision(t *testing.T) {
{"exact prefix", http.MethodGet, "/acme-admin", false}, {"exact prefix", http.MethodGet, "/acme-admin", false},
{"under prefix", http.MethodPost, "/acme-admin/hook", false}, {"under prefix", http.MethodPost, "/acme-admin/hook", false},
{"raw under api", http.MethodGet, "/acme-admin/api/v1/extra", true}, {"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) { t.Run(tc.name, func(t *testing.T) {
app := adminPrefixApp(t) app := adminPrefixApp(t)
@@ -57,9 +59,52 @@ func TestPhase10AdminPrefixCollision(t *testing.T) {
t.Fatalf("sibling path rejected: %v", err) 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 { 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() t.Helper()
dir := t.TempDir() dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: admin-prefix\n"), 0o644); err != nil { 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 { 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) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(dir, "backend.yaml"), []byte("uri: /acme-admin\n"), 0o644); err != nil { if uri != "" {
t.Fatal(err) 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{ cfg, err := compass.Open(compass.Options{
Dir: dir, Dir: dir,

View File

@@ -7,10 +7,10 @@ import (
) )
// Gap (d): pathScopedCORS with a path matching NONE of the configured // 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/*). // groups; this fixture is framework-only (/healthz vs api/*).
func TestCORSPathScopedNoMatchIndependentOfFonoteka(t *testing.T) { func TestCORSPathScopedNoMatchIndependentOfAcme(t *testing.T) {
cfg := CORSConfig{ cfg := CORSConfig{
Paths: []string{"api/*", "oauth/mcp/*"}, Paths: []string{"api/*", "oauth/mcp/*"},
AllowedMethods: []string{"*"}, AllowedMethods: []string{"*"},

View File

@@ -14,11 +14,11 @@ import (
func TestCORSLaravelGlobMatchesNestedPaths(t *testing.T) { func TestCORSLaravelGlobMatchesNestedPaths(t *testing.T) {
re := compileLaravelGlob("api/*") re := compileLaravelGlob("api/*")
if re == nil || !re.MatchString("api/v1/fonoteka/genres") { if re == nil || !re.MatchString("api/v1/acme/genres") {
t.Fatal("api/* must match api/v1/fonoteka/genres (Laravel Str::is, not Go path.Match)") t.Fatal("api/* must match api/v1/acme/genres (Laravel Str::is, not Go path.Match)")
} }
if re.MatchString("_fonoteka/api/v1/genres") { if re.MatchString("_acme/api/v1/genres") {
t.Fatal("api/* must not match _fonoteka/api/v1/genres") t.Fatal("api/* must not match _acme/api/v1/genres")
} }
mcp := compileLaravelGlob("oauth/mcp/*") mcp := compileLaravelGlob("oauth/mcp/*")
if mcp == nil || !mcp.MatchString("oauth/mcp/token") { if mcp == nil || !mcp.MatchString("oauth/mcp/token") {
@@ -34,22 +34,22 @@ func TestCORSPathScopedHeaders(t *testing.T) {
AllowedHeaders: []string{"*"}, AllowedHeaders: []string{"*"},
} }
mux := http.NewServeMux() 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) 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) w.WriteHeader(http.StatusOK)
}) })
h := pathScopedCORS(cfg, mux) h := pathScopedCORS(cfg, mux)
rec := httptest.NewRecorder() 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") != "*" { if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("token group ACAO = %q", rec.Header().Get("Access-Control-Allow-Origin")) t.Fatalf("token group ACAO = %q", rec.Header().Get("Access-Control-Allow-Origin"))
} }
rec = httptest.NewRecorder() 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 != "" { if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("JWT group ACAO = %q, want empty", got) t.Fatalf("JWT group ACAO = %q, want empty", got)
} }

View File

@@ -101,7 +101,7 @@ func TestPipelineOrderRecoverCORSLocaleAuthPasswordOrgRateHandler(t *testing.T)
if err := r.RegisterMiddleware("golem15.user", "jwt.auth", record("jwt.auth")); err != nil { if err := r.RegisterMiddleware("golem15.user", "jwt.auth", record("jwt.auth")); err != nil {
t.Fatal(err) 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) t.Fatal(err)
} }
r.BindPlugin("golem15.demo") r.BindPlugin("golem15.demo")

View File

@@ -436,7 +436,7 @@ func TestMiddlewareFactoryReceivesParam(t *testing.T) {
r := New(nil) r := New(nil)
var gotParam string var gotParam string
ran := false 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 gotParam = param
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
@@ -472,11 +472,11 @@ func TestDuplicateMiddlewareFactoryNamesPluginAndName(t *testing.T) {
fn := func(string) pact.Middleware { fn := func(string) pact.Middleware {
return func(next http.Handler) http.Handler { return next } 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) t.Fatal(err)
} }
err := r.RegisterMiddlewareFactory("golem15.other", "inv.scope", fn) 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) t.Fatalf("want plugin and factory name in error, got %v", err)
} }
} }