Files
summercms/modules/cabana/account_contributions_hardening_test.go
Jakub Zych ccf77b9103 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)
2026-10-23 18:36:16 +02:00

189 lines
6.8 KiB
Go

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