- 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
329 lines
13 KiB
Go
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)
|
|
}
|
|
}
|