Files
notarius/internal/framework/checkpoint/identity_test.go

203 lines
7.4 KiB
Go

package checkpoint
import (
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestNewIdentityNormalizesOrderAndEmptyValues(t *testing.T) {
base := representativeIdentityInput()
identity, err := NewIdentity(base)
if err != nil {
t.Fatal(err)
}
for _, tt := range []struct {
name string
mutate func(*IdentityInput)
}{
{"selected lanes", func(v *IdentityInput) { v.SelectedLanes = []string{"lane-a", "lane-b"} }},
{"runtime fingerprints", func(v *IdentityInput) {
v.RuntimeOverrides = []Fingerprint{{Name: "model", Value: "large"}, {Name: "timeout", Value: "30s"}}
}},
{"references", func(v *IdentityInput) {
v.References = []artifacts.ReferenceProvenance{v.References[1], v.References[0]}
}},
{"provenance fingerprints", func(v *IdentityInput) {
v.ProvenanceFingerprints = []Fingerprint{{Name: "source", Value: "v2"}, {Name: "runner", Value: "v1"}}
}},
{"resolved lanes", func(v *IdentityInput) {
v.Pipeline.ArtifactLanes = []pipeline.ResolvedArtifactLane{v.Pipeline.ArtifactLanes[1], v.Pipeline.ArtifactLanes[0]}
}},
} {
t.Run(tt.name, func(t *testing.T) {
changed := cloneIdentityInput(base)
tt.mutate(&changed)
got, err := NewIdentity(changed)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(identity, got) {
t.Fatalf("reordered identity differs:\nbase=%#v\ngot=%#v", identity, got)
}
})
}
t.Run("duplicates and blanks are ignored", func(t *testing.T) {
changed := base
changed.SelectedLanes = []string{" ", "lane-b", "lane-a", "lane-a", ""}
changed.RuntimeOverrides = append(changed.RuntimeOverrides, Fingerprint{}, Fingerprint{Name: " ", Value: "ignored"}, Fingerprint{Name: "timeout", Value: "30s"})
changed.ProvenanceFingerprints = append(changed.ProvenanceFingerprints, Fingerprint{}, Fingerprint{Name: "", Value: "ignored"})
got, err := NewIdentity(changed)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(identity, got) {
t.Fatalf("empty or duplicate values changed identity:\nbase=%#v\ngot=%#v", identity, got)
}
})
}
func TestNewIdentityChangesForMeaningfulInputs(t *testing.T) {
base := representativeIdentityInput()
original, err := NewIdentity(base)
if err != nil {
t.Fatal(err)
}
cases := map[string]func(*IdentityInput){
"pipeline id": func(v *IdentityInput) { v.Pipeline.ID = "another-pipeline" },
"pipeline digest": func(v *IdentityInput) { v.Pipeline.Digest = "sha256:pipeline-digest-2" },
"input key": func(v *IdentityInput) { v.InputKey = "another-input" },
"raw input digest": func(v *IdentityInput) { v.RawInputDigest = "sha256:raw-input-2" },
"source digest": func(v *IdentityInput) { v.SourceDigest = "sha256:source-2" },
"selected lanes": func(v *IdentityInput) { v.SelectedLanes = []string{"lane-a"} },
"runtime override": func(v *IdentityInput) { v.RuntimeOverrides[0].Value = "60s" },
"reference digest": func(v *IdentityInput) { v.References[0].Digest = "sha256:reference-2" },
"reference identity": func(v *IdentityInput) { v.References[0].OriginURI = "file:///other-reference" },
"provenance": func(v *IdentityInput) { v.ProvenanceFingerprints[0].Value = "v3" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
changed := cloneIdentityInput(base)
mutate(&changed)
got, err := NewIdentity(changed)
if err != nil {
t.Fatal(err)
}
if got.Digest == original.Digest {
t.Fatalf("meaningful %s input did not change digest %q", name, got.Digest)
}
})
}
}
func TestNewIdentityUsesResolvedInputWhenKeyIsOmitted(t *testing.T) {
input := representativeIdentityInput()
input.InputKey = ""
identity, err := NewIdentity(input)
if err != nil {
t.Fatal(err)
}
if identity.InputKey != input.Pipeline.Input.Module {
t.Fatalf("input key = %q, want resolved module %q", identity.InputKey, input.Pipeline.Input.Module)
}
}
func TestNewIdentityRejectsMissingRequiredInputs(t *testing.T) {
cases := []struct {
name string
mutate func(*IdentityInput)
want string
}{
{"pipeline id", func(v *IdentityInput) { v.Pipeline.ID = "" }, "pipeline id"},
{"pipeline digest", func(v *IdentityInput) { v.Pipeline.Digest = "" }, "pipeline digest"},
{"input key", func(v *IdentityInput) { v.InputKey = ""; v.Pipeline.Input = pipeline.Binding("") }, "input key"},
{"input digests", func(v *IdentityInput) { v.RawInputDigest = ""; v.SourceDigest = "" }, "raw input digest or source digest"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
input := representativeIdentityInput()
tt.mutate(&input)
_, err := NewIdentity(input)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want category containing %q", err, tt.want)
}
})
}
}
func TestIdentityRelativePathIsDeterministicAndConfined(t *testing.T) {
identity, err := NewIdentity(representativeIdentityInput())
if err != nil {
t.Fatal(err)
}
first, err := identity.RelativePath()
if err != nil {
t.Fatal(err)
}
second, err := identity.RelativePath()
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatalf("relative path is not deterministic: %q != %q", first, second)
}
if filepath.IsAbs(first) || filepath.Clean(first) != first || filepath.ToSlash(first) != first {
t.Fatalf("path is not a clean relative slash-separated path: %q", first)
}
if strings.Contains(first, "../") || strings.HasPrefix(first, "../") || strings.Contains(first, `\\`) {
t.Fatalf("path escapes its root: %q", first)
}
parts := strings.Split(first, "/")
if len(parts) != 4 || parts[0] != "pipeline" || !strings.HasPrefix(parts[1], "input-") {
t.Fatalf("path does not contain the documented identity hierarchy: %q", first)
}
if !strings.Contains(parts[1], "source-digest") || !strings.Contains(parts[2], "pipeline-digest") || parts[3] == "" {
t.Fatalf("path omits digest-derived hierarchy: %q", first)
}
}
func representativeIdentityInput() IdentityInput {
return IdentityInput{
Pipeline: pipeline.ResolvedPipeline{
ID: "pipeline",
Digest: "sha256:pipeline-digest-000000000000",
Input: pipeline.Binding("input"),
ArtifactLanes: []pipeline.ResolvedArtifactLane{
{ID: "lane-b"},
{ID: "lane-a"},
},
},
InputKey: "input",
RawInputDigest: "sha256:raw-input-000000000000",
SourceDigest: "sha256:source-digest-000000000000",
SelectedLanes: []string{"lane-b", "lane-a"},
RuntimeOverrides: []Fingerprint{{Name: "timeout", Value: "30s"}, {Name: "model", Value: "large"}},
References: []artifacts.ReferenceProvenance{
{Stage: "chunk", SlotName: "glossary", OriginURI: "file:///glossary", Digest: "sha256:reference-1"},
{Stage: "extract", LaneID: "lane-a", SlotName: "party", OriginURI: "file:///party", Digest: "sha256:reference-2"},
},
ProvenanceFingerprints: []Fingerprint{{Name: "runner", Value: "v1"}, {Name: "source", Value: "v2"}},
}
}
func cloneIdentityInput(input IdentityInput) IdentityInput {
input.SelectedLanes = append([]string(nil), input.SelectedLanes...)
input.RuntimeOverrides = append([]Fingerprint(nil), input.RuntimeOverrides...)
input.References = append([]artifacts.ReferenceProvenance(nil), input.References...)
input.ProvenanceFingerprints = append([]Fingerprint(nil), input.ProvenanceFingerprints...)
input.Pipeline.ArtifactLanes = append([]pipeline.ResolvedArtifactLane(nil), input.Pipeline.ArtifactLanes...)
return input
}