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  }