1package cli
2
3import (
4 "context"
5 "io"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/stretchr/testify/assert"
13)
14
15func TestValidateBuildArg(t *testing.T) {
16 tests := []struct {
17 name string
18 arg string
19 wantErr bool
20 }{
21 {"valid simple", "FOO=bar", false},
22 {"valid with underscore key", "_FOO=bar", false},
23 {"valid with numbers in key", "FOO123=bar", false},
24 {"valid empty value", "FOO=", false},
25 {"valid complex value", "FOO=bar baz=qux", false},
26 {"invalid no equals", "FOO", true},
27 {"invalid starts with number", "1FOO=bar", true},
28 {"invalid starts with dash", "-FOO=bar", true},
29 {"invalid special chars in key", "FOO-BAR=baz", true},
30 {"invalid empty string", "", true},
31 {"invalid equals only", "=value", true},
32 }
33
34 for _, tt := range tests {
35 t.Run(tt.name, func(t *testing.T) {
36 err := validateBuildArg(tt.arg)
37 if tt.wantErr {
38 assert.Error(t, err)
39 } else {
40 assert.NoError(t, err)
41 }
42 })
43 }
44}
45
46func TestParseImageRef(t *testing.T) {
47 tests := []struct {
48 name string
49 image string
50 wantRegistry string
51 wantName string
52 wantTag string
53 }{
54 {"full ref", "reg.example.com/myapp:v1.0.0", "reg.example.com", "myapp", "v1.0.0"},
55 {"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"},
56 {"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"},
57 {"no slash", "myapp:v1.0.0", "", "", ""},
58 }
59
60 for _, tt := range tests {
61 t.Run(tt.name, func(t *testing.T) {
62 registry, name, tag := parseImageRef(tt.image)
63 assert.Equal(t, tt.wantRegistry, registry)
64 assert.Equal(t, tt.wantName, name)
65 assert.Equal(t, tt.wantTag, tag)
66 })
67 }
68}
69
70func TestClassifyPushArgument(t *testing.T) {
71 tests := []struct {
72 name string
73 arg string
74 want classifiedPushArg
75 }{
76 {name: "tagged image latest", arg: "myapp:latest", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp:latest", repository: "myapp"}},
77 {name: "tagged image semver", arg: "myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp:v1.2.3", repository: "myapp"}},
78 {name: "registry qualified image", arg: "registry.example.com/myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "registry.example.com/myapp:v1.2.3", repository: "myapp"}},
79 {name: "registry qualified digest", arg: "registry.example.com/myapp@sha256:deadbeef", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "registry.example.com/myapp@sha256:deadbeef", repository: "myapp"}},
80 {name: "legacy domain", arg: "app.example.com", want: classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: "app.example.com"}},
81 {name: "bare image name", arg: "myapp", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp", repository: "myapp"}},
82 }
83
84 for _, tt := range tests {
85 t.Run(tt.name, func(t *testing.T) {
86 got := classifyPushArgument(tt.arg)
87 assert.Equal(t, tt.want, got)
88 })
89 }
90}
91
92func TestBuildAndPush_BuildArgs(t *testing.T) {
93 // Verify buildImageArgs produces --load instead of --push
94 args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")
95
96 assert.Contains(t, args, "--load")
97 assert.NotContains(t, args, "--push")
98 assert.Contains(t, args, "--platform")
99 assert.Contains(t, args, "-f")
100 assert.Contains(t, args, "Dockerfile")
101 assert.Contains(t, args, "VERSION=v1.0.0")
102}
103
104func TestBuildImageArgsInjectsGitBuildArgs(t *testing.T) {
105 args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", nil, "registry/img:v1.2.3", "registry/img:latest")
106
107 // Must contain explicit KEY=VALUE for all standard git build args
108 argStr := strings.Join(args, " ")
109 for _, key := range []string{"VERSION=v1.2.3", "GIT_TAG=v1.2.3", "GIT_SHA=", "BUILD_TIME="} {
110 if !strings.Contains(argStr, key) {
111 t.Errorf("expected args to contain %q, got: %s", key, argStr)
112 }
113 }
114
115 // Must NOT contain bare "--build-arg VERSION" (without =value)
116 for i, a := range args {
117 if a == "--build-arg" && i+1 < len(args) && args[i+1] == "VERSION" {
118 t.Error("found bare '--build-arg VERSION' (without =value); should be '--build-arg VERSION=v1.2.3'")
119 }
120 }
121}
122
123func TestBuildImageArgsUserArgsOverrideDefaults(t *testing.T) {
124 userArgs := []string{"GIT_TAG=custom-override"}
125 args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", userArgs, "r/i:v1.2.3", "r/i:latest")
126
127 // Count how many times GIT_TAG appears and track the last occurrence index.
128 // Docker uses the last occurrence of a duplicate --build-arg key, so the user
129 // override must come after the default injected value.
130 count := 0
131 lastIdx := -1
132 for i, a := range args {
133 if a == "--build-arg" && i+1 < len(args) && strings.HasPrefix(args[i+1], "GIT_TAG=") {
134 count++
135 lastIdx = i + 1
136 }
137 }
138 if count < 2 {
139 t.Errorf("expected GIT_TAG to appear twice (default + override), got %d", count)
140 }
141 if lastIdx < 0 || args[lastIdx] != "GIT_TAG="+userArgs[0][len("GIT_TAG="):] {
142 t.Errorf("expected last GIT_TAG= arg to be the user override %q, got %q", "GIT_TAG=custom-override", args[lastIdx])
143 }
144}
145
146func TestParseTagRef(t *testing.T) {
147 tests := []struct {
148 name string
149 ref string
150 want string
151 }{
152 {name: "github tag ref", ref: "refs/tags/v1.2.3", want: "v1.2.3"},
153 {name: "peeled tag ref", ref: "refs/tags/v1.2.3^{}", want: "v1.2.3"},
154 {name: "branch ref", ref: "refs/heads/main", want: ""},
155 {name: "empty", ref: "", want: ""},
156 }
157
158 for _, tt := range tests {
159 t.Run(tt.name, func(t *testing.T) {
160 assert.Equal(t, tt.want, parseTagRef(tt.ref))
161 })
162 }
163}
164
165func TestVersionFromTagRefs(t *testing.T) {
166 tests := []struct {
167 name string
168 env map[string]string
169 want string
170 }{
171 {
172 name: "github ref tag",
173 env: map[string]string{"GITHUB_REF": "refs/tags/v2.0.0"},
174 want: "v2.0.0",
175 },
176 {
177 name: "github ref type and name",
178 env: map[string]string{
179 "GITHUB_REF_TYPE": "tag",
180 "GITHUB_REF_NAME": "v2.1.0",
181 },
182 want: "v2.1.0",
183 },
184 {
185 name: "gitlab commit tag",
186 env: map[string]string{"CI_COMMIT_TAG": "v3.0.0"},
187 want: "v3.0.0",
188 },
189 {
190 name: "azure source branch tag ref",
191 env: map[string]string{"BUILD_SOURCEBRANCH": "refs/tags/v4.0.0"},
192 want: "v4.0.0",
193 },
194 {
195 name: "no tag refs",
196 env: map[string]string{"GITHUB_REF": "refs/heads/main"},
197 want: "",
198 },
199 }
200
201 for _, tt := range tests {
202 t.Run(tt.name, func(t *testing.T) {
203 got := versionFromTagRefs(func(key string) string {
204 return tt.env[key]
205 })
206 assert.Equal(t, tt.want, got)
207 })
208 }
209}
210
211func TestBuildImageArgs_CustomDockerfile(t *testing.T) {
212 args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")
213
214 assert.Contains(t, args, "-f")
215 assert.Contains(t, args, "docker/app/Dockerfile")
216}
217
218func TestParseDockerfileLabels(t *testing.T) {
219 tests := []struct {
220 name string
221 content string
222 expected map[string]string
223 }{
224 {
225 name: "simple label",
226 content: `LABEL gordon.domain="myapp.example.com"`,
227 expected: map[string]string{
228 "gordon.domain": "myapp.example.com",
229 },
230 },
231 {
232 name: "multiple labels",
233 content: `LABEL gordon.domain="myapp.example.com"
234LABEL gordon.proxy.port="8080"
235LABEL gordon.health="/health"`,
236 expected: map[string]string{
237 "gordon.domain": "myapp.example.com",
238 "gordon.proxy.port": "8080",
239 "gordon.health": "/health",
240 },
241 },
242 {
243 name: "multi-label on one line",
244 content: `LABEL gordon.domain="myapp.example.com" gordon.proxy.port="8080"`,
245 expected: map[string]string{
246 "gordon.domain": "myapp.example.com",
247 "gordon.proxy.port": "8080",
248 },
249 },
250 {
251 name: "unquoted value",
252 content: `LABEL gordon.domain=myapp.example.com`,
253 expected: map[string]string{
254 "gordon.domain": "myapp.example.com",
255 },
256 },
257 {
258 name: "single-quoted value",
259 content: `LABEL gordon.domain='myapp.example.com'`,
260 expected: map[string]string{
261 "gordon.domain": "myapp.example.com",
262 },
263 },
264 {
265 name: "non-gordon labels ignored",
266 content: `LABEL maintainer="John" gordon.domain="myapp.example.com"`,
267 expected: map[string]string{
268 "gordon.domain": "myapp.example.com",
269 },
270 },
271 {
272 name: "comma-separated domains",
273 content: `LABEL gordon.domains="app1.example.com,app2.example.com"`,
274 expected: map[string]string{
275 "gordon.domains": "app1.example.com,app2.example.com",
276 },
277 },
278 {
279 name: "no labels",
280 content: `FROM alpine:latest`,
281 expected: map[string]string{},
282 },
283 {
284 name: "comments and empty lines",
285 content: "# This is a comment\n\nLABEL gordon.domain=\"test.com\"",
286 expected: map[string]string{"gordon.domain": "test.com"},
287 },
288 }
289
290 for _, tt := range tests {
291 t.Run(tt.name, func(t *testing.T) {
292 // Write a temp Dockerfile
293 dir := t.TempDir()
294 dockerfile := filepath.Join(dir, "Dockerfile")
295 err := os.WriteFile(dockerfile, []byte(tt.content), 0644)
296 assert.NoError(t, err)
297
298 labels := parseDockerfileLabels(dockerfile)
299 assert.Equal(t, tt.expected, labels)
300 })
301 }
302}
303
304func TestParseDockerfileLabels_NonExistent(t *testing.T) {
305 labels := parseDockerfileLabels("/nonexistent/Dockerfile")
306 assert.Empty(t, labels)
307}
308
309func TestDetectImageName_FromDockerfileLabels(t *testing.T) {
310 dir := t.TempDir()
311 dockerfile := filepath.Join(dir, "Dockerfile")
312 err := os.WriteFile(dockerfile, []byte(`FROM alpine
313LABEL gordon.domain="myapp.example.com"
314`), 0644)
315 assert.NoError(t, err)
316
317 name, err := detectImageName(dockerfile)
318 assert.NoError(t, err)
319 assert.Equal(t, "myapp.example.com", name)
320}
321
322func TestDetectImageName_FallbackToDir(t *testing.T) {
323 dir := t.TempDir()
324 dockerfile := filepath.Join(dir, "Dockerfile")
325 err := os.WriteFile(dockerfile, []byte(`FROM alpine
326`), 0644)
327 assert.NoError(t, err)
328
329 // When no gordon.domain label, falls back to cwd basename
330 name, err := detectImageName(dockerfile)
331 assert.NoError(t, err)
332 assert.NotEmpty(t, name)
333}
334
335func TestSplitLabelPairs(t *testing.T) {
336 tests := []struct {
337 name string
338 content string
339 expected []string
340 }{
341 {
342 name: "single pair",
343 content: `gordon.domain="test.com"`,
344 expected: []string{`gordon.domain="test.com"`},
345 },
346 {
347 name: "two pairs",
348 content: `gordon.domain="test.com" gordon.proxy.port="8080"`,
349 expected: []string{`gordon.domain="test.com"`, `gordon.proxy.port="8080"`},
350 },
351 {
352 name: "unquoted",
353 content: `gordon.domain=test.com gordon.proxy.port=8080`,
354 expected: []string{`gordon.domain=test.com`, `gordon.proxy.port=8080`},
355 },
356 {
357 name: "quoted with spaces in value",
358 content: `gordon.domain="my app.com"`,
359 expected: []string{`gordon.domain="my app.com"`},
360 },
361 }
362
363 for _, tt := range tests {
364 t.Run(tt.name, func(t *testing.T) {
365 result := splitLabelPairs(tt.content)
366 assert.Equal(t, tt.expected, result)
367 })
368 }
369}
370
371func TestGetGitVersionNoTagsReturnsFallbackAndWarns(t *testing.T) {
372 ctx := context.Background()
373
374 // Create a temp dir with a git repo that has no tags
375 tmpDir := t.TempDir()
376 if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil { // #nosec G204
377 t.Skipf("git init failed: %v", err)
378 }
379 // Need at least one commit so git describe has something to describe
380 if err := exec.Command("git", "-C", tmpDir, "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "init").Run(); err != nil { // #nosec G204
381 t.Skipf("git commit failed: %v", err)
382 }
383
384 // Change to the tmpDir for this test so getGitVersion uses the tag-less repo
385 origDir, err := os.Getwd()
386 if err != nil {
387 t.Fatal(err)
388 }
389 if err := os.Chdir(tmpDir); err != nil { // #nosec G204
390 t.Fatal(err)
391 }
392 defer os.Chdir(origDir) // #nosec G204
393
394 // Redirect stderr to capture the warning
395 origStderr := os.Stderr
396 r, w, err := os.Pipe()
397 if err != nil {
398 t.Fatal(err)
399 }
400 os.Stderr = w
401 defer func() {
402 w.Close()
403 os.Stderr = origStderr
404 }()
405
406 v := getGitVersion(ctx)
407
408 w.Close()
409 os.Stderr = origStderr
410 stderrOutput, _ := io.ReadAll(r)
411
412 // Should return "" (fallback to "latest" handled by determineVersion)
413 assert.Equal(t, "", v, "expected empty string fallback when no git tags exist")
414
415 // Should have printed a warning to stderr
416 assert.Contains(t, string(stderrOutput), "latest",
417 "expected a warning about 'latest' fallback on stderr")
418}
419
420func TestParseLabelPair(t *testing.T) {
421 tests := []struct {
422 name string
423 pair string
424 wantKey string
425 wantValue string
426 wantOk bool
427 }{
428 {"quoted", `gordon.domain="test.com"`, "gordon.domain", "test.com", true},
429 {"unquoted", `gordon.domain=test.com`, "gordon.domain", "test.com", true},
430 {"single quoted", `gordon.domain='test.com'`, "gordon.domain", "test.com", true},
431 {"empty value", `gordon.domain=`, "gordon.domain", "", true},
432 {"no equals", `gordon.domain`, "", "", false},
433 }
434
435 for _, tt := range tests {
436 t.Run(tt.name, func(t *testing.T) {
437 key, value, ok := parseLabelPair(tt.pair)
438 assert.Equal(t, tt.wantOk, ok)
439 if ok {
440 assert.Equal(t, tt.wantKey, key)
441 assert.Equal(t, tt.wantValue, value)
442 }
443 })
444 }
445}