1package cli
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "github.com/bnema/gordon/internal/domain"
18)
19
20func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
21 prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
22 assert.Equal(t, "myapp", image)
23 return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
24 })
25 staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
26 assert.Equal(t, "myapp", image)
27 return nil, http.StatusOK
28 })
29
30 configurePushRemoteInferenceTestEnv(t, `
31[remotes.prod]
32url = "`+prod.URL+`"
33
34[remotes.staging]
35url = "`+staging.URL+`"
36`)
37
38 resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
39 require.NoError(t, err)
40 require.NotNil(t, resolved)
41 assert.Equal(t, "prod", resolved.Name)
42 assert.Equal(t, prod.URL, resolved.URL)
43}
44
45func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
46 prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
47 return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
48 })
49 staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
50 return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
51 })
52
53 configurePushRemoteInferenceTestEnv(t, `
54[remotes.prod]
55url = "`+prod.URL+`"
56
57[remotes.staging]
58url = "`+staging.URL+`"
59`)
60
61 resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
62 require.Error(t, err)
63 assert.Nil(t, resolved)
64 assert.Contains(t, err.Error(), `multiple saved remotes match image "myapp"`)
65 assert.Contains(t, err.Error(), "prod")
66 assert.Contains(t, err.Error(), "staging")
67}
68
69func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
70 originalRemoteFlag := remoteFlag
71 t.Cleanup(func() {
72 remoteFlag = originalRemoteFlag
73 })
74
75 called := false
76 prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
77 called = true
78 return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
79 })
80
81 configurePushRemoteInferenceTestEnv(t, `
82[remotes.prod]
83url = "`+prod.URL+`"
84`)
85 t.Setenv("GORDON_REMOTE", "")
86 remoteFlag = "prod"
87
88 resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
89 require.NoError(t, err)
90 assert.Nil(t, resolved)
91 assert.False(t, called)
92}
93
94func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
95 called := false
96 prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
97 called = true
98 return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
99 })
100
101 configurePushRemoteInferenceTestEnv(t, `
102active = "prod"
103
104[remotes.prod]
105url = "`+prod.URL+`"
106`)
107
108 resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
109 require.NoError(t, err)
110 assert.Nil(t, resolved)
111 assert.False(t, called)
112}
113
114func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) {
115 prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
116 return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
117 })
118
119 configurePushRemoteInferenceTestEnv(t, `
120[remotes.prod]
121url = "`+prod.URL+`"
122`)
123
124 resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
125 require.NoError(t, err)
126 assert.Nil(t, resolved)
127}
128
129func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
130 prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
131 return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
132 })
133 broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
134 return nil, http.StatusInternalServerError
135 })
136
137 configurePushRemoteInferenceTestEnv(t, `
138[remotes.prod]
139url = "`+prod.URL+`"
140
141[remotes.broken]
142url = "`+broken.URL+`"
143`)
144
145 resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
146 require.Error(t, err)
147 assert.Nil(t, resolved)
148 assert.Contains(t, err.Error(), `could not safely infer remote for image "myapp"`)
149 assert.Contains(t, err.Error(), "broken")
150}
151
152func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) {
153 prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
154 return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
155 })
156 staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
157 return nil, http.StatusNotFound
158 })
159
160 configurePushRemoteInferenceTestEnv(t, `
161[remotes.prod]
162url = "`+prod.URL+`"
163
164[remotes.staging]
165url = "`+staging.URL+`"
166`)
167
168 resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
169 require.NoError(t, err)
170 require.NotNil(t, resolved)
171 assert.Equal(t, "prod", resolved.Name)
172}
173
174func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
175 prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
176 return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
177 })
178 staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
179 return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
180 })
181
182 configurePushRemoteInferenceTestEnv(t, `
183[remotes.prod]
184url = "`+prod.URL+`"
185
186[remotes.staging]
187url = "`+staging.URL+`"
188`)
189
190 resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
191 require.Error(t, err)
192 assert.Nil(t, resolved)
193 assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
194}
195
196func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
197 prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
198 return []string{"app.example.com"}, http.StatusOK
199 })
200 staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
201 return nil, http.StatusOK
202 })
203
204 configurePushRemoteInferenceTestEnv(t, `
205[remotes.prod]
206url = "`+prod.URL+`"
207
208[remotes.staging]
209url = "`+staging.URL+`"
210`)
211
212 resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
213 require.NoError(t, err)
214 require.NotNil(t, resolved)
215 assert.Equal(t, "prod", resolved.Name)
216}
217
218func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
219 prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
220 getRoute: func(domainName string) (*domain.Route, int) {
221 return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
222 },
223 getAttachmentsConfig: func(target string) ([]string, int) {
224 return nil, http.StatusNotFound
225 },
226 })
227 staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
228 getRoute: func(domainName string) (*domain.Route, int) {
229 return nil, http.StatusNotFound
230 },
231 getAttachmentsConfig: func(target string) ([]string, int) {
232 return nil, http.StatusNotFound
233 },
234 })
235
236 configurePushRemoteInferenceTestEnv(t, `
237[remotes.prod]
238url = "`+prod.URL+`"
239
240[remotes.staging]
241url = "`+staging.URL+`"
242`)
243
244 resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
245 require.NoError(t, err)
246 require.NotNil(t, resolved)
247 assert.Equal(t, "prod", resolved.Name)
248}
249
250func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
251 prod := newTagsTestServer(t, func(repository string) ([]string, int) {
252 return []string{"latest", "v1.0.0"}, http.StatusOK
253 })
254 staging := newTagsTestServer(t, func(repository string) ([]string, int) {
255 return []string{"latest"}, http.StatusOK
256 })
257
258 configurePushRemoteInferenceTestEnv(t, `
259[remotes.prod]
260url = "`+prod.URL+`"
261
262[remotes.staging]
263url = "`+staging.URL+`"
264`)
265
266 resolved, err := inferRemoteForRepository(context.Background(), "myapp")
267 require.Error(t, err)
268 assert.Nil(t, resolved)
269 assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`)
270}
271
272func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
273 t.Helper()
274
275 originalRemoteFlag := remoteFlag
276 originalTokenFlag := tokenFlag
277 originalInsecureTLSFlag := insecureTLSFlag
278 t.Cleanup(func() {
279 remoteFlag = originalRemoteFlag
280 tokenFlag = originalTokenFlag
281 insecureTLSFlag = originalInsecureTLSFlag
282 })
283
284 remoteFlag = ""
285 tokenFlag = ""
286 insecureTLSFlag = false
287 t.Setenv("GORDON_REMOTE", "")
288 t.Setenv("GORDON_TOKEN", "")
289 t.Setenv("GORDON_INSECURE", "")
290 t.Setenv("HOME", t.TempDir())
291
292 configHome := t.TempDir()
293 t.Setenv("XDG_CONFIG_HOME", configHome)
294 configPath := filepath.Join(configHome, "gordon", "remotes.toml")
295 require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
296 require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
297}
298
299func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {
300 t.Helper()
301
302 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303 if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
304 http.NotFound(w, r)
305 return
306 }
307
308 image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
309 if !assert.NoError(t, err) {
310 return
311 }
312
313 routes, status := handler(image)
314 if status >= http.StatusBadRequest {
315 w.Header().Set("Content-Type", "application/json")
316 w.WriteHeader(status)
317 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
318 return
319 }
320 return
321 }
322
323 w.Header().Set("Content-Type", "application/json")
324 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
325 "image": image,
326 "routes": routes,
327 })) {
328 return
329 }
330 }))
331 t.Cleanup(server.Close)
332 return server
333}
334
335func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
336 t.Helper()
337 return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
338}
339
340type multiAdminProbeHandlers struct {
341 getRoute func(domainName string) (*domain.Route, int)
342 getAttachmentsConfig func(target string) ([]string, int)
343 listTags func(repository string) ([]string, int)
344 findAttachmentTarget func(image string) ([]string, int)
345}
346
347func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
348 t.Helper()
349
350 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
351 switch {
352 case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
353 handleGetRouteProbe(t, w, r, handlers.getRoute)
354 case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
355 handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
356 case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
357 handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
358 case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
359 handleListTagsProbe(t, w, r, handlers.listTags)
360 default:
361 http.NotFound(w, r)
362 }
363 }))
364 t.Cleanup(server.Close)
365 return server
366}
367
368func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
369 t.Helper()
370 return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
371}
372
373func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
374 t.Helper()
375 return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
376}
377
378func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
379 t.Helper()
380 if handler == nil {
381 http.NotFound(w, r)
382 return
383 }
384 domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
385 if !assert.NoError(t, err) {
386 return
387 }
388 route, status := handler(domainName)
389 if status >= http.StatusBadRequest {
390 w.Header().Set("Content-Type", "application/json")
391 w.WriteHeader(status)
392 if status == http.StatusNotFound {
393 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) {
394 return
395 }
396 return
397 }
398 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
399 return
400 }
401 return
402 }
403 w.Header().Set("Content-Type", "application/json")
404 if !assert.NoError(t, json.NewEncoder(w).Encode(route)) {
405 return
406 }
407}
408
409func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
410 t.Helper()
411 if handler == nil {
412 http.NotFound(w, r)
413 return
414 }
415 image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
416 if !assert.NoError(t, err) {
417 return
418 }
419 targets, status := handler(image)
420 if status >= http.StatusBadRequest {
421 w.Header().Set("Content-Type", "application/json")
422 w.WriteHeader(status)
423 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
424 return
425 }
426 return
427 }
428 w.Header().Set("Content-Type", "application/json")
429 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) {
430 return
431 }
432}
433
434func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
435 t.Helper()
436 if handler == nil {
437 http.NotFound(w, r)
438 return
439 }
440 target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
441 if !assert.NoError(t, err) {
442 return
443 }
444 images, status := handler(target)
445 if status >= http.StatusBadRequest {
446 w.Header().Set("Content-Type", "application/json")
447 w.WriteHeader(status)
448 if status == http.StatusNotFound {
449 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) {
450 return
451 }
452 return
453 }
454 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
455 return
456 }
457 return
458 }
459 w.Header().Set("Content-Type", "application/json")
460 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) {
461 return
462 }
463}
464
465func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
466 t.Helper()
467 if handler == nil {
468 http.NotFound(w, r)
469 return
470 }
471 repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/"))
472 if !assert.NoError(t, err) {
473 return
474 }
475 tags, status := handler(repository)
476 if status >= http.StatusBadRequest {
477 w.Header().Set("Content-Type", "application/json")
478 w.WriteHeader(status)
479 if status == http.StatusNotFound {
480 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"})) {
481 return
482 }
483 return
484 }
485 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
486 return
487 }
488 return
489 }
490 w.Header().Set("Content-Type", "application/json")
491 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags})) {
492 return
493 }
494}