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:
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
132
bouncer/cookie_guard_test.go
Normal file
132
bouncer/cookie_guard_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
366
cabana/phase10_coverage_test.go
Normal file
366
cabana/phase10_coverage_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
203
internal/tools/swagger2openapi/main_test.go
Normal file
203
internal/tools/swagger2openapi/main_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,9 +113,11 @@ 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 != "" {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "backend.yaml"), []byte("uri: "+uri+"\n"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
cfg, err := compass.Open(compass.Options{
|
cfg, err := compass.Open(compass.Options{
|
||||||
Dir: dir,
|
Dir: dir,
|
||||||
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=summercms-test-only-admin-hs256-secret"},
|
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=summercms-test-only-admin-hs256-secret"},
|
||||||
|
|||||||
@@ -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{"*"},
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user