354 lines
13 KiB
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
|
|
}
|