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