Files
OwnCord/Server/admin/setup_wizard_test.go
T
J3vbandClaude Fable 5 63c87df487 refactor(b3-8): settings/audit family behind SettingsService (S-09, family 1) (#1477)
* feat(service): settings family — SettingsService over the Store seam

The B3-8 settings/audit family's service: List, Patch (whitelist,
boolean normalization, the require_2fa preconditions incl. the TOTP
census and the unrelated-key guard, atomic apply, one audit row per
changed key) and Setting (the read the hub and the backup scheduler
consume; wraps db.ErrNotFound as the store reports it). db gains
ApplySettings — the handler's raw upsert loop as one hand-written
transactional wrapper where raw SQL belongs — and Store carries it.

parseSettingsPatchBool duplicates auth.go's parseBooleanSettingValue
with the admin surface's own pinned error wording; both messages are
test-pinned, so the twins stay separate.

Service-level characterization in settings_test.go mirrors the
admin/api_test.go PATCH rows and adds the service-only contracts
(ErrNotFound wrap, audit rows, multi-key apply).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* refactor(admin): settings handlers thin over SettingsService; scheduler reads via it

handleGetSettings/handlePatchSettings become adapters (decode, delegate,
map ErrBadRequest to 400 with the service's prefix-free message); the
whitelist and every precondition now live only in the service, so
admin/types.go's copy is gone. MaintainBackups reads backup_schedule and
backup_retention through the service — its backup mechanics keep the
handle — and the maintenance chain threads Settings from the runtime the
hub stage built. NewHandler/NewAdminAPI gain the settings parameter;
all 207 construction sites wired via the newTestSettingsService helper.

Behavior parity pinned by the existing TestAdminAPI_*Settings* rows
(all green); the only unpinned change is the PATCH 500 path collapsing
its four stage-specific internal messages into one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* refactor(ws): hub settings cache reads through a SettingsReader

The hub's server_name/motd cache consumes a consumer-side SettingsReader
interface (service.SettingsService satisfies it; HubOptions.Settings is
required and validated like DB and Limiter — the RequiredCollaborators
pin gains the refusal case). hub_settings.go no longer touches db at
all, so the import pin from the B3-5 finisher goes, and its allowlist
row goes with it; the thinned admin settings handler's row is deleted
too — two allowlist rows down, the settings family's persistence now
lives only in db/ and service/.

Test helpers (both ws package namespaces) default the reader over the
test database; newBareHub wires it explicitly; production passes
Services.Settings from StartRuntime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* docs(boundaries,b3): settings/audit family re-measure and evidence

The backup pair takes its forecast boundary disposition; the family's
two deleted rows and the disposition counts (28/18/15 -> 24/18/17)
re-derived from the tool. Family evidence block appended to the B3-8
section; README B3 row records B3-5 complete and the family opened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* fix(service): prefix-free ErrBadRequest wraps for the pinned admin bodies

The %.0w rework was meant to ride the service commit but was left
unstaged: with the plain %w wrap the PATCH error bodies carry a
'bad request: ' prefix the admin pins reject. Zero-width wrapping keeps
errors.Is(ErrBadRequest) while err.Error() stays exactly the pinned
message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* test(app): lifecycle hub fixtures wire the required Settings reader

The two direct ws.NewHub sites in lifecycle_test predate Settings
becoming required; race across internal/app is green again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* test(db): cover ApplySettings — the db coverage floor caught the gap

CI's coverage floor failed db at 78.9% against 79.3%: ApplySettings was
exercised only from service tests, which do not count toward db's own
figure. Four db-side rows cover the apply, the empty no-op, the
in-transaction failure rollback and the begin failure, using the
package's full-migration opener.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* chore(coverage): raise the service floor to the branch's measured 69.2

The settings family's tested service code raised the Linux figure from
the 67.8 floor to 69.2; the ratchet raises the floor in the same PR
(service is not in the run-varying set). db stays at 79.3 — this PR
restores its figure (79.5 with the ApplySettings tests), it did not set
out to raise it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 15:13:38 +00:00

469 lines
16 KiB
Go

package admin_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/J3vb/OwnCord/Server/admin"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
)
// wizardRunningCfg mimics the config a fresh server boots with: file defaults
// plus the runtime-generated LiveKit credentials.
func wizardRunningCfg() *config.Config {
return &config.Config{
Server: config.ServerConfig{Port: 8443, Name: "OwnCord Server"},
TLS: config.TLSConfig{Mode: "self_signed"},
Upload: config.UploadConfig{MaxSizeMB: 100},
Voice: config.VoiceConfig{
LiveKitAPIKey: "key-generated123",
LiveKitAPISecret: "generated-secret-0123456789abcdef",
Quality: "medium",
},
}
}
// wizardHandler builds the admin API with wizard options and a restart stub
// that signals restarted (buffered) instead of restarting the process. A
// wizard run that triggers a restart leaves the process-global
// restart-serialization guard in restart-pending, so it is reset after every
// wizard test.
func wizardHandler(t *testing.T, database *db.DB, cfgPath string, restarted chan string) http.Handler {
t.Helper()
t.Cleanup(admin.ResetRestartState)
return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database),
admin.SetupOptions{
ConfigPath: cfgPath,
RunningCfg: wizardRunningCfg(),
Restart: func(reason string) { restarted <- reason },
})
}
func getSetting(t *testing.T, database *db.DB, key string) string {
t.Helper()
v, err := database.GetSetting(context.Background(), key)
if err != nil {
t.Fatalf("GetSetting(%q): %v", key, err)
}
return v
}
func TestSetupWizard_FullFlow(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": map[string]any{
"server_name": "My Cool Server",
"motd": "Welcome friends!",
"registration_open": true,
"port": 9000,
"tls_mode": "off",
"upload_max_size_mb": 250,
"voice_quality": "high",
"voice_auto_download": true,
},
})
if rr.Code != http.StatusCreated {
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
}
var resp struct {
Token string `json:"token"`
InviteCode string `json:"invite_code"`
RestartRequired bool `json:"restart_required"`
RestartURL string `json:"restart_url"`
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Token == "" || resp.InviteCode == "" {
t.Error("token/invite_code missing — account creation should be unchanged")
}
if len(resp.Warnings) != 0 {
t.Errorf("warnings = %v, want none", resp.Warnings)
}
if !resp.RestartRequired {
t.Fatal("restart_required = false, want true (port and tls changed)")
}
// httptest requests carry Host "example.com"; tls off → http scheme.
if resp.RestartURL != "http://example.com:9000/admin" {
t.Errorf("restart_url = %q, want %q", resp.RestartURL, "http://example.com:9000/admin")
}
select {
case reason := <-restarted:
if reason != "setup_wizard" {
t.Errorf("restart reason = %q, want setup_wizard", reason)
}
case <-time.After(5 * time.Second):
t.Fatal("restart hook was never invoked")
}
// DB settings the app reads live.
if got := getSetting(t, database, "server_name"); got != "My Cool Server" {
t.Errorf("server_name = %q", got)
}
if got := getSetting(t, database, "motd"); got != "Welcome friends!" {
t.Errorf("motd = %q", got)
}
if got := getSetting(t, database, "registration_open"); got != "1" {
t.Errorf("registration_open = %q, want 1", got)
}
if got := getSetting(t, database, "max_upload_bytes"); got != "262144000" {
t.Errorf("max_upload_bytes = %q, want 262144000 (250 MB)", got)
}
if got := getSetting(t, database, "voice_quality"); got != "high" {
t.Errorf("voice_quality = %q, want high", got)
}
// config.yaml written with the wizard values + persisted voice creds.
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("loading wizard-written config: %v", err)
}
if cfg.Server.Port != 9000 {
t.Errorf("config port = %d, want 9000", cfg.Server.Port)
}
if cfg.Server.Name != "My Cool Server" {
t.Errorf("config server name = %q", cfg.Server.Name)
}
if cfg.TLS.Mode != "off" {
t.Errorf("config tls mode = %q, want off", cfg.TLS.Mode)
}
if cfg.Upload.MaxSizeMB != 250 {
t.Errorf("config upload max = %d, want 250", cfg.Upload.MaxSizeMB)
}
if cfg.Voice.Quality != "high" {
t.Errorf("config voice quality = %q, want high", cfg.Voice.Quality)
}
if cfg.Voice.LiveKitAPIKey != "key-generated123" {
t.Errorf("LiveKit key = %q — the running credentials were not persisted", cfg.Voice.LiveKitAPIKey)
}
if cfg.Voice.LiveKitAPISecret != "generated-secret-0123456789abcdef" {
t.Errorf("LiveKit secret = %q — the running credentials were not persisted", cfg.Voice.LiveKitAPISecret)
}
if !cfg.Voice.AutoDownloadLiveKit {
t.Error("config voice.auto_download_livekit = false, want true from wizard toggle")
}
}
func TestSetupWizard_NoRestartWhenValuesMatchRunning(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
// Same port/tls/upload/voice as the running config; only live-read
// values (name, motd) change.
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": map[string]any{
"server_name": "Renamed Server",
"motd": "hi",
"port": 8443,
"tls_mode": "self_signed",
"upload_max_size_mb": 100,
"voice_quality": "medium",
},
})
if rr.Code != http.StatusCreated {
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
}
var resp struct {
RestartRequired bool `json:"restart_required"`
RestartURL string `json:"restart_url"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.RestartRequired {
t.Error("restart_required = true, want false (no startup-only value changed)")
}
if resp.RestartURL != "" {
t.Errorf("restart_url = %q, want empty", resp.RestartURL)
}
select {
case <-restarted:
t.Error("restart hook invoked though nothing needed a restart")
case <-time.After(100 * time.Millisecond):
}
// Config is still written (server.name changed on disk).
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("loading wizard-written config: %v", err)
}
if cfg.Server.Name != "Renamed Server" {
t.Errorf("config server name = %q, want Renamed Server", cfg.Server.Name)
}
}
// TestSetupWizard_IdentityFieldsStoredRawNotEscaped pins OC-0173: the wizard
// must store server_name/motd the same way handlePatchSettings does later
// (raw survivors, not HTML-entity-escaped), so a name set at first run and
// the identical name set afterwards through the admin Settings page produce
// the same stored value. Before the fix, wizardValidateIdentity ran these
// fields through the bare bluemonday sanitizer, which HTML-escapes
// survivors (' -> &#39;, " -> &#34;, & -> &amp;) — see service.SanitizeText's
// doc comment, which the setup_handler.go username path already follows for
// exactly this reason.
func TestSetupWizard_IdentityFieldsStoredRawNotEscaped(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": map[string]any{
"server_name": "Bob's Place",
"motd": `Say "hi" & relax`,
},
})
if rr.Code != http.StatusCreated {
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
}
if got, want := getSetting(t, database, "server_name"), "Bob's Place"; got != want {
t.Errorf("server_name = %q, want %q (stored HTML-escaped instead of raw)", got, want)
}
if got, want := getSetting(t, database, "motd"), `Say "hi" & relax`; got != want {
t.Errorf("motd = %q, want %q (stored HTML-escaped instead of raw)", got, want)
}
}
func TestSetupWizard_InvalidValuesRejectBeforeAccountCreation(t *testing.T) {
cases := map[string]map[string]any{
"port too low": {"port": 0},
"port too high": {"port": 70000},
"bad tls mode": {"tls_mode": "quantum"},
"acme without domain": {"tls_mode": "acme"},
"bad domain chars": {"tls_mode": "acme", "tls_domain": "not a domain!"},
"single-label domain": {"tls_mode": "acme", "tls_domain": "localhost"},
"upload zero": {"upload_max_size_mb": 0},
"upload too large": {"upload_max_size_mb": 20000},
"bad voice quality": {"voice_quality": "ultra"},
"empty server name": {"server_name": " "},
"tag-only server name": {"server_name": "<b></b>"},
}
for name, wizard := range cases {
t.Run(name, func(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": wizard,
})
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rr.Code, rr.Body.String())
}
count, err := database.UserCount(context.Background())
if err != nil {
t.Fatalf("UserCount: %v", err)
}
if count != 0 {
t.Errorf("user count = %d, want 0 — invalid wizard payload must reject before account creation", count)
}
if _, err := os.Stat(cfgPath); !os.IsNotExist(err) {
t.Error("config file written despite rejected payload")
}
})
}
}
func TestSetupWizard_ConfigWriteFailureWarnsButCreatesAccount(t *testing.T) {
database := openAdminTestDB(t)
// Point at a directory that does not exist so the atomic write fails.
cfgPath := filepath.Join(t.TempDir(), "missing-dir", "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": map[string]any{"port": 9000},
})
if rr.Code != http.StatusCreated {
t.Fatalf("POST /setup = %d, want 201 despite config failure; body=%s", rr.Code, rr.Body.String())
}
var resp struct {
Token string `json:"token"`
RestartRequired bool `json:"restart_required"`
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Token == "" {
t.Error("token missing — the account must still be created")
}
if len(resp.Warnings) == 0 {
t.Error("warnings empty, want a config-write warning")
}
if resp.RestartRequired {
t.Error("restart_required = true, but the config was never written — restarting would change nothing")
}
select {
case <-restarted:
t.Error("restart hook invoked after a failed config write")
case <-time.After(100 * time.Millisecond):
}
count, err := database.UserCount(context.Background())
if err != nil {
t.Fatalf("UserCount: %v", err)
}
if count != 1 {
t.Errorf("user count = %d, want 1", count)
}
}
func TestSetupWizard_LegacyPayloadUnchangedBehaviour(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
"username": "owner",
"password": "SecurePass123!",
})
if rr.Code != http.StatusCreated {
t.Fatalf("legacy POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
}
var resp struct {
RestartRequired bool `json:"restart_required"`
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.RestartRequired || len(resp.Warnings) != 0 {
t.Error("legacy payload must not trigger restarts or warnings")
}
if _, err := os.Stat(cfgPath); !os.IsNotExist(err) {
t.Error("legacy payload must not write config.yaml")
}
select {
case <-restarted:
t.Error("legacy payload must not restart the server")
case <-time.After(100 * time.Millisecond):
}
}
func TestSetupStatus_DefaultsOnlyPreSetupAndSecretFree(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
if rr.Code != http.StatusOK {
t.Fatalf("GET /setup/status = %d, want 200", rr.Code)
}
var resp struct {
NeedsSetup bool `json:"needs_setup"`
Defaults *struct {
ServerName string `json:"server_name"`
Motd string `json:"motd"`
Port int `json:"port"`
TLSMode string `json:"tls_mode"`
UploadMaxSizeMB int `json:"upload_max_size_mb"`
VoiceQuality string `json:"voice_quality"`
} `json:"defaults"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !resp.NeedsSetup || resp.Defaults == nil {
t.Fatalf("pre-setup status should carry defaults; body=%s", rr.Body.String())
}
// server_name/motd come from the seeded settings table, the rest from the
// running config.
if resp.Defaults.ServerName != "Test Server" {
t.Errorf("defaults.server_name = %q, want Test Server (DB value)", resp.Defaults.ServerName)
}
if resp.Defaults.Motd != "Hello" {
t.Errorf("defaults.motd = %q, want Hello (DB value)", resp.Defaults.Motd)
}
if resp.Defaults.Port != 8443 || resp.Defaults.TLSMode != "self_signed" ||
resp.Defaults.UploadMaxSizeMB != 100 || resp.Defaults.VoiceQuality != "medium" {
t.Errorf("config-derived defaults wrong: %+v", resp.Defaults)
}
// Never leak credentials through the unauthenticated status endpoint.
lower := strings.ToLower(rr.Body.String())
for _, needle := range []string{"livekit", "secret", "api_key", "token", "cidr"} {
if strings.Contains(lower, needle) {
t.Errorf("status response leaks %q: %s", needle, rr.Body.String())
}
}
// After setup completes, defaults disappear along with needs_setup.
rr2 := doRequest(t, handler, "POST", "/setup", "", map[string]string{
"username": "owner", "password": "SecurePass123!",
})
if rr2.Code != http.StatusCreated {
t.Fatalf("setup = %d, want 201", rr2.Code)
}
rr3 := doRequest(t, handler, "GET", "/setup/status", "", nil)
if !strings.Contains(rr3.Body.String(), `"needs_setup":false`) {
t.Errorf("post-setup status = %s, want needs_setup false", rr3.Body.String())
}
if strings.Contains(rr3.Body.String(), "defaults") {
t.Errorf("post-setup status still exposes defaults: %s", rr3.Body.String())
}
}
func TestSetupWizard_ForeignOriginBlocked(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
body := map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": map[string]any{"port": 9000},
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest("POST", "/setup", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("wizard POST from foreign origin = %d, want 403", rr.Code)
}
if _, err := os.Stat(cfgPath); !os.IsNotExist(err) {
t.Error("config file written from a cross-origin request")
}
}