573 lines
23 KiB
Go
573 lines
23 KiB
Go
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
|
|
}
|