Files
beads/cmd/bd/doctor_repair_chaos_test.go
Jordan Hubbard 8166207eb4 doctor: add JSONL integrity check/fix and harden repairs
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-26 08:18:25 -04:00

306 lines
9.9 KiB
Go

//go:build chaos
package main
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDoctorRepair_CorruptDatabase_NotADatabase_RebuildFromJSONL(t *testing.T) {
bdExe := buildBDForTest(t)
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-*")
dbPath := filepath.Join(ws, ".beads", "beads.db")
jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl")
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
t.Fatalf("bd init failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
t.Fatalf("bd create failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil {
t.Fatalf("bd export failed: %v", err)
}
// Make the DB unreadable.
if err := os.WriteFile(dbPath, []byte("not a database"), 0644); err != nil {
t.Fatalf("corrupt db: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil {
t.Fatalf("bd doctor --fix failed: %v", err)
}
if out, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor"); err != nil {
t.Fatalf("bd doctor after fix failed: %v\n%s", err, out)
}
}
func TestDoctorRepair_CorruptDatabase_NoJSONL_FixFails(t *testing.T) {
bdExe := buildBDForTest(t)
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-nojsonl-*")
dbPath := filepath.Join(ws, ".beads", "beads.db")
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
t.Fatalf("bd init failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
t.Fatalf("bd create failed: %v", err)
}
// Some workflows keep JSONL in sync automatically; force it to be missing.
_ = os.Remove(filepath.Join(ws, ".beads", "issues.jsonl"))
_ = os.Remove(filepath.Join(ws, ".beads", "beads.jsonl"))
// Corrupt without providing JSONL source-of-truth.
if err := os.Truncate(dbPath, 64); err != nil {
t.Fatalf("truncate db: %v", err)
}
out, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes")
if err == nil {
t.Fatalf("expected bd doctor --fix to fail without JSONL")
}
if !strings.Contains(out, "cannot auto-recover") {
t.Fatalf("expected auto-recover error, got:\n%s", out)
}
// Ensure we don't mis-configure jsonl_export to a system file during failure.
metadata, readErr := os.ReadFile(filepath.Join(ws, ".beads", "metadata.json"))
if readErr == nil {
if strings.Contains(string(metadata), "interactions.jsonl") {
t.Fatalf("unexpected metadata.json jsonl_export set to interactions.jsonl:\n%s", string(metadata))
}
}
}
func TestDoctorRepair_CorruptDatabase_BacksUpSidecars(t *testing.T) {
bdExe := buildBDForTest(t)
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-sidecars-*")
dbPath := filepath.Join(ws, ".beads", "beads.db")
jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl")
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
t.Fatalf("bd init failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
t.Fatalf("bd create failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil {
t.Fatalf("bd export failed: %v", err)
}
// Ensure sidecars exist so we can verify they get moved with the backup.
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
if err := os.WriteFile(dbPath+suffix, []byte("x"), 0644); err != nil {
t.Fatalf("write sidecar %s: %v", suffix, err)
}
}
if err := os.Truncate(dbPath, 64); err != nil {
t.Fatalf("truncate db: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil {
t.Fatalf("bd doctor --fix failed: %v", err)
}
// Verify a backup exists, and at least one sidecar got moved.
entries, err := os.ReadDir(filepath.Join(ws, ".beads"))
if err != nil {
t.Fatalf("readdir: %v", err)
}
var backup string
for _, e := range entries {
if strings.Contains(e.Name(), ".corrupt.backup.db") {
backup = filepath.Join(ws, ".beads", e.Name())
break
}
}
if backup == "" {
t.Fatalf("expected backup db in .beads, found none")
}
wal := backup + "-wal"
if _, err := os.Stat(wal); err != nil {
// At minimum, the backup DB itself should exist; sidecar backup is best-effort.
if _, err2 := os.Stat(backup); err2 != nil {
t.Fatalf("backup db missing: %v", err2)
}
}
}
func TestDoctorRepair_CorruptDatabase_WithRunningDaemon_FixSucceeds(t *testing.T) {
bdExe := buildBDForTest(t)
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-daemon-*")
dbPath := filepath.Join(ws, ".beads", "beads.db")
jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl")
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
t.Fatalf("bd init failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
t.Fatalf("bd create failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil {
t.Fatalf("bd export failed: %v", err)
}
cmd := startDaemonForChaosTest(t, bdExe, ws, dbPath)
defer func() {
if cmd.Process != nil && (cmd.ProcessState == nil || !cmd.ProcessState.Exited()) {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}()
// Corrupt the DB.
if err := os.WriteFile(dbPath, []byte("not a database"), 0644); err != nil {
t.Fatalf("corrupt db: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil {
t.Fatalf("bd doctor --fix failed: %v", err)
}
// Ensure we can cleanly stop the daemon afterwards (repair shouldn't wedge it).
if cmd.Process != nil {
_ = cmd.Process.Kill()
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-time.After(3 * time.Second):
t.Fatalf("expected daemon to exit when killed")
case <-done:
// ok
}
}
}
func TestDoctorRepair_JSONLIntegrity_MalformedLine_ReexportFromDB(t *testing.T) {
bdExe := buildBDForTest(t)
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-jsonl-*")
dbPath := filepath.Join(ws, ".beads", "beads.db")
jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl")
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
t.Fatalf("bd init failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
t.Fatalf("bd create failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil {
t.Fatalf("bd export failed: %v", err)
}
// Corrupt JSONL (leave DB intact).
f, err := os.OpenFile(jsonlPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
t.Fatalf("open jsonl: %v", err)
}
if _, err := f.WriteString("{not json}\n"); err != nil {
_ = f.Close()
t.Fatalf("append corrupt jsonl: %v", err)
}
_ = f.Close()
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil {
t.Fatalf("bd doctor --fix failed: %v", err)
}
data, err := os.ReadFile(jsonlPath)
if err != nil {
t.Fatalf("read jsonl: %v", err)
}
if strings.Contains(string(data), "{not json}") {
t.Fatalf("expected JSONL to be regenerated without corrupt line")
}
}
func TestDoctorRepair_CorruptDatabase_ReadOnlyBeadsDir_PermissionsFixMakesWritable(t *testing.T) {
bdExe := buildBDForTest(t)
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-readonly-*")
beadsDir := filepath.Join(ws, ".beads")
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
t.Fatalf("bd init failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
t.Fatalf("bd create failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil {
t.Fatalf("bd export failed: %v", err)
}
// Corrupt the DB.
if err := os.Truncate(dbPath, 64); err != nil {
t.Fatalf("truncate db: %v", err)
}
// Make .beads read-only; the Permissions fix should make it writable again.
if err := os.Chmod(beadsDir, 0555); err != nil {
t.Fatalf("chmod beads dir: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(beadsDir, 0755) })
if out, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil {
t.Fatalf("expected bd doctor --fix to succeed (permissions auto-fix), got: %v\n%s", err, out)
}
info, err := os.Stat(beadsDir)
if err != nil {
t.Fatalf("stat beads dir: %v", err)
}
if info.Mode().Perm()&0200 == 0 {
t.Fatalf("expected .beads to be writable after permissions fix, mode=%v", info.Mode().Perm())
}
}
func startDaemonForChaosTest(t *testing.T, bdExe, ws, dbPath string) *exec.Cmd {
t.Helper()
cmd := exec.Command(bdExe, "--db", dbPath, "daemon", "--start", "--foreground", "--local", "--interval", "10m")
cmd.Dir = ws
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// Inherit environment, but explicitly ensure daemon mode is allowed.
env := make([]string, 0, len(os.Environ())+1)
for _, e := range os.Environ() {
if strings.HasPrefix(e, "BEADS_NO_DAEMON=") {
continue
}
env = append(env, e)
}
cmd.Env = env
if err := cmd.Start(); err != nil {
t.Fatalf("start daemon: %v", err)
}
// Wait for socket to appear.
sock := filepath.Join(ws, ".beads", "bd.sock")
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(sock); err == nil {
// Put the process back into the caller's control.
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
return cmd
}
time.Sleep(50 * time.Millisecond)
}
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("daemon failed to start (no socket: %s)\nstdout:\n%s\nstderr:\n%s", sock, stdout.String(), stderr.String())
return nil
}