Files
member-console/test/e2e/operator-walkthroughs/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

258 lines
13 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// walkthrough: integration-settings
// covers: POST /operator/integrations/{integrationKey}/settings
//
// DELETE /operator/integrations/{integrationKey}/settings/{key}
// DELETE /operator/integrations/config-orphans/{key}
//
// Exercises the runtime-config surface end to end: save an override on an
// enum key (choosing whichever member is NOT currently effective, so the
// pending-restart badge must appear), clear it from that row's own Clear,
// and delete a seeded orphan override row via the shared confirm-action
// modal. Overrides apply on restart by design, so the walkthrough asserts
// stored-state feedback (alerts, badges, row presence), not live behavior
// changes.
package operatorwalkthroughs_test
import (
"context"
"database/sql"
"strings"
"testing"
"time"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
)
func TestIntegrationSettingsWalkthrough(t *testing.T) {
base := baseURL(t)
browser := newBrowser(t)
page := browser.MustPage()
loginAsOperator(t, page, base, "/operator/integrations")
t.Logf("phase 1: landing lists every integration with Settings links")
body := page.Timeout(10 * time.Second).MustElement(`#operator-body`).MustHTML()
for _, want := range []string{"/operator/integrations/fedwiki/settings", "/operator/integrations/stripe/settings", "/operator/integrations/discourse/settings"} {
if !strings.Contains(body, want) {
t.Fatalf("phase 1: landing missing settings link %q", want)
}
}
if !strings.Contains(body, "Stripe") {
t.Fatalf("phase 1: payments integration missing from the unified table")
}
// Phases 2 and 3 drive FedWiki's settings surface because it is the
// integration with an enum key to toggle, not because this walkthrough is
// about wikis. FedWiki is profile-gated like every other integration
// (COMPOSE_PROFILES in test/.env), so a stack composed without it must
// skip here rather than fail — the same contract Discourse's walkthrough
// has always had. An unconfigured farm is not a skip: the settings surface
// is config plumbing and never dials the farm, so the guard returns and
// the phases run.
skipUnlessFarmReachable(t, page, base)
t.Logf("phase 2: save an override for fedwiki-site-scheme (enum select)")
page.MustNavigate(base + "/operator/integrations/fedwiki/settings")
// One declaration, one form, one table, one Save
// (operator.integration.settings in the Table family, forms-library
// round 4, design D21): no per-row form, no hidden key field, and no
// second table below the form for the secret keys. Each key is a row
// carrying its effective value and its winning source in their own
// columns; a key's own select, checkbox or text control carries its
// name directly and is bound to the value in force. Every control sets
// a value and nothing else (design D21 round 5): the page's single
// commit saves changed values, and removing a stored override is the
// row's own Clear, checked in phase 3.
commit := `[data-form="operator.integration.settings"] button[type=submit]`
settingsForm := page.Timeout(10 * time.Second).MustElement(`[data-form="operator.integration.settings"]`).MustHTML()
for _, want := range []string{">Key<", ">Effective value<", ">Source<", ">Override<"} {
if !strings.Contains(settingsForm, want) {
t.Errorf("phase 2: settings table missing the column %q", want)
}
}
if strings.Contains(settingsForm, "(optional)") {
t.Errorf("phase 2: the settings table must not mark every row optional")
}
if n := strings.Count(page.MustElement(`#operator-body`).MustHTML(), "<table"); n != 1 {
t.Errorf("phase 2: settings page renders %d tables, want 1 (the secret rows live in the same one)", n)
}
field := `select[name="fedwiki-site-scheme"]`
page.Timeout(10 * time.Second).MustElement(field)
current := page.MustElement(field).MustProperty("value").String()
target := "http"
if current == "http" {
target = "https"
}
page.MustElement(field).MustSelect(target)
// Success is an HX-Redirect to the settings page carrying ?flash=saved
// (design D9: the htmx submit gets HX-Redirect, a native one a 303), a
// real navigation the shared #successToast reads on load. Wait for that
// navigation before querying anything: a toast from an earlier save is
// still showing when the next Save is clicked, so waiting for the toast
// alone returns before the old document is torn down and the next query
// dies with a destroyed execution context.
clickAndLand := func(selector string) {
wait := page.Timeout(10 * time.Second).MustWaitNavigation()
page.MustElement(selector).MustClick()
wait()
page.Timeout(10 * time.Second).MustWaitLoad()
}
clickAndLand(commit)
page.Timeout(10 * time.Second).MustElement(`#successToast.show`)
if toast := strings.ToLower(page.MustElement(`#successToastBody`).MustText()); !strings.Contains(toast, "saved") {
t.Errorf("phase 2: expected a saved toast, got %q", toast)
}
saved := page.MustElement(`#operator-body`).MustHTML()
if !strings.Contains(saved, "Pending restart") {
t.Errorf("phase 2: saved override differing from boot-effective value must show a Pending restart badge")
}
t.Logf("phase 2b: a bogus duration value is refused, not stored (typed-config-keys)")
// spec integration-config-declaration, "A duration key refuses a word
// at save and at boot": fedwiki-sync-interval receiving "hello" answers
// 422 with the parser's own sentence under that control. A refusal is
// a 422 swap of #operator-body, not a navigation, so wait for the
// error text itself rather than for a navigation event (clickAndLand
// below is for the opposite case, a successful save).
durationField := `input[name="fedwiki-sync-interval"]`
page.Timeout(10 * time.Second).MustElement(durationField)
originalInterval := page.MustElement(durationField).MustProperty("value").String()
page.MustElement(durationField).MustSelectAllText().MustInput("hello")
page.MustElement(commit).MustClick()
errEl := page.Timeout(10*time.Second).MustElementR(`.invalid-feedback`, `is not a duration`)
if msg := strings.TrimSpace(errEl.MustText()); msg != `value "hello" is not a duration; for example 30m or 1h30m` {
t.Errorf("phase 2b: refusal text = %q", msg)
}
refusedControl := page.MustElement(durationField)
if class := refusedControl.MustProperty("className").String(); !strings.Contains(class, "is-invalid") {
t.Errorf("phase 2b: expected the refused control to carry is-invalid, got %q", class)
}
if got := refusedControl.MustProperty("value").String(); got != "hello" {
t.Errorf("phase 2b: the control should keep what was typed, got %q", got)
}
// The row is unchanged: no row can be written for a 422, and the
// unrelated fedwiki-site-scheme row from phase 2 still shows its own
// saved override and Pending restart badge, proving the refusal
// touched only the one field submitted with a bad value.
refused := page.MustElement(`#operator-body`).MustHTML()
if !strings.Contains(refused, "Pending restart") {
t.Errorf("phase 2b: the unrelated fedwiki-site-scheme row must still show Pending restart after a refusal elsewhere on the page")
}
t.Logf("phase 2c: the value is restored and the page saves cleanly again")
page.MustElement(durationField).MustSelectAllText().MustInput(originalInterval)
clickAndLand(commit)
page.Timeout(10 * time.Second).MustElement(`#successToast.show`)
if toast := strings.ToLower(page.MustElement(`#successToastBody`).MustText()); !strings.Contains(toast, "saved") {
t.Errorf("phase 2c: expected a saved toast after restoring the value, got %q", toast)
}
t.Logf("phase 3: clear the override from that row's own Clear")
// Removing an override is an action on the row, not a value the control
// can carry: the select offers only real values now, and the overridden
// row's Source cell carries Clear, a DELETE on the key's own route
// (design D21 round 5). Its success navigates the same way a save does,
// so it is waited for the same way.
clearRoute := "/operator/integrations/fedwiki/settings/fedwiki-site-scheme"
clear := `button[hx-delete="` + clearRoute + `"]`
page.Timeout(10 * time.Second).MustElement(clear)
clickAndLand(clear)
page.Timeout(10 * time.Second).MustElement(`#successToast.show`)
if toast := strings.ToLower(page.MustElement(`#successToastBody`).MustText()); !strings.Contains(toast, "cleared") {
t.Errorf("phase 3: expected a cleared toast, got %q", toast)
}
cleared := page.MustElement(`#operator-body`).MustHTML()
if strings.Contains(cleared, "Pending restart") {
t.Errorf("phase 3: cleared override matching boot state must not show Pending restart")
}
if strings.Contains(cleared, clearRoute) {
t.Errorf("phase 3: the cleared row still offers Clear; only a row with a stored override does")
}
t.Logf("phase 4: delete a seeded orphan override via the confirm-action modal")
orphanKey := "e2e-orphan-" + uuid.New().String()[:8]
dsn := envFromTestDir(t)["MC_DB_DSN"]
if dsn == "" {
t.Fatal("MC_DB_DSN not set in test/.env")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { database.Close() })
ctx := context.Background()
if _, err := database.ExecContext(ctx,
"INSERT INTO core.integration_config_overrides (key, value) VALUES ($1, 'leftover')", orphanKey); err != nil {
t.Fatalf("seed orphan override: %v", err)
}
t.Cleanup(func() {
_, _ = database.ExecContext(ctx, "DELETE FROM core.integration_config_overrides WHERE key = $1", orphanKey)
})
page.MustNavigate(base + "/operator/integrations")
page.Timeout(10*time.Second).MustElementR(`#operator-body`, orphanKey)
page.MustElement(`button[data-action-url="/operator/integrations/config-orphans/` + orphanKey + `"]`).MustClick()
page.Timeout(10 * time.Second).MustElement(`#confirmActionModal.show`)
page.MustElement(`[data-form="shell.confirm"] button[type="submit"]`).MustClick()
// HX-Redirect reloads the landing; the orphan row must be gone.
page.Timeout(10 * time.Second).MustWaitStable()
if after := page.MustElement(`#operator-body`).MustHTML(); strings.Contains(after, orphanKey) {
t.Errorf("phase 4: orphan override %q still listed after delete", orphanKey)
}
var n int
if err := database.QueryRowContext(ctx,
"SELECT count(*) FROM core.integration_config_overrides WHERE key = $1", orphanKey).Scan(&n); err != nil {
t.Fatalf("verify orphan deletion: %v", err)
}
if n != 0 {
t.Errorf("phase 4: orphan override row still in database after delete")
}
t.Logf("phase 5: the Stripe provider page carries its status and the delivery queue, and links to settings")
page.MustNavigate(base + "/operator/integrations/stripe")
stripePage := page.Timeout(10 * time.Second).MustElement(`#operator-body`).MustHTML()
for _, want := range []string{"Delivery queue", "/operator/integrations/stripe/settings"} {
if !strings.Contains(stripePage, want) {
t.Errorf("phase 5: Stripe provider page missing %q", want)
}
}
t.Logf("phase 6: unknown operator paths 404 in-shell; legacy fedwiki URL redirects")
// /operator/integrations/stripe is no longer unmatched (design D24: it
// is Stripe's own provider page, checked above) — a genuinely unclaimed
// integration key exercises the same in-shell 404 the old assertion here
// checked.
page.MustNavigate(base + "/operator/integrations/does-not-exist")
notFound := page.Timeout(10 * time.Second).MustElement(`#operator-body`).MustHTML()
if !strings.Contains(notFound, "Page not found") {
t.Errorf("phase 6: unknown integration path should render the operator 404, got a different page")
}
if strings.Contains(notFound, "Your Wiki") || strings.Contains(notFound, "member-dashboard") {
t.Errorf("phase 6: unknown operator path leaked to the member dashboard")
}
page.MustNavigate(base + "/operator/fedwiki-sites")
page.Timeout(10 * time.Second).MustElement(`#operator-body`)
if got := page.MustInfo().URL; !strings.HasSuffix(got, "/operator/integrations/fedwiki") {
t.Errorf("phase 6: legacy /operator/fedwiki-sites should redirect to /operator/integrations/fedwiki, landed on %s", got)
}
fedwikiAdmin := page.MustElement(`#operator-body`).MustHTML()
if !strings.Contains(fedwikiAdmin, "/operator/integrations/fedwiki/settings") {
t.Errorf("phase 6: FedWiki admin page missing its settings link")
}
// A configured farm shows its sync health; an unconfigured one (the
// stack's default, where the fedwiki profile is off) renders the
// not-configured state instead of an empty listing (fedwiki-sites
// "Operator panel site listing"; acceptance-fixes, 2026-09-02). Either
// shape is the honest one for its state; a page with neither is not.
syncHealth := strings.Contains(fedwikiAdmin, "Sync health") && strings.Contains(fedwikiAdmin, "Last sync")
notConfigured := strings.Contains(strings.ToLower(fedwikiAdmin), "not configured")
if !syncHealth && !notConfigured {
t.Errorf("phase 6: FedWiki admin page shows neither its sync health nor the not-configured state")
}
}