package artifacts import ( "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) func TestInspectAnalyzeEvidenceAcceptsCurrentCanonicalOutput(t *testing.T) { paths := buildSessionPaths(t.TempDir(), "campaign", "session") body := []byte("recap\n") m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", body) got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"}) if got.State != AnalyzeEvidenceCurrent { t.Fatalf("State = %q, reason = %q", got.State, got.Reason) } if got.SourceID != ConfiguredArtifactSourceID("session_recap") || got.Path != filepath.Join(paths.ArtifactsDir, "session_recap.md") || got.ProducerRunID != "run-1" { t.Fatalf("evidence = %#v", got) } } func TestInspectAnalyzeEvidenceRejectsNonCurrentAndInvalidEvidence(t *testing.T) { tests := []struct { name string mutate func(*manifest.Manifest) config ConfiguredArtifactDefinition reason string }{ {name: "absent manifest", mutate: func(m *manifest.Manifest) { *m = manifest.Manifest{} }, reason: "absent"}, {name: "legacy", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeStateVersion = 0 m.Stages["analyze"].AnalyzeArtifacts = nil }, reason: "legacy"}, {name: "unsupported version", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeStateVersion++ }, reason: "legacy or unsupported"}, {name: "missing record", mutate: func(m *manifest.Manifest) { delete(m.Stages["analyze"].AnalyzeArtifacts, "session_recap") }, reason: "no analyze manifest record"}, {name: "stale", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactStale), reason: `status is "stale"`}, {name: "missing", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactMissing), reason: `status is "missing"`}, {name: "failed", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactFailed), reason: `status is "failed"`}, {name: "unselected", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactUnselected), reason: `status is "unselected"`}, {name: "fingerprint version", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.FingerprintVersion++ }) }, reason: "malformed"}, {name: "key mismatch", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Key = "other" }) }, reason: "malformed"}, {name: "source mismatch", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.SourceID = ConfiguredArtifactSourceID("other") }) }, reason: "malformed"}, {name: "missing contract", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Contract = nil }) }, reason: "malformed"}, {name: "configured path mismatch", config: ConfiguredArtifactDefinition{OutputPath: "artifacts/renamed.md"}, reason: "differs from current configuration"}, {name: "record path mismatch", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.LocalPath = "artifacts/other.md" }) }, reason: "differs from current configuration"}, {name: "size mismatch", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.OutputSize++ }) }, reason: "size differs"}, {name: "checksum mismatch", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("0", 64) }) }, reason: "checksum differs"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { paths := buildSessionPaths(t.TempDir(), "campaign", "session") m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n")) if test.mutate != nil { test.mutate(m) } definition := test.config if definition.OutputPath == "" { definition.OutputPath = "artifacts/session_recap.md" } got := InspectAnalyzeEvidence(paths, m, "session_recap", definition) if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, test.reason) { t.Fatalf("evidence = %#v, want non-current reason containing %q", got, test.reason) } }) } } func TestInspectAnalyzeEvidenceRejectsMissingAndUnsafeFiles(t *testing.T) { tests := []struct { name string alter func(t *testing.T, paths SessionPaths, outputPath string) }{ {name: "missing", alter: func(t *testing.T, _ SessionPaths, outputPath string) { if err := os.Remove(outputPath); err != nil { t.Fatal(err) } }}, {name: "directory", alter: func(t *testing.T, _ SessionPaths, outputPath string) { if err := os.Remove(outputPath); err != nil { t.Fatal(err) } if err := os.Mkdir(outputPath, 0o755); err != nil { t.Fatal(err) } }}, {name: "symlink leaf", alter: func(t *testing.T, paths SessionPaths, outputPath string) { if err := os.Remove(outputPath); err != nil { t.Fatal(err) } target := filepath.Join(paths.Root, "target.md") if err := os.WriteFile(target, []byte("recap\n"), 0o644); err != nil { t.Fatal(err) } if err := os.Symlink(target, outputPath); err != nil { t.Fatal(err) } }}, {name: "symlink ancestor", alter: func(t *testing.T, paths SessionPaths, outputPath string) { if err := os.RemoveAll(paths.ArtifactsDir); err != nil { t.Fatal(err) } outside := t.TempDir() if err := os.WriteFile(filepath.Join(outside, "session_recap.md"), []byte("recap\n"), 0o644); err != nil { t.Fatal(err) } if err := os.Symlink(outside, paths.ArtifactsDir); err != nil { t.Fatal(err) } }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { paths := buildSessionPaths(t.TempDir(), "campaign", "session") m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n")) outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md") test.alter(t, paths, outputPath) got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"}) if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, "missing or unsafe") { t.Fatalf("evidence = %#v", got) } }) } } func TestHydrateAnalyzeArtifactsUsesOnlyCurrentConfiguredKeys(t *testing.T) { paths := buildSessionPaths(t.TempDir(), "campaign", "session") m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n")) m.Stages["analyze"].AnalyzeArtifacts["removed"] = analyzeEvidenceRecord(t, paths, "removed", "artifacts/removed.md", []byte("old\n")) configured := map[string]ConfiguredArtifactDefinition{"session_recap": {OutputPath: "artifacts/session_recap.md"}} catalog := NewArtifactCatalog() if err := catalog.RegisterConfiguredArtifacts(configured, nil); err != nil { t.Fatal(err) } catalog.HydrateAnalyzeArtifacts(paths, m, configured) entry, _ := catalog.Lookup(ConfiguredArtifactSourceID("session_recap")) if !entry.Available || entry.Provenance != ArtifactProvenanceCurrentAnalyzeManifest || entry.ProducerRunID != "run-1" { t.Fatalf("entry = %#v", entry) } if _, ok := catalog.Lookup(ConfiguredArtifactSourceID("removed")); ok { t.Fatal("removed manifest record was advertised in current catalog") } } func analyzeEvidenceFixture(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) *manifest.Manifest { t.Helper() record := analyzeEvidenceRecord(t, paths, key, relativePath, body) now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC) m := manifest.New(paths.SessionID, now) m.Stages["analyze"] = &manifest.StageRecord{ Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now, AnalyzeStateVersion: manifest.AnalyzeStateContractVersion, AnalyzeArtifacts: map[string]manifest.AnalyzeArtifactRecord{key: record}, } return m } func analyzeEvidenceRecord(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) manifest.AnalyzeArtifactRecord { t.Helper() outputPath := filepath.Join(paths.Root, filepath.FromSlash(relativePath)) if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(outputPath, body, 0o644); err != nil { t.Fatal(err) } checksum, err := SHA256File(outputPath) if err != nil { t.Fatal(err) } now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC) return manifest.AnalyzeArtifactRecord{ Key: key, Status: manifest.AnalyzeArtifactCurrent, FingerprintVersion: manifest.AnalyzeFingerprintContractVersion, Fingerprint: strings.Repeat("1", 64), Output: &manifest.ArtifactRecord{ Kind: "scriptorium_artifact", SourceID: ConfiguredArtifactSourceID(key), LocalPath: relativePath, Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"}, ProducerRunID: "run-1", Checksum: checksum, }, OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now, } } func analyzeEvidenceStatus(status manifest.AnalyzeArtifactStatus) func(*manifest.Manifest) { return func(m *manifest.Manifest) { record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"] record.Status = status record.Output = nil record.OutputSize = 0 if status == manifest.AnalyzeArtifactFailed { record.Error = "generation failed" } m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = record } } func mutateAnalyzeEvidenceRecord(m *manifest.Manifest, mutate func(*manifest.AnalyzeArtifactRecord)) manifest.AnalyzeArtifactRecord { record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"] mutate(&record) return record }