Files
member-console/internal/server/operator_setup_render_test.go
T
cgalo5758 0b28a9dc29 Remediate security audit findings
- 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
2026-09-09 13:25:43 -05:00

329 lines
13 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"bytes"
"html/template"
"io/fs"
"regexp"
"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"
)
// checklistPageTemplate parses the same template set NewOperatorHandler
// builds, renderBody included — unlike overviewTemplate (in
// operator_overview_render_test.go), whose renderBody is a landing-only
// no-op stub. GetSetupPage dispatches through BodyTemplate, so exercising it
// needs the real dispatcher.
func checklistPageTemplate(t *testing.T) *template.Template {
t.Helper()
sub, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
var tmpl *template.Template
tmpl = template.New("operator").Funcs(template.FuncMap{
"renderBody": func(name string, data any) (template.HTML, error) {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
},
"deploymentName": config.DeploymentName,
"pageTitle": pageTitle,
})
if tmpl, err = web.ParseUIPartials(template.Must(tmpl.ParseFS(sub, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html"))); err != nil {
t.Fatalf("ParseFS: %v", err)
}
return tmpl
}
func renderChecklistPage(t *testing.T, data OperatorPageData) string {
t.Helper()
var buf bytes.Buffer
if err := checklistPageTemplate(t).ExecuteTemplate(&buf, "operator.html", data); err != nil {
t.Fatalf("ExecuteTemplate: %v", err)
}
return buf.String()
}
// Render coverage for the setup checklist (setup_state.go, operator_setup.go,
// partials/operator_setup.html; design D12 / operator-setup-checklist) and
// the operator overview's setup banner (operator.html; design D12's "The
// overview carries a dismissible setup banner"). Template-level only,
// executed against hand-built OperatorPageData/SetupState the same way
// operator_overview_render_test.go covers the landing surface's other
// regions — reuses that file's overviewTemplate/renderOperator/
// landingRegion/populatedOverview helpers (same package, same test binary).
// Live-state derivation is covered separately by setup_state_test.go's
// DB-backed tests.
// mixedSetupState is a representative checklist mid-progress: two steps
// done (entitlement-set, product), four still open, including the two
// whose relevance depends on the deployment's model (price-sync,
// org-type-default) and the price step's Stripe-not-configured note. Every
// step is equal now (design D12, round 2): no Required/Condition fields,
// no "Required" label, one tone per completion state.
func mixedSetupState() SetupState {
return SetupState{
Steps: []SetupStep{
{
Key: "integrations", Label: "Configure integrations",
Description: "Integrations deliver what a product confers through a connected service. Needed when a product delivers through a connected service.",
Complete: false,
Href: "/operator/integrations", LinkText: "Go to integrations",
Unlocks: "Selling or granting a product that delivers through that service.",
},
{
Key: "entitlement-set", Label: "Create an entitlement set",
Description: "Entitlement sets define what a product grants; every product needs one.",
Complete: true,
Href: "/operator/entitlement-sets", LinkText: "Go to entitlement sets",
Unlocks: "Creating a product that uses it.",
},
{
Key: "product", Label: "Create a product",
Description: "Products are what this deployment grants or sells; each product uses an entitlement set and is published as soon as it is created.",
Complete: true,
Href: "/operator/products", LinkText: "Go to products",
Unlocks: "Adding the product to a plan ladder, and selling it.",
},
{
Key: "price-sync", Label: "Add an active price and sync it to Stripe",
Description: "A price makes a product chargeable; syncing it creates the matching Stripe price. Needed only when products are sold for money.",
Complete: false,
Href: "/operator/products", LinkText: "Go to products",
Unlocks: "Charging members for this product.",
Note: "Stripe integration is not configured yet; missing stripe-api-key.",
NoteHref: "/operator/integrations/stripe/settings",
NoteLinkText: "Go to Stripe settings",
},
{
Key: "ladder", Label: "Build a plan ladder with this product as a tier",
Description: "Plan ladders group the products members move between; the rank-0 tier is the deployment's base plan and is what completes this step.",
Complete: false,
Href: "/operator/plan-ladders", LinkText: "Go to plan ladders",
Unlocks: "Choosing an org-type default plan.",
},
{
Key: "org-type-default", Label: "Choose an org-type default plan",
Description: "An org-type default plan is conferred automatically on signup. Needed only when signup should confer a plan automatically.",
Complete: false,
Href: "/operator/org-types", LinkText: "Go to org types",
},
},
}
}
func TestSetupChecklistPageRendersAllSteps(t *testing.T) {
out := renderChecklistPage(t, OperatorPageData{
IAPosition: "runtime:setup",
BodyTemplate: "operator_setup.html",
BodyData: mixedSetupState(),
})
for _, want := range []string{
"<h1 class=\"h2 mb-0\">Setup checklist</h1>",
"Configure integrations",
"Create an entitlement set",
"Create a product",
"Add an active price and sync it to Stripe",
"Build a plan ladder with this product as a tier",
"Choose an org-type default plan",
// A step whose relevance depends on the deployment's model says so
// in its own copy (design D12, round 2: the Condition class is
// gone).
"Needed when a product delivers through a connected service.",
"Needed only when products are sold for money.",
"Needed only when signup should confer a plan automatically.",
// Each incomplete step names the downstream step it feeds.
"Unlocks: Selling or granting a product that delivers through that service.",
"Unlocks: Charging members for this product.",
"Unlocks: Choosing an org-type default plan.",
// Links go straight to the surface where the step is completed.
`href="/operator/integrations"`,
`href="/operator/entitlement-sets"`,
`href="/operator/products"`,
`href="/operator/plan-ladders"`,
`href="/operator/org-types"`,
// The Stripe-configuration note on the price step, with its own link.
"Stripe integration is not configured yet",
`href="/operator/integrations/stripe/settings"`,
// design D12 round 2: one tone per completion state, no exceptions.
`<span class="badge text-bg-success">Done</span>`,
`<span class="badge text-bg-secondary">Incomplete</span>`,
} {
if !strings.Contains(out, want) {
t.Errorf("setup checklist page missing %q", want)
}
}
// The retired required/conditional vocabulary must not appear.
for _, forbidden := range []string{"Required", "Optional", "Not yet"} {
if strings.Contains(out, forbidden) {
t.Errorf("setup checklist page still uses the retired %q marker", forbidden)
}
}
// Two steps done (entitlement-set, product), four incomplete.
if n := strings.Count(out, `<span class="badge text-bg-success">Done</span>`); n != 2 {
t.Errorf("setup checklist page shows %d Done badges, want 2", n)
}
if n := strings.Count(out, `<span class="badge text-bg-secondary">Incomplete</span>`); n != 4 {
t.Errorf("setup checklist page shows %d Incomplete badges, want 4", n)
}
// A completed step's Unlocks line is not shown — it has nothing left to
// unlock.
if strings.Contains(out, "Unlocks: Creating a product that uses it.") {
t.Errorf("completed entitlement-set step still rendered its Unlocks line")
}
if strings.Contains(out, "Unlocks: Adding the product to a plan ladder, and selling it.") {
t.Errorf("completed product step still rendered its Unlocks line")
}
}
// /operator/setup always renders, including when every step is complete —
// it is persistent and revisitable, never a wizard that disappears once
// done.
func TestSetupChecklistPageRendersWhenEverythingComplete(t *testing.T) {
state := mixedSetupState()
for i := range state.Steps {
state.Steps[i].Complete = true
}
out := renderChecklistPage(t, OperatorPageData{
BodyTemplate: "operator_setup.html",
BodyData: state,
})
if !strings.Contains(out, "Setup checklist") {
t.Errorf("fully-complete checklist page dropped its own heading")
}
// design D12 round 2: every step renders "Done" in the SAME success
// tone — no split between a required and a conditional completion.
if n := strings.Count(out, `<span class="badge text-bg-success">Done</span>`); n != 6 {
t.Errorf("fully-complete checklist page shows %d success-tone Done badges, want 6 (every step)", n)
}
if strings.Contains(out, `text-bg-secondary">Done`) {
t.Errorf("fully-complete checklist page still renders a neutral-tone Done badge")
}
if strings.Contains(out, "Unlocks:") {
t.Errorf("fully-complete checklist page still names an unlock (every step is done)")
}
}
// Strict CSP: nothing on this surface may rely on an inline script, an
// inline event handler, or a style attribute.
func TestSetupChecklistIsCSPClean(t *testing.T) {
out := renderChecklistPage(t, OperatorPageData{
BodyTemplate: "operator_setup.html",
BodyData: mixedSetupState(),
})
if regexp.MustCompile(`(?i)<script(\s[^>]*)?>[^<]`).MatchString(out) {
t.Errorf("setup checklist contains an inline <script> body")
}
if m := regexp.MustCompile(`(?i)\son(click|load|change|submit|input|focus|error)\s*=`).FindString(out); m != "" {
t.Errorf("setup checklist contains an inline event handler: %q", strings.TrimSpace(m))
}
if regexp.MustCompile(`(?i)\sstyle\s*=\s*"`).MatchString(out) {
t.Errorf("setup checklist contains an inline style attribute")
}
}
// TestSetupBannerShowsWhileIncompleteAndNotDismissed covers design D12's
// "The overview carries a dismissible setup banner": while not dismissed
// and at least one step is incomplete, the banner renders above the
// lookup as the blue Bootstrap info alert (maintainer, 2026-09-03: "I back
// the blue", over a plain card, keeping round 4's copy, no step count)
// with a Continue setup link and a close control that posts to the
// dismiss route.
func TestSetupBannerShowsWhileIncompleteAndNotDismissed(t *testing.T) {
data := populatedOverview()
data.Setup = SetupState{Steps: []SetupStep{
{Key: "entitlement-set", Complete: true},
{Key: "ladder", Complete: false},
}}
out := landingRegion(t, renderOperator(t, data))
if !strings.Contains(out, `id="setup-banner"`) {
t.Fatalf("landing surface did not render the setup banner while a step is incomplete, got:\n%s", out)
}
if !strings.Contains(out, `class="alert alert-info`) {
t.Errorf("setup banner is not the blue info alert, got:\n%s", out)
}
wantLead := "<strong>Getting started.</strong> Finish setting up " + config.DeploymentName() + "."
if !strings.Contains(out, wantLead) {
t.Errorf("setup banner did not read the getting-started lead, got:\n%s", out)
}
if strings.Contains(out, "steps done") {
t.Errorf("setup banner still names a step count, got:\n%s", out)
}
if !strings.Contains(out, `<a href="/operator/setup" class="alert-link">Continue setup</a>`) {
t.Errorf("setup banner dropped its Continue setup link or the alert-link contrast class")
}
if !strings.Contains(out, `hx-post="/partials/operator/setup/dismiss"`) ||
!strings.Contains(out, `hx-target="#setup-banner"`) ||
!strings.Contains(out, `hx-swap="outerHTML"`) {
t.Errorf("setup banner dropped its dismiss control, got:\n%s", out)
}
}
// The banner never renders once it has been dismissed, even with
// incomplete steps remaining — dismissal is deployment-wide state, not
// re-derived per render.
func TestSetupBannerHiddenWhenDismissed(t *testing.T) {
data := populatedOverview()
data.Setup = SetupState{
Steps: []SetupStep{{Key: "ladder", Complete: false}},
Dismissed: true,
}
out := landingRegion(t, renderOperator(t, data))
if strings.Contains(out, `id="setup-banner"`) {
t.Errorf("dismissed setup banner still rendered, got:\n%s", out)
}
}
// A finished checklist withdraws the banner on its own, dismissed or not —
// it has nothing left to announce.
func TestSetupBannerHiddenWhenEveryStepDone(t *testing.T) {
data := populatedOverview()
data.Setup = SetupState{Steps: []SetupStep{
{Key: "entitlement-set", Complete: true},
{Key: "ladder", Complete: true},
}}
out := landingRegion(t, renderOperator(t, data))
if strings.Contains(out, `id="setup-banner"`) {
t.Errorf("finished setup still rendered the banner, got:\n%s", out)
}
}
// The banner renders ahead of the lookup (design D12: "SHALL render, above
// the lookup"; operator-panel-navigation's region order names the banner
// first).
func TestSetupBannerPrecedesLookup(t *testing.T) {
data := populatedOverview()
data.Setup = SetupState{Steps: []SetupStep{{Key: "ladder", Complete: false}}}
out := landingRegion(t, renderOperator(t, data))
bannerIdx := strings.Index(out, `id="setup-banner"`)
lookupIdx := strings.Index(out, `<section aria-label="Lookup">`)
if bannerIdx < 0 {
t.Fatal("landing surface did not render the setup banner")
}
if lookupIdx < 0 {
t.Fatal("landing surface did not render the lookup region")
}
if bannerIdx > lookupIdx {
t.Errorf("setup banner (%d) did not render ahead of the lookup (%d)", bannerIdx, lookupIdx)
}
}