- Restructure operator sidebar into a flat task list with indented children; fold plan topology into plan ladders - Expand member catalog non-plan section to all published non-tier products; require recurring Stripe-mapped prices for purchase - Add operator domains placements and terminal-claims ledger; redirect /domains to the FedWiki Sites Domains anchor - Apply canonical vocabulary and chrome/form conventions; migrate seeded FedWiki Sites display name
304 lines
12 KiB
Go
304 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"log/slog"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// acmeConfigs is a brand-neutral installed-integration fixture for the
|
|
// settings surface.
|
|
var acmeConfigs = []IntegrationConfigInfo{
|
|
{
|
|
Slug: "acme",
|
|
DisplayName: "Acme Widgets",
|
|
Keys: []config.ConfigKey{
|
|
{Name: "acme-widget-url", RequiredGroup: "Acme", Usage: "Widget service URL"},
|
|
{Name: "acme-widget-scheme", Default: "https", Enum: []string{"http", "https"}, Usage: "Widget URL scheme"},
|
|
{Name: "acme-widget-domains", Default: []string(nil), Usage: "Widget domains"},
|
|
{Name: "acme-widget-token", Secret: true, RequiredGroup: "Acme", Usage: "Widget admin token"},
|
|
},
|
|
},
|
|
}
|
|
|
|
func settingsTestHandler() *OperatorPartialsHandler {
|
|
return &OperatorPartialsHandler{
|
|
IntegrationConfigs: acmeConfigs,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
}
|
|
}
|
|
|
|
func TestIntegrationSettingsTemplate(t *testing.T) {
|
|
tmpl := parseOperatorPartials(t)
|
|
data := IntegrationSettingsData{
|
|
Slug: "acme",
|
|
DisplayName: "Acme Widgets",
|
|
SurfacePath: "/operator/integrations/acme",
|
|
Saved: "acme-widget-scheme",
|
|
Error: "value \"gopher\" is not one of http|https",
|
|
ErrorKey: "acme-widget-scheme",
|
|
Rows: []SettingRow{
|
|
{Key: "acme-widget-url", Usage: "Widget service URL", Kind: "string", Required: true,
|
|
Effective: "https://widgets.example.com", Source: "environment"},
|
|
{Key: "acme-widget-scheme", Usage: "Widget URL scheme", Kind: "enum", Enum: []string{"http", "https"},
|
|
Effective: "https", Source: "override", Override: "http", HasOverride: true, PendingRestart: true},
|
|
{Key: "acme-widget-domains", Usage: "Widget domains", Kind: "list",
|
|
Effective: "", Source: "default"},
|
|
{Key: "acme-widget-token", Usage: "Widget admin token", Secret: true, SecretSet: false},
|
|
{Key: "acme-widget-secondary-token", Usage: "Secondary widget token", Secret: true, SecretSet: true},
|
|
{Key: "acme-mode", Usage: "Processing mode", Kind: "enum", Enum: []string{"test", "live"},
|
|
Effective: "test", Source: "default",
|
|
Warning: "Selecting live moves this deployment onto real payment processing: charges become real money, not test transactions."},
|
|
},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_settings.html", data); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
out := buf.String()
|
|
for _, want := range []string{
|
|
"Acme Widgets settings",
|
|
`hx-post="/operator/integrations/acme/settings"`,
|
|
"Pending restart",
|
|
"Managed via environment",
|
|
`<option value="http" selected>`, // enum select preselects the override
|
|
"Override for <code>acme-widget-scheme</code> saved",
|
|
">Optional</span>", // required-is-default: only non-required keys carry a badge
|
|
`href="/operator/integrations/acme"`, // admin-page cross-link
|
|
`src="/static/settings-flash.js"`, // one-shot banner param strip
|
|
`id="key-acme-widget-scheme"`, // row anchor for error banner link
|
|
`href="#key-acme-widget-scheme"`, // banner links to the offending row
|
|
"is not one of http|https", // row-level invalid-feedback text
|
|
"Not set", // absent secret: presence, not the masked treatment
|
|
`Set <span aria-hidden="true">`, // present secret: masked but visibly distinct from "Not set"
|
|
"real payment processing", // stripe-mode-style consequence warning, adjacent to its control
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("settings page missing %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(out, `name="value"`) && strings.Contains(out, "acme-widget-token\" value=") {
|
|
t.Error("secret key must not render an input with a value")
|
|
}
|
|
for _, forbidden := range []string{"hunter2"} {
|
|
if strings.Contains(out, forbidden) {
|
|
t.Errorf("secret value leaked into page: %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIntegrationsLandingUnifiedTable(t *testing.T) {
|
|
tmpl := parseOperatorPartials(t)
|
|
data := IntegrationsData{
|
|
Integrations: []IntegrationRow{
|
|
{Slug: "acme", DisplayName: "Acme Widgets", Kind: "provisioning", Status: "active",
|
|
SurfacePath: "/operator/integrations/acme", SettingsPath: "/operator/integrations/acme/settings",
|
|
Configured: true},
|
|
{Slug: "acmepay", DisplayName: "Acme Pay", Kind: "payment", Status: "active",
|
|
SettingsPath: "/operator/integrations/acmepay/settings",
|
|
Configured: false, MissingKeysText: "acmepay-api-key, acmepay-webhook-secret"},
|
|
},
|
|
Orphans: []OrphanOverrideRow{{Key: "retired-key", Value: "leftover"}},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_integrations.html", data); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
out := buf.String()
|
|
for _, want := range []string{
|
|
`href="/operator/integrations/acme/settings"`,
|
|
`href="/operator/integrations/acmepay/settings"`, // payments row gets Settings despite no admin surface
|
|
`href="/operator/integrations/acme"`,
|
|
"Acme Pay", // non-provisioning integration has a full row
|
|
"Registry status", // honest label; not read as a health check
|
|
"Not configured", // acmepay's readiness signal
|
|
`title="Missing: acmepay-api-key, acmepay-webhook-secret"`, // names the unresolved keys
|
|
"Unrecognized overrides",
|
|
`data-action-url="/operator/integrations/config-orphans/retired-key"`,
|
|
`data-action-method="delete"`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("landing missing %q", want)
|
|
}
|
|
}
|
|
// A fully-configured integration must not double-claim it too.
|
|
if n := strings.Count(out, "Not configured"); n != 1 {
|
|
t.Errorf("landing shows %d \"Not configured\" markers, want exactly 1 (Acme Pay only)", n)
|
|
}
|
|
if strings.Contains(out, "connected to the member console") {
|
|
t.Errorf("landing copy still implies connectivity")
|
|
}
|
|
}
|
|
|
|
func TestPlanLadderValidationMalformedRanks(t *testing.T) {
|
|
tmpl := parseOperatorPartials(t)
|
|
data := PlanLadderValidationData{
|
|
MalformedRankLadders: []MalformedRankLadderViewModel{
|
|
{LadderID: "11111111-1111-7111-8111-111111111111", Name: "Acme Ladder", MinRank: 1, MaxRank: 5, TierCount: 2},
|
|
},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_plan_ladder_validation.html", data); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
out := buf.String()
|
|
for _, want := range []string{
|
|
"Malformed Rank Sequences",
|
|
"Acme Ladder",
|
|
"1–5",
|
|
`href="/operator/plan-ladders/11111111-1111-7111-8111-111111111111"`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("validation page missing %q", want)
|
|
}
|
|
}
|
|
|
|
// Empty case renders the all-clear, not an empty table.
|
|
buf.Reset()
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_plan_ladder_validation.html", PlanLadderValidationData{}); err != nil {
|
|
t.Fatalf("render empty: %v", err)
|
|
}
|
|
if !strings.Contains(buf.String(), "All ladders have contiguous 0-based ranks") {
|
|
t.Error("validation page missing all-clear for rank check")
|
|
}
|
|
}
|
|
|
|
func TestOperatorNotFoundTemplate(t *testing.T) {
|
|
tmpl := parseOperatorPartials(t)
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_not_found.html", OperatorNotFoundData{Path: "/operator/integrations/acmepay"}); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "Page not found") || !strings.Contains(out, "/operator/integrations/acmepay") {
|
|
t.Errorf("404 body missing heading or path: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestGetIntegrationSettingsPageUnknownSlug(t *testing.T) {
|
|
h := settingsTestHandler()
|
|
r := httptest.NewRequest("GET", "/operator/integrations/nope/settings", nil)
|
|
r.SetPathValue("slug", "nope")
|
|
w := httptest.NewRecorder()
|
|
h.GetIntegrationSettingsPage(w, r)
|
|
if w.Code != 404 {
|
|
t.Fatalf("unknown slug: want 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// postSetting drives PostIntegrationSetting with a form body and returns the
|
|
// HX-Redirect target. All cases below fail validation before any database
|
|
// access, which is why the handler needs no DB here.
|
|
func postSetting(t *testing.T, form url.Values) string {
|
|
t.Helper()
|
|
h := settingsTestHandler()
|
|
r := httptest.NewRequest("POST", "/operator/integrations/acme/settings", strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.SetPathValue("slug", "acme")
|
|
w := httptest.NewRecorder()
|
|
h.PostIntegrationSetting(w, r)
|
|
if w.Code != 200 {
|
|
t.Fatalf("want 200 + HX-Redirect, got %d", w.Code)
|
|
}
|
|
return w.Header().Get("HX-Redirect")
|
|
}
|
|
|
|
func TestPostIntegrationSettingValidation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
form url.Values
|
|
wantErr string
|
|
}{
|
|
{"unknown key rejected",
|
|
url.Values{"action": {"save"}, "key": {"acme-unheard-of"}, "value": {"x"}},
|
|
"Unknown+configuration+key"},
|
|
{"secret key rejected",
|
|
url.Values{"action": {"save"}, "key": {"acme-widget-token"}, "value": {"hunter2"}},
|
|
"Secret+keys+are+managed+via+the+environment"},
|
|
{"enum non-member rejected",
|
|
url.Values{"action": {"save"}, "key": {"acme-widget-scheme"}, "value": {"gopher"}},
|
|
"not+one+of"},
|
|
{"empty value rejected",
|
|
url.Values{"action": {"save"}, "key": {"acme-widget-scheme"}, "value": {""}},
|
|
"Provide+a+value"},
|
|
{"unknown action rejected",
|
|
url.Values{"action": {"detonate"}, "key": {"acme-widget-scheme"}, "value": {"http"}},
|
|
"Unknown+action"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
redirect := postSetting(t, tc.form)
|
|
if !strings.HasPrefix(redirect, "/operator/integrations/acme/settings?error=") {
|
|
t.Fatalf("want error redirect, got %q", redirect)
|
|
}
|
|
if !strings.Contains(redirect, tc.wantErr) {
|
|
t.Errorf("redirect %q missing %q", redirect, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSettingKindAndNormalize(t *testing.T) {
|
|
if k := settingKind(config.ConfigKey{Enum: []string{"a"}}); k != "enum" {
|
|
t.Errorf("enum kind: got %q", k)
|
|
}
|
|
if k := settingKind(config.ConfigKey{Default: []string(nil)}); k != "list" {
|
|
t.Errorf("list kind: got %q", k)
|
|
}
|
|
if k := settingKind(config.ConfigKey{Default: true}); k != "bool" {
|
|
t.Errorf("bool kind: got %q", k)
|
|
}
|
|
if k := settingKind(config.ConfigKey{Default: "x"}); k != "string" {
|
|
t.Errorf("string kind: got %q", k)
|
|
}
|
|
got := normalizedOverride(config.ConfigKey{Default: []string(nil)}, " a.example , b.example ,")
|
|
if got != "a.example,b.example" {
|
|
t.Errorf("normalizedOverride: got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestConfigurationReadiness covers the shared signal both the Integrations
|
|
// list and the overview's System region render (design decision 2,
|
|
// ux-honest-surfaces): it must derive from the exact same required-key
|
|
// resolution the settings page itself uses (acmeConfigs' "Acme"
|
|
// RequiredGroup spans one secret and one non-secret key, mirroring how
|
|
// Stripe/FedWiki/Discourse declare theirs).
|
|
func TestConfigurationReadiness(t *testing.T) {
|
|
t.Run("nothing resolved reports both missing keys", func(t *testing.T) {
|
|
viper.Reset()
|
|
configured, missing := configurationReadiness(acmeConfigs, "acme")
|
|
if configured {
|
|
t.Errorf("configured = true, want false with nothing set")
|
|
}
|
|
want := []string{"acme-widget-url", "acme-widget-token"}
|
|
if len(missing) != len(want) || missing[0] != want[0] || missing[1] != want[1] {
|
|
t.Errorf("missing = %v, want %v", missing, want)
|
|
}
|
|
})
|
|
|
|
t.Run("fully resolved (including the secret) reports configured", func(t *testing.T) {
|
|
viper.Reset()
|
|
viper.Set("acme-widget-url", "https://widgets.example.com")
|
|
viper.Set("acme-widget-token", "sekret") // secret presence only, never displayed
|
|
configured, missing := configurationReadiness(acmeConfigs, "acme")
|
|
if !configured || len(missing) != 0 {
|
|
t.Errorf("configured, missing = %v, %v, want true, none", configured, missing)
|
|
}
|
|
})
|
|
|
|
t.Run("a slug with no declared config is vacuously configured", func(t *testing.T) {
|
|
viper.Reset()
|
|
configured, missing := configurationReadiness(acmeConfigs, "unknown-provider")
|
|
if !configured || missing != nil {
|
|
t.Errorf("configured, missing = %v, %v, want true, nil (nothing required)", configured, missing)
|
|
}
|
|
})
|
|
}
|