Files
OwnCord/Server/admin/handlers_backup_test.go
T
Claude 7a4e5dc357 refactor: rename the Go module to github.com/J3vb/OwnCord/Server (RL-13)
`Server/go.mod` declared `github.com/owncord/server` while the public
repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is
no `owncord` GitHub org and no vanity-import host serving go-import metadata
for it — so every import line in the tree named a location that does not
exist. It compiles because a main module's own path is never fetched, which is
exactly why it went unnoticed.

The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) —
is wrong here, and provably so. Six of the 722 occurrences are not imports at
all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern),
`telemetry/metrics.go:17-19` (three OTel instrumentation-scope names),
`invariants/syncutil_locks.go:73` (a diagnostic message), and
`invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go
fixture). An import rewriter touches none of them, and the compiler cannot
see any of them either.

Done as one scripted substitution over `git ls-files`, anchored on the full
`github.com/owncord/server` string. The anchor matters: `owncord-server` is a
different identifier — the OTel `service.name` (`config/config.go`,
`telemetry/telemetry_otel.go`) and the GHCR image name
(`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern
would have moved it. It is untouched: 10 occurrences across 9 files, before
and after.

350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files,
plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`,
`docs/architecture/server.md:5`, and the ledger pair
(`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of
`FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in
`Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard`
rule keys on the module path, so import grouping is not configured anywhere).

The plan's blast-radius estimate missed one thing, and it is the one that
would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase
letter, so in the 36 files where a module-local import shares a contiguous
group with a third-party one, the module's imports must move above
`github.com/go-chi/...`. `gofmt -l` was clean before the substitution and
listed exactly 36 files after it; `gofmt -w` on those 36 restores it to
clean. `gofmt` is an enforced gate — the `formatters` block in
`Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails
Lint.

Verified: both directions, and the line accounting is exact. Every added line
in this diff contains the new module path (728) and every removed line
contains the old one (728); the count of changed lines containing neither is
**zero**, so the gofmt re-sort moved module-path lines only and touched no
third-party import. The residual check
(`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns
exactly two hits, both deliberately out of scope: the RL-13 row in
`docs/audit-2026-08-23-repository-layout.md` and the measurement row in this
phase's own plan. The compiler-invisible half was proven by reverting *only*
`api/main_test.go:20` to the old path on the otherwise-renamed tree:
`go build ./...` and `go vet ./api/` both still pass — they see nothing wrong
— while `go test ./api/` FAILS, because the runtime function name now carries
the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the
line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and
none was needed). All four build-tag variants compile; `go vet ./...`,
`go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass;
`go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...`
passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel)
runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally
against Go 1.26 because the packaged binary cannot load a 1.26 config —
reports **0 issues**. `go run ./cmd/genprotocol` leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the rename does not reach the generated protocol constants.
`npx prettier --check .` and `node .superpowers/render-ledger.mjs --check`
pass.

Not included: `docs/audit-2026-08-23-repository-layout.md` and
`docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they
are the audit row and the measurement that motivated this change, and
rewriting them would erase the record of what was measured. They are why the
residual check needs a two-path allowance rather than being empty; that
allowance is stated above rather than hidden in a pathspec.
`telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package
that does not exist; the substitution carried the dead path forward verbatim
as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because
correcting a real observability bug inside a mechanical rename would hide it
in a 350-file diff. It needs its own item. No `go.work`, no second module,
and no vanity-import host was set up — the new path resolves against the real
repository, but nothing imports this module as a library, so `go get`
reachability was not exercised either way.

Refs RL-13, L-12
2026-08-26 20:23:49 +00:00

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))
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))
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))
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))
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))
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))
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))
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))
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))
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))
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))
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))
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))
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))
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))
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))
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))
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)
}
}