package app import ( "context" "os" "path/filepath" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" ) func TestRestorePlanDefaultScope(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`)) seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n")) seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio")) seedRestoreObject(store, current.SessionPrefix+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}")) seedRestoreObject(store, current.SessionPrefix+"logs/publish.log", []byte("log")) plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"artifacts/session_recap.md", "manifest.json", "transcripts/full.json"} if !reflect.DeepEqual(got, want) { t.Fatalf("action local paths = %#v, want %#v", got, want) } if plan.DownloadCount != 3 || plan.SkipSameCount != 0 || plan.ConflictCount != 0 { t.Fatalf("counts = download=%d skip_same=%d conflict=%d, want 3/0/0", plan.DownloadCount, plan.SkipSameCount, plan.ConflictCount) } } func TestRestorePlanIncludeAudio(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio")) plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"audio/alice.flac", "manifest.json"} if !reflect.DeepEqual(got, want) { t.Fatalf("action local paths = %#v, want %#v", got, want) } } func TestRestorePlanExistingAudioRequiresVerifiedReplacement(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio")) sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) mustWriteTestFile(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "local") plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } if len(store.Downloads) != 0 { t.Fatalf("downloads = %d, want no remote checksum download for audio", len(store.Downloads)) } actionByRel := map[string]RestoreAction{} for _, action := range plan.Actions { actionByRel[action.LocalRelativePath] = action } audioAction := actionByRel["audio/alice.flac"] if audioAction.Kind != RestoreActionConflict { t.Fatalf("audio action kind = %q, want %q", audioAction.Kind, RestoreActionConflict) } forcedPlan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true, Force: true}) if err != nil { t.Fatalf("forced buildRestorePlan() error = %v", err) } for _, action := range forcedPlan.Actions { if action.LocalRelativePath == "audio/alice.flac" && action.Kind != RestoreActionDownload { t.Fatalf("forced audio action kind = %q, want %q", action.Kind, RestoreActionDownload) } } } func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) { cfg := restorePlanConfig(t) configureRestorePlanPreviousRequirement(cfg, true) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestorePreviousCurrent(t, store, cfg, "# previous recap\n") plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"manifest.json", "previous/artifacts/session_recap.md", "previous/manifest.json"} if !reflect.DeepEqual(got, want) { t.Fatalf("action local paths = %#v, want %#v", got, want) } } func TestRestorePlanIgnoresCurrentSessionArchivedPreviousCache(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestoreObject(store, current.SessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`)) seedRestoreObject(store, current.SessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n")) plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"manifest.json"} if !reflect.DeepEqual(got, want) { t.Fatalf("action local paths = %#v, want %#v", got, want) } } func TestRestorePlanMissingOptionalPreviousCacheSkipsArtifact(t *testing.T) { cfg := restorePlanConfig(t) configureRestorePlanPreviousRequirement(cfg, false) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestorePreviousCurrentManifestOnly(t, store, cfg) plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"manifest.json", "previous/manifest.json"} if !reflect.DeepEqual(got, want) { t.Fatalf("action local paths = %#v, want %#v", got, want) } } func TestRestorePlanMissingRequiredPreviousCacheFails(t *testing.T) { cfg := restorePlanConfig(t) configureRestorePlanPreviousRequirement(cfg, true) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestorePreviousCurrentManifestOnly(t, store, cfg) _, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err == nil || !strings.Contains(err.Error(), "required previous-session artifact") { t.Fatalf("buildRestorePlan() error = %v, want required previous artifact failure", err) } } func TestRestorePlanPreviousCacheConflictRequiresForce(t *testing.T) { cfg := restorePlanConfig(t) configureRestorePlanPreviousRequirement(cfg, true) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestorePreviousCurrent(t, store, cfg, "# remote previous recap\n") sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n") plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } if plan.ConflictCount != 1 { t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount) } plan, err = buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true}) if err != nil { t.Fatalf("buildRestorePlan(force) error = %v", err) } if plan.ConflictCount != 0 { t.Fatalf("force ConflictCount = %d, want 0", plan.ConflictCount) } } func TestRestorePlanClassifiesSameAndConflict(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`)) seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n")) sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1]}`) mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n") plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } if plan.SkipSameCount != 1 { t.Fatalf("SkipSameCount = %d, want 1", plan.SkipSameCount) } if plan.ConflictCount != 1 { t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount) } actionByRel := map[string]RestoreAction{} for _, action := range plan.Actions { actionByRel[action.LocalRelativePath] = action } if actionByRel["transcripts/full.json"].Kind != RestoreActionSkipSame { t.Fatalf("transcripts/full.json kind = %q, want %q", actionByRel["transcripts/full.json"].Kind, RestoreActionSkipSame) } if actionByRel["artifacts/session_recap.md"].Kind != RestoreActionConflict { t.Fatalf("artifacts/session_recap.md kind = %q, want %q", actionByRel["artifacts/session_recap.md"].Kind, RestoreActionConflict) } } func TestRestorePlanForceTurnsConflictsIntoDownloads(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n")) sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n") plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } actionByRel := map[string]RestoreAction{} for _, action := range plan.Actions { actionByRel[action.LocalRelativePath] = action } recap := actionByRel["artifacts/session_recap.md"] if recap.Kind != RestoreActionDownload { t.Fatalf("artifacts/session_recap.md kind = %q, want %q", recap.Kind, RestoreActionDownload) } if plan.ConflictCount != 0 { t.Fatalf("ConflictCount = %d, want 0", plan.ConflictCount) } } func TestRestorePlanSymlinksRemainNonRegularConflicts(t *testing.T) { for _, tt := range []struct { name string setup func(t *testing.T, sessionRoot string) }{ { name: "leaf", setup: func(t *testing.T, sessionRoot string) { t.Helper() outside := filepath.Join(t.TempDir(), "recap.md") mustWriteTestFile(t, outside, "remote-content\n") if err := os.MkdirAll(filepath.Join(sessionRoot, "artifacts"), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.Symlink(outside, filepath.Join(sessionRoot, "artifacts", "session_recap.md")); err != nil { t.Skipf("Symlink() unavailable: %v", err) } }, }, { name: "ancestor", setup: func(t *testing.T, sessionRoot string) { t.Helper() outside := t.TempDir() mustWriteTestFile(t, filepath.Join(outside, "session_recap.md"), "remote-content\n") if err := os.Symlink(outside, filepath.Join(sessionRoot, "artifacts")); err != nil { t.Skipf("Symlink() unavailable: %v", err) } }, }, } { t.Run(tt.name, func(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n")) sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) tt.setup(t, sessionRoot) for _, options := range []RestorePlanOptions{{DryRun: true}, {Force: true}} { plan, err := buildRestorePlan(context.Background(), cfg, current, store, options) if err != nil { t.Fatalf("buildRestorePlan(%+v) error = %v", options, err) } action := restoreActionForPath(t, plan, "artifacts/session_recap.md") if action.Kind != RestoreActionConflict || action.ConflictKind != RestoreConflictNonRegular { t.Fatalf("action = %#v, want non-regular conflict", action) } } }) } } func restoreActionForPath(t *testing.T, plan *RestorePlan, path string) RestoreAction { t.Helper() for _, action := range plan.Actions { if action.LocalRelativePath == path { return action } } t.Fatalf("restore action for %q not found", path) return RestoreAction{} } func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/../../escape.txt", []byte("bad")) _, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "committed destination is unsafe") { t.Fatalf("error = %v, want traversal safety failure", err) } } func seedCurrentRestoreObject(t *testing.T, store *storage.FakeBackend, current *RemoteCurrentState, key string, data []byte) { t.Helper() artifactType := artifacts.RemoteArtifactTypePublishedOutput source := "narratio.restore." + strings.ReplaceAll(strings.TrimPrefix(key, current.SessionPrefix), "/", ".") destination := key if key == current.CurrentManifestKey { artifactType = artifacts.RemoteArtifactTypeSessionManifest source = "session.manifest" current.ManifestData = append([]byte(nil), data...) } else if strings.HasPrefix(key, current.SessionPrefix) { destination = artifacts.S3RunPrefix(current.SessionPrefix, current.RunID) + strings.TrimPrefix(key, current.SessionPrefix) } seedRestoreObject(store, destination, data) current.Commit.Artifacts = append(current.Commit.Artifacts, remoteRestoreArtifact(store, artifactType, source, destination)) } func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) { if strings.HasSuffix(key, "/current/commit-pointer.json") { if _, exists := store.Objects[key]; exists { return } } store.SeedObject(storage.FakeObject{Key: key, Data: data}) for pointerKey, pointerObject := range store.Objects { if !strings.HasSuffix(pointerKey, "/current/commit-pointer.json") || key == pointerKey { continue } pointer, err := artifacts.DecodeCurrentCommitPointer(pointerObject.Data) if err != nil { continue } commitObject, ok := store.Objects[pointer.CommitKey] if !ok { continue } commit, err := artifacts.DecodeRemoteCommitManifest(commitObject.Data) if err != nil { continue } runPrefix := strings.TrimSuffix(pointer.CommitKey, "commit.json") sessionPrefix := strings.TrimSuffix(runPrefix, "runs/"+pointer.RunID+"/") if !strings.HasPrefix(key, sessionPrefix) { continue } destination := key if strings.HasPrefix(key, sessionPrefix) && !strings.HasPrefix(key, runPrefix) { relative := strings.TrimPrefix(key, sessionPrefix) destination = runPrefix + relative store.SeedObject(storage.FakeObject{Key: destination, Data: data}) } typeID := artifacts.RemoteArtifactTypePublishedOutput source := "narratio.test" if destination == artifacts.S3RunSessionManifestKey(sessionPrefix, pointer.RunID) { typeID, source = artifacts.RemoteArtifactTypeSessionManifest, "session.manifest" } else if relative := strings.TrimPrefix(destination, runPrefix); strings.HasPrefix(relative, "artifacts/") || strings.HasPrefix(relative, "published/artifacts/") { name := strings.TrimPrefix(relative, "published/") name = strings.TrimSuffix(strings.TrimPrefix(name, "artifacts/"), filepath.Ext(name)) source = artifacts.ConfiguredArtifactSourceID(name) } artifact := remoteRestoreArtifact(store, typeID, source, destination) replaced := false for index := range commit.Artifacts { if commit.Artifacts[index].DestinationKey == destination { commit.Artifacts[index] = artifact replaced = true } } if !replaced { commit.Artifacts = append(commit.Artifacts, artifact) } commitData, err := artifacts.EncodeRemoteCommitManifest(*commit) if err != nil { continue } store.SeedObject(storage.FakeObject{Key: pointer.CommitKey, Data: commitData}) commitObject = store.Objects[pointer.CommitKey] pointer.CommitSHA256 = restoreCommitSHA256(commitData) pointer.CommitSize = int64(len(commitData)) pointer.CommitGeneration = commitObject.ETag pointerData, err := artifacts.EncodeCurrentCommitPointer(*pointer) if err == nil { store.SeedObject(storage.FakeObject{Key: pointerKey, Data: pointerData}) } } } func actionRelPaths(actions []RestoreAction) []string { out := make([]string, 0, len(actions)) for _, action := range actions { out = append(out, action.LocalRelativePath) } return out } func restorePlanConfig(t *testing.T) *config.Config { t.Helper() workspaceRoot := t.TempDir() return &config.Config{ Pipeline: &config.PipelineConfig{ Workspace: config.WorkspaceConfig{Root: workspaceRoot}, Storage: config.StorageConfig{S3: &config.StorageS3Config{ Bucket: "test-bucket", RootPrefix: "dnd", }}, }, Session: &config.SessionConfig{ SessionID: "2026-05-03", Campaign: "sample-campaign", }, } } func configureRestorePlanPreviousRequirement(cfg *config.Config, required bool) { cfg.Session.PreviousSessionID = "2026-04-26" cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{ Artifacts: map[string]config.ScriptoriumArtifactConfig{ "session_recap": { Enabled: true, PromptID: "dnd.session_recap", OutputPath: "artifacts/session_recap.md", Inputs: map[string]config.ScriptoriumInputConfig{ "previous_recap": { Source: "narratio.previous_session.artifact.session_recap", Required: required, }, }, }, }, } } func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState { t.Helper() sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) return &RemoteCurrentState{ Bucket: "test-bucket", SessionPrefix: sessionPrefix, CurrentManifestKey: artifacts.S3RunSessionManifestKey(sessionPrefix, "20260519T010203Z-a1b2c3d4"), RunID: "20260519T010203Z-a1b2c3d4", SessionID: cfg.Session.SessionID, Campaign: cfg.Session.Campaign, Commit: &artifacts.RemoteCommitManifest{ FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.SessionID, RunID: "20260519T010203Z-a1b2c3d4", }, } } func TestWriteRestorePlan(t *testing.T) { current := &RemoteCurrentState{Campaign: "sample-campaign", SessionID: "2026-05-03", RunID: "r-1"} plan := &RestorePlan{Actions: []RestoreAction{{Kind: RestoreActionDownload, LocalRelativePath: "manifest.json", RemoteKey: "k", Reason: "local file missing"}}, DownloadCount: 1} var out strings.Builder if err := writeRestorePlan(&out, current, plan, RestorePlanOptions{DryRun: true}); err != nil { t.Fatalf("writeRestorePlan() error = %v", err) } text := out.String() if !strings.Contains(text, "restore plan: session sample-campaign/2026-05-03 run=r-1") { t.Fatalf("output = %q, want plan summary", text) } if !strings.Contains(text, "download manifest.json <- k") { t.Fatalf("output = %q, want action line", text) } }