// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package integration_test import ( "context" "database/sql" "os" "strings" "testing" "time" _ "github.com/jackc/pgx/v5/stdlib" "github.com/spf13/viper" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/db" "git.coopcloud.tech/wiki-cafe/member-console/internal/integration" "git.coopcloud.tech/wiki-cafe/member-console/internal/migrate" ) // settingsSvcSpecs is a brand-neutral fixture spec for SettingsService's // DB-backed tests: one duration key (the shape the maintainer's bogus // fedwiki-sync-interval save exposed), one secret key, and one string key // with a default, covering every branch List, Set, and Clear take. var settingsSvcSpecs = []config.ConfigKey{ {Name: "acme-widget-interval", Default: time.Hour, Usage: "widget sync interval"}, {Name: "acme-widget-token", Secret: true, Usage: "widget admin token"}, {Name: "acme-widget-color", Default: "blue", Usage: "widget color"}, } // newSettingsService opens a real database (TEST_DATABASE_URL), migrates it, // and returns a SettingsService plus a raw handle for seeding/assertions // outside the service's own API. func newSettingsService(t *testing.T) (*integration.SettingsService, *sql.DB) { t.Helper() dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL not set, skipping integration test") } database, err := sql.Open("pgx", dsn) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { database.Close() }) if err := db.RunMigrations(database, migrate.Sources()); err != nil { t.Fatalf("run migrations: %v", err) } t.Cleanup(func() { if _, err := database.ExecContext(context.Background(), `DELETE FROM core.integration_config_overrides WHERE key LIKE 'acme-%'`); err != nil { t.Errorf("cleanup: %v", err) } }) return integration.NewSettingsService(database), database } func TestSettingsServiceSet(t *testing.T) { t.Run("a word is refused for a duration key with the parser's sentence, and writes nothing", func(t *testing.T) { svc, database := newSettingsService(t) ctx := context.Background() err := svc.Set(ctx, settingsSvcSpecs, "acme-widget-interval", "hello", "") if err == nil || !strings.Contains(err.Error(), `value "hello" is not a duration; for example 30m or 1h30m`) { t.Fatalf("want the parser's sentence, got %v", err) } var n int if err := database.QueryRowContext(ctx, `SELECT count(*) FROM core.integration_config_overrides WHERE key = 'acme-widget-interval'`).Scan(&n); err != nil { t.Fatalf("count overrides: %v", err) } if n != 0 { t.Errorf("override rows = %d, want 0 (a refused value is never written)", n) } }) t.Run("a secret key is refused", func(t *testing.T) { svc, _ := newSettingsService(t) err := svc.Set(context.Background(), settingsSvcSpecs, "acme-widget-token", "hunter2", "") if err == nil || err.Error() != "acme-widget-token is a secret key and is set in the environment" { t.Fatalf("want the secret-key refusal, got %v", err) } }) t.Run("an unknown key is refused", func(t *testing.T) { svc, _ := newSettingsService(t) err := svc.Set(context.Background(), settingsSvcSpecs, "acme-nonexistent", "x", "") if err == nil || err.Error() != "unknown key acme-nonexistent" { t.Fatalf("want the unknown-key refusal, got %v", err) } }) t.Run("a valid value is written and records the actor", func(t *testing.T) { svc, database := newSettingsService(t) ctx := context.Background() if err := svc.Set(ctx, settingsSvcSpecs, "acme-widget-color", "green", "op@example.com"); err != nil { t.Fatalf("set: %v", err) } var value string var updatedBy sql.NullString if err := database.QueryRowContext(ctx, `SELECT value, updated_by FROM core.integration_config_overrides WHERE key = 'acme-widget-color'`).Scan(&value, &updatedBy); err != nil { t.Fatalf("read override: %v", err) } if value != "green" { t.Errorf("stored value = %q, want green", value) } if !updatedBy.Valid || updatedBy.String != "op@example.com" { t.Errorf("updated_by = %+v, want op@example.com", updatedBy) } }) } func TestSettingsServiceClear(t *testing.T) { svc, database := newSettingsService(t) ctx := context.Background() if err := svc.Set(ctx, settingsSvcSpecs, "acme-widget-color", "green", ""); err != nil { t.Fatalf("seed override: %v", err) } found, err := svc.Clear(ctx, "acme-widget-color") if err != nil { t.Fatalf("clear existing: %v", err) } if !found { t.Error("clear existing: want found = true") } var n int if err := database.QueryRowContext(ctx, `SELECT count(*) FROM core.integration_config_overrides WHERE key = 'acme-widget-color'`).Scan(&n); err != nil { t.Fatalf("count overrides: %v", err) } if n != 0 { t.Errorf("override rows = %d, want 0 after Clear", n) } found, err = svc.Clear(ctx, "acme-widget-color") if err != nil { t.Fatalf("clear missing: %v", err) } if found { t.Error("clear missing: want found = false") } // Clear removes an unrecognized row (one matching no declared key) the // same way (design D11, "an unrecognized row included"). if _, err := database.ExecContext(ctx, `INSERT INTO core.integration_config_overrides (key, value) VALUES ('acme-orphan-key', 'x')`); err != nil { t.Fatalf("seed orphan row: %v", err) } found, err = svc.Clear(ctx, "acme-orphan-key") if err != nil { t.Fatalf("clear orphan: %v", err) } if !found { t.Error("clear orphan: want found = true") } } func TestSettingsServiceList(t *testing.T) { svc, database := newSettingsService(t) ctx := context.Background() // List's Environment resolution (config.EffectiveString) reads straight // from viper, exactly like ApplyOverlay's own pre-overlay pass; a // running console registers each key's default via viper.SetDefault at // boot (cmd/start.go's registerIntegrationConfigFlags), which this test // binary never runs, so it is restated here for the one non-secret, // non-override key these assertions read a default from. viper.SetDefault("acme-widget-color", "blue") t.Cleanup(func() { viper.Reset() }) t.Run("a declared key with no override reports the default and Default as winner", func(t *testing.T) { rows, err := svc.List(ctx, settingsSvcSpecs) if err != nil { t.Fatalf("list: %v", err) } row := findRow(t, rows, "acme-widget-color") if !row.Declared || row.HasOverride || row.Winner != config.SourceDefault || row.Environment != "blue" { t.Fatalf("acme-widget-color row = %+v, want declared/no-override/Default/blue", row) } }) t.Run("a stored override reports Override as winner", func(t *testing.T) { if err := svc.Set(ctx, settingsSvcSpecs, "acme-widget-color", "green", ""); err != nil { t.Fatalf("seed override: %v", err) } t.Cleanup(func() { svc.Clear(ctx, "acme-widget-color") }) rows, err := svc.List(ctx, settingsSvcSpecs) if err != nil { t.Fatalf("list: %v", err) } row := findRow(t, rows, "acme-widget-color") if !row.HasOverride || row.Override != "green" || row.Winner != config.SourceOverride { t.Fatalf("acme-widget-color row = %+v, want override green / Override", row) } }) t.Run("a secret key never echoes its value and always wins from the environment", func(t *testing.T) { rows, err := svc.List(ctx, settingsSvcSpecs) if err != nil { t.Fatalf("list: %v", err) } row := findRow(t, rows, "acme-widget-token") if row.Environment != "" || row.Winner != config.SourceEnvironment { t.Fatalf("acme-widget-token row = %+v, want blank Environment and Environment winner", row) } }) t.Run("a stored row matching no declaration is listed as unrecognized", func(t *testing.T) { if _, err := database.ExecContext(ctx, `INSERT INTO core.integration_config_overrides (key, value) VALUES ('acme-orphan-key', 'x')`); err != nil { t.Fatalf("seed orphan row: %v", err) } t.Cleanup(func() { svc.Clear(ctx, "acme-orphan-key") }) rows, err := svc.List(ctx, settingsSvcSpecs) if err != nil { t.Fatalf("list: %v", err) } row := findRow(t, rows, "acme-orphan-key") if row.Declared { t.Fatalf("acme-orphan-key row = %+v, want Declared = false", row) } if !row.HasOverride || row.Override != "x" { t.Fatalf("acme-orphan-key row = %+v, want the stored value surfaced", row) } }) } func findRow(t *testing.T, rows []integration.SettingsRow, key string) integration.SettingsRow { t.Helper() for _, r := range rows { if r.Key == key { return r } } t.Fatalf("no row for %q in %+v", key, rows) return integration.SettingsRow{} }