Files
member-console/internal/server/operator_integration_settings_test.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

962 lines
41 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"bytes"
"context"
"database/sql"
"io"
"log/slog"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/alexedwards/scs/v2"
"github.com/spf13/viper"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
)
// acmeConfigs is a brand-neutral installed-integration fixture for the
// settings surface.
var acmeConfigs = []IntegrationConfigInfo{
{
Key: "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-sync-enabled", Default: false, Usage: "Sync widgets periodically"},
{Name: "acme-widget-token", Secret: true, RequiredGroup: "Acme", Usage: "Widget admin token"},
},
},
}
// settingsBootOnce guards the boot-effective snapshot the settings handler
// reads. A running console fills it during boot, from the declared defaults
// and whatever the environment set; a test binary never boots, and an empty
// snapshot gives every row an empty value in force, which no deployment can
// produce and which a select offering only real values cannot submit. The
// fixture stands acme-widget-url up as an environment-set key and leaves
// the rest on their declared defaults, matching settingsTestRows.
var settingsBootOnce sync.Once
func seedSettingsBoot(t *testing.T) {
t.Helper()
settingsBootOnce.Do(func() {
for _, key := range acmeConfigs[0].Keys {
if key.Secret || key.Default == nil {
continue
}
viper.SetDefault(key.Name, key.Default)
}
viper.Set("acme-widget-url", "https://widgets.example.com")
if err := config.ApplyOverlay(acmeConfigs[0].Keys, nil); err != nil {
t.Fatalf("seed the boot-effective snapshot: %v", err)
}
})
}
// settingsSubmission is one full page save: every control on the settings
// table comes back on every save, so a body that leaves one out is not a
// save a browser could make. It starts from the values in force and applies
// the changes the test names.
func settingsSubmission(changes map[string]string) url.Values {
form := url.Values{
"acme-widget-url": {"https://widgets.example.com"},
"acme-widget-scheme": {"https"},
}
for name, value := range changes {
form.Set(name, value)
}
return form
}
func settingsTestHandler() *OperatorPartialsHandler {
return &OperatorPartialsHandler{
IntegrationConfigs: acmeConfigs,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
}
// settingsTestHandlerDB is settingsTestHandler with a real (scratch) database
// and a loaded (unauthenticated) session behind it, for the POST path:
// loadSettingRows reads the stored overrides before the declaration can be
// built, so a save, a clear or a refusal that reaches that point all need a
// database, unlike the render-only tests; PostIntegrationSetting also reads
// the session for the override's UpdatedBy, which panics on a nil
// AuthConfig. newRollbackTestDB (member_domains_rollback_db_test.go) is this
// package's own DB-backed test helper; core.integration_config_overrides is
// a core migration, so its narrower core+domains source list still covers it.
func settingsTestHandlerDB(t *testing.T) (*OperatorPartialsHandler, *sql.DB, context.Context) {
t.Helper()
seedSettingsBoot(t)
database := newRollbackTestDB(t)
// The test database is shared and nothing rolls it back, so each
// settings test starts and ends with no override of its own fixture's
// keys; every other package's rows are left alone.
clean := func() {
if _, err := database.ExecContext(context.Background(),
`DELETE FROM core.integration_config_overrides WHERE key LIKE 'acme-%'`); err != nil {
t.Fatalf("clear overrides: %v", err)
}
}
clean()
t.Cleanup(clean)
h := settingsTestHandler()
h.Database = database
// The 422 path renders operator_integration_settings.html directly
// (never operator.html's full shell); parseOperatorPartials builds the
// same partial set the real handler does.
h.Templates = NewSafeTemplates(parseOperatorPartials(t), h.Logger)
sm := scs.New()
ctx, err := sm.Load(context.Background(), "")
if err != nil {
t.Fatalf("load session: %v", err)
}
h.AuthConfig = &auth.Config{SessionManager: sm}
return h, database, ctx
}
// settingsTestRows is the fixture TestIntegrationSettingsTemplate and the
// POST-side tests share: one required string key, one overridden enum key
// pending restart, one unset list key, one bool key that is on, two secret
// keys (set and unset), and the stripe-mode-shaped consequence warning.
func settingsTestRows() []SettingRow {
return []SettingRow{
{Key: "acme-widget-url", IntegrationKey: "acme", Usage: "Widget service URL", Kind: "string", Required: true,
Effective: "https://widgets.example.com", Source: "environment"},
{Key: "acme-widget-scheme", IntegrationKey: "acme", Usage: "Widget URL scheme", Kind: "enum", Enum: []string{"http", "https"},
Effective: "https", Source: "override", Override: "http", HasOverride: true, PendingRestart: true},
{Key: "acme-widget-domains", IntegrationKey: "acme", Usage: "Widget domains", Kind: "list",
Effective: "", Source: "default"}, // unset plain key: renders "Not set", not an em dash
{Key: "acme-widget-sync-enabled", IntegrationKey: "acme", Usage: "Sync widgets periodically", Kind: "bool",
Effective: "true", Source: "environment"},
{Key: "acme-widget-token", IntegrationKey: "acme", Usage: "Widget admin token", Secret: true, SecretSet: false},
{Key: "acme-widget-secondary-token", IntegrationKey: "acme", Usage: "Secondary widget token", Secret: true, SecretSet: true},
{Key: "acme-mode", IntegrationKey: "acme", 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."},
}
}
// settingsPageBody renders the settings page from its rows the way the
// handler does: one declaration in the Table family, bound to the values
// in force, with the page's own Effective value and Source cells.
func settingsPageBody(t *testing.T, rows []SettingRow) string {
t.Helper()
tmpl := parseOperatorPartials(t)
st := NewSafeTemplates(tmpl, slog.New(slog.NewTextHandler(io.Discard, nil)))
data := IntegrationSettingsData{
Key: "acme",
DisplayName: "Acme Widgets",
SurfacePath: "/operator/integrations/acme",
Form: forms.Render(settingsFormFor(rows), forms.Binding{
Mode: forms.ModeRecord,
Action: "/operator/integrations/acme/settings",
Values: settingsValues(rows),
Cells: settingsCells(st, rows),
ControlFooters: settingsControlFooters(st, rows),
}),
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_settings.html", data); err != nil {
t.Fatalf("render: %v", err)
}
return buf.String()
}
// The settings page is one table with one Save (design D21): the key and
// its usage in the first column, the effective value and the winning
// source in their own columns, the control last, and every secret key a
// static row inside the same table rather than a second table below it.
func TestIntegrationSettingsTemplate(t *testing.T) {
out := settingsPageBody(t, settingsTestRows())
for _, want := range []string{
"Acme Widgets settings",
`data-form="operator.integration.settings"`,
`hx-post="/operator/integrations/acme/settings"`,
"Pending restart",
">Key</th>", ">Effective value</th>", ">Source</th>", ">Override</th>",
`<code>https://widgets.example.com</code>`, // the effective value is a column, not a hint
`<option value="http" selected>`, // the control shows the override in force
`<option value="test" selected>`, // and the effective value when no override is stored
`href="/operator/integrations/acme"`, // admin-page cross-link
"Not set", // absent secret: presence, not the masked treatment
`<span class="text-muted">Set</span>`, // present secret: "Set", distinct from "Not set" (integration-settings "Unset keys read Not set")
"real payment processing", // stripe-mode-style consequence warning, in its row
"Set acme-widget-token (or acme-widget-token-file) in the environment.", // the secret's own static row
`btn btn-primary">`, // the form's sole commit is filled (design D19)
} {
if !strings.Contains(out, want) {
t.Errorf("settings page missing %q", want)
}
}
if strings.Contains(out, `name="acme-widget-token"`) {
t.Error("secret key must not render a control at all")
}
for _, forbidden := range []string{
"hunter2", "All fields are required", "—",
`placeholder="https://widgets.example.com"`,
"Currently:", // the Effective column carries the fact now (design D21)
"(optional)", // every setting is optional by nature; the marker said nothing
`<option value="">`, // no empty choice: a control sets a value (design D21 round 5)
">None<",
"Secret keys are managed via the environment and never shown.",
} {
if strings.Contains(out, forbidden) {
t.Errorf("settings page rendered forbidden content: %q", forbidden)
}
}
// One table, not two: the secret rows live in the same one.
if n := strings.Count(out, "<table"); n != 1 {
t.Errorf("settings page renders %d tables, want 1 (design D21)", n)
}
// Every unset key reads "Not set", never an em dash (integration-settings:
// "Unset keys read 'Not set'"): the unset secret, and the unset list key
// whose Effective column has nothing to show.
if n := strings.Count(out, "Not set"); n != 2 {
t.Errorf(`settings page shows %d "Not set" markers, want 2 (the unset secret and the unset list key), got:\n%s`, n, out)
}
}
// Every control on the settings table sets a value and nothing else, and
// removing a stored override is the row's own action (design D21 round 5,
// after the maintainer's 2026-09-05 reading of the FedWiki page: "Picking
// None maybe shouldn't even be possible and it basically just gets ignored
// on save... fedwiki-sync-enabled is also odd").
func TestSettingsControlsSetValuesOnly(t *testing.T) {
out := settingsPageBody(t, settingsTestRows())
// An enum key offers its declared members and nothing else, bound to
// the value in force.
for _, want := range []string{
`<option value="http" selected>`,
`<option value="https">`,
`<option value="test" selected>`,
`<option value="live">`,
} {
if !strings.Contains(out, want) {
t.Errorf("enum row missing %q", want)
}
}
if n := strings.Count(out, "<option"); n != 4 {
t.Errorf("settings page renders %d options, want 4 (two enum keys, two members each)", n)
}
// A bool key is a checkbox, ticked from the value in force, with no
// second label: the table's label column already names it.
checkbox := `<input class="form-check-input" type="checkbox" ` +
`id="form-operator.integration.settings-acme-widget-sync-enabled-control" ` +
`name="acme-widget-sync-enabled" value="true" checked`
if !strings.Contains(out, checkbox) {
t.Errorf("bool row is not a checkbox ticked from the effective value, got:\n%s", out)
}
if strings.Contains(out, "form-check-label") {
t.Error("the table places the label itself; the control cell must not render a second one")
}
// Clear belongs to the row that has a stored override, and to no other,
// rendered under its control on one line with the Pending restart badge
// (design D9, typed-config-keys): the Override cell, not the Source
// cell any more.
clear := `hx-delete="/operator/integrations/acme/settings/acme-widget-scheme"`
if !strings.Contains(out, clear) {
t.Errorf("the overridden row is missing its Clear, got:\n%s", out)
}
if n := strings.Count(out, "hx-delete="); n != 1 {
t.Errorf("settings page renders %d Clear buttons, want 1 (only the overridden row has an override to remove)", n)
}
if !strings.Contains(out, `class="btn btn-outline-secondary btn-sm"`) {
t.Error("Clear is a small outline button")
}
}
// A refused save keeps every submitted value and puts each reason under
// the control it belongs to, in that row's own cell (design D21, D9).
func TestIntegrationSettingsRefusalStaysInItsRow(t *testing.T) {
rows := settingsTestRows()
tmpl := parseOperatorPartials(t)
st := NewSafeTemplates(tmpl, slog.New(slog.NewTextHandler(io.Discard, nil)))
values := settingsValues(rows)
values.Set("acme-widget-url", "not-a-url")
errs := forms.NewErrors()
errs.Field("acme-widget-url", "Enter a valid URL.")
view := forms.Render(settingsFormFor(rows), forms.Binding{
Mode: forms.ModeSubmission,
Action: "/operator/integrations/acme/settings",
Values: values,
Cells: settingsCells(st, rows),
ControlFooters: settingsControlFooters(st, rows),
Errors: errs,
})
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_settings.html", IntegrationSettingsData{
Key: "acme", DisplayName: "Acme Widgets", Form: view,
}); err != nil {
t.Fatalf("render: %v", err)
}
out := buf.String()
for _, want := range []string{
`value="not-a-url"`,
`id="form-operator.integration.settings-acme-widget-url-error" class="invalid-feedback d-block">Enter a valid URL.`,
} {
if !strings.Contains(out, want) {
t.Errorf("refused settings page missing %q, got:\n%s", want, out)
}
}
}
// TestSettingsOverrideCellThreeStates covers design D9 (typed-config-keys,
// the integration-settings delta): the Override cell holds Clear and the
// Pending restart badge under the control, on one line, and the Effective
// and Source cells hold their own subject alone, never a fact that
// describes the stored override rather than the running value.
func TestSettingsOverrideCellThreeStates(t *testing.T) {
tmpl := parseOperatorPartials(t)
st := NewSafeTemplates(tmpl, slog.New(slog.NewTextHandler(io.Discard, nil)))
noOverride := SettingRow{Key: "acme-widget-url", IntegrationKey: "acme",
Effective: "https://widgets.example.com", Source: "environment"}
savedAndPending := SettingRow{Key: "acme-widget-scheme", IntegrationKey: "acme",
Effective: "https", Source: "override", Override: "http", HasOverride: true, PendingRestart: true}
clearedAndRunning := SettingRow{Key: "acme-widget-domains", IntegrationKey: "acme",
Effective: "a.example", Source: "override", PendingRestart: true}
rows := []SettingRow{noOverride, savedAndPending, clearedAndRunning}
footers := settingsControlFooters(st, rows)
cells := settingsCells(st, rows)
t.Run("no override: the Override cell is empty", func(t *testing.T) {
if got := strings.TrimSpace(string(footers[noOverride.Key])); got != "" {
t.Errorf("Override cell = %q, want empty", got)
}
})
t.Run("override saved and pending: Clear and the badge, on one line, under the control", func(t *testing.T) {
got := string(footers[savedAndPending.Key])
if !strings.Contains(got, `hx-delete="/operator/integrations/acme/settings/acme-widget-scheme"`) {
t.Errorf("Override cell missing Clear: %s", got)
}
if !strings.Contains(got, "Pending restart") {
t.Errorf("Override cell missing the Pending restart badge: %s", got)
}
})
t.Run("override cleared and still running: the badge, no Clear", func(t *testing.T) {
got := string(footers[clearedAndRunning.Key])
if strings.Contains(got, "hx-delete=") {
t.Errorf("a cleared row must not offer Clear: %s", got)
}
if !strings.Contains(got, "Pending restart") {
t.Errorf("Override cell missing the Pending restart badge: %s", got)
}
})
t.Run("the Effective and Source cells never carry Clear or the Pending badge", func(t *testing.T) {
for _, row := range rows {
for i, cell := range cells[row.Key] {
got := string(cell)
if strings.Contains(got, "hx-delete=") || strings.Contains(got, "Pending restart") {
t.Errorf("%s cell %d carries a fact that belongs to the Override cell: %s", row.Key, i, got)
}
}
}
})
}
// The per-request declaration settingsFormFor builds satisfies every
// structural invariant: the settings kind is in the table family, secret
// keys are static rows and static rows exist only in a table, selects
// carry their options, every field has a label and a unique name, and no
// string carries an em dash (design D21).
//
// It does not satisfy the two hint-copy rules, and cannot: both the label
// (the key) and the hint (the integration's declared Usage, plus any
// consequence Warning) come from the integration's own ConfigSpec, not
// from console copy, so a usage line that spells its key out reads as a
// restatement and a consequence sentence runs past the hint cap. Those two
// rules are for authored copy; this test names the boundary rather than
// letting it drift silently.
func TestSettingsFormForSatisfiesTheStructuralInvariants(t *testing.T) {
for _, problem := range forms.CheckInvariants(settingsFormFor(settingsTestRows())) {
if strings.Contains(problem, "hint restates its label") ||
strings.Contains(problem, "over the 100-character cap") {
continue
}
t.Errorf("the settings declaration breaks a structural invariant: %s", problem)
}
}
func TestIntegrationsLandingUnifiedTable(t *testing.T) {
tmpl := parseOperatorPartials(t)
data := IntegrationsData{
Integrations: []IntegrationRow{
{Key: "acme", DisplayName: "Acme Widgets", Kind: "provisioning", Status: "active",
SurfacePath: "/operator/integrations/acme", SettingsPath: "/operator/integrations/acme/settings",
Configured: true},
{Key: "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
">Status</th>", // one honest Status column (integration-settings)
"Not configured", // acmepay's readiness signal
"Configured", // acme's readiness signal (Stripe-style dashes retired, ACC-6)
`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")
}
for _, banned := range []string{">Manage<", "Registry status", ">Configuration<"} {
if strings.Contains(out, banned) {
t.Errorf("landing still renders %q (integration-settings: one Status column, no Manage control)", banned)
}
}
}
// TestPlanLadderValidationMalformedRanks and the structural-validation page
// it rendered were removed in acceptance-fixes round 2 (design D8,
// plan-ladder-management "Structural invariant validation view"): the one
// check it ran is impossible under the database's exclusion constraint.
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 TestGetIntegrationSettingsPageUnknownKey(t *testing.T) {
h := settingsTestHandler()
r := httptest.NewRequest("GET", "/operator/integrations/nope/settings", nil)
r.SetPathValue("integrationKey", "nope")
w := httptest.NewRecorder()
h.GetIntegrationSettingsPage(w, r)
if w.Code != 404 {
t.Fatalf("unknown key: want 404, got %d", w.Code)
}
}
// saveSettings drives PostIntegrationSetting with a form body and returns
// the response.
func saveSettings(t *testing.T, h *OperatorPartialsHandler, ctx context.Context, form url.Values) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("POST", "/operator/integrations/acme/settings", strings.NewReader(form.Encode())).WithContext(ctx)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.SetPathValue("integrationKey", "acme")
w := httptest.NewRecorder()
h.PostIntegrationSetting(w, r)
return w
}
// postSetting is saveSettings against a fresh scratch-database handler.
func postSetting(t *testing.T, changes map[string]string) *httptest.ResponseRecorder {
t.Helper()
h, _, ctx := settingsTestHandlerDB(t)
return saveSettings(t, h, ctx, settingsSubmission(changes))
}
// TestPostIntegrationSettingValidation covers the refusals a submission can
// carry (spec integration-settings, "A refused value stays on the form"):
// every one answers 422 with the reason under its own field, never an
// HX-Redirect and never a query string (finding FA-24). An undeclared field
// name (an unknown key, or a secret key's value) is simply not part of the
// declaration, so Parse ignores it rather than refusing it (design D6).
func TestPostIntegrationSettingValidation(t *testing.T) {
t.Run("enum non-member rejected", func(t *testing.T) {
w := postSetting(t, map[string]string{"acme-widget-scheme": "gopher"})
if w.Code != 422 {
t.Fatalf("status = %d, want 422: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "Choose") {
t.Errorf("refusal missing the field's own reason: %s", body)
}
if w.Header().Get("HX-Redirect") != "" {
t.Error("a refusal must not carry HX-Redirect")
}
})
t.Run("a secret key's submitted value is ignored, not refused", func(t *testing.T) {
w := postSetting(t, map[string]string{"acme-widget-token": "hunter2"})
if w.Code != 303 {
t.Fatalf("status = %d, want 303 (nothing declared changed): %s", w.Code, w.Body.String())
}
if strings.Contains(w.Header().Get("Location"), "hunter2") {
t.Error("a secret value must never round-trip anywhere, including the redirect")
}
})
t.Run("an unknown key is ignored, not refused", func(t *testing.T) {
w := postSetting(t, map[string]string{"acme-unheard-of": "x"})
if w.Code != 303 {
t.Fatalf("status = %d, want 303 (an undeclared name changes nothing): %s", w.Code, w.Body.String())
}
})
t.Run("an emptied text control with no override is refused", func(t *testing.T) {
// A control sets a value; emptying one is not how an override is
// removed, and this row has none to remove anyway (design D21
// round 5).
w := postSetting(t, map[string]string{"acme-widget-url": ""})
if w.Code != 422 {
t.Fatalf("status = %d, want 422: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Enter a value.") {
t.Errorf("refusal missing the row's own reason: %s", w.Body.String())
}
if w.Header().Get("HX-Redirect") != "" {
t.Error("a refusal must not carry HX-Redirect")
}
})
t.Run("an emptied text control over an override is refused, and the override survives", func(t *testing.T) {
h, database, ctx := settingsTestHandlerDB(t)
if _, err := database.ExecContext(context.Background(),
`INSERT INTO core.integration_config_overrides (key, value) VALUES ('acme-widget-url', 'https://other.example.com')`); err != nil {
t.Fatalf("seed override: %v", err)
}
w := saveSettings(t, h, ctx, settingsSubmission(map[string]string{"acme-widget-url": ""}))
if w.Code != 422 {
t.Fatalf("status = %d, want 422: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Enter a value, or clear the override.") {
t.Errorf("refusal must name the way out this row has: %s", w.Body.String())
}
var n int
if err := database.QueryRowContext(context.Background(),
`SELECT count(*) FROM core.integration_config_overrides WHERE key = 'acme-widget-url'`).Scan(&n); err != nil {
t.Fatalf("count overrides: %v", err)
}
if n != 1 {
t.Errorf("override rows for acme-widget-url = %d, want 1 (emptying a control removes nothing)", n)
}
})
t.Run("an unticked checkbox is false, and false is what was in force", func(t *testing.T) {
// The bool row's control is a checkbox, so a save that leaves it
// alone sends nothing for it. Absence reads as false, which is the
// value in force for a key with no override and a false default,
// so the no-change rule holds and nothing is written (design D21
// round 5).
h, database, ctx := settingsTestHandlerDB(t)
w := saveSettings(t, h, ctx, settingsSubmission(nil))
if w.Code != 303 {
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body.String())
}
var n int
if err := database.QueryRowContext(context.Background(),
`SELECT count(*) FROM core.integration_config_overrides WHERE key LIKE 'acme-%'`).Scan(&n); err != nil {
t.Fatalf("count overrides: %v", err)
}
if n != 0 {
t.Errorf("override rows = %d, want 0 (an untouched page writes nothing)", n)
}
})
t.Run("a ticked checkbox is saved", func(t *testing.T) {
h, database, ctx := settingsTestHandlerDB(t)
w := saveSettings(t, h, ctx, settingsSubmission(map[string]string{"acme-widget-sync-enabled": "true"}))
if w.Code != 303 {
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body.String())
}
var value string
if err := database.QueryRowContext(context.Background(),
`SELECT value FROM core.integration_config_overrides WHERE key = 'acme-widget-sync-enabled'`).Scan(&value); err != nil {
t.Fatalf("read override: %v", err)
}
if value != "true" {
t.Errorf("stored override = %q, want %q", value, "true")
}
})
t.Run("a valid change is saved and lands with the flash toast", func(t *testing.T) {
w := postSetting(t, map[string]string{"acme-widget-scheme": "http"})
if w.Code != 303 {
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body.String())
}
loc := w.Header().Get("Location")
if loc != "/operator/integrations/acme/settings?flash=saved" {
t.Errorf("Location = %q, want the settings page with the flash toast", loc)
}
})
}
// clearOverride drives DeleteIntegrationSettingOverride against a fresh
// scratch-database handler.
func clearOverride(t *testing.T, h *OperatorPartialsHandler, integrationKey, key string, htmx bool) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("DELETE", "/operator/integrations/"+integrationKey+"/settings/"+key, nil)
r.SetPathValue("integrationKey", integrationKey)
r.SetPathValue("key", key)
if htmx {
r.Header.Set("HX-Request", "true")
}
w := httptest.NewRecorder()
h.DeleteIntegrationSettingOverride(w, r)
return w
}
// Clear is the row's own removal of a stored override: an action trigger on
// the key's route, answering the navigating success every settings write
// answers with (design D9, D21 round 5).
func TestDeleteIntegrationSettingOverride(t *testing.T) {
seed := func(t *testing.T) (*OperatorPartialsHandler, *sql.DB) {
t.Helper()
h, database, _ := settingsTestHandlerDB(t)
if _, err := database.ExecContext(context.Background(),
`INSERT INTO core.integration_config_overrides (key, value) VALUES ('acme-widget-scheme', 'http')`); err != nil {
t.Fatalf("seed override: %v", err)
}
return h, database
}
t.Run("an htmx trigger gets HX-Redirect and the row is gone", func(t *testing.T) {
h, database := seed(t)
w := clearOverride(t, h, "acme", "acme-widget-scheme", true)
if w.Code != 200 {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
if got := w.Header().Get("HX-Redirect"); got != "/operator/integrations/acme/settings?flash=cleared" {
t.Errorf("HX-Redirect = %q, want the settings page with the cleared toast", got)
}
var n int
if err := database.QueryRowContext(context.Background(),
`SELECT count(*) FROM core.integration_config_overrides WHERE key = 'acme-widget-scheme'`).Scan(&n); err != nil {
t.Fatalf("count overrides: %v", err)
}
if n != 0 {
t.Errorf("override rows for acme-widget-scheme = %d, want 0 after Clear", n)
}
})
t.Run("a native trigger gets the 303", func(t *testing.T) {
h, _ := seed(t)
w := clearOverride(t, h, "acme", "acme-widget-scheme", false)
if w.Code != 303 {
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body.String())
}
if got := w.Header().Get("Location"); got != "/operator/integrations/acme/settings?flash=cleared" {
t.Errorf("Location = %q, want the settings page with the cleared toast", got)
}
})
t.Run("nothing at that address answers 404", func(t *testing.T) {
h, _ := seed(t)
for name, call := range map[string]func() *httptest.ResponseRecorder{
"an integration this console does not run": func() *httptest.ResponseRecorder {
return clearOverride(t, h, "nope", "acme-widget-scheme", true)
},
"a key that integration does not declare": func() *httptest.ResponseRecorder {
return clearOverride(t, h, "acme", "acme-unheard-of", true)
},
"a secret key, which never has an override": func() *httptest.ResponseRecorder {
return clearOverride(t, h, "acme", "acme-widget-token", true)
},
"a key with no stored override": func() *httptest.ResponseRecorder {
return clearOverride(t, h, "acme", "acme-widget-url", true)
},
} {
if w := call(); w.Code != 404 {
t.Errorf("%s: status = %d, want 404", name, w.Code)
}
}
})
}
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)
}
// A url, duration or int key renders the same text shape a bare string
// does (typed-config-keys design D1, D4): settingsPlaceholder is what
// tells them apart on the page, not settingKind.
if k := settingKind(config.ConfigKey{Type: config.TypeURL}); k != "string" {
t.Errorf("url kind: got %q, want string", k)
}
if k := settingKind(config.ConfigKey{Default: time.Hour}); k != "string" {
t.Errorf("duration kind: got %q, want string", k)
}
if k := settingKind(config.ConfigKey{Default: 7}); k != "string" {
t.Errorf("int kind: got %q, want string", k)
}
got := normalizedOverride(config.ConfigKey{Default: []string(nil)}, " a.example , b.example ,")
if got != "a.example,b.example" {
t.Errorf("normalizedOverride: got %q", got)
}
}
// TestSettingsPlaceholderPerType covers design D4 (typed-config-keys): a
// text control's placeholder is its declared or inferred type's format
// example, shown for url, duration, int and list keys and absent for a bare
// string (form-conventions: a placeholder is a format example).
func TestSettingsPlaceholderPerType(t *testing.T) {
if got, want := settingsPlaceholder(config.TypeURL), "https://host.example"; got != want {
t.Errorf("url placeholder = %q, want %q", got, want)
}
if got, want := settingsPlaceholder(config.TypeDuration), "1h30m"; got != want {
t.Errorf("duration placeholder = %q, want %q", got, want)
}
if got, want := settingsPlaceholder(config.TypeInt), "10"; got != want {
t.Errorf("int placeholder = %q, want %q", got, want)
}
if got, want := settingsPlaceholder(config.TypeList), "a, b"; got != want {
t.Errorf("list placeholder = %q, want %q", got, want)
}
if got := settingsPlaceholder(config.TypeString); got != "" {
t.Errorf("string placeholder = %q, want none", got)
}
if got := settingsPlaceholder(config.TypeEnum); got != "" {
t.Errorf("enum placeholder = %q, want none (it renders a select, never a text control)", got)
}
if got := settingsPlaceholder(config.TypeBool); got != "" {
t.Errorf("bool placeholder = %q, want none (it renders a checkbox, never a text control)", got)
}
// End to end: the rendered control carries the placeholder attribute
// for each text-shaped type and carries none for a bare string.
rows := []SettingRow{
{Key: "acme-widget-farm-url", IntegrationKey: "acme", Kind: "string", Placeholder: settingsPlaceholder(config.TypeURL),
Effective: "https://farm.example.com", Source: "environment"},
{Key: "acme-widget-sync-interval", IntegrationKey: "acme", Kind: "string", Placeholder: settingsPlaceholder(config.TypeDuration),
Effective: "1h0m0s", Source: "default"},
{Key: "acme-widget-pending-cap", IntegrationKey: "acme", Kind: "string", Placeholder: settingsPlaceholder(config.TypeInt),
Effective: "5", Source: "default"},
{Key: "acme-widget-domains", IntegrationKey: "acme", Kind: "list", Placeholder: settingsPlaceholder(config.TypeList),
Effective: "", Source: "default"},
{Key: "acme-widget-color", IntegrationKey: "acme", Kind: "string", Placeholder: settingsPlaceholder(config.TypeString),
Effective: "blue", Source: "default"},
}
out := settingsPageBody(t, rows)
for _, want := range []string{
`name="acme-widget-farm-url" value="https://farm.example.com" placeholder="https://host.example"`,
`name="acme-widget-sync-interval" value="1h0m0s" placeholder="1h30m"`,
`name="acme-widget-pending-cap" value="5" placeholder="10"`,
`name="acme-widget-domains" value="" placeholder="a, b"`,
} {
if !strings.Contains(out, want) {
t.Errorf("settings page missing %q, got:\n%s", want, out)
}
}
if strings.Contains(out, `name="acme-widget-color" value="blue" placeholder`) {
t.Error("a bare string key must carry no placeholder")
}
}
// widgetConfigs is a settings-page fixture isolated to the typed-refusal
// handler test below (typed-config-keys design D2, D3): a duration key and
// a url key, kept separate from acmeConfigs (whose POST-path tests above
// depend on its exact field set staying as declared) so adding these two
// types touches no other test.
var widgetConfigs = []IntegrationConfigInfo{
{
Key: "widget",
DisplayName: "Widget Sync",
Keys: []config.ConfigKey{
{Name: "widget-sync-interval", Default: time.Hour, Usage: "Widget sync interval"},
{Name: "widget-farm-url", Type: config.TypeURL, Usage: "Widget farm URL"},
},
},
}
var widgetBootOnce sync.Once
func seedWidgetBoot(t *testing.T) {
t.Helper()
widgetBootOnce.Do(func() {
for _, key := range widgetConfigs[0].Keys {
if key.Default != nil {
viper.SetDefault(key.Name, key.Default)
}
}
viper.Set("widget-farm-url", "https://farm.widgets.example.com")
if err := config.ApplyOverlay(widgetConfigs[0].Keys, nil); err != nil {
t.Fatalf("seed the boot-effective snapshot: %v", err)
}
})
}
// widgetTestHandlerDB is settingsTestHandlerDB's counterpart for
// widgetConfigs: a real (scratch) database and a loaded (unauthenticated)
// session, for the POST path.
func widgetTestHandlerDB(t *testing.T) (*OperatorPartialsHandler, *sql.DB, context.Context) {
t.Helper()
seedWidgetBoot(t)
database := newRollbackTestDB(t)
clean := func() {
if _, err := database.ExecContext(context.Background(),
`DELETE FROM core.integration_config_overrides WHERE key LIKE 'widget-%'`); err != nil {
t.Fatalf("clear overrides: %v", err)
}
}
clean()
t.Cleanup(clean)
h := &OperatorPartialsHandler{
IntegrationConfigs: widgetConfigs,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Database: database,
}
h.Templates = NewSafeTemplates(parseOperatorPartials(t), h.Logger)
sm := scs.New()
ctx, err := sm.Load(context.Background(), "")
if err != nil {
t.Fatalf("load session: %v", err)
}
h.AuthConfig = &auth.Config{SessionManager: sm}
return h, database, ctx
}
// postWidgetSetting drives PostIntegrationSetting against widgetConfigs with
// the given field values.
func postWidgetSetting(t *testing.T, h *OperatorPartialsHandler, ctx context.Context, form url.Values) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("POST", "/operator/integrations/widget/settings", strings.NewReader(form.Encode())).WithContext(ctx)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.SetPathValue("integrationKey", "widget")
w := httptest.NewRecorder()
h.PostIntegrationSetting(w, r)
return w
}
// countWidgetOverrides is a small helper the two refusal tests below share.
func countWidgetOverrides(t *testing.T, database *sql.DB, key string) int {
t.Helper()
var n int
if err := database.QueryRowContext(context.Background(),
`SELECT count(*) FROM core.integration_config_overrides WHERE key = $1`, key).Scan(&n); err != nil {
t.Fatalf("count overrides for %q: %v", key, err)
}
return n
}
// TestPostIntegrationSettingTypedRefusals covers spec integration-config-
// declaration's "A duration key refuses a word at save and at boot" and "A
// URL key is declared, not inferred": a bogus value for either type answers
// 422 with Parse's own sentence under its control, and the refused batch
// writes no row at all (design D9, "every change is validated before
// anything is written").
func TestPostIntegrationSettingTypedRefusals(t *testing.T) {
t.Run("a word is refused for a duration key, and nothing is written", func(t *testing.T) {
h, database, ctx := widgetTestHandlerDB(t)
w := postWidgetSetting(t, h, ctx, url.Values{
"widget-sync-interval": {"hello"},
"widget-farm-url": {"https://farm.widgets.example.com"},
})
if w.Code != 422 {
t.Fatalf("status = %d, want 422: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `value &#34;hello&#34; is not a duration; for example 30m or 1h30m`) {
t.Errorf("refusal missing the parser's sentence: %s", w.Body.String())
}
if n := countWidgetOverrides(t, database, "widget-sync-interval"); n != 0 {
t.Errorf("override rows for widget-sync-interval = %d, want 0", n)
}
if n := countWidgetOverrides(t, database, "widget-farm-url"); n != 0 {
t.Errorf("override rows for widget-farm-url = %d, want 0 (a refusal writes no row at all)", n)
}
})
t.Run("a word is refused for a url key, and nothing is written", func(t *testing.T) {
h, database, ctx := widgetTestHandlerDB(t)
w := postWidgetSetting(t, h, ctx, url.Values{
"widget-sync-interval": {"1h"},
"widget-farm-url": {"hello"},
})
if w.Code != 422 {
t.Fatalf("status = %d, want 422: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `value &#34;hello&#34; is not an absolute http or https URL`) {
t.Errorf("refusal missing the parser's sentence: %s", w.Body.String())
}
if n := countWidgetOverrides(t, database, "widget-farm-url"); n != 0 {
t.Errorf("override rows for widget-farm-url = %d, want 0", n)
}
if n := countWidgetOverrides(t, database, "widget-sync-interval"); n != 0 {
t.Errorf("override rows for widget-sync-interval = %d, want 0 (a refusal writes no row at all)", n)
}
})
}
// 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 provider key 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)
}
})
}