A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "net/http" 8 "net/http/httptest" 9 "os" 10 "testing" 11 12 "Coves/internal/api/handlers/oauth" 13 "Coves/internal/atproto/identity" 14 oauthCore "Coves/internal/core/oauth" 15 16 "github.com/lestrrat-go/jwx/v2/jwk" 17) 18 19// TestOAuthClientMetadata tests the /oauth/client-metadata.json endpoint 20func TestOAuthClientMetadata(t *testing.T) { 21 tests := []struct { 22 name string 23 appviewURL string 24 expectedClientID string 25 expectedJWKSURI string 26 expectedRedirect string 27 }{ 28 { 29 name: "localhost development", 30 appviewURL: "http://localhost:8081", 31 expectedClientID: "http://localhost?redirect_uri=http://localhost:8081/oauth/callback&scope=atproto%20transition:generic", 32 expectedJWKSURI: "", // No JWKS URI for localhost 33 expectedRedirect: "http://localhost:8081/oauth/callback", 34 }, 35 { 36 name: "production HTTPS", 37 appviewURL: "https://coves.social", 38 expectedClientID: "https://coves.social/oauth/client-metadata.json", 39 expectedJWKSURI: "https://coves.social/oauth/jwks.json", 40 expectedRedirect: "https://coves.social/oauth/callback", 41 }, 42 } 43 44 for _, tt := range tests { 45 t.Run(tt.name, func(t *testing.T) { 46 // Set environment 47 os.Setenv("APPVIEW_PUBLIC_URL", tt.appviewURL) 48 defer os.Unsetenv("APPVIEW_PUBLIC_URL") 49 50 // Create request 51 req := httptest.NewRequest("GET", "/oauth/client-metadata.json", nil) 52 w := httptest.NewRecorder() 53 54 // Call handler 55 oauth.HandleClientMetadata(w, req) 56 57 // Check status code 58 if w.Code != http.StatusOK { 59 t.Fatalf("expected status 200, got %d", w.Code) 60 } 61 62 // Parse response 63 var metadata oauth.ClientMetadata 64 if err := json.NewDecoder(w.Body).Decode(&metadata); err != nil { 65 t.Fatalf("failed to decode response: %v", err) 66 } 67 68 // Verify client ID 69 if metadata.ClientID != tt.expectedClientID { 70 t.Errorf("expected client_id %q, got %q", tt.expectedClientID, metadata.ClientID) 71 } 72 73 // Verify JWKS URI 74 if metadata.JwksURI != tt.expectedJWKSURI { 75 t.Errorf("expected jwks_uri %q, got %q", tt.expectedJWKSURI, metadata.JwksURI) 76 } 77 78 // Verify redirect URI 79 if len(metadata.RedirectURIs) != 1 || metadata.RedirectURIs[0] != tt.expectedRedirect { 80 t.Errorf("expected redirect_uris [%q], got %v", tt.expectedRedirect, metadata.RedirectURIs) 81 } 82 83 // Verify OAuth spec compliance 84 if metadata.ClientName != "Coves" { 85 t.Errorf("expected client_name 'Coves', got %q", metadata.ClientName) 86 } 87 if metadata.TokenEndpointAuthMethod != "private_key_jwt" { 88 t.Errorf("expected token_endpoint_auth_method 'private_key_jwt', got %q", metadata.TokenEndpointAuthMethod) 89 } 90 if metadata.TokenEndpointAuthSigningAlg != "ES256" { 91 t.Errorf("expected token_endpoint_auth_signing_alg 'ES256', got %q", metadata.TokenEndpointAuthSigningAlg) 92 } 93 if !metadata.DpopBoundAccessTokens { 94 t.Error("expected dpop_bound_access_tokens to be true") 95 } 96 }) 97 } 98} 99 100// TestOAuthJWKS tests the /oauth/jwks.json endpoint 101func TestOAuthJWKS(t *testing.T) { 102 // Use the test JWK from .env.dev 103 testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}` 104 105 tests := []struct { 106 name string 107 envValue string 108 expectSuccess bool 109 }{ 110 { 111 name: "valid plain JWK", 112 envValue: testJWK, 113 expectSuccess: true, 114 }, 115 { 116 name: "missing JWK", 117 envValue: "", 118 expectSuccess: false, 119 }, 120 } 121 122 for _, tt := range tests { 123 t.Run(tt.name, func(t *testing.T) { 124 // Set environment 125 if tt.envValue != "" { 126 os.Setenv("OAUTH_PRIVATE_JWK", tt.envValue) 127 defer os.Unsetenv("OAUTH_PRIVATE_JWK") 128 } 129 130 // Create request 131 req := httptest.NewRequest("GET", "/oauth/jwks.json", nil) 132 w := httptest.NewRecorder() 133 134 // Call handler 135 oauth.HandleJWKS(w, req) 136 137 // Check status code 138 if tt.expectSuccess { 139 if w.Code != http.StatusOK { 140 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) 141 } 142 143 // Parse response 144 var jwksResp struct { 145 Keys []map[string]interface{} `json:"keys"` 146 } 147 if err := json.NewDecoder(w.Body).Decode(&jwksResp); err != nil { 148 t.Fatalf("failed to decode JWKS: %v", err) 149 } 150 151 // Verify we got a public key 152 if len(jwksResp.Keys) != 1 { 153 t.Fatalf("expected 1 key, got %d", len(jwksResp.Keys)) 154 } 155 156 key := jwksResp.Keys[0] 157 if key["kty"] != "EC" { 158 t.Errorf("expected kty 'EC', got %v", key["kty"]) 159 } 160 if key["alg"] != "ES256" { 161 t.Errorf("expected alg 'ES256', got %v", key["alg"]) 162 } 163 if key["kid"] != "oauth-client-key" { 164 t.Errorf("expected kid 'oauth-client-key', got %v", key["kid"]) 165 } 166 167 // Verify private key is NOT exposed 168 if _, hasPrivate := key["d"]; hasPrivate { 169 t.Error("SECURITY: private key 'd' should not be in JWKS!") 170 } 171 172 } else { 173 if w.Code == http.StatusOK { 174 t.Fatalf("expected error status, got 200") 175 } 176 } 177 }) 178 } 179} 180 181// TestOAuthLoginHandler tests the OAuth login initiation 182func TestOAuthLoginHandler(t *testing.T) { 183 // Skip if running in CI without database 184 if os.Getenv("SKIP_INTEGRATION") == "true" { 185 t.Skip("Skipping integration test") 186 } 187 188 // Setup test database 189 db := setupTestDB(t) 190 defer db.Close() 191 192 // Create session store 193 sessionStore := oauthCore.NewPostgresSessionStore(db) 194 195 // Create identity resolver (mock for now - we'll test with real PDS separately) 196 // For now, just test the handler structure and validation 197 198 tests := []struct { 199 name string 200 requestBody map[string]interface{} 201 envJWK string 202 expectedStatus int 203 }{ 204 { 205 name: "missing handle", 206 requestBody: map[string]interface{}{ 207 "handle": "", 208 }, 209 envJWK: `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`, 210 expectedStatus: http.StatusBadRequest, 211 }, 212 { 213 name: "invalid handle format", 214 requestBody: map[string]interface{}{ 215 "handle": "no-dots-invalid", 216 }, 217 envJWK: `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`, 218 expectedStatus: http.StatusBadRequest, 219 }, 220 { 221 name: "missing OAuth JWK", 222 requestBody: map[string]interface{}{ 223 "handle": "alice.bsky.social", 224 }, 225 envJWK: "", 226 expectedStatus: http.StatusInternalServerError, 227 }, 228 } 229 230 for _, tt := range tests { 231 t.Run(tt.name, func(t *testing.T) { 232 // Set environment 233 if tt.envJWK != "" { 234 os.Setenv("OAUTH_PRIVATE_JWK", tt.envJWK) 235 defer os.Unsetenv("OAUTH_PRIVATE_JWK") 236 } else { 237 os.Unsetenv("OAUTH_PRIVATE_JWK") 238 } 239 240 // Create mock identity resolver for validation tests 241 mockResolver := &mockIdentityResolver{} 242 243 // Create handler 244 handler := oauth.NewLoginHandler(mockResolver, sessionStore) 245 246 // Create request 247 bodyBytes, _ := json.Marshal(tt.requestBody) 248 req := httptest.NewRequest("POST", "/oauth/login", bytes.NewReader(bodyBytes)) 249 req.Header.Set("Content-Type", "application/json") 250 w := httptest.NewRecorder() 251 252 // Call handler 253 handler.HandleLogin(w, req) 254 255 // Check status code 256 if w.Code != tt.expectedStatus { 257 t.Errorf("expected status %d, got %d: %s", tt.expectedStatus, w.Code, w.Body.String()) 258 } 259 }) 260 } 261} 262 263// TestOAuthCallbackHandler tests the OAuth callback handling 264func TestOAuthCallbackHandler(t *testing.T) { 265 // Skip if running in CI without database 266 if os.Getenv("SKIP_INTEGRATION") == "true" { 267 t.Skip("Skipping integration test") 268 } 269 270 // Setup test database 271 db := setupTestDB(t) 272 defer db.Close() 273 274 // Create session store 275 sessionStore := oauthCore.NewPostgresSessionStore(db) 276 277 testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}` 278 cookieSecret := "f1132c01b1a625a865c6c455a75ee793572cedb059cebe0c4c1ae4c446598f7d" 279 280 tests := []struct { 281 name string 282 queryParams map[string]string 283 expectedStatus int 284 }{ 285 { 286 name: "missing code", 287 queryParams: map[string]string{ 288 "state": "test-state", 289 "iss": "https://bsky.social", 290 }, 291 expectedStatus: http.StatusBadRequest, 292 }, 293 { 294 name: "missing state", 295 queryParams: map[string]string{ 296 "code": "test-code", 297 "iss": "https://bsky.social", 298 }, 299 expectedStatus: http.StatusBadRequest, 300 }, 301 { 302 name: "missing issuer", 303 queryParams: map[string]string{ 304 "code": "test-code", 305 "state": "test-state", 306 }, 307 expectedStatus: http.StatusBadRequest, 308 }, 309 { 310 name: "OAuth error parameter", 311 queryParams: map[string]string{ 312 "error": "access_denied", 313 "error_description": "User denied access", 314 }, 315 expectedStatus: http.StatusBadRequest, 316 }, 317 } 318 319 for _, tt := range tests { 320 t.Run(tt.name, func(t *testing.T) { 321 // Set environment 322 os.Setenv("OAUTH_PRIVATE_JWK", testJWK) 323 defer os.Unsetenv("OAUTH_PRIVATE_JWK") 324 325 // Create handler 326 handler := oauth.NewCallbackHandler(sessionStore, cookieSecret) 327 328 // Build query string 329 req := httptest.NewRequest("GET", "/oauth/callback", nil) 330 q := req.URL.Query() 331 for k, v := range tt.queryParams { 332 q.Add(k, v) 333 } 334 req.URL.RawQuery = q.Encode() 335 336 w := httptest.NewRecorder() 337 338 // Call handler 339 handler.HandleCallback(w, req) 340 341 // Check status code 342 if w.Code != tt.expectedStatus { 343 t.Errorf("expected status %d, got %d: %s", tt.expectedStatus, w.Code, w.Body.String()) 344 } 345 }) 346 } 347} 348 349// mockIdentityResolver is a mock for testing 350type mockIdentityResolver struct{} 351 352func (m *mockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 353 // Return a mock resolved identity 354 return &identity.Identity{ 355 DID: "did:plc:test123", 356 Handle: identifier, 357 PDSURL: "https://test.pds.example", 358 }, nil 359} 360 361func (m *mockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (string, string, error) { 362 return "did:plc:test123", "https://test.pds.example", nil 363} 364 365func (m *mockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 366 return &identity.DIDDocument{ 367 DID: did, 368 Service: []identity.Service{ 369 { 370 ID: "#atproto_pds", 371 Type: "AtprotoPersonalDataServer", 372 ServiceEndpoint: "https://test.pds.example", 373 }, 374 }, 375 }, nil 376} 377 378func (m *mockIdentityResolver) Purge(ctx context.Context, identifier string) error { 379 return nil 380} 381 382// TestJWKParsing tests that we can parse JWKs correctly 383func TestJWKParsing(t *testing.T) { 384 testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}` 385 386 // Parse the JWK 387 key, err := jwk.ParseKey([]byte(testJWK)) 388 if err != nil { 389 t.Fatalf("failed to parse JWK: %v", err) 390 } 391 392 // Verify it's an EC key 393 if key.KeyType() != "EC" { 394 t.Errorf("expected key type 'EC', got %v", key.KeyType()) 395 } 396 397 // Verify we can get the public key 398 pubKey, err := key.PublicKey() 399 if err != nil { 400 t.Fatalf("failed to get public key: %v", err) 401 } 402 403 // Verify public key doesn't have private component 404 pubKeyJSON, _ := json.Marshal(pubKey) 405 var pubKeyMap map[string]interface{} 406 json.Unmarshal(pubKeyJSON, &pubKeyMap) 407 408 if _, hasPrivate := pubKeyMap["d"]; hasPrivate { 409 t.Error("SECURITY: public key should not contain private 'd' component!") 410 } 411}