Files
narratio/internal/app/restore_plan_test.go

519 lines
20 KiB
Go

package app
import (
"context"
"os"
"path/filepath"
"slices"
"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 !slices.Equal(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 !slices.Equal(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 !slices.Equal(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 !slices.Equal(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 !slices.Equal(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(), "clean relative object key") {
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
sourceKey := strings.NewReplacer("/", "_", ".", "_", "-", "_").Replace(strings.TrimPrefix(key, current.SessionPrefix))
source := artifacts.ConfiguredArtifactSourceID(sourceKey)
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) + "published/" + 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 + "published/" + relative
store.SeedObject(storage.FakeObject{Key: destination, Data: data})
}
typeID := artifacts.RemoteArtifactTypePublishedOutput
source := artifacts.ConfiguredArtifactSourceID("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)
}
}