package json import ( "context" stdjson "encoding/json" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) func TestModuleSpecAndRegister(t *testing.T) { want := pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"encoded"}, } if got := ModuleSpec(); !reflect.DeepEqual(got, want) { t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) } registry := pipeline.NewOutputEncoderRegistry() if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err) } spec, ok := registry.Spec(Key) if !ok { t.Fatalf("Spec(%q) ok = false, want true", Key) } if !reflect.DeepEqual(spec, want) { t.Fatalf("registered spec = %#v, want %#v", spec, want) } } func TestEncodeReturnsLogicalFilesGroupedByArtifactType(t *testing.T) { req := contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1", PipelineID: "pipeline-1"}, Approved: []artifacts.Artifact{ artifact("dnd.spell-cast", "first"), artifact("notes/item", "item"), artifact("dnd.spell-cast", "second"), }, Rejected: []artifacts.RejectedArtifact{ { Candidate: candidate("bad type", "bad"), ValidatorName: "validator", ReasonCode: "invalid", Message: "not accepted", }, }, Warnings: []contracts.Warning{{ReasonCode: "warning", Message: "check source"}}, } result, err := New().Encode(context.Background(), req) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } wantNames := []string{ "artifacts/dnd.spell-cast.json", "artifacts/notes_item.json", "index.json", "manifest.json", "rejected.json", "warnings.json", } if got := outputFileNames(result.Files); !reflect.DeepEqual(got, wantNames) { t.Fatalf("file names = %#v, want %#v", got, wantNames) } for _, file := range result.Files { if file.ContentType != contentTypeJSON { t.Fatalf("%s ContentType = %q, want %q", file.Name, file.ContentType, contentTypeJSON) } if !strings.HasSuffix(string(file.Bytes), "\n") { t.Fatalf("%s does not end with newline: %q", file.Name, string(file.Bytes)) } if !stdjson.Valid(file.Bytes) { t.Fatalf("%s has invalid JSON: %s", file.Name, file.Bytes) } } spellFile := decodeObject(t, fileBytes(t, result.Files, "artifacts/dnd.spell-cast.json")) if spellFile["artifact_type"] != "dnd.spell-cast" { t.Fatalf("artifact_type = %#v, want dnd.spell-cast", spellFile["artifact_type"]) } spells := spellFile["artifacts"].([]any) if len(spells) != 2 { t.Fatalf("len(spells) = %d, want 2", len(spells)) } firstPayload := spells[0].(map[string]any)["payload"].(map[string]any) secondPayload := spells[1].(map[string]any)["payload"].(map[string]any) if firstPayload["name"] != "first" || secondPayload["name"] != "second" { t.Fatalf("spell order payloads = %#v then %#v, want runner order", firstPayload, secondPayload) } index := decodeObject(t, fileBytes(t, result.Files, "index.json")) artifactFiles := index["artifact_files"].([]any) if len(artifactFiles) != 2 { t.Fatalf("len(index artifact_files) = %d, want 2", len(artifactFiles)) } firstIndex := artifactFiles[0].(map[string]any) secondIndex := artifactFiles[1].(map[string]any) if firstIndex["artifact_type"] != "dnd.spell-cast" || secondIndex["artifact_type"] != "notes/item" { t.Fatalf("artifact_files = %#v, want sorted by artifact type", artifactFiles) } } func TestEncodeIncludesRejectedAndWarningsWhenEmpty(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1"}, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } rejected := decodeObject(t, fileBytes(t, result.Files, "rejected.json")) if got := rejected["rejected"].([]any); len(got) != 0 { t.Fatalf("rejected = %#v, want empty array", got) } warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json")) if got := warnings["warnings"].([]any); len(got) != 0 { t.Fatalf("warnings = %#v, want empty array", got) } } func TestEncodePrettyPrintsJSON(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1"}, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } manifest := string(fileBytes(t, result.Files, "manifest.json")) if !strings.Contains(manifest, "\n \"run_id\": \"run-1\"\n") { t.Fatalf("manifest JSON = %q, want two-space indentation", manifest) } } func TestEncodeIncludesManifestReferences(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ Manifest: artifacts.RunManifest{ RunID: "run-1", References: []artifacts.ReferenceProvenance{ { Stage: "extract", LaneID: "events", SlotName: "roster", OriginType: "file", OriginURI: "file:///tmp/roster.txt", Digest: "sha256:reference", MediaType: "text/plain; charset=utf-8", SizeBytes: 12, BindingSource: "config", }, }, }, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json")) references := manifest["references"].([]any) if len(references) != 1 { t.Fatalf("references = %#v, want one entry", references) } reference := references[0].(map[string]any) if reference["lane_id"] != "events" || reference["slot_name"] != "roster" || reference["digest"] != "sha256:reference" { t.Fatalf("reference manifest = %#v, want lane slot digest", reference) } if _, ok := reference["content"]; ok { t.Fatalf("reference manifest = %#v, want no content field", reference) } } func TestEncodeRejectsArtifactTypeWithoutSafeFileName(t *testing.T) { _, err := New().Encode(context.Background(), contracts.OutputRequest{ Approved: []artifacts.Artifact{artifact("///", "unsafe")}, }) if err == nil { t.Fatal("Encode() error = nil, want unsafe artifact type error") } if !strings.Contains(err.Error(), "json output encoder") || !strings.Contains(err.Error(), "safe file name") { t.Fatalf("Encode() error = %q, want safe file name context", err.Error()) } } func TestEncodeSanitizesParentPathSequences(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ Approved: []artifacts.Artifact{artifact("dnd..spell.", "spell")}, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } if got := outputFileNames(result.Files); !containsString(got, "artifacts/dnd__spell.json") { t.Fatalf("file names = %#v, want sanitized artifact filename", got) } } func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) { _, err := New().Encode(context.Background(), contracts.OutputRequest{ Approved: []artifacts.Artifact{ artifact("a/b", "slash"), artifact("a?b", "question"), }, }) if err == nil { t.Fatal("Encode() error = nil, want duplicate file error") } if !strings.Contains(err.Error(), "duplicate output file") { t.Fatalf("Encode() error = %q, want duplicate file context", err.Error()) } } func TestEncodeDoesNotMutateInputs(t *testing.T) { req := contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1"}, Approved: []artifacts.Artifact{ artifact("dnd.spell", "original"), }, Rejected: []artifacts.RejectedArtifact{ { Candidate: candidate("bad", "rejected"), ValidatorName: "validator", ReasonCode: "invalid", Message: "not accepted", }, }, Warnings: []contracts.Warning{{ReasonCode: "warning", Message: "message"}}, } before := mustMarshal(t, req) result, err := New().Encode(context.Background(), req) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } after := mustMarshal(t, req) if before != after { t.Fatalf("request mutated:\nbefore: %s\nafter: %s", before, after) } req.Approved[0].Payload[0] = '[' req.Approved[0].SourceRefs[0].StartUnitID = "changed" req.Approved[0].Metadata["name"] = "changed" req.Rejected[0].Candidate.Payload[0] = '[' req.Warnings[0].Message = "changed" if !stdjson.Valid(fileBytes(t, result.Files, "artifacts/dnd.spell.json")) { t.Fatal("artifact output changed after request mutation") } warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json")) gotWarnings := warnings["warnings"].([]any) if gotWarnings[0].(map[string]any)["message"] != "message" { t.Fatalf("warnings output changed after request mutation: %#v", gotWarnings) } } func TestArtifactFilesDoNotContainWarnings(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ Approved: []artifacts.Artifact{artifact("dnd.spell", "spell")}, Warnings: []contracts.Warning{ {ReasonCode: "pipeline-warning", Message: "warning"}, }, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } artifactFile := decodeObject(t, fileBytes(t, result.Files, "artifacts/dnd.spell.json")) if _, ok := artifactFile["warnings"]; ok { t.Fatalf("artifact file contains warnings: %#v", artifactFile) } } func artifact(artifactType, name string) artifacts.Artifact { return artifacts.Artifact{ ExtractorKey: "extractor", ArtifactType: artifactType, SchemaVersion: "v1", Payload: stdjson.RawMessage(`{"name":"` + name + `"}`), SourceRefs: []source.SourceRef{ {SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u1"}, }, Metadata: map[string]any{"name": name}, } } func candidate(artifactType, name string) artifacts.ArtifactCandidate { return artifacts.ArtifactCandidate{ Index: 1, ExtractorKey: "extractor", ArtifactType: artifactType, SchemaVersion: "v1", Payload: stdjson.RawMessage(`{"name":"` + name + `"}`), SourceRefs: []source.SourceRef{ {SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u1"}, }, Metadata: map[string]any{"name": name}, } } func outputFileNames(files []contracts.OutputFile) []string { names := make([]string, 0, len(files)) for _, file := range files { names = append(names, file.Name) } return names } func containsString(values []string, want string) bool { for _, value := range values { if value == want { return true } } return false } func fileBytes(t *testing.T, files []contracts.OutputFile, name string) []byte { t.Helper() for _, file := range files { if file.Name == name { return file.Bytes } } t.Fatalf("file %q not found in %#v", name, outputFileNames(files)) return nil } func decodeObject(t *testing.T, data []byte) map[string]any { t.Helper() var got map[string]any if err := stdjson.Unmarshal(data, &got); err != nil { t.Fatalf("Unmarshal() error = %v, want nil\n%s", err, data) } return got } func mustMarshal(t *testing.T, value any) string { t.Helper() data, err := stdjson.Marshal(value) if err != nil { t.Fatalf("Marshal() error = %v, want nil", err) } return string(data) }