Files
summercms/modules/cabana/account_contributions_hardening_test.go
Jakub Zych 81342f1d9b fix(19.7): RB-IN-02 a failing Applies marks its My account section
An action whose Applies failed was dropped with no signal, so a section
whose Status succeeded showed a normal card with a button silently
missing. The section is now also sent with error: true, and My account
shows its error line. The error field's description, the cabana README
and the My account docs say so; the OpenAPI document and the SPA schema
types are regenerated.
2026-10-23 20:34:44 +02:00

234 lines
8.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)
}
}
// TestAccountContributionsAppliesFailureFlagsSection pins RB-IN-02: an
// action whose Applies fails is left out and marks its section error: true
// even though the section's Status succeeded, so My account shows the
// section error line instead of a silently missing button. A section whose
// actions all answer is not marked.
func TestAccountContributionsAppliesFailureFlagsSection(t *testing.T) {
run := func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) {
return pact.AccountActionResult{Message: "Done"}, nil
}
linked := func(context.Context, uint) (string, error) { return "Linked", nil }
plugin := sectionPlugin{id: "acme.gamma", sections: []pact.AccountContribution{
{
Name: "gamma", Label: "Gamma", Status: linked,
Actions: []pact.AccountAction{
{Name: "keep", Label: "Keep", Run: run},
{Name: "unlink", Label: "Unlink", Run: run, Applies: func(context.Context, uint) (bool, error) {
return false, errors.New("gamma: applies failed")
}},
},
},
{
Name: "delta", Label: "Delta", Status: linked,
Actions: []pact.AccountAction{{Name: "hidden", Label: "Hidden", Run: run, Applies: func(context.Context, uint) (bool, error) {
return false, nil
}}},
},
}}
h, err := assembleWithoutDB(t, plugin)
if err != nil {
t.Fatal(err)
}
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())
}
gamma, delta := out.Data[0], out.Data[1]
if !gamma.Error || gamma.Status != "Linked" || len(gamma.Actions) != 1 || gamma.Actions[0].Name != "keep" {
t.Fatalf("section with a failing Applies = %+v, want error with its status and the other action kept", gamma)
}
if delta.Error || len(delta.Actions) != 0 {
t.Fatalf("section whose Applies answered false = %+v, want no error and no action", delta)
}
}