Files
member-console/internal/integration/settings_db_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

244 lines
8.5 KiB
Go

// 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{}
}