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>
703 lines
27 KiB
Go
703 lines
27 KiB
Go
package admin_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/J3vb/OwnCord/Server/admin"
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
)
|
|
|
|
// chdirTemp changes the working directory to a fresh temp directory for the
|
|
// duration of t and restores the original on cleanup. Backup handlers use
|
|
// relative paths ("data/backups") that are resolved against cwd.
|
|
func chdirTemp(t *testing.T) string {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
origDir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("os.Getwd: %v", err)
|
|
}
|
|
if err := os.Chdir(tmpDir); err != nil {
|
|
t.Fatalf("os.Chdir(%q): %v", tmpDir, err)
|
|
}
|
|
// Update the package-level backup dir to match the new CWD (L14).
|
|
admin.SetBackupBaseDir(filepath.Join(tmpDir, "data", "backups"))
|
|
t.Cleanup(func() {
|
|
_ = os.Chdir(origDir)
|
|
admin.SetBackupBaseDir(filepath.Join(origDir, "data", "backups"))
|
|
})
|
|
return tmpDir
|
|
}
|
|
|
|
// ─── POST /backup ─────────────────────────────────────────────────────────────
|
|
|
|
// TestHandleBackup_Success verifies that the backup endpoint creates a backup
|
|
// file and returns 200 with path and created fields.
|
|
func TestHandleBackup_Success(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)
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("POST /backup status = %d, want 200; 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 response: %v", err)
|
|
}
|
|
if resp["path"] == "" {
|
|
t.Error("response missing 'path' field")
|
|
}
|
|
if resp["created"] == "" {
|
|
t.Error("response missing 'created' field")
|
|
}
|
|
|
|
// Verify the backup file actually exists on disk.
|
|
backupDir := filepath.Join(tmpDir, "data", "backups")
|
|
entries, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir(%q): %v", backupDir, err)
|
|
}
|
|
if len(entries) == 0 {
|
|
t.Error("no backup files found after successful backup")
|
|
}
|
|
}
|
|
|
|
// TestHandleBackup_RequiresOwner verifies that admin-role (not owner) receives 403.
|
|
func TestHandleBackup_RequiresOwner(t *testing.T) {
|
|
_ = chdirTemp(t)
|
|
database := openAdminTestDB(t)
|
|
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
|
|
|
adminUID, _ := database.CreateUser(context.Background(), "backupadmin", "hash", 2)
|
|
token := "backup-admin-token"
|
|
_, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1")
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("admin user on /backup status = %d, want 403", w.Code)
|
|
}
|
|
}
|
|
|
|
// ─── GET /backups ─────────────────────────────────────────────────────────────
|
|
|
|
// TestHandleListBackups_EmptyWhenNoDirExists verifies that the endpoint returns
|
|
// an empty JSON array when the backups directory does not exist.
|
|
func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) {
|
|
_ = 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)
|
|
|
|
w := doRequest(t, handler, http.MethodGet, "/backups", token, nil)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET /backups status = %d, want 200; body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var backups []any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &backups); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(backups) != 0 {
|
|
t.Errorf("expected 0 backups when dir missing, got %d", len(backups))
|
|
}
|
|
}
|
|
|
|
// TestHandleListBackups_ReturnsCreatedBackup verifies that a backup created via
|
|
// POST /backup appears in GET /backups.
|
|
func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) {
|
|
_ = 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)
|
|
|
|
// Create a backup first.
|
|
wBackup := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
|
|
if wBackup.Code != http.StatusOK {
|
|
t.Fatalf("POST /backup failed: %d %s", wBackup.Code, wBackup.Body.String())
|
|
}
|
|
|
|
// Now list them.
|
|
w := doRequest(t, handler, http.MethodGet, "/backups", token, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET /backups status = %d, want 200; body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var backups []map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &backups); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(backups) == 0 {
|
|
t.Fatal("expected at least 1 backup in list after POST /backup")
|
|
}
|
|
|
|
b := backups[0]
|
|
if b["name"] == "" {
|
|
t.Error("backup entry missing 'name'")
|
|
}
|
|
if b["size"] == nil {
|
|
t.Error("backup entry missing 'size'")
|
|
}
|
|
if b["date"] == "" {
|
|
t.Error("backup entry missing 'date'")
|
|
}
|
|
}
|
|
|
|
// ─── DELETE /backups/{name} ───────────────────────────────────────────────────
|
|
|
|
// TestHandleDeleteBackup_Success verifies that an existing backup file is
|
|
// deleted and 204 is returned.
|
|
func TestHandleDeleteBackup_Success(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)
|
|
|
|
// Create a real backup file to delete.
|
|
backupDir := filepath.Join(tmpDir, "data", "backups")
|
|
if err := os.MkdirAll(backupDir, 0o750); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
backupName := "chatserver_20240101_120000.db"
|
|
backupPath := filepath.Join(backupDir, backupName)
|
|
if err := os.WriteFile(backupPath, []byte("fake backup"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
w := doRequest(t, handler, http.MethodDelete, "/backups/"+backupName, token, nil)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("DELETE /backups/%s status = %d, want 204; body: %s", backupName, w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify the file is gone.
|
|
if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
|
|
t.Error("backup file still exists after delete")
|
|
}
|
|
}
|
|
|
|
// TestHandleDeleteBackup_NotFound verifies that deleting a nonexistent backup
|
|
// returns 404.
|
|
func TestHandleDeleteBackup_NotFound(t *testing.T) {
|
|
_ = 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)
|
|
|
|
w := doRequest(t, handler, http.MethodDelete, "/backups/nonexistent.db", token, nil)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleDeleteBackup_InvalidNameTraversal verifies that path traversal
|
|
// names are rejected with 400.
|
|
func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) {
|
|
_ = 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)
|
|
|
|
// The chi router URL-decodes the path parameter, so ".." arrives decoded.
|
|
// The handler checks for ".." and returns 400.
|
|
w := doRequest(t, handler, http.MethodDelete, "/backups/..evil.db", token, nil)
|
|
|
|
// Either 400 (blocked) or 404 (file not found) is acceptable.
|
|
// What must NOT happen is 204 (successful delete).
|
|
if w.Code == http.StatusNoContent {
|
|
t.Error("path traversal name resulted in 204 — traversal not blocked")
|
|
}
|
|
}
|
|
|
|
// TestHandleDeleteBackup_RequiresOwner verifies that admin-role is denied.
|
|
func TestHandleDeleteBackup_RequiresOwner(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))
|
|
|
|
adminUID, _ := database.CreateUser(context.Background(), "deladmin", "hash", 2)
|
|
token := "del-admin-token"
|
|
_, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1")
|
|
|
|
// Create the file so path validation doesn't return 404 before the 403.
|
|
backupDir := filepath.Join(tmpDir, "data", "backups")
|
|
_ = os.MkdirAll(backupDir, 0o750)
|
|
_ = os.WriteFile(filepath.Join(backupDir, "test.db"), []byte("x"), 0o644)
|
|
|
|
w := doRequest(t, handler, http.MethodDelete, "/backups/test.db", token, nil)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("admin user on delete-backup status = %d, want 403", w.Code)
|
|
}
|
|
}
|
|
|
|
// ─── POST /backups/{name}/restore ─────────────────────────────────────────────
|
|
|
|
// TestHandleRestoreBackup_Success verifies that a restore operation returns 200
|
|
// with the expected message and backup name.
|
|
func TestHandleRestoreBackup_Success(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)
|
|
|
|
// Set up backup and data directories.
|
|
backupDir := filepath.Join(tmpDir, "data", "backups")
|
|
dataDir := filepath.Join(tmpDir, "data")
|
|
if err := os.MkdirAll(backupDir, 0o750); err != nil {
|
|
t.Fatalf("MkdirAll backups: %v", err)
|
|
}
|
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
|
t.Fatalf("MkdirAll data: %v", err)
|
|
}
|
|
|
|
// A real SQLite backup to restore from — the handler now verifies backups
|
|
// with integrity_check before touching the live database, so a text
|
|
// fixture would be (correctly) refused.
|
|
backupName := "chatserver_20240101_120000.db"
|
|
backupPath := filepath.Join(backupDir, backupName)
|
|
if err := database.BackupToSafe(context.Background(), backupPath, backupDir); err != nil {
|
|
t.Fatalf("BackupToSafe fixture: %v", err)
|
|
}
|
|
|
|
restarted, restoreHook := admin.StubRestart()
|
|
defer restoreHook()
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/backups/"+backupName+"/restore", token, nil)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("POST /backups/%s/restore status = %d, want 200; body: %s", backupName, 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["message"] == "" {
|
|
t.Error("response missing 'message' field")
|
|
}
|
|
if resp["backup"] != backupName {
|
|
t.Errorf("backup = %q, want %q", resp["backup"], backupName)
|
|
}
|
|
|
|
// The response and the server_restart broadcast both promise a restart.
|
|
// Without one the process keeps serving requests against a closed DB.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for !restarted() && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !restarted() {
|
|
t.Error("restore did not request a process restart")
|
|
}
|
|
|
|
// The safety copy the panel promises must exist on disk.
|
|
entries, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir backups: %v", err)
|
|
}
|
|
preRestore := ""
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "pre_restore_") {
|
|
preRestore = filepath.Join(backupDir, e.Name())
|
|
}
|
|
}
|
|
if preRestore == "" {
|
|
t.Fatal("no pre_restore_*.db safety backup was created")
|
|
}
|
|
|
|
// The backup_restore audit row must be INSIDE the safety copy — the live
|
|
// DB file is replaced by the restore, so the pre_restore backup is that
|
|
// row's only durable home. Asserting against the reopened backup file (not
|
|
// the handler's DB, which is closed by now) proves both the write and its
|
|
// ordering before BackupTo.
|
|
restoredDB, err := db.Open(preRestore)
|
|
if err != nil {
|
|
t.Fatalf("db.Open(pre-restore backup): %v", err)
|
|
}
|
|
defer restoredDB.Close() //nolint:errcheck
|
|
audits, err := restoredDB.GetAuditLog(context.Background(), 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("GetAuditLog on pre-restore backup: %v", err)
|
|
}
|
|
foundAudit := false
|
|
for _, e := range audits {
|
|
if e.Action == "backup_restore" {
|
|
foundAudit = true
|
|
}
|
|
}
|
|
if !foundAudit {
|
|
t.Error("expected a backup_restore audit entry inside the pre-restore safety backup")
|
|
}
|
|
}
|
|
|
|
// TestHandleRestoreBackup_RollsBackWhenCopyFails verifies the live database file
|
|
// is not left destroyed when the copy fails partway. copyFile truncates the live
|
|
// DB with os.Create before it can know whether the read will succeed, so a
|
|
// failure there leaves a closed DB and a zero-byte file underneath it; the
|
|
// pre-restore safety copy must be put back, and the process must still respawn
|
|
// because the DB is closed either way.
|
|
//
|
|
// The failure is injected by making the "backup" a directory: it passes the
|
|
// handler's existence check and opens, but reading it fails after the truncate.
|
|
func TestHandleRestoreBackup_RollsBackWhenCopyFails(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)
|
|
}
|
|
dbPath := filepath.Join(tmpDir, "data", "chatserver.db")
|
|
if err := os.WriteFile(dbPath, []byte("live database contents"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile live db: %v", err)
|
|
}
|
|
|
|
// A valid backup (it must pass the pre-copy integrity gate); the mid-copy
|
|
// failure is injected through the copy hook below, reproducing the exact
|
|
// failure mode the rollback exists for: os.Create truncates the live DB,
|
|
// then the copy dies.
|
|
backupName := "chatserver_20240102_120000.db"
|
|
if err := database.BackupToSafe(context.Background(), filepath.Join(backupDir, backupName), backupDir); err != nil {
|
|
t.Fatalf("BackupToSafe fixture: %v", err)
|
|
}
|
|
|
|
failedOnce := false
|
|
restoreCopy := admin.StubCopyBackup(func(src, dst string) error {
|
|
if !failedOnce {
|
|
failedOnce = true
|
|
// Truncate the destination the way the real copy's os.Create
|
|
// does, then fail — the state the rollback must repair.
|
|
f, createErr := os.Create(dst)
|
|
if createErr == nil {
|
|
_ = f.Close()
|
|
}
|
|
return fmt.Errorf("injected copy failure")
|
|
}
|
|
return admin.CopyBackupForTest(src, dst)
|
|
})
|
|
defer restoreCopy()
|
|
|
|
restarted, restoreHook := admin.StubRestart()
|
|
defer restoreHook()
|
|
|
|
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())
|
|
}
|
|
|
|
entries, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir backups: %v", err)
|
|
}
|
|
preRestore := ""
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "pre_restore_") {
|
|
preRestore = filepath.Join(backupDir, e.Name())
|
|
}
|
|
}
|
|
if preRestore == "" {
|
|
t.Fatal("no pre_restore_*.db safety backup was created")
|
|
}
|
|
want, err := os.Stat(preRestore)
|
|
if err != nil {
|
|
t.Fatalf("Stat pre-restore backup: %v", err)
|
|
}
|
|
|
|
got, err := os.Stat(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Stat live db after failed restore: %v", err)
|
|
}
|
|
if got.Size() == 0 {
|
|
t.Error("live database file was left truncated after the failed restore")
|
|
}
|
|
if got.Size() != want.Size() {
|
|
t.Errorf("live db size = %d, want %d (the safety copy should have been put back)", got.Size(), want.Size())
|
|
}
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for !restarted() && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !restarted() {
|
|
t.Error("failed restore did not request a process restart, but the database is closed")
|
|
}
|
|
}
|
|
|
|
// TestHandleRestoreBackup_RestartsWhenCloseFails verifies OC-0209: a failed
|
|
// database.Close() must still schedule a process restart. database.Close()
|
|
// closes the writer and reader pools regardless of the error it returns
|
|
// (Server/db/db.go), and the server_restart broadcast already went out to
|
|
// every client before Close() is even called — so a process that answers 500
|
|
// here without respawning leaves clients pinned on "Reconnecting..." forever
|
|
// while the process quietly keeps failing every request with a closed DB.
|
|
func TestHandleRestoreBackup_RestartsWhenCloseFails(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)
|
|
}
|
|
dbPath := filepath.Join(tmpDir, "data", "chatserver.db")
|
|
if err := os.WriteFile(dbPath, []byte("original live contents"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile live db: %v", err)
|
|
}
|
|
// A real SQLite backup — the restore handler verifies backups with
|
|
// integrity_check before touching the live database, so a text fixture
|
|
// would be (correctly) refused with 400 before the Close-failure branch
|
|
// under test is ever reached.
|
|
backupName := "chatserver_20240103_120000.db"
|
|
if err := database.BackupToSafe(context.Background(), filepath.Join(backupDir, backupName), backupDir); err != nil {
|
|
t.Fatalf("BackupToSafe fixture: %v", err)
|
|
}
|
|
|
|
restarted, restoreRestartHook := admin.StubRestart()
|
|
defer restoreRestartHook()
|
|
restoreCloseHook := admin.StubCloseError("simulated close failure")
|
|
defer restoreCloseHook()
|
|
|
|
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 !restarted() && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !restarted() {
|
|
t.Error("a failed database.Close() did not request a process restart, " +
|
|
"leaving a live server answering requests against closed DB pools")
|
|
}
|
|
}
|
|
|
|
// TestHandleRestoreBackup_AbortsWithoutSafetyBackup verifies the restore fails
|
|
// closed when the pre-restore backup can't be written: the panel promises that
|
|
// safety copy, and overwriting the live database without one is unrecoverable.
|
|
func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(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"
|
|
dbFile := filepath.Join(tmpDir, "data", "chatserver.db")
|
|
if err := database.BackupToSafe(context.Background(), filepath.Join(backupDir, backupName), backupDir); err != nil {
|
|
t.Fatalf("BackupToSafe fixture: %v", err)
|
|
}
|
|
if err := os.WriteFile(dbFile, []byte("original"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile db: %v", err)
|
|
}
|
|
|
|
restarted, restoreHook := admin.StubRestart()
|
|
defer restoreHook()
|
|
|
|
// Make the safety copy impossible: VACUUM INTO refuses a destination that
|
|
// already exists. The name is pre_restore_<UTC seconds>.db, so occupy the
|
|
// next two minutes' worth of candidates — a 4-second window flaked on slow
|
|
// Windows CI runners where the request itself outlived it.
|
|
admin.SetBackupBaseDir(backupDir)
|
|
for i := range 120 {
|
|
name := "pre_restore_" + time.Now().UTC().Add(time.Duration(i)*time.Second).Format("20060102_150405") + ".db"
|
|
if err := os.WriteFile(filepath.Join(backupDir, name), []byte("occupied"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile blocker: %v", err)
|
|
}
|
|
}
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/backups/"+backupName+"/restore", token, nil)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500 (restore must abort); body: %s", w.Code, w.Body.String())
|
|
}
|
|
if restarted() {
|
|
t.Error("aborted restore must not restart the process")
|
|
}
|
|
data, err := os.ReadFile(dbFile)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile db: %v", err)
|
|
}
|
|
if string(data) != "original" {
|
|
t.Errorf("database was overwritten despite the abort: %q", string(data))
|
|
}
|
|
}
|
|
|
|
// TestHandleRestoreBackup_UsesConfiguredDatabasePath verifies that the
|
|
// restore handler writes to the SQLite file the server was actually
|
|
// configured to use (SetDatabasePath), not a hardcoded "data/chatserver.db".
|
|
// A server with database.path set to anything else must not have its real
|
|
// database silently left untouched by a "successful" restore (OC-0097).
|
|
func TestHandleRestoreBackup_UsesConfiguredDatabasePath(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)
|
|
}
|
|
|
|
// Configure a non-default database path, as an operator would via
|
|
// database.path in config.yaml.
|
|
customDBPath := filepath.Join(tmpDir, "custom", "oc.db")
|
|
if err := os.MkdirAll(filepath.Dir(customDBPath), 0o750); err != nil {
|
|
t.Fatalf("MkdirAll custom db dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(customDBPath, []byte("original live contents"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile custom db: %v", err)
|
|
}
|
|
admin.SetDatabasePath(customDBPath)
|
|
t.Cleanup(func() { admin.SetDatabasePath(filepath.Join("data", "chatserver.db")) })
|
|
|
|
backupName := "chatserver_20240101_120000.db"
|
|
backupPath := filepath.Join(backupDir, backupName)
|
|
if err := database.BackupToSafe(context.Background(), backupPath, backupDir); err != nil {
|
|
t.Fatalf("BackupToSafe fixture: %v", err)
|
|
}
|
|
backupContent, err := os.ReadFile(backupPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile fixture: %v", err)
|
|
}
|
|
|
|
restarted, restoreHook := admin.StubRestart()
|
|
defer restoreHook()
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/backups/"+backupName+"/restore", token, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for !restarted() && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !restarted() {
|
|
t.Error("restore did not request a process restart")
|
|
}
|
|
|
|
got, err := os.ReadFile(customDBPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%q): %v", customDBPath, err)
|
|
}
|
|
if !bytes.Equal(got, backupContent) {
|
|
t.Errorf("configured database file content = %q, want %q — restore wrote to the wrong path", got, backupContent)
|
|
}
|
|
|
|
// The hardcoded default path must NOT have been created/touched.
|
|
defaultPath := filepath.Join(tmpDir, "data", "chatserver.db")
|
|
if _, err := os.Stat(defaultPath); err == nil {
|
|
t.Error("restore wrote to the hardcoded default database path instead of the configured one")
|
|
}
|
|
}
|
|
|
|
// TestHandleRestoreBackup_NotFound verifies that restoring a missing backup
|
|
// returns 404.
|
|
func TestHandleRestoreBackup_NotFound(t *testing.T) {
|
|
_ = 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)
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/backups/missing.db/restore", token, nil)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleRestoreBackup_InvalidName verifies that a name containing ".." is
|
|
// rejected with 400.
|
|
func TestHandleRestoreBackup_InvalidName(t *testing.T) {
|
|
_ = 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)
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/backups/..evil.db/restore", token, nil)
|
|
|
|
// Must not return 200 OK.
|
|
if w.Code == http.StatusOK {
|
|
t.Error("path-traversal restore name returned 200 — traversal not blocked")
|
|
}
|
|
}
|
|
|
|
// TestHandleListBackups_ErrorReadingDir verifies that if the backups path
|
|
// exists but is a file (not a directory), the endpoint returns 500.
|
|
func TestHandleListBackups_ErrorReadingDir(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)
|
|
|
|
// Create data/ directory but make "backups" a file instead of a directory.
|
|
dataDir := filepath.Join(tmpDir, "data")
|
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
|
t.Fatalf("MkdirAll data: %v", err)
|
|
}
|
|
backupsFile := filepath.Join(dataDir, "backups")
|
|
if err := os.WriteFile(backupsFile, []byte("not a directory"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
w := doRequest(t, handler, http.MethodGet, "/backups", token, nil)
|
|
|
|
// os.ReadDir on a file (not a directory) fails with a non-IsNotExist error
|
|
// on most platforms, but the exact behavior is platform-dependent.
|
|
// On Windows, ReadDir on a file returns an error that is NOT os.IsNotExist.
|
|
// So we expect either 500 or (in edge cases) 200 with empty list.
|
|
if w.Code != http.StatusInternalServerError && w.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 500 or 200 (platform dependent)", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleRestoreBackup_RequiresOwner verifies that admin-role is denied.
|
|
func TestHandleRestoreBackup_RequiresOwner(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))
|
|
|
|
adminUID, _ := database.CreateUser(context.Background(), "restoreadmin", "hash", 2)
|
|
token := "restore-admin-token"
|
|
_, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1")
|
|
|
|
// Create files so path checks pass before auth check.
|
|
backupDir := filepath.Join(tmpDir, "data", "backups")
|
|
dataDir := filepath.Join(tmpDir, "data")
|
|
_ = os.MkdirAll(backupDir, 0o750)
|
|
_ = os.MkdirAll(dataDir, 0o750)
|
|
_ = os.WriteFile(filepath.Join(backupDir, "test.db"), []byte("x"), 0o644)
|
|
|
|
w := doRequest(t, handler, http.MethodPost, "/backups/test.db/restore", token, nil)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("admin user on restore status = %d, want 403", w.Code)
|
|
}
|
|
}
|