package app import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) func TestRestoreHoldsTransitionLockBeforePlanning(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) origStoreFn := newObjectStoreFromConfigFn origDiscoverFn := discoverRemoteCurrentStateFn origPlanFn := buildRestorePlanFn t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn discoverRemoteCurrentStateFn = origDiscoverFn buildRestorePlanFn = origPlanFn }) current, err := discoverRemoteCurrentState(context.Background(), cfg, fake) if err != nil { t.Fatalf("discoverRemoteCurrentState() error = %v", err) } planning := make(chan struct{}) releasePlanning := make(chan struct{}) newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { return fake, nil } discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) { return current, nil } buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) { close(planning) <-releasePlanning return &RestorePlan{}, nil } restoreDone := make(chan error, 1) go func() { restoreDone <- Restore(context.Background(), []string{ cfg.Session.SessionID, "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &bytes.Buffer{}) }() <-planning runnerCtx, cancelRunner := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancelRunner() if _, err := executeStages(runnerCtx, cfg, nil, RunOptions{}); err == nil || !strings.Contains(err.Error(), "acquire session lock") { t.Fatalf("runner while restore plans error = %v, want session-lock failure", err) } close(releasePlanning) if err := <-restoreDone; err != nil { t.Fatalf("Restore() error = %v", err) } } func TestExecuteStagesRefusesIncompleteRestore(t *testing.T) { cfg := testConfig(t) paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) if err := writeRestoreMarker(paths); err != nil { t.Fatalf("writeRestoreMarker() error = %v", err) } _, err := executeStages(context.Background(), cfg, nil, RunOptions{}) if err == nil || !strings.Contains(err.Error(), "restore is incomplete") { t.Fatalf("executeStages() error = %v, want incomplete restore failure", err) } } func TestForcedRestoreRetryClearsIncompleteMarker(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) artifactKey := sessionPrefix + "artifacts/session_recap.md" transcriptKey := sessionPrefix + "transcripts/full.json" seedRestoreObject(fake, artifactKey, []byte("remote recap\n")) seedRestoreObject(fake, transcriptKey, []byte("remote transcript\n")) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "local recap\n") mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local transcript\n") fake.DownloadHook = func(call storage.FakeDownloadCall) error { if call.Key == transcriptKey { return context.Canceled } return nil } restoreWithStoreAndRealPhases(t, fake) var stdout bytes.Buffer var stderr bytes.Buffer args := []string{"session", "restore", cfg.Session.SessionID, "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"} if code := Execute(args, &stdout, &stderr); code == 0 { t.Fatal("forced restore exit code = 0, want partial-install failure") } markerPath := artifacts.SessionRestoreMarkerPathForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) if _, err := os.Stat(markerPath); err != nil { t.Fatalf("incomplete restore marker missing after partial forced restore: %v", err) } mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "remote recap\n") fake.DownloadHook = nil stdout.Reset() stderr.Reset() if code := Execute(args, &stdout, &stderr); code != 0 { t.Fatalf("restore retry exit code = %d, want 0; stderr=%q", code, stderr.String()) } if _, err := os.Stat(markerPath); !os.IsNotExist(err) { t.Fatalf("incomplete restore marker should be cleared after retry; stat err=%v", err) } mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote transcript\n") } func TestRebaseRestoredManifestPathsRejectsUnsafeReferencesAndRebasesPortablePaths(t *testing.T) { for _, test := range []struct { name string workDir string output string wantPath string wantError string }{ { name: "unix absolute path within producer root", workDir: "/producer/work/sample-campaign/2026-05-03/runs/20260519T010203Z-a1b2c3d4", output: "/producer/work/sample-campaign/2026-05-03/artifacts/recap.md", wantPath: "artifacts/recap.md", }, { name: "windows absolute path within producer root", workDir: `C:\producer\work\sample-campaign\2026-05-03\runs\20260519T010203Z-a1b2c3d4`, output: `C:\producer\work\sample-campaign\2026-05-03\artifacts\recap.md`, wantPath: "artifacts/recap.md", }, { name: "absolute path outside producer root", workDir: "/producer/work/sample-campaign/2026-05-03/runs/20260519T010203Z-a1b2c3d4", output: "/another-machine/recap.md", wantError: "outside the producer session root", }, { name: "relative traversal", workDir: "/producer/work/sample-campaign/2026-05-03/runs/20260519T010203Z-a1b2c3d4", output: "../recap.md", wantError: "unsafe relative path", }, } { t.Run(test.name, func(t *testing.T) { cfg := testConfig(t) m := manifest.New(cfg.Session.SessionID, nowUTC()) m.Campaign = cfg.Session.Campaign m.RunID = "20260519T010203Z-a1b2c3d4" m.LocalWorkDir = test.workDir m.Stages["analyze"] = &manifest.StageRecord{Outputs: []manifest.ArtifactRecord{{Kind: "recap", LocalPath: test.output}}} destination := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) err := rebaseRestoredManifestPaths(cfg, m, destination) if test.wantError != "" { if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Fatalf("rebaseRestoredManifestPaths() error = %v, want %q", err, test.wantError) } return } if err != nil { t.Fatalf("rebaseRestoredManifestPaths() error = %v", err) } if got, want := m.Stages["analyze"].Outputs[0].LocalPath, filepath.Join(destination, test.wantPath); got != want { t.Fatalf("rebased local path = %q, want %q", got, want) } }) } }