refactor(auth): remove dead RegistryAuth middleware and deprecated GetRegistryAuthConfig
- commit
- d7ba6d99778499fedb27664a208e14f8f8bb6d1d
- author
- bnema · · 2026-02-24 21:49 UTC
- parent
- 56a8a7b
- signature
- unsigned
4 files · +115 -416
M internal/adapters/in/http/middleware/auth.go +0 -69
| @@ -19,75 +19,6 @@ import ( | |||
| 19 | 19 | // Using domain key for consistency across all auth flows. | |
| 20 | 20 | const TokenClaimsKey = domain.TokenClaimsKey | |
| 21 | 21 | ||
| 22 | - | // RegistryAuth middleware provides Docker Registry authentication. | |
| 23 | - | func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 24 | - | return func(next http.Handler) http.Handler { | |
| 25 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 26 | - | // Require auth for all registry operations | |
| 27 | - | if !isAuthenticated(r, username, password, log) { | |
| 28 | - | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 29 | - | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 30 | - | w.Header().Set("Content-Type", "application/json") | |
| 31 | - | w.WriteHeader(http.StatusUnauthorized) | |
| 32 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"}) | |
| 33 | - | ||
| 34 | - | log.Warn(). | |
| 35 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 36 | - | Str(zerowrap.FieldAdapter, "http"). | |
| 37 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 38 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 39 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 40 | - | Msg("unauthorized registry access attempt") | |
| 41 | - | return | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | next.ServeHTTP(w, r) | |
| 45 | - | }) | |
| 46 | - | } | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // isAuthenticated checks basic auth credentials. | |
| 50 | - | func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, log zerowrap.Logger) bool { | |
| 51 | - | authHeader := r.Header.Get("Authorization") | |
| 52 | - | log.Debug(). | |
| 53 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 54 | - | Str(zerowrap.FieldAdapter, "http"). | |
| 55 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 56 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 57 | - | Bool("has_auth_header", authHeader != ""). | |
| 58 | - | Msg("processing authentication request") | |
| 59 | - | ||
| 60 | - | username, password, ok := r.BasicAuth() | |
| 61 | - | if !ok { | |
| 62 | - | log.Debug(). | |
| 63 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 64 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 65 | - | Msg("no basic auth provided") | |
| 66 | - | return false | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // Use constant-time comparison to prevent timing attacks | |
| 70 | - | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1 | |
| 71 | - | passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1 | |
| 72 | - | ||
| 73 | - | authenticated := usernameMatch && passwordMatch | |
| 74 | - | if !authenticated { | |
| 75 | - | log.Debug(). | |
| 76 | - | Str("provided_username", redactUsername(username)). | |
| 77 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 78 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 79 | - | Msg("authentication failed") | |
| 80 | - | } else { | |
| 81 | - | log.Debug(). | |
| 82 | - | Str("username", redactUsername(username)). | |
| 83 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 84 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 85 | - | Msg("authentication successful") | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | return authenticated | |
| 89 | - | } | |
| 90 | - | ||
| 91 | 22 | // sanitizeHeaderValue removes characters that could enable header injection. | |
| 92 | 23 | // Only allows alphanumeric, dots, hyphens, colons, and square brackets | |
| 93 | 24 | // (sufficient for host:port and IPv6 addresses). | |
| @@ -19,75 +19,6 @@ import ( | |||||
| 19 | // Using domain key for consistency across all auth flows. | 19 | // Using domain key for consistency across all auth flows. | ||
| 20 | const TokenClaimsKey = domain.TokenClaimsKey | 20 | const TokenClaimsKey = domain.TokenClaimsKey | ||
| 21 | 21 | ||||
| 22 | - | // RegistryAuth middleware provides Docker Registry authentication. | |||
| 23 | - | func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 24 | - | return func(next http.Handler) http.Handler { | |||
| 25 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 26 | - | // Require auth for all registry operations | |||
| 27 | - | if !isAuthenticated(r, username, password, log) { | |||
| 28 | - | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |||
| 29 | - | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |||
| 30 | - | w.Header().Set("Content-Type", "application/json") | |||
| 31 | - | w.WriteHeader(http.StatusUnauthorized) | |||
| 32 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"}) | |||
| 33 | - | ||||
| 34 | - | log.Warn(). | |||
| 35 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 36 | - | Str(zerowrap.FieldAdapter, "http"). | |||
| 37 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 38 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 39 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |||
| 40 | - | Msg("unauthorized registry access attempt") | |||
| 41 | - | return | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | next.ServeHTTP(w, r) | |||
| 45 | - | }) | |||
| 46 | - | } | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | // isAuthenticated checks basic auth credentials. | |||
| 50 | - | func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, log zerowrap.Logger) bool { | |||
| 51 | - | authHeader := r.Header.Get("Authorization") | |||
| 52 | - | log.Debug(). | |||
| 53 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 54 | - | Str(zerowrap.FieldAdapter, "http"). | |||
| 55 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 56 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 57 | - | Bool("has_auth_header", authHeader != ""). | |||
| 58 | - | Msg("processing authentication request") | |||
| 59 | - | ||||
| 60 | - | username, password, ok := r.BasicAuth() | |||
| 61 | - | if !ok { | |||
| 62 | - | log.Debug(). | |||
| 63 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 64 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 65 | - | Msg("no basic auth provided") | |||
| 66 | - | return false | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | // Use constant-time comparison to prevent timing attacks | |||
| 70 | - | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1 | |||
| 71 | - | passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1 | |||
| 72 | - | ||||
| 73 | - | authenticated := usernameMatch && passwordMatch | |||
| 74 | - | if !authenticated { | |||
| 75 | - | log.Debug(). | |||
| 76 | - | Str("provided_username", redactUsername(username)). | |||
| 77 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 78 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 79 | - | Msg("authentication failed") | |||
| 80 | - | } else { | |||
| 81 | - | log.Debug(). | |||
| 82 | - | Str("username", redactUsername(username)). | |||
| 83 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 84 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 85 | - | Msg("authentication successful") | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | return authenticated | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | // sanitizeHeaderValue removes characters that could enable header injection. | 22 | // sanitizeHeaderValue removes characters that could enable header injection. | ||
| 92 | // Only allows alphanumeric, dots, hyphens, colons, and square brackets | 23 | // Only allows alphanumeric, dots, hyphens, colons, and square brackets | ||
| 93 | // (sufficient for host:port and IPv6 addresses). | 24 | // (sufficient for host:port and IPv6 addresses). | ||
M internal/adapters/in/http/middleware/auth_test.go +0 -335
| @@ -2,7 +2,6 @@ package middleware | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "encoding/base64" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "net/http" | |
| 8 | 7 | "net/http/httptest" | |
| @@ -19,340 +18,6 @@ func testLogger() zerowrap.Logger { | |||
| 19 | 18 | return zerowrap.Default() | |
| 20 | 19 | } | |
| 21 | 20 | ||
| 22 | - | func TestRegistryAuth_ValidCredentials(t *testing.T) { | |
| 23 | - | log := testLogger() | |
| 24 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 25 | - | ||
| 26 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 27 | - | w.WriteHeader(http.StatusOK) | |
| 28 | - | w.Write([]byte("authenticated")) | |
| 29 | - | })) | |
| 30 | - | ||
| 31 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 32 | - | req.SetBasicAuth("admin", "secret") | |
| 33 | - | rec := httptest.NewRecorder() | |
| 34 | - | ||
| 35 | - | handler.ServeHTTP(rec, req) | |
| 36 | - | ||
| 37 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 38 | - | assert.Equal(t, "authenticated", rec.Body.String()) | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | func TestRegistryAuth_InvalidUsername(t *testing.T) { | |
| 42 | - | log := testLogger() | |
| 43 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 44 | - | ||
| 45 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 46 | - | w.WriteHeader(http.StatusOK) | |
| 47 | - | })) | |
| 48 | - | ||
| 49 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 50 | - | req.SetBasicAuth("wronguser", "secret") | |
| 51 | - | rec := httptest.NewRecorder() | |
| 52 | - | ||
| 53 | - | handler.ServeHTTP(rec, req) | |
| 54 | - | ||
| 55 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 56 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |
| 57 | - | assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version")) | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func TestRegistryAuth_InvalidPassword(t *testing.T) { | |
| 61 | - | log := testLogger() | |
| 62 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 63 | - | ||
| 64 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 65 | - | w.WriteHeader(http.StatusOK) | |
| 66 | - | })) | |
| 67 | - | ||
| 68 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 69 | - | req.SetBasicAuth("admin", "wrongpassword") | |
| 70 | - | rec := httptest.NewRecorder() | |
| 71 | - | ||
| 72 | - | handler.ServeHTTP(rec, req) | |
| 73 | - | ||
| 74 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | func TestRegistryAuth_NoCredentials(t *testing.T) { | |
| 78 | - | log := testLogger() | |
| 79 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 80 | - | ||
| 81 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 82 | - | w.WriteHeader(http.StatusOK) | |
| 83 | - | })) | |
| 84 | - | ||
| 85 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 86 | - | // No auth header | |
| 87 | - | rec := httptest.NewRecorder() | |
| 88 | - | ||
| 89 | - | handler.ServeHTTP(rec, req) | |
| 90 | - | ||
| 91 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 92 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | func TestRegistryAuth_MalformedAuthHeader(t *testing.T) { | |
| 96 | - | log := testLogger() | |
| 97 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 98 | - | ||
| 99 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 100 | - | w.WriteHeader(http.StatusOK) | |
| 101 | - | })) | |
| 102 | - | ||
| 103 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 104 | - | req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64 | |
| 105 | - | rec := httptest.NewRecorder() | |
| 106 | - | ||
| 107 | - | handler.ServeHTTP(rec, req) | |
| 108 | - | ||
| 109 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | func TestRegistryAuth_WrongAuthScheme(t *testing.T) { | |
| 113 | - | log := testLogger() | |
| 114 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 115 | - | ||
| 116 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 117 | - | w.WriteHeader(http.StatusOK) | |
| 118 | - | })) | |
| 119 | - | ||
| 120 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 121 | - | req.Header.Set("Authorization", "Bearer some-token") | |
| 122 | - | rec := httptest.NewRecorder() | |
| 123 | - | ||
| 124 | - | handler.ServeHTTP(rec, req) | |
| 125 | - | ||
| 126 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | func TestRegistryAuth_EmptyCredentials(t *testing.T) { | |
| 130 | - | log := testLogger() | |
| 131 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 132 | - | ||
| 133 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 134 | - | w.WriteHeader(http.StatusOK) | |
| 135 | - | })) | |
| 136 | - | ||
| 137 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 138 | - | req.SetBasicAuth("", "") | |
| 139 | - | rec := httptest.NewRecorder() | |
| 140 | - | ||
| 141 | - | handler.ServeHTTP(rec, req) | |
| 142 | - | ||
| 143 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | func TestRegistryAuth_PartialCredentials(t *testing.T) { | |
| 147 | - | log := testLogger() | |
| 148 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 149 | - | ||
| 150 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 151 | - | w.WriteHeader(http.StatusOK) | |
| 152 | - | })) | |
| 153 | - | ||
| 154 | - | // Only username, empty password | |
| 155 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 156 | - | req.SetBasicAuth("admin", "") | |
| 157 | - | rec := httptest.NewRecorder() | |
| 158 | - | ||
| 159 | - | handler.ServeHTTP(rec, req) | |
| 160 | - | ||
| 161 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | func TestIsAuthenticated_ValidCredentials(t *testing.T) { | |
| 165 | - | log := testLogger() | |
| 166 | - | ||
| 167 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 168 | - | req.SetBasicAuth("admin", "secret") | |
| 169 | - | ||
| 170 | - | result := isAuthenticated(req, "admin", "secret", log) | |
| 171 | - | ||
| 172 | - | assert.True(t, result) | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | func TestIsAuthenticated_InvalidCredentials(t *testing.T) { | |
| 176 | - | log := testLogger() | |
| 177 | - | ||
| 178 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 179 | - | req.SetBasicAuth("admin", "wrong") | |
| 180 | - | ||
| 181 | - | result := isAuthenticated(req, "admin", "secret", log) | |
| 182 | - | ||
| 183 | - | assert.False(t, result) | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | func TestIsAuthenticated_NoAuthHeader(t *testing.T) { | |
| 187 | - | log := testLogger() | |
| 188 | - | ||
| 189 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 190 | - | ||
| 191 | - | result := isAuthenticated(req, "admin", "secret", log) | |
| 192 | - | ||
| 193 | - | assert.False(t, result) | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) { | |
| 197 | - | // This test verifies that constant-time comparison is used | |
| 198 | - | // by checking both username and password are validated | |
| 199 | - | log := testLogger() | |
| 200 | - | ||
| 201 | - | tests := []struct { | |
| 202 | - | name string | |
| 203 | - | username string | |
| 204 | - | password string | |
| 205 | - | expectedUser string | |
| 206 | - | expectedPass string | |
| 207 | - | shouldAuth bool | |
| 208 | - | }{ | |
| 209 | - | { | |
| 210 | - | name: "both correct", | |
| 211 | - | username: "admin", | |
| 212 | - | password: "secret", | |
| 213 | - | expectedUser: "admin", | |
| 214 | - | expectedPass: "secret", | |
| 215 | - | shouldAuth: true, | |
| 216 | - | }, | |
| 217 | - | { | |
| 218 | - | name: "username wrong first char", | |
| 219 | - | username: "bdmin", | |
| 220 | - | password: "secret", | |
| 221 | - | expectedUser: "admin", | |
| 222 | - | expectedPass: "secret", | |
| 223 | - | shouldAuth: false, | |
| 224 | - | }, | |
| 225 | - | { | |
| 226 | - | name: "username wrong last char", | |
| 227 | - | username: "admio", | |
| 228 | - | password: "secret", | |
| 229 | - | expectedUser: "admin", | |
| 230 | - | expectedPass: "secret", | |
| 231 | - | shouldAuth: false, | |
| 232 | - | }, | |
| 233 | - | { | |
| 234 | - | name: "password wrong first char", | |
| 235 | - | username: "admin", | |
| 236 | - | password: "aecret", | |
| 237 | - | expectedUser: "admin", | |
| 238 | - | expectedPass: "secret", | |
| 239 | - | shouldAuth: false, | |
| 240 | - | }, | |
| 241 | - | { | |
| 242 | - | name: "password wrong last char", | |
| 243 | - | username: "admin", | |
| 244 | - | password: "secreo", | |
| 245 | - | expectedUser: "admin", | |
| 246 | - | expectedPass: "secret", | |
| 247 | - | shouldAuth: false, | |
| 248 | - | }, | |
| 249 | - | { | |
| 250 | - | name: "different length username", | |
| 251 | - | username: "adm", | |
| 252 | - | password: "secret", | |
| 253 | - | expectedUser: "admin", | |
| 254 | - | expectedPass: "secret", | |
| 255 | - | shouldAuth: false, | |
| 256 | - | }, | |
| 257 | - | { | |
| 258 | - | name: "different length password", | |
| 259 | - | username: "admin", | |
| 260 | - | password: "sec", | |
| 261 | - | expectedUser: "admin", | |
| 262 | - | expectedPass: "secret", | |
| 263 | - | shouldAuth: false, | |
| 264 | - | }, | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | for _, tt := range tests { | |
| 268 | - | t.Run(tt.name, func(t *testing.T) { | |
| 269 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 270 | - | req.SetBasicAuth(tt.username, tt.password) | |
| 271 | - | ||
| 272 | - | result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log) | |
| 273 | - | ||
| 274 | - | assert.Equal(t, tt.shouldAuth, result) | |
| 275 | - | }) | |
| 276 | - | } | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) { | |
| 280 | - | log := testLogger() | |
| 281 | - | ||
| 282 | - | tests := []struct { | |
| 283 | - | name string | |
| 284 | - | username string | |
| 285 | - | password string | |
| 286 | - | }{ | |
| 287 | - | {"unicode username", "用户", "password"}, | |
| 288 | - | {"unicode password", "admin", "密码"}, | |
| 289 | - | {"special chars", "admin@example.com", "p@ss:w0rd!"}, | |
| 290 | - | {"spaces", "admin user", "pass word"}, | |
| 291 | - | {"colon in password", "admin", "pass:word"}, | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | for _, tt := range tests { | |
| 295 | - | t.Run(tt.name, func(t *testing.T) { | |
| 296 | - | middleware := RegistryAuth(tt.username, tt.password, log) | |
| 297 | - | ||
| 298 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 299 | - | w.WriteHeader(http.StatusOK) | |
| 300 | - | })) | |
| 301 | - | ||
| 302 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 303 | - | req.SetBasicAuth(tt.username, tt.password) | |
| 304 | - | rec := httptest.NewRecorder() | |
| 305 | - | ||
| 306 | - | handler.ServeHTTP(rec, req) | |
| 307 | - | ||
| 308 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 309 | - | }) | |
| 310 | - | } | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | func TestRegistryAuth_Base64EdgeCases(t *testing.T) { | |
| 314 | - | log := testLogger() | |
| 315 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 316 | - | ||
| 317 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 318 | - | w.WriteHeader(http.StatusOK) | |
| 319 | - | })) | |
| 320 | - | ||
| 321 | - | tests := []struct { | |
| 322 | - | name string | |
| 323 | - | authHeader string | |
| 324 | - | wantStatus int | |
| 325 | - | }{ | |
| 326 | - | { | |
| 327 | - | name: "empty basic auth", | |
| 328 | - | authHeader: "Basic ", | |
| 329 | - | wantStatus: http.StatusUnauthorized, | |
| 330 | - | }, | |
| 331 | - | { | |
| 332 | - | name: "basic auth with only colon", | |
| 333 | - | authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")), | |
| 334 | - | wantStatus: http.StatusUnauthorized, | |
| 335 | - | }, | |
| 336 | - | { | |
| 337 | - | name: "missing colon in decoded value", | |
| 338 | - | authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")), | |
| 339 | - | wantStatus: http.StatusUnauthorized, | |
| 340 | - | }, | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | for _, tt := range tests { | |
| 344 | - | t.Run(tt.name, func(t *testing.T) { | |
| 345 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 346 | - | req.Header.Set("Authorization", tt.authHeader) | |
| 347 | - | rec := httptest.NewRecorder() | |
| 348 | - | ||
| 349 | - | handler.ServeHTTP(rec, req) | |
| 350 | - | ||
| 351 | - | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 352 | - | }) | |
| 353 | - | } | |
| 354 | - | } | |
| 355 | - | ||
| 356 | 21 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | |
| 357 | 22 | log := testLogger() | |
| 358 | 23 | called := false | |
| @@ -2,7 +2,6 @@ package middleware | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "encoding/base64" | |||
| 6 | "errors" | 5 | "errors" | ||
| 7 | "net/http" | 6 | "net/http" | ||
| 8 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| @@ -19,340 +18,6 @@ func testLogger() zerowrap.Logger { | |||||
| 19 | return zerowrap.Default() | 18 | return zerowrap.Default() | ||
| 20 | } | 19 | } | ||
| 21 | 20 | ||||
| 22 | - | func TestRegistryAuth_ValidCredentials(t *testing.T) { | |||
| 23 | - | log := testLogger() | |||
| 24 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 25 | - | ||||
| 26 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 27 | - | w.WriteHeader(http.StatusOK) | |||
| 28 | - | w.Write([]byte("authenticated")) | |||
| 29 | - | })) | |||
| 30 | - | ||||
| 31 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 32 | - | req.SetBasicAuth("admin", "secret") | |||
| 33 | - | rec := httptest.NewRecorder() | |||
| 34 | - | ||||
| 35 | - | handler.ServeHTTP(rec, req) | |||
| 36 | - | ||||
| 37 | - | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 38 | - | assert.Equal(t, "authenticated", rec.Body.String()) | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | func TestRegistryAuth_InvalidUsername(t *testing.T) { | |||
| 42 | - | log := testLogger() | |||
| 43 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 44 | - | ||||
| 45 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 46 | - | w.WriteHeader(http.StatusOK) | |||
| 47 | - | })) | |||
| 48 | - | ||||
| 49 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 50 | - | req.SetBasicAuth("wronguser", "secret") | |||
| 51 | - | rec := httptest.NewRecorder() | |||
| 52 | - | ||||
| 53 | - | handler.ServeHTTP(rec, req) | |||
| 54 | - | ||||
| 55 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 56 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |||
| 57 | - | assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version")) | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | func TestRegistryAuth_InvalidPassword(t *testing.T) { | |||
| 61 | - | log := testLogger() | |||
| 62 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 63 | - | ||||
| 64 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 65 | - | w.WriteHeader(http.StatusOK) | |||
| 66 | - | })) | |||
| 67 | - | ||||
| 68 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 69 | - | req.SetBasicAuth("admin", "wrongpassword") | |||
| 70 | - | rec := httptest.NewRecorder() | |||
| 71 | - | ||||
| 72 | - | handler.ServeHTTP(rec, req) | |||
| 73 | - | ||||
| 74 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | func TestRegistryAuth_NoCredentials(t *testing.T) { | |||
| 78 | - | log := testLogger() | |||
| 79 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 80 | - | ||||
| 81 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 82 | - | w.WriteHeader(http.StatusOK) | |||
| 83 | - | })) | |||
| 84 | - | ||||
| 85 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 86 | - | // No auth header | |||
| 87 | - | rec := httptest.NewRecorder() | |||
| 88 | - | ||||
| 89 | - | handler.ServeHTTP(rec, req) | |||
| 90 | - | ||||
| 91 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 92 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | func TestRegistryAuth_MalformedAuthHeader(t *testing.T) { | |||
| 96 | - | log := testLogger() | |||
| 97 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 98 | - | ||||
| 99 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 100 | - | w.WriteHeader(http.StatusOK) | |||
| 101 | - | })) | |||
| 102 | - | ||||
| 103 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 104 | - | req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64 | |||
| 105 | - | rec := httptest.NewRecorder() | |||
| 106 | - | ||||
| 107 | - | handler.ServeHTTP(rec, req) | |||
| 108 | - | ||||
| 109 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | func TestRegistryAuth_WrongAuthScheme(t *testing.T) { | |||
| 113 | - | log := testLogger() | |||
| 114 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 115 | - | ||||
| 116 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 117 | - | w.WriteHeader(http.StatusOK) | |||
| 118 | - | })) | |||
| 119 | - | ||||
| 120 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 121 | - | req.Header.Set("Authorization", "Bearer some-token") | |||
| 122 | - | rec := httptest.NewRecorder() | |||
| 123 | - | ||||
| 124 | - | handler.ServeHTTP(rec, req) | |||
| 125 | - | ||||
| 126 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | func TestRegistryAuth_EmptyCredentials(t *testing.T) { | |||
| 130 | - | log := testLogger() | |||
| 131 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 132 | - | ||||
| 133 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 134 | - | w.WriteHeader(http.StatusOK) | |||
| 135 | - | })) | |||
| 136 | - | ||||
| 137 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 138 | - | req.SetBasicAuth("", "") | |||
| 139 | - | rec := httptest.NewRecorder() | |||
| 140 | - | ||||
| 141 | - | handler.ServeHTTP(rec, req) | |||
| 142 | - | ||||
| 143 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | func TestRegistryAuth_PartialCredentials(t *testing.T) { | |||
| 147 | - | log := testLogger() | |||
| 148 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 149 | - | ||||
| 150 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 151 | - | w.WriteHeader(http.StatusOK) | |||
| 152 | - | })) | |||
| 153 | - | ||||
| 154 | - | // Only username, empty password | |||
| 155 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 156 | - | req.SetBasicAuth("admin", "") | |||
| 157 | - | rec := httptest.NewRecorder() | |||
| 158 | - | ||||
| 159 | - | handler.ServeHTTP(rec, req) | |||
| 160 | - | ||||
| 161 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | func TestIsAuthenticated_ValidCredentials(t *testing.T) { | |||
| 165 | - | log := testLogger() | |||
| 166 | - | ||||
| 167 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 168 | - | req.SetBasicAuth("admin", "secret") | |||
| 169 | - | ||||
| 170 | - | result := isAuthenticated(req, "admin", "secret", log) | |||
| 171 | - | ||||
| 172 | - | assert.True(t, result) | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | func TestIsAuthenticated_InvalidCredentials(t *testing.T) { | |||
| 176 | - | log := testLogger() | |||
| 177 | - | ||||
| 178 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 179 | - | req.SetBasicAuth("admin", "wrong") | |||
| 180 | - | ||||
| 181 | - | result := isAuthenticated(req, "admin", "secret", log) | |||
| 182 | - | ||||
| 183 | - | assert.False(t, result) | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | func TestIsAuthenticated_NoAuthHeader(t *testing.T) { | |||
| 187 | - | log := testLogger() | |||
| 188 | - | ||||
| 189 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 190 | - | ||||
| 191 | - | result := isAuthenticated(req, "admin", "secret", log) | |||
| 192 | - | ||||
| 193 | - | assert.False(t, result) | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) { | |||
| 197 | - | // This test verifies that constant-time comparison is used | |||
| 198 | - | // by checking both username and password are validated | |||
| 199 | - | log := testLogger() | |||
| 200 | - | ||||
| 201 | - | tests := []struct { | |||
| 202 | - | name string | |||
| 203 | - | username string | |||
| 204 | - | password string | |||
| 205 | - | expectedUser string | |||
| 206 | - | expectedPass string | |||
| 207 | - | shouldAuth bool | |||
| 208 | - | }{ | |||
| 209 | - | { | |||
| 210 | - | name: "both correct", | |||
| 211 | - | username: "admin", | |||
| 212 | - | password: "secret", | |||
| 213 | - | expectedUser: "admin", | |||
| 214 | - | expectedPass: "secret", | |||
| 215 | - | shouldAuth: true, | |||
| 216 | - | }, | |||
| 217 | - | { | |||
| 218 | - | name: "username wrong first char", | |||
| 219 | - | username: "bdmin", | |||
| 220 | - | password: "secret", | |||
| 221 | - | expectedUser: "admin", | |||
| 222 | - | expectedPass: "secret", | |||
| 223 | - | shouldAuth: false, | |||
| 224 | - | }, | |||
| 225 | - | { | |||
| 226 | - | name: "username wrong last char", | |||
| 227 | - | username: "admio", | |||
| 228 | - | password: "secret", | |||
| 229 | - | expectedUser: "admin", | |||
| 230 | - | expectedPass: "secret", | |||
| 231 | - | shouldAuth: false, | |||
| 232 | - | }, | |||
| 233 | - | { | |||
| 234 | - | name: "password wrong first char", | |||
| 235 | - | username: "admin", | |||
| 236 | - | password: "aecret", | |||
| 237 | - | expectedUser: "admin", | |||
| 238 | - | expectedPass: "secret", | |||
| 239 | - | shouldAuth: false, | |||
| 240 | - | }, | |||
| 241 | - | { | |||
| 242 | - | name: "password wrong last char", | |||
| 243 | - | username: "admin", | |||
| 244 | - | password: "secreo", | |||
| 245 | - | expectedUser: "admin", | |||
| 246 | - | expectedPass: "secret", | |||
| 247 | - | shouldAuth: false, | |||
| 248 | - | }, | |||
| 249 | - | { | |||
| 250 | - | name: "different length username", | |||
| 251 | - | username: "adm", | |||
| 252 | - | password: "secret", | |||
| 253 | - | expectedUser: "admin", | |||
| 254 | - | expectedPass: "secret", | |||
| 255 | - | shouldAuth: false, | |||
| 256 | - | }, | |||
| 257 | - | { | |||
| 258 | - | name: "different length password", | |||
| 259 | - | username: "admin", | |||
| 260 | - | password: "sec", | |||
| 261 | - | expectedUser: "admin", | |||
| 262 | - | expectedPass: "secret", | |||
| 263 | - | shouldAuth: false, | |||
| 264 | - | }, | |||
| 265 | - | } | |||
| 266 | - | ||||
| 267 | - | for _, tt := range tests { | |||
| 268 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 269 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 270 | - | req.SetBasicAuth(tt.username, tt.password) | |||
| 271 | - | ||||
| 272 | - | result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log) | |||
| 273 | - | ||||
| 274 | - | assert.Equal(t, tt.shouldAuth, result) | |||
| 275 | - | }) | |||
| 276 | - | } | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) { | |||
| 280 | - | log := testLogger() | |||
| 281 | - | ||||
| 282 | - | tests := []struct { | |||
| 283 | - | name string | |||
| 284 | - | username string | |||
| 285 | - | password string | |||
| 286 | - | }{ | |||
| 287 | - | {"unicode username", "用户", "password"}, | |||
| 288 | - | {"unicode password", "admin", "密码"}, | |||
| 289 | - | {"special chars", "admin@example.com", "p@ss:w0rd!"}, | |||
| 290 | - | {"spaces", "admin user", "pass word"}, | |||
| 291 | - | {"colon in password", "admin", "pass:word"}, | |||
| 292 | - | } | |||
| 293 | - | ||||
| 294 | - | for _, tt := range tests { | |||
| 295 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 296 | - | middleware := RegistryAuth(tt.username, tt.password, log) | |||
| 297 | - | ||||
| 298 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 299 | - | w.WriteHeader(http.StatusOK) | |||
| 300 | - | })) | |||
| 301 | - | ||||
| 302 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 303 | - | req.SetBasicAuth(tt.username, tt.password) | |||
| 304 | - | rec := httptest.NewRecorder() | |||
| 305 | - | ||||
| 306 | - | handler.ServeHTTP(rec, req) | |||
| 307 | - | ||||
| 308 | - | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 309 | - | }) | |||
| 310 | - | } | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | func TestRegistryAuth_Base64EdgeCases(t *testing.T) { | |||
| 314 | - | log := testLogger() | |||
| 315 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 316 | - | ||||
| 317 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 318 | - | w.WriteHeader(http.StatusOK) | |||
| 319 | - | })) | |||
| 320 | - | ||||
| 321 | - | tests := []struct { | |||
| 322 | - | name string | |||
| 323 | - | authHeader string | |||
| 324 | - | wantStatus int | |||
| 325 | - | }{ | |||
| 326 | - | { | |||
| 327 | - | name: "empty basic auth", | |||
| 328 | - | authHeader: "Basic ", | |||
| 329 | - | wantStatus: http.StatusUnauthorized, | |||
| 330 | - | }, | |||
| 331 | - | { | |||
| 332 | - | name: "basic auth with only colon", | |||
| 333 | - | authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")), | |||
| 334 | - | wantStatus: http.StatusUnauthorized, | |||
| 335 | - | }, | |||
| 336 | - | { | |||
| 337 | - | name: "missing colon in decoded value", | |||
| 338 | - | authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")), | |||
| 339 | - | wantStatus: http.StatusUnauthorized, | |||
| 340 | - | }, | |||
| 341 | - | } | |||
| 342 | - | ||||
| 343 | - | for _, tt := range tests { | |||
| 344 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 345 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 346 | - | req.Header.Set("Authorization", tt.authHeader) | |||
| 347 | - | rec := httptest.NewRecorder() | |||
| 348 | - | ||||
| 349 | - | handler.ServeHTTP(rec, req) | |||
| 350 | - | ||||
| 351 | - | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 352 | - | }) | |||
| 353 | - | } | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | 21 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | ||
| 357 | log := testLogger() | 22 | log := testLogger() | ||
| 358 | called := false | 23 | called := false | ||
M internal/usecase/config/service.go +0 -7
| @@ -534,13 +534,6 @@ func (s *Service) GetConfig() Config { | |||
| 534 | 534 | return s.config | |
| 535 | 535 | } | |
| 536 | 536 | ||
| 537 | - | // GetRegistryAuthConfig returns registry authentication configuration. | |
| 538 | - | func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) { | |
| 539 | - | s.mu.RLock() | |
| 540 | - | defer s.mu.RUnlock() | |
| 541 | - | return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword | |
| 542 | - | } | |
| 543 | - | ||
| 544 | 537 | // GetVolumeConfig returns volume configuration. | |
| 545 | 538 | func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) { | |
| 546 | 539 | s.mu.RLock() | |
| @@ -534,13 +534,6 @@ func (s *Service) GetConfig() Config { | |||||
| 534 | return s.config | 534 | return s.config | ||
| 535 | } | 535 | } | ||
| 536 | 536 | ||||
| 537 | - | // GetRegistryAuthConfig returns registry authentication configuration. | |||
| 538 | - | func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) { | |||
| 539 | - | s.mu.RLock() | |||
| 540 | - | defer s.mu.RUnlock() | |||
| 541 | - | return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword | |||
| 542 | - | } | |||
| 543 | - | ||||
| 544 | // GetVolumeConfig returns volume configuration. | 537 | // GetVolumeConfig returns volume configuration. | ||
| 545 | func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) { | 538 | func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) { | ||
| 546 | s.mu.RLock() | 539 | s.mu.RLock() | ||
M internal/usecase/config/service_test.go +115 -5
| @@ -53,11 +53,6 @@ func TestService_Load(t *testing.T) { | |||
| 53 | 53 | assert.True(t, svc.IsNetworkIsolationEnabled()) | |
| 54 | 54 | assert.Equal(t, "gordon", svc.GetNetworkPrefix()) | |
| 55 | 55 | ||
| 56 | - | enabled, username, password := svc.GetRegistryAuthConfig() | |
| 57 | - | assert.True(t, enabled) | |
| 58 | - | assert.Equal(t, "admin", username) | |
| 59 | - | assert.Equal(t, "secret", password) | |
| 60 | - | ||
| 61 | 56 | autoCreate, prefix, preserve := svc.GetVolumeConfig() | |
| 62 | 57 | assert.True(t, autoCreate) | |
| 63 | 58 | assert.Equal(t, "gordon", prefix) | |
| @@ -1091,6 +1086,121 @@ func TestService_FindRoutesByImage(t *testing.T) { | |||
| 1091 | 1086 | }) | |
| 1092 | 1087 | } | |
| 1093 | 1088 | ||
| 1089 | + | func TestSavePreservesAllConfigFields(t *testing.T) { | |
| 1090 | + | t.Run("network_groups are persisted to disk", func(t *testing.T) { | |
| 1091 | + | // Setup: config file with auth, server, and network_groups | |
| 1092 | + | tmpDir := t.TempDir() | |
| 1093 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 1094 | + | initialConfig := ` | |
| 1095 | + | [server] | |
| 1096 | + | port = 9999 | |
| 1097 | + | ||
| 1098 | + | [auth] | |
| 1099 | + | username = "admin" | |
| 1100 | + | token_secret = "supersecretvalue" | |
| 1101 | + | ||
| 1102 | + | [routes] | |
| 1103 | + | "app.example.com" = "myapp:latest" | |
| 1104 | + | ||
| 1105 | + | [network_groups] | |
| 1106 | + | frontend = ["app1.example.com", "app2.example.com"] | |
| 1107 | + | ` | |
| 1108 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 1109 | + | require.NoError(t, err) | |
| 1110 | + | ||
| 1111 | + | v := viper.New() | |
| 1112 | + | v.SetConfigFile(configFile) | |
| 1113 | + | err = v.ReadInConfig() | |
| 1114 | + | require.NoError(t, err) | |
| 1115 | + | ||
| 1116 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1117 | + | svc := NewService(v, eventBus) | |
| 1118 | + | ctx := testContext() | |
| 1119 | + | ||
| 1120 | + | err = svc.Load(ctx) | |
| 1121 | + | require.NoError(t, err) | |
| 1122 | + | ||
| 1123 | + | // Verify network_groups loaded correctly in memory | |
| 1124 | + | groups := svc.GetNetworkGroups() | |
| 1125 | + | require.Len(t, groups, 1) | |
| 1126 | + | require.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"]) | |
| 1127 | + | ||
| 1128 | + | // Trigger a Save via AddRoute (which calls Save internally) | |
| 1129 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |
| 1130 | + | require.NoError(t, err) | |
| 1131 | + | ||
| 1132 | + | // Re-read the file to verify network_groups was persisted | |
| 1133 | + | v2 := viper.New() | |
| 1134 | + | v2.SetConfigFile(configFile) | |
| 1135 | + | err = v2.ReadInConfig() | |
| 1136 | + | require.NoError(t, err) | |
| 1137 | + | ||
| 1138 | + | // Verify network_groups is still in the file after save | |
| 1139 | + | savedGroups := loadStringArrayMap(v2.Get("network_groups")) | |
| 1140 | + | assert.Len(t, savedGroups, 1, "network_groups should still be present in saved config") | |
| 1141 | + | assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, savedGroups["frontend"]) | |
| 1142 | + | ||
| 1143 | + | // Verify auth fields are preserved | |
| 1144 | + | assert.Equal(t, "admin", v2.GetString("auth.username")) | |
| 1145 | + | assert.Equal(t, "supersecretvalue", v2.GetString("auth.token_secret")) | |
| 1146 | + | ||
| 1147 | + | // Verify server fields are preserved | |
| 1148 | + | assert.Equal(t, 9999, v2.GetInt("server.port")) | |
| 1149 | + | }) | |
| 1150 | + | ||
| 1151 | + | t.Run("network_groups added in memory are written to disk on Save", func(t *testing.T) { | |
| 1152 | + | // Setup: config file WITHOUT network_groups | |
| 1153 | + | tmpDir := t.TempDir() | |
| 1154 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 1155 | + | initialConfig := ` | |
| 1156 | + | [server] | |
| 1157 | + | port = 8080 | |
| 1158 | + | ||
| 1159 | + | [routes] | |
| 1160 | + | "app.example.com" = "myapp:latest" | |
| 1161 | + | ` | |
| 1162 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 1163 | + | require.NoError(t, err) | |
| 1164 | + | ||
| 1165 | + | v := viper.New() | |
| 1166 | + | v.SetConfigFile(configFile) | |
| 1167 | + | err = v.ReadInConfig() | |
| 1168 | + | require.NoError(t, err) | |
| 1169 | + | ||
| 1170 | + | // Inject network_groups into viper as if they were loaded from some other source | |
| 1171 | + | // This simulates a case where network_groups exist in memory but not on disk | |
| 1172 | + | v.Set("network_groups", map[string]interface{}{ | |
| 1173 | + | "backend": []interface{}{"api.example.com"}, | |
| 1174 | + | }) | |
| 1175 | + | ||
| 1176 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1177 | + | svc := NewService(v, eventBus) | |
| 1178 | + | ctx := testContext() | |
| 1179 | + | ||
| 1180 | + | err = svc.Load(ctx) | |
| 1181 | + | require.NoError(t, err) | |
| 1182 | + | ||
| 1183 | + | // Verify network_groups is loaded in memory | |
| 1184 | + | groups := svc.GetNetworkGroups() | |
| 1185 | + | require.Len(t, groups, 1) | |
| 1186 | + | ||
| 1187 | + | // Trigger Save | |
| 1188 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |
| 1189 | + | require.NoError(t, err) | |
| 1190 | + | ||
| 1191 | + | // Re-read the file | |
| 1192 | + | v2 := viper.New() | |
| 1193 | + | v2.SetConfigFile(configFile) | |
| 1194 | + | err = v2.ReadInConfig() | |
| 1195 | + | require.NoError(t, err) | |
| 1196 | + | ||
| 1197 | + | // network_groups must be present in the saved file | |
| 1198 | + | savedGroups := loadStringArrayMap(v2.Get("network_groups")) | |
| 1199 | + | assert.Len(t, savedGroups, 1, "network_groups added in memory must be written on Save") | |
| 1200 | + | assert.ElementsMatch(t, []string{"api.example.com"}, savedGroups["backend"]) | |
| 1201 | + | }) | |
| 1202 | + | } | |
| 1203 | + | ||
| 1094 | 1204 | func TestExtractDomainFromImageName(t *testing.T) { | |
| 1095 | 1205 | tests := []struct { | |
| 1096 | 1206 | name string | |
| @@ -53,11 +53,6 @@ func TestService_Load(t *testing.T) { | |||||
| 53 | assert.True(t, svc.IsNetworkIsolationEnabled()) | 53 | assert.True(t, svc.IsNetworkIsolationEnabled()) | ||
| 54 | assert.Equal(t, "gordon", svc.GetNetworkPrefix()) | 54 | assert.Equal(t, "gordon", svc.GetNetworkPrefix()) | ||
| 55 | 55 | ||||
| 56 | - | enabled, username, password := svc.GetRegistryAuthConfig() | |||
| 57 | - | assert.True(t, enabled) | |||
| 58 | - | assert.Equal(t, "admin", username) | |||
| 59 | - | assert.Equal(t, "secret", password) | |||
| 60 | - | ||||
| 61 | autoCreate, prefix, preserve := svc.GetVolumeConfig() | 56 | autoCreate, prefix, preserve := svc.GetVolumeConfig() | ||
| 62 | assert.True(t, autoCreate) | 57 | assert.True(t, autoCreate) | ||
| 63 | assert.Equal(t, "gordon", prefix) | 58 | assert.Equal(t, "gordon", prefix) | ||
| @@ -1091,6 +1086,121 @@ func TestService_FindRoutesByImage(t *testing.T) { | |||||
| 1091 | }) | 1086 | }) | ||
| 1092 | } | 1087 | } | ||
| 1093 | 1088 | ||||
| 1089 | + | func TestSavePreservesAllConfigFields(t *testing.T) { | |||
| 1090 | + | t.Run("network_groups are persisted to disk", func(t *testing.T) { | |||
| 1091 | + | // Setup: config file with auth, server, and network_groups | |||
| 1092 | + | tmpDir := t.TempDir() | |||
| 1093 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 1094 | + | initialConfig := ` | |||
| 1095 | + | [server] | |||
| 1096 | + | port = 9999 | |||
| 1097 | + | ||||
| 1098 | + | [auth] | |||
| 1099 | + | username = "admin" | |||
| 1100 | + | token_secret = "supersecretvalue" | |||
| 1101 | + | ||||
| 1102 | + | [routes] | |||
| 1103 | + | "app.example.com" = "myapp:latest" | |||
| 1104 | + | ||||
| 1105 | + | [network_groups] | |||
| 1106 | + | frontend = ["app1.example.com", "app2.example.com"] | |||
| 1107 | + | ` | |||
| 1108 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 1109 | + | require.NoError(t, err) | |||
| 1110 | + | ||||
| 1111 | + | v := viper.New() | |||
| 1112 | + | v.SetConfigFile(configFile) | |||
| 1113 | + | err = v.ReadInConfig() | |||
| 1114 | + | require.NoError(t, err) | |||
| 1115 | + | ||||
| 1116 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1117 | + | svc := NewService(v, eventBus) | |||
| 1118 | + | ctx := testContext() | |||
| 1119 | + | ||||
| 1120 | + | err = svc.Load(ctx) | |||
| 1121 | + | require.NoError(t, err) | |||
| 1122 | + | ||||
| 1123 | + | // Verify network_groups loaded correctly in memory | |||
| 1124 | + | groups := svc.GetNetworkGroups() | |||
| 1125 | + | require.Len(t, groups, 1) | |||
| 1126 | + | require.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"]) | |||
| 1127 | + | ||||
| 1128 | + | // Trigger a Save via AddRoute (which calls Save internally) | |||
| 1129 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |||
| 1130 | + | require.NoError(t, err) | |||
| 1131 | + | ||||
| 1132 | + | // Re-read the file to verify network_groups was persisted | |||
| 1133 | + | v2 := viper.New() | |||
| 1134 | + | v2.SetConfigFile(configFile) | |||
| 1135 | + | err = v2.ReadInConfig() | |||
| 1136 | + | require.NoError(t, err) | |||
| 1137 | + | ||||
| 1138 | + | // Verify network_groups is still in the file after save | |||
| 1139 | + | savedGroups := loadStringArrayMap(v2.Get("network_groups")) | |||
| 1140 | + | assert.Len(t, savedGroups, 1, "network_groups should still be present in saved config") | |||
| 1141 | + | assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, savedGroups["frontend"]) | |||
| 1142 | + | ||||
| 1143 | + | // Verify auth fields are preserved | |||
| 1144 | + | assert.Equal(t, "admin", v2.GetString("auth.username")) | |||
| 1145 | + | assert.Equal(t, "supersecretvalue", v2.GetString("auth.token_secret")) | |||
| 1146 | + | ||||
| 1147 | + | // Verify server fields are preserved | |||
| 1148 | + | assert.Equal(t, 9999, v2.GetInt("server.port")) | |||
| 1149 | + | }) | |||
| 1150 | + | ||||
| 1151 | + | t.Run("network_groups added in memory are written to disk on Save", func(t *testing.T) { | |||
| 1152 | + | // Setup: config file WITHOUT network_groups | |||
| 1153 | + | tmpDir := t.TempDir() | |||
| 1154 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 1155 | + | initialConfig := ` | |||
| 1156 | + | [server] | |||
| 1157 | + | port = 8080 | |||
| 1158 | + | ||||
| 1159 | + | [routes] | |||
| 1160 | + | "app.example.com" = "myapp:latest" | |||
| 1161 | + | ` | |||
| 1162 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 1163 | + | require.NoError(t, err) | |||
| 1164 | + | ||||
| 1165 | + | v := viper.New() | |||
| 1166 | + | v.SetConfigFile(configFile) | |||
| 1167 | + | err = v.ReadInConfig() | |||
| 1168 | + | require.NoError(t, err) | |||
| 1169 | + | ||||
| 1170 | + | // Inject network_groups into viper as if they were loaded from some other source | |||
| 1171 | + | // This simulates a case where network_groups exist in memory but not on disk | |||
| 1172 | + | v.Set("network_groups", map[string]interface{}{ | |||
| 1173 | + | "backend": []interface{}{"api.example.com"}, | |||
| 1174 | + | }) | |||
| 1175 | + | ||||
| 1176 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1177 | + | svc := NewService(v, eventBus) | |||
| 1178 | + | ctx := testContext() | |||
| 1179 | + | ||||
| 1180 | + | err = svc.Load(ctx) | |||
| 1181 | + | require.NoError(t, err) | |||
| 1182 | + | ||||
| 1183 | + | // Verify network_groups is loaded in memory | |||
| 1184 | + | groups := svc.GetNetworkGroups() | |||
| 1185 | + | require.Len(t, groups, 1) | |||
| 1186 | + | ||||
| 1187 | + | // Trigger Save | |||
| 1188 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |||
| 1189 | + | require.NoError(t, err) | |||
| 1190 | + | ||||
| 1191 | + | // Re-read the file | |||
| 1192 | + | v2 := viper.New() | |||
| 1193 | + | v2.SetConfigFile(configFile) | |||
| 1194 | + | err = v2.ReadInConfig() | |||
| 1195 | + | require.NoError(t, err) | |||
| 1196 | + | ||||
| 1197 | + | // network_groups must be present in the saved file | |||
| 1198 | + | savedGroups := loadStringArrayMap(v2.Get("network_groups")) | |||
| 1199 | + | assert.Len(t, savedGroups, 1, "network_groups added in memory must be written on Save") | |||
| 1200 | + | assert.ElementsMatch(t, []string{"api.example.com"}, savedGroups["backend"]) | |||
| 1201 | + | }) | |||
| 1202 | + | } | |||
| 1203 | + | ||||
| 1094 | func TestExtractDomainFromImageName(t *testing.T) { | 1204 | func TestExtractDomainFromImageName(t *testing.T) { | ||
| 1095 | tests := []struct { | 1205 | tests := []struct { | ||
| 1096 | name string | 1206 | name string | ||