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