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) { steps := append([]pipeline.ResolvedPipelineStep(nil), v.Pipeline.Steps...) steps[0].ArtifactLanes = []pipeline.ResolvedArtifactLane{steps[0].ArtifactLanes[1], steps[0].ArtifactLanes[0]} v.Pipeline.Steps = steps }}, } { 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 tt.name == "resolved lanes" { if reflect.DeepEqual(identity, got) { t.Fatal("reordered topology did not change checkpoint identity") } return } 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"), Steps: []pipeline.ResolvedPipelineStep{{ ID: "default", 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.Steps[0].ArtifactLanes = append([]pipeline.ResolvedArtifactLane(nil), input.Pipeline.Steps[0].ArtifactLanes...) return input }