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}