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.
258 lines
13 KiB
Go
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")
|
|
}
|
|
}
|