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