package runner import ( "context" "strings" "testing" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" ) // TestRunRestoreShipsExpectedEnvelopes: a fake restic emits a couple // of restore status lines and a summary; the runner translates them // into job.progress envelopes and finishes the job successfully. func TestRunRestoreShipsExpectedEnvelopes(t *testing.T) { t.Parallel() bin := setupScript(t, ` case "$1" in restore) echo '{"message_type":"status","seconds_elapsed":1,"percent_done":0.5,"total_files":10,"files_restored":5,"total_bytes":1000,"bytes_restored":500}' echo '{"message_type":"status","seconds_elapsed":2,"percent_done":1.0,"total_files":10,"files_restored":10,"total_bytes":1000,"bytes_restored":1000}' echo '{"message_type":"summary","seconds_elapsed":2,"total_files":10,"files_restored":10,"total_bytes":1000,"bytes_restored":1000}' ;; *) echo "unknown: $*" ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunRestore(context.Background(), "job-r1", "f3a7b2c1", []string{"/etc/nginx/sites-available/alfa.conf"}, false, "/tmp/restore-out"); err != nil { t.Fatalf("RunRestore: %v", err) } // Confirm landmarks: started → progress → finished. order := envelopeOrder(tx.envs) wants := []api.MessageType{api.MsgJobStarted, api.MsgJobProgress, 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(wants)-1; i++ { a, b := wants[i], wants[i+1] pa, aOK := positions[a] pb, bOK := positions[b] if !aOK { t.Fatalf("envelope %q not found in %v", a, order) } if !bOK { t.Fatalf("envelope %q not found in %v", b, order) } if pa >= pb { t.Fatalf("expected %q before %q (positions %d, %d)", a, b, pa, pb) } } // Started carries the right kind. startEnv := firstEnvOfType(t, tx.envs, api.MsgJobStarted) var startP api.JobStartedPayload if err := startEnv.UnmarshalPayload(&startP); err != nil { t.Fatalf("unmarshal started: %v", err) } if startP.Kind != api.JobRestore { t.Fatalf("kind: got %q want %q", startP.Kind, api.JobRestore) } // Finished is succeeded. finEnv := firstEnvOfType(t, tx.envs, api.MsgJobFinished) var finP api.JobFinishedPayload if err := finEnv.UnmarshalPayload(&finP); err != nil { t.Fatalf("unmarshal finished: %v", err) } if finP.Status != api.JobSucceeded { t.Fatalf("status: got %q want %q", finP.Status, api.JobSucceeded) } // Progress envelope reflects the last status line: 100% with 10 files. progEnv := firstEnvOfType(t, tx.envs, api.MsgJobProgress) var progP api.JobProgressPayload if err := progEnv.UnmarshalPayload(&progP); err != nil { t.Fatalf("unmarshal progress: %v", err) } // First progress will be from line 1 (50%) since we send first status // immediately. Verify we at least see a sensible value. if progP.PercentDone <= 0 { t.Fatalf("expected non-zero progress, got %v", progP.PercentDone) } if progP.FilesDone <= 0 || progP.TotalFiles <= 0 { t.Fatalf("expected file counters set, got %+v", progP) } } // TestRunRestoreInPlaceArgvHasNoNoOwnership: indirectly verifies that // in-place mode doesn't pass --no-ownership. We can't see the actual // argv without a custom test harness, so we use a fake restic that // echoes its args and check the captured log.stream. func TestRunRestoreInPlaceArgvHasNoNoOwnership(t *testing.T) { t.Parallel() bin := setupScript(t, ` case "$1" in restore) # Print all args on stderr so they're forwarded as log.stream. echo "argv: $*" 1>&2 echo '{"message_type":"summary","seconds_elapsed":0,"total_files":0,"files_restored":0,"total_bytes":0,"bytes_restored":0}' ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunRestore(context.Background(), "job-r2", "abc", nil, true, ""); err != nil { t.Fatalf("RunRestore: %v", err) } // Reconstruct the argv from the captured stderr log line. var argv string for _, e := range tx.envs { if e.Type == api.MsgLogStream { var p api.LogStreamLine _ = e.UnmarshalPayload(&p) if p.Stream == api.LogStderr && strings.HasPrefix(p.Payload, "argv:") { argv = p.Payload break } } } if argv == "" { t.Fatal("never captured argv echo from fake restic") } if strings.Contains(argv, "--no-ownership") { t.Errorf("in-place restore should NOT pass --no-ownership; got argv=%q", argv) } if !strings.Contains(argv, "--target /") { t.Errorf("in-place restore should pass --target /; got argv=%q", argv) } } // TestRunRestoreNewDirArgvHasNoOwnership: complement of the above — // non-in-place restore must include --no-ownership. func TestRunRestoreNewDirArgvHasNoOwnership(t *testing.T) { t.Parallel() bin := setupScript(t, ` case "$1" in restore) echo "argv: $*" 1>&2 echo '{"message_type":"summary","seconds_elapsed":0,"total_files":0,"files_restored":0,"total_bytes":0,"bytes_restored":0}' ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunRestore(context.Background(), "job-r3", "abc", []string{"/etc/foo"}, false, "/tmp/restore-out"); err != nil { t.Fatalf("RunRestore: %v", err) } var argv string for _, e := range tx.envs { if e.Type == api.MsgLogStream { var p api.LogStreamLine _ = e.UnmarshalPayload(&p) if p.Stream == api.LogStderr && strings.HasPrefix(p.Payload, "argv:") { argv = p.Payload break } } } if argv == "" { t.Fatal("no argv echo") } if !strings.Contains(argv, "--no-ownership") { t.Errorf("new-dir restore should pass --no-ownership; got argv=%q", argv) } if !strings.Contains(argv, "--target /tmp/restore-out") { t.Errorf("expected --target /tmp/restore-out; got argv=%q", argv) } if !strings.Contains(argv, "--include /etc/foo") { t.Errorf("expected --include /etc/foo; got argv=%q", argv) } } // TestRunDiffShipsLogLines: diff output is forwarded as log.stream. func TestRunDiffShipsLogLines(t *testing.T) { t.Parallel() bin := setupScript(t, ` case "$1" in diff) echo '{"message_type":"change","path":"/etc/nginx/nginx.conf","modifier":"M"}' echo '{"message_type":"statistics","added":{"files":0,"dirs":0}}' ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunDiff(context.Background(), "job-d1", "snap-a", "snap-b"); err != nil { t.Fatalf("RunDiff: %v", err) } startEnv := firstEnvOfType(t, tx.envs, api.MsgJobStarted) var startP api.JobStartedPayload _ = startEnv.UnmarshalPayload(&startP) if startP.Kind != api.JobDiff { t.Fatalf("kind: got %q want %q", startP.Kind, api.JobDiff) } finEnv := firstEnvOfType(t, tx.envs, api.MsgJobFinished) var finP api.JobFinishedPayload _ = finEnv.UnmarshalPayload(&finP) if finP.Status != api.JobSucceeded { t.Fatalf("status: %q", finP.Status) } // At least one log line should carry the change payload. var sawChange bool for _, e := range tx.envs { if e.Type != api.MsgLogStream { continue } var p api.LogStreamLine _ = e.UnmarshalPayload(&p) if strings.Contains(p.Payload, `"message_type":"change"`) { sawChange = true } } if !sawChange { t.Fatal("never saw a change log line in diff output") } }