package notarius import ( "context" "encoding/json" "errors" "os" "path/filepath" "reflect" "strings" "testing" "time" sharedsubprocess "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" ) func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) { req := validRunRequest(t) var captured sharedsubprocess.RunRequest runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { captured = processReq writeValidBundleAndReceipt(t, req, true) return sharedsubprocess.RunResult{ExitCode: 0, Duration: 2 * time.Second}, nil }} result, err := runner.Run(context.Background(), req) if err != nil { t.Fatalf("Run() error = %v", err) } wantArgs := []string{ "run", "dnd-session", "--config", req.ConfigPath, "--input", req.InputPath, "--output-dir", req.OutputRoot, "--json", } if !reflect.DeepEqual(captured.Args, wantArgs) { t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs) } if captured.Executable != req.Binary || captured.WorkingDir != req.WorkingDirectory || captured.Timeout != req.Timeout { t.Fatalf("subprocess request = %#v", captured) } if captured.StdoutLogPath != req.ReceiptPath || captured.StderrLogPath != req.LogPath { t.Fatalf("stream paths = stdout %q stderr %q", captured.StdoutLogPath, captured.StderrLogPath) } if captured.EnvOverrides != nil { t.Fatalf("environment overrides = %#v, want inherited environment only", captured.EnvOverrides) } for _, arg := range captured.Args { if arg == "--session-id" { t.Fatal("subprocess args unexpectedly contain --session-id") } } if result.Receipt.SchemaVersion != ReceiptSchemaVersion || result.Receipt.RunID != "notarius-run-1" { t.Fatalf("receipt = %#v", result.Receipt) } if len(result.Index.Lanes) != 1 || result.Index.Lanes[0].LaneID != "npc-registry" { t.Fatalf("lanes = %#v", result.Index.Lanes) } if result.Index.ChunkMap == nil || result.Index.ChunkMap.ArtifactKind != "chunk_map" { t.Fatalf("chunk map = %#v", result.Index.ChunkMap) } if result.Index.EvidenceContext == nil || result.Index.EvidenceContext.ArtifactKind != "evidence_context" { t.Fatalf("evidence context = %#v", result.Index.EvidenceContext) } if len(result.Rejections) != 1 || result.Rejections[0].LaneID != "spells" || result.Rejections[0].ReasonCode != "invalid_spell" { t.Fatalf("rejections = %#v", result.Rejections) } if len(result.Warnings) != 1 || result.Warnings[0].Scope != "lane:npc-registry" || result.Warnings[0].ReasonCode != "normalized_name" { t.Fatalf("warnings = %#v", result.Warnings) } } func TestSubprocessRunnerInheritsEnvironmentAndSeparatesStreams(t *testing.T) { req := validRunRequest(t) writeValidBundleAndReceipt(t, req, false) receiptFixture := req.ReceiptPath + ".fixture" data, err := os.ReadFile(req.ReceiptPath) if err != nil { t.Fatalf("ReadFile(receipt) error = %v", err) } if err := os.WriteFile(receiptFixture, data, 0o644); err != nil { t.Fatalf("WriteFile(receipt fixture) error = %v", err) } if err := os.Remove(req.ReceiptPath); err != nil { t.Fatalf("Remove(receipt) error = %v", err) } captureDir := filepath.Join(filepath.Dir(req.ReceiptPath), "capture") if err := os.Mkdir(captureDir, 0o755); err != nil { t.Fatalf("Mkdir(capture) error = %v", err) } script := writeShellScript(t, `#!/bin/sh pwd > "$NOTARIUS_CAPTURE_DIR/working-directory" printf '%s' "$NOTARIUS_INHERITED_VALUE" > "$NOTARIUS_CAPTURE_DIR/environment" printf 'diagnostic stream\n' >&2 cat "$NOTARIUS_RECEIPT_FIXTURE" `) req.Binary = script t.Setenv("NOTARIUS_CAPTURE_DIR", captureDir) t.Setenv("NOTARIUS_INHERITED_VALUE", "inherited-value") t.Setenv("NOTARIUS_RECEIPT_FIXTURE", receiptFixture) if _, err := NewSubprocessRunner().Run(context.Background(), req); err != nil { t.Fatalf("Run() error = %v", err) } assertTextFile(t, filepath.Join(captureDir, "working-directory"), req.WorkingDirectory+"\n") assertTextFile(t, filepath.Join(captureDir, "environment"), "inherited-value") assertTextFile(t, req.LogPath, "diagnostic stream\n") receiptBytes, err := os.ReadFile(req.ReceiptPath) if err != nil { t.Fatalf("ReadFile(receipt) error = %v", err) } if strings.Contains(string(receiptBytes), "diagnostic stream") { t.Fatal("receipt contains stderr output") } } func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T) { tests := []struct { name string scriptBody string timeout time.Duration cancel bool want string }{ {name: "nonzero", scriptBody: "printf '{malformed receipt'; printf 'failed\\n' >&2; exit 7\n", timeout: time.Second, want: "exit code 7"}, {name: "timeout", scriptBody: "sleep 5\n", timeout: 20 * time.Millisecond, want: "timed out"}, {name: "cancellation", scriptBody: "sleep 5\n", timeout: time.Second, cancel: true, want: "canceled"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { req := validRunRequest(t) req.Binary = writeShellScript(t, "#!/bin/sh\n"+test.scriptBody) req.Timeout = test.timeout ctx := context.Background() if test.cancel { cancelCtx, cancel := context.WithCancel(ctx) ctx = cancelCtx time.AfterFunc(20*time.Millisecond, cancel) } _, err := NewSubprocessRunner().Run(ctx, req) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Run() error = %v, want fragment %q", err, test.want) } if strings.Contains(err.Error(), "decode notarius receipt") { t.Fatalf("Run() parsed stdout after process failure: %v", err) } }) } } func TestSubprocessRunnerReturnsSharedSubprocessErrorWithoutReadingReceipt(t *testing.T) { req := validRunRequest(t) if err := os.WriteFile(req.ReceiptPath, []byte("not json"), 0o644); err != nil { t.Fatalf("WriteFile(receipt) error = %v", err) } wantErr := errors.New("process failed") runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { return sharedsubprocess.RunResult{ExitCode: 9}, wantErr }} _, err := runner.Run(context.Background(), req) if !errors.Is(err, wantErr) { t.Fatalf("Run() error = %v, want wrapped process error", err) } if strings.Contains(err.Error(), "decode") { t.Fatalf("Run() parsed receipt after failure: %v", err) } } func TestLoadReceiptValidation(t *testing.T) { root := t.TempDir() valid := map[string]any{ "schema_version": ReceiptSchemaVersion, "run_id": "run-1", "pipeline_id": "pipeline-1", "output_directory": filepath.Join(root, "outputs", "run-1"), "index_file": "index.json", "normalized_output_count": 1, "rejected_output_count": 0, "warning_count": 0, "validation_status": "approved", "future_field": true, } tests := []struct { name string mutate func(map[string]any) raw []byte wantOK bool wantError string }{ {name: "unknown fields tolerated", wantOK: true}, {name: "malformed", raw: []byte("{")}, {name: "unsupported version", mutate: func(v map[string]any) { v["schema_version"] = "notarius.run-result.v2" }}, {name: "missing field", mutate: func(v map[string]any) { delete(v, "run_id") }}, {name: "pipeline mismatch", mutate: func(v map[string]any) { v["pipeline_id"] = "other" }}, {name: "relative output", mutate: func(v map[string]any) { v["output_directory"] = "run-1" }}, {name: "negative count", mutate: func(v map[string]any) { v["warning_count"] = -1 }}, { name: "nested index", mutate: func(v map[string]any) { v["index_file"] = "nested/index.json" }, wantError: `index_file "nested/index.json"`, }, { name: "cleanable index", mutate: func(v map[string]any) { v["index_file"] = "./index.json" }, wantError: `index_file "./index.json"`, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { path := filepath.Join(root, strings.ReplaceAll(test.name, " ", "-")+".json") values := cloneMap(valid) if test.mutate != nil { test.mutate(values) } if test.raw != nil { if err := os.WriteFile(path, test.raw, 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } } else { writeJSONFile(t, path, values) } _, err := loadReceipt(path, "pipeline-1") if test.wantOK && err != nil { t.Fatalf("loadReceipt() error = %v", err) } if !test.wantOK && err == nil { t.Fatal("loadReceipt() error = nil, want validation failure") } if test.wantError != "" && !strings.Contains(err.Error(), test.wantError) { t.Fatalf("loadReceipt() error = %v, want fragment %q", err, test.wantError) } }) } oversized := filepath.Join(root, "oversized.json") if err := os.WriteFile(oversized, []byte(strings.Repeat("x", maxReceiptBytes+1)), 0o644); err != nil { t.Fatalf("WriteFile(oversized) error = %v", err) } if _, err := loadReceipt(oversized, "pipeline-1"); err == nil || !strings.Contains(err.Error(), "exceeds") { t.Fatalf("loadReceipt(oversized) error = %v", err) } } func TestValidateBundleRootRejectsEscapesAndSymlinks(t *testing.T) { root := t.TempDir() outputRoot := filepath.Join(root, "output") if err := os.Mkdir(outputRoot, 0o755); err != nil { t.Fatalf("Mkdir(output root) error = %v", err) } validBundle := filepath.Join(outputRoot, "run-1") if err := os.Mkdir(validBundle, 0o755); err != nil { t.Fatalf("Mkdir(bundle) error = %v", err) } if _, err := validateBundleRoot(outputRoot, validBundle); err != nil { t.Fatalf("validateBundleRoot(valid) error = %v", err) } outside := filepath.Join(root, "output-other") if err := os.Mkdir(outside, 0o755); err != nil { t.Fatalf("Mkdir(outside) error = %v", err) } for name, candidate := range map[string]string{"equal root": outputRoot, "escape": root, "prefix confusion": outside} { t.Run(name, func(t *testing.T) { if _, err := validateBundleRoot(outputRoot, candidate); err == nil { t.Fatalf("validateBundleRoot(%q) error = nil", candidate) } }) } symlink := filepath.Join(outputRoot, "linked") if err := os.Symlink(outside, symlink); err != nil { t.Skipf("Symlink() unavailable: %v", err) } if _, err := validateBundleRoot(outputRoot, symlink); err == nil { t.Fatal("validateBundleRoot(symlink) error = nil") } } func TestLoadIndexRejectsMalformedUnsafeAndUnsupportedDocuments(t *testing.T) { tests := []struct { name string indexValue any prepare func(*testing.T, string) wantError string }{ {name: "malformed", indexValue: json.RawMessage(`{"manifest_file":`)}, {name: "unsupported output shape", indexValue: map[string]any{"manifest_file": "manifest.json", "output_files": map[string]any{}, "rejected_file": "rejected.json", "warnings_file": "warnings.json"}}, {name: "missing management path", indexValue: map[string]any{"output_files": []any{}, "rejected_file": "rejected.json", "warnings_file": "warnings.json"}}, {name: "renamed manifest", indexValue: func() any { value := validIndexValue([]any{}) value["manifest_file"] = "metadata.json" return value }(), wantError: `manifest_file "metadata.json"`}, {name: "cleanable manifest", indexValue: func() any { value := validIndexValue([]any{}) value["manifest_file"] = "./manifest.json" return value }(), wantError: `manifest_file "./manifest.json"`}, {name: "renamed rejections", indexValue: func() any { value := validIndexValue([]any{}) value["rejected_file"] = "rejections.json" return value }(), wantError: `rejected_file "rejections.json"`}, {name: "renamed warnings", indexValue: func() any { value := validIndexValue([]any{}) value["warnings_file"] = "diagnostics/warnings.json" return value }(), wantError: `warnings_file "diagnostics/warnings.json"`}, {name: "duplicate lane", indexValue: validIndexValue([]any{ map[string]any{"lane_id": "npc", "file": "lanes/npc.json"}, map[string]any{"lane_id": "npc", "file": "lanes/npc.json"}, })}, {name: "absolute logical path", indexValue: validIndexValue([]any{map[string]any{"lane_id": "npc", "file": "/tmp/npc.json"}})}, {name: "lexical traversal", indexValue: validIndexValue([]any{map[string]any{"lane_id": "npc", "file": "../outside.json"}})}, {name: "root prefix confusion", indexValue: validIndexValue([]any{map[string]any{"lane_id": "npc", "file": "../bundle-other/npc.json"}})}, {name: "file symlink", indexValue: validIndexValue([]any{map[string]any{"lane_id": "npc", "file": "lanes/npc.json"}}), prepare: func(t *testing.T, bundle string) { if err := os.Symlink(filepath.Join(bundle, "manifest.json"), filepath.Join(bundle, "lanes", "npc.json")); err != nil { t.Skipf("Symlink() unavailable: %v", err) } }}, {name: "directory symlink", indexValue: validIndexValue([]any{map[string]any{"lane_id": "npc", "file": "linked/npc.json"}}), prepare: func(t *testing.T, bundle string) { if err := os.Symlink(filepath.Join(bundle, "lanes"), filepath.Join(bundle, "linked")); err != nil { t.Skipf("Symlink() unavailable: %v", err) } }}, {name: "missing management file", indexValue: validIndexValue([]any{}), prepare: func(t *testing.T, bundle string) { if err := os.Remove(filepath.Join(bundle, "manifest.json")); err != nil { t.Fatalf("Remove(manifest) error = %v", err) } }}, {name: "incomplete pipeline descriptor", indexValue: func() any { value := validIndexValue([]any{}) value["chunk_map"] = map[string]any{"artifact_kind": "chunk_map", "file": "chunk-map.json"} return value }()}, {name: "pipeline descriptor escape", indexValue: func() any { value := validIndexValue([]any{}) value["evidence_context"] = map[string]any{ "artifact_kind": "evidence_context", "file": "../evidence.json", "media_type": "application/json", "schema_id": "evidence", "schema_name": "Evidence", "schema_version": "v1", } return value }()}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { bundle := createBundleSkeleton(t) indexPath := filepath.Join(bundle, "index.json") if raw, ok := test.indexValue.(json.RawMessage); ok { if err := os.WriteFile(indexPath, raw, 0o644); err != nil { t.Fatalf("WriteFile(index) error = %v", err) } } else { writeJSONFile(t, indexPath, test.indexValue) } if test.prepare != nil { test.prepare(t, bundle) } if _, err := loadIndex(bundle, indexPath); err == nil { t.Fatal("loadIndex() error = nil, want failure") } else if test.wantError != "" && !strings.Contains(err.Error(), test.wantError) { t.Fatalf("loadIndex() error = %v, want fragment %q", err, test.wantError) } }) } bundle := createBundleSkeleton(t) oversizedIndex := filepath.Join(bundle, "index.json") if err := os.WriteFile(oversizedIndex, []byte(strings.Repeat("x", maxIndexBytes+1)), 0o644); err != nil { t.Fatalf("WriteFile(oversized index) error = %v", err) } if _, err := loadIndex(bundle, oversizedIndex); err == nil || !strings.Contains(err.Error(), "exceeds") { t.Fatalf("loadIndex(oversized) error = %v", err) } } func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testing.T) { root := t.TempDir() rejectedPath := filepath.Join(root, "rejected.json") warningsPath := filepath.Join(root, "warnings.json") writeJSONFile(t, rejectedPath, map[string]any{"rejected": []any{map[string]any{ "stage": "validate", "lane_id": "spells", "reason_code": "invalid", "message": "do not retain this", "future": true, }}, "future": true}) writeJSONFile(t, warningsPath, map[string]any{"warnings": []any{map[string]any{ "scope": "lane:spells", "reason_code": "bounded", "message": "do not retain this", "future": true, }}, "future": true}) rejections, err := loadRejections(rejectedPath) if err != nil || len(rejections) != 1 || rejections[0].ReasonCode != "invalid" { t.Fatalf("loadRejections() = %#v, %v", rejections, err) } warnings, err := loadWarnings(warningsPath) if err != nil || len(warnings) != 1 || warnings[0].Scope != "lane:spells" { t.Fatalf("loadWarnings() = %#v, %v", warnings, err) } for name, path := range map[string]string{"rejections": rejectedPath, "warnings": warningsPath} { t.Run("malformed "+name, func(t *testing.T) { if err := os.WriteFile(path, []byte("{"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } var err error if name == "rejections" { _, err = loadRejections(path) } else { _, err = loadWarnings(path) } if err == nil { t.Fatal("summary decoder error = nil") } }) } oversized := filepath.Join(root, "oversized.json") if err := os.WriteFile(oversized, []byte(strings.Repeat("x", maxSummaryBytes+1)), 0o644); err != nil { t.Fatalf("WriteFile(oversized) error = %v", err) } if _, err := loadWarnings(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") { t.Fatalf("loadWarnings(oversized) error = %v", err) } if _, err := loadRejections(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") { t.Fatalf("loadRejections(oversized) error = %v", err) } } func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) { req := RunRequest{PipelineID: "pipeline"} want := RunResult{BundleRoot: "/bundle"} fake := &FakeRunner{Result: want} got, err := fake.Run(context.Background(), req) if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{req}) { t.Fatalf("Run() = %#v, %v; requests = %#v", got, err, fake.Requests) } wantErr := errors.New("configured failure") fake.Err = wantErr if _, err := fake.Run(context.Background(), req); !errors.Is(err, wantErr) { t.Fatalf("Run(configured error) = %v", err) } canceled, cancel := context.WithCancel(context.Background()) cancel() before := len(fake.Requests) if _, err := fake.Run(canceled, req); !errors.Is(err, context.Canceled) || len(fake.Requests) != before { t.Fatalf("Run(canceled) error = %v; requests = %d", err, len(fake.Requests)) } } func validRunRequest(t *testing.T) RunRequest { t.Helper() root := t.TempDir() configPath := filepath.Join(root, "notarius.yml") inputPath := filepath.Join(root, "input.json") outputRoot := filepath.Join(root, "outputs") workingDirectory := filepath.Join(root, "work") diagnostics := filepath.Join(root, "diagnostics") for _, directory := range []string{outputRoot, workingDirectory, diagnostics} { if err := os.Mkdir(directory, 0o755); err != nil { t.Fatalf("Mkdir(%q) error = %v", directory, err) } } if err := os.WriteFile(configPath, []byte("pipelines: {}\n"), 0o644); err != nil { t.Fatalf("WriteFile(config) error = %v", err) } if err := os.WriteFile(inputPath, []byte("{}\n"), 0o644); err != nil { t.Fatalf("WriteFile(input) error = %v", err) } return RunRequest{ Binary: "notarius", ConfigPath: configPath, PipelineID: "dnd-session", InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory, ReceiptPath: filepath.Join(diagnostics, "receipt.json"), LogPath: filepath.Join(diagnostics, "stderr.log"), Timeout: time.Second, } } func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown bool) { t.Helper() bundle := filepath.Join(req.OutputRoot, "notarius-run-1") if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil { t.Fatalf("MkdirAll(bundle) error = %v", err) } for path, data := range map[string]string{ "manifest.json": `{}`, "lanes/npc.json": `{}`, "chunk-map.json": `{}`, "evidence-context.json": `{}`, } { if err := os.WriteFile(filepath.Join(bundle, filepath.FromSlash(path)), []byte(data), 0o644); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) } } rejection := map[string]any{"stage": "validate", "lane_id": "spells", "reason_code": "invalid_spell", "message": strings.Repeat("external detail", 20)} warning := map[string]any{"scope": "lane:npc-registry", "reason_code": "normalized_name", "message": strings.Repeat("external warning", 20)} if includeUnknown { rejection["future"] = true warning["future"] = true } writeJSONFile(t, filepath.Join(bundle, "rejected.json"), map[string]any{"rejected": []any{rejection}, "future": true}) writeJSONFile(t, filepath.Join(bundle, "warnings.json"), map[string]any{"warnings": []any{warning}, "future": true}) index := validIndexValue([]any{map[string]any{ "lane_id": "npc-registry", "file": "lanes/npc.json", "media_type": "application/json", "module_key": "dnd/npc-registry", "schema_id": "notarius.dnd.npc_registry", "schema_name": "NPCRegistry", "schema_version": "v1", "future": true, }}) index["chunk_map"] = map[string]any{ "artifact_kind": "chunk_map", "file": "chunk-map.json", "media_type": "application/json", "schema_id": "notarius.chunk_map", "schema_name": "ChunkMap", "schema_version": "v1", "future": true, } index["evidence_context"] = map[string]any{ "artifact_kind": "evidence_context", "file": "evidence-context.json", "media_type": "application/json", "schema_id": "notarius.evidence_context", "schema_name": "EvidenceContext", "schema_version": "v1", "future": true, } index["future"] = true writeJSONFile(t, filepath.Join(bundle, "index.json"), index) receipt := map[string]any{ "schema_version": ReceiptSchemaVersion, "run_id": "notarius-run-1", "pipeline_id": req.PipelineID, "output_directory": bundle, "index_file": "index.json", "normalized_output_count": 1, "rejected_output_count": 1, "warning_count": 1, "validation_status": "rejected", } if includeUnknown { receipt["future"] = true } writeJSONFile(t, req.ReceiptPath, receipt) } func createBundleSkeleton(t *testing.T) string { t.Helper() bundle := filepath.Join(t.TempDir(), "bundle") if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil { t.Fatalf("MkdirAll(bundle) error = %v", err) } for _, name := range []string{"manifest.json", "rejected.json", "warnings.json", "lanes/npc.json", "chunk-map.json"} { if err := os.WriteFile(filepath.Join(bundle, filepath.FromSlash(name)), []byte("{}"), 0o644); err != nil { t.Fatalf("WriteFile(%q) error = %v", name, err) } } return bundle } func validIndexValue(lanes []any) map[string]any { return map[string]any{ "manifest_file": "manifest.json", "output_files": lanes, "rejected_file": "rejected.json", "warnings_file": "warnings.json", } } func writeJSONFile(t *testing.T, path string, value any) { t.Helper() data, err := json.Marshal(value) if err != nil { t.Fatalf("json.Marshal() error = %v", err) } if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) } } func writeShellScript(t *testing.T, body string) string { t.Helper() path := filepath.Join(t.TempDir(), "notarius-helper") if err := os.WriteFile(path, []byte(body), 0o755); err != nil { t.Fatalf("WriteFile(script) error = %v", err) } return path } func assertTextFile(t *testing.T, path, want string) { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(%q) error = %v", path, err) } if string(data) != want { t.Fatalf("ReadFile(%q) = %q, want %q", path, string(data), want) } } func cloneMap(source map[string]any) map[string]any { result := make(map[string]any, len(source)) for key, value := range source { result[key] = value } return result }