- Replace gorilla/csrf with net/http CrossOriginProtection - Require valkey-password and add TLS options for session store - End session at /logout and revoke refresh tokens - Re-derive identity and roles from provider every five minutes - Process each Stripe webhook event in its own Temporal workflow - Give each outbox entry its own workflow with Temporal retries - Guard against stale Stripe events with provider timestamps - Derive transport security from base-url scheme
211 lines
8.2 KiB
Go
211 lines
8.2 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"html/template"
|
|
"io/fs"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
"log/slog"
|
|
)
|
|
|
|
func renderIndex(t *testing.T, data IndexPageData) string {
|
|
t.Helper()
|
|
sub, err := fs.Sub(embeds.Templates, "templates")
|
|
if err != nil {
|
|
t.Fatalf("sub templates FS: %v", err)
|
|
}
|
|
// index.html references no other templates, so it parses standalone; the
|
|
// pending-domain notice uses routeURL, registered for real.
|
|
tmpl, err := template.New("root").Funcs(template.FuncMap{
|
|
"routeURL": web.RouteURL,
|
|
"deploymentName": config.DeploymentName,
|
|
}).ParseFS(sub, "index.html")
|
|
if err != nil {
|
|
t.Fatalf("parse index.html: %v", err)
|
|
}
|
|
tmpl = template.Must(web.ParseUIPartials(tmpl))
|
|
rec := httptest.NewRecorder()
|
|
NewSafeTemplates(tmpl, slog.Default()).Render(rec, "index.html", data)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("render = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
return rec.Body.String()
|
|
}
|
|
|
|
// The raw template bytes must be integration-free too — a rendered-output
|
|
// check could miss a reference hidden behind an untaken template branch.
|
|
func TestIndexTemplateNamesNoIntegration(t *testing.T) {
|
|
raw, err := fs.ReadFile(embeds.Templates, "templates/index.html")
|
|
if err != nil {
|
|
t.Fatalf("read embedded index.html: %v", err)
|
|
}
|
|
for _, banned := range []string{"fedwiki", "discourse", "stripe"} {
|
|
if strings.Contains(strings.ToLower(string(raw)), banned) {
|
|
t.Errorf("core index.html template references integration %q", banned)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The dashboard is registry-driven: core's index.html must not name any
|
|
// integration, and with no declared cards it must render no card section
|
|
// chrome and no integration script tags.
|
|
func TestIndexNoDashboardCards(t *testing.T) {
|
|
body := renderIndex(t, IndexPageData{})
|
|
|
|
if strings.Contains(strings.ToLower(body), "fedwiki") {
|
|
t.Errorf("index.html without cards still references fedwiki")
|
|
}
|
|
if strings.Contains(strings.ToLower(body), "discourse") {
|
|
t.Errorf("index.html without cards references discourse")
|
|
}
|
|
if strings.Contains(body, "hx-trigger=") {
|
|
t.Errorf("card shell rendered despite zero declared cards")
|
|
}
|
|
}
|
|
|
|
// Declared cards render as generic shells in declaration (registry) order,
|
|
// with the declared partial path, trigger spec, and page-level deferred
|
|
// script tags.
|
|
func TestIndexRendersDeclaredCards(t *testing.T) {
|
|
body := renderIndex(t, IndexPageData{
|
|
DashboardCards: []DashboardCardView{
|
|
{DashboardCard: DashboardCard{
|
|
Title: "Stub Sites",
|
|
PartialPath: "/partials/stub/sites",
|
|
RefreshEvent: "refreshStub",
|
|
Scripts: []string{"/static/stub/stub-form.js"},
|
|
}, Body: "<p>stub body</p>"},
|
|
{DashboardCard: DashboardCard{
|
|
Title: "Other Service",
|
|
PartialPath: "/partials/other/status",
|
|
}, Body: "<p>other body</p>"},
|
|
},
|
|
})
|
|
|
|
first := strings.Index(body, "Stub Sites")
|
|
second := strings.Index(body, "Other Service")
|
|
if first == -1 || second == -1 {
|
|
t.Fatalf("declared card titles missing (first=%d second=%d)", first, second)
|
|
}
|
|
if first > second {
|
|
t.Errorf("cards rendered out of declaration order")
|
|
}
|
|
// Bodies are composed on the server (page-anatomy "A page arrives
|
|
// complete"): both render inline; only the card with a refresh event
|
|
// keeps its partial route as an HTMX swap source, and nothing fetches
|
|
// after load.
|
|
if !strings.Contains(body, "<p>stub body</p>") || !strings.Contains(body, "<p>other body</p>") {
|
|
t.Errorf("composed card bodies missing")
|
|
}
|
|
if !strings.Contains(body, `<div hx-get="/partials/stub/sites" hx-trigger="refreshStub from:body" hx-swap="innerHTML"><p>stub body</p></div>`) {
|
|
t.Errorf("refresh-event card must keep its partial route as the swap source")
|
|
}
|
|
if strings.Contains(body, `hx-get="/partials/other/status"`) {
|
|
t.Errorf("a card without a refresh event must not carry an hx-get")
|
|
}
|
|
// The confirm modal's submit indicator is a spinner too, but it is an
|
|
// action's loading state, hidden until a request; the first-paint
|
|
// spinner variant is what must be gone.
|
|
if strings.Contains(body, `hx-trigger="load`) || strings.Contains(body, "spinner-border text-primary") {
|
|
t.Errorf("nothing on the dashboard may fetch after load or show a spinner")
|
|
}
|
|
if !strings.Contains(body, `<script defer src="/static/stub/stub-form.js"></script>`) {
|
|
t.Errorf("declared card script not rendered as page-level deferred tag")
|
|
}
|
|
if strings.Contains(strings.ToLower(body), "fedwiki") {
|
|
t.Errorf("generic shell leaked an integration name")
|
|
}
|
|
}
|
|
|
|
// A card declaration's Description renders as the card's lead, in the same
|
|
// style pageHeader uses for its Lead (design D7); a card with no
|
|
// description renders no lead paragraph.
|
|
func TestIndexRendersCardDescription(t *testing.T) {
|
|
body := renderIndex(t, IndexPageData{
|
|
DashboardCards: []DashboardCardView{
|
|
{DashboardCard: DashboardCard{
|
|
Title: "FedWiki sites",
|
|
Description: "Your hosted wiki sites.",
|
|
PartialPath: "/partials/stub/sites",
|
|
}, Body: "<p>stub body</p>"},
|
|
{DashboardCard: DashboardCard{
|
|
Title: "Other Service",
|
|
PartialPath: "/partials/other/status",
|
|
}, Body: "<p>other body</p>"},
|
|
},
|
|
})
|
|
if !strings.Contains(body, `<p class="text-body-secondary mb-2">Your hosted wiki sites.</p>`) {
|
|
t.Errorf("card description did not render as the card's lead")
|
|
}
|
|
// The second card declares no description: no stray empty lead
|
|
// paragraph for it.
|
|
if strings.Count(body, `class="text-body-secondary mb-2"`) != 1 {
|
|
t.Errorf("a card with no description must render no lead paragraph")
|
|
}
|
|
}
|
|
|
|
// The member dashboard wraps its content in exactly one <main> (page-anatomy
|
|
// "Every page has one main landmark", ACC-40).
|
|
func TestIndexHasOneMainLandmark(t *testing.T) {
|
|
body := renderIndex(t, IndexPageData{})
|
|
if n := strings.Count(body, "<main"); n != 1 {
|
|
t.Errorf("index.html must render exactly one <main>, got %d", n)
|
|
}
|
|
if !strings.Contains(body, "</main>") {
|
|
t.Errorf("index.html must close its <main>")
|
|
}
|
|
}
|
|
|
|
// TestIndexEmptyState covers member-dashboard's ADDED requirement "The
|
|
// dashboard has one empty state" (design D25): with no declared card and
|
|
// no org entitlement, one emptyState renders the D25 sentence in place of
|
|
// the cards section, and no card chrome renders beside it.
|
|
func TestIndexEmptyState(t *testing.T) {
|
|
body := renderIndex(t, IndexPageData{})
|
|
if !strings.Contains(body, "Nothing to show yet.") {
|
|
t.Errorf("empty dashboard missing the D25 headline, got:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "Your services and entitlements appear here once your organization holds a plan or a grant.") {
|
|
t.Errorf("empty dashboard missing the D25 note, got:\n%s", body)
|
|
}
|
|
// The Account section always renders its own card; what must NOT
|
|
// render is dashboard-card chrome (an integration card's swap-source
|
|
// wrapper), the same marker TestIndexNoDashboardCards checks.
|
|
if strings.Contains(body, "hx-trigger=") {
|
|
t.Errorf("empty dashboard must render no dashboard-card chrome beside the empty state")
|
|
}
|
|
}
|
|
|
|
// TestIndexNoEmptyStateWithEntitlement covers the other half of the same
|
|
// requirement: an org that holds an entitlement is not "nothing to show",
|
|
// even with zero declared cards (no configured integration on this
|
|
// deployment) — the empty state must not render.
|
|
func TestIndexNoEmptyStateWithEntitlement(t *testing.T) {
|
|
body := renderIndex(t, IndexPageData{HasEntitlement: true})
|
|
if strings.Contains(body, "Nothing to show yet.") {
|
|
t.Errorf("dashboard with an org entitlement must not render the empty state, got:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// TestIndexNoEmptyStateWithCards covers the same requirement from the
|
|
// other input: a declared card alone (regardless of HasEntitlement) is
|
|
// enough to skip the empty state.
|
|
func TestIndexNoEmptyStateWithCards(t *testing.T) {
|
|
body := renderIndex(t, IndexPageData{
|
|
DashboardCards: []DashboardCardView{
|
|
{DashboardCard: DashboardCard{Title: "Other Service", PartialPath: "/partials/other/status"}, Body: "<p>other body</p>"},
|
|
},
|
|
})
|
|
if strings.Contains(body, "Nothing to show yet.") {
|
|
t.Errorf("dashboard with a declared card must not render the empty state, got:\n%s", body)
|
|
}
|
|
}
|