1package cli
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/gordon/internal/adapters/dto"
14 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15)
16
17// newTestServer creates a mock server that handles admin token exchange
18// and custom handlers for specific paths.
19func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
20 t.Helper()
21 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22 // Handle admin token exchange for all tests — the client does this automatically.
23 if r.URL.Path == "/auth/token" && r.URL.Query().Get("scope") == "admin:*:*" {
24 w.Header().Set("Content-Type", "application/json")
25 json.NewEncoder(w).Encode(dto.TokenResponse{
26 Token: "ephemeral-admin-token",
27 ExpiresIn: 900,
28 }) //nolint:errcheck
29 return
30 }
31 handler(w, r)
32 }))
33}
34
35// TestExchangeRegistryAuth_Success verifies the happy path:
36// VerifyAuth is called, then ExchangeRegistryToken is called with the
37// subject from VerifyAuth, and the result is "Bearer <short-lived-token>".
38func TestExchangeRegistryAuth_Success(t *testing.T) {
39 const subject = "ci-bot"
40 const shortToken = "short-lived-token-abc123"
41
42 verifyHandled := false
43 tokenHandled := false
44
45 srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
46 switch r.URL.Path {
47 case "/admin/auth/verify":
48 verifyHandled = true
49 w.Header().Set("Content-Type", "application/json")
50 resp := dto.AuthVerifyResponse{
51 Valid: true,
52 Subject: subject,
53 Scopes: []string{"push", "pull"},
54 }
55 json.NewEncoder(w).Encode(resp) //nolint:errcheck
56 case "/auth/token":
57 // Registry token exchange (scope != admin:*:*)
58 tokenHandled = true
59 u, p, ok := r.BasicAuth()
60 assert.True(t, ok, "expected basic auth")
61 assert.Equal(t, subject, u, "expected subject as username")
62 assert.NotEmpty(t, p, "expected token as password")
63
64 w.Header().Set("Content-Type", "application/json")
65 resp := dto.TokenResponse{Token: shortToken}
66 json.NewEncoder(w).Encode(resp) //nolint:errcheck
67 default:
68 http.NotFound(w, r)
69 }
70 })
71 defer srv.Close()
72
73 client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))
74 ops := &dockerImageOps{
75 remoteClient: client,
76 }
77
78 got, err := ops.exchangeRegistryAuth(context.Background())
79
80 require.NoError(t, err)
81 assert.Equal(t, "Bearer "+shortToken, got)
82 assert.True(t, verifyHandled, "expected /admin/auth/verify to be called")
83 assert.True(t, tokenHandled, "expected /auth/token to be called")
84}
85
86// TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,
87// exchangeRegistryAuth returns an error.
88func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
89 srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
90 if r.URL.Path == "/admin/auth/verify" {
91 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
92 return
93 }
94 http.NotFound(w, r)
95 })
96 defer srv.Close()
97
98 client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))
99 ops := &dockerImageOps{
100 remoteClient: client,
101 }
102
103 _, err := ops.exchangeRegistryAuth(context.Background())
104
105 require.Error(t, err)
106 assert.Contains(t, err.Error(), "failed to verify token")
107}
108
109// TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns
110// Valid=false, exchangeRegistryAuth returns an error.
111func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
112 srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
113 if r.URL.Path == "/admin/auth/verify" {
114 w.Header().Set("Content-Type", "application/json")
115 resp := dto.AuthVerifyResponse{Valid: false}
116 json.NewEncoder(w).Encode(resp) //nolint:errcheck
117 return
118 }
119 http.NotFound(w, r)
120 })
121 defer srv.Close()
122
123 client := remote.NewClient(srv.URL, remote.WithToken("expired-token"))
124 ops := &dockerImageOps{
125 remoteClient: client,
126 }
127
128 _, err := ops.exchangeRegistryAuth(context.Background())
129
130 require.Error(t, err)
131 assert.Contains(t, err.Error(), "token is not valid")
132}