Files
notarius/internal/framework/pipeline/artifact_codec_registry_test.go

354 lines
13 KiB
Go

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"`
}
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)
}
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) }
var _ contracts.ArtifactCodec[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 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":{"items":{"items":{"type":"string"},"type":"array"}},"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 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
}
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)
}
}
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":{"items":{"items":{"type":"string"},"type":"array"}},"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
}