v3.2.0 4fc01fa

annotations_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close