test(19.7-09): named tests for every framework and admin SPA fix of plans 06 and 07
- pier: TestSessionReserve (A-WR-03), TestLockScope (A-WR-07), TestScopeWidening (A-WR-04), TestStatelessFanOut (A-WR-05, A-WR-06), TestPersonalTokenBinding (A-WR-09), TestDispatchHardening (A-WR-01, A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08), TestCatalogPathFields (A-WR-02, A-IN-07), TestPromptWriteCeiling (C-IN-01), TestTokenScopedRevoke (B-WR-04, C-IN-02), TestResolveClientAddress (B-IN-04); test seam WrapStore - pact TestFrontendTokenScopes, towel TestClientIP, cabana TestTokenScopeNameParity - cabana TestAccountContributionURL (B-WR-03), TestAccountContributionsIsolation (B-WR-06, B-IN-03), TestRecordActionPromptLocalized (B-IN-02), and the Postgres TestAccountTokenRevokeEvent (B-WR-04) and TestResolveBackendTokenRemote (B-IN-04) - vitest: hotkeysEditing.test.ts (B-WR-01, B-WR-02) and the extended AccountContributions.test.ts (B-WR-03, B-WR-06, B-IN-01) - check-phase19.7.sh requires every name (GO_TESTS, GO_TAGGED_TESTS, DOCKER_TESTS, ADMIN_FILES) and scans the new files (PHASE_TEST_FILES)
This commit is contained in:
188
modules/cabana/account_contributions_hardening_test.go
Normal file
188
modules/cabana/account_contributions_hardening_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// syncLog is a goroutine-safe slog destination.
|
||||
type syncLog struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncLog) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncLog) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// captureProcessLog routes the process logger (and so diary's components)
|
||||
// into a buffer for the rest of the test.
|
||||
func captureProcessLog(t *testing.T) *syncLog {
|
||||
t.Helper()
|
||||
buf := &syncLog{}
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
return buf
|
||||
}
|
||||
|
||||
// sectionPlugin contributes the sections it is given.
|
||||
type sectionPlugin struct {
|
||||
id string
|
||||
sections []pact.AccountContribution
|
||||
}
|
||||
|
||||
func (p sectionPlugin) ID() string { return p.id }
|
||||
func (sectionPlugin) Requires() []string { return nil }
|
||||
func (sectionPlugin) Register(*backpack.App) error { return nil }
|
||||
func (sectionPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p sectionPlugin) AccountContributions() []pact.AccountContribution { return p.sections }
|
||||
|
||||
// contributionViews decodes the list answer.
|
||||
type contributionViews struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Error bool `json:"error"`
|
||||
Actions []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"actions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// TestAccountContributionURL pins plan 07's URL rule for account actions
|
||||
// (B-WR-03 server side, P-33): an action answering a javascript:, data:,
|
||||
// vbscript: or protocol-relative URL is answered without it (url empty) and
|
||||
// the refusal is logged without the URL; an https URL with a fragment and a
|
||||
// same-origin path pass unchanged.
|
||||
func TestAccountContributionURL(t *testing.T) {
|
||||
var answer atomic.Value
|
||||
answer.Store("")
|
||||
plugin := sectionPlugin{id: "acme.links", sections: []pact.AccountContribution{{
|
||||
Name: "site_link", Label: "Site account",
|
||||
Actions: []pact.AccountAction{{Name: "open", Label: "Open", Run: func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) {
|
||||
return pact.AccountActionResult{Message: "Done", URL: answer.Load().(string)}, nil
|
||||
}}},
|
||||
}}}
|
||||
h, err := assembleWithoutDB(t, plugin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := &bouncer.Principal{ID: 42, Backend: true}
|
||||
run := func(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
answer.Store(raw)
|
||||
rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/open", admin, nil)
|
||||
var out struct {
|
||||
Data struct {
|
||||
Message string `json:"message"`
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.Data.Message != "Done" {
|
||||
t.Fatalf("%q: %d %s", raw, rec.Code, rec.Body.String())
|
||||
}
|
||||
return out.Data.URL
|
||||
}
|
||||
for _, raw := range []string{"javascript:alert(1)", "data:text/html,x", "//evil.example/x", "vbscript:msgbox(1)"} {
|
||||
t.Run("drops "+raw, func(t *testing.T) {
|
||||
logs := captureProcessLog(t)
|
||||
if got := run(t, raw); got != "" {
|
||||
t.Fatalf("%q answered url %q", raw, got)
|
||||
}
|
||||
logged := logs.String()
|
||||
if !strings.Contains(logged, "answered a non-http URL") {
|
||||
t.Fatalf("the refusal is not logged: %s", logged)
|
||||
}
|
||||
if strings.Contains(logged, raw) {
|
||||
t.Fatalf("the log carries the refused URL: %s", logged)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, raw := range []string{"https://site/link-admin#code=x", "/relative"} {
|
||||
t.Run("keeps "+raw, func(t *testing.T) {
|
||||
if got := run(t, raw); got != raw {
|
||||
t.Fatalf("%q answered url %q", raw, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountContributionsIsolation pins plan 07's per-section isolation
|
||||
// (B-WR-06 server side, B-IN-03): a plugin whose Status fails gets
|
||||
// error: true and an empty status while another plugin's section and
|
||||
// actions are intact and the list answers 200; an action whose Applies
|
||||
// fails is dropped alone; and a dynamic status text is sent as is, never
|
||||
// looked up as a phrase key.
|
||||
func TestAccountContributionsIsolation(t *testing.T) {
|
||||
run := func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) {
|
||||
return pact.AccountActionResult{Message: "Done"}, nil
|
||||
}
|
||||
failing := sectionPlugin{id: "acme.alpha", sections: []pact.AccountContribution{{
|
||||
Name: "alpha", Label: "Alpha",
|
||||
Status: func(context.Context, uint) (string, error) { return "", errors.New("alpha: database is down") },
|
||||
Actions: []pact.AccountAction{
|
||||
{Name: "keep", Label: "Keep", Run: run},
|
||||
{Name: "broken", Label: "Broken", Run: run, Applies: func(context.Context, uint) (bool, error) {
|
||||
return false, errors.New("alpha: applies failed")
|
||||
}},
|
||||
},
|
||||
}}}
|
||||
healthy := sectionPlugin{id: "acme.beta", sections: []pact.AccountContribution{{
|
||||
Name: "beta", Label: "Beta",
|
||||
Status: func(context.Context, uint) (string, error) { return "Linked as jan@example.test", nil },
|
||||
Actions: []pact.AccountAction{{Name: "open", Label: "Open", Run: run}},
|
||||
}}}
|
||||
h, err := assembleWithoutDB(t, failing, healthy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logs := captureProcessLog(t)
|
||||
rec := asAdmin(h, http.MethodGet, "/account/contributions", &bouncer.Principal{ID: 42, Backend: true}, nil)
|
||||
var out contributionViews
|
||||
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || len(out.Data) != 2 {
|
||||
t.Fatalf("list = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
alpha, beta := out.Data[0], out.Data[1]
|
||||
if alpha.Name != "alpha" || !alpha.Error || alpha.Status != "" {
|
||||
t.Fatalf("failing section = %+v", alpha)
|
||||
}
|
||||
if len(alpha.Actions) != 1 || alpha.Actions[0].Name != "keep" {
|
||||
t.Fatalf("failing section's actions = %+v (the Applies error drops only that action)", alpha.Actions)
|
||||
}
|
||||
if beta.Name != "beta" || beta.Error || beta.Status != "Linked as jan@example.test" || len(beta.Actions) != 1 || beta.Actions[0].Name != "open" {
|
||||
t.Fatalf("healthy section = %+v", beta)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), `"error":false`) {
|
||||
t.Fatalf("error is sent for a healthy section: %s", rec.Body.String())
|
||||
}
|
||||
logged := logs.String()
|
||||
if !strings.Contains(logged, "account contribution status failed") || !strings.Contains(logged, "account contribution applies failed") {
|
||||
t.Fatalf("the plugin failures are not logged: %s", logged)
|
||||
}
|
||||
// Labels are phrase keys or literal text, so "Alpha" may be looked up;
|
||||
// the dynamic status text never is.
|
||||
if strings.Contains(logged, "jan@example.test") {
|
||||
t.Fatalf("a dynamic status text was looked up as a phrase key: %s", logged)
|
||||
}
|
||||
}
|
||||
61
modules/cabana/account_token_events_test.go
Normal file
61
modules/cabana/account_token_events_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestAccountTokenRevokeEvent pins plan 07's token-specific revocation
|
||||
// (B-WR-04): revoking one g15_ token from My account fires exactly one
|
||||
// revoked subject event for the admin, after the commit, whose TokenID is
|
||||
// the revoked row's id, and no event for the admin as a whole (their other
|
||||
// tokens and OAuth sessions stay). Postgres only: under -short nothing runs.
|
||||
func TestAccountTokenRevokeEvent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
return
|
||||
}
|
||||
t.Run("one revoked event with the token id", func(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
var mu sync.Mutex
|
||||
var got []pact.MCPSubjectChanged
|
||||
env.app.Events.Listen("test.revoke", func(_ context.Context, ev *pact.MCPSubjectChanged) error {
|
||||
mu.Lock()
|
||||
got = append(got, *ev)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
admin, jwt := env.admin(t, env.unique("revoke"), true, "")
|
||||
ids := make([]uint, 2)
|
||||
for i := range ids {
|
||||
out := env.expect(t, http.StatusCreated, http.MethodPost, "/account/tokens", jwt, map[string]any{
|
||||
"name": env.unique("agent"), "expires": "90d", "scopes": []string{"*"},
|
||||
})
|
||||
raw, _ := json.Marshal(out["data"])
|
||||
var created cabana.AdminAPITokenCreated
|
||||
if err := json.Unmarshal(raw, &created); err != nil || created.ID == 0 {
|
||||
t.Fatalf("created token = %s (%v)", raw, err)
|
||||
}
|
||||
ids[i] = created.ID
|
||||
}
|
||||
mu.Lock()
|
||||
got = nil
|
||||
mu.Unlock()
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, "/account/tokens/"+strconv.FormatUint(uint64(ids[0]), 10), jwt, map[string]any{})
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("revoking one token fired %d events, want 1: %+v", len(got), got)
|
||||
}
|
||||
ev := got[0]
|
||||
if ev.Kind != pact.MCPSubjectRevoked || ev.Guard != "backend" || ev.UserID != admin.ID || ev.TokenID != ids[0] || ev.ClientID != "" {
|
||||
t.Fatalf("event = %+v, want revoked backend %d with token %d", ev, admin.ID, ids[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
68
modules/cabana/record_action_prompt_localized_test.go
Normal file
68
modules/cabana/record_action_prompt_localized_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
// TestRecordActionPromptLocalized pins plan 07's localized prompt refusal
|
||||
// (B-IN-02): a prompted record action posted a non-string payload answers
|
||||
// 422 on payload with the backend::lang.form.action_prompt_invalid text in
|
||||
// the request's language (en and pl), and the action never runs.
|
||||
func TestRecordActionPromptLocalized(t *testing.T) {
|
||||
var got []string
|
||||
extender := screensExtender{extensions: []pact.AdminActionExtension{{
|
||||
Controller: AdministratorsControllerID,
|
||||
RecordActions: []pact.AdminRecordAction{promptedAction("acme_link", &pact.AdminActionPrompt{Label: "Login"}, &got)},
|
||||
}}}
|
||||
reg, err := compileWithScreens(t, []party.Plugin{extender})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(nil)
|
||||
if err := phrasebook.Activate(app, []party.Plugin{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service{app: app, reg: reg}
|
||||
if svc.translator() == nil {
|
||||
t.Fatal("no translator published")
|
||||
}
|
||||
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
|
||||
for locale, want := range map[string]string{"en": "The answer must be text.", "pl": "Odpowiedź musi być tekstem."} {
|
||||
for _, body := range []string{`{"payload":42}`, `{"payload":true}`, `{"payload":["a"]}`, `{"payload":{"v":"a"}}`} {
|
||||
t.Run(locale+" "+body, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
for k, v := range map[string]string{"vendor": "summercms", "plugin": "cabana", "controller": "users", "id": "5", "action": "acme_link"} {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), super), locale))
|
||||
rec := httptest.NewRecorder()
|
||||
svc.recordAction(rec, req)
|
||||
var out struct {
|
||||
Error struct {
|
||||
Details map[string][]string `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if rec.Code != http.StatusUnprocessableEntity || json.Unmarshal(rec.Body.Bytes(), &out) != nil {
|
||||
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if msgs := out.Error.Details["payload"]; len(msgs) != 1 || msgs[0] != want {
|
||||
t.Fatalf("payload errors = %v, want [%q] (%s)", msgs, want, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("a refused request ran the action: %v", got)
|
||||
}
|
||||
}
|
||||
78
modules/cabana/token_resolver_remote_test.go
Normal file
78
modules/cabana/token_resolver_remote_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
// TestResolveBackendTokenRemote pins plan 07's client address on the MCP
|
||||
// token audit line (B-IN-04): ResolveBackendToken logs the address the
|
||||
// caller put on the context with towel.WithClientIP as remote, on a failed
|
||||
// and on a successful resolve, and never the token.
|
||||
func TestResolveBackendTokenRemote(t *testing.T) {
|
||||
const bogus = "g15_0000000000000000000000000000000000000000"
|
||||
|
||||
t.Run("a failed resolve without the admin logs remote", func(t *testing.T) {
|
||||
logs := captureProcessLog(t)
|
||||
ctx := towel.WithClientIP(context.Background(), "198.51.100.4")
|
||||
if _, _, _, err := cabana.ResolveBackendToken(ctx, backpack.New(nil), bogus); !errors.Is(err, cabana.ErrUnauthenticated) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
logged := logs.String()
|
||||
if !strings.Contains(logged, "admin.auth") || !strings.Contains(logged, "outcome=failed") || !strings.Contains(logged, "remote=198.51.100.4") {
|
||||
t.Fatalf("audit line = %s", logged)
|
||||
}
|
||||
})
|
||||
|
||||
// The Postgres part runs on a Docker host (the gate's docker stage);
|
||||
// under -short it is not created, so no subtest is reported as skipped.
|
||||
if testing.Short() {
|
||||
return
|
||||
}
|
||||
t.Run("failed and successful resolves log remote", func(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
admin, jwt := env.admin(t, env.unique("remote"), true, "")
|
||||
out := env.expect(t, http.StatusCreated, http.MethodPost, "/account/tokens", jwt, map[string]any{
|
||||
"name": env.unique("agent"), "expires": "90d", "scopes": []string{"*"},
|
||||
})
|
||||
raw, _ := json.Marshal(out["data"])
|
||||
var created cabana.AdminAPITokenCreated
|
||||
if err := json.Unmarshal(raw, &created); err != nil || created.Token == "" {
|
||||
t.Fatalf("created token = %s (%v)", raw, err)
|
||||
}
|
||||
logs := captureProcessLog(t)
|
||||
ctx := towel.WithClientIP(context.Background(), "203.0.113.9")
|
||||
if _, _, _, err := cabana.ResolveBackendToken(ctx, env.app, bogus); !errors.Is(err, cabana.ErrUnauthenticated) {
|
||||
t.Fatalf("unknown token err = %v", err)
|
||||
}
|
||||
principal, row, _, err := cabana.ResolveBackendToken(ctx, env.app, created.Token)
|
||||
if err != nil || principal == nil || principal.ID != admin.ID || row == nil || row.ID != created.ID {
|
||||
t.Fatalf("resolve = %+v, %+v, %v", principal, row, err)
|
||||
}
|
||||
var failed, success bool
|
||||
for _, line := range strings.Split(logs.String(), "\n") {
|
||||
if !strings.Contains(line, "admin.auth") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, created.Token) || strings.Contains(line, bogus) {
|
||||
t.Fatalf("the audit line carries a token: %s", line)
|
||||
}
|
||||
if !strings.Contains(line, "remote=203.0.113.9") {
|
||||
t.Fatalf("audit line without the client address: %s", line)
|
||||
}
|
||||
failed = failed || strings.Contains(line, "outcome=failed")
|
||||
success = success || strings.Contains(line, "outcome=success")
|
||||
}
|
||||
if !failed || !success {
|
||||
t.Fatalf("audit lines (failed %v, success %v): %s", failed, success, logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
41
modules/cabana/token_scope_name_test.go
Normal file
41
modules/cabana/token_scope_name_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestTokenScopeNameParity pins plan 07's shared scope name rule (P-30):
|
||||
// cabana's plugin scope validation accepts and refuses exactly the names
|
||||
// pact.ValidTokenScopeName does (the table of pact's TestFrontendTokenScopes;
|
||||
// "*" is refused by both, by cabana also as reserved).
|
||||
func TestTokenScopeNameParity(t *testing.T) {
|
||||
names := []struct {
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"read", true},
|
||||
{"servers:write", true},
|
||||
{"a_b-c:d", true},
|
||||
{"", false},
|
||||
{"Read", false},
|
||||
{"servers:", false},
|
||||
{":x", false},
|
||||
{"a:b:c", false},
|
||||
{"*", false},
|
||||
{"with space", false},
|
||||
}
|
||||
for _, c := range names {
|
||||
if got := pact.ValidTokenScopeName(c.name); got != c.ok {
|
||||
t.Fatalf("pact.ValidTokenScopeName(%q) = %v, want %v (the table drifted from pact's)", c.name, got, c.ok)
|
||||
}
|
||||
cat := &tokenScopeCatalog{byName: map[string]*compiledTokenScope{}}
|
||||
scope := pact.TokenScope{Name: c.name, Label: "Label", Description: "Description", Permissions: []string{"acme.*"}, Methods: []string{"GET"}}
|
||||
reg := &Registry{permissions: map[string]pact.Permission{"acme.read": {Code: "acme.read"}}}
|
||||
err := validatePluginTokenScope(reg, "acme.plugin", scope, cat)
|
||||
if (err == nil) != c.ok {
|
||||
t.Errorf("validatePluginTokenScope(%q) err = %v, want accepted %v", c.name, err, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user