github.com/google/osv-scalibr@v0.4.1/veles/secrets/postmanapikey/validator_test.go (about) 1 // Copyright 2025 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package postmanapikey_test 16 17 import ( 18 "context" 19 "encoding/json" 20 "net/http" 21 "net/http/httptest" 22 "net/url" 23 "testing" 24 25 "github.com/google/go-cmp/cmp" 26 "github.com/google/go-cmp/cmp/cmpopts" 27 "github.com/google/osv-scalibr/veles" 28 postmanapikey "github.com/google/osv-scalibr/veles/secrets/postmanapikey" 29 ) 30 31 const ( 32 validatorTestAPIKey = "PMAK-68b96bd4ae8d2b0001db8a86-192b1cb49020c70a4d0c814ab71de822d7" 33 validatorTestCollectionKey = "PMAT-01K4A58P2HS2Q43TXHSXFRDBZX" 34 ) 35 36 // mockTransport redirects requests to the test server for the configured hosts. 37 type mockTransport struct { 38 testServer *httptest.Server 39 } 40 41 func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { 42 // Replace the original URL with our test server URL for Postman API hosts. 43 if req.URL.Host == "api.getpostman.com" || req.URL.Host == "api.postman.com" { 44 testURL, _ := url.Parse(m.testServer.URL) 45 req.URL.Scheme = testURL.Scheme 46 req.URL.Host = testURL.Host 47 } 48 return http.DefaultTransport.RoundTrip(req) 49 } 50 51 // mockAPIServer creates a mock Postman /me endpoint for testing API validator. 52 func mockAPIServer(t *testing.T, expectedKey string, statusCode int, body any) *httptest.Server { 53 t.Helper() 54 55 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 // Expect a GET to /me 57 if r.Method != http.MethodGet || r.URL.Path != "/me" { 58 t.Errorf("unexpected request: %s %s, expected: GET /me", r.Method, r.URL.Path) 59 http.Error(w, "not found", http.StatusNotFound) 60 return 61 } 62 63 // Check X-Api-Key header contains the expected key 64 apiKeyHeader := r.Header.Get("X-Api-Key") 65 if expectedKey != "" && apiKeyHeader != expectedKey { 66 t.Errorf("expected X-Api-Key header to be %s, got: %s", expectedKey, apiKeyHeader) 67 } 68 69 w.Header().Set("Content-Type", "application/json") 70 w.WriteHeader(statusCode) 71 if body != nil { 72 _ = json.NewEncoder(w).Encode(body) 73 } 74 })) 75 } 76 77 // mockCollectionServer creates a mock Postman collection endpoint for testing collection validator. 78 func mockCollectionServer(t *testing.T, expectedKey string, statusCode int, body any) *httptest.Server { 79 t.Helper() 80 81 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 82 // Expect a GET to /collections/aaaaaaaa-aaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa 83 expectedPath := "/collections/aaaaaaaa-aaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa" 84 if r.Method != http.MethodGet || r.URL.Path != expectedPath { 85 t.Errorf("unexpected request: %s %s, expected: GET %s", r.Method, r.URL.Path, expectedPath) 86 http.Error(w, "not found", http.StatusNotFound) 87 return 88 } 89 90 // Check access_key query parameter 91 if expectedKey != "" { 92 accessKey := r.URL.Query().Get("access_key") 93 if accessKey != expectedKey { 94 t.Errorf("expected access_key query parameter to be %s, got: %s", expectedKey, accessKey) 95 } 96 } 97 98 w.Header().Set("Content-Type", "application/json") 99 w.WriteHeader(statusCode) 100 if body != nil { 101 _ = json.NewEncoder(w).Encode(body) 102 } 103 })) 104 } 105 106 func TestValidatorAPI(t *testing.T) { 107 cases := []struct { 108 name string 109 statusCode int 110 body any 111 want veles.ValidationStatus 112 wantErr error 113 }{ 114 { 115 name: "valid_key", 116 statusCode: http.StatusOK, 117 body: map[string]any{ 118 "user": map[string]any{ 119 "id": 12345, 120 "name": "Test User", 121 }, 122 }, 123 want: veles.ValidationValid, 124 }, 125 { 126 name: "invalid_key_unauthorized", 127 statusCode: http.StatusUnauthorized, 128 body: map[string]any{ 129 "error": map[string]any{ 130 "name": "AuthenticationError", 131 "message": "Invalid API Key. Every request requires a valid API Key to be sent.", 132 }, 133 }, 134 want: veles.ValidationInvalid, 135 }, 136 { 137 name: "server_error", 138 statusCode: http.StatusInternalServerError, 139 body: nil, 140 want: veles.ValidationFailed, 141 wantErr: cmpopts.AnyError, 142 }, 143 { 144 name: "forbidden_error", 145 statusCode: http.StatusForbidden, 146 body: nil, 147 want: veles.ValidationFailed, 148 wantErr: cmpopts.AnyError, 149 }, 150 } 151 152 for _, tc := range cases { 153 t.Run(tc.name, func(t *testing.T) { 154 // Create mock server 155 server := mockAPIServer(t, validatorTestAPIKey, tc.statusCode, tc.body) 156 defer server.Close() 157 158 // Create validator with mock client 159 validator := postmanapikey.NewAPIValidator() 160 validator.HTTPC = server.Client() 161 validator.Endpoint = server.URL + "/me" 162 163 // Create test key 164 key := postmanapikey.PostmanAPIKey{Key: validatorTestAPIKey} 165 166 // Test validation 167 got, err := validator.Validate(t.Context(), key) 168 169 if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 170 t.Errorf("Validate() error mismatch (-want +got):\n%s", diff) 171 } 172 173 // Check validation status 174 if got != tc.want { 175 t.Errorf("Validate() = %v, want %v", got, tc.want) 176 } 177 }) 178 } 179 } 180 181 func TestValidatorAPI_ContextCancellation(t *testing.T) { 182 server := httptest.NewServer(nil) 183 t.Cleanup(func() { 184 server.Close() 185 }) 186 187 validator := postmanapikey.NewAPIValidator() 188 validator.HTTPC = server.Client() 189 validator.Endpoint = server.URL + "/me" 190 191 key := postmanapikey.PostmanAPIKey{Key: validatorTestAPIKey} 192 193 // Create context that is immediately cancelled 194 ctx, cancel := context.WithCancel(t.Context()) 195 cancel() 196 197 // Test validation with cancelled context 198 got, err := validator.Validate(ctx, key) 199 200 if diff := cmp.Diff(cmpopts.AnyError, err, cmpopts.EquateErrors()); diff != "" { 201 t.Errorf("Validate() error mismatch (-want +got):\n%s", diff) 202 } 203 if got != veles.ValidationFailed { 204 t.Errorf("Validate() = %v, want %v", got, veles.ValidationFailed) 205 } 206 } 207 208 func TestValidatorAPI_InvalidRequest(t *testing.T) { 209 // For API validator, an "invalid" key is communicated via 401 status. 210 server := mockAPIServer(t, "", http.StatusUnauthorized, map[string]any{ 211 "error": map[string]any{ 212 "name": "AuthenticationError", 213 "message": "Invalid API Key. Every request requires a valid API Key to be sent.", 214 }, 215 }) 216 defer server.Close() 217 218 validator := postmanapikey.NewAPIValidator() 219 validator.HTTPC = server.Client() 220 validator.Endpoint = server.URL + "/me" 221 222 testCases := []struct { 223 name string 224 key string 225 expected veles.ValidationStatus 226 }{ 227 { 228 name: "empty_key", 229 key: "", 230 expected: veles.ValidationInvalid, 231 }, 232 { 233 name: "invalid_key_format", 234 key: "invalid-api-key-format", 235 expected: veles.ValidationInvalid, 236 }, 237 } 238 239 for _, tc := range testCases { 240 t.Run(tc.name, func(t *testing.T) { 241 k := postmanapikey.PostmanAPIKey{Key: tc.key} 242 243 got, err := validator.Validate(t.Context(), k) 244 245 if err != nil { 246 t.Errorf("Validate() unexpected error for %s: %v", tc.name, err) 247 } 248 if got != tc.expected { 249 t.Errorf("Validate() = %v, want %v for %s", got, tc.expected, tc.name) 250 } 251 }) 252 } 253 } 254 255 func TestValidatorCollection(t *testing.T) { 256 cases := []struct { 257 name string 258 statusCode int 259 body any 260 want veles.ValidationStatus 261 wantErr error 262 }{ 263 { 264 name: "valid_key_with_access", 265 statusCode: http.StatusOK, 266 want: veles.ValidationValid, 267 }, 268 { 269 name: "valid_key_forbidden_exact_match", 270 statusCode: http.StatusForbidden, 271 body: map[string]any{ 272 "error": map[string]any{ 273 "name": "forbiddenError", 274 }, 275 }, 276 want: veles.ValidationValid, 277 }, 278 { 279 name: "invalid_key_unauthorized", 280 statusCode: http.StatusUnauthorized, 281 body: map[string]any{ 282 "error": map[string]any{ 283 "name": "AuthenticationError", 284 "message": "Invalid access token.", 285 }, 286 }, 287 want: veles.ValidationInvalid, 288 }, 289 { 290 name: "forbidden_other_error", 291 statusCode: http.StatusForbidden, 292 body: map[string]any{ 293 "error": map[string]any{ 294 "name": "otherError", 295 "message": "Some other forbidden error.", 296 }, 297 }, 298 want: veles.ValidationInvalid, 299 }, 300 { 301 name: "server_error", 302 statusCode: http.StatusInternalServerError, 303 body: nil, 304 want: veles.ValidationFailed, 305 wantErr: cmpopts.AnyError, 306 }, 307 { 308 name: "forbidden_bad_json", 309 statusCode: http.StatusForbidden, 310 body: "not-a-json", // this will be encoded as a string -> invalid JSON structure for decoding 311 wantErr: cmpopts.AnyError, 312 want: veles.ValidationFailed, 313 }, 314 } 315 316 for _, tc := range cases { 317 t.Run(tc.name, func(t *testing.T) { 318 // Create mock collection server 319 server := mockCollectionServer(t, validatorTestCollectionKey, tc.statusCode, tc.body) 320 defer server.Close() 321 322 // Create client with custom transport 323 client := &http.Client{ 324 Transport: &mockTransport{testServer: server}, 325 } 326 327 // Create validator with mock client. We use the mockTransport intentionally 328 // here to test the validator's endpoint construction from the key. 329 validator := postmanapikey.NewCollectionValidator() 330 validator.HTTPC = client 331 332 // Create test key 333 key := postmanapikey.PostmanCollectionToken{Key: validatorTestCollectionKey} 334 335 // Test validation 336 got, err := validator.Validate(t.Context(), key) 337 338 if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 339 t.Errorf("Validate() error mismatch (-want +got):\n%s", diff) 340 } 341 342 // Check validation status 343 if got != tc.want { 344 t.Errorf("Validate() = %v, want %v", got, tc.want) 345 } 346 }) 347 } 348 } 349 350 func TestValidatorCollection_ContextCancellation(t *testing.T) { 351 server := httptest.NewServer(nil) 352 t.Cleanup(func() { 353 server.Close() 354 }) 355 356 validator := postmanapikey.NewCollectionValidator() 357 validator.HTTPC = server.Client() 358 validator.EndpointFunc = func(k postmanapikey.PostmanCollectionToken) (string, error) { 359 return server.URL + "/collections/aaaaaaaa-aaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa?access_key=" + k.Key, nil 360 } 361 key := postmanapikey.PostmanCollectionToken{Key: validatorTestCollectionKey} 362 363 // Create context that is immediately cancelled 364 ctx, cancel := context.WithCancel(t.Context()) 365 cancel() 366 367 // Test validation with cancelled context 368 got, err := validator.Validate(ctx, key) 369 370 if diff := cmp.Diff(cmpopts.AnyError, err, cmpopts.EquateErrors()); diff != "" { 371 t.Errorf("Validate() error mismatch (-want +got):\n%s", diff) 372 } 373 if got != veles.ValidationFailed { 374 t.Errorf("Validate() = %v, want %v", got, veles.ValidationFailed) 375 } 376 } 377 378 func TestValidatorCollection_InvalidRequest(t *testing.T) { 379 // For collection validator, a 401 indicates invalid token (no error returned). 380 server := mockCollectionServer(t, "", http.StatusUnauthorized, nil) 381 defer server.Close() 382 383 validator := postmanapikey.NewCollectionValidator() 384 validator.HTTPC = server.Client() 385 validator.EndpointFunc = func(k postmanapikey.PostmanCollectionToken) (string, error) { 386 return server.URL + "/collections/aaaaaaaa-aaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa?access_key=" + k.Key, nil 387 } 388 testCases := []struct { 389 name string 390 key string 391 expected veles.ValidationStatus 392 }{ 393 { 394 name: "empty_key", 395 key: "", 396 expected: veles.ValidationInvalid, 397 }, 398 { 399 name: "invalid_key_format", 400 key: "invalid-collection-token", 401 expected: veles.ValidationInvalid, 402 }, 403 } 404 405 for _, tc := range testCases { 406 t.Run(tc.name, func(t *testing.T) { 407 k := postmanapikey.PostmanCollectionToken{Key: tc.key} 408 409 got, err := validator.Validate(t.Context(), k) 410 411 if err != nil { 412 t.Errorf("Validate() unexpected error for %s: %v", tc.name, err) 413 } 414 if got != tc.expected { 415 t.Errorf("Validate() = %v, want %v for %s", got, tc.expected, tc.name) 416 } 417 }) 418 } 419 }