mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
264 lines
10 KiB
Go
264 lines
10 KiB
Go
package admin_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/J3vb/OwnCord/Server/admin"
|
|
"github.com/J3vb/OwnCord/Server/updater"
|
|
)
|
|
|
|
// ─── The restart handoff: swap success paths and the serialization guard ────
|
|
//
|
|
// applyStagedUpdate no longer spawns, signals, or exits — the restart goes
|
|
// through the coordinator hook (admin.SetRestartHandoff) and the main package
|
|
// performs the drain + handoff. That is what makes the success paths below
|
|
// testable at all, and the three-state guard (idle → busy → restart-pending)
|
|
// is what keeps concurrent applies/restores/restarts from racing each other.
|
|
|
|
// stageFakeUpdate lays out a fake current binary and a staged .new whose
|
|
// hash matches, returning the three paths plus the staged hash.
|
|
func stageFakeUpdate(t *testing.T) (exePath, oldPath, newPath, stagedHash string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
exePath = filepath.Join(dir, "chatserver")
|
|
oldPath = exePath + ".old"
|
|
newPath = exePath + ".new"
|
|
if err := os.WriteFile(exePath, []byte("old binary"), 0o755); err != nil {
|
|
t.Fatalf("writing fake exe: %v", err)
|
|
}
|
|
staged := []byte("verified staged bytes")
|
|
if err := os.WriteFile(newPath, staged, 0o755); err != nil {
|
|
t.Fatalf("writing staged binary: %v", err)
|
|
}
|
|
sum := sha256.Sum256(staged)
|
|
return exePath, oldPath, newPath, hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// The success path: the verified staged binary ends up at exePath, the
|
|
// previous binary at .old, no corrective broadcast is sent, and the swap
|
|
// reports committed.
|
|
func TestApplyStagedUpdate_Success_SwapsWithoutAbortBroadcast(t *testing.T) {
|
|
exePath, oldPath, newPath, stagedHash := stageFakeUpdate(t)
|
|
|
|
hub := &mockHub{}
|
|
if !admin.ApplyStagedUpdate(hub, exePath, oldPath, newPath, stagedHash) {
|
|
t.Fatal("ApplyStagedUpdate = false, want committed swap")
|
|
}
|
|
|
|
if len(hub.restartCalls) != 0 {
|
|
t.Errorf("restartCalls = %+v, want none (no corrective broadcast on success)", hub.restartCalls)
|
|
}
|
|
if got, err := os.ReadFile(exePath); err != nil || string(got) != "verified staged bytes" {
|
|
t.Errorf("exePath contents = %q, err=%v; want the staged bytes", got, err)
|
|
}
|
|
if got, err := os.ReadFile(oldPath); err != nil || string(got) != "old binary" {
|
|
t.Errorf(".old contents = %q, err=%v; want the previous binary", got, err)
|
|
}
|
|
if _, err := os.Stat(newPath); !os.IsNotExist(err) {
|
|
t.Errorf(".new still exists after commit (stat err=%v)", err)
|
|
}
|
|
}
|
|
|
|
// The full background tail on success: countdown broadcast, swap, guard
|
|
// promoted to restart-pending, restart requested through the hook with
|
|
// reason "update".
|
|
func TestApplyAndRestart_Success_RequestsRestartAndMarksPending(t *testing.T) {
|
|
exePath, oldPath, newPath, stagedHash := stageFakeUpdate(t)
|
|
|
|
admin.ResetRestartState()
|
|
admin.ForceRestartState(true) // the handler claims busy before spawning the goroutine
|
|
reasons, restoreHook := admin.StubRestartCapture()
|
|
defer restoreHook()
|
|
defer admin.SetApplyRestartDelay(time.Millisecond)()
|
|
|
|
hub := &mockHub{}
|
|
admin.ApplyAndRestart(hub, exePath, oldPath, newPath, stagedHash)
|
|
|
|
if len(hub.restartCalls) != 1 || hub.restartCalls[0].reason != "update" {
|
|
t.Fatalf("restartCalls = %+v, want exactly the update countdown", hub.restartCalls)
|
|
}
|
|
if got := reasons(); len(got) != 1 || got[0] != "update" {
|
|
t.Errorf("restart hook reasons = %v, want [update]", got)
|
|
}
|
|
if got := admin.CurrentRestartState(); got != "pending" {
|
|
t.Errorf("restart state after committed swap = %q, want pending", got)
|
|
}
|
|
}
|
|
|
|
// The background tail on a failed swap: corrective broadcast (covered in
|
|
// depth by the OC-0226 tests), no restart request, and the busy slot released
|
|
// so a corrected release can be applied without a manual restart.
|
|
func TestApplyAndRestart_Abort_ReleasesGuard(t *testing.T) {
|
|
dir := t.TempDir()
|
|
exePath := filepath.Join(dir, "chatserver")
|
|
oldPath := exePath + ".old"
|
|
newPath := exePath + ".new" // never written → re-verification fails
|
|
|
|
admin.ResetRestartState()
|
|
admin.ForceRestartState(true)
|
|
reasons, restoreHook := admin.StubRestartCapture()
|
|
defer restoreHook()
|
|
defer admin.SetApplyRestartDelay(time.Millisecond)()
|
|
|
|
hub := &mockHub{}
|
|
admin.ApplyAndRestart(hub, exePath, oldPath, newPath,
|
|
"0000000000000000000000000000000000000000000000000000000000000000")
|
|
|
|
if got := reasons(); len(got) != 0 {
|
|
t.Errorf("restart hook reasons = %v, want none on abort", got)
|
|
}
|
|
if got := admin.CurrentRestartState(); got != "idle" {
|
|
t.Errorf("restart state after aborted swap = %q, want idle (slot released)", got)
|
|
}
|
|
if len(hub.restartCalls) != 2 || hub.restartCalls[1].reason != "update_aborted" {
|
|
t.Errorf("restartCalls = %+v, want countdown then update_aborted", hub.restartCalls)
|
|
}
|
|
}
|
|
|
|
// POST /updates/apply answers 409 without touching the updater when a restart
|
|
// is already pending or another restart-sensitive operation is in flight.
|
|
// The guard sits before CheckForUpdate, so no GitHub call is attempted — the
|
|
// updater here has no reachable base URL and would error loudly if consulted.
|
|
func TestApplyUpdate_Conflict409(t *testing.T) {
|
|
t.Setenv("OWNCORD_CONTAINER", "0")
|
|
database := openAdminTestDB(t)
|
|
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
|
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
|
token := createAdminUser(t, database)
|
|
|
|
cases := []struct {
|
|
name string
|
|
busy bool
|
|
wantCode string
|
|
}{
|
|
{"restart pending", false, "RESTART_PENDING"},
|
|
{"apply or restore in flight", true, "UPDATE_IN_PROGRESS"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
admin.ForceRestartState(tc.busy)
|
|
t.Cleanup(admin.ResetRestartState)
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d, want 409; body: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if resp["error"] != tc.wantCode {
|
|
t.Errorf("error code = %q, want %q", resp["error"], tc.wantCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// POST /backups/{name}/restore refuses the same way — before any disk I/O.
|
|
func TestRestoreBackup_Conflict409(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
|
token := createAdminUser(t, database)
|
|
|
|
admin.ForceRestartState(false) // restart pending
|
|
t.Cleanup(admin.ResetRestartState)
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/backups/chatserver_x.db/restore", token, nil)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d, want 409; body: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if resp["error"] != "RESTART_PENDING" {
|
|
t.Errorf("error code = %q, want RESTART_PENDING", resp["error"])
|
|
}
|
|
}
|
|
|
|
// A restore whose database.Close() fails is still committed to dying: it must
|
|
// request a restart AND leave the guard in restart-pending so nothing else
|
|
// starts an update against a process with closed DB pools.
|
|
func TestRestore_CloseFailure_StillMarksPendingAndRequestsRestart(t *testing.T) {
|
|
tmpDir := chdirTemp(t)
|
|
database := openAdminTestDB(t)
|
|
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
|
token := createAdminUser(t, database)
|
|
|
|
backupDir := filepath.Join(tmpDir, "data", "backups")
|
|
if err := os.MkdirAll(backupDir, 0o750); err != nil {
|
|
t.Fatalf("MkdirAll backups: %v", err)
|
|
}
|
|
backupName := "chatserver_20240101_120000.db"
|
|
if err := database.BackupToSafe(context.Background(), filepath.Join(backupDir, backupName), backupDir); err != nil {
|
|
t.Fatalf("BackupToSafe fixture: %v", err)
|
|
}
|
|
|
|
reasons, restoreHook := admin.StubRestartCapture()
|
|
defer restoreHook()
|
|
restoreClose := admin.StubCloseError("injected close failure")
|
|
defer restoreClose()
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/backups/"+backupName+"/restore", token, nil)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500; body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for len(reasons()) == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if got := reasons(); len(got) != 1 || got[0] != "backup_restore_close_failed" {
|
|
t.Errorf("restart hook reasons = %v, want [backup_restore_close_failed]", got)
|
|
}
|
|
if got := admin.CurrentRestartState(); got != "pending" {
|
|
t.Errorf("restart state = %q, want pending (process is committed to dying)", got)
|
|
}
|
|
}
|
|
|
|
// A setup-wizard restart is skipped (with the response already written) when
|
|
// an update or restore already owns the restart: two restart paths must never
|
|
// race each other's teardown.
|
|
func TestSetupRestart_SkippedWhenPending(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
restarted := make(chan string, 1)
|
|
handler := wizardHandler(t, database, cfgPath, restarted)
|
|
|
|
admin.ForceRestartState(false) // restart pending
|
|
|
|
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
|
|
"username": "owner",
|
|
"password": "SecurePass123!",
|
|
"wizard": map[string]any{
|
|
"server_name": "S",
|
|
"port": 9000,
|
|
"tls_mode": "off",
|
|
},
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
select {
|
|
case reason := <-restarted:
|
|
t.Errorf("setup requested restart %q despite a pending restart", reason)
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// The unwired default hook must degrade to a loud log — never exit, spawn,
|
|
// or panic — so a binary that misses the SetRestartHandoff wiring (or a test
|
|
// that forgets to stub) fails soft.
|
|
func TestRequestRestart_UnwiredDefaultIsInert(t *testing.T) {
|
|
admin.RequestRestartForTest("unwired-test")
|
|
}
|