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