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.
244 lines
8.5 KiB
Go
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{}
|
|
}
|