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