mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(client): 1 defect(s) (OC-0201)
* fix(service): 1 defect(s) (OC-0202)
HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.
* fix(client): 2 defect(s) (OC-0203, OC-0224)
* fix(server): 1 defect(s) (OC-0204)
* fix(ws): 2 defect(s) (OC-0205, OC-0211)
* fix(admin): 2 defect(s) (OC-0209, OC-0212)
* fix(client): 1 defect(s) (OC-0210)
* fix(db): 1 defect(s) (OC-0213)
* fix(ws): 1 defect(s) (OC-0214)
Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.
* fix(admin): 1 defect(s) (OC-0215)
PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.
* fix(db): 1 defect(s) (OC-0216)
LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.
* fix(emoji): 1 defect(s) (OC-0217)
* fix(client): 1 defect(s) (OC-0218)
The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.
Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.
Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* fix(ws): 1 defect(s) (OC-0219)
* fix(client): 1 defect(s) (OC-0221)
UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().
* fix(dm): 1 defect(s) (OC-0222)
* fix(client): 1 defect(s) (OC-0223)
* fix(voice): 1 defect(s) (OC-0225)
The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().
* fix(admin): 1 defect(s) (OC-0226)
handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.
* fix(admin): 1 defect(s) (OC-0227)
PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.
* fix(identity): 1 defect(s) (OC-0228)
* fix(admin): run deferred cleanup before the update restart exits
The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.
applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.
Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* test(ws): pin the live presence path against the invisible custom-status leak
OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.
This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* fix(ws): 1 defect(s) (OC-0206)
* test(ws): silence a contextcheck false positive in the reconnect race test
RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.
golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
---------
Co-authored-by: Claude <noreply@anthropic.com>
673 lines
25 KiB
Go
673 lines
25 KiB
Go
package admin_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/owncord/server/admin"
|
|
"github.com/owncord/server/auth"
|
|
"github.com/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)
|
|
}
|
|
|
|
// Write content as the "backup" to restore from.
|
|
backupName := "chatserver_20240101_120000.db"
|
|
backupPath := filepath.Join(backupDir, backupName)
|
|
fakeContent := []byte("fake sqlite db content")
|
|
if err := os.WriteFile(backupPath, fakeContent, 0o644); err != nil {
|
|
t.Fatalf("WriteFile backup: %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)
|
|
}
|
|
|
|
backupName := "chatserver_20240102_120000.db"
|
|
if err := os.MkdirAll(filepath.Join(backupDir, backupName), 0o750); err != nil {
|
|
t.Fatalf("MkdirAll fake backup: %v", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
backupName := "chatserver_20240103_120000.db"
|
|
if err := os.WriteFile(filepath.Join(backupDir, backupName), []byte("replacement contents"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile backup: %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 := os.WriteFile(filepath.Join(backupDir, backupName), []byte("replacement"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile backup: %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"
|
|
backupContent := []byte("restored contents")
|
|
if err := os.WriteFile(filepath.Join(backupDir, backupName), backupContent, 0o644); err != nil {
|
|
t.Fatalf("WriteFile backup: %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)
|
|
}
|
|
}
|