1package domain
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestValidOCIDigest(t *testing.T) {
9 tests := []struct {
10 name string
11 digest string
12 want bool
13 }{
14 {"sha256", "sha256:" + strings.Repeat("ab", 32), true},
15 {"sha512", "sha512:" + strings.Repeat("cd", 64), true},
16 {"empty", "", false},
17 {"no algorithm", strings.Repeat("ab", 32), false},
18 {"empty encoded", "sha256:", false},
19 {"short encoded", "sha256:abcd", false},
20 {"uppercase algorithm", "SHA256:" + strings.Repeat("ab", 32), false},
21 {"non hex", "sha256:" + strings.Repeat("zz", 32), false},
22 {"space inside", "sha256:" + strings.Repeat("ab", 31) + "a ", false},
23 }
24 for _, tc := range tests {
25 t.Run(tc.name, func(t *testing.T) {
26 if got := ValidOCIDigest(tc.digest); got != tc.want {
27 t.Fatalf("ValidOCIDigest(%q) = %v, want %v", tc.digest, got, tc.want)
28 }
29 })
30 }
31}
32
33func TestRegistryTagRefValidation(t *testing.T) {
34 valid := RegistryTagRef{Repository: "app", Tag: "v1"}
35 if !valid.Valid() || valid.String() != "app:v1" || valid.Key() == "" {
36 t.Fatalf("valid registry tag ref not accepted: %+v", valid)
37 }
38
39 for _, ref := range []RegistryTagRef{
40 {},
41 {Repository: "app"},
42 {Tag: "v1"},
43 {Repository: "app", Tag: " "},
44 {Repository: "a pp", Tag: "v1"},
45 } {
46 if ref.Valid() {
47 t.Fatalf("empty or whitespace identity accepted: %+v", ref)
48 }
49 if ref.Key() != "" {
50 t.Fatalf("invalid ref produced a map key: %+v", ref)
51 }
52 }
53
54 if _, err := NewRegistryTagRef("", "v1"); err == nil {
55 t.Fatal("expected constructor to reject an empty repository")
56 }
57}
58
59func TestIdentityKeysAreUnambiguous(t *testing.T) {
60 // A repository containing a colon must not collide with the
61 // repository/tag separator used by the map key. The human display
62 // form is documented as ambiguous and never parsed back.
63 first := RegistryTagRef{Repository: "a:b", Tag: "c"}
64 second := RegistryTagRef{Repository: "a", Tag: "b:c"}
65 if first.Key() == second.Key() {
66 t.Fatalf("distinct references collided on key: %q", first.Key())
67 }
68 if first.Key() == "" || second.Key() == "" {
69 t.Fatalf("valid references must carry a canonical key: %q / %q", first.Key(), second.Key())
70 }
71}
72
73func TestRuntimeImageRefRejectsPlaceholders(t *testing.T) {
74 for _, id := range []string{"", " ", "<none>", "<none>:<none>"} {
75 if ref := (RuntimeImageRef{ID: id}); ref.Valid() {
76 t.Fatalf("placeholder image ID %q accepted", id)
77 }
78 }
79 if !(RuntimeImageRef{ID: "sha256:abc"}).Valid() {
80 t.Fatal("exact image ID rejected")
81 }
82}
83
84func TestRuntimeVolumeRefValidation(t *testing.T) {
85 if !(RuntimeVolumeRef{Name: "gordon-app-data"}).Valid() {
86 t.Fatal("exact volume name rejected")
87 }
88 for _, name := range []string{"", " ", "vol ume", "vol\tume"} {
89 if ref := (RuntimeVolumeRef{Name: name}); ref.Valid() {
90 t.Fatalf("invalid volume name %q accepted", name)
91 }
92 }
93}
94
95func TestPruneReasonCoverage(t *testing.T) {
96 reasons := PruneReasons()
97 if len(reasons) == 0 {
98 t.Fatal("no reason codes defined")
99 }
100 seen := make(map[PruneReason]struct{}, len(reasons))
101 for _, reason := range reasons {
102 if _, dup := seen[reason]; dup {
103 t.Fatalf("reason %q listed twice in canonical order", reason)
104 }
105 seen[reason] = struct{}{}
106 if !reason.Valid() {
107 t.Fatalf("reason %q has no verdict category", reason)
108 }
109 if reason.Category() == "" {
110 t.Fatalf("reason %q has an empty category", reason)
111 }
112 }
113 if len(seen) != len(pruneReasonCategory) {
114 t.Fatalf("canonical order covers %d reasons, category map has %d", len(seen), len(pruneReasonCategory))
115 }
116}
117
118func TestPruneReasonWireValuesAreStable(t *testing.T) {
119 // These strings appear in JSON and human output. If one changes,
120 // this test must change deliberately.
121 want := map[PruneReason]string{
122 PruneReasonEligibleRetention: "eligible-retention-window",
123 PruneReasonEligibleUnreferencedBlob: "eligible-unreferenced-blob",
124 PruneReasonEligibleDanglingRuntimeImage: "eligible-dangling-runtime-image",
125 PruneReasonEligibleReleasedVolume: "eligible-released-volume",
126 PruneReasonProtectedLatest: "protected-latest",
127 PruneReasonProtectedRetentionWindow: "protected-retention-window",
128 PruneReasonProtectedDesiredRevision: "protected-desired-revision",
129 PruneReasonProtectedActiveService: "protected-active-service",
130 PruneReasonProtectedRecoveryInhibition: "protected-recovery-inhibition",
131 PruneReasonProtectedApplyIntent: "protected-apply-intent",
132 PruneReasonProtectedOperation: "protected-operation",
133 PruneReasonProtectedOwnership: "protected-ownership",
134 PruneReasonProtectedContainerUse: "protected-container-use",
135 PruneReasonProtectedPendingUpload: "protected-pending-upload",
136 PruneReasonProtectedSharedContent: "protected-shared-content",
137 PruneReasonProtectedPinned: "protected-pinned",
138 PruneReasonProtectedServiceManaged: "protected-service-managed",
139 PruneReasonProtectedManagedOnly: "protected-managed-without-ownership",
140 PruneReasonProtectedTagged: "protected-tagged-runtime-image",
141 PruneReasonProtectedUnmanaged: "protected-unmanaged",
142 PruneReasonUnknownIdentity: "unknown-identity",
143 PruneReasonUnknownProvenance: "unknown-provenance",
144 PruneReasonUnknownInventory: "unknown-incomplete-inventory",
145 PruneReasonUnknownManifest: "unknown-manifest-unreadable",
146 PruneReasonUnknownMediaType: "unknown-unsupported-media-type",
147 PruneReasonUnknownContainerUse: "unknown-container-use",
148 }
149 for reason, wire := range want {
150 if string(reason) != wire {
151 t.Fatalf("reason %q wire value changed to %q", wire, reason)
152 }
153 }
154 if len(want) != len(pruneReasonCategory) {
155 t.Fatalf("stability table covers %d reasons, %d defined", len(want), len(pruneReasonCategory))
156 }
157}
158
159func TestCanonicalReasonsIsDeterministic(t *testing.T) {
160 input := []PruneReason{
161 PruneReasonUnknownInventory,
162 PruneReasonProtectedActiveService,
163 PruneReasonProtectedLatest,
164 PruneReasonProtectedActiveService,
165 }
166 got := CanonicalReasons(input...)
167 want := []PruneReason{
168 PruneReasonProtectedLatest,
169 PruneReasonProtectedActiveService,
170 PruneReasonUnknownInventory,
171 }
172 if len(got) != len(want) {
173 t.Fatalf("CanonicalReasons returned %v, want %v", got, want)
174 }
175 for i := range want {
176 if got[i] != want[i] {
177 t.Fatalf("CanonicalReasons returned %v, want %v", got, want)
178 }
179 }
180
181 // A permuted input must produce the identical output.
182 permuted := []PruneReason{input[2], input[0], input[1], input[3]}
183 again := CanonicalReasons(permuted...)
184 for i := range want {
185 if again[i] != want[i] {
186 t.Fatalf("permuted input produced %v, want %v", again, want)
187 }
188 }
189}
190
191func TestValidateVerdictReasons(t *testing.T) {
192 if err := validateVerdictReasons(PruneVerdictEligible, []PruneReason{PruneReasonEligibleRetention}); err != nil {
193 t.Fatalf("well-formed eligible verdict rejected: %v", err)
194 }
195 cases := []struct {
196 name string
197 verdict PruneVerdict
198 reasons []PruneReason
199 }{
200 {"undefined verdict", PruneVerdict("maybe"), []PruneReason{PruneReasonEligibleRetention}},
201 {"no reasons", PruneVerdictEligible, nil},
202 {"undefined reason", PruneVerdictEligible, []PruneReason{"made-up"}},
203 {"reason contradicts verdict", PruneVerdictEligible, []PruneReason{PruneReasonProtectedLatest}},
204 {"unknown reason under protected", PruneVerdictProtected, []PruneReason{PruneReasonUnknownIdentity}},
205 }
206 for _, tc := range cases {
207 t.Run(tc.name, func(t *testing.T) {
208 if err := validateVerdictReasons(tc.verdict, tc.reasons); err == nil {
209 t.Fatal("expected validation failure")
210 }
211 })
212 }
213}
214
215func TestPrunePlanValidateRejectsMalformedEntries(t *testing.T) {
216 plan := &PrunePlan{
217 Tags: []RegistryTagVerdict{{
218 Ref: RegistryTagRef{Repository: "app", Tag: "v1"},
219 Verdict: PruneVerdictEligible,
220 Reasons: []PruneReason{PruneReasonEligibleRetention},
221 }},
222 Volumes: []VolumeVerdict{{
223 Ref: RuntimeVolumeRef{Name: "gordon-app-data"},
224 Verdict: PruneVerdictProtected,
225 Reasons: []PruneReason{PruneReasonProtectedOwnership},
226 }},
227 Gaps: []InventoryGap{{
228 Source: InventorySourceRegistry,
229 Reason: PruneReasonUnknownInventory,
230 Detail: "list tags failed",
231 }},
232 }
233 if err := plan.Validate(); err != nil {
234 t.Fatalf("well-formed plan rejected: %v", err)
235 }
236
237 bad := *plan
238 bad.Tags = []RegistryTagVerdict{{Verdict: PruneVerdictEligible, Reasons: []PruneReason{PruneReasonEligibleRetention}}}
239 if err := bad.Validate(); err == nil {
240 t.Fatal("plan with an invalid tag identity passed validation")
241 }
242
243 badGap := *plan
244 badGap.Gaps = []InventoryGap{{Source: InventorySourceRegistry, Reason: PruneReasonProtectedLatest}}
245 if err := badGap.Validate(); err == nil {
246 t.Fatal("plan with a non-unknown gap reason passed validation")
247 }
248}
249
250func TestPrunePlanEligibleAccessors(t *testing.T) {
251 plan := &PrunePlan{
252 Tags: []RegistryTagVerdict{
253 {Ref: RegistryTagRef{Repository: "app", Tag: "v1"}, Verdict: PruneVerdictEligible, Reasons: []PruneReason{PruneReasonEligibleRetention}},
254 {Ref: RegistryTagRef{Repository: "app", Tag: "v2"}, Verdict: PruneVerdictProtected, Reasons: []PruneReason{PruneReasonProtectedActiveService}},
255 },
256 Images: []RuntimeImageVerdict{
257 {Ref: RuntimeImageRef{ID: "sha256:dead"}, Verdict: PruneVerdictEligible, Reasons: []PruneReason{PruneReasonEligibleDanglingRuntimeImage}},
258 {Ref: RuntimeImageRef{ID: "sha256:beef"}, Verdict: PruneVerdictUnknown, Reasons: []PruneReason{PruneReasonUnknownContainerUse}},
259 },
260 Volumes: []VolumeVerdict{
261 {Ref: RuntimeVolumeRef{Name: "gordon-a-data"}, Verdict: PruneVerdictEligible, Reasons: []PruneReason{PruneReasonEligibleReleasedVolume}},
262 },
263 }
264
265 eligibleTags := plan.EligibleTags()
266 if len(eligibleTags) != 1 || eligibleTags[0].Tag != "v1" {
267 t.Fatalf("EligibleTags() = %v, want only app:v1", eligibleTags)
268 }
269 eligibleImages := plan.EligibleImages()
270 if len(eligibleImages) != 1 || eligibleImages[0].ID != "sha256:dead" {
271 t.Fatalf("EligibleImages() = %v, want only sha256:dead", eligibleImages)
272 }
273 eligibleVolumes := plan.EligibleVolumes()
274 if len(eligibleVolumes) != 1 || eligibleVolumes[0].Name != "gordon-a-data" {
275 t.Fatalf("EligibleVolumes() = %v, want only gordon-a-data", eligibleVolumes)
276 }
277 if got := plan.CountByVerdict(PruneVerdictEligible); got != 3 {
278 t.Fatalf("CountByVerdict(eligible) = %d, want 3", got)
279 }
280 if got := plan.CountByVerdict(PruneVerdictUnknown); got != 1 {
281 t.Fatalf("CountByVerdict(unknown) = %d, want 1", got)
282 }
283}
284
285func TestPruneProtectionSnapshotLookups(t *testing.T) {
286 digest := "sha256:" + strings.Repeat("ab", 32)
287 snapshot := &PruneProtectionSnapshot{
288 Roots: []ProtectionRoot{
289 {Kind: ProtectionLatest, Ref: "app\x00latest"},
290 {Kind: ProtectionActiveService, Ref: digest, Owner: "app/web"},
291 {Kind: ProtectionContainerUse, Ref: "sha256:imageid", Owner: "container-1"},
292 {Kind: ProtectionOwnership, Ref: "gordon-app-data", Owner: "app"},
293 },
294 VolumeClaims: []VolumeClaim{
295 {Name: "gordon-app-data", App: "app", Service: "web", State: VolumeClaimAttached},
296 {Name: "gordon-app-data", App: "app", Service: "web", State: VolumeClaimRetained},
297 },
298 }
299
300 if !snapshot.ProtectsDigest(digest) {
301 t.Fatal("digest root not matched")
302 }
303 if snapshot.ProtectsDigest("") {
304 t.Fatal("empty digest matched a root")
305 }
306 if snapshot.ProtectsDigest("not-a-digest") {
307 t.Fatal("invalid digest matched a root")
308 }
309 if !snapshot.ProtectsRuntimeImage("sha256:imageid", nil) {
310 t.Fatal("runtime image ID root not matched")
311 }
312 if !snapshot.ProtectsRuntimeImage("", []string{digest}) {
313 t.Fatal("repo digest root not matched")
314 }
315 if snapshot.Protects(ProtectionLatest, "other\x00latest") {
316 t.Fatal("non-matching tag root matched")
317 }
318
319 claim, ok := snapshot.VolumeClaimFor("gordon-app-data")
320 if !ok || claim.State != VolumeClaimRetained {
321 t.Fatalf("VolumeClaimFor returned %+v/%v, want the last recorded claim", claim, ok)
322 }
323 if claim.State.Protects() != true {
324 t.Fatal("retained claim must protect")
325 }
326 if VolumeClaimReleased.Protects() {
327 t.Fatal("released claim must not protect")
328 }
329 if _, ok := snapshot.VolumeClaimFor("absent"); ok {
330 t.Fatal("absent volume reported a claim")
331 }
332 if snapshot.Complete() != true {
333 t.Fatal("snapshot without gaps must be complete")
334 }
335
336 gapped := &PruneProtectionSnapshot{Gaps: []InventoryGap{{
337 Source: InventorySourceAppState,
338 Reason: PruneReasonUnknownInventory,
339 Detail: "unreadable app record",
340 }}}
341 if gapped.Complete() {
342 t.Fatal("snapshot with a valid gap reported complete")
343 }
344 if (*PruneProtectionSnapshot)(nil).Complete() {
345 t.Fatal("nil snapshot must not report complete")
346 }
347}
348
349func TestVolumeClaimValidation(t *testing.T) {
350 if !(VolumeClaim{Name: "vol", App: "app", State: VolumeClaimAttached}).Valid() {
351 t.Fatal("complete claim rejected")
352 }
353 for _, claim := range []VolumeClaim{
354 {App: "app", State: VolumeClaimAttached},
355 {Name: "vol", State: VolumeClaimAttached},
356 {Name: "vol", App: "app"},
357 {Name: "vol", App: "app", State: "gone"},
358 } {
359 if claim.Valid() {
360 t.Fatalf("incomplete claim accepted: %+v", claim)
361 }
362 }
363}
364
365func TestRuntimeInventoryCompleteness(t *testing.T) {
366 if !(&RuntimeInventory{}).Complete() {
367 t.Fatal("empty inventory must be complete")
368 }
369 if (*RuntimeInventory)(nil).Complete() {
370 t.Fatal("nil inventory must not be complete")
371 }
372 if (&RuntimeInventory{Gaps: []InventoryGap{{
373 Source: InventorySourceRuntimeImages,
374 Reason: PruneReasonUnknownInventory,
375 Detail: "list images failed",
376 }}}).Complete() {
377 t.Fatal("inventory with a gap reported complete")
378 }
379}