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.
962 lines
41 KiB
Go
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 "hello" 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 "hello" 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)
|
|
}
|
|
})
|
|
}
|