1package manifest
2
3import (
4 "encoding/json"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9)
10
11func TestParseManifestAnnotations(t *testing.T) {
12 tests := []struct {
13 name string
14 manifestData []byte
15 contentType string
16 expectedAnnotations map[string]string
17 expectedError bool
18 errorContains string
19 }{
20 {
21 name: "OCI manifest with annotations",
22 manifestData: []byte(`{
23 "schemaVersion": 2,
24 "mediaType": "application/vnd.oci.image.manifest.v1+json",
25 "config": {
26 "mediaType": "application/vnd.oci.image.config.v1+json",
27 "digest": "sha256:abc123",
28 "size": 1234
29 },
30 "layers": [],
31 "annotations": {
32 "version": "1.0.0",
33 "created-by": "gordon",
34 "description": "Test image"
35 }
36 }`),
37 contentType: "application/vnd.oci.image.manifest.v1+json",
38 expectedAnnotations: map[string]string{
39 "version": "1.0.0",
40 "created-by": "gordon",
41 "description": "Test image",
42 },
43 },
44 {
45 name: "OCI manifest without annotations",
46 manifestData: []byte(`{
47 "schemaVersion": 2,
48 "mediaType": "application/vnd.oci.image.manifest.v1+json",
49 "config": {
50 "mediaType": "application/vnd.oci.image.config.v1+json",
51 "digest": "sha256:def456",
52 "size": 5678
53 },
54 "layers": []
55 }`),
56 contentType: "application/vnd.oci.image.manifest.v1+json",
57 expectedAnnotations: map[string]string{},
58 },
59 {
60 name: "OCI manifest with empty annotations",
61 manifestData: []byte(`{
62 "schemaVersion": 2,
63 "mediaType": "application/vnd.oci.image.manifest.v1+json",
64 "config": {
65 "mediaType": "application/vnd.oci.image.config.v1+json",
66 "digest": "sha256:empty123",
67 "size": 100
68 },
69 "layers": [],
70 "annotations": {}
71 }`),
72 contentType: "application/vnd.oci.image.manifest.v1+json",
73 expectedAnnotations: map[string]string{},
74 },
75 {
76 name: "Docker v2.2 manifest (no annotations support)",
77 manifestData: []byte(`{
78 "schemaVersion": 2,
79 "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
80 "config": {
81 "mediaType": "application/vnd.docker.container.image.v1+json",
82 "digest": "sha256:docker123",
83 "size": 2468
84 },
85 "layers": []
86 }`),
87 contentType: "application/vnd.docker.distribution.manifest.v2+json",
88 expectedAnnotations: map[string]string{},
89 },
90 {
91 name: "OCI image index with annotations",
92 manifestData: []byte(`{
93 "schemaVersion": 2,
94 "mediaType": "application/vnd.oci.image.index.v1+json",
95 "manifests": [],
96 "annotations": {
97 "version": "2.0.0",
98 "multi-arch": "true"
99 }
100 }`),
101 contentType: "application/vnd.oci.image.index.v1+json",
102 expectedAnnotations: map[string]string{
103 "version": "2.0.0",
104 "multi-arch": "true",
105 },
106 },
107 {
108 name: "Docker manifest list with annotations",
109 manifestData: []byte(`{
110 "schemaVersion": 2,
111 "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
112 "manifests": [],
113 "annotations": {
114 "build-date": "2023-01-01"
115 }
116 }`),
117 contentType: "application/vnd.docker.distribution.manifest.list.v2+json",
118 expectedAnnotations: map[string]string{
119 "build-date": "2023-01-01",
120 },
121 },
122 {
123 name: "manifest list without annotations",
124 manifestData: []byte(`{
125 "schemaVersion": 2,
126 "mediaType": "application/vnd.oci.image.index.v1+json",
127 "manifests": []
128 }`),
129 contentType: "application/vnd.oci.image.index.v1+json",
130 expectedAnnotations: map[string]string{},
131 },
132 {
133 name: "unsupported media type",
134 manifestData: []byte(`{
135 "schemaVersion": 1,
136 "mediaType": "application/vnd.docker.distribution.manifest.v1+json"
137 }`),
138 contentType: "application/vnd.docker.distribution.manifest.v1+json",
139 expectedError: true,
140 errorContains: "unsupported manifest media type",
141 },
142 {
143 name: "invalid JSON for OCI manifest",
144 manifestData: []byte(`{
145 "schemaVersion": 2,
146 "mediaType": "application/vnd.oci.image.manifest.v1+json",
147 "config": {
148 "invalid": json
149 }
150 }`),
151 contentType: "application/vnd.oci.image.manifest.v1+json",
152 expectedError: true,
153 errorContains: "failed to parse OCI manifest",
154 },
155 {
156 name: "invalid JSON for manifest list",
157 manifestData: []byte(`{
158 "schemaVersion": 2,
159 "mediaType": "application/vnd.oci.image.index.v1+json",
160 "manifests": [
161 invalid json
162 ]
163 }`),
164 contentType: "application/vnd.oci.image.index.v1+json",
165 expectedError: true,
166 errorContains: "failed to parse manifest list",
167 },
168 {
169 name: "OCI manifest with special characters in annotations",
170 manifestData: []byte(`{
171 "schemaVersion": 2,
172 "mediaType": "application/vnd.oci.image.manifest.v1+json",
173 "config": {
174 "mediaType": "application/vnd.oci.image.config.v1+json",
175 "digest": "sha256:special123",
176 "size": 999
177 },
178 "layers": [],
179 "annotations": {
180 "unicode": "测试🚀",
181 "special-chars": "!@#$%^&*()_+-={}[]|\\:;\"'<>?,./"
182 }
183 }`),
184 contentType: "application/vnd.oci.image.manifest.v1+json",
185 expectedAnnotations: map[string]string{
186 "unicode": "测试🚀",
187 "special-chars": "!@#$%^&*()_+-={}[]|\\:;\"'<>?,./",
188 },
189 },
190 }
191
192 for _, tt := range tests {
193 t.Run(tt.name, func(t *testing.T) {
194 annotations, err := ParseManifestAnnotations(tt.manifestData, tt.contentType)
195
196 if tt.expectedError {
197 require.Error(t, err)
198 if tt.errorContains != "" {
199 assert.Contains(t, err.Error(), tt.errorContains)
200 }
201 } else {
202 require.NoError(t, err)
203 assert.Equal(t, tt.expectedAnnotations, annotations)
204 }
205 })
206 }
207}
208
209func TestParseOCIManifestAnnotations(t *testing.T) {
210 tests := []struct {
211 name string
212 manifestData []byte
213 expectedAnnotations map[string]string
214 expectedError bool
215 errorContains string
216 }{
217 {
218 name: "valid OCI manifest with annotations",
219 manifestData: []byte(`{
220 "schemaVersion": 2,
221 "mediaType": "application/vnd.oci.image.manifest.v1+json",
222 "config": {
223 "mediaType": "application/vnd.oci.image.config.v1+json",
224 "digest": "sha256:test123",
225 "size": 1000
226 },
227 "layers": [],
228 "annotations": {
229 "key1": "value1",
230 "key2": "value2"
231 }
232 }`),
233 expectedAnnotations: map[string]string{
234 "key1": "value1",
235 "key2": "value2",
236 },
237 },
238 {
239 name: "OCI manifest with nil annotations",
240 manifestData: []byte(`{
241 "schemaVersion": 2,
242 "mediaType": "application/vnd.oci.image.manifest.v1+json",
243 "config": {
244 "mediaType": "application/vnd.oci.image.config.v1+json",
245 "digest": "sha256:nil123",
246 "size": 500
247 },
248 "layers": []
249 }`),
250 expectedAnnotations: map[string]string{},
251 },
252 {
253 name: "invalid JSON",
254 manifestData: []byte(`{
255 "schemaVersion": 2,
256 "config": invalid json
257 }`),
258 expectedError: true,
259 errorContains: "failed to parse OCI manifest",
260 },
261 {
262 name: "empty manifest data",
263 manifestData: []byte(`{}`),
264 expectedAnnotations: map[string]string{},
265 },
266 }
267
268 for _, tt := range tests {
269 t.Run(tt.name, func(t *testing.T) {
270 annotations, err := parseOCIManifestAnnotations(tt.manifestData)
271
272 if tt.expectedError {
273 require.Error(t, err)
274 if tt.errorContains != "" {
275 assert.Contains(t, err.Error(), tt.errorContains)
276 }
277 } else {
278 require.NoError(t, err)
279 assert.Equal(t, tt.expectedAnnotations, annotations)
280 }
281 })
282 }
283}
284
285func TestParseManifestListAnnotations(t *testing.T) {
286 tests := []struct {
287 name string
288 manifestData []byte
289 expectedAnnotations map[string]string
290 expectedError bool
291 errorContains string
292 }{
293 {
294 name: "valid manifest list with annotations",
295 manifestData: []byte(`{
296 "schemaVersion": 2,
297 "mediaType": "application/vnd.oci.image.index.v1+json",
298 "manifests": [],
299 "annotations": {
300 "platform": "multi",
301 "supported": "yes"
302 }
303 }`),
304 expectedAnnotations: map[string]string{
305 "platform": "multi",
306 "supported": "yes",
307 },
308 },
309 {
310 name: "manifest list with nil annotations",
311 manifestData: []byte(`{
312 "schemaVersion": 2,
313 "mediaType": "application/vnd.oci.image.index.v1+json",
314 "manifests": []
315 }`),
316 expectedAnnotations: map[string]string{},
317 },
318 {
319 name: "invalid JSON",
320 manifestData: []byte(`{
321 "schemaVersion": 2,
322 "manifests": invalid json
323 }`),
324 expectedError: true,
325 errorContains: "failed to parse manifest list",
326 },
327 {
328 name: "empty manifest list",
329 manifestData: []byte(`{}`),
330 expectedAnnotations: map[string]string{},
331 },
332 }
333
334 for _, tt := range tests {
335 t.Run(tt.name, func(t *testing.T) {
336 annotations, err := parseManifestListAnnotations(tt.manifestData)
337
338 if tt.expectedError {
339 require.Error(t, err)
340 if tt.errorContains != "" {
341 assert.Contains(t, err.Error(), tt.errorContains)
342 }
343 } else {
344 require.NoError(t, err)
345 assert.Equal(t, tt.expectedAnnotations, annotations)
346 }
347 })
348 }
349}
350
351func TestIsVersionedDeployment(t *testing.T) {
352 tests := []struct {
353 name string
354 annotations map[string]string
355 expected bool
356 }{
357 {
358 name: "has version annotation",
359 annotations: map[string]string{
360 "version": "1.0.0",
361 "created-by": "gordon",
362 },
363 expected: true,
364 },
365 {
366 name: "has empty version annotation",
367 annotations: map[string]string{
368 "version": "",
369 "other": "value",
370 },
371 expected: true,
372 },
373 {
374 name: "no version annotation",
375 annotations: map[string]string{
376 "created-by": "gordon",
377 "description": "test image",
378 },
379 expected: false,
380 },
381 {
382 name: "empty annotations map",
383 annotations: map[string]string{},
384 expected: false,
385 },
386 {
387 name: "nil annotations map",
388 annotations: nil,
389 expected: false,
390 },
391 {
392 name: "version annotation with whitespace",
393 annotations: map[string]string{
394 "version": " 1.2.3 ",
395 },
396 expected: true,
397 },
398 {
399 name: "version annotation case sensitivity",
400 annotations: map[string]string{
401 "Version": "1.0.0", // Different case
402 "VERSION": "2.0.0", // All caps
403 },
404 expected: false, // Should be case-sensitive
405 },
406 {
407 name: "multiple version-like annotations",
408 annotations: map[string]string{
409 "app-version": "1.0.0",
410 "image-version": "2.0.0",
411 "semver": "3.0.0",
412 },
413 expected: false, // Only exact "version" key should match
414 },
415 }
416
417 for _, tt := range tests {
418 t.Run(tt.name, func(t *testing.T) {
419 result := IsVersionedDeployment(tt.annotations)
420 assert.Equal(t, tt.expected, result)
421 })
422 }
423}
424
425func TestGetDeploymentVersion(t *testing.T) {
426 tests := []struct {
427 name string
428 annotations map[string]string
429 expected string
430 }{
431 {
432 name: "has version annotation",
433 annotations: map[string]string{
434 "version": "1.0.0",
435 "created-by": "gordon",
436 },
437 expected: "1.0.0",
438 },
439 {
440 name: "version with semantic versioning",
441 annotations: map[string]string{
442 "version": "2.1.3-alpha.1+build.123",
443 },
444 expected: "2.1.3-alpha.1+build.123",
445 },
446 {
447 name: "version with commit hash",
448 annotations: map[string]string{
449 "version": "abc123def456",
450 },
451 expected: "abc123def456",
452 },
453 {
454 name: "version with special characters",
455 annotations: map[string]string{
456 "version": "v1.0.0-rc.1_build-2023-01-01",
457 },
458 expected: "v1.0.0-rc.1_build-2023-01-01",
459 },
460 {
461 name: "empty version annotation",
462 annotations: map[string]string{
463 "version": "",
464 "other": "value",
465 },
466 expected: "",
467 },
468 {
469 name: "version with only whitespace",
470 annotations: map[string]string{
471 "version": " ",
472 },
473 expected: " ", // Should return as-is, not trimmed
474 },
475 {
476 name: "no version annotation",
477 annotations: map[string]string{
478 "created-by": "gordon",
479 "description": "test image",
480 },
481 expected: "",
482 },
483 {
484 name: "empty annotations map",
485 annotations: map[string]string{},
486 expected: "",
487 },
488 {
489 name: "nil annotations map",
490 annotations: nil,
491 expected: "",
492 },
493 {
494 name: "version annotation case sensitivity",
495 annotations: map[string]string{
496 "Version": "1.0.0", // Different case
497 "VERSION": "2.0.0", // All caps
498 "other": "value",
499 },
500 expected: "", // Should be case-sensitive, only "version" matches
501 },
502 {
503 name: "multiple version-like annotations",
504 annotations: map[string]string{
505 "app-version": "1.0.0",
506 "image-version": "2.0.0",
507 "version": "3.0.0", // Only this should be returned
508 "semver": "4.0.0",
509 },
510 expected: "3.0.0",
511 },
512 {
513 name: "version with unicode characters",
514 annotations: map[string]string{
515 "version": "测试版本-1.0.0",
516 },
517 expected: "测试版本-1.0.0",
518 },
519 {
520 name: "long version string",
521 annotations: map[string]string{
522 "version": "1.0.0-alpha.beta.gamma.delta.epsilon.zeta.eta.theta.iota.kappa.lambda.mu.nu.xi.omicron.pi.rho.sigma.tau.upsilon.phi.chi.psi.omega",
523 },
524 expected: "1.0.0-alpha.beta.gamma.delta.epsilon.zeta.eta.theta.iota.kappa.lambda.mu.nu.xi.omicron.pi.rho.sigma.tau.upsilon.phi.chi.psi.omega",
525 },
526 }
527
528 for _, tt := range tests {
529 t.Run(tt.name, func(t *testing.T) {
530 result := GetDeploymentVersion(tt.annotations)
531 assert.Equal(t, tt.expected, result)
532 })
533 }
534}
535
536func TestAnnotations_Integration(t *testing.T) {
537 t.Run("parse and check versioned deployment", func(t *testing.T) {
538 manifestData := []byte(`{
539 "schemaVersion": 2,
540 "mediaType": "application/vnd.oci.image.manifest.v1+json",
541 "config": {
542 "mediaType": "application/vnd.oci.image.config.v1+json",
543 "digest": "sha256:integration123",
544 "size": 1000
545 },
546 "layers": [],
547 "annotations": {
548 "version": "1.2.3",
549 "created-by": "gordon-ci",
550 "build-date": "2023-01-01T00:00:00Z"
551 }
552 }`)
553
554 // Parse annotations
555 annotations, err := ParseManifestAnnotations(manifestData, "application/vnd.oci.image.manifest.v1+json")
556 require.NoError(t, err)
557
558 // Check if it's a versioned deployment
559 isVersioned := IsVersionedDeployment(annotations)
560 assert.True(t, isVersioned)
561
562 // Get the version
563 version := GetDeploymentVersion(annotations)
564 assert.Equal(t, "1.2.3", version)
565 })
566
567 t.Run("parse Docker manifest and check versioning", func(t *testing.T) {
568 manifestData := []byte(`{
569 "schemaVersion": 2,
570 "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
571 "config": {
572 "mediaType": "application/vnd.docker.container.image.v1+json",
573 "digest": "sha256:docker123",
574 "size": 500
575 },
576 "layers": []
577 }`)
578
579 // Parse annotations (should be empty for Docker manifest)
580 annotations, err := ParseManifestAnnotations(manifestData, "application/vnd.docker.distribution.manifest.v2+json")
581 require.NoError(t, err)
582 assert.Empty(t, annotations)
583
584 // Check versioning (should be false)
585 isVersioned := IsVersionedDeployment(annotations)
586 assert.False(t, isVersioned)
587
588 version := GetDeploymentVersion(annotations)
589 assert.Empty(t, version)
590 })
591
592 t.Run("parse manifest list with version", func(t *testing.T) {
593 manifestData := []byte(`{
594 "schemaVersion": 2,
595 "mediaType": "application/vnd.oci.image.index.v1+json",
596 "manifests": [
597 {
598 "mediaType": "application/vnd.oci.image.manifest.v1+json",
599 "digest": "sha256:amd64",
600 "size": 1000,
601 "platform": {
602 "architecture": "amd64",
603 "os": "linux"
604 }
605 }
606 ],
607 "annotations": {
608 "version": "2.0.0-beta.1",
609 "multi-arch": "true"
610 }
611 }`)
612
613 // Parse annotations
614 annotations, err := ParseManifestAnnotations(manifestData, "application/vnd.oci.image.index.v1+json")
615 require.NoError(t, err)
616
617 // Verify specific annotations
618 assert.Equal(t, "2.0.0-beta.1", annotations["version"])
619 assert.Equal(t, "true", annotations["multi-arch"])
620
621 // Check versioning functions
622 isVersioned := IsVersionedDeployment(annotations)
623 assert.True(t, isVersioned)
624
625 version := GetDeploymentVersion(annotations)
626 assert.Equal(t, "2.0.0-beta.1", version)
627 })
628
629 t.Run("round trip JSON parsing", func(t *testing.T) {
630 // Create an OCI manifest struct
631 originalManifest := OCIManifest{
632 SchemaVersion: 2,
633 MediaType: "application/vnd.oci.image.manifest.v1+json",
634 Config: OCIDescriptor{
635 MediaType: "application/vnd.oci.image.config.v1+json",
636 Digest: "sha256:config123",
637 Size: 999,
638 },
639 Layers: []OCIDescriptor{},
640 Annotations: map[string]string{
641 "version": "1.0.0",
642 "description": "Round trip test",
643 },
644 }
645
646 // Marshal to JSON
647 jsonData, err := json.Marshal(originalManifest)
648 require.NoError(t, err)
649
650 // Parse annotations using our function
651 annotations, err := ParseManifestAnnotations(jsonData, "application/vnd.oci.image.manifest.v1+json")
652 require.NoError(t, err)
653
654 // Verify annotations match
655 assert.Equal(t, originalManifest.Annotations, annotations)
656
657 // Verify versioning functions work
658 assert.True(t, IsVersionedDeployment(annotations))
659 assert.Equal(t, "1.0.0", GetDeploymentVersion(annotations))
660 })
661}