// 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", ">Effective value", ">Source", ">Override", `https://widgets.example.com`, // the effective value is a column, not a hint `