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.
234 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|