package pipeline import ( "bytes" "encoding/json" "errors" "fmt" "io" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) type codecNotes struct { Items []string `json:"items"` Labels map[string]string `json:"labels,omitempty"` Details *codecNoteDetails `json:"details,omitempty"` } type codecNoteDetails struct { Name string `json:"name"` } type codecScore struct { Value int `json:"value"` } type codecNotesAlias codecNotes type testArtifactCodec[T any] struct { kind contracts.ArtifactKind schema contracts.ArtifactSchema mediaType string encodeFunc func(T) ([]byte, error) candidateFunc func(T) ([]byte, error) decodeFunc func([]byte) (T, error) candidateDecodeFunc func([]byte) (T, error) } func (c testArtifactCodec[T]) Kind() contracts.ArtifactKind { return c.kind } func (c testArtifactCodec[T]) Schema() contracts.ArtifactSchema { return c.schema } func (c testArtifactCodec[T]) MediaType() string { return c.mediaType } func (c testArtifactCodec[T]) EncodeCandidate(value T) ([]byte, error) { if c.candidateFunc != nil { return c.candidateFunc(value) } return c.encodeFunc(value) } func (c testArtifactCodec[T]) Encode(value T) ([]byte, error) { return c.encodeFunc(value) } func (c testArtifactCodec[T]) Decode(content []byte) (T, error) { return c.decodeFunc(content) } func (c testArtifactCodec[T]) DecodeCandidate(content []byte) (T, error) { if c.candidateDecodeFunc != nil { return c.candidateDecodeFunc(content) } return c.decodeFunc(content) } var _ contracts.ArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{} var _ contracts.CandidateArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{} func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) { registry := NewArtifactCodecRegistry() if err := RegisterArtifactCodec(registry, notesCodec()); err != nil { t.Fatalf("RegisterArtifactCodec(notes) error = %v, want nil", err) } if err := RegisterArtifactCodec(registry, scoreCodec()); err != nil { t.Fatalf("RegisterArtifactCodec(score) error = %v, want nil", err) } if got, want := registry.RegisteredKinds(), []contracts.ArtifactKind{"test/notes", "test/score"}; !reflect.DeepEqual(got, want) { t.Fatalf("RegisteredKinds() = %#v, want %#v", got, want) } notes := codecNotes{Items: []string{"second", "first"}} first, err := registry.Encode("test/notes", notes) if err != nil { t.Fatalf("Encode(notes) error = %v, want nil", err) } second, err := registry.Encode(" test/notes ", notes) if err != nil { t.Fatalf("Encode(notes again) error = %v, want nil", err) } if !bytes.Equal(first.Content, second.Content) { t.Fatalf("equal values encoded as %q and %q, want deterministic bytes", first.Content, second.Content) } decodedNotes, err := registry.Decode(first) if err != nil { t.Fatalf("Decode(notes) error = %v, want nil", err) } if !reflect.DeepEqual(decodedNotes, notes) { t.Fatalf("Decode(notes) = %#v, want %#v", decodedNotes, notes) } score := codecScore{Value: 17} serializedScore, err := registry.Encode("test/score", score) if err != nil { t.Fatalf("Encode(score) error = %v, want nil", err) } decodedScore, err := registry.Decode(serializedScore) if err != nil { t.Fatalf("Decode(score) error = %v, want nil", err) } if decodedScore != score { t.Fatalf("Decode(score) = %#v, want %#v", decodedScore, score) } _, err = registry.Encode("test/notes", codecNotesAlias(notes)) var typeErr *ArtifactCodecTypeError if !errors.As(err, &typeErr) { t.Fatalf("Encode(alias) error = %T %v, want ArtifactCodecTypeError", err, err) } if typeErr.ExpectedType == typeErr.ActualType { t.Fatalf("type error = %#v, want distinct exact types", typeErr) } } func TestArtifactCodecRegistryKeepsCandidateAndFinalEncodingDistinct(t *testing.T) { candidateCalls, finalCalls := 0, 0 codec := notesCodec() codec.candidateFunc = func(codecNotes) ([]byte, error) { candidateCalls++ return []byte(`{"items":["candidate"]}`), nil } codec.encodeFunc = func(codecNotes) ([]byte, error) { finalCalls++ return []byte(`{"items":["final"]}`), nil } registry := NewArtifactCodecRegistry() if err := RegisterArtifactCodec(registry, codec); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) } entry, _, err := registry.entry(codec.kind) if err != nil { t.Fatalf("entry() error = %v, want nil", err) } candidate, err := serializeArtifact(entry, codecNotes{}, true) if err != nil { t.Fatalf("serialize candidate error = %v, want nil", err) } if string(candidate.Content) != `{"items":["candidate"]}` || candidateCalls != 1 || finalCalls != 0 { t.Fatalf("candidate content = %s, calls = candidate %d, final %d", candidate.Content, candidateCalls, finalCalls) } final, err := serializeArtifact(entry, codecNotes{}, false) if err != nil { t.Fatalf("serialize final error = %v, want nil", err) } if string(final.Content) != `{"items":["final"]}` || candidateCalls != 1 || finalCalls != 1 { t.Fatalf("final content = %s, calls = candidate %d, final %d", final.Content, candidateCalls, finalCalls) } } func TestArtifactCodecRegistryKeepsCandidateAndFinalDecodingDistinct(t *testing.T) { candidateCalls, finalCalls := 0, 0 codec := notesCodec() codec.candidateDecodeFunc = func([]byte) (codecNotes, error) { candidateCalls++ return codecNotes{Items: []string{"candidate"}}, nil } codec.decodeFunc = func([]byte) (codecNotes, error) { finalCalls++ return codecNotes{Items: []string{"final"}}, nil } registry := NewArtifactCodecRegistry() if err := RegisterArtifactCodec(registry, codec); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) } entry, _, err := registry.entry(codec.kind) if err != nil { t.Fatalf("entry() error = %v, want nil", err) } candidate, err := entry.decodeCandidate([]byte(`{"items":["one"]}`)) if err != nil { t.Fatalf("candidate decode error = %v", err) } candidateWant := codecNotes{Items: []string{"candidate"}} if !reflect.DeepEqual(candidate, candidateWant) || candidateCalls != 1 || finalCalls != 0 { t.Fatalf("candidate decode = %#v, calls = candidate %d, final %d", candidate, candidateCalls, finalCalls) } decoded, err := entry.decode([]byte(`{"items":["one"]}`)) if err != nil { t.Fatalf("final decode error = %v", err) } finalWant := codecNotes{Items: []string{"final"}} if !reflect.DeepEqual(decoded, finalWant) || candidateCalls != 1 || finalCalls != 1 { t.Fatalf("final decode = %#v, calls = candidate %d, final %d", decoded, candidateCalls, finalCalls) } } func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) { registry := NewArtifactCodecRegistry() codec := notesCodec() codec.kind = " test/notes " codec.schema.ID = " notes.v1 " codec.schema.Name = " notes " codec.schema.Version = " v1 " codec.mediaType = " application/json " if err := RegisterArtifactCodec(registry, codec); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) } codec.schema.JSONSchema[0] = '[' spec, ok := registry.Spec("test/notes") if !ok { t.Fatal("Spec() ok = false, want true") } if spec.Kind != "test/notes" || spec.Schema.ID != "notes.v1" || spec.Schema.Name != "notes" || spec.Schema.Version != "v1" || spec.MediaType != "application/json" { t.Fatalf("Spec() = %#v, want normalized metadata", spec) } if spec.SchemaDigest != contracts.DigestArtifactSchema(spec.Schema) { t.Fatalf("schema digest = %q, want %q", spec.SchemaDigest, contracts.DigestArtifactSchema(spec.Schema)) } spec.Schema.JSONSchema[0] = '[' again, _ := registry.Spec("test/notes") if string(again.Schema.JSONSchema) != `{"additionalProperties":false,"properties":{"details":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"items":{"items":{"type":"string"},"type":"array"},"labels":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["items"],"type":"object"}` { t.Fatalf("stored JSON Schema changed through Spec result: %q", again.Schema.JSONSchema) } } func TestArtifactCodecRegistryRejectsInvalidRegistration(t *testing.T) { tests := []struct { name string mutate func(*testArtifactCodec[codecNotes]) want string }{ {name: "kind", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.kind = " " }, want: "kind"}, {name: "schema id", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.ID = "" }, want: "schema id"}, {name: "schema name", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.Name = "" }, want: "schema name"}, {name: "schema version", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.Version = "" }, want: "schema version"}, {name: "media type", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.mediaType = "" }, want: "media type"}, {name: "empty JSON Schema", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.JSONSchema = nil }, want: "JSON Schema"}, {name: "invalid JSON Schema", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.JSONSchema = []byte(`{"type":`) }, want: "valid JSON"}, {name: "non-schema JSON", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.JSONSchema = []byte(`[]`) }, want: "JSON Schema is invalid"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { registry := NewArtifactCodecRegistry() codec := notesCodec() test.mutate(&codec) if err := RegisterArtifactCodec(registry, codec); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("RegisterArtifactCodec() error = %v, want %q", err, test.want) } }) } } func TestArtifactCodecRegistryRejectsDuplicateKind(t *testing.T) { registry := NewArtifactCodecRegistry() if err := RegisterArtifactCodec(registry, notesCodec()); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) } duplicate := notesCodec() duplicate.kind = " test/notes " if err := RegisterArtifactCodec(registry, duplicate); err == nil || !strings.Contains(err.Error(), "already registered") { t.Fatalf("duplicate registration error = %v, want duplicate kind error", err) } } func TestArtifactCodecRegistryRejectsNilRegistryAndCodec(t *testing.T) { codec := notesCodec() if err := RegisterArtifactCodec[codecNotes](nil, codec); err == nil || !strings.Contains(err.Error(), "registry") { t.Fatalf("nil registry error = %v, want registry error", err) } var nilCodec *testArtifactCodec[codecNotes] if err := RegisterArtifactCodec(NewArtifactCodecRegistry(), nilCodec); err == nil || !strings.Contains(err.Error(), "must not be nil") { t.Fatalf("nil codec error = %v, want codec error", err) } } func TestArtifactCodecRegistryStrictDecodeAndTypedFailures(t *testing.T) { registry := NewArtifactCodecRegistry() if err := RegisterArtifactCodec(registry, notesCodec()); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) } valid, err := registry.Encode("test/notes", codecNotes{Items: []string{"one"}}) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } for _, content := range [][]byte{ []byte(`{"items":["one"],"unknown":true}`), []byte(`{"items":["one"]} {}`), } { candidate := contracts.CloneSerializedArtifact(valid) candidate.Content = content _, err := registry.Decode(candidate) var operationErr *ArtifactCodecOperationError if !errors.As(err, &operationErr) || operationErr.Operation != "decode" { t.Fatalf("Decode(%q) error = %T %v, want typed decode error", content, err, err) } } wrongSchema := contracts.CloneSerializedArtifact(valid) wrongSchema.Schema.Version = "v2" _, err = registry.Decode(wrongSchema) var compatibilityErr *ArtifactCodecCompatibilityError if !errors.As(err, &compatibilityErr) { t.Fatalf("Decode(wrong schema) error = %T %v, want ArtifactCodecCompatibilityError", err, err) } } func TestArtifactCodecRegistryWrapsEncodeFailure(t *testing.T) { codec := notesCodec() cause := errors.New("cannot encode notes") codec.encodeFunc = func(codecNotes) ([]byte, error) { return nil, cause } registry := NewArtifactCodecRegistry() if err := RegisterArtifactCodec(registry, codec); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) } _, err := registry.Encode("test/notes", codecNotes{}) var operationErr *ArtifactCodecOperationError if !errors.As(err, &operationErr) || operationErr.Operation != "encode" || !errors.Is(err, cause) { t.Fatalf("Encode() error = %T %v, want typed wrapping encode error", err, err) } } func TestArtifactCodecRegistryWrapsCandidateDecodeFailure(t *testing.T) { cause := errors.New("cannot decode candidate notes") codec := notesCodec() codec.candidateDecodeFunc = func([]byte) (codecNotes, error) { return codecNotes{}, cause } registry := NewArtifactCodecRegistry() if err := RegisterArtifactCodec(registry, codec); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) } entry, _, err := registry.entry(codec.kind) if err != nil { t.Fatalf("entry() error = %v, want nil", err) } _, err = entry.decodeCandidate([]byte(`{"items":["one"]}`)) var operationErr *ArtifactCodecOperationError if !errors.As(err, &operationErr) || operationErr.Operation != "decode candidate" || !errors.Is(err, cause) { t.Fatalf("candidate decode error = %T %v, want typed wrapping error", err, err) } } func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) { shared := []byte(`{"items":["one"]}`) codec := notesCodec() codec.encodeFunc = func(codecNotes) ([]byte, error) { return shared, nil } codec.decodeFunc = func(content []byte) (codecNotes, error) { content[0] = '[' return codecNotes{Items: []string{"one"}}, nil } codec.candidateDecodeFunc = func(content []byte) (codecNotes, error) { content[0] = '[' return codecNotes{Items: []string{"candidate"}}, nil } registry := NewArtifactCodecRegistry() if err := RegisterArtifactCodec(registry, codec); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) } artifact, err := registry.Encode("test/notes", codecNotes{}) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } shared[0] = '[' if string(artifact.Content) != `{"items":["one"]}` { t.Fatalf("encoded content = %q after codec buffer mutation, want owned bytes", artifact.Content) } before := append([]byte(nil), artifact.Content...) if _, err := registry.Decode(artifact); err != nil { t.Fatalf("Decode() error = %v, want nil", err) } if !bytes.Equal(artifact.Content, before) { t.Fatalf("serialized content changed during decode: %q", artifact.Content) } candidateContent := []byte(`{"items":["candidate"]}`) candidateBefore := append([]byte(nil), candidateContent...) entry, _, err := registry.entry("test/notes") if err != nil { t.Fatalf("entry() error = %v, want nil", err) } if _, err := entry.decodeCandidate(candidateContent); err != nil { t.Fatalf("candidate decode error = %v, want nil", err) } if !bytes.Equal(candidateContent, candidateBefore) { t.Fatalf("candidate content changed during decode: %q", candidateContent) } } func notesCodec() testArtifactCodec[codecNotes] { return testArtifactCodec[codecNotes]{ kind: "test/notes", schema: contracts.ArtifactSchema{ ID: "notes.v1", Name: "notes", Version: "v1", JSONSchema: []byte(`{"additionalProperties":false,"properties":{"details":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"items":{"items":{"type":"string"},"type":"array"},"labels":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["items"],"type":"object"}`), }, mediaType: "application/json", encodeFunc: func(value codecNotes) ([]byte, error) { return json.Marshal(value) }, decodeFunc: func(content []byte) (codecNotes, error) { var value codecNotes return value, decodeStrictJSON(content, &value) }, } } func scoreCodec() testArtifactCodec[codecScore] { return testArtifactCodec[codecScore]{ kind: "test/score", schema: contracts.ArtifactSchema{ ID: "score.v1", Name: "score", Version: "v1", JSONSchema: []byte(`{"additionalProperties":false,"properties":{"value":{"type":"integer"}},"required":["value"],"type":"object"}`), }, mediaType: "application/json", encodeFunc: func(value codecScore) ([]byte, error) { return json.Marshal(value) }, decodeFunc: func(content []byte) (codecScore, error) { var value codecScore return value, decodeStrictJSON(content, &value) }, } } func decodeStrictJSON(content []byte, out any) error { decoder := json.NewDecoder(bytes.NewReader(content)) decoder.DisallowUnknownFields() if err := decoder.Decode(out); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return fmt.Errorf("unexpected trailing JSON value") } return fmt.Errorf("decode trailing JSON: %w", err) } return nil }