package runner import ( "context" "errors" "os" "os/exec" "path/filepath" "sync" "syscall" "testing" "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/restic" ) // fakeSender collects sent envelopes for assertions. Lock-protected // because the runner's pumpStdout / pumpStderr goroutines call Send // concurrently — without the mutex, -race in CI flags every test // that exercises a Run* method with both pumps active. type fakeSender struct { mu sync.Mutex envs []api.Envelope } func (s *fakeSender) Send(e api.Envelope) error { s.mu.Lock() s.envs = append(s.envs, e) s.mu.Unlock() return nil } // snapshot returns a copy of the captured envelopes safe to read // without holding the lock. Tests use this when iterating envs while // other goroutines may still be writing — though in practice all // runner Run* methods join their pumps before returning, so callers // can also read .envs directly post-return. func (s *fakeSender) snapshot() []api.Envelope { s.mu.Lock() defer s.mu.Unlock() out := make([]api.Envelope, len(s.envs)) copy(out, s.envs) return out } // setupScript writes a shell script (without shebang) to a temp dir, // names it "restic", makes it executable, and returns the path. // // Writes to ".tmp" then renames into place. The rename is the // usual guard against ETXTBSY: under -race + many t.Parallel tests, // a fork-from-another-goroutine can inherit the writable fd from // os.WriteFile before close completes, and exec'ing the file then // returns ETXTBSY ("text file busy"). The renamed dirent points at // an inode that has no writable fd open anywhere — exec is safe on // a vanilla filesystem. // // On overlayfs (every job that runs inside a `container:` block on // our Gitea runner), the rename can briefly leak ETXTBSY anyway — // the upper layer's "writable inode" bookkeeping lags the userspace // close. To make the helper deterministic across environments, we // probe-exec the file with a benign argument until exec succeeds, // then return. Each script body has a `case "$1" in ... esac` shape // where unknown args fall through to a clean exit, so the probe is // a no-op from the test's point of view. func setupScript(t *testing.T, body string) string { t.Helper() dir := t.TempDir() final := filepath.Join(dir, "restic") tmp := final + ".tmp" if err := os.WriteFile(tmp, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { t.Fatalf("setupScript: write tmp: %v", err) } if err := os.Rename(tmp, final); err != nil { t.Fatalf("setupScript: rename: %v", err) } deadline := time.Now().Add(3 * time.Second) for { err := exec.Command(final, "__rm_probe__").Run() if err == nil { return final } if !errors.Is(err, syscall.ETXTBSY) { t.Fatalf("setupScript: probe exec: %v", err) } if time.Now().After(deadline) { t.Fatalf("setupScript: %s still ETXTBSY after 3s", final) } time.Sleep(10 * time.Millisecond) } } // firstEnvOfType returns the first envelope with the given type, or // fails the test if none is found. func firstEnvOfType(t *testing.T, envs []api.Envelope, mt api.MessageType) api.Envelope { t.Helper() for _, e := range envs { if e.Type == mt { return e } } t.Fatalf("no envelope of type %q found in %d envelopes", mt, len(envs)) return api.Envelope{} } // envelopeOrder returns the message types of all sent envelopes. func envelopeOrder(envs []api.Envelope) []api.MessageType { out := make([]api.MessageType, len(envs)) for i, e := range envs { out[i] = e.Type } return out } // TestRunPruneShipsExpectedEnvelopes drives RunPrune with a fake // binary that prints "prune" on stdout (for the log.stream envelope) // and emits valid stats JSON so reportStats can populate size fields. // Expected sequence: job.started → log.stream → repo.stats → job.finished. func TestRunPruneShipsExpectedEnvelopes(t *testing.T) { t.Parallel() // The fake "restic" handles both "prune" and "stats --json" calls. statsJSON := `{"total_size":1000,"total_uncompressed_size":2000,"snapshots_count":3,"total_file_count":10}` bin := setupScript(t, ` case "$1" in prune) echo "prune" ;; stats) echo '`+statsJSON+`' ;; *) echo "unknown: $*" ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunPrune(context.Background(), "job-1"); err != nil { t.Fatalf("RunPrune: %v", err) } order := envelopeOrder(tx.envs) // Confirm landmark envelope types appear in the required order. wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished} positions := map[api.MessageType]int{} for i, mt := range order { if _, seen := positions[mt]; !seen { positions[mt] = i } } for i := 0; i < len(wantTypes)-1; i++ { a, b := wantTypes[i], wantTypes[i+1] pa, aOK := positions[a] pb, bOK := positions[b] if !aOK { t.Errorf("envelope type %q not found in output %v", a, order) continue } if !bOK { t.Errorf("envelope type %q not found in output %v", b, order) continue } if pa >= pb { t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order) } } // The repo.stats payload must have LastPruneAt set. statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats) var statsPayload api.RepoStatsPayload if err := statsEnv.UnmarshalPayload(&statsPayload); err != nil { t.Fatalf("unmarshal repo.stats payload: %v", err) } if statsPayload.LastPruneAt == nil { t.Error("expected LastPruneAt to be set in repo.stats after prune") } // The job.finished payload must indicate success. finEnv := firstEnvOfType(t, tx.envs, api.MsgJobFinished) var finPayload api.JobFinishedPayload if err := finEnv.UnmarshalPayload(&finPayload); err != nil { t.Fatalf("unmarshal job.finished payload: %v", err) } if finPayload.Status != api.JobSucceeded { t.Errorf("expected job.finished status=%q, got %q", api.JobSucceeded, finPayload.Status) } } // TestRunCheckShipsCheckStatus verifies that a check run which emits // a stale-lock line on stderr (exit 0) reports LastCheckStatus="ok" // and LockPresent=true. func TestRunCheckShipsCheckStatus(t *testing.T) { t.Parallel() statsJSON := `{"total_size":500,"total_uncompressed_size":600,"snapshots_count":1,"total_file_count":5}` bin := setupScript(t, ` case "$1" in check) echo "Found stale lock" >&2; exit 0 ;; stats) echo '`+statsJSON+`' ;; *) exit 0 ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunCheck(context.Background(), "job-2", 0); err != nil { t.Fatalf("RunCheck: %v", err) } // Assert envelope ordering: job.started → log.stream → repo.stats → job.finished. order := envelopeOrder(tx.envs) wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished} positions := map[api.MessageType]int{} for i, mt := range order { if _, seen := positions[mt]; !seen { positions[mt] = i } } for i := 0; i < len(wantTypes)-1; i++ { a, b := wantTypes[i], wantTypes[i+1] pa, aOK := positions[a] pb, bOK := positions[b] if !aOK { t.Errorf("envelope type %q not found in output %v", a, order) continue } if !bOK { t.Errorf("envelope type %q not found in output %v", b, order) continue } if pa >= pb { t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order) } } statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats) var p api.RepoStatsPayload if err := statsEnv.UnmarshalPayload(&p); err != nil { t.Fatalf("unmarshal: %v", err) } if p.LastCheckStatus != "ok" { t.Errorf("LastCheckStatus: got %q, want %q", p.LastCheckStatus, "ok") } if p.LockPresent == nil || !*p.LockPresent { t.Errorf("expected LockPresent=true, got %v", p.LockPresent) } if p.LastCheckAt == nil { t.Error("expected LastCheckAt to be set") } } // TestRunCheckErrorsFoundShipsErrorsStatus verifies that a check run // that exits 1 (errors found) reports LastCheckStatus="errors_found". func TestRunCheckErrorsFoundShipsErrorsStatus(t *testing.T) { t.Parallel() statsJSON := `{"total_size":500,"total_uncompressed_size":600,"snapshots_count":1,"total_file_count":5}` bin := setupScript(t, ` case "$1" in check) exit 1 ;; stats) echo '`+statsJSON+`' ;; *) exit 0 ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) // RunCheck returns nil for exit 1 (errors_found is not a wrapper failure). if err := r.RunCheck(context.Background(), "job-3", 0); err != nil { t.Fatalf("RunCheck: %v", err) } // Assert envelope ordering: job.started → repo.stats → job.finished. // (No log.stream expected because the fake script produces no // output before exit 1 — a real restic check would emit log lines // before exiting non-zero.) order := envelopeOrder(tx.envs) wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgRepoStats, api.MsgJobFinished} positions := map[api.MessageType]int{} for i, mt := range order { if _, seen := positions[mt]; !seen { positions[mt] = i } } for i := 0; i < len(wantTypes)-1; i++ { a, b := wantTypes[i], wantTypes[i+1] pa, aOK := positions[a] pb, bOK := positions[b] if !aOK { t.Errorf("envelope type %q not found in output %v", a, order) continue } if !bOK { t.Errorf("envelope type %q not found in output %v", b, order) continue } if pa >= pb { t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order) } } statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats) var p api.RepoStatsPayload if err := statsEnv.UnmarshalPayload(&p); err != nil { t.Fatalf("unmarshal: %v", err) } if p.LastCheckStatus != "errors_found" { t.Errorf("LastCheckStatus: got %q, want %q", p.LastCheckStatus, "errors_found") } } // TestRunUnlockClearsLock verifies that a successful unlock ships a // repo.stats envelope with LockPresent=false. func TestRunUnlockClearsLock(t *testing.T) { t.Parallel() statsJSON := `{"total_size":100,"total_uncompressed_size":150,"snapshots_count":2,"total_file_count":8}` bin := setupScript(t, ` case "$1" in unlock) echo "removed 1 locks" ;; stats) echo '`+statsJSON+`' ;; *) exit 0 ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunUnlock(context.Background(), "job-4"); err != nil { t.Fatalf("RunUnlock: %v", err) } // Assert envelope ordering: job.started → log.stream → repo.stats → job.finished. order := envelopeOrder(tx.envs) wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished} positions := map[api.MessageType]int{} for i, mt := range order { if _, seen := positions[mt]; !seen { positions[mt] = i } } for i := 0; i < len(wantTypes)-1; i++ { a, b := wantTypes[i], wantTypes[i+1] pa, aOK := positions[a] pb, bOK := positions[b] if !aOK { t.Errorf("envelope type %q not found in output %v", a, order) continue } if !bOK { t.Errorf("envelope type %q not found in output %v", b, order) continue } if pa >= pb { t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order) } } statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats) var p api.RepoStatsPayload if err := statsEnv.UnmarshalPayload(&p); err != nil { t.Fatalf("unmarshal: %v", err) } if p.LockPresent == nil { t.Fatal("expected LockPresent to be set (non-nil)") } if *p.LockPresent { t.Errorf("expected LockPresent=false after unlock, got true") } } // TestRunInitShipsStartedAndFinished confirms the refactored RunInit // still produces job.started and job.finished envelopes. func TestRunInitShipsStartedAndFinished(t *testing.T) { t.Parallel() bin := setupScript(t, `echo "initialised repository"`) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunInit(context.Background(), "job-init"); err != nil { t.Fatalf("RunInit: %v", err) } _ = firstEnvOfType(t, tx.envs, api.MsgJobStarted) _ = firstEnvOfType(t, tx.envs, api.MsgJobFinished) } // TestRunForgetShipsStartedAndFinished confirms the refactored // RunForget still produces job.started and job.finished envelopes. func TestRunForgetShipsStartedAndFinished(t *testing.T) { t.Parallel() // Script handles both "forget --json ..." and "snapshots --json" calls. bin := setupScript(t, ` case "$1" in forget) echo "[]" ;; snapshots) echo "[]" ;; *) exit 0 ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) keepLast := 1 groups := []restic.ForgetGroup{{ Tag: "documents", Policy: restic.ForgetPolicy{KeepLast: &keepLast}, }} if err := r.RunForget(context.Background(), "job-forget", groups); err != nil { t.Fatalf("RunForget: %v", err) } _ = firstEnvOfType(t, tx.envs, api.MsgJobStarted) _ = firstEnvOfType(t, tx.envs, api.MsgJobFinished) }