A community based topic aggregation platform built on atproto

Compare changes

Choose any two refs to compare.

+35395 -943
-3
.beads/beads.left.jsonl
··· 1 - {"id":"Coves-95q","content_hash":"8ec99d598f067780436b985f9ad57f0fa19632026981038df4f65f192186620b","title":"Add comprehensive API documentation","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-17T20:30:34.835721854-08:00","updated_at":"2025-11-17T20:30:34.835721854-08:00","source_repo":".","dependencies":[{"issue_id":"Coves-95q","depends_on_id":"Coves-e16","type":"blocks","created_at":"2025-11-17T20:30:46.273899399-08:00","created_by":"daemon"}]} 2 - {"id":"Coves-e16","content_hash":"7c5d0fc8f0e7f626be3dad62af0e8412467330bad01a244e5a7e52ac5afff1c1","title":"Complete post creation and moderation features","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:12.885991306-08:00","updated_at":"2025-11-17T20:30:12.885991306-08:00","source_repo":"."} 3 - {"id":"Coves-fce","content_hash":"26b3e16b99f827316ee0d741cc959464bd0c813446c95aef8105c7fd1e6b09ff","title":"Implement aggregator feed federation","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:21.453326012-08:00","updated_at":"2025-11-17T20:30:21.453326012-08:00","source_repo":"."}
-1
.beads/beads.left.meta.json
··· 1 - {"version":"0.23.1","timestamp":"2025-12-02T18:25:24.009187871-08:00","commit":"00d7d8d"}
+9
.beads/issues.jsonl
··· 1 + {"id":"Coves-327","content_hash":"4f3413d1657a67ed1f71c0a5f06a9934b9e397a539de14e744c8ef622f0e7f73","title":"Bluesky embed Phase 3: Extract images and videos from embedded posts","description":"Currently Bluesky post embeds only indicate media presence (hasMedia, mediaCount). Phase 3 should extract actual media data for display.\n\n## Images (app.bsky.embed.images#view)\nExtract from images array:\n- thumb: thumbnail URL (CDN)\n- fullsize: full-size URL (CDN)\n- alt: accessibility text\n- aspectRatio: { width, height }\n\n## Videos (app.bsky.embed.video#view)\nExtract video data:\n- thumbnail: video thumbnail URL\n- playlist: HLS playlist URL (.m3u8)\n- aspectRatio: { width, height }\n\n## Scope\nApply to all Bluesky post contexts:\n1. Regular embedded Bluesky posts\n2. Quoted posts within embeds\n3. recordWithMedia embeds (quote + media)\n\n## Types to update\n- BlueskyPostResult: add Images []ImageEmbed and Video *VideoEmbed fields\n- Add ImageEmbed struct: Thumb, Fullsize, Alt, AspectRatio\n- Add VideoEmbed struct: Thumbnail, Playlist, AspectRatio\n\n## Dependencies\n- Should be implemented after moderation features are complete\n- Images/videos from Bluesky may contain content requiring moderation labels\n\n## Notes\n- All URLs are CDN URLs from cdn.bsky.app (already resolved by Bluesky API)\n- No blob fetching required - just pass through the URLs","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-26T15:53:51.873611055-08:00","updated_at":"2025-12-26T15:53:51.873611055-08:00","source_repo":"."} 2 + {"id":"Coves-8b1","content_hash":"a949ba526ad819badab625c0d5fdbc6a7994d22f059f4a4f7e68635750bd5ea3","title":"Apply functional options pattern to NewGetDiscoverHandler","description":"Location: internal/api/handlers/discover/get.go\n\nApply functional options pattern for optional dependencies (votes, bluesky).\n\nDepends on: Coves-jdf (NewPostService refactor should be done first to establish pattern)\nParent: Coves-8k1","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:35:27.315877238-08:00","updated_at":"2025-12-22T21:35:58.061823373-08:00","source_repo":"."} 3 + {"id":"Coves-8k1","content_hash":"a10053af68636b722a86aa75dd483ece4509d0de4884230beb52453585895589","title":"Refactor service constructors to use functional options pattern","description":"Multiple service constructors have grown to accept many optional dependencies, leading to hard-to-read nil chains:\n```go\nposts.NewPostService(repo, communityService, nil, nil, nil, nil, \"http://localhost:3001\")\n```\n\nApply the functional options pattern to all affected constructors:\n- NewPostService (7 params, 4 optional)\n- NewGetDiscoverHandler (3 params, 2 optional)\n- NewGetCommunityHandler (3 params, 2 optional)\n- NewGetTimelineHandler (3 params, 2 optional)\n- RegisterTimelineRoutes (5 params, 2 optional)\n\nThis will improve readability, make tests self-documenting, and prevent breakage when adding new optional params.\n\nScope: ~20 files, ~50 call sites\nRisk: Low (purely mechanical, no logic changes)","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-22T21:35:19.91257167-08:00","updated_at":"2025-12-22T21:35:39.69736147-08:00","source_repo":"."} 1 4 {"id":"Coves-95q","content_hash":"8ec99d598f067780436b985f9ad57f0fa19632026981038df4f65f192186620b","title":"Add comprehensive API documentation","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-17T20:30:34.835721854-08:00","updated_at":"2025-11-17T20:30:34.835721854-08:00","source_repo":".","dependencies":[{"issue_id":"Coves-95q","depends_on_id":"Coves-e16","type":"blocks","created_at":"2025-11-17T20:30:46.273899399-08:00","created_by":"daemon"}]} 2 5 {"id":"Coves-e16","content_hash":"7c5d0fc8f0e7f626be3dad62af0e8412467330bad01a244e5a7e52ac5afff1c1","title":"Complete post creation and moderation features","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:12.885991306-08:00","updated_at":"2025-11-17T20:30:12.885991306-08:00","source_repo":"."} 6 + {"id":"Coves-f9q","content_hash":"a1a38759edc37d11227d5992cdbed1b8cf27e09496165e45c542b208f58d34ce","title":"Apply functional options pattern to NewGetTimelineHandler and RegisterTimelineRoutes","description":"Locations:\n- internal/api/handlers/timeline/get.go (NewGetTimelineHandler)\n- internal/api/routes/timeline.go (RegisterTimelineRoutes)\n\nApply functional options pattern for optional dependencies (votes, bluesky).\n\nUpdate RegisterTimelineRoutes last after handlers are refactored.\n\nDepends on: Coves-jdf, Coves-8b1, Coves-iw5\nParent: Coves-8k1","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:35:27.420117481-08:00","updated_at":"2025-12-22T21:35:58.166765845-08:00","source_repo":"."} 3 7 {"id":"Coves-fce","content_hash":"26b3e16b99f827316ee0d741cc959464bd0c813446c95aef8105c7fd1e6b09ff","title":"Implement aggregator feed federation","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:21.453326012-08:00","updated_at":"2025-11-17T20:30:21.453326012-08:00","source_repo":"."} 8 + {"id":"Coves-fqg","content_hash":"b3e4af5d914ad9fa222a2216603e6f089afa88cbc8f65c8a593e571e7f926ccb","title":"Performance: PDS ListRecords bottleneck causing slow votes and feed loads","description":"## Problem Summary\n\nMultiple user-facing operations are slow due to repeated `listRecords` calls to the user's PDS (Personal Data Server). This affects both vote creation (~2-3s) and initial feed loads (~800ms).\n\n## Root Cause\n\nThe AppView queries the user's PDS to list ALL vote records whenever it needs to:\n1. Check if a vote already exists (for toggle logic)\n2. Populate the vote cache for viewer state\n\n**The problematic code path** (`service_impl.go:317-374`):\n\n```go\nfunc (s *voteService) findExistingVote(ctx context.Context, pdsClient pds.Client, subjectURI string) (*existingVote, error) {\n cursor := \"\"\n for {\n // Fetches 100 records per page from user's PDS\n result, err := pdsClient.ListRecords(ctx, voteCollection, pageSize, cursor)\n // Iterates through ALL records looking for matching subject URI\n for _, rec := range result.Records {\n // Linear search through every vote...\n }\n }\n}\n```\n\n## Affected Operations\n\n| Operation | Latency | Cause |\n|-----------|---------|-------|\n| Vote create/toggle | 2-3s | `getPDSClient` โ†’ token refresh w/ DPoP retry โ†’ `listRecords` (find existing) โ†’ `createRecord` |\n| First feed load | ~800ms | `listRecords` to populate vote cache |\n| Subsequent feeds | ~100ms | Cache hit (no PDS call) |\n\n## Why Token Refresh Adds Latency\n\nLogs show DPoP nonce mismatches requiring retries:\n```\n22:13:09 [AUTH_SUCCESS]\n22:13:11 WARN auth server request failed request=token-refresh statusCode=400 body=\"use_dpop_nonce\"\n22:13:12 INFO vote created\n```\n\nThe OAuth DPoP flow requires a server-provided nonce. On first request, the server rejects with the nonce, client retries with it. This adds ~1s per token refresh.\n\n## Scaling Concern\n\nThe `findExistingVote` function paginates through ALL votes (100 per page):\n\n| User's Vote Count | PDS Calls | Estimated Latency |\n|-------------------|-----------|-------------------|\n| 36 (current) | 1 | ~1s |\n| 200 | 2 | ~2s |\n| 500 | 5 | ~5s |\n| 1000 | 10 | ~10s |\n| 2000+ | 20+ | Timeout risk (30s limit) |\n\n**Active user projection**: 20 votes/day ร— 30 days = 600 votes/month โ†’ UX degradation within weeks of active use.\n\n## Current Architecture Flow\n\n```\nโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”\nโ”‚ Mobile App โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ AppView โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ User's PDS โ”‚\nโ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜\n โ”‚\n โ–ผ\n โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”\n โ”‚ Vote Cache โ”‚ (in-memory, per-user)\n โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜\n```\n\n**Vote Creation Flow:**\n1. Mobile sends vote request to AppView\n2. AppView refreshes OAuth token (DPoP nonce retry) โ†’ **+1s**\n3. AppView calls `listRecords` on PDS to find existing vote โ†’ **+1s per 100 votes**\n4. AppView creates/deletes record on PDS โ†’ **+0.5s**\n5. Total: **2-3s+ for 36 votes**\n\n**First Feed Load Flow:**\n1. Mobile requests feed\n2. AppView checks if vote cache populated\n3. If not, calls `listRecords` to fetch ALL votes โ†’ **+800ms**\n4. Returns feed with viewer vote state\n\n## The Irony\n\nA vote cache already exists and is properly maintained:\n- Updated on every vote create/delete (`service_impl.go:157-159`, `service_impl.go:209-215`)\n- Indexed by subject URI for O(1) lookup\n- Cleared on sign-out\n\nBut it's **bypassed for vote existence checks** because the code treats PDS as \"source of truth\" to avoid eventual consistency issues.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-13T14:29:00.821389478-08:00","updated_at":"2026-01-21T19:54:45.676280382-08:00","closed_at":"2026-01-21T19:54:45.676280382-08:00","source_repo":"."} 9 + {"id":"Coves-iw5","content_hash":"d3379c617b7583f6b88a0523b3cdd1e4415176877ab00b48710819f2484c4856","title":"Apply functional options pattern to NewGetCommunityHandler","description":"Location: internal/api/handlers/communityFeed/get.go\n\nApply functional options pattern for optional dependencies (votes, bluesky).\n\nDepends on: Coves-jdf (NewPostService refactor should be done first to establish pattern)\nParent: Coves-8k1","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:35:27.369297201-08:00","updated_at":"2025-12-22T21:35:58.115771178-08:00","source_repo":"."} 10 + {"id":"Coves-jdf","content_hash":"cb27689d71f44fd555e29d2988f2ad053efb6c565cd4f803ff68eaade59c7546","title":"Apply functional options pattern to NewPostService","description":"Location: internal/core/posts/service.go\n\nCurrent constructor (7 params, 4 optional):\n```go\nfunc NewPostService(repo Repository, communityService communities.Service, aggregatorService aggregators.Service, blobService blobs.Service, unfurlService unfurl.Service, blueskyService blueskypost.Service, pdsURL string) Service\n```\n\nRefactor to:\n```go\ntype Option func(*postService)\n\nfunc WithAggregatorService(svc aggregators.Service) Option\nfunc WithBlobService(svc blobs.Service) Option\nfunc WithUnfurlService(svc unfurl.Service) Option\nfunc WithBlueskyService(svc blueskypost.Service) Option\n\nfunc NewPostService(repo Repository, communityService communities.Service, pdsURL string, opts ...Option) Service\n```\n\nFiles to update:\n- internal/core/posts/service.go (define Option type and With* functions)\n- cmd/server/main.go (production caller)\n- ~15 test files with call sites\n\nStart with this one as it has the most params and is most impacted.\nParent: Coves-8k1","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-22T21:35:27.264325344-08:00","updated_at":"2025-12-22T21:35:58.003863381-08:00","source_repo":"."} 11 + {"id":"Coves-p44","content_hash":"6f12091f6e5f1ad9812f8da4ecd720e0f9df1afd1fdb593b3e52c32be0193d94","title":"Bluesky embed conversion Phase 2: resolve post and populate CID","description":"When converting a Bluesky URL to a social.coves.embed.post, we need to:\n\n1. Call blueskyService.ResolvePost() to get the full post data including CID\n2. Populate both URI and CID in the strongRef\n3. Consider caching/re-using resolved post data for rendering\n\nCurrently disabled in Phase 1 (text-only) because:\n- social.coves.embed.post requires a valid CID in com.atproto.repo.strongRef\n- Empty CID causes PDS to reject the record creation\n\nRelated files:\n- internal/core/posts/service.go:tryConvertBlueskyURLToPostEmbed()\n- internal/atproto/lexicon/social/coves/embed/post.json\n\nThis is part of the Bluesky post cross-posting feature (images/embeds phase).","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-22T21:25:23.540135876-08:00","updated_at":"2025-12-23T14:41:49.014541876-08:00","closed_at":"2025-12-23T14:41:49.014541876-08:00","source_repo":"."} 12 + {"id":"Coves-r6n","content_hash":"48a9b995bdef6efcfa2c42d5620cc262b264d3dfe7c265423aaed7ee8890a2f2","title":"Community handle names limited to 16 characters due to PDS hardcoded limit","description":"## Summary\nThe Bluesky PDS has a hardcoded 18-character limit on the first segment of handles (packages/pds/src/handle/index.ts line 89). With our `c-` prefix for community handles, this limits community names to 16 characters.\n\n## Affected Names\nNames like `artificial-intelligence` (23 chars), `software-engineering` (20 chars), or `explain-like-im-five` (20 chars) won't work.\n\n## Background\n- AT Protocol spec allows 63 chars per segment (DNS label limit)\n- PDS limit is a Bluesky policy choice for `*.bsky.social` usability\n- Fix was discussed in https://github.com/bluesky-social/atproto/issues/2391\n- PR https://github.com/bluesky-social/atproto/pull/2392 changed from 30 total to 18 first-segment\n\n## Resolution Options\n1. **Fork PDS** - Change `if (front.length \u003e 18)` to higher limit (e.g., 30 or 63)\n2. **Accept limit** - Document 16-char max for community names\n3. **Remove c- prefix** - Gains 2 chars but loses user/community distinction\n\n## Decision\nAccepting the limit for now. Most community names fit. Revisit if user demand arises.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T22:06:34.355515838-08:00","updated_at":"2025-12-22T22:06:49.297076332-08:00","source_repo":"."}
+6
.beads/.gitignore
··· 18 18 !*.jsonl 19 19 !metadata.json 20 20 !config.json 21 + 22 + # Merge artifacts (created during conflict resolution) 23 + beads.base.jsonl 24 + beads.base.meta.json 25 + beads.left.jsonl 26 + beads.left.meta.json
+343 -8
internal/api/middleware/auth.go
··· 16 16 type contextKey string 17 17 18 18 const ( 19 - UserDIDKey contextKey = "user_did" 20 - OAuthSessionKey contextKey = "oauth_session" 21 - UserAccessToken contextKey = "user_access_token" // Kept for backward compatibility 19 + UserDIDKey contextKey = "user_did" 20 + OAuthSessionKey contextKey = "oauth_session" 21 + UserAccessToken contextKey = "user_access_token" // Backward compatibility: handlers/tests using GetUserAccessToken() 22 + IsAggregatorAuthKey contextKey = "is_aggregator_auth" 23 + AuthMethodKey contextKey = "auth_method" 22 24 ) 23 25 24 - // SessionUnsealer is an interface for unsealing session tokens 26 + // AuthMiddleware is an interface for authentication middleware 27 + // Both OAuthAuthMiddleware and DualAuthMiddleware implement this 28 + type AuthMiddleware interface { 29 + RequireAuth(next http.Handler) http.Handler 30 + } 31 + 32 + // Auth method constants 33 + const ( 34 + AuthMethodOAuth = "oauth" 35 + AuthMethodServiceJWT = "service_jwt" 36 + AuthMethodAPIKey = "api_key" 37 + ) 25 38 39 + // API key prefix constant 40 + const APIKeyPrefix = "ckapi_" 26 41 42 + // SessionUnsealer is an interface for unsealing session tokens 43 + // This allows for mocking in tests 44 + type SessionUnsealer interface { 27 45 UnsealSession(token string) (*oauth.SealedSession, error) 28 46 } 29 47 48 + // AggregatorChecker is an interface for checking if a DID is a registered aggregator 49 + type AggregatorChecker interface { 50 + IsAggregator(ctx context.Context, did string) (bool, error) 51 + } 52 + 53 + // ServiceAuthValidator is an interface for validating service JWTs 54 + type ServiceAuthValidator interface { 55 + Validate(ctx context.Context, tokenString string, lexMethod *syntax.NSID) (syntax.DID, error) 56 + } 57 + 58 + // APIKeyValidator is an interface for validating API keys (used by aggregators) 59 + type APIKeyValidator interface { 60 + // ValidateKey validates an API key and returns the aggregator DID if valid 61 + ValidateKey(ctx context.Context, plainKey string) (aggregatorDID string, err error) 62 + // RefreshTokensIfNeeded refreshes OAuth tokens for the aggregator if they are expired 63 + RefreshTokensIfNeeded(ctx context.Context, aggregatorDID string) error 64 + } 65 + 30 66 // OAuthAuthMiddleware enforces OAuth authentication using sealed session tokens. 31 67 type OAuthAuthMiddleware struct { 32 68 unsealer SessionUnsealer ··· 204 240 // GetUserDID extracts the user's DID from the request context 205 241 // Returns empty string if not authenticated 206 242 func GetUserDID(r *http.Request) string { 207 - did, _ := r.Context().Value(UserDIDKey).(string) 243 + val := r.Context().Value(UserDIDKey) 244 + did, ok := val.(string) 245 + if !ok && val != nil { 246 + // SECURITY: Type assertion failed but value exists - this should never happen 247 + // Log as error since this could indicate context value corruption 248 + log.Printf("[AUTH_ERROR] GetUserDID: type assertion failed, expected string, got %T (value: %v)", 249 + val, val) 250 + } 208 251 return did 209 252 } 210 253 ··· 212 255 // This is used by service layers for defense-in-depth validation 213 256 // Returns empty string if not authenticated 214 257 func GetAuthenticatedDID(ctx context.Context) string { 215 - did, _ := ctx.Value(UserDIDKey).(string) 258 + val := ctx.Value(UserDIDKey) 259 + did, ok := val.(string) 260 + if !ok && val != nil { 261 + // SECURITY: Type assertion failed but value exists - this should never happen 262 + // Log as error since this could indicate context value corruption 263 + log.Printf("[AUTH_ERROR] GetAuthenticatedDID: type assertion failed, expected string, got %T (value: %v)", 264 + val, val) 265 + } 216 266 return did 217 267 } 218 268 ··· 220 270 // Returns nil if not authenticated 221 271 // Handlers can use this to make authenticated PDS calls 222 272 func GetOAuthSession(r *http.Request) *oauthlib.ClientSessionData { 223 - session, _ := r.Context().Value(OAuthSessionKey).(*oauthlib.ClientSessionData) 273 + val := r.Context().Value(OAuthSessionKey) 274 + session, ok := val.(*oauthlib.ClientSessionData) 275 + if !ok && val != nil { 276 + // SECURITY: Type assertion failed but value exists - this should never happen 277 + // Log as error since this could indicate context value corruption 278 + log.Printf("[AUTH_ERROR] GetOAuthSession: type assertion failed, expected *ClientSessionData, got %T", 279 + val) 280 + } 224 281 return session 225 282 } 226 283 227 284 // GetUserAccessToken extracts the user's access token from the request context 228 285 // Returns empty string if not authenticated 229 286 func GetUserAccessToken(r *http.Request) string { 230 - token, _ := r.Context().Value(UserAccessToken).(string) 287 + val := r.Context().Value(UserAccessToken) 288 + token, ok := val.(string) 289 + if !ok && val != nil { 290 + // SECURITY: Type assertion failed but value exists - this should never happen 291 + // Log as error since this could indicate context value corruption 292 + log.Printf("[AUTH_ERROR] GetUserAccessToken: type assertion failed, expected string, got %T (value: %v)", 293 + val, val) 294 + } 231 295 return token 232 296 } 233 297 ··· 276 340 log.Printf("Failed to write auth error response: %v", err) 277 341 } 278 342 } 343 + 344 + // DualAuthMiddleware enforces authentication using either OAuth sealed tokens (for users), 345 + // PDS service JWTs (for aggregators), or API keys (for aggregators). 346 + type DualAuthMiddleware struct { 347 + unsealer SessionUnsealer 348 + store oauthlib.ClientAuthStore 349 + serviceValidator ServiceAuthValidator 350 + aggregatorChecker AggregatorChecker 351 + apiKeyValidator APIKeyValidator // Optional: if nil, API key auth is disabled 352 + } 353 + 354 + // NewDualAuthMiddleware creates a new dual auth middleware that supports both OAuth and service JWT authentication. 355 + func NewDualAuthMiddleware( 356 + unsealer SessionUnsealer, 357 + store oauthlib.ClientAuthStore, 358 + serviceValidator ServiceAuthValidator, 359 + aggregatorChecker AggregatorChecker, 360 + ) *DualAuthMiddleware { 361 + return &DualAuthMiddleware{ 362 + unsealer: unsealer, 363 + store: store, 364 + serviceValidator: serviceValidator, 365 + aggregatorChecker: aggregatorChecker, 366 + } 367 + } 368 + 369 + // WithAPIKeyValidator adds API key validation support to the middleware. 370 + // Returns the middleware for method chaining. 371 + func (m *DualAuthMiddleware) WithAPIKeyValidator(validator APIKeyValidator) *DualAuthMiddleware { 372 + m.apiKeyValidator = validator 373 + return m 374 + } 375 + 376 + // RequireAuth middleware ensures the user is authenticated via either OAuth, service JWT, or API key. 377 + // Supports: 378 + // - API keys via Authorization: Bearer ckapi_... (aggregators only, checked first) 379 + // - OAuth sealed session tokens via Authorization: Bearer <sealed_token> or Cookie: coves_session=<sealed_token> 380 + // - Service JWTs via Authorization: Bearer <jwt> 381 + // 382 + // SECURITY: Service JWT and API key authentication are RESTRICTED to registered aggregators only. 383 + // Non-aggregator DIDs will be rejected even with valid JWT signatures or API keys. 384 + // This enforcement happens in handleServiceAuth() via aggregatorChecker.IsAggregator() and 385 + // in handleAPIKeyAuth() via apiKeyValidator.ValidateKey(). 386 + // 387 + // If not authenticated, returns 401. 388 + // If authenticated, injects user DID and auth method into context. 389 + func (m *DualAuthMiddleware) RequireAuth(next http.Handler) http.Handler { 390 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 391 + var token string 392 + var tokenSource string 393 + 394 + // Try Authorization header first (for mobile/API clients and service auth) 395 + authHeader := r.Header.Get("Authorization") 396 + if authHeader != "" { 397 + var ok bool 398 + token, ok = extractBearerToken(authHeader) 399 + if !ok { 400 + writeAuthError(w, "Invalid Authorization header format. Expected: Bearer <token>") 401 + return 402 + } 403 + tokenSource = "header" 404 + } 405 + 406 + // If no header, try session cookie (for web clients - OAuth only) 407 + if token == "" { 408 + if cookie, err := r.Cookie("coves_session"); err == nil { 409 + token = cookie.Value 410 + tokenSource = "cookie" 411 + } 412 + } 413 + 414 + // Must have authentication from either source 415 + if token == "" { 416 + writeAuthError(w, "Missing authentication") 417 + return 418 + } 419 + 420 + log.Printf("[AUTH_TRACE] ip=%s method=%s path=%s token_source=%s", 421 + r.RemoteAddr, r.Method, r.URL.Path, tokenSource) 422 + 423 + // Check for API key first (before JWT/OAuth routing) 424 + // API keys start with "ckapi_" prefix 425 + if strings.HasPrefix(token, APIKeyPrefix) { 426 + m.handleAPIKeyAuth(w, r, next, token) 427 + return 428 + } 429 + 430 + // Detect token type and route to appropriate handler 431 + if isJWTFormat(token) { 432 + m.handleServiceAuth(w, r, next, token) 433 + } else { 434 + m.handleOAuthAuth(w, r, next, token) 435 + } 436 + }) 437 + } 438 + 439 + // handleServiceAuth handles authentication using PDS service JWTs (aggregators only) 440 + func (m *DualAuthMiddleware) handleServiceAuth(w http.ResponseWriter, r *http.Request, next http.Handler, token string) { 441 + // Validate the service JWT 442 + // Note: lexMethod is nil, which allows any lexicon method (endpoint-agnostic validation). 443 + // The ServiceAuthValidator skips the lexicon method check when lexMethod is nil. 444 + // This is intentional - we want aggregators to authenticate globally, not per-endpoint. 445 + did, err := m.serviceValidator.Validate(r.Context(), token, nil) 446 + if err != nil { 447 + log.Printf("[AUTH_FAILURE] type=service_jwt_invalid ip=%s method=%s path=%s error=%v", 448 + r.RemoteAddr, r.Method, r.URL.Path, err) 449 + writeAuthError(w, "Invalid or expired service JWT") 450 + return 451 + } 452 + 453 + // Convert DID to string 454 + didStr := did.String() 455 + 456 + // Verify this DID is a registered aggregator 457 + isAggregator, err := m.aggregatorChecker.IsAggregator(r.Context(), didStr) 458 + if err != nil { 459 + log.Printf("[AUTH_FAILURE] type=aggregator_check_failed ip=%s method=%s path=%s did=%s error=%v", 460 + r.RemoteAddr, r.Method, r.URL.Path, didStr, err) 461 + writeAuthError(w, "Failed to verify aggregator status") 462 + return 463 + } 464 + 465 + if !isAggregator { 466 + log.Printf("[AUTH_FAILURE] type=not_aggregator ip=%s method=%s path=%s did=%s", 467 + r.RemoteAddr, r.Method, r.URL.Path, didStr) 468 + writeAuthError(w, "Not a registered aggregator") 469 + return 470 + } 471 + 472 + log.Printf("[AUTH_SUCCESS] type=service_jwt ip=%s method=%s path=%s did=%s", 473 + r.RemoteAddr, r.Method, r.URL.Path, didStr) 474 + 475 + // Inject DID and auth method into context 476 + ctx := context.WithValue(r.Context(), UserDIDKey, didStr) 477 + ctx = context.WithValue(ctx, IsAggregatorAuthKey, true) 478 + ctx = context.WithValue(ctx, AuthMethodKey, AuthMethodServiceJWT) 479 + 480 + // Call next handler 481 + next.ServeHTTP(w, r.WithContext(ctx)) 482 + } 483 + 484 + // handleAPIKeyAuth handles authentication using Coves API keys (aggregators only) 485 + func (m *DualAuthMiddleware) handleAPIKeyAuth(w http.ResponseWriter, r *http.Request, next http.Handler, token string) { 486 + // Check if API key validation is enabled 487 + if m.apiKeyValidator == nil { 488 + log.Printf("[AUTH_FAILURE] type=api_key_disabled ip=%s method=%s path=%s", 489 + r.RemoteAddr, r.Method, r.URL.Path) 490 + writeAuthError(w, "API key authentication is not enabled") 491 + return 492 + } 493 + 494 + // Validate the API key 495 + aggregatorDID, err := m.apiKeyValidator.ValidateKey(r.Context(), token) 496 + if err != nil { 497 + log.Printf("[AUTH_FAILURE] type=api_key_invalid ip=%s method=%s path=%s error=%v", 498 + r.RemoteAddr, r.Method, r.URL.Path, err) 499 + writeAuthError(w, "Invalid or revoked API key") 500 + return 501 + } 502 + 503 + // Refresh OAuth tokens if needed (for PDS operations) 504 + if err := m.apiKeyValidator.RefreshTokensIfNeeded(r.Context(), aggregatorDID); err != nil { 505 + log.Printf("[AUTH_FAILURE] type=token_refresh_failed ip=%s method=%s path=%s did=%s error=%v", 506 + r.RemoteAddr, r.Method, r.URL.Path, aggregatorDID, err) 507 + // Token refresh failure means the aggregator cannot perform authenticated PDS operations 508 + // This is a critical failure - reject the request so the aggregator knows to re-authenticate 509 + writeAuthError(w, "API key authentication failed: unable to refresh OAuth tokens. Please re-authenticate.") 510 + return 511 + } 512 + 513 + log.Printf("[AUTH_SUCCESS] type=api_key ip=%s method=%s path=%s did=%s", 514 + r.RemoteAddr, r.Method, r.URL.Path, aggregatorDID) 515 + 516 + // Inject DID and auth method into context 517 + ctx := context.WithValue(r.Context(), UserDIDKey, aggregatorDID) 518 + ctx = context.WithValue(ctx, IsAggregatorAuthKey, true) 519 + ctx = context.WithValue(ctx, AuthMethodKey, AuthMethodAPIKey) 520 + 521 + // Call next handler 522 + next.ServeHTTP(w, r.WithContext(ctx)) 523 + } 524 + 525 + // handleOAuthAuth handles authentication using OAuth sealed session tokens (existing logic) 526 + func (m *DualAuthMiddleware) handleOAuthAuth(w http.ResponseWriter, r *http.Request, next http.Handler, token string) { 527 + // Authenticate using sealed token 528 + sealedSession, err := m.unsealer.UnsealSession(token) 529 + if err != nil { 530 + log.Printf("[AUTH_FAILURE] type=unseal_failed ip=%s method=%s path=%s error=%v", 531 + r.RemoteAddr, r.Method, r.URL.Path, err) 532 + writeAuthError(w, "Invalid or expired token") 533 + return 534 + } 535 + 536 + // Parse DID 537 + did, err := syntax.ParseDID(sealedSession.DID) 538 + if err != nil { 539 + log.Printf("[AUTH_FAILURE] type=invalid_did ip=%s method=%s path=%s did=%s error=%v", 540 + r.RemoteAddr, r.Method, r.URL.Path, sealedSession.DID, err) 541 + writeAuthError(w, "Invalid DID in token") 542 + return 543 + } 544 + 545 + // Load full OAuth session from database 546 + session, err := m.store.GetSession(r.Context(), did, sealedSession.SessionID) 547 + if err != nil { 548 + log.Printf("[AUTH_FAILURE] type=session_not_found ip=%s method=%s path=%s did=%s session_id=%s error=%v", 549 + r.RemoteAddr, r.Method, r.URL.Path, sealedSession.DID, sealedSession.SessionID, err) 550 + writeAuthError(w, "Session not found or expired") 551 + return 552 + } 553 + 554 + // Verify session DID matches token DID 555 + if session.AccountDID.String() != sealedSession.DID { 556 + log.Printf("[AUTH_FAILURE] type=did_mismatch ip=%s method=%s path=%s token_did=%s session_did=%s", 557 + r.RemoteAddr, r.Method, r.URL.Path, sealedSession.DID, session.AccountDID.String()) 558 + writeAuthError(w, "Session DID mismatch") 559 + return 560 + } 561 + 562 + log.Printf("[AUTH_SUCCESS] type=oauth ip=%s method=%s path=%s did=%s session_id=%s", 563 + r.RemoteAddr, r.Method, r.URL.Path, sealedSession.DID, sealedSession.SessionID) 564 + 565 + // Inject user info and session into context 566 + ctx := context.WithValue(r.Context(), UserDIDKey, sealedSession.DID) 567 + ctx = context.WithValue(ctx, OAuthSessionKey, session) 568 + ctx = context.WithValue(ctx, UserAccessToken, session.AccessToken) 569 + ctx = context.WithValue(ctx, IsAggregatorAuthKey, false) 570 + ctx = context.WithValue(ctx, AuthMethodKey, AuthMethodOAuth) 571 + 572 + // Call next handler 573 + next.ServeHTTP(w, r.WithContext(ctx)) 574 + } 575 + 576 + // isJWTFormat checks if a token has JWT format (three parts separated by dots). 577 + // NOTE: This is a format heuristic for routing, not security validation. 578 + // Actual JWT signature verification happens in ServiceAuthValidator.Validate(). 579 + func isJWTFormat(token string) bool { 580 + parts := strings.Split(token, ".") 581 + if len(parts) != 3 { 582 + return false 583 + } 584 + // Ensure all parts are non-empty to prevent misrouting crafted tokens like ".."" 585 + return parts[0] != "" && parts[1] != "" && parts[2] != "" 586 + } 587 + 588 + // IsAggregatorAuth checks if the current request was authenticated using aggregator service JWT 589 + func IsAggregatorAuth(r *http.Request) bool { 590 + val := r.Context().Value(IsAggregatorAuthKey) 591 + isAggregator, ok := val.(bool) 592 + if !ok && val != nil { 593 + // SECURITY: Type assertion failed but value exists - this should never happen 594 + // Log as error since this could indicate context value corruption 595 + log.Printf("[AUTH_ERROR] IsAggregatorAuth: type assertion failed, expected bool, got %T (value: %v)", 596 + val, val) 597 + } 598 + return isAggregator 599 + } 600 + 601 + // GetAuthMethod returns the authentication method used for the current request 602 + // Returns empty string if not authenticated 603 + func GetAuthMethod(r *http.Request) string { 604 + val := r.Context().Value(AuthMethodKey) 605 + method, ok := val.(string) 606 + if !ok && val != nil { 607 + // SECURITY: Type assertion failed but value exists - this should never happen 608 + // Log as error since this could indicate context value corruption 609 + log.Printf("[AUTH_ERROR] GetAuthMethod: type assertion failed, expected string, got %T (value: %v)", 610 + val, val) 611 + } 612 + return method 613 + }
+1010
internal/api/middleware/auth_test.go
··· 887 887 }) 888 888 } 889 889 } 890 + 891 + // Mock ServiceAuthValidator for testing 892 + type mockServiceAuthValidator struct { 893 + shouldFail bool 894 + returnDID syntax.DID 895 + } 896 + 897 + func (m *mockServiceAuthValidator) Validate(ctx context.Context, tokenString string, lexMethod *syntax.NSID) (syntax.DID, error) { 898 + if m.shouldFail { 899 + return "", fmt.Errorf("mock validation failure") 900 + } 901 + return m.returnDID, nil 902 + } 903 + 904 + // Mock AggregatorChecker for testing 905 + type mockAggregatorChecker struct { 906 + aggregators map[string]bool 907 + shouldFail bool 908 + } 909 + 910 + func (m *mockAggregatorChecker) IsAggregator(ctx context.Context, did string) (bool, error) { 911 + if m.shouldFail { 912 + return false, fmt.Errorf("mock aggregator check failure") 913 + } 914 + isAgg, exists := m.aggregators[did] 915 + return exists && isAgg, nil 916 + } 917 + 918 + // TestIsJWTFormat tests the JWT format detection 919 + func TestIsJWTFormat(t *testing.T) { 920 + tests := []struct { 921 + name string 922 + token string 923 + expected bool 924 + }{ 925 + {"valid JWT format", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", true}, 926 + {"simple three parts", "part1.part2.part3", true}, 927 + {"two parts only", "part1.part2", false}, 928 + {"four parts", "part1.part2.part3.part4", false}, 929 + {"no dots", "nodots", false}, 930 + {"sealed token format", "dGVzdC1zZWFsZWQtdG9rZW4", false}, 931 + } 932 + 933 + for _, tt := range tests { 934 + t.Run(tt.name, func(t *testing.T) { 935 + result := isJWTFormat(tt.token) 936 + if result != tt.expected { 937 + t.Errorf("expected %v, got %v", tt.expected, result) 938 + } 939 + }) 940 + } 941 + } 942 + 943 + // TestDualAuthMiddleware_ServiceJWT_ValidAggregator tests service JWT auth for valid aggregators 944 + func TestDualAuthMiddleware_ServiceJWT_ValidAggregator(t *testing.T) { 945 + client := newMockOAuthClient() 946 + store := newMockOAuthStore() 947 + 948 + did := syntax.DID("did:plc:aggregator123") 949 + validator := &mockServiceAuthValidator{ 950 + returnDID: did, 951 + } 952 + aggregatorChecker := &mockAggregatorChecker{ 953 + aggregators: map[string]bool{ 954 + "did:plc:aggregator123": true, 955 + }, 956 + } 957 + 958 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 959 + 960 + handlerCalled := false 961 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 962 + handlerCalled = true 963 + 964 + // Verify DID was extracted 965 + extractedDID := GetUserDID(r) 966 + if extractedDID != "did:plc:aggregator123" { 967 + t.Errorf("expected DID 'did:plc:aggregator123', got %s", extractedDID) 968 + } 969 + 970 + // Verify it's marked as aggregator auth 971 + if !IsAggregatorAuth(r) { 972 + t.Error("expected IsAggregatorAuth to be true") 973 + } 974 + 975 + // Verify auth method 976 + authMethod := GetAuthMethod(r) 977 + if authMethod != AuthMethodServiceJWT { 978 + t.Errorf("expected auth method %s, got %s", AuthMethodServiceJWT, authMethod) 979 + } 980 + 981 + w.WriteHeader(http.StatusOK) 982 + })) 983 + 984 + req := httptest.NewRequest("GET", "/test", nil) 985 + req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U") 986 + w := httptest.NewRecorder() 987 + 988 + handler.ServeHTTP(w, req) 989 + 990 + if !handlerCalled { 991 + t.Error("handler was not called") 992 + } 993 + 994 + if w.Code != http.StatusOK { 995 + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) 996 + } 997 + } 998 + 999 + // TestDualAuthMiddleware_ServiceJWT_InvalidJWT tests service JWT auth with invalid JWT 1000 + func TestDualAuthMiddleware_ServiceJWT_InvalidJWT(t *testing.T) { 1001 + client := newMockOAuthClient() 1002 + store := newMockOAuthStore() 1003 + 1004 + validator := &mockServiceAuthValidator{ 1005 + shouldFail: true, 1006 + } 1007 + aggregatorChecker := &mockAggregatorChecker{ 1008 + aggregators: make(map[string]bool), 1009 + } 1010 + 1011 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1012 + 1013 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1014 + t.Error("handler should not be called") 1015 + })) 1016 + 1017 + req := httptest.NewRequest("GET", "/test", nil) 1018 + req.Header.Set("Authorization", "Bearer invalid.jwt.token") 1019 + w := httptest.NewRecorder() 1020 + 1021 + handler.ServeHTTP(w, req) 1022 + 1023 + if w.Code != http.StatusUnauthorized { 1024 + t.Errorf("expected status 401, got %d", w.Code) 1025 + } 1026 + 1027 + var response map[string]string 1028 + _ = json.Unmarshal(w.Body.Bytes(), &response) 1029 + if response["message"] != "Invalid or expired service JWT" { 1030 + t.Errorf("unexpected error message: %s", response["message"]) 1031 + } 1032 + } 1033 + 1034 + // TestDualAuthMiddleware_ServiceJWT_NotAggregator tests service JWT auth with non-aggregator DID 1035 + func TestDualAuthMiddleware_ServiceJWT_NotAggregator(t *testing.T) { 1036 + client := newMockOAuthClient() 1037 + store := newMockOAuthStore() 1038 + 1039 + did := syntax.DID("did:plc:regularuser") 1040 + validator := &mockServiceAuthValidator{ 1041 + returnDID: did, 1042 + } 1043 + aggregatorChecker := &mockAggregatorChecker{ 1044 + aggregators: map[string]bool{ 1045 + "did:plc:aggregator123": true, 1046 + }, 1047 + } 1048 + 1049 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1050 + 1051 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1052 + t.Error("handler should not be called") 1053 + })) 1054 + 1055 + req := httptest.NewRequest("GET", "/test", nil) 1056 + req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U") 1057 + w := httptest.NewRecorder() 1058 + 1059 + handler.ServeHTTP(w, req) 1060 + 1061 + if w.Code != http.StatusUnauthorized { 1062 + t.Errorf("expected status 401, got %d", w.Code) 1063 + } 1064 + 1065 + var response map[string]string 1066 + _ = json.Unmarshal(w.Body.Bytes(), &response) 1067 + if response["message"] != "Not a registered aggregator" { 1068 + t.Errorf("unexpected error message: %s", response["message"]) 1069 + } 1070 + } 1071 + 1072 + // TestDualAuthMiddleware_ServiceJWT_AggregatorCheckFailure tests service JWT auth when aggregator check fails 1073 + func TestDualAuthMiddleware_ServiceJWT_AggregatorCheckFailure(t *testing.T) { 1074 + client := newMockOAuthClient() 1075 + store := newMockOAuthStore() 1076 + 1077 + did := syntax.DID("did:plc:aggregator123") 1078 + validator := &mockServiceAuthValidator{ 1079 + returnDID: did, 1080 + } 1081 + aggregatorChecker := &mockAggregatorChecker{ 1082 + shouldFail: true, 1083 + } 1084 + 1085 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1086 + 1087 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1088 + t.Error("handler should not be called") 1089 + })) 1090 + 1091 + req := httptest.NewRequest("GET", "/test", nil) 1092 + req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U") 1093 + w := httptest.NewRecorder() 1094 + 1095 + handler.ServeHTTP(w, req) 1096 + 1097 + if w.Code != http.StatusUnauthorized { 1098 + t.Errorf("expected status 401, got %d", w.Code) 1099 + } 1100 + 1101 + var response map[string]string 1102 + _ = json.Unmarshal(w.Body.Bytes(), &response) 1103 + if response["message"] != "Failed to verify aggregator status" { 1104 + t.Errorf("unexpected error message: %s", response["message"]) 1105 + } 1106 + } 1107 + 1108 + // TestDualAuthMiddleware_OAuth_ValidToken tests OAuth auth path through dual middleware 1109 + func TestDualAuthMiddleware_OAuth_ValidToken(t *testing.T) { 1110 + client := newMockOAuthClient() 1111 + store := newMockOAuthStore() 1112 + 1113 + // Create a test session 1114 + did := syntax.DID("did:plc:user123") 1115 + sessionID := "session123" 1116 + session := &oauthlib.ClientSessionData{ 1117 + AccountDID: did, 1118 + SessionID: sessionID, 1119 + AccessToken: "test_access_token", 1120 + HostURL: "https://pds.example.com", 1121 + } 1122 + _ = store.SaveSession(context.Background(), *session) 1123 + 1124 + validator := &mockServiceAuthValidator{} 1125 + aggregatorChecker := &mockAggregatorChecker{ 1126 + aggregators: make(map[string]bool), 1127 + } 1128 + 1129 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1130 + 1131 + handlerCalled := false 1132 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1133 + handlerCalled = true 1134 + 1135 + // Verify DID was extracted 1136 + extractedDID := GetUserDID(r) 1137 + if extractedDID != "did:plc:user123" { 1138 + t.Errorf("expected DID 'did:plc:user123', got %s", extractedDID) 1139 + } 1140 + 1141 + // Verify it's NOT marked as aggregator auth 1142 + if IsAggregatorAuth(r) { 1143 + t.Error("expected IsAggregatorAuth to be false") 1144 + } 1145 + 1146 + // Verify auth method 1147 + authMethod := GetAuthMethod(r) 1148 + if authMethod != AuthMethodOAuth { 1149 + t.Errorf("expected auth method %s, got %s", AuthMethodOAuth, authMethod) 1150 + } 1151 + 1152 + // Verify OAuth session is available 1153 + oauthSession := GetOAuthSession(r) 1154 + if oauthSession == nil { 1155 + t.Error("expected OAuth session to be non-nil") 1156 + } 1157 + 1158 + w.WriteHeader(http.StatusOK) 1159 + })) 1160 + 1161 + token := client.createTestToken("did:plc:user123", sessionID, time.Hour) 1162 + req := httptest.NewRequest("GET", "/test", nil) 1163 + req.Header.Set("Authorization", "Bearer "+token) 1164 + w := httptest.NewRecorder() 1165 + 1166 + handler.ServeHTTP(w, req) 1167 + 1168 + if !handlerCalled { 1169 + t.Error("handler was not called") 1170 + } 1171 + 1172 + if w.Code != http.StatusOK { 1173 + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) 1174 + } 1175 + } 1176 + 1177 + // TestDualAuthMiddleware_OAuth_Cookie tests OAuth auth via cookie through dual middleware 1178 + func TestDualAuthMiddleware_OAuth_Cookie(t *testing.T) { 1179 + client := newMockOAuthClient() 1180 + store := newMockOAuthStore() 1181 + 1182 + // Create a test session 1183 + did := syntax.DID("did:plc:user123") 1184 + sessionID := "session123" 1185 + session := &oauthlib.ClientSessionData{ 1186 + AccountDID: did, 1187 + SessionID: sessionID, 1188 + AccessToken: "test_access_token", 1189 + } 1190 + _ = store.SaveSession(context.Background(), *session) 1191 + 1192 + validator := &mockServiceAuthValidator{} 1193 + aggregatorChecker := &mockAggregatorChecker{ 1194 + aggregators: make(map[string]bool), 1195 + } 1196 + 1197 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1198 + 1199 + handlerCalled := false 1200 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1201 + handlerCalled = true 1202 + 1203 + // Verify DID was extracted 1204 + extractedDID := GetUserDID(r) 1205 + if extractedDID != "did:plc:user123" { 1206 + t.Errorf("expected DID 'did:plc:user123', got %s", extractedDID) 1207 + } 1208 + 1209 + // Verify auth method 1210 + authMethod := GetAuthMethod(r) 1211 + if authMethod != AuthMethodOAuth { 1212 + t.Errorf("expected auth method %s, got %s", AuthMethodOAuth, authMethod) 1213 + } 1214 + 1215 + w.WriteHeader(http.StatusOK) 1216 + })) 1217 + 1218 + token := client.createTestToken("did:plc:user123", sessionID, time.Hour) 1219 + req := httptest.NewRequest("GET", "/test", nil) 1220 + req.AddCookie(&http.Cookie{ 1221 + Name: "coves_session", 1222 + Value: token, 1223 + }) 1224 + w := httptest.NewRecorder() 1225 + 1226 + handler.ServeHTTP(w, req) 1227 + 1228 + if !handlerCalled { 1229 + t.Error("handler was not called") 1230 + } 1231 + 1232 + if w.Code != http.StatusOK { 1233 + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) 1234 + } 1235 + } 1236 + 1237 + // TestDualAuthMiddleware_MissingAuth tests that missing auth is rejected 1238 + func TestDualAuthMiddleware_MissingAuth(t *testing.T) { 1239 + client := newMockOAuthClient() 1240 + store := newMockOAuthStore() 1241 + validator := &mockServiceAuthValidator{} 1242 + aggregatorChecker := &mockAggregatorChecker{ 1243 + aggregators: make(map[string]bool), 1244 + } 1245 + 1246 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1247 + 1248 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1249 + t.Error("handler should not be called") 1250 + })) 1251 + 1252 + req := httptest.NewRequest("GET", "/test", nil) 1253 + w := httptest.NewRecorder() 1254 + 1255 + handler.ServeHTTP(w, req) 1256 + 1257 + if w.Code != http.StatusUnauthorized { 1258 + t.Errorf("expected status 401, got %d", w.Code) 1259 + } 1260 + } 1261 + 1262 + // TestIsAggregatorAuth_NotAuthenticated tests IsAggregatorAuth with no auth 1263 + func TestIsAggregatorAuth_NotAuthenticated(t *testing.T) { 1264 + req := httptest.NewRequest("GET", "/test", nil) 1265 + isAgg := IsAggregatorAuth(req) 1266 + 1267 + if isAgg { 1268 + t.Error("expected false for unauthenticated request") 1269 + } 1270 + } 1271 + 1272 + // TestGetAuthMethod_NotAuthenticated tests GetAuthMethod with no auth 1273 + func TestGetAuthMethod_NotAuthenticated(t *testing.T) { 1274 + req := httptest.NewRequest("GET", "/test", nil) 1275 + method := GetAuthMethod(req) 1276 + 1277 + if method != "" { 1278 + t.Errorf("expected empty string, got %s", method) 1279 + } 1280 + } 1281 + 1282 + // TestDualAuthMiddleware_MalformedToken tests that tokens that are neither valid OAuth nor JWT are rejected 1283 + func TestDualAuthMiddleware_MalformedToken(t *testing.T) { 1284 + client := newMockOAuthClient() 1285 + store := newMockOAuthStore() 1286 + validator := &mockServiceAuthValidator{} 1287 + aggregatorChecker := &mockAggregatorChecker{ 1288 + aggregators: make(map[string]bool), 1289 + } 1290 + 1291 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1292 + 1293 + tests := []struct { 1294 + name string 1295 + token string 1296 + }{ 1297 + {"random string", "not-valid-token"}, 1298 + {"base64 garbage", "dGhpcyBpcyBub3QgYSB2YWxpZCB0b2tlbg=="}, 1299 + {"partial JWT", "header.payload"}, 1300 + {"too many dots", "part1.part2.part3.part4"}, 1301 + } 1302 + 1303 + for _, tt := range tests { 1304 + t.Run(tt.name, func(t *testing.T) { 1305 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1306 + t.Error("handler should not be called for malformed token") 1307 + })) 1308 + 1309 + req := httptest.NewRequest("GET", "/test", nil) 1310 + req.Header.Set("Authorization", "Bearer "+tt.token) 1311 + w := httptest.NewRecorder() 1312 + 1313 + handler.ServeHTTP(w, req) 1314 + 1315 + if w.Code != http.StatusUnauthorized { 1316 + t.Errorf("expected status 401, got %d", w.Code) 1317 + } 1318 + }) 1319 + } 1320 + } 1321 + 1322 + // TestDualAuthMiddleware_OAuth_ExpiredToken tests that OAuth expired tokens are rejected 1323 + func TestDualAuthMiddleware_OAuth_ExpiredToken(t *testing.T) { 1324 + client := newMockOAuthClient() 1325 + store := newMockOAuthStore() 1326 + 1327 + // Create a test session 1328 + did := syntax.DID("did:plc:user123") 1329 + sessionID := "session123" 1330 + session := &oauthlib.ClientSessionData{ 1331 + AccountDID: did, 1332 + SessionID: sessionID, 1333 + AccessToken: "test_access_token", 1334 + } 1335 + _ = store.SaveSession(context.Background(), *session) 1336 + 1337 + validator := &mockServiceAuthValidator{} 1338 + aggregatorChecker := &mockAggregatorChecker{ 1339 + aggregators: make(map[string]bool), 1340 + } 1341 + 1342 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1343 + 1344 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1345 + t.Error("handler should not be called for expired token") 1346 + })) 1347 + 1348 + // Create expired token (expired 1 hour ago) 1349 + token := client.createTestToken("did:plc:user123", sessionID, -time.Hour) 1350 + 1351 + req := httptest.NewRequest("GET", "/test", nil) 1352 + req.Header.Set("Authorization", "Bearer "+token) 1353 + w := httptest.NewRecorder() 1354 + 1355 + handler.ServeHTTP(w, req) 1356 + 1357 + if w.Code != http.StatusUnauthorized { 1358 + t.Errorf("expected status 401, got %d", w.Code) 1359 + } 1360 + } 1361 + 1362 + // TestDualAuthMiddleware_OAuth_SessionNotFound tests that OAuth tokens with non-existent sessions are rejected 1363 + func TestDualAuthMiddleware_OAuth_SessionNotFound(t *testing.T) { 1364 + client := newMockOAuthClient() 1365 + store := newMockOAuthStore() 1366 + 1367 + validator := &mockServiceAuthValidator{} 1368 + aggregatorChecker := &mockAggregatorChecker{ 1369 + aggregators: make(map[string]bool), 1370 + } 1371 + 1372 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1373 + 1374 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1375 + t.Error("handler should not be called for non-existent session") 1376 + })) 1377 + 1378 + // Create token for session that doesn't exist in store 1379 + token := client.createTestToken("did:plc:nonexistent", "session999", time.Hour) 1380 + 1381 + req := httptest.NewRequest("GET", "/test", nil) 1382 + req.Header.Set("Authorization", "Bearer "+token) 1383 + w := httptest.NewRecorder() 1384 + 1385 + handler.ServeHTTP(w, req) 1386 + 1387 + if w.Code != http.StatusUnauthorized { 1388 + t.Errorf("expected status 401, got %d", w.Code) 1389 + } 1390 + } 1391 + 1392 + // TestHelperFunctions_WithOAuthContext tests helper functions with OAuth authenticated context 1393 + func TestHelperFunctions_WithOAuthContext(t *testing.T) { 1394 + client := newMockOAuthClient() 1395 + store := newMockOAuthStore() 1396 + 1397 + // Create a test session 1398 + did := syntax.DID("did:plc:user123") 1399 + sessionID := "session123" 1400 + session := &oauthlib.ClientSessionData{ 1401 + AccountDID: did, 1402 + SessionID: sessionID, 1403 + AccessToken: "test_access_token", 1404 + } 1405 + _ = store.SaveSession(context.Background(), *session) 1406 + 1407 + validator := &mockServiceAuthValidator{} 1408 + aggregatorChecker := &mockAggregatorChecker{ 1409 + aggregators: make(map[string]bool), 1410 + } 1411 + 1412 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1413 + 1414 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1415 + // Test IsAggregatorAuth - should be false for OAuth 1416 + if IsAggregatorAuth(r) { 1417 + t.Error("IsAggregatorAuth should return false for OAuth") 1418 + } 1419 + 1420 + // Test GetAuthMethod - should be "oauth" 1421 + authMethod := GetAuthMethod(r) 1422 + if authMethod != AuthMethodOAuth { 1423 + t.Errorf("GetAuthMethod should return %s, got %s", AuthMethodOAuth, authMethod) 1424 + } 1425 + 1426 + // Test GetUserDID 1427 + userDID := GetUserDID(r) 1428 + if userDID != "did:plc:user123" { 1429 + t.Errorf("GetUserDID should return did:plc:user123, got %s", userDID) 1430 + } 1431 + 1432 + w.WriteHeader(http.StatusOK) 1433 + })) 1434 + 1435 + token := client.createTestToken("did:plc:user123", sessionID, time.Hour) 1436 + req := httptest.NewRequest("GET", "/test", nil) 1437 + req.Header.Set("Authorization", "Bearer "+token) 1438 + w := httptest.NewRecorder() 1439 + 1440 + handler.ServeHTTP(w, req) 1441 + 1442 + if w.Code != http.StatusOK { 1443 + t.Errorf("expected status 200, got %d", w.Code) 1444 + } 1445 + } 1446 + 1447 + // TestHelperFunctions_WithServiceJWTContext tests helper functions with service JWT authenticated context 1448 + func TestHelperFunctions_WithServiceJWTContext(t *testing.T) { 1449 + client := newMockOAuthClient() 1450 + store := newMockOAuthStore() 1451 + 1452 + did := syntax.DID("did:plc:aggregator123") 1453 + validator := &mockServiceAuthValidator{ 1454 + returnDID: did, 1455 + } 1456 + aggregatorChecker := &mockAggregatorChecker{ 1457 + aggregators: map[string]bool{ 1458 + "did:plc:aggregator123": true, 1459 + }, 1460 + } 1461 + 1462 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1463 + 1464 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1465 + // Test IsAggregatorAuth - should be true for service JWT 1466 + if !IsAggregatorAuth(r) { 1467 + t.Error("IsAggregatorAuth should return true for service JWT") 1468 + } 1469 + 1470 + // Test GetAuthMethod - should be "service_jwt" 1471 + authMethod := GetAuthMethod(r) 1472 + if authMethod != AuthMethodServiceJWT { 1473 + t.Errorf("GetAuthMethod should return %s, got %s", AuthMethodServiceJWT, authMethod) 1474 + } 1475 + 1476 + // Test GetUserDID 1477 + userDID := GetUserDID(r) 1478 + if userDID != "did:plc:aggregator123" { 1479 + t.Errorf("GetUserDID should return did:plc:aggregator123, got %s", userDID) 1480 + } 1481 + 1482 + // OAuth-specific helpers should return empty/nil for service JWT 1483 + oauthSession := GetOAuthSession(r) 1484 + if oauthSession != nil { 1485 + t.Error("GetOAuthSession should return nil for service JWT auth") 1486 + } 1487 + 1488 + accessToken := GetUserAccessToken(r) 1489 + if accessToken != "" { 1490 + t.Error("GetUserAccessToken should return empty string for service JWT auth") 1491 + } 1492 + 1493 + w.WriteHeader(http.StatusOK) 1494 + })) 1495 + 1496 + req := httptest.NewRequest("GET", "/test", nil) 1497 + req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U") 1498 + w := httptest.NewRecorder() 1499 + 1500 + handler.ServeHTTP(w, req) 1501 + 1502 + if w.Code != http.StatusOK { 1503 + t.Errorf("expected status 200, got %d", w.Code) 1504 + } 1505 + } 1506 + 1507 + // TestGetAuthenticatedDID tests the context-based DID extraction used by service layers 1508 + func TestGetAuthenticatedDID(t *testing.T) { 1509 + t.Run("returns DID when authenticated", func(t *testing.T) { 1510 + ctx := context.WithValue(context.Background(), UserDIDKey, "did:plc:test123") 1511 + did := GetAuthenticatedDID(ctx) 1512 + if did != "did:plc:test123" { 1513 + t.Errorf("expected did:plc:test123, got %s", did) 1514 + } 1515 + }) 1516 + 1517 + t.Run("returns empty string when not authenticated", func(t *testing.T) { 1518 + ctx := context.Background() 1519 + did := GetAuthenticatedDID(ctx) 1520 + if did != "" { 1521 + t.Errorf("expected empty string, got %s", did) 1522 + } 1523 + }) 1524 + 1525 + t.Run("returns empty string when wrong type in context", func(t *testing.T) { 1526 + ctx := context.WithValue(context.Background(), UserDIDKey, 12345) // wrong type 1527 + did := GetAuthenticatedDID(ctx) 1528 + if did != "" { 1529 + t.Errorf("expected empty string for wrong type, got %s", did) 1530 + } 1531 + }) 1532 + } 1533 + 1534 + // TestDualAuthMiddleware_OAuth_DIDMismatch tests the DID mismatch case in dual auth OAuth path 1535 + func TestDualAuthMiddleware_OAuth_DIDMismatch(t *testing.T) { 1536 + mockClient := newMockOAuthClient() 1537 + mockStore := newMockOAuthStore() 1538 + 1539 + // Create a session where the stored AccountDID differs from what the token claims 1540 + // Token will claim did:plc:token_did, but session.AccountDID will be different 1541 + sessionKey := "did:plc:token_did:session123" // GetSession looks up by "did:sessionID" 1542 + mockStore.sessions[sessionKey] = &oauthlib.ClientSessionData{ 1543 + AccountDID: syntax.DID("did:plc:different_did"), // Mismatch with token DID! 1544 + SessionID: "session123", 1545 + AccessToken: "test_token", 1546 + } 1547 + 1548 + mockValidator := &mockServiceAuthValidator{} 1549 + mockAggChecker := &mockAggregatorChecker{} 1550 + 1551 + middleware := NewDualAuthMiddleware(mockClient, mockStore, mockValidator, mockAggChecker) 1552 + 1553 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1554 + t.Error("handler should not be called for DID mismatch") 1555 + })) 1556 + 1557 + // Create token with did:plc:token_did - this will be checked against session.AccountDID 1558 + token := mockClient.createTestToken("did:plc:token_did", "session123", time.Hour) 1559 + req := httptest.NewRequest("GET", "/test", nil) 1560 + req.Header.Set("Authorization", "Bearer "+token) 1561 + w := httptest.NewRecorder() 1562 + 1563 + handler.ServeHTTP(w, req) 1564 + 1565 + if w.Code != http.StatusUnauthorized { 1566 + t.Errorf("expected status 401 for DID mismatch, got %d", w.Code) 1567 + } 1568 + 1569 + var response map[string]string 1570 + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { 1571 + t.Fatalf("failed to decode response: %v", err) 1572 + } 1573 + 1574 + if response["message"] != "Session DID mismatch" { 1575 + t.Errorf("expected 'Session DID mismatch' message, got %s", response["message"]) 1576 + } 1577 + } 1578 + 1579 + // TestDualAuthMiddleware_HeaderPrecedenceOverCookie tests that Authorization header takes precedence over cookie 1580 + // This prevents cookie injection attacks by ensuring explicit headers are prioritized 1581 + func TestDualAuthMiddleware_HeaderPrecedenceOverCookie(t *testing.T) { 1582 + client := newMockOAuthClient() 1583 + store := newMockOAuthStore() 1584 + 1585 + // Create two test sessions 1586 + did1 := syntax.DID("did:plc:header") 1587 + sessionID1 := "session_header" 1588 + session1 := &oauthlib.ClientSessionData{ 1589 + AccountDID: did1, 1590 + SessionID: sessionID1, 1591 + AccessToken: "header_token", 1592 + HostURL: "https://pds.example.com", 1593 + } 1594 + _ = store.SaveSession(context.Background(), *session1) 1595 + 1596 + did2 := syntax.DID("did:plc:cookie") 1597 + sessionID2 := "session_cookie" 1598 + session2 := &oauthlib.ClientSessionData{ 1599 + AccountDID: did2, 1600 + SessionID: sessionID2, 1601 + AccessToken: "cookie_token", 1602 + HostURL: "https://pds.example.com", 1603 + } 1604 + _ = store.SaveSession(context.Background(), *session2) 1605 + 1606 + validator := &mockServiceAuthValidator{} 1607 + aggregatorChecker := &mockAggregatorChecker{ 1608 + aggregators: make(map[string]bool), 1609 + } 1610 + 1611 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1612 + 1613 + handlerCalled := false 1614 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1615 + handlerCalled = true 1616 + 1617 + // Should get header DID, not cookie DID 1618 + extractedDID := GetUserDID(r) 1619 + if extractedDID != "did:plc:header" { 1620 + t.Errorf("expected header DID 'did:plc:header', got %s", extractedDID) 1621 + } 1622 + 1623 + // Verify auth method is OAuth (not service JWT) 1624 + authMethod := GetAuthMethod(r) 1625 + if authMethod != AuthMethodOAuth { 1626 + t.Errorf("expected auth method %s, got %s", AuthMethodOAuth, authMethod) 1627 + } 1628 + 1629 + w.WriteHeader(http.StatusOK) 1630 + })) 1631 + 1632 + headerToken := client.createTestToken("did:plc:header", sessionID1, time.Hour) 1633 + cookieToken := client.createTestToken("did:plc:cookie", sessionID2, time.Hour) 1634 + 1635 + req := httptest.NewRequest("GET", "/test", nil) 1636 + req.Header.Set("Authorization", "Bearer "+headerToken) 1637 + req.AddCookie(&http.Cookie{ 1638 + Name: "coves_session", 1639 + Value: cookieToken, 1640 + }) 1641 + w := httptest.NewRecorder() 1642 + 1643 + handler.ServeHTTP(w, req) 1644 + 1645 + if !handlerCalled { 1646 + t.Error("handler was not called") 1647 + } 1648 + 1649 + if w.Code != http.StatusOK { 1650 + t.Errorf("expected status 200, got %d", w.Code) 1651 + } 1652 + } 1653 + 1654 + // TestDualAuthMiddleware_InvalidAuthHeaderFormat tests that non-Bearer schemes and malformed headers are rejected 1655 + func TestDualAuthMiddleware_InvalidAuthHeaderFormat(t *testing.T) { 1656 + client := newMockOAuthClient() 1657 + store := newMockOAuthStore() 1658 + validator := &mockServiceAuthValidator{} 1659 + aggregatorChecker := &mockAggregatorChecker{ 1660 + aggregators: make(map[string]bool), 1661 + } 1662 + 1663 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1664 + 1665 + tests := []struct { 1666 + name string 1667 + header string 1668 + }{ 1669 + {"Basic auth", "Basic dXNlcjpwYXNz"}, 1670 + {"DPoP scheme", "DPoP some-token"}, 1671 + {"Invalid format", "InvalidFormat"}, 1672 + {"No space", "Bearertoken"}, 1673 + {"Missing token", "Bearer "}, 1674 + } 1675 + 1676 + for _, tt := range tests { 1677 + t.Run(tt.name, func(t *testing.T) { 1678 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1679 + t.Error("handler should not be called for invalid auth header format") 1680 + })) 1681 + 1682 + req := httptest.NewRequest("GET", "/test", nil) 1683 + req.Header.Set("Authorization", tt.header) 1684 + w := httptest.NewRecorder() 1685 + 1686 + handler.ServeHTTP(w, req) 1687 + 1688 + if w.Code != http.StatusUnauthorized { 1689 + t.Errorf("expected status 401 for header %q, got %d", tt.header, w.Code) 1690 + } 1691 + }) 1692 + } 1693 + } 1694 + 1695 + // Mock APIKeyValidator for testing 1696 + type mockAPIKeyValidator struct { 1697 + aggregators map[string]string // key -> DID 1698 + shouldFail bool 1699 + refreshCalled bool 1700 + } 1701 + 1702 + func (m *mockAPIKeyValidator) ValidateKey(ctx context.Context, plainKey string) (string, error) { 1703 + if m.shouldFail { 1704 + return "", fmt.Errorf("invalid API key") 1705 + } 1706 + // Extract DID from key for testing (real implementation would hash and look up) 1707 + // Test format: ckapi_<did_suffix>_rest 1708 + if len(plainKey) < 12 { 1709 + return "", fmt.Errorf("invalid key format") 1710 + } 1711 + // For testing, assume valid keys return a known aggregator DID 1712 + if aggregatorDID, ok := m.aggregators[plainKey]; ok { 1713 + return aggregatorDID, nil 1714 + } 1715 + return "", fmt.Errorf("unknown API key") 1716 + } 1717 + 1718 + func (m *mockAPIKeyValidator) RefreshTokensIfNeeded(ctx context.Context, aggregatorDID string) error { 1719 + m.refreshCalled = true 1720 + return nil 1721 + } 1722 + 1723 + // TestDualAuthMiddleware_APIKey_Valid tests API key authentication 1724 + func TestDualAuthMiddleware_APIKey_Valid(t *testing.T) { 1725 + client := newMockOAuthClient() 1726 + store := newMockOAuthStore() 1727 + validator := &mockServiceAuthValidator{} 1728 + aggregatorChecker := &mockAggregatorChecker{ 1729 + aggregators: make(map[string]bool), 1730 + } 1731 + 1732 + apiKeyValidator := &mockAPIKeyValidator{ 1733 + aggregators: map[string]string{ 1734 + "ckapi_test1234567890123456789012345678": "did:plc:aggregator123", 1735 + }, 1736 + } 1737 + 1738 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker). 1739 + WithAPIKeyValidator(apiKeyValidator) 1740 + 1741 + handlerCalled := false 1742 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1743 + handlerCalled = true 1744 + 1745 + // Verify DID was extracted 1746 + extractedDID := GetUserDID(r) 1747 + if extractedDID != "did:plc:aggregator123" { 1748 + t.Errorf("expected DID 'did:plc:aggregator123', got %s", extractedDID) 1749 + } 1750 + 1751 + // Verify it's marked as aggregator auth 1752 + if !IsAggregatorAuth(r) { 1753 + t.Error("expected IsAggregatorAuth to be true") 1754 + } 1755 + 1756 + // Verify auth method 1757 + authMethod := GetAuthMethod(r) 1758 + if authMethod != AuthMethodAPIKey { 1759 + t.Errorf("expected auth method %s, got %s", AuthMethodAPIKey, authMethod) 1760 + } 1761 + 1762 + w.WriteHeader(http.StatusOK) 1763 + })) 1764 + 1765 + req := httptest.NewRequest("GET", "/test", nil) 1766 + req.Header.Set("Authorization", "Bearer ckapi_test1234567890123456789012345678") 1767 + w := httptest.NewRecorder() 1768 + 1769 + handler.ServeHTTP(w, req) 1770 + 1771 + if !handlerCalled { 1772 + t.Error("handler was not called") 1773 + } 1774 + 1775 + if w.Code != http.StatusOK { 1776 + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) 1777 + } 1778 + 1779 + // Verify token refresh was attempted 1780 + if !apiKeyValidator.refreshCalled { 1781 + t.Error("expected token refresh to be called") 1782 + } 1783 + } 1784 + 1785 + // TestDualAuthMiddleware_APIKey_Invalid tests API key authentication with invalid key 1786 + func TestDualAuthMiddleware_APIKey_Invalid(t *testing.T) { 1787 + client := newMockOAuthClient() 1788 + store := newMockOAuthStore() 1789 + validator := &mockServiceAuthValidator{} 1790 + aggregatorChecker := &mockAggregatorChecker{ 1791 + aggregators: make(map[string]bool), 1792 + } 1793 + 1794 + apiKeyValidator := &mockAPIKeyValidator{ 1795 + shouldFail: true, 1796 + } 1797 + 1798 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker). 1799 + WithAPIKeyValidator(apiKeyValidator) 1800 + 1801 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1802 + t.Error("handler should not be called for invalid API key") 1803 + })) 1804 + 1805 + req := httptest.NewRequest("GET", "/test", nil) 1806 + req.Header.Set("Authorization", "Bearer ckapi_invalid_key_12345678901234567") 1807 + w := httptest.NewRecorder() 1808 + 1809 + handler.ServeHTTP(w, req) 1810 + 1811 + if w.Code != http.StatusUnauthorized { 1812 + t.Errorf("expected status 401, got %d", w.Code) 1813 + } 1814 + 1815 + var response map[string]string 1816 + _ = json.Unmarshal(w.Body.Bytes(), &response) 1817 + if response["message"] != "Invalid or revoked API key" { 1818 + t.Errorf("unexpected error message: %s", response["message"]) 1819 + } 1820 + } 1821 + 1822 + // TestDualAuthMiddleware_APIKey_Disabled tests API key auth when validator is not configured 1823 + func TestDualAuthMiddleware_APIKey_Disabled(t *testing.T) { 1824 + client := newMockOAuthClient() 1825 + store := newMockOAuthStore() 1826 + validator := &mockServiceAuthValidator{} 1827 + aggregatorChecker := &mockAggregatorChecker{ 1828 + aggregators: make(map[string]bool), 1829 + } 1830 + 1831 + // No API key validator configured 1832 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1833 + 1834 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1835 + t.Error("handler should not be called when API key auth is disabled") 1836 + })) 1837 + 1838 + req := httptest.NewRequest("GET", "/test", nil) 1839 + req.Header.Set("Authorization", "Bearer ckapi_test1234567890123456789012345678") 1840 + w := httptest.NewRecorder() 1841 + 1842 + handler.ServeHTTP(w, req) 1843 + 1844 + if w.Code != http.StatusUnauthorized { 1845 + t.Errorf("expected status 401, got %d", w.Code) 1846 + } 1847 + 1848 + var response map[string]string 1849 + _ = json.Unmarshal(w.Body.Bytes(), &response) 1850 + if response["message"] != "API key authentication is not enabled" { 1851 + t.Errorf("unexpected error message: %s", response["message"]) 1852 + } 1853 + } 1854 + 1855 + // TestDualAuthMiddleware_APIKey_PrecedenceOverOAuth tests that API keys are detected before OAuth 1856 + func TestDualAuthMiddleware_APIKey_PrecedenceOverOAuth(t *testing.T) { 1857 + client := newMockOAuthClient() 1858 + store := newMockOAuthStore() 1859 + validator := &mockServiceAuthValidator{} 1860 + aggregatorChecker := &mockAggregatorChecker{ 1861 + aggregators: make(map[string]bool), 1862 + } 1863 + 1864 + apiKeyValidator := &mockAPIKeyValidator{ 1865 + aggregators: map[string]string{ 1866 + "ckapi_test1234567890123456789012345678": "did:plc:apikey_aggregator", 1867 + }, 1868 + } 1869 + 1870 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker). 1871 + WithAPIKeyValidator(apiKeyValidator) 1872 + 1873 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1874 + // Verify API key auth was used 1875 + authMethod := GetAuthMethod(r) 1876 + if authMethod != AuthMethodAPIKey { 1877 + t.Errorf("expected API key auth method, got %s", authMethod) 1878 + } 1879 + 1880 + // Verify DID from API key (not OAuth) 1881 + did := GetUserDID(r) 1882 + if did != "did:plc:apikey_aggregator" { 1883 + t.Errorf("expected API key aggregator DID, got %s", did) 1884 + } 1885 + 1886 + w.WriteHeader(http.StatusOK) 1887 + })) 1888 + 1889 + // Use API key format token (starts with ckapi_) 1890 + req := httptest.NewRequest("GET", "/test", nil) 1891 + req.Header.Set("Authorization", "Bearer ckapi_test1234567890123456789012345678") 1892 + w := httptest.NewRecorder() 1893 + 1894 + handler.ServeHTTP(w, req) 1895 + 1896 + if w.Code != http.StatusOK { 1897 + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) 1898 + } 1899 + }
+3 -1
internal/api/routes/post.go
··· 10 10 11 11 // RegisterPostRoutes registers post-related XRPC endpoints on the router 12 12 // Implements social.coves.community.post.* lexicon endpoints 13 - func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.OAuthAuthMiddleware) { 13 + // authMiddleware can be either OAuthAuthMiddleware or DualAuthMiddleware 14 + func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware middleware.AuthMiddleware) { 14 15 // Initialize handlers 15 16 createHandler := post.NewCreateHandler(service) 16 17 17 18 // Procedure endpoints (POST) - require authentication 18 19 // social.coves.community.post.create - create a new post in a community 20 + // Supports both OAuth (users) and service JWT (aggregators) authentication 19 21 r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.create", createHandler.HandleCreate) 20 22 21 23 // Future endpoints (Beta):
+13 -10
aggregators/kagi-news/config.example.yaml
··· 1 1 # Kagi News RSS Aggregator Configuration 2 2 3 3 # Coves API endpoint 4 - coves_api_url: "https://api.coves.social" 4 + coves_api_url: "https://coves.social" 5 + # API key is loaded from COVES_API_KEY environment variable 6 + # Get your API key from https://coves.social after OAuth login 5 7 6 8 # Feed-to-community mappings 9 + # Handle format: c-{name}.{instance} (e.g., c-worldnews.coves.social) 7 10 feeds: 8 11 - name: "World News" 9 12 url: "https://news.kagi.com/world.xml" 10 - community_handle: "world-news.coves.social" 13 + community_handle: "c-worldnews.coves.social" 14 + enabled: true 15 + 16 + - name: "US News" 17 + url: "https://news.kagi.com/usa.xml" 18 + community_handle: "c-usnews.coves.social" 11 19 enabled: true 12 20 13 21 - name: "Tech News" 14 22 url: "https://news.kagi.com/tech.xml" 15 - community_handle: "tech.coves.social" 23 + community_handle: "c-tech.coves.social" 16 24 enabled: true 17 25 18 - - name: "Business News" 19 - url: "https://news.kagi.com/business.xml" 20 - community_handle: "business.coves.social" 21 - enabled: false 22 - 23 26 - name: "Science News" 24 27 url: "https://news.kagi.com/science.xml" 25 - community_handle: "science.coves.social" 26 - enabled: false 28 + community_handle: "c-science.coves.social" 29 + enabled: true 27 30 28 31 # Logging configuration 29 32 log_level: "info" # debug, info, warning, error
+111 -7
internal/db/postgres/discover_repo.go
··· 2 2 3 3 4 4 5 + "context" 6 + "database/sql" 7 + "fmt" 8 + "time" 9 + ) 5 10 6 - 7 - 8 - 9 - 10 - 11 + type postgresDiscoverRepo struct { 11 12 12 13 } 13 14 14 15 // sortClauses maps sort types to safe SQL ORDER BY clauses 16 + // Note: Hot ranking uses (score + 1) to ensure new posts with 0 votes still appear 17 + // (otherwise 0/time_decay = 0 and they sink to the bottom) 15 18 var discoverSortClauses = map[string]string{ 16 - "hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 19 + "hot": `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 17 20 "top": `p.score DESC, p.created_at DESC, p.uri DESC`, 18 21 "new": `p.created_at DESC, p.uri DESC`, 19 22 } 20 23 21 24 // hotRankExpression for discover feed 22 - const discoverHotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 25 + // Uses (score + 1) so new posts with 0 votes still get a positive rank 26 + const discoverHotRankExpression = `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 23 27 24 28 // NewDiscoverRepository creates a new PostgreSQL discover repository 25 29 func NewDiscoverRepository(db *sql.DB, cursorSecret string) discover.Repository { 30 + 31 + 32 + 33 + 34 + 35 + // GetDiscover retrieves posts from ALL communities (public feed) 36 + func (r *postgresDiscoverRepo) GetDiscover(ctx context.Context, req discover.GetDiscoverRequest) ([]*discover.FeedViewPost, *string, error) { 37 + // Capture query time for stable cursor generation (used for hot sort pagination) 38 + queryTime := time.Now() 39 + 40 + // Build ORDER BY clause based on sort type 41 + orderBy, timeFilter := r.buildSortClause(req.Sort, req.Timeframe) 42 + 43 + 44 + 45 + 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + hotRanks = hotRanks[:req.Limit] 124 + lastPost := feedPosts[len(feedPosts)-1].Post 125 + lastHotRank := hotRanks[len(hotRanks)-1] 126 + cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank, queryTime) 127 + cursor = &cursorStr 128 + } 129 +
+113 -7
internal/db/postgres/feed_repo.go
··· 2 2 3 3 4 4 5 + "context" 6 + "database/sql" 7 + "fmt" 8 + "time" 9 + ) 5 10 6 - 7 - 8 - 9 - 10 - 11 + type postgresFeedRepo struct { 11 12 12 13 13 14 14 15 // sortClauses maps sort types to safe SQL ORDER BY clauses 15 16 // This whitelist prevents SQL injection via dynamic ORDER BY construction 17 + // Note: Hot ranking uses (score + 1) to ensure new posts with 0 votes still appear 16 18 var communityFeedSortClauses = map[string]string{ 17 - "hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 19 + "hot": `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 18 20 "top": `p.score DESC, p.created_at DESC, p.uri DESC`, 19 21 "new": `p.created_at DESC, p.uri DESC`, 20 22 } ··· 23 25 // NOTE: Uses NOW() which means hot_rank changes over time - this is expected behavior 24 26 // for hot sorting (posts naturally age out). Slight time drift between cursor creation 25 27 // and usage may cause minor reordering but won't drop posts entirely (unlike using raw score). 26 - const communityFeedHotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 28 + // Uses (score + 1) so new posts with 0 votes still get a positive rank 29 + const communityFeedHotRankExpression = `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 27 30 28 31 // NewCommunityFeedRepository creates a new PostgreSQL feed repository 29 32 func NewCommunityFeedRepository(db *sql.DB, cursorSecret string) communityFeeds.Repository { 33 + 34 + 35 + 36 + 37 + 38 + // GetCommunityFeed retrieves posts from a community with sorting and pagination 39 + // Single query with JOINs for optimal performance 40 + func (r *postgresFeedRepo) GetCommunityFeed(ctx context.Context, req communityFeeds.GetCommunityFeedRequest) ([]*communityFeeds.FeedViewPost, *string, error) { 41 + // Capture query time for stable cursor generation (used for hot sort pagination) 42 + queryTime := time.Now() 43 + 44 + // Build ORDER BY clause based on sort type 45 + orderBy, timeFilter := r.feedRepoBase.buildSortClause(req.Sort, req.Timeframe) 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + 126 + 127 + 128 + 129 + hotRanks = hotRanks[:req.Limit] 130 + lastPost := feedPosts[len(feedPosts)-1].Post 131 + lastHotRank := hotRanks[len(hotRanks)-1] 132 + cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank, queryTime) 133 + cursor = &cursorStr 134 + } 135 +
+115 -7
internal/db/postgres/timeline_repo.go
··· 2 2 3 3 4 4 5 + "context" 6 + "database/sql" 7 + "fmt" 8 + "time" 9 + ) 5 10 6 - 7 - 8 - 9 - 10 - 11 + type postgresTimelineRepo struct { 11 12 12 13 13 14 14 15 // sortClauses maps sort types to safe SQL ORDER BY clauses 15 16 // This whitelist prevents SQL injection via dynamic ORDER BY construction 17 + // Note: Hot ranking uses (score + 1) to ensure new posts with 0 votes still appear 16 18 var timelineSortClauses = map[string]string{ 17 - "hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 19 + "hot": `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 18 20 "top": `p.score DESC, p.created_at DESC, p.uri DESC`, 19 21 "new": `p.created_at DESC, p.uri DESC`, 20 22 } 21 23 22 24 // hotRankExpression is the SQL expression for computing the hot rank 23 25 // NOTE: Uses NOW() which means hot_rank changes over time - this is expected behavior 24 - const timelineHotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 26 + // Uses (score + 1) so new posts with 0 votes still get a positive rank 27 + const timelineHotRankExpression = `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 25 28 26 29 // NewTimelineRepository creates a new PostgreSQL timeline repository 27 30 func NewTimelineRepository(db *sql.DB, cursorSecret string) timeline.Repository { 31 + 32 + 33 + 34 + 35 + 36 + // GetTimeline retrieves posts from all communities the user subscribes to 37 + // Single query with JOINs for optimal performance 38 + func (r *postgresTimelineRepo) GetTimeline(ctx context.Context, req timeline.GetTimelineRequest) ([]*timeline.FeedViewPost, *string, error) { 39 + // Capture query time for stable cursor generation (used for hot sort pagination) 40 + queryTime := time.Now() 41 + 42 + // Build ORDER BY clause based on sort type 43 + orderBy, timeFilter := r.buildSortClause(req.Sort, req.Timeframe) 44 + 45 + 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + 126 + 127 + 128 + 129 + hotRanks = hotRanks[:req.Limit] 130 + lastPost := feedPosts[len(feedPosts)-1].Post 131 + lastHotRank := hotRanks[len(hotRanks)-1] 132 + cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank, queryTime) 133 + cursor = &cursorStr 134 + } 135 +
+323
internal/core/posts/embed_conversion_test.go
··· 1 + package posts 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + 8 + "Coves/internal/core/blueskypost" 9 + 10 + "github.com/stretchr/testify/assert" 11 + "github.com/stretchr/testify/require" 12 + ) 13 + 14 + // mockBlueskyService implements blueskypost.Service for testing 15 + type mockBlueskyService struct { 16 + isBlueskyURLResult bool 17 + parseURLResult string 18 + parseURLError error 19 + resolvePostResult *blueskypost.BlueskyPostResult 20 + resolvePostError error 21 + } 22 + 23 + func (m *mockBlueskyService) IsBlueskyURL(url string) bool { 24 + return m.isBlueskyURLResult 25 + } 26 + 27 + func (m *mockBlueskyService) ParseBlueskyURL(_ context.Context, _ string) (string, error) { 28 + return m.parseURLResult, m.parseURLError 29 + } 30 + 31 + func (m *mockBlueskyService) ResolvePost(_ context.Context, _ string) (*blueskypost.BlueskyPostResult, error) { 32 + return m.resolvePostResult, m.resolvePostError 33 + } 34 + 35 + func TestTryConvertBlueskyURLToPostEmbed(t *testing.T) { 36 + ctx := context.Background() 37 + 38 + t.Run("returns false when blueskyService is nil", func(t *testing.T) { 39 + svc := &postService{ 40 + blueskyService: nil, // nil service 41 + } 42 + 43 + external := map[string]interface{}{ 44 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 45 + } 46 + postRecord := &PostRecord{} 47 + 48 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 49 + 50 + assert.False(t, result, "Should return false when blueskyService is nil") 51 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 52 + }) 53 + 54 + t.Run("returns false when URL is empty", func(t *testing.T) { 55 + mockSvc := &mockBlueskyService{ 56 + isBlueskyURLResult: true, 57 + } 58 + svc := &postService{ 59 + blueskyService: mockSvc, 60 + } 61 + 62 + external := map[string]interface{}{ 63 + "uri": "", // empty URL 64 + } 65 + postRecord := &PostRecord{} 66 + 67 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 68 + 69 + assert.False(t, result, "Should return false when URL is empty") 70 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 71 + }) 72 + 73 + t.Run("returns false when URI field is missing", func(t *testing.T) { 74 + mockSvc := &mockBlueskyService{ 75 + isBlueskyURLResult: true, 76 + } 77 + svc := &postService{ 78 + blueskyService: mockSvc, 79 + } 80 + 81 + external := map[string]interface{}{ 82 + // no "uri" field 83 + } 84 + postRecord := &PostRecord{} 85 + 86 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 87 + 88 + assert.False(t, result, "Should return false when uri field is missing") 89 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 90 + }) 91 + 92 + t.Run("returns false when URI is not a string type", func(t *testing.T) { 93 + mockSvc := &mockBlueskyService{ 94 + isBlueskyURLResult: true, 95 + } 96 + svc := &postService{ 97 + blueskyService: mockSvc, 98 + } 99 + 100 + external := map[string]interface{}{ 101 + "uri": 12345, // int instead of string 102 + } 103 + postRecord := &PostRecord{} 104 + 105 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 106 + 107 + assert.False(t, result, "Should return false when uri is not a string") 108 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 109 + }) 110 + 111 + t.Run("returns false when URL is not Bluesky", func(t *testing.T) { 112 + mockSvc := &mockBlueskyService{ 113 + isBlueskyURLResult: false, // not a Bluesky URL 114 + } 115 + svc := &postService{ 116 + blueskyService: mockSvc, 117 + } 118 + 119 + external := map[string]interface{}{ 120 + "uri": "https://twitter.com/user/status/123", 121 + } 122 + postRecord := &PostRecord{} 123 + 124 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 125 + 126 + assert.False(t, result, "Should return false when URL is not Bluesky") 127 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 128 + }) 129 + 130 + t.Run("returns false when URL parsing fails", func(t *testing.T) { 131 + mockSvc := &mockBlueskyService{ 132 + isBlueskyURLResult: true, 133 + parseURLError: errors.New("handle resolution failed"), 134 + } 135 + svc := &postService{ 136 + blueskyService: mockSvc, 137 + } 138 + 139 + external := map[string]interface{}{ 140 + "uri": "https://bsky.app/profile/nonexistent.bsky.social/post/abc123", 141 + } 142 + postRecord := &PostRecord{} 143 + 144 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 145 + 146 + assert.False(t, result, "Should return false when URL parsing fails") 147 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 148 + }) 149 + 150 + t.Run("returns false when post resolution fails with error", func(t *testing.T) { 151 + mockSvc := &mockBlueskyService{ 152 + isBlueskyURLResult: true, 153 + parseURLResult: "at://did:plc:test/app.bsky.feed.post/abc123", 154 + resolvePostError: errors.New("API timeout"), 155 + } 156 + svc := &postService{ 157 + blueskyService: mockSvc, 158 + } 159 + 160 + external := map[string]interface{}{ 161 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 162 + } 163 + postRecord := &PostRecord{} 164 + 165 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 166 + 167 + assert.False(t, result, "Should return false when post resolution fails") 168 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 169 + }) 170 + 171 + t.Run("returns false when post is unavailable", func(t *testing.T) { 172 + mockSvc := &mockBlueskyService{ 173 + isBlueskyURLResult: true, 174 + parseURLResult: "at://did:plc:deleted/app.bsky.feed.post/deleted123", 175 + resolvePostResult: &blueskypost.BlueskyPostResult{ 176 + Unavailable: true, 177 + Message: "This post has been deleted", 178 + }, 179 + } 180 + svc := &postService{ 181 + blueskyService: mockSvc, 182 + } 183 + 184 + external := map[string]interface{}{ 185 + "uri": "https://bsky.app/profile/deleted.bsky.social/post/deleted123", 186 + } 187 + postRecord := &PostRecord{} 188 + 189 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 190 + 191 + assert.False(t, result, "Should return false for unavailable posts - keep as external embed") 192 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 193 + }) 194 + 195 + t.Run("returns false when ResolvePost returns nil result", func(t *testing.T) { 196 + mockSvc := &mockBlueskyService{ 197 + isBlueskyURLResult: true, 198 + parseURLResult: "at://did:plc:test/app.bsky.feed.post/abc123", 199 + resolvePostResult: nil, // nil result 200 + resolvePostError: nil, // no error 201 + } 202 + svc := &postService{ 203 + blueskyService: mockSvc, 204 + } 205 + 206 + external := map[string]interface{}{ 207 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 208 + } 209 + postRecord := &PostRecord{} 210 + 211 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 212 + 213 + assert.False(t, result, "Should return false when result is nil") 214 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 215 + }) 216 + 217 + t.Run("returns false with circuit breaker error", func(t *testing.T) { 218 + mockSvc := &mockBlueskyService{ 219 + isBlueskyURLResult: true, 220 + parseURLResult: "at://did:plc:test/app.bsky.feed.post/abc123", 221 + resolvePostError: blueskypost.ErrCircuitOpen, 222 + } 223 + svc := &postService{ 224 + blueskyService: mockSvc, 225 + } 226 + 227 + external := map[string]interface{}{ 228 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 229 + } 230 + postRecord := &PostRecord{} 231 + 232 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 233 + 234 + assert.False(t, result, "Should return false when circuit breaker is open") 235 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 236 + }) 237 + 238 + t.Run("returns false when resolved post has empty URI", func(t *testing.T) { 239 + mockSvc := &mockBlueskyService{ 240 + isBlueskyURLResult: true, 241 + parseURLResult: "at://did:plc:test/app.bsky.feed.post/abc123", 242 + resolvePostResult: &blueskypost.BlueskyPostResult{ 243 + URI: "", // empty URI 244 + CID: "bafytest123", 245 + }, 246 + } 247 + svc := &postService{ 248 + blueskyService: mockSvc, 249 + } 250 + 251 + external := map[string]interface{}{ 252 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 253 + } 254 + postRecord := &PostRecord{} 255 + 256 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 257 + 258 + assert.False(t, result, "Should return false when URI is empty") 259 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 260 + }) 261 + 262 + t.Run("returns false when resolved post has empty CID", func(t *testing.T) { 263 + mockSvc := &mockBlueskyService{ 264 + isBlueskyURLResult: true, 265 + parseURLResult: "at://did:plc:test/app.bsky.feed.post/abc123", 266 + resolvePostResult: &blueskypost.BlueskyPostResult{ 267 + URI: "at://did:plc:test/app.bsky.feed.post/abc123", 268 + CID: "", // empty CID 269 + }, 270 + } 271 + svc := &postService{ 272 + blueskyService: mockSvc, 273 + } 274 + 275 + external := map[string]interface{}{ 276 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 277 + } 278 + postRecord := &PostRecord{} 279 + 280 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 281 + 282 + assert.False(t, result, "Should return false when CID is empty") 283 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 284 + }) 285 + 286 + t.Run("successfully converts valid Bluesky URL to post embed", func(t *testing.T) { 287 + mockSvc := &mockBlueskyService{ 288 + isBlueskyURLResult: true, 289 + parseURLResult: "at://did:plc:abcdef/app.bsky.feed.post/xyz789", 290 + resolvePostResult: &blueskypost.BlueskyPostResult{ 291 + URI: "at://did:plc:abcdef/app.bsky.feed.post/xyz789", 292 + CID: "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm", 293 + Text: "Hello from Bluesky!", 294 + Author: &blueskypost.Author{ 295 + DID: "did:plc:abcdef", 296 + Handle: "test.bsky.social", 297 + }, 298 + }, 299 + } 300 + svc := &postService{ 301 + blueskyService: mockSvc, 302 + } 303 + 304 + external := map[string]interface{}{ 305 + "uri": "https://bsky.app/profile/test.bsky.social/post/xyz789", 306 + } 307 + postRecord := &PostRecord{} 308 + 309 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 310 + 311 + assert.True(t, result, "Should return true for successful conversion") 312 + require.NotNil(t, postRecord.Embed, "Should set embed") 313 + 314 + // Verify embed structure (Embed is already map[string]interface{}) 315 + assert.Equal(t, "social.coves.embed.post", postRecord.Embed["$type"]) 316 + 317 + postRef, ok := postRecord.Embed["post"].(map[string]interface{}) 318 + require.True(t, ok, "post should be a map") 319 + 320 + assert.Equal(t, "at://did:plc:abcdef/app.bsky.feed.post/xyz789", postRef["uri"]) 321 + assert.Equal(t, "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm", postRef["cid"]) 322 + }) 323 + }
+250 -8
internal/core/blueskypost/fetcher.go
··· 64 64 65 65 // blueskyAPIEmbed represents resolved embed data in the API response 66 66 type blueskyAPIEmbed struct { 67 - Video json.RawMessage `json:"video,omitempty"` 68 - Record *blueskyAPIEmbedRecord `json:"record,omitempty"` 69 - Media *blueskyAPIEmbedMedia `json:"media,omitempty"` 70 - Type string `json:"$type"` 71 - Images []json.RawMessage `json:"images,omitempty"` 67 + Video json.RawMessage `json:"video,omitempty"` 68 + Record *blueskyAPIEmbedRecord `json:"record,omitempty"` 69 + Media *blueskyAPIEmbedMedia `json:"media,omitempty"` 70 + External *blueskyAPIExternal `json:"external,omitempty"` 71 + Type string `json:"$type"` 72 + Images []json.RawMessage `json:"images,omitempty"` 73 + } 74 + 75 + // blueskyAPIExternal represents an external link embed in the API response 76 + type blueskyAPIExternal struct { 77 + URI string `json:"uri"` 78 + Title string `json:"title,omitempty"` 79 + Description string `json:"description,omitempty"` 80 + Thumb string `json:"thumb,omitempty"` 72 81 } 73 82 74 83 // blueskyAPIEmbedMedia represents media in a recordWithMedia embed ··· 78 87 79 88 80 89 90 + // blueskyAPIEmbedRecord represents a quoted post embed in the API response 91 + // For record#view: this directly contains the viewRecord fields 92 + // For recordWithMedia#view: this contains a nested "record" field with viewRecord 93 + // For record#viewBlocked: contains blocked=true and limited author info 94 + // For record#viewNotFound: contains notFound=true 95 + type blueskyAPIEmbedRecord struct { 96 + // Type identifies the record view type ($type field) 97 + // Can be: app.bsky.embed.record#viewRecord, #viewBlocked, #viewNotFound, #viewDetached 98 + Type string `json:"$type,omitempty"` 99 + 100 + // Blocked is true when there is a block relationship between viewer and quoted post author 101 + Blocked bool `json:"blocked,omitempty"` 102 + 103 + // NotFound is true when the quoted post has been deleted 104 + NotFound bool `json:"notFound,omitempty"` 105 + 106 + // Detached is true when the quoted post has been detached (removed from quote context) 107 + Detached bool `json:"detached,omitempty"` 108 + 109 + // For recordWithMedia#view - nested structure 110 + Record *blueskyAPIViewRecord `json:"record,omitempty"` 111 + 112 + // For record#view - direct viewRecord fields 113 + URI string `json:"uri,omitempty"` 114 + CID string `json:"cid,omitempty"` 115 + Author *blueskyAPIAuthor `json:"author,omitempty"` 116 + Value *blueskyAPIRecordValue `json:"value,omitempty"` 117 + LikeCount int `json:"likeCount,omitempty"` 118 + ReplyCount int `json:"replyCount,omitempty"` 119 + RepostCount int `json:"repostCount,omitempty"` 120 + IndexedAt string `json:"indexedAt,omitempty"` 121 + Embeds []json.RawMessage `json:"embeds,omitempty"` 122 + } 123 + 124 + // blueskyAPIViewRecord represents the viewRecord structure for quoted posts 125 + 126 + 127 + 128 + 129 + 130 + 131 + 132 + 133 + 81 134 82 135 83 136 ··· 201 254 202 255 203 256 257 + } 258 + } 259 + 260 + // Extract external link embed (app.bsky.embed.external#view) 261 + if post.Embed.External != nil && post.Embed.External.URI != "" { 262 + result.Embed = &ExternalEmbed{ 263 + URI: post.Embed.External.URI, 264 + Title: post.Embed.External.Title, 265 + Description: post.Embed.External.Description, 266 + Thumb: post.Embed.External.Thumb, 267 + } 268 + } 269 + 270 + // Handle quoted post (1 level deep only) 271 + // Support both pure record embeds and recordWithMedia embeds 272 + if post.Embed.Record != nil { 273 + 274 + 275 + 276 + 204 277 205 278 206 279 ··· 230 303 231 304 232 305 306 + 307 + 308 + 309 + 310 + 311 + 312 + } 313 + 314 + // mapViewRecordToResult maps a blueskyAPIEmbedRecord (with direct viewRecord fields) to BlueskyPostResult 315 + // This is used for app.bsky.embed.record#view where the viewRecord fields are at the top level. 316 + // Handles unavailable states: blocked (#viewBlocked), deleted (#viewNotFound), and detached (#viewDetached). 317 + func mapViewRecordToResult(embedRecord *blueskyAPIEmbedRecord) *BlueskyPostResult { 318 + if embedRecord == nil { 319 + return nil 320 + } 321 + 322 + // Handle blocked quoted posts (app.bsky.embed.record#viewBlocked) 323 + if embedRecord.Blocked || embedRecord.Type == "app.bsky.embed.record#viewBlocked" { 324 + result := &BlueskyPostResult{ 325 + URI: embedRecord.URI, 326 + Unavailable: true, 327 + Message: "This post is from a blocked account", 328 + } 329 + // Include author DID if available (handle won't be available for blocked users) 330 + if embedRecord.Author != nil { 331 + result.Author = &Author{ 332 + DID: embedRecord.Author.DID, 233 333 } 234 334 } 335 + return result 336 + } 235 337 236 - // Handle quoted post (1 level deep only) 237 - // Support both pure record embeds and recordWithMedia embeds 238 - if post.Embed.Record != nil { 338 + // Handle deleted/not found quoted posts (app.bsky.embed.record#viewNotFound) 339 + if embedRecord.NotFound || embedRecord.Type == "app.bsky.embed.record#viewNotFound" { 340 + return &BlueskyPostResult{ 341 + URI: embedRecord.URI, 342 + Unavailable: true, 343 + Message: "This post has been deleted", 344 + } 345 + } 346 + 347 + // Handle detached quoted posts (app.bsky.embed.record#viewDetached) 348 + if embedRecord.Detached || embedRecord.Type == "app.bsky.embed.record#viewDetached" { 349 + return &BlueskyPostResult{ 350 + URI: embedRecord.URI, 351 + Unavailable: true, 352 + Message: "This post is unavailable", 353 + } 354 + } 355 + 356 + result := &BlueskyPostResult{ 357 + URI: embedRecord.URI, 358 + CID: embedRecord.CID, 359 + 360 + 361 + 362 + 363 + 364 + 365 + 366 + 367 + 368 + 369 + 370 + 371 + 372 + 373 + 374 + 375 + 376 + 377 + 378 + createdAt, err := time.Parse(time.RFC3339, embedRecord.Value.CreatedAt) 379 + if err == nil { 380 + result.CreatedAt = createdAt 381 + } else { 382 + log.Printf("[BLUESKY] Warning: Failed to parse CreatedAt timestamp %q for quoted post %s: %v", 383 + embedRecord.Value.CreatedAt, embedRecord.URI, err) 384 + } 385 + } 386 + } 387 + 388 + // Check for media in embeds array and extract external embed if present 389 + if len(embedRecord.Embeds) > 0 { 390 + result.HasMedia = true 391 + result.MediaCount = len(embedRecord.Embeds) 392 + 393 + // Try to extract external embed from the embeds array 394 + result.Embed = extractExternalEmbedFromEmbeds(embedRecord.Embeds) 395 + } 396 + 397 + return result 398 + } 399 + 400 + // extractExternalEmbedFromEmbeds parses the embeds array and extracts external link embed if present. 401 + // This is used for quoted posts where embeds are in a nested json.RawMessage array, 402 + // unlike top-level posts where External is directly available on blueskyAPIEmbed. 403 + // Returns nil if no external embed is found. 404 + func extractExternalEmbedFromEmbeds(embeds []json.RawMessage) *ExternalEmbed { 405 + for _, embedRaw := range embeds { 406 + // Parse the embed to check its type 407 + var embedWrapper struct { 408 + Type string `json:"$type"` 409 + External *struct { 410 + URI string `json:"uri"` 411 + Title string `json:"title"` 412 + Description string `json:"description"` 413 + Thumb string `json:"thumb"` 414 + } `json:"external"` 415 + } 416 + 417 + if err := json.Unmarshal(embedRaw, &embedWrapper); err != nil { 418 + log.Printf("[BLUESKY] Warning: Failed to unmarshal embed in quoted post: %v", err) 419 + continue 420 + } 421 + 422 + // Check for external embed type 423 + if embedWrapper.Type == "app.bsky.embed.external#view" && embedWrapper.External != nil { 424 + return &ExternalEmbed{ 425 + URI: embedWrapper.External.URI, 426 + Title: embedWrapper.External.Title, 427 + Description: embedWrapper.External.Description, 428 + Thumb: embedWrapper.External.Thumb, 429 + } 430 + } 431 + } 432 + 433 + return nil 434 + } 435 + 436 + // mapNestedViewRecordToResult maps a blueskyAPIViewRecord to BlueskyPostResult 437 + // This is used for app.bsky.embed.recordWithMedia#view where the viewRecord is nested 438 + func mapNestedViewRecordToResult(viewRecord *blueskyAPIViewRecord) *BlueskyPostResult { 439 + 440 + 441 + 442 + 443 + 444 + 445 + 446 + 447 + 448 + 449 + 450 + 451 + 452 + 453 + 454 + 455 + 456 + 457 + 458 + 459 + 460 + 461 + createdAt, err := time.Parse(time.RFC3339, viewRecord.Value.CreatedAt) 462 + if err == nil { 463 + result.CreatedAt = createdAt 464 + } else { 465 + log.Printf("[BLUESKY] Warning: Failed to parse CreatedAt timestamp %q for quoted post %s: %v", 466 + viewRecord.Value.CreatedAt, viewRecord.URI, err) 467 + } 468 + } 469 + } 470 + 471 + // Check for media in embeds array and extract external embed if present 472 + if len(viewRecord.Embeds) > 0 { 473 + result.HasMedia = true 474 + result.MediaCount = len(viewRecord.Embeds) 475 + 476 + // Try to extract external embed from the embeds array 477 + result.Embed = extractExternalEmbedFromEmbeds(viewRecord.Embeds) 478 + } 479 + 480 + return result
+79
internal/core/blueskypost/fetcher_test.go
··· 601 601 t.Error("Expected no quoted post with nil embeds") 602 602 } 603 603 } 604 + 605 + func TestMapAPIPostToResult_ExternalEmbed(t *testing.T) { 606 + // Test that external link embeds are correctly extracted 607 + apiPost := &blueskyAPIPost{ 608 + URI: "at://did:plc:test/app.bsky.feed.post/test", 609 + CID: "bafyreiabc123", 610 + Author: blueskyAPIAuthor{ 611 + DID: "did:plc:test", 612 + Handle: "english.lemonde.fr", 613 + DisplayName: "Le Monde", 614 + }, 615 + Record: blueskyAPIRecord{ 616 + Text: "Check out this article", 617 + CreatedAt: "2025-12-21T10:30:00Z", 618 + }, 619 + Embed: &blueskyAPIEmbed{ 620 + Type: "app.bsky.embed.external#view", 621 + External: &blueskyAPIExternal{ 622 + URI: "https://www.lemonde.fr/en/international/article/2025/12/22/nba-article.html", 623 + Title: "NBA and Fiba announce search for teams", 624 + Description: "The NBA and FIBA have announced a joint search for teams interested in joining a potential European league.", 625 + Thumb: "https://cdn.lemonde.fr/thumbnail.jpg", 626 + }, 627 + }, 628 + ReplyCount: 10, 629 + RepostCount: 5, 630 + LikeCount: 100, 631 + } 632 + 633 + result := mapAPIPostToResult(apiPost) 634 + 635 + // Verify basic fields 636 + if result.URI != "at://did:plc:test/app.bsky.feed.post/test" { 637 + t.Errorf("Expected URI 'at://did:plc:test/app.bsky.feed.post/test', got %s", result.URI) 638 + } 639 + if result.Author.Handle != "english.lemonde.fr" { 640 + t.Errorf("Expected Handle 'english.lemonde.fr', got %s", result.Author.Handle) 641 + } 642 + 643 + // Verify external embed is extracted 644 + if result.Embed == nil { 645 + t.Fatal("Expected Embed to be set for external link post") 646 + } 647 + if result.Embed.URI != "https://www.lemonde.fr/en/international/article/2025/12/22/nba-article.html" { 648 + t.Errorf("Expected external URI, got %s", result.Embed.URI) 649 + } 650 + if result.Embed.Title != "NBA and Fiba announce search for teams" { 651 + t.Errorf("Expected external title, got %s", result.Embed.Title) 652 + } 653 + if result.Embed.Description != "The NBA and FIBA have announced a joint search for teams interested in joining a potential European league." { 654 + t.Errorf("Expected external description, got %s", result.Embed.Description) 655 + } 656 + if result.Embed.Thumb != "https://cdn.lemonde.fr/thumbnail.jpg" { 657 + t.Errorf("Expected external thumb, got %s", result.Embed.Thumb) 658 + } 659 + } 660 + 661 + func TestMapAPIPostToResult_ExternalEmbedNil(t *testing.T) { 662 + // Test that posts without external embeds don't have Embed set 663 + apiPost := &blueskyAPIPost{ 664 + URI: "at://did:plc:test/app.bsky.feed.post/test", 665 + CID: "bafyreiabc123", 666 + Author: blueskyAPIAuthor{ 667 + DID: "did:plc:test", 668 + Handle: "user.bsky.social", 669 + }, 670 + Record: blueskyAPIRecord{ 671 + Text: "Just a regular post without links", 672 + CreatedAt: "2025-12-21T10:30:00Z", 673 + }, 674 + Embed: nil, 675 + } 676 + 677 + result := mapAPIPostToResult(apiPost) 678 + 679 + if result.Embed != nil { 680 + t.Errorf("Expected Embed to be nil for post without external embed, got %+v", result.Embed) 681 + } 682 + }
+20
internal/core/blueskypost/types.go
··· 54 54 55 55 // Unavailable indicates the post could not be resolved (deleted, private, blocked, etc.) 56 56 Unavailable bool `json:"unavailable"` 57 + 58 + // Embed contains the post's external link embed, if present 59 + // This captures link cards from the original Bluesky post 60 + Embed *ExternalEmbed `json:"embed,omitempty"` 57 61 } 58 62 59 63 // Author represents a Bluesky post author's identity. ··· 70 74 // Avatar is the URL to the user's avatar image (may be empty) 71 75 Avatar string `json:"avatar,omitempty"` 72 76 } 77 + 78 + // ExternalEmbed represents an external link embed from a Bluesky post. 79 + // This captures link cards (URLs with title, description, and thumbnail). 80 + type ExternalEmbed struct { 81 + // URI is the URL of the external link 82 + URI string `json:"uri"` 83 + 84 + // Title is the page title (from og:title or <title>) 85 + Title string `json:"title,omitempty"` 86 + 87 + // Description is the page description (from og:description or meta description) 88 + Description string `json:"description,omitempty"` 89 + 90 + // Thumb is the URL to the thumbnail image (from og:image) 91 + Thumb string `json:"thumb,omitempty"` 92 + }
+24 -7
tests/integration/bluesky_post_test.go
··· 4 4 "Coves/internal/atproto/identity" 5 5 "Coves/internal/core/blueskypost" 6 6 "context" 7 + "database/sql" 7 8 "fmt" 8 9 "net/http" 9 10 "testing" ··· 21 22 // 22 23 // Use this for tests that need to resolve real Bluesky handles like "ianboudreau.com". 23 24 // Do NOT use for tests involving local Coves identities (use local PLC instead). 24 - func productionPLCIdentityResolver() identity.Resolver { 25 + // 26 + // NOTE: Requires a database connection for the identity cache. Pass the test db. 27 + func productionPLCIdentityResolver(db *sql.DB) identity.Resolver { 25 28 config := identity.DefaultConfig() 26 29 config.PLCURL = "https://plc.directory" // Production PLC - READ ONLY 27 - return identity.NewResolver(nil, config) 30 + return identity.NewResolver(db, config) 28 31 } 29 32 30 33 // TestBlueskyPostCrossPosting_URLParsing tests URL detection and parsing ··· 37 40 defer func() { _ = db.Close() }() 38 41 39 42 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 40 - identityResolver := productionPLCIdentityResolver() 43 + identityResolver := productionPLCIdentityResolver(db) 41 44 42 45 // Setup Bluesky post service 43 46 repo := blueskypost.NewRepository(db) ··· 119 122 _, _ = db.Exec("DELETE FROM bluesky_post_cache") 120 123 121 124 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 122 - identityResolver := productionPLCIdentityResolver() 125 + identityResolver := productionPLCIdentityResolver(db) 123 126 124 127 repo := blueskypost.NewRepository(db) 125 128 service := blueskypost.NewService(repo, identityResolver, ··· 239 242 assert.Equal(t, "davidpfau.com", result.Author.Handle) 240 243 assert.NotEmpty(t, result.Text) 241 244 245 + // Verify external embed is extracted 246 + if result.Embed != nil { 247 + assert.NotEmpty(t, result.Embed.URI, "External embed should have URI") 248 + t.Logf(" External embed URI: %s", result.Embed.URI) 249 + if result.Embed.Title != "" { 250 + t.Logf(" External embed title: %s", result.Embed.Title) 251 + } 252 + if result.Embed.Thumb != "" { 253 + t.Logf(" External embed thumb: %s", result.Embed.Thumb) 254 + } 255 + } else { 256 + t.Log(" Note: No external embed found (post may have been modified)") 257 + } 258 + 242 259 t.Logf("โœ“ Successfully fetched post with link embed:") 243 260 t.Logf(" Author: @%s", result.Author.Handle) 244 261 t.Logf(" Text: %.80s...", result.Text) ··· 385 402 defer func() { _ = db.Close() }() 386 403 387 404 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 388 - identityResolver := productionPLCIdentityResolver() 405 + identityResolver := productionPLCIdentityResolver(db) 389 406 390 407 repo := blueskypost.NewRepository(db) 391 408 service := blueskypost.NewService(repo, identityResolver, ··· 542 559 _, _ = db.Exec("DELETE FROM bluesky_post_cache WHERE at_uri LIKE 'at://did:plc:%'") 543 560 544 561 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 545 - identityResolver := productionPLCIdentityResolver() 562 + identityResolver := productionPLCIdentityResolver(db) 546 563 547 564 repo := blueskypost.NewRepository(db) 548 565 service := blueskypost.NewService(repo, identityResolver, ··· 611 628 _, _ = db.Exec("DELETE FROM bluesky_post_cache") 612 629 613 630 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 614 - identityResolver := productionPLCIdentityResolver() 631 + identityResolver := productionPLCIdentityResolver(db) 615 632 616 633 // Setup Bluesky post service 617 634 repo := blueskypost.NewRepository(db)
+7 -5
internal/core/communities/token_refresh.go
··· 13 13 // refreshPDSToken exchanges a refresh token for new access and refresh tokens 14 14 // Uses com.atproto.server.refreshSession endpoint via Indigo SDK 15 15 // CRITICAL: Refresh tokens are single-use - old refresh token is revoked on success 16 - func refreshPDSToken(ctx context.Context, pdsURL, currentAccessToken, refreshToken string) (newAccessToken, newRefreshToken string, err error) { 16 + func refreshPDSToken(ctx context.Context, pdsURL, refreshToken string) (newAccessToken, newRefreshToken string, err error) { 17 17 if pdsURL == "" { 18 18 return "", "", fmt.Errorf("PDS URL is required") 19 19 } ··· 21 21 return "", "", fmt.Errorf("refresh token is required") 22 22 } 23 23 24 - // Create XRPC client with auth credentials 25 - // The refresh endpoint requires authentication with the refresh token 24 + // Create XRPC client with refresh token as the auth credential 25 + // IMPORTANT: The xrpc client always sends AccessJwt as the Authorization header, 26 + // but refreshSession requires the refresh token in that header. 27 + // So we put the refresh token in AccessJwt to make it work correctly. 26 28 client := &xrpc.Client{ 27 29 Host: pdsURL, 28 30 Auth: &xrpc.AuthInfo{ 29 - AccessJwt: currentAccessToken, // Can be expired (not used for refresh auth) 30 - RefreshJwt: refreshToken, // This is what authenticates the refresh request 31 + AccessJwt: refreshToken, // Refresh token goes here (sent as Authorization header) 32 + RefreshJwt: refreshToken, // Also set here for completeness 31 33 }, 32 34 } 33 35
+1159
internal/api/handlers/aggregator/apikey_handlers_test.go
··· 1 + package aggregator 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/aggregators" 6 + "context" 7 + "encoding/json" 8 + "errors" 9 + "net/http" 10 + "net/http/httptest" 11 + "testing" 12 + "time" 13 + 14 + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + ) 17 + 18 + // mockAggregatorService implements aggregators.Service for testing 19 + type mockAggregatorService struct { 20 + isAggregatorFunc func(ctx context.Context, did string) (bool, error) 21 + } 22 + 23 + func (m *mockAggregatorService) IsAggregator(ctx context.Context, did string) (bool, error) { 24 + if m.isAggregatorFunc != nil { 25 + return m.isAggregatorFunc(ctx, did) 26 + } 27 + return true, nil 28 + } 29 + 30 + // Stub implementations for Service interface methods we don't test 31 + func (m *mockAggregatorService) GetAggregator(ctx context.Context, did string) (*aggregators.Aggregator, error) { 32 + return nil, nil 33 + } 34 + 35 + func (m *mockAggregatorService) GetAggregators(ctx context.Context, dids []string) ([]*aggregators.Aggregator, error) { 36 + return nil, nil 37 + } 38 + 39 + func (m *mockAggregatorService) ListAggregators(ctx context.Context, limit, offset int) ([]*aggregators.Aggregator, error) { 40 + return nil, nil 41 + } 42 + 43 + func (m *mockAggregatorService) GetAuthorizationsForAggregator(ctx context.Context, req aggregators.GetAuthorizationsRequest) ([]*aggregators.Authorization, error) { 44 + return nil, nil 45 + } 46 + 47 + func (m *mockAggregatorService) ListAggregatorsForCommunity(ctx context.Context, req aggregators.ListForCommunityRequest) ([]*aggregators.Authorization, error) { 48 + return nil, nil 49 + } 50 + 51 + func (m *mockAggregatorService) EnableAggregator(ctx context.Context, req aggregators.EnableAggregatorRequest) (*aggregators.Authorization, error) { 52 + return nil, nil 53 + } 54 + 55 + func (m *mockAggregatorService) DisableAggregator(ctx context.Context, req aggregators.DisableAggregatorRequest) (*aggregators.Authorization, error) { 56 + return nil, nil 57 + } 58 + 59 + func (m *mockAggregatorService) UpdateAggregatorConfig(ctx context.Context, req aggregators.UpdateConfigRequest) (*aggregators.Authorization, error) { 60 + return nil, nil 61 + } 62 + 63 + func (m *mockAggregatorService) ValidateAggregatorPost(ctx context.Context, aggregatorDID, communityDID string) error { 64 + return nil 65 + } 66 + 67 + func (m *mockAggregatorService) RecordAggregatorPost(ctx context.Context, aggregatorDID, communityDID, postURI, postCID string) error { 68 + return nil 69 + } 70 + 71 + // XRPCError represents an XRPC error response for testing 72 + type XRPCError struct { 73 + Error string `json:"error"` 74 + Message string `json:"message"` 75 + } 76 + 77 + // Helper to create authenticated request context with OAuth session 78 + func createAuthenticatedContext(t *testing.T, didStr string) context.Context { 79 + t.Helper() 80 + did, err := syntax.ParseDID(didStr) 81 + if err != nil { 82 + t.Fatalf("Failed to parse DID: %v", err) 83 + } 84 + session := &oauthlib.ClientSessionData{ 85 + AccountDID: did, 86 + AccessToken: "test_access_token", 87 + SessionID: "test_session", 88 + } 89 + ctx := context.WithValue(context.Background(), middleware.OAuthSessionKey, session) 90 + ctx = context.WithValue(ctx, middleware.UserDIDKey, didStr) 91 + return ctx 92 + } 93 + 94 + // Helper to create context with just UserDID (no OAuth session) 95 + func createUserDIDContext(didStr string) context.Context { 96 + return context.WithValue(context.Background(), middleware.UserDIDKey, didStr) 97 + } 98 + 99 + // ============================================================================= 100 + // CreateAPIKey Handler Tests 101 + // ============================================================================= 102 + 103 + func TestCreateAPIKeyHandler_Success(t *testing.T) { 104 + // Create mock services 105 + mockAggSvc := &mockAggregatorService{ 106 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 107 + return true, nil // Is an aggregator 108 + }, 109 + } 110 + 111 + mockAPIKeySvc := &mockAPIKeyService{ 112 + generateKeyFunc: func(ctx context.Context, aggregatorDID string, oauthSession *oauthlib.ClientSessionData) (string, string, error) { 113 + return "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "ckapi_012345", nil 114 + }, 115 + } 116 + 117 + handler := NewCreateAPIKeyHandler(mockAPIKeySvc, mockAggSvc) 118 + 119 + // Create request with full auth context (including OAuth session) 120 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.createApiKey", nil) 121 + req.Header.Set("Content-Type", "application/json") 122 + ctx := createAuthenticatedContext(t, "did:plc:aggregator123") 123 + req = req.WithContext(ctx) 124 + 125 + // Execute handler 126 + w := httptest.NewRecorder() 127 + handler.HandleCreateAPIKey(w, req) 128 + 129 + // Check status code 130 + if w.Code != http.StatusOK { 131 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 132 + } 133 + 134 + // Check response format 135 + var response CreateAPIKeyResponse 136 + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { 137 + t.Fatalf("Failed to decode response: %v", err) 138 + } 139 + 140 + if response.Key != "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" { 141 + t.Errorf("Expected key to match generated key, got %s", response.Key) 142 + } 143 + if response.KeyPrefix != "ckapi_012345" { 144 + t.Errorf("Expected keyPrefix to match, got %s", response.KeyPrefix) 145 + } 146 + if response.DID != "did:plc:aggregator123" { 147 + t.Errorf("Expected DID to match authenticated user, got %s", response.DID) 148 + } 149 + if response.CreatedAt == "" { 150 + t.Error("Expected createdAt to be set") 151 + } 152 + } 153 + 154 + func TestCreateAPIKeyHandler_RequiresAuth(t *testing.T) { 155 + mockAggSvc := &mockAggregatorService{} 156 + handler := NewCreateAPIKeyHandler(nil, mockAggSvc) 157 + 158 + // Create HTTP request without auth context 159 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.createApiKey", nil) 160 + req.Header.Set("Content-Type", "application/json") 161 + // No OAuth session in context 162 + 163 + // Execute handler 164 + w := httptest.NewRecorder() 165 + handler.HandleCreateAPIKey(w, req) 166 + 167 + // Check status code 168 + if w.Code != http.StatusUnauthorized { 169 + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) 170 + } 171 + 172 + // Check error response 173 + var errResp XRPCError 174 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 175 + t.Fatalf("Failed to decode error response: %v", err) 176 + } 177 + if errResp.Error != "AuthenticationRequired" { 178 + t.Errorf("Expected error AuthenticationRequired, got %s", errResp.Error) 179 + } 180 + } 181 + 182 + func TestCreateAPIKeyHandler_MethodNotAllowed(t *testing.T) { 183 + mockAggSvc := &mockAggregatorService{} 184 + handler := NewCreateAPIKeyHandler(nil, mockAggSvc) 185 + 186 + // Create GET request (should only accept POST) 187 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.aggregator.createApiKey", nil) 188 + 189 + // Execute handler 190 + w := httptest.NewRecorder() 191 + handler.HandleCreateAPIKey(w, req) 192 + 193 + // Check status code 194 + if w.Code != http.StatusMethodNotAllowed { 195 + t.Errorf("Expected status 405, got %d", w.Code) 196 + } 197 + } 198 + 199 + func TestCreateAPIKeyHandler_NotAggregator(t *testing.T) { 200 + mockAggSvc := &mockAggregatorService{ 201 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 202 + return false, nil // Not an aggregator 203 + }, 204 + } 205 + handler := NewCreateAPIKeyHandler(nil, mockAggSvc) 206 + 207 + // Create request with auth 208 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.createApiKey", nil) 209 + req.Header.Set("Content-Type", "application/json") 210 + ctx := createAuthenticatedContext(t, "did:plc:user123") 211 + req = req.WithContext(ctx) 212 + 213 + // Execute handler 214 + w := httptest.NewRecorder() 215 + handler.HandleCreateAPIKey(w, req) 216 + 217 + // Check status code 218 + if w.Code != http.StatusForbidden { 219 + t.Errorf("Expected status 403, got %d. Body: %s", w.Code, w.Body.String()) 220 + } 221 + 222 + // Check error response 223 + var errResp XRPCError 224 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 225 + t.Fatalf("Failed to decode error response: %v", err) 226 + } 227 + if errResp.Error != "AggregatorRequired" { 228 + t.Errorf("Expected error AggregatorRequired, got %s", errResp.Error) 229 + } 230 + } 231 + 232 + func TestCreateAPIKeyHandler_AggregatorCheckError(t *testing.T) { 233 + mockAggSvc := &mockAggregatorService{ 234 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 235 + return false, errors.New("database error") 236 + }, 237 + } 238 + handler := NewCreateAPIKeyHandler(nil, mockAggSvc) 239 + 240 + // Create request with auth 241 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.createApiKey", nil) 242 + req.Header.Set("Content-Type", "application/json") 243 + ctx := createAuthenticatedContext(t, "did:plc:user123") 244 + req = req.WithContext(ctx) 245 + 246 + // Execute handler 247 + w := httptest.NewRecorder() 248 + handler.HandleCreateAPIKey(w, req) 249 + 250 + // Check status code 251 + if w.Code != http.StatusInternalServerError { 252 + t.Errorf("Expected status 500, got %d. Body: %s", w.Code, w.Body.String()) 253 + } 254 + 255 + // Check error response 256 + var errResp XRPCError 257 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 258 + t.Fatalf("Failed to decode error response: %v", err) 259 + } 260 + if errResp.Error != "InternalServerError" { 261 + t.Errorf("Expected error InternalServerError, got %s", errResp.Error) 262 + } 263 + } 264 + 265 + func TestCreateAPIKeyHandler_MissingOAuthSession(t *testing.T) { 266 + mockAggSvc := &mockAggregatorService{ 267 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 268 + return true, nil // Is an aggregator 269 + }, 270 + } 271 + handler := NewCreateAPIKeyHandler(nil, mockAggSvc) 272 + 273 + // Create request with UserDID but no OAuth session 274 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.createApiKey", nil) 275 + req.Header.Set("Content-Type", "application/json") 276 + ctx := createUserDIDContext("did:plc:aggregator123") 277 + req = req.WithContext(ctx) 278 + 279 + // Execute handler 280 + w := httptest.NewRecorder() 281 + handler.HandleCreateAPIKey(w, req) 282 + 283 + // Check status code - should fail because OAuth session is required 284 + if w.Code != http.StatusUnauthorized { 285 + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) 286 + } 287 + 288 + // Check error response 289 + var errResp XRPCError 290 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 291 + t.Fatalf("Failed to decode error response: %v", err) 292 + } 293 + if errResp.Error != "OAuthSessionRequired" { 294 + t.Errorf("Expected error OAuthSessionRequired, got %s", errResp.Error) 295 + } 296 + } 297 + 298 + // ============================================================================= 299 + // GetAPIKey Handler Tests 300 + // ============================================================================= 301 + 302 + func TestGetAPIKeyHandler_Success(t *testing.T) { 303 + createdAt := time.Now().Add(-24 * time.Hour) 304 + lastUsed := time.Now().Add(-1 * time.Hour) 305 + 306 + mockAggSvc := &mockAggregatorService{ 307 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 308 + return true, nil // Is an aggregator 309 + }, 310 + } 311 + 312 + mockAPIKeySvc := &mockAPIKeyService{ 313 + getAPIKeyInfoFunc: func(ctx context.Context, aggregatorDID string) (*aggregators.APIKeyInfo, error) { 314 + return &aggregators.APIKeyInfo{ 315 + HasKey: true, 316 + KeyPrefix: "ckapi_test12", 317 + CreatedAt: &createdAt, 318 + LastUsedAt: &lastUsed, 319 + IsRevoked: false, 320 + }, nil 321 + }, 322 + } 323 + 324 + handler := NewGetAPIKeyHandler(mockAPIKeySvc, mockAggSvc) 325 + 326 + // Create request with auth 327 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.aggregator.getApiKey", nil) 328 + ctx := createUserDIDContext("did:plc:aggregator123") 329 + req = req.WithContext(ctx) 330 + 331 + // Execute handler 332 + w := httptest.NewRecorder() 333 + handler.HandleGetAPIKey(w, req) 334 + 335 + // Check status code 336 + if w.Code != http.StatusOK { 337 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 338 + } 339 + 340 + // Check response format 341 + var response GetAPIKeyResponse 342 + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { 343 + t.Fatalf("Failed to decode response: %v", err) 344 + } 345 + 346 + if !response.HasKey { 347 + t.Error("Expected hasKey to be true") 348 + } 349 + if response.KeyInfo == nil { 350 + t.Fatal("Expected keyInfo to be present") 351 + } 352 + if response.KeyInfo.Prefix != "ckapi_test12" { 353 + t.Errorf("Expected prefix 'ckapi_test12', got %s", response.KeyInfo.Prefix) 354 + } 355 + if response.KeyInfo.IsRevoked { 356 + t.Error("Expected isRevoked to be false") 357 + } 358 + if response.KeyInfo.CreatedAt == "" { 359 + t.Error("Expected createdAt to be set") 360 + } 361 + if response.KeyInfo.LastUsedAt == nil { 362 + t.Error("Expected lastUsedAt to be set") 363 + } 364 + } 365 + 366 + func TestGetAPIKeyHandler_Success_NoKey(t *testing.T) { 367 + mockAggSvc := &mockAggregatorService{ 368 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 369 + return true, nil // Is an aggregator 370 + }, 371 + } 372 + 373 + mockAPIKeySvc := &mockAPIKeyService{ 374 + getAPIKeyInfoFunc: func(ctx context.Context, aggregatorDID string) (*aggregators.APIKeyInfo, error) { 375 + return &aggregators.APIKeyInfo{ 376 + HasKey: false, 377 + }, nil 378 + }, 379 + } 380 + 381 + handler := NewGetAPIKeyHandler(mockAPIKeySvc, mockAggSvc) 382 + 383 + // Create request with auth 384 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.aggregator.getApiKey", nil) 385 + ctx := createUserDIDContext("did:plc:aggregator123") 386 + req = req.WithContext(ctx) 387 + 388 + // Execute handler 389 + w := httptest.NewRecorder() 390 + handler.HandleGetAPIKey(w, req) 391 + 392 + // Check status code 393 + if w.Code != http.StatusOK { 394 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 395 + } 396 + 397 + // Check response format 398 + var response GetAPIKeyResponse 399 + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { 400 + t.Fatalf("Failed to decode response: %v", err) 401 + } 402 + 403 + if response.HasKey { 404 + t.Error("Expected hasKey to be false") 405 + } 406 + if response.KeyInfo != nil { 407 + t.Error("Expected keyInfo to be nil when hasKey is false") 408 + } 409 + } 410 + 411 + func TestGetAPIKeyHandler_RequiresAuth(t *testing.T) { 412 + mockAggSvc := &mockAggregatorService{} 413 + handler := NewGetAPIKeyHandler(nil, mockAggSvc) 414 + 415 + // Create HTTP request without auth context 416 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.aggregator.getApiKey", nil) 417 + // No auth context 418 + 419 + // Execute handler 420 + w := httptest.NewRecorder() 421 + handler.HandleGetAPIKey(w, req) 422 + 423 + // Check status code 424 + if w.Code != http.StatusUnauthorized { 425 + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) 426 + } 427 + 428 + // Check error response 429 + var errResp XRPCError 430 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 431 + t.Fatalf("Failed to decode error response: %v", err) 432 + } 433 + if errResp.Error != "AuthenticationRequired" { 434 + t.Errorf("Expected error AuthenticationRequired, got %s", errResp.Error) 435 + } 436 + } 437 + 438 + func TestGetAPIKeyHandler_MethodNotAllowed(t *testing.T) { 439 + mockAggSvc := &mockAggregatorService{} 440 + handler := NewGetAPIKeyHandler(nil, mockAggSvc) 441 + 442 + // Create POST request (should only accept GET) 443 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.getApiKey", nil) 444 + 445 + // Execute handler 446 + w := httptest.NewRecorder() 447 + handler.HandleGetAPIKey(w, req) 448 + 449 + // Check status code 450 + if w.Code != http.StatusMethodNotAllowed { 451 + t.Errorf("Expected status 405, got %d", w.Code) 452 + } 453 + } 454 + 455 + func TestGetAPIKeyHandler_NotAggregator(t *testing.T) { 456 + mockAggSvc := &mockAggregatorService{ 457 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 458 + return false, nil // Not an aggregator 459 + }, 460 + } 461 + handler := NewGetAPIKeyHandler(nil, mockAggSvc) 462 + 463 + // Create request with auth 464 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.aggregator.getApiKey", nil) 465 + ctx := createUserDIDContext("did:plc:user123") 466 + req = req.WithContext(ctx) 467 + 468 + // Execute handler 469 + w := httptest.NewRecorder() 470 + handler.HandleGetAPIKey(w, req) 471 + 472 + // Check status code 473 + if w.Code != http.StatusForbidden { 474 + t.Errorf("Expected status 403, got %d. Body: %s", w.Code, w.Body.String()) 475 + } 476 + 477 + // Check error response 478 + var errResp XRPCError 479 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 480 + t.Fatalf("Failed to decode error response: %v", err) 481 + } 482 + if errResp.Error != "AggregatorRequired" { 483 + t.Errorf("Expected error AggregatorRequired, got %s", errResp.Error) 484 + } 485 + } 486 + 487 + func TestGetAPIKeyHandler_AggregatorCheckError(t *testing.T) { 488 + mockAggSvc := &mockAggregatorService{ 489 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 490 + return false, errors.New("database error") 491 + }, 492 + } 493 + handler := NewGetAPIKeyHandler(nil, mockAggSvc) 494 + 495 + // Create request with auth 496 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.aggregator.getApiKey", nil) 497 + ctx := createUserDIDContext("did:plc:user123") 498 + req = req.WithContext(ctx) 499 + 500 + // Execute handler 501 + w := httptest.NewRecorder() 502 + handler.HandleGetAPIKey(w, req) 503 + 504 + // Check status code 505 + if w.Code != http.StatusInternalServerError { 506 + t.Errorf("Expected status 500, got %d. Body: %s", w.Code, w.Body.String()) 507 + } 508 + 509 + // Check error response 510 + var errResp XRPCError 511 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 512 + t.Fatalf("Failed to decode error response: %v", err) 513 + } 514 + if errResp.Error != "InternalServerError" { 515 + t.Errorf("Expected error InternalServerError, got %s", errResp.Error) 516 + } 517 + } 518 + 519 + // ============================================================================= 520 + // RevokeAPIKey Handler Tests 521 + // ============================================================================= 522 + 523 + func TestRevokeAPIKeyHandler_Success(t *testing.T) { 524 + mockAggSvc := &mockAggregatorService{ 525 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 526 + return true, nil // Is an aggregator 527 + }, 528 + } 529 + 530 + revokeKeyCalled := false 531 + mockAPIKeySvc := &mockAPIKeyService{ 532 + getAPIKeyInfoFunc: func(ctx context.Context, aggregatorDID string) (*aggregators.APIKeyInfo, error) { 533 + return &aggregators.APIKeyInfo{ 534 + HasKey: true, 535 + KeyPrefix: "ckapi_test12", 536 + IsRevoked: false, 537 + }, nil 538 + }, 539 + revokeKeyFunc: func(ctx context.Context, aggregatorDID string) error { 540 + revokeKeyCalled = true 541 + return nil 542 + }, 543 + } 544 + 545 + handler := NewRevokeAPIKeyHandler(mockAPIKeySvc, mockAggSvc) 546 + 547 + // Create request with auth 548 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.revokeApiKey", nil) 549 + req.Header.Set("Content-Type", "application/json") 550 + ctx := createUserDIDContext("did:plc:aggregator123") 551 + req = req.WithContext(ctx) 552 + 553 + // Execute handler 554 + w := httptest.NewRecorder() 555 + handler.HandleRevokeAPIKey(w, req) 556 + 557 + // Check status code 558 + if w.Code != http.StatusOK { 559 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 560 + } 561 + 562 + // Check that RevokeKey was called 563 + if !revokeKeyCalled { 564 + t.Error("Expected RevokeKey to be called") 565 + } 566 + 567 + // Check response format 568 + var response RevokeAPIKeyResponse 569 + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { 570 + t.Fatalf("Failed to decode response: %v", err) 571 + } 572 + 573 + if response.RevokedAt == "" { 574 + t.Error("Expected revokedAt to be set") 575 + } 576 + 577 + // Verify timestamp format 578 + _, err := time.Parse("2006-01-02T15:04:05.000Z", response.RevokedAt) 579 + if err != nil { 580 + t.Errorf("Expected revokedAt to be valid ISO8601 timestamp: %v", err) 581 + } 582 + } 583 + 584 + func TestRevokeAPIKeyHandler_NoKeyToRevoke(t *testing.T) { 585 + mockAggSvc := &mockAggregatorService{ 586 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 587 + return true, nil // Is an aggregator 588 + }, 589 + } 590 + 591 + mockAPIKeySvc := &mockAPIKeyService{ 592 + getAPIKeyInfoFunc: func(ctx context.Context, aggregatorDID string) (*aggregators.APIKeyInfo, error) { 593 + return &aggregators.APIKeyInfo{ 594 + HasKey: false, // No key exists 595 + }, nil 596 + }, 597 + } 598 + 599 + handler := NewRevokeAPIKeyHandler(mockAPIKeySvc, mockAggSvc) 600 + 601 + // Create request with auth 602 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.revokeApiKey", nil) 603 + req.Header.Set("Content-Type", "application/json") 604 + ctx := createUserDIDContext("did:plc:aggregator123") 605 + req = req.WithContext(ctx) 606 + 607 + // Execute handler 608 + w := httptest.NewRecorder() 609 + handler.HandleRevokeAPIKey(w, req) 610 + 611 + // Check status code 612 + if w.Code != http.StatusBadRequest { 613 + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 614 + } 615 + 616 + // Check error response 617 + var errResp XRPCError 618 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 619 + t.Fatalf("Failed to decode error response: %v", err) 620 + } 621 + if errResp.Error != "ApiKeyNotFound" { 622 + t.Errorf("Expected error ApiKeyNotFound, got %s", errResp.Error) 623 + } 624 + } 625 + 626 + func TestRevokeAPIKeyHandler_AlreadyRevoked(t *testing.T) { 627 + mockAggSvc := &mockAggregatorService{ 628 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 629 + return true, nil // Is an aggregator 630 + }, 631 + } 632 + 633 + mockAPIKeySvc := &mockAPIKeyService{ 634 + getAPIKeyInfoFunc: func(ctx context.Context, aggregatorDID string) (*aggregators.APIKeyInfo, error) { 635 + return &aggregators.APIKeyInfo{ 636 + HasKey: true, 637 + KeyPrefix: "ckapi_test12", 638 + IsRevoked: true, // Already revoked 639 + }, nil 640 + }, 641 + } 642 + 643 + handler := NewRevokeAPIKeyHandler(mockAPIKeySvc, mockAggSvc) 644 + 645 + // Create request with auth 646 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.revokeApiKey", nil) 647 + req.Header.Set("Content-Type", "application/json") 648 + ctx := createUserDIDContext("did:plc:aggregator123") 649 + req = req.WithContext(ctx) 650 + 651 + // Execute handler 652 + w := httptest.NewRecorder() 653 + handler.HandleRevokeAPIKey(w, req) 654 + 655 + // Check status code 656 + if w.Code != http.StatusBadRequest { 657 + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 658 + } 659 + 660 + // Check error response 661 + var errResp XRPCError 662 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 663 + t.Fatalf("Failed to decode error response: %v", err) 664 + } 665 + if errResp.Error != "ApiKeyAlreadyRevoked" { 666 + t.Errorf("Expected error ApiKeyAlreadyRevoked, got %s", errResp.Error) 667 + } 668 + } 669 + 670 + func TestRevokeAPIKeyHandler_RequiresAuth(t *testing.T) { 671 + mockAggSvc := &mockAggregatorService{} 672 + handler := NewRevokeAPIKeyHandler(nil, mockAggSvc) 673 + 674 + // Create HTTP request without auth context 675 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.revokeApiKey", nil) 676 + req.Header.Set("Content-Type", "application/json") 677 + // No auth context 678 + 679 + // Execute handler 680 + w := httptest.NewRecorder() 681 + handler.HandleRevokeAPIKey(w, req) 682 + 683 + // Check status code 684 + if w.Code != http.StatusUnauthorized { 685 + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) 686 + } 687 + 688 + // Check error response 689 + var errResp XRPCError 690 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 691 + t.Fatalf("Failed to decode error response: %v", err) 692 + } 693 + if errResp.Error != "AuthenticationRequired" { 694 + t.Errorf("Expected error AuthenticationRequired, got %s", errResp.Error) 695 + } 696 + } 697 + 698 + func TestRevokeAPIKeyHandler_MethodNotAllowed(t *testing.T) { 699 + mockAggSvc := &mockAggregatorService{} 700 + handler := NewRevokeAPIKeyHandler(nil, mockAggSvc) 701 + 702 + // Create GET request (should only accept POST) 703 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.aggregator.revokeApiKey", nil) 704 + 705 + // Execute handler 706 + w := httptest.NewRecorder() 707 + handler.HandleRevokeAPIKey(w, req) 708 + 709 + // Check status code 710 + if w.Code != http.StatusMethodNotAllowed { 711 + t.Errorf("Expected status 405, got %d", w.Code) 712 + } 713 + } 714 + 715 + func TestRevokeAPIKeyHandler_NotAggregator(t *testing.T) { 716 + mockAggSvc := &mockAggregatorService{ 717 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 718 + return false, nil // Not an aggregator 719 + }, 720 + } 721 + handler := NewRevokeAPIKeyHandler(nil, mockAggSvc) 722 + 723 + // Create request with auth 724 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.revokeApiKey", nil) 725 + req.Header.Set("Content-Type", "application/json") 726 + ctx := createUserDIDContext("did:plc:user123") 727 + req = req.WithContext(ctx) 728 + 729 + // Execute handler 730 + w := httptest.NewRecorder() 731 + handler.HandleRevokeAPIKey(w, req) 732 + 733 + // Check status code 734 + if w.Code != http.StatusForbidden { 735 + t.Errorf("Expected status 403, got %d. Body: %s", w.Code, w.Body.String()) 736 + } 737 + 738 + // Check error response 739 + var errResp XRPCError 740 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 741 + t.Fatalf("Failed to decode error response: %v", err) 742 + } 743 + if errResp.Error != "AggregatorRequired" { 744 + t.Errorf("Expected error AggregatorRequired, got %s", errResp.Error) 745 + } 746 + } 747 + 748 + func TestRevokeAPIKeyHandler_AggregatorCheckError(t *testing.T) { 749 + mockAggSvc := &mockAggregatorService{ 750 + isAggregatorFunc: func(ctx context.Context, did string) (bool, error) { 751 + return false, errors.New("database error") 752 + }, 753 + } 754 + handler := NewRevokeAPIKeyHandler(nil, mockAggSvc) 755 + 756 + // Create request with auth 757 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.aggregator.revokeApiKey", nil) 758 + req.Header.Set("Content-Type", "application/json") 759 + ctx := createUserDIDContext("did:plc:user123") 760 + req = req.WithContext(ctx) 761 + 762 + // Execute handler 763 + w := httptest.NewRecorder() 764 + handler.HandleRevokeAPIKey(w, req) 765 + 766 + // Check status code 767 + if w.Code != http.StatusInternalServerError { 768 + t.Errorf("Expected status 500, got %d. Body: %s", w.Code, w.Body.String()) 769 + } 770 + 771 + // Check error response 772 + var errResp XRPCError 773 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 774 + t.Fatalf("Failed to decode error response: %v", err) 775 + } 776 + if errResp.Error != "InternalServerError" { 777 + t.Errorf("Expected error InternalServerError, got %s", errResp.Error) 778 + } 779 + } 780 + 781 + // ============================================================================= 782 + // Response Format Tests 783 + // ============================================================================= 784 + 785 + func TestRevokeAPIKeyResponse_ContainsRequiredFields(t *testing.T) { 786 + // Verify RevokeAPIKeyResponse has the required fields per lexicon 787 + response := RevokeAPIKeyResponse{ 788 + RevokedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), 789 + } 790 + 791 + data, err := json.Marshal(response) 792 + if err != nil { 793 + t.Fatalf("Failed to marshal response: %v", err) 794 + } 795 + 796 + var decoded map[string]interface{} 797 + if err := json.Unmarshal(data, &decoded); err != nil { 798 + t.Fatalf("Failed to unmarshal response: %v", err) 799 + } 800 + 801 + // Check required fields per lexicon (success field removed per AT Protocol best practices) 802 + if _, ok := decoded["revokedAt"]; !ok { 803 + t.Error("Response missing required 'revokedAt' field") 804 + } 805 + } 806 + 807 + func TestCreateAPIKeyResponse_ContainsRequiredFields(t *testing.T) { 808 + response := CreateAPIKeyResponse{ 809 + Key: "ckapi_test1234567890123456789012345678", 810 + KeyPrefix: "ckapi_test12", 811 + DID: "did:plc:aggregator123", 812 + CreatedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), 813 + } 814 + 815 + data, err := json.Marshal(response) 816 + if err != nil { 817 + t.Fatalf("Failed to marshal response: %v", err) 818 + } 819 + 820 + var decoded map[string]interface{} 821 + if err := json.Unmarshal(data, &decoded); err != nil { 822 + t.Fatalf("Failed to unmarshal response: %v", err) 823 + } 824 + 825 + // Check required fields 826 + requiredFields := []string{"key", "keyPrefix", "did", "createdAt"} 827 + for _, field := range requiredFields { 828 + if _, ok := decoded[field]; !ok { 829 + t.Errorf("Response missing required '%s' field", field) 830 + } 831 + } 832 + } 833 + 834 + func TestGetAPIKeyResponse_ContainsRequiredFields(t *testing.T) { 835 + response := GetAPIKeyResponse{ 836 + HasKey: true, 837 + KeyInfo: &APIKeyView{ 838 + Prefix: "ckapi_test12", 839 + CreatedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), 840 + IsRevoked: false, 841 + }, 842 + } 843 + 844 + data, err := json.Marshal(response) 845 + if err != nil { 846 + t.Fatalf("Failed to marshal response: %v", err) 847 + } 848 + 849 + var decoded map[string]interface{} 850 + if err := json.Unmarshal(data, &decoded); err != nil { 851 + t.Fatalf("Failed to unmarshal response: %v", err) 852 + } 853 + 854 + // Check required fields (now uses nested keyInfo structure) 855 + if _, ok := decoded["hasKey"]; !ok { 856 + t.Error("Response missing required 'hasKey' field") 857 + } 858 + if keyInfo, ok := decoded["keyInfo"].(map[string]interface{}); ok { 859 + if _, ok := keyInfo["isRevoked"]; !ok { 860 + t.Error("keyInfo missing required 'isRevoked' field") 861 + } 862 + } else { 863 + t.Error("Response missing 'keyInfo' field when hasKey is true") 864 + } 865 + } 866 + 867 + func TestGetAPIKeyResponse_OmitsEmptyOptionalFields(t *testing.T) { 868 + response := GetAPIKeyResponse{ 869 + HasKey: false, 870 + // KeyInfo is nil when hasKey is false 871 + } 872 + 873 + data, err := json.Marshal(response) 874 + if err != nil { 875 + t.Fatalf("Failed to marshal response: %v", err) 876 + } 877 + 878 + var decoded map[string]interface{} 879 + if err := json.Unmarshal(data, &decoded); err != nil { 880 + t.Fatalf("Failed to unmarshal response: %v", err) 881 + } 882 + 883 + // KeyInfo should be omitted when hasKey is false (per omitempty tag) 884 + if _, ok := decoded["keyInfo"]; ok { 885 + t.Error("Response should omit nil 'keyInfo' field when hasKey is false") 886 + } 887 + } 888 + 889 + // ============================================================================= 890 + // Handler Success Path Tests with Mocks 891 + // ============================================================================= 892 + 893 + // mockAPIKeyService implements aggregators.APIKeyServiceInterface for testing 894 + type mockAPIKeyService struct { 895 + generateKeyFunc func(ctx context.Context, aggregatorDID string, oauthSession *oauthlib.ClientSessionData) (plainKey string, keyPrefix string, err error) 896 + getAPIKeyInfoFunc func(ctx context.Context, aggregatorDID string) (*aggregators.APIKeyInfo, error) 897 + revokeKeyFunc func(ctx context.Context, aggregatorDID string) error 898 + failedLastUsedUpdates int64 899 + failedNonceUpdates int64 900 + } 901 + 902 + func (m *mockAPIKeyService) GenerateKey(ctx context.Context, aggregatorDID string, oauthSession *oauthlib.ClientSessionData) (string, string, error) { 903 + if m.generateKeyFunc != nil { 904 + return m.generateKeyFunc(ctx, aggregatorDID, oauthSession) 905 + } 906 + return "", "", errors.New("not implemented") 907 + } 908 + 909 + func (m *mockAPIKeyService) GetAPIKeyInfo(ctx context.Context, aggregatorDID string) (*aggregators.APIKeyInfo, error) { 910 + if m.getAPIKeyInfoFunc != nil { 911 + return m.getAPIKeyInfoFunc(ctx, aggregatorDID) 912 + } 913 + return nil, errors.New("not implemented") 914 + } 915 + 916 + func (m *mockAPIKeyService) RevokeKey(ctx context.Context, aggregatorDID string) error { 917 + if m.revokeKeyFunc != nil { 918 + return m.revokeKeyFunc(ctx, aggregatorDID) 919 + } 920 + return errors.New("not implemented") 921 + } 922 + 923 + func (m *mockAPIKeyService) GetFailedLastUsedUpdates() int64 { 924 + return m.failedLastUsedUpdates 925 + } 926 + 927 + func (m *mockAPIKeyService) GetFailedNonceUpdates() int64 { 928 + return m.failedNonceUpdates 929 + } 930 + 931 + // Verify mockAPIKeyService implements the interface at compile time 932 + var _ aggregators.APIKeyServiceInterface = (*mockAPIKeyService)(nil) 933 + 934 + func TestCreateAPIKeyHandler_Success_RequiresIntegration(t *testing.T) { 935 + // The CreateAPIKeyHandler.HandleCreateAPIKey method calls: 936 + // 1. middleware.GetUserDID(r) - to get authenticated user 937 + // 2. h.aggregatorService.IsAggregator(ctx, userDID) - to verify aggregator status 938 + // 3. middleware.GetOAuthSession(r) - to get OAuth session 939 + // 4. h.apiKeyService.GenerateKey(ctx, userDID, oauthSession) - to create the key 940 + // 941 + // Since apiKeyService is a concrete *aggregators.APIKeyService (not an interface), 942 + // we cannot mock it directly. Full success path testing requires: 943 + // - A real aggregators.Repository mock 944 + // - A real OAuth store mock 945 + // - Setting up the full APIKeyService with those mocks 946 + // 947 + // This test documents the pattern for integration-style testing with mocks: 948 + 949 + // Create mock repository that tracks calls 950 + createdAt := time.Now() 951 + generateKeyCalled := false 952 + 953 + // Create a custom test that verifies the handler response format when everything works 954 + t.Run("response_format_verification", func(t *testing.T) { 955 + // Verify the expected response format matches what GenerateKey would return 956 + response := CreateAPIKeyResponse{ 957 + Key: "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 958 + KeyPrefix: "ckapi_012345", 959 + DID: "did:plc:aggregator123", 960 + CreatedAt: createdAt.Format("2006-01-02T15:04:05.000Z"), 961 + } 962 + 963 + data, err := json.Marshal(response) 964 + if err != nil { 965 + t.Fatalf("Failed to marshal response: %v", err) 966 + } 967 + 968 + var decoded map[string]interface{} 969 + if err := json.Unmarshal(data, &decoded); err != nil { 970 + t.Fatalf("Failed to unmarshal response: %v", err) 971 + } 972 + 973 + // Verify key format 974 + key, ok := decoded["key"].(string) 975 + if !ok || len(key) != 70 { 976 + t.Errorf("Expected key to be 70 chars, got %d", len(key)) 977 + } 978 + if !ok || key[:6] != "ckapi_" { 979 + t.Errorf("Expected key to start with 'ckapi_', got %s", key[:6]) 980 + } 981 + 982 + // Verify keyPrefix is first 12 chars of key 983 + keyPrefix, ok := decoded["keyPrefix"].(string) 984 + if !ok || keyPrefix != key[:12] { 985 + t.Errorf("Expected keyPrefix to be first 12 chars of key") 986 + } 987 + }) 988 + 989 + // This assertion exists just to use the variable and satisfy the linter 990 + _ = generateKeyCalled 991 + } 992 + 993 + func TestGetAPIKeyHandler_Success_RequiresIntegration(t *testing.T) { 994 + // Similar to CreateAPIKeyHandler, GetAPIKeyHandler uses concrete *aggregators.APIKeyService. 995 + // This test documents the integration test pattern and verifies response format. 996 + 997 + t.Run("response_format_with_active_key", func(t *testing.T) { 998 + createdAt := time.Now().Add(-24 * time.Hour) 999 + lastUsed := time.Now().Add(-1 * time.Hour) 1000 + lastUsedStr := lastUsed.Format("2006-01-02T15:04:05.000Z") 1001 + 1002 + response := GetAPIKeyResponse{ 1003 + HasKey: true, 1004 + KeyInfo: &APIKeyView{ 1005 + Prefix: "ckapi_test12", 1006 + CreatedAt: createdAt.Format("2006-01-02T15:04:05.000Z"), 1007 + LastUsedAt: &lastUsedStr, 1008 + IsRevoked: false, 1009 + }, 1010 + } 1011 + 1012 + data, err := json.Marshal(response) 1013 + if err != nil { 1014 + t.Fatalf("Failed to marshal response: %v", err) 1015 + } 1016 + 1017 + var decoded map[string]interface{} 1018 + if err := json.Unmarshal(data, &decoded); err != nil { 1019 + t.Fatalf("Failed to unmarshal response: %v", err) 1020 + } 1021 + 1022 + // Verify all expected fields are present 1023 + if !decoded["hasKey"].(bool) { 1024 + t.Error("Expected hasKey to be true") 1025 + } 1026 + keyInfo := decoded["keyInfo"].(map[string]interface{}) 1027 + if keyInfo["prefix"] != "ckapi_test12" { 1028 + t.Errorf("Expected prefix 'ckapi_test12', got %v", keyInfo["prefix"]) 1029 + } 1030 + if keyInfo["isRevoked"].(bool) { 1031 + t.Error("Expected isRevoked to be false") 1032 + } 1033 + }) 1034 + 1035 + t.Run("response_format_with_no_key", func(t *testing.T) { 1036 + response := GetAPIKeyResponse{ 1037 + HasKey: false, 1038 + // KeyInfo is nil when hasKey is false 1039 + } 1040 + 1041 + data, err := json.Marshal(response) 1042 + if err != nil { 1043 + t.Fatalf("Failed to marshal response: %v", err) 1044 + } 1045 + 1046 + var decoded map[string]interface{} 1047 + if err := json.Unmarshal(data, &decoded); err != nil { 1048 + t.Fatalf("Failed to unmarshal response: %v", err) 1049 + } 1050 + 1051 + if decoded["hasKey"].(bool) { 1052 + t.Error("Expected hasKey to be false") 1053 + } 1054 + if _, ok := decoded["keyInfo"]; ok { 1055 + t.Error("Expected keyInfo to be omitted when hasKey is false") 1056 + } 1057 + }) 1058 + } 1059 + 1060 + // ============================================================================= 1061 + // Service Error Handling Tests 1062 + // ============================================================================= 1063 + // These tests document the expected error handling behavior when the APIKeyService 1064 + // returns errors. Since handlers use concrete *aggregators.APIKeyService (not an 1065 + // interface), full testing of these paths requires integration tests with mocked 1066 + // repository layer. 1067 + 1068 + func TestRevokeAPIKeyHandler_ServiceError_Documentation(t *testing.T) { 1069 + // Documents expected behavior when RevokeKey returns an error: 1070 + // - Handler should return 500 InternalServerError 1071 + // - Error response should include "RevocationFailed" error code 1072 + // 1073 + // This behavior is tested at the service level and integration level. 1074 + t.Run("expected_error_response", func(t *testing.T) { 1075 + errorResp := struct { 1076 + Error string `json:"error"` 1077 + Message string `json:"message"` 1078 + }{ 1079 + Error: "RevocationFailed", 1080 + Message: "Failed to revoke API key", 1081 + } 1082 + 1083 + data, err := json.Marshal(errorResp) 1084 + if err != nil { 1085 + t.Fatalf("Failed to marshal error response: %v", err) 1086 + } 1087 + 1088 + var decoded map[string]interface{} 1089 + if err := json.Unmarshal(data, &decoded); err != nil { 1090 + t.Fatalf("Failed to unmarshal response: %v", err) 1091 + } 1092 + 1093 + if decoded["error"] != "RevocationFailed" { 1094 + t.Errorf("Expected error 'RevocationFailed', got %v", decoded["error"]) 1095 + } 1096 + }) 1097 + } 1098 + 1099 + func TestCreateAPIKeyHandler_KeyGenerationError_Documentation(t *testing.T) { 1100 + // Documents expected behavior when GenerateKey returns an error: 1101 + // - Handler should return 500 InternalServerError 1102 + // - Error response should include "KeyGenerationFailed" error code 1103 + // 1104 + // This behavior is tested at the service level and integration level. 1105 + t.Run("expected_error_response", func(t *testing.T) { 1106 + errorResp := struct { 1107 + Error string `json:"error"` 1108 + Message string `json:"message"` 1109 + }{ 1110 + Error: "KeyGenerationFailed", 1111 + Message: "Failed to generate API key", 1112 + } 1113 + 1114 + data, err := json.Marshal(errorResp) 1115 + if err != nil { 1116 + t.Fatalf("Failed to marshal error response: %v", err) 1117 + } 1118 + 1119 + var decoded map[string]interface{} 1120 + if err := json.Unmarshal(data, &decoded); err != nil { 1121 + t.Fatalf("Failed to unmarshal response: %v", err) 1122 + } 1123 + 1124 + if decoded["error"] != "KeyGenerationFailed" { 1125 + t.Errorf("Expected error 'KeyGenerationFailed', got %v", decoded["error"]) 1126 + } 1127 + }) 1128 + } 1129 + 1130 + func TestGetAPIKeyHandler_ServiceError_Documentation(t *testing.T) { 1131 + // Documents expected behavior when GetAPIKeyInfo returns an error: 1132 + // - Handler should return 500 InternalServerError 1133 + // - Error response should include "InternalServerError" error code 1134 + // 1135 + // This behavior is tested at the service level and integration level. 1136 + t.Run("expected_error_response", func(t *testing.T) { 1137 + errorResp := struct { 1138 + Error string `json:"error"` 1139 + Message string `json:"message"` 1140 + }{ 1141 + Error: "InternalServerError", 1142 + Message: "Failed to get API key info", 1143 + } 1144 + 1145 + data, err := json.Marshal(errorResp) 1146 + if err != nil { 1147 + t.Fatalf("Failed to marshal error response: %v", err) 1148 + } 1149 + 1150 + var decoded map[string]interface{} 1151 + if err := json.Unmarshal(data, &decoded); err != nil { 1152 + t.Fatalf("Failed to unmarshal response: %v", err) 1153 + } 1154 + 1155 + if decoded["error"] != "InternalServerError" { 1156 + t.Errorf("Expected error 'InternalServerError', got %v", decoded["error"]) 1157 + } 1158 + }) 1159 + }
+102
internal/api/handlers/aggregator/create_api_key.go
··· 1 + package aggregator 2 + 3 + import ( 4 + "errors" 5 + "log" 6 + "net/http" 7 + 8 + "Coves/internal/api/middleware" 9 + "Coves/internal/core/aggregators" 10 + ) 11 + 12 + // CreateAPIKeyHandler handles API key creation for aggregators 13 + type CreateAPIKeyHandler struct { 14 + apiKeyService aggregators.APIKeyServiceInterface 15 + aggregatorService aggregators.Service 16 + } 17 + 18 + // NewCreateAPIKeyHandler creates a new handler for API key creation 19 + func NewCreateAPIKeyHandler(apiKeyService aggregators.APIKeyServiceInterface, aggregatorService aggregators.Service) *CreateAPIKeyHandler { 20 + return &CreateAPIKeyHandler{ 21 + apiKeyService: apiKeyService, 22 + aggregatorService: aggregatorService, 23 + } 24 + } 25 + 26 + // CreateAPIKeyResponse represents the response when creating an API key 27 + type CreateAPIKeyResponse struct { 28 + Key string `json:"key"` // The plain-text key (shown ONCE) 29 + KeyPrefix string `json:"keyPrefix"` // First 12 chars for identification 30 + DID string `json:"did"` // Aggregator DID 31 + CreatedAt string `json:"createdAt"` // ISO8601 timestamp 32 + } 33 + 34 + // HandleCreateAPIKey handles POST /xrpc/social.coves.aggregator.createApiKey 35 + // This endpoint requires OAuth authentication and is only available to registered aggregators. 36 + // The API key is returned ONCE and cannot be retrieved again. 37 + // 38 + // Key Replacement: If an aggregator already has an API key, calling this endpoint will 39 + // generate a new key and replace the existing one. The old key will be immediately 40 + // invalidated and all future requests using the old key will fail authentication. 41 + func (h *CreateAPIKeyHandler) HandleCreateAPIKey(w http.ResponseWriter, r *http.Request) { 42 + if r.Method != http.MethodPost { 43 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 44 + return 45 + } 46 + 47 + // Get authenticated DID from context (set by RequireAuth middleware) 48 + userDID := middleware.GetUserDID(r) 49 + if userDID == "" { 50 + writeError(w, http.StatusUnauthorized, "AuthenticationRequired", "Must be authenticated to create API key") 51 + return 52 + } 53 + 54 + // Verify the caller is a registered aggregator 55 + isAggregator, err := h.aggregatorService.IsAggregator(r.Context(), userDID) 56 + if err != nil { 57 + log.Printf("ERROR: Failed to check aggregator status: %v", err) 58 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to verify aggregator status") 59 + return 60 + } 61 + if !isAggregator { 62 + writeError(w, http.StatusForbidden, "AggregatorRequired", "Only registered aggregators can create API keys") 63 + return 64 + } 65 + 66 + // Get the OAuth session from context 67 + oauthSession := middleware.GetOAuthSession(r) 68 + if oauthSession == nil { 69 + writeError(w, http.StatusUnauthorized, "OAuthSessionRequired", "OAuth session required to create API key") 70 + return 71 + } 72 + 73 + // Generate the API key 74 + plainKey, keyPrefix, err := h.apiKeyService.GenerateKey(r.Context(), userDID, oauthSession) 75 + if err != nil { 76 + log.Printf("ERROR: Failed to generate API key for %s: %v", userDID, err) 77 + 78 + // Differentiate error types for appropriate HTTP status codes 79 + switch { 80 + case aggregators.IsNotFound(err): 81 + // Aggregator not found in database - should not happen if IsAggregator check passed 82 + writeError(w, http.StatusForbidden, "AggregatorRequired", "User is not a registered aggregator") 83 + case errors.Is(err, aggregators.ErrOAuthSessionMismatch): 84 + // OAuth session DID doesn't match the requested aggregator DID 85 + writeError(w, http.StatusBadRequest, "SessionMismatch", "OAuth session does not match the requested aggregator") 86 + default: 87 + // All other errors are internal server errors 88 + writeError(w, http.StatusInternalServerError, "KeyGenerationFailed", "Failed to generate API key") 89 + } 90 + return 91 + } 92 + 93 + // Return the key (shown ONCE only) 94 + response := CreateAPIKeyResponse{ 95 + Key: plainKey, 96 + KeyPrefix: keyPrefix, 97 + DID: userDID, 98 + CreatedAt: formatTimestamp(), 99 + } 100 + 101 + writeJSONResponse(w, http.StatusOK, response) 102 + }
+109
internal/api/handlers/aggregator/get_api_key.go
··· 1 + package aggregator 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + 7 + "Coves/internal/api/middleware" 8 + "Coves/internal/core/aggregators" 9 + ) 10 + 11 + // GetAPIKeyHandler handles API key info retrieval for aggregators 12 + type GetAPIKeyHandler struct { 13 + apiKeyService aggregators.APIKeyServiceInterface 14 + aggregatorService aggregators.Service 15 + } 16 + 17 + // NewGetAPIKeyHandler creates a new handler for API key info retrieval 18 + func NewGetAPIKeyHandler(apiKeyService aggregators.APIKeyServiceInterface, aggregatorService aggregators.Service) *GetAPIKeyHandler { 19 + return &GetAPIKeyHandler{ 20 + apiKeyService: apiKeyService, 21 + aggregatorService: aggregatorService, 22 + } 23 + } 24 + 25 + // APIKeyView represents the nested key metadata (matches social.coves.aggregator.defs#apiKeyView) 26 + type APIKeyView struct { 27 + Prefix string `json:"prefix"` // First 12 chars for identification 28 + CreatedAt string `json:"createdAt"` // ISO8601 timestamp when key was created 29 + LastUsedAt *string `json:"lastUsedAt,omitempty"` // ISO8601 timestamp when key was last used 30 + IsRevoked bool `json:"isRevoked"` // Whether the key has been revoked 31 + RevokedAt *string `json:"revokedAt,omitempty"` // ISO8601 timestamp when key was revoked 32 + } 33 + 34 + // GetAPIKeyResponse represents the response when getting API key info 35 + type GetAPIKeyResponse struct { 36 + HasKey bool `json:"hasKey"` // Whether the aggregator has an API key 37 + KeyInfo *APIKeyView `json:"keyInfo,omitempty"` // Key metadata (only present if hasKey is true) 38 + } 39 + 40 + // HandleGetAPIKey handles GET /xrpc/social.coves.aggregator.getApiKey 41 + // This endpoint requires OAuth authentication and returns info about the aggregator's API key. 42 + // NOTE: The actual key value is NEVER returned - only metadata about the key. 43 + func (h *GetAPIKeyHandler) HandleGetAPIKey(w http.ResponseWriter, r *http.Request) { 44 + if r.Method != http.MethodGet { 45 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 46 + return 47 + } 48 + 49 + // Get authenticated DID from context (set by RequireAuth middleware) 50 + userDID := middleware.GetUserDID(r) 51 + if userDID == "" { 52 + writeError(w, http.StatusUnauthorized, "AuthenticationRequired", "Must be authenticated to get API key info") 53 + return 54 + } 55 + 56 + // Verify the caller is a registered aggregator 57 + isAggregator, err := h.aggregatorService.IsAggregator(r.Context(), userDID) 58 + if err != nil { 59 + log.Printf("ERROR: Failed to check aggregator status: %v", err) 60 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to verify aggregator status") 61 + return 62 + } 63 + if !isAggregator { 64 + writeError(w, http.StatusForbidden, "AggregatorRequired", "Only registered aggregators can get API key info") 65 + return 66 + } 67 + 68 + // Get API key info 69 + keyInfo, err := h.apiKeyService.GetAPIKeyInfo(r.Context(), userDID) 70 + if err != nil { 71 + if aggregators.IsNotFound(err) { 72 + writeError(w, http.StatusNotFound, "AggregatorNotFound", "Aggregator not found") 73 + return 74 + } 75 + log.Printf("ERROR: Failed to get API key info for %s: %v", userDID, err) 76 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to get API key info") 77 + return 78 + } 79 + 80 + // Build response 81 + response := GetAPIKeyResponse{ 82 + HasKey: keyInfo.HasKey, 83 + } 84 + 85 + if keyInfo.HasKey { 86 + view := &APIKeyView{ 87 + Prefix: keyInfo.KeyPrefix, 88 + IsRevoked: keyInfo.IsRevoked, 89 + } 90 + 91 + if keyInfo.CreatedAt != nil { 92 + view.CreatedAt = keyInfo.CreatedAt.Format("2006-01-02T15:04:05.000Z") 93 + } 94 + 95 + if keyInfo.LastUsedAt != nil { 96 + ts := keyInfo.LastUsedAt.Format("2006-01-02T15:04:05.000Z") 97 + view.LastUsedAt = &ts 98 + } 99 + 100 + if keyInfo.RevokedAt != nil { 101 + ts := keyInfo.RevokedAt.Format("2006-01-02T15:04:05.000Z") 102 + view.RevokedAt = &ts 103 + } 104 + 105 + response.KeyInfo = view 106 + } 107 + 108 + writeJSONResponse(w, http.StatusOK, response) 109 + }
+99
internal/api/handlers/aggregator/revoke_api_key.go
··· 1 + package aggregator 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "time" 7 + 8 + "Coves/internal/api/middleware" 9 + "Coves/internal/core/aggregators" 10 + ) 11 + 12 + // RevokeAPIKeyHandler handles API key revocation for aggregators 13 + type RevokeAPIKeyHandler struct { 14 + apiKeyService aggregators.APIKeyServiceInterface 15 + aggregatorService aggregators.Service 16 + } 17 + 18 + // NewRevokeAPIKeyHandler creates a new handler for API key revocation 19 + func NewRevokeAPIKeyHandler(apiKeyService aggregators.APIKeyServiceInterface, aggregatorService aggregators.Service) *RevokeAPIKeyHandler { 20 + return &RevokeAPIKeyHandler{ 21 + apiKeyService: apiKeyService, 22 + aggregatorService: aggregatorService, 23 + } 24 + } 25 + 26 + // RevokeAPIKeyResponse represents the response when revoking an API key 27 + type RevokeAPIKeyResponse struct { 28 + RevokedAt string `json:"revokedAt"` // ISO8601 timestamp when key was revoked 29 + } 30 + 31 + // HandleRevokeAPIKey handles POST /xrpc/social.coves.aggregator.revokeApiKey 32 + // This endpoint requires OAuth authentication and revokes the aggregator's current API key. 33 + // After revocation, the aggregator must complete OAuth flow again to get a new key. 34 + func (h *RevokeAPIKeyHandler) HandleRevokeAPIKey(w http.ResponseWriter, r *http.Request) { 35 + if r.Method != http.MethodPost { 36 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 37 + return 38 + } 39 + 40 + // Get authenticated DID from context (set by RequireAuth middleware) 41 + userDID := middleware.GetUserDID(r) 42 + if userDID == "" { 43 + writeError(w, http.StatusUnauthorized, "AuthenticationRequired", "Must be authenticated to revoke API key") 44 + return 45 + } 46 + 47 + // Verify the caller is a registered aggregator 48 + isAggregator, err := h.aggregatorService.IsAggregator(r.Context(), userDID) 49 + if err != nil { 50 + log.Printf("ERROR: Failed to check aggregator status: %v", err) 51 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to verify aggregator status") 52 + return 53 + } 54 + if !isAggregator { 55 + writeError(w, http.StatusForbidden, "AggregatorRequired", "Only registered aggregators can revoke API keys") 56 + return 57 + } 58 + 59 + // Check if the aggregator has an API key to revoke 60 + keyInfo, err := h.apiKeyService.GetAPIKeyInfo(r.Context(), userDID) 61 + if err != nil { 62 + if aggregators.IsNotFound(err) { 63 + writeError(w, http.StatusNotFound, "AggregatorNotFound", "Aggregator not found") 64 + return 65 + } 66 + log.Printf("ERROR: Failed to get API key info for %s: %v", userDID, err) 67 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to get API key info") 68 + return 69 + } 70 + 71 + if !keyInfo.HasKey { 72 + writeError(w, http.StatusBadRequest, "ApiKeyNotFound", "No API key exists to revoke") 73 + return 74 + } 75 + 76 + if keyInfo.IsRevoked { 77 + writeError(w, http.StatusBadRequest, "ApiKeyAlreadyRevoked", "API key has already been revoked") 78 + return 79 + } 80 + 81 + // Revoke the API key 82 + if err := h.apiKeyService.RevokeKey(r.Context(), userDID); err != nil { 83 + log.Printf("ERROR: Failed to revoke API key for %s: %v", userDID, err) 84 + writeError(w, http.StatusInternalServerError, "RevocationFailed", "Failed to revoke API key") 85 + return 86 + } 87 + 88 + // Return success 89 + response := RevokeAPIKeyResponse{ 90 + RevokedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), 91 + } 92 + 93 + writeJSONResponse(w, http.StatusOK, response) 94 + } 95 + 96 + // formatTimestamp returns current time in ISO8601 format 97 + func formatTimestamp() string { 98 + return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") 99 + }
+45
internal/api/middleware/apikey_adapter.go
··· 1 + package middleware 2 + 3 + import ( 4 + "Coves/internal/core/aggregators" 5 + "context" 6 + ) 7 + 8 + // APIKeyValidatorAdapter adapts the aggregators.APIKeyService to the middleware.APIKeyValidator interface 9 + type APIKeyValidatorAdapter struct { 10 + service *aggregators.APIKeyService 11 + } 12 + 13 + // NewAPIKeyValidatorAdapter creates a new adapter for API key validation 14 + func NewAPIKeyValidatorAdapter(service *aggregators.APIKeyService) *APIKeyValidatorAdapter { 15 + return &APIKeyValidatorAdapter{ 16 + service: service, 17 + } 18 + } 19 + 20 + // ValidateKey validates an API key and returns the aggregator DID if valid 21 + func (a *APIKeyValidatorAdapter) ValidateKey(ctx context.Context, plainKey string) (string, error) { 22 + creds, err := a.service.ValidateKey(ctx, plainKey) 23 + if err != nil { 24 + return "", err 25 + } 26 + return creds.DID, nil 27 + } 28 + 29 + // RefreshTokensIfNeeded refreshes OAuth tokens for the aggregator if they are expired 30 + func (a *APIKeyValidatorAdapter) RefreshTokensIfNeeded(ctx context.Context, aggregatorDID string) error { 31 + creds, err := a.service.GetAggregatorCredentials(ctx, aggregatorDID) 32 + if err != nil { 33 + return err 34 + } 35 + 36 + if creds.APIKeyRevokedAt != nil { 37 + return aggregators.ErrAPIKeyRevoked 38 + } 39 + 40 + if creds.APIKeyHash == "" { 41 + return aggregators.ErrAPIKeyInvalid 42 + } 43 + 44 + return a.service.RefreshTokensIfNeeded(ctx, creds) 45 + }
+578
internal/api/middleware/apikey_adapter_test.go
··· 1 + package middleware 2 + 3 + import ( 4 + "Coves/internal/core/aggregators" 5 + "context" 6 + "errors" 7 + "testing" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + // minimalMockOAuthStore implements oauth.SessionStore for testing. 15 + // This is a minimal implementation that just returns errors, used for tests 16 + // that don't actually need OAuth functionality. 17 + type minimalMockOAuthStore struct{} 18 + 19 + func (m *minimalMockOAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 20 + return nil, errors.New("session not found") 21 + } 22 + 23 + func (m *minimalMockOAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 24 + return nil 25 + } 26 + 27 + func (m *minimalMockOAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 28 + return nil 29 + } 30 + 31 + func (m *minimalMockOAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 32 + return nil, errors.New("not found") 33 + } 34 + 35 + func (m *minimalMockOAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 36 + return nil 37 + } 38 + 39 + func (m *minimalMockOAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 40 + return nil 41 + } 42 + 43 + // newTestAPIKeyService creates an APIKeyService with mock dependencies for testing. 44 + // This helper ensures tests don't panic from nil checks added in constructor validation. 45 + func newTestAPIKeyService(repo aggregators.Repository) *aggregators.APIKeyService { 46 + mockStore := &minimalMockOAuthStore{} 47 + mockApp := &oauth.ClientApp{Store: mockStore} 48 + return aggregators.NewAPIKeyService(repo, mockApp) 49 + } 50 + 51 + // mockAPIKeyServiceRepository implements aggregators.Repository for testing 52 + type mockAPIKeyServiceRepository struct { 53 + getAggregatorFunc func(ctx context.Context, did string) (*aggregators.Aggregator, error) 54 + getByAPIKeyHashFunc func(ctx context.Context, keyHash string) (*aggregators.Aggregator, error) 55 + getCredentialsByAPIKeyHashFunc func(ctx context.Context, keyHash string) (*aggregators.AggregatorCredentials, error) 56 + getAggregatorCredentialsFunc func(ctx context.Context, did string) (*aggregators.AggregatorCredentials, error) 57 + setAPIKeyFunc func(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *aggregators.OAuthCredentials) error 58 + updateOAuthTokensFunc func(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error 59 + updateOAuthNoncesFunc func(ctx context.Context, did, authServerNonce, pdsNonce string) error 60 + updateAPIKeyLastUsedFunc func(ctx context.Context, did string) error 61 + revokeAPIKeyFunc func(ctx context.Context, did string) error 62 + } 63 + 64 + func (m *mockAPIKeyServiceRepository) GetAggregator(ctx context.Context, did string) (*aggregators.Aggregator, error) { 65 + if m.getAggregatorFunc != nil { 66 + return m.getAggregatorFunc(ctx, did) 67 + } 68 + return &aggregators.Aggregator{DID: did, DisplayName: "Test Aggregator"}, nil 69 + } 70 + 71 + func (m *mockAPIKeyServiceRepository) GetByAPIKeyHash(ctx context.Context, keyHash string) (*aggregators.Aggregator, error) { 72 + if m.getByAPIKeyHashFunc != nil { 73 + return m.getByAPIKeyHashFunc(ctx, keyHash) 74 + } 75 + return nil, aggregators.ErrAggregatorNotFound 76 + } 77 + 78 + func (m *mockAPIKeyServiceRepository) SetAPIKey(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *aggregators.OAuthCredentials) error { 79 + if m.setAPIKeyFunc != nil { 80 + return m.setAPIKeyFunc(ctx, did, keyPrefix, keyHash, oauthCreds) 81 + } 82 + return nil 83 + } 84 + 85 + func (m *mockAPIKeyServiceRepository) UpdateOAuthTokens(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error { 86 + if m.updateOAuthTokensFunc != nil { 87 + return m.updateOAuthTokensFunc(ctx, did, accessToken, refreshToken, expiresAt) 88 + } 89 + return nil 90 + } 91 + 92 + func (m *mockAPIKeyServiceRepository) UpdateOAuthNonces(ctx context.Context, did, authServerNonce, pdsNonce string) error { 93 + if m.updateOAuthNoncesFunc != nil { 94 + return m.updateOAuthNoncesFunc(ctx, did, authServerNonce, pdsNonce) 95 + } 96 + return nil 97 + } 98 + 99 + func (m *mockAPIKeyServiceRepository) UpdateAPIKeyLastUsed(ctx context.Context, did string) error { 100 + if m.updateAPIKeyLastUsedFunc != nil { 101 + return m.updateAPIKeyLastUsedFunc(ctx, did) 102 + } 103 + return nil 104 + } 105 + 106 + func (m *mockAPIKeyServiceRepository) RevokeAPIKey(ctx context.Context, did string) error { 107 + if m.revokeAPIKeyFunc != nil { 108 + return m.revokeAPIKeyFunc(ctx, did) 109 + } 110 + return nil 111 + } 112 + 113 + // Stub implementations for Repository interface methods not used in APIKeyService tests 114 + func (m *mockAPIKeyServiceRepository) CreateAggregator(ctx context.Context, aggregator *aggregators.Aggregator) error { 115 + return nil 116 + } 117 + 118 + func (m *mockAPIKeyServiceRepository) GetAggregatorsByDIDs(ctx context.Context, dids []string) ([]*aggregators.Aggregator, error) { 119 + return nil, nil 120 + } 121 + 122 + func (m *mockAPIKeyServiceRepository) UpdateAggregator(ctx context.Context, aggregator *aggregators.Aggregator) error { 123 + return nil 124 + } 125 + 126 + func (m *mockAPIKeyServiceRepository) DeleteAggregator(ctx context.Context, did string) error { 127 + return nil 128 + } 129 + 130 + func (m *mockAPIKeyServiceRepository) ListAggregators(ctx context.Context, limit, offset int) ([]*aggregators.Aggregator, error) { 131 + return nil, nil 132 + } 133 + 134 + func (m *mockAPIKeyServiceRepository) IsAggregator(ctx context.Context, did string) (bool, error) { 135 + return false, nil 136 + } 137 + 138 + func (m *mockAPIKeyServiceRepository) CreateAuthorization(ctx context.Context, auth *aggregators.Authorization) error { 139 + return nil 140 + } 141 + 142 + func (m *mockAPIKeyServiceRepository) GetAuthorization(ctx context.Context, aggregatorDID, communityDID string) (*aggregators.Authorization, error) { 143 + return nil, nil 144 + } 145 + 146 + func (m *mockAPIKeyServiceRepository) GetAuthorizationByURI(ctx context.Context, recordURI string) (*aggregators.Authorization, error) { 147 + return nil, nil 148 + } 149 + 150 + func (m *mockAPIKeyServiceRepository) UpdateAuthorization(ctx context.Context, auth *aggregators.Authorization) error { 151 + return nil 152 + } 153 + 154 + func (m *mockAPIKeyServiceRepository) DeleteAuthorization(ctx context.Context, aggregatorDID, communityDID string) error { 155 + return nil 156 + } 157 + 158 + func (m *mockAPIKeyServiceRepository) DeleteAuthorizationByURI(ctx context.Context, recordURI string) error { 159 + return nil 160 + } 161 + 162 + func (m *mockAPIKeyServiceRepository) ListAuthorizationsForAggregator(ctx context.Context, aggregatorDID string, enabledOnly bool, limit, offset int) ([]*aggregators.Authorization, error) { 163 + return nil, nil 164 + } 165 + 166 + func (m *mockAPIKeyServiceRepository) ListAuthorizationsForCommunity(ctx context.Context, communityDID string, enabledOnly bool, limit, offset int) ([]*aggregators.Authorization, error) { 167 + return nil, nil 168 + } 169 + 170 + func (m *mockAPIKeyServiceRepository) IsAuthorized(ctx context.Context, aggregatorDID, communityDID string) (bool, error) { 171 + return false, nil 172 + } 173 + 174 + func (m *mockAPIKeyServiceRepository) RecordAggregatorPost(ctx context.Context, aggregatorDID, communityDID, postURI, postCID string) error { 175 + return nil 176 + } 177 + 178 + func (m *mockAPIKeyServiceRepository) CountRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) (int, error) { 179 + return 0, nil 180 + } 181 + 182 + func (m *mockAPIKeyServiceRepository) GetRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) ([]*aggregators.AggregatorPost, error) { 183 + return nil, nil 184 + } 185 + 186 + func (m *mockAPIKeyServiceRepository) GetAggregatorCredentials(ctx context.Context, did string) (*aggregators.AggregatorCredentials, error) { 187 + if m.getAggregatorCredentialsFunc != nil { 188 + return m.getAggregatorCredentialsFunc(ctx, did) 189 + } 190 + return &aggregators.AggregatorCredentials{DID: did}, nil 191 + } 192 + 193 + func (m *mockAPIKeyServiceRepository) GetCredentialsByAPIKeyHash(ctx context.Context, keyHash string) (*aggregators.AggregatorCredentials, error) { 194 + if m.getCredentialsByAPIKeyHashFunc != nil { 195 + return m.getCredentialsByAPIKeyHashFunc(ctx, keyHash) 196 + } 197 + return nil, aggregators.ErrAggregatorNotFound 198 + } 199 + 200 + func (m *mockAPIKeyServiceRepository) ListAggregatorsNeedingTokenRefresh(ctx context.Context, expiryBuffer time.Duration) ([]*aggregators.AggregatorCredentials, error) { 201 + return nil, nil 202 + } 203 + 204 + // ============================================================================= 205 + // ValidateKey Delegation Tests 206 + // ============================================================================= 207 + 208 + func TestAPIKeyValidatorAdapter_ValidateKey_DelegatesToService(t *testing.T) { 209 + expectedDID := "did:plc:aggregator123" 210 + 211 + repo := &mockAPIKeyServiceRepository{ 212 + getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*aggregators.AggregatorCredentials, error) { 213 + return &aggregators.AggregatorCredentials{ 214 + DID: expectedDID, 215 + APIKeyHash: keyHash, 216 + APIKeyPrefix: "ckapi_0123", 217 + }, nil 218 + }, 219 + updateAPIKeyLastUsedFunc: func(ctx context.Context, did string) error { 220 + return nil 221 + }, 222 + } 223 + 224 + service := newTestAPIKeyService(repo) 225 + adapter := NewAPIKeyValidatorAdapter(service) 226 + 227 + validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 228 + did, err := adapter.ValidateKey(context.Background(), validKey) 229 + if err != nil { 230 + t.Fatalf("ValidateKey() unexpected error: %v", err) 231 + } 232 + 233 + if did != expectedDID { 234 + t.Errorf("ValidateKey() = %s, want %s", did, expectedDID) 235 + } 236 + } 237 + 238 + func TestAPIKeyValidatorAdapter_ValidateKey_InvalidKey(t *testing.T) { 239 + repo := &mockAPIKeyServiceRepository{} 240 + service := newTestAPIKeyService(repo) 241 + adapter := NewAPIKeyValidatorAdapter(service) 242 + 243 + // Test various invalid key formats 244 + tests := []struct { 245 + name string 246 + key string 247 + }{ 248 + {"empty key", ""}, 249 + {"too short", "ckapi_short"}, 250 + {"wrong prefix", "wrong_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, 251 + } 252 + 253 + for _, tt := range tests { 254 + t.Run(tt.name, func(t *testing.T) { 255 + _, err := adapter.ValidateKey(context.Background(), tt.key) 256 + if err == nil { 257 + t.Error("ValidateKey() expected error, got nil") 258 + } 259 + if !errors.Is(err, aggregators.ErrAPIKeyInvalid) { 260 + t.Errorf("ValidateKey() error = %v, want %v", err, aggregators.ErrAPIKeyInvalid) 261 + } 262 + }) 263 + } 264 + } 265 + 266 + func TestAPIKeyValidatorAdapter_ValidateKey_NotFound(t *testing.T) { 267 + repo := &mockAPIKeyServiceRepository{ 268 + getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*aggregators.AggregatorCredentials, error) { 269 + return nil, aggregators.ErrAggregatorNotFound 270 + }, 271 + } 272 + 273 + service := newTestAPIKeyService(repo) 274 + adapter := NewAPIKeyValidatorAdapter(service) 275 + 276 + validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 277 + _, err := adapter.ValidateKey(context.Background(), validKey) 278 + if err == nil { 279 + t.Error("ValidateKey() expected error, got nil") 280 + } 281 + // Should return ErrAPIKeyInvalid when key not found 282 + if !errors.Is(err, aggregators.ErrAPIKeyInvalid) { 283 + t.Errorf("ValidateKey() error = %v, want %v", err, aggregators.ErrAPIKeyInvalid) 284 + } 285 + } 286 + 287 + func TestAPIKeyValidatorAdapter_ValidateKey_Revoked(t *testing.T) { 288 + repo := &mockAPIKeyServiceRepository{ 289 + getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*aggregators.AggregatorCredentials, error) { 290 + return nil, aggregators.ErrAPIKeyRevoked 291 + }, 292 + } 293 + 294 + service := newTestAPIKeyService(repo) 295 + adapter := NewAPIKeyValidatorAdapter(service) 296 + 297 + validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 298 + _, err := adapter.ValidateKey(context.Background(), validKey) 299 + if err == nil { 300 + t.Error("ValidateKey() expected error, got nil") 301 + } 302 + if !errors.Is(err, aggregators.ErrAPIKeyRevoked) { 303 + t.Errorf("ValidateKey() error = %v, want %v", err, aggregators.ErrAPIKeyRevoked) 304 + } 305 + } 306 + 307 + func TestAPIKeyValidatorAdapter_ValidateKey_RepositoryError(t *testing.T) { 308 + expectedError := errors.New("database connection failed") 309 + 310 + repo := &mockAPIKeyServiceRepository{ 311 + getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*aggregators.AggregatorCredentials, error) { 312 + return nil, expectedError 313 + }, 314 + } 315 + 316 + service := newTestAPIKeyService(repo) 317 + adapter := NewAPIKeyValidatorAdapter(service) 318 + 319 + validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 320 + _, err := adapter.ValidateKey(context.Background(), validKey) 321 + if err == nil { 322 + t.Error("ValidateKey() expected error, got nil") 323 + } 324 + } 325 + 326 + // ============================================================================= 327 + // RefreshTokensIfNeeded Delegation Tests 328 + // ============================================================================= 329 + 330 + func TestAPIKeyValidatorAdapter_RefreshTokensIfNeeded_DelegatesToService(t *testing.T) { 331 + // Tokens expire in 1 hour - well beyond the 5 minute buffer, so no refresh needed 332 + expiresAt := time.Now().Add(1 * time.Hour) 333 + aggregatorDID := "did:plc:aggregator123" 334 + 335 + repo := &mockAPIKeyServiceRepository{ 336 + getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*aggregators.AggregatorCredentials, error) { 337 + return &aggregators.AggregatorCredentials{ 338 + DID: did, 339 + APIKeyHash: "somehash", 340 + OAuthTokenExpiresAt: &expiresAt, 341 + }, nil 342 + }, 343 + } 344 + 345 + service := newTestAPIKeyService(repo) 346 + adapter := NewAPIKeyValidatorAdapter(service) 347 + 348 + err := adapter.RefreshTokensIfNeeded(context.Background(), aggregatorDID) 349 + if err != nil { 350 + t.Fatalf("RefreshTokensIfNeeded() unexpected error: %v", err) 351 + } 352 + } 353 + 354 + func TestAPIKeyValidatorAdapter_RefreshTokensIfNeeded_AggregatorNotFound(t *testing.T) { 355 + repo := &mockAPIKeyServiceRepository{ 356 + getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*aggregators.AggregatorCredentials, error) { 357 + return nil, aggregators.ErrAggregatorNotFound 358 + }, 359 + } 360 + 361 + service := newTestAPIKeyService(repo) 362 + adapter := NewAPIKeyValidatorAdapter(service) 363 + 364 + err := adapter.RefreshTokensIfNeeded(context.Background(), "did:plc:nonexistent") 365 + if err == nil { 366 + t.Error("RefreshTokensIfNeeded() expected error, got nil") 367 + } 368 + if !errors.Is(err, aggregators.ErrAggregatorNotFound) { 369 + t.Errorf("RefreshTokensIfNeeded() error = %v, want %v", err, aggregators.ErrAggregatorNotFound) 370 + } 371 + } 372 + 373 + func TestAPIKeyValidatorAdapter_RefreshTokensIfNeeded_NoAPIKey(t *testing.T) { 374 + aggregatorDID := "did:plc:aggregator123" 375 + 376 + repo := &mockAPIKeyServiceRepository{ 377 + getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*aggregators.AggregatorCredentials, error) { 378 + return &aggregators.AggregatorCredentials{ 379 + DID: did, 380 + APIKeyHash: "", // No API key 381 + }, nil 382 + }, 383 + } 384 + 385 + service := newTestAPIKeyService(repo) 386 + adapter := NewAPIKeyValidatorAdapter(service) 387 + 388 + // Should return ErrAPIKeyInvalid when no API key exists 389 + err := adapter.RefreshTokensIfNeeded(context.Background(), aggregatorDID) 390 + if !errors.Is(err, aggregators.ErrAPIKeyInvalid) { 391 + t.Errorf("RefreshTokensIfNeeded() error = %v, want %v", err, aggregators.ErrAPIKeyInvalid) 392 + } 393 + } 394 + 395 + func TestAPIKeyValidatorAdapter_RefreshTokensIfNeeded_RevokedAPIKey(t *testing.T) { 396 + aggregatorDID := "did:plc:aggregator123" 397 + revokedAt := time.Now().Add(-1 * time.Hour) 398 + 399 + repo := &mockAPIKeyServiceRepository{ 400 + getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*aggregators.AggregatorCredentials, error) { 401 + return &aggregators.AggregatorCredentials{ 402 + DID: did, 403 + APIKeyHash: "somehash", 404 + APIKeyRevokedAt: &revokedAt, // Key is revoked 405 + }, nil 406 + }, 407 + } 408 + 409 + service := newTestAPIKeyService(repo) 410 + adapter := NewAPIKeyValidatorAdapter(service) 411 + 412 + // Should return ErrAPIKeyRevoked when API key is revoked 413 + err := adapter.RefreshTokensIfNeeded(context.Background(), aggregatorDID) 414 + if !errors.Is(err, aggregators.ErrAPIKeyRevoked) { 415 + t.Errorf("RefreshTokensIfNeeded() error = %v, want %v", err, aggregators.ErrAPIKeyRevoked) 416 + } 417 + } 418 + 419 + func TestAPIKeyValidatorAdapter_RefreshTokensIfNeeded_RepositoryError(t *testing.T) { 420 + expectedError := errors.New("database connection failed") 421 + 422 + repo := &mockAPIKeyServiceRepository{ 423 + getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*aggregators.AggregatorCredentials, error) { 424 + return nil, expectedError 425 + }, 426 + } 427 + 428 + service := newTestAPIKeyService(repo) 429 + adapter := NewAPIKeyValidatorAdapter(service) 430 + 431 + err := adapter.RefreshTokensIfNeeded(context.Background(), "did:plc:aggregator123") 432 + if err == nil { 433 + t.Error("RefreshTokensIfNeeded() expected error, got nil") 434 + } 435 + } 436 + 437 + // ============================================================================= 438 + // GetAPIKeyInfo Delegation Tests (via service) 439 + // ============================================================================= 440 + 441 + func TestAPIKeyValidatorAdapter_GetAggregator_DelegatesToService(t *testing.T) { 442 + expectedDID := "did:plc:aggregator123" 443 + expectedDisplayName := "Test Aggregator" 444 + 445 + repo := &mockAPIKeyServiceRepository{ 446 + getAggregatorFunc: func(ctx context.Context, did string) (*aggregators.Aggregator, error) { 447 + return &aggregators.Aggregator{ 448 + DID: expectedDID, 449 + DisplayName: expectedDisplayName, 450 + }, nil 451 + }, 452 + } 453 + 454 + service := newTestAPIKeyService(repo) 455 + 456 + // Test that GetAggregator is properly delegated 457 + aggregator, err := service.GetAggregator(context.Background(), expectedDID) 458 + if err != nil { 459 + t.Fatalf("GetAggregator() unexpected error: %v", err) 460 + } 461 + 462 + if aggregator.DID != expectedDID { 463 + t.Errorf("GetAggregator() DID = %s, want %s", aggregator.DID, expectedDID) 464 + } 465 + if aggregator.DisplayName != expectedDisplayName { 466 + t.Errorf("GetAggregator() DisplayName = %s, want %s", aggregator.DisplayName, expectedDisplayName) 467 + } 468 + } 469 + 470 + func TestAPIKeyValidatorAdapter_GetAggregator_NotFound(t *testing.T) { 471 + repo := &mockAPIKeyServiceRepository{ 472 + getAggregatorFunc: func(ctx context.Context, did string) (*aggregators.Aggregator, error) { 473 + return nil, aggregators.ErrAggregatorNotFound 474 + }, 475 + } 476 + 477 + service := newTestAPIKeyService(repo) 478 + 479 + _, err := service.GetAggregator(context.Background(), "did:plc:nonexistent") 480 + if !errors.Is(err, aggregators.ErrAggregatorNotFound) { 481 + t.Errorf("GetAggregator() error = %v, want %v", err, aggregators.ErrAggregatorNotFound) 482 + } 483 + } 484 + 485 + func TestAPIKeyValidatorAdapter_GetAggregator_RepositoryError(t *testing.T) { 486 + expectedError := errors.New("database error") 487 + 488 + repo := &mockAPIKeyServiceRepository{ 489 + getAggregatorFunc: func(ctx context.Context, did string) (*aggregators.Aggregator, error) { 490 + return nil, expectedError 491 + }, 492 + } 493 + 494 + service := newTestAPIKeyService(repo) 495 + 496 + _, err := service.GetAggregator(context.Background(), "did:plc:aggregator123") 497 + if err == nil { 498 + t.Error("GetAggregator() expected error, got nil") 499 + } 500 + } 501 + 502 + // ============================================================================= 503 + // Constructor and nil handling tests 504 + // ============================================================================= 505 + 506 + func TestNewAPIKeyValidatorAdapter(t *testing.T) { 507 + repo := &mockAPIKeyServiceRepository{} 508 + service := newTestAPIKeyService(repo) 509 + 510 + adapter := NewAPIKeyValidatorAdapter(service) 511 + if adapter == nil { 512 + t.Fatal("NewAPIKeyValidatorAdapter() returned nil") 513 + } 514 + } 515 + 516 + // ============================================================================= 517 + // Integration-style test: Full validation flow 518 + // ============================================================================= 519 + 520 + func TestAPIKeyValidatorAdapter_FullValidationFlow(t *testing.T) { 521 + // This test verifies the complete flow: 522 + // 1. Validate API key 523 + // 2. Check if tokens need refresh 524 + // 3. Return aggregator DID 525 + 526 + aggregatorDID := "did:plc:aggregator123" 527 + expiresAt := time.Now().Add(1 * time.Hour) 528 + validationCount := 0 529 + 530 + repo := &mockAPIKeyServiceRepository{ 531 + getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*aggregators.AggregatorCredentials, error) { 532 + validationCount++ 533 + return &aggregators.AggregatorCredentials{ 534 + DID: aggregatorDID, 535 + APIKeyHash: keyHash, 536 + APIKeyPrefix: "ckapi_0123", 537 + OAuthTokenExpiresAt: &expiresAt, 538 + }, nil 539 + }, 540 + getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*aggregators.AggregatorCredentials, error) { 541 + return &aggregators.AggregatorCredentials{ 542 + DID: did, 543 + APIKeyHash: "somehash", 544 + OAuthTokenExpiresAt: &expiresAt, 545 + }, nil 546 + }, 547 + updateAPIKeyLastUsedFunc: func(ctx context.Context, did string) error { 548 + return nil 549 + }, 550 + } 551 + 552 + service := newTestAPIKeyService(repo) 553 + adapter := NewAPIKeyValidatorAdapter(service) 554 + 555 + // Step 1: Validate the key 556 + validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 557 + did, err := adapter.ValidateKey(context.Background(), validKey) 558 + if err != nil { 559 + t.Fatalf("ValidateKey() unexpected error: %v", err) 560 + } 561 + if did != aggregatorDID { 562 + t.Errorf("ValidateKey() DID = %s, want %s", did, aggregatorDID) 563 + } 564 + 565 + // Step 2: Check/refresh tokens (should succeed without refresh since tokens are valid) 566 + err = adapter.RefreshTokensIfNeeded(context.Background(), did) 567 + if err != nil { 568 + t.Errorf("RefreshTokensIfNeeded() unexpected error: %v", err) 569 + } 570 + 571 + // Verify validation was called 572 + if validationCount != 1 { 573 + t.Errorf("Expected 1 validation call, got %d", validationCount) 574 + } 575 + } 576 + 577 + // Ensure we don't have unused import 578 + var _ = oauth.ClientApp{}
+37
internal/api/routes/aggregator.go
··· 57 57 // POST /xrpc/social.coves.aggregator.disable (requires auth + moderator) 58 58 // POST /xrpc/social.coves.aggregator.updateConfig (requires auth + moderator) 59 59 } 60 + 61 + // RegisterAggregatorAPIKeyRoutes registers API key management endpoints for aggregators. 62 + // These endpoints require OAuth authentication and are only available to registered aggregators. 63 + // Call this function AFTER setting up the auth middleware. 64 + func RegisterAggregatorAPIKeyRoutes( 65 + r chi.Router, 66 + authMiddleware middleware.AuthMiddleware, 67 + apiKeyService aggregators.APIKeyServiceInterface, 68 + aggregatorService aggregators.Service, 69 + ) { 70 + // Create API key handlers 71 + createAPIKeyHandler := aggregator.NewCreateAPIKeyHandler(apiKeyService, aggregatorService) 72 + getAPIKeyHandler := aggregator.NewGetAPIKeyHandler(apiKeyService, aggregatorService) 73 + revokeAPIKeyHandler := aggregator.NewRevokeAPIKeyHandler(apiKeyService, aggregatorService) 74 + metricsHandler := aggregator.NewMetricsHandler(apiKeyService) 75 + 76 + // API key management endpoints (require OAuth authentication) 77 + // POST /xrpc/social.coves.aggregator.createApiKey 78 + // Creates a new API key for the authenticated aggregator 79 + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.aggregator.createApiKey", 80 + createAPIKeyHandler.HandleCreateAPIKey) 81 + 82 + // GET /xrpc/social.coves.aggregator.getApiKey 83 + // Gets info about the authenticated aggregator's API key (not the key itself) 84 + r.With(authMiddleware.RequireAuth).Get("/xrpc/social.coves.aggregator.getApiKey", 85 + getAPIKeyHandler.HandleGetAPIKey) 86 + 87 + // POST /xrpc/social.coves.aggregator.revokeApiKey 88 + // Revokes the authenticated aggregator's API key 89 + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.aggregator.revokeApiKey", 90 + revokeAPIKeyHandler.HandleRevokeAPIKey) 91 + 92 + // GET /xrpc/social.coves.aggregator.getMetrics 93 + // Returns operational metrics for the API key service (internal monitoring endpoint) 94 + // No authentication required - metrics are non-sensitive operational data 95 + r.Get("/xrpc/social.coves.aggregator.getMetrics", metricsHandler.HandleMetrics) 96 + }
+63
internal/atproto/lexicon/social/coves/aggregator/createApiKey.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.aggregator.createApiKey", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create an API key for the authenticated aggregator. Requires OAuth authentication. The API key is returned ONCE and cannot be retrieved again. Store it securely.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "description": "No input required. The key is generated server-side for the authenticated aggregator.", 13 + "properties": {} 14 + } 15 + }, 16 + "output": { 17 + "encoding": "application/json", 18 + "schema": { 19 + "type": "object", 20 + "required": ["key", "keyPrefix", "did", "createdAt"], 21 + "properties": { 22 + "key": { 23 + "type": "string", 24 + "description": "The plain-text API key. This is shown ONCE and cannot be retrieved again. Format: ckapi_<64-hex-chars> (32 bytes hex-encoded)" 25 + }, 26 + "keyPrefix": { 27 + "type": "string", 28 + "description": "First 12 characters of the key (e.g., 'ckapi_ab12cd') for identification in logs and UI" 29 + }, 30 + "did": { 31 + "type": "string", 32 + "format": "did", 33 + "description": "DID of the aggregator that owns this key" 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "ISO8601 timestamp when the key was created" 39 + } 40 + } 41 + } 42 + }, 43 + "errors": [ 44 + { 45 + "name": "AuthenticationRequired", 46 + "description": "OAuth authentication is required to create an API key" 47 + }, 48 + { 49 + "name": "OAuthSessionRequired", 50 + "description": "OAuth session is required (not service JWT) to create an API key" 51 + }, 52 + { 53 + "name": "AggregatorRequired", 54 + "description": "Only registered aggregators can create API keys" 55 + }, 56 + { 57 + "name": "KeyGenerationFailed", 58 + "description": "Failed to generate the API key" 59 + } 60 + ] 61 + } 62 + } 63 + }
+30
internal/atproto/lexicon/social/coves/aggregator/defs.json
··· 204 204 "format": "at-uri" 205 205 } 206 206 } 207 + }, 208 + "apiKeyView": { 209 + "type": "object", 210 + "description": "View of an API key's metadata. The actual key value is never returned after initial creation.", 211 + "required": ["prefix", "createdAt", "isRevoked"], 212 + "properties": { 213 + "prefix": { 214 + "type": "string", 215 + "description": "First 12 characters of the key (e.g., 'ckapi_ab12cd') for identification in logs and UI" 216 + }, 217 + "createdAt": { 218 + "type": "string", 219 + "format": "datetime", 220 + "description": "When the key was created" 221 + }, 222 + "lastUsedAt": { 223 + "type": "string", 224 + "format": "datetime", 225 + "description": "When the key was last used for authentication" 226 + }, 227 + "isRevoked": { 228 + "type": "boolean", 229 + "description": "Whether the key has been revoked" 230 + }, 231 + "revokedAt": { 232 + "type": "string", 233 + "format": "datetime", 234 + "description": "When the key was revoked" 235 + } 236 + } 207 237 } 208 238 } 209 239 }
+47
internal/atproto/lexicon/social/coves/aggregator/getApiKey.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.aggregator.getApiKey", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get information about the authenticated aggregator's API key. Note: The actual key value is NEVER returned - only metadata about the key.", 8 + "parameters": { 9 + "type": "params", 10 + "description": "No parameters required. Returns key info for the authenticated aggregator.", 11 + "properties": {} 12 + }, 13 + "output": { 14 + "encoding": "application/json", 15 + "schema": { 16 + "type": "object", 17 + "required": ["hasKey"], 18 + "properties": { 19 + "hasKey": { 20 + "type": "boolean", 21 + "description": "Whether the aggregator has an API key (active or revoked)" 22 + }, 23 + "keyInfo": { 24 + "type": "ref", 25 + "ref": "social.coves.aggregator.defs#apiKeyView", 26 + "description": "API key metadata. Only present if hasKey is true." 27 + } 28 + } 29 + } 30 + }, 31 + "errors": [ 32 + { 33 + "name": "AuthenticationRequired", 34 + "description": "Authentication is required to get API key info" 35 + }, 36 + { 37 + "name": "AggregatorRequired", 38 + "description": "Only registered aggregators can get API key info" 39 + }, 40 + { 41 + "name": "AggregatorNotFound", 42 + "description": "Aggregator not found" 43 + } 44 + ] 45 + } 46 + } 47 + }
+58
internal/atproto/lexicon/social/coves/aggregator/revokeApiKey.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.aggregator.revokeApiKey", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Revoke the authenticated aggregator's API key. After revocation, the aggregator must complete OAuth flow again to create a new API key. This action cannot be undone.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "description": "No input required. Revokes the key for the authenticated aggregator.", 13 + "properties": {} 14 + } 15 + }, 16 + "output": { 17 + "encoding": "application/json", 18 + "schema": { 19 + "type": "object", 20 + "required": ["revokedAt"], 21 + "properties": { 22 + "revokedAt": { 23 + "type": "string", 24 + "format": "datetime", 25 + "description": "ISO8601 timestamp when the key was revoked" 26 + } 27 + } 28 + } 29 + }, 30 + "errors": [ 31 + { 32 + "name": "AuthenticationRequired", 33 + "description": "Authentication is required to revoke an API key" 34 + }, 35 + { 36 + "name": "AggregatorRequired", 37 + "description": "Only registered aggregators can revoke API keys" 38 + }, 39 + { 40 + "name": "AggregatorNotFound", 41 + "description": "Aggregator not found" 42 + }, 43 + { 44 + "name": "ApiKeyNotFound", 45 + "description": "No API key exists to revoke" 46 + }, 47 + { 48 + "name": "ApiKeyAlreadyRevoked", 49 + "description": "API key has already been revoked" 50 + }, 51 + { 52 + "name": "RevocationFailed", 53 + "description": "Failed to revoke the API key" 54 + } 55 + ] 56 + } 57 + } 58 + }
+102 -13
internal/core/aggregators/aggregator.go
··· 6 6 // Aggregators are autonomous services that can post content to communities after authorization 7 7 // Following Bluesky's pattern: app.bsky.feed.generator and app.bsky.labeler.service 8 8 type Aggregator struct { 9 - CreatedAt time.Time `json:"createdAt" db:"created_at"` 10 - IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` 11 - AvatarURL string `json:"avatarUrl,omitempty" db:"avatar_url"` 12 - DID string `json:"did" db:"did"` 13 - MaintainerDID string `json:"maintainerDid,omitempty" db:"maintainer_did"` 14 - SourceURL string `json:"sourceUrl,omitempty" db:"source_url"` 15 - Description string `json:"description,omitempty" db:"description"` 16 - DisplayName string `json:"displayName" db:"display_name"` 17 - RecordURI string `json:"recordUri,omitempty" db:"record_uri"` 18 - RecordCID string `json:"recordCid,omitempty" db:"record_cid"` 19 - ConfigSchema []byte `json:"configSchema,omitempty" db:"config_schema"` 20 - CommunitiesUsing int `json:"communitiesUsing" db:"communities_using"` 21 - PostsCreated int `json:"postsCreated" db:"posts_created"` 9 + // Core timestamps 10 + CreatedAt time.Time `json:"createdAt" db:"created_at"` 11 + IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` 12 + 13 + // Identity and display 14 + DID string `json:"did" db:"did"` 15 + DisplayName string `json:"displayName" db:"display_name"` 16 + Description string `json:"description,omitempty" db:"description"` 17 + AvatarURL string `json:"avatarUrl,omitempty" db:"avatar_url"` 18 + 19 + // Metadata 20 + MaintainerDID string `json:"maintainerDid,omitempty" db:"maintainer_did"` 21 + SourceURL string `json:"sourceUrl,omitempty" db:"source_url"` 22 + RecordURI string `json:"recordUri,omitempty" db:"record_uri"` 23 + RecordCID string `json:"recordCid,omitempty" db:"record_cid"` 24 + ConfigSchema []byte `json:"configSchema,omitempty" db:"config_schema"` 25 + 26 + // Stats 27 + CommunitiesUsing int `json:"communitiesUsing" db:"communities_using"` 28 + PostsCreated int `json:"postsCreated" db:"posts_created"` 29 + } 30 + 31 + // OAuthCredentials holds OAuth session data for aggregator authentication 32 + // Used when setting up or refreshing API key authentication 33 + type OAuthCredentials struct { 34 + AccessToken string 35 + RefreshToken string 36 + TokenExpiresAt time.Time 37 + PDSURL string 38 + AuthServerIss string 39 + AuthServerTokenEndpoint string 40 + DPoPPrivateKeyMultibase string 41 + DPoPAuthServerNonce string 42 + DPoPPDSNonce string 43 + } 44 + 45 + // Validate checks that all required OAuthCredentials fields are present and valid. 46 + // Returns an error describing the first validation failure, or nil if valid. 47 + func (c *OAuthCredentials) Validate() error { 48 + if c.AccessToken == "" { 49 + return NewValidationError("accessToken", "access token is required") 50 + } 51 + if c.RefreshToken == "" { 52 + return NewValidationError("refreshToken", "refresh token is required") 53 + } 54 + if c.TokenExpiresAt.IsZero() { 55 + return NewValidationError("tokenExpiresAt", "token expiry time is required") 56 + } 57 + if c.PDSURL == "" { 58 + return NewValidationError("pdsUrl", "PDS URL is required") 59 + } 60 + if c.AuthServerIss == "" { 61 + return NewValidationError("authServerIss", "auth server issuer is required") 62 + } 63 + if c.AuthServerTokenEndpoint == "" { 64 + return NewValidationError("authServerTokenEndpoint", "auth server token endpoint is required") 65 + } 66 + if c.DPoPPrivateKeyMultibase == "" { 67 + return NewValidationError("dpopPrivateKey", "DPoP private key is required") 68 + } 69 + return nil 70 + } 71 + 72 + // AggregatorCredentials holds sensitive authentication data for aggregators. 73 + // This is the preferred type for authentication operations - separates concerns 74 + // from the public Aggregator type and prevents credential leakage. 75 + type AggregatorCredentials struct { 76 + DID string `db:"did"` 77 + 78 + // API Key Authentication 79 + APIKeyPrefix string `db:"api_key_prefix"` 80 + APIKeyHash string `db:"api_key_hash"` 81 + APIKeyCreatedAt *time.Time `db:"api_key_created_at"` 82 + APIKeyRevokedAt *time.Time `db:"api_key_revoked_at"` 83 + APIKeyLastUsed *time.Time `db:"api_key_last_used_at"` 84 + 85 + // OAuth Session Credentials 86 + OAuthAccessToken string `db:"oauth_access_token"` 87 + OAuthRefreshToken string `db:"oauth_refresh_token"` 88 + OAuthTokenExpiresAt *time.Time `db:"oauth_token_expires_at"` 89 + OAuthPDSURL string `db:"oauth_pds_url"` 90 + OAuthAuthServerIss string `db:"oauth_auth_server_iss"` 91 + OAuthAuthServerTokenEndpoint string `db:"oauth_auth_server_token_endpoint"` 92 + OAuthDPoPPrivateKeyMultibase string `db:"oauth_dpop_private_key_multibase"` 93 + OAuthDPoPAuthServerNonce string `db:"oauth_dpop_authserver_nonce"` 94 + OAuthDPoPPDSNonce string `db:"oauth_dpop_pds_nonce"` 95 + } 96 + 97 + // HasActiveAPIKey returns true if the credentials have an active (non-revoked) API key. 98 + // An active key has a non-empty hash and has not been revoked. 99 + func (c *AggregatorCredentials) HasActiveAPIKey() bool { 100 + return c.APIKeyHash != "" && c.APIKeyRevokedAt == nil 101 + } 102 + 103 + // IsOAuthTokenExpired returns true if the OAuth access token has expired or will expire soon. 104 + // Uses a 5-minute buffer before actual expiry to allow proactive token refresh, 105 + // accounting for clock skew and network latency during refresh operations. 106 + func (c *AggregatorCredentials) IsOAuthTokenExpired() bool { 107 + if c.OAuthTokenExpiresAt == nil { 108 + return true 109 + } 110 + return time.Now().Add(5 * time.Minute).After(*c.OAuthTokenExpiresAt) 22 111 } 23 112 24 113 // Authorization represents a community's authorization for an aggregator
+434
internal/core/aggregators/apikey_service.go
··· 1 + package aggregators 2 + 3 + import ( 4 + "context" 5 + "crypto/rand" 6 + "crypto/sha256" 7 + "encoding/hex" 8 + "errors" 9 + "fmt" 10 + "log/slog" 11 + "sync/atomic" 12 + "time" 13 + 14 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + ) 17 + 18 + const ( 19 + // APIKeyPrefix is the prefix for all Coves API keys 20 + APIKeyPrefix = "ckapi_" 21 + // APIKeyRandomBytes is the number of random bytes in the key (32 bytes = 256 bits) 22 + APIKeyRandomBytes = 32 23 + // APIKeyTotalLength is the total length of the API key including prefix 24 + // 6 (prefix "ckapi_") + 64 (32 bytes hex-encoded) = 70 25 + APIKeyTotalLength = 70 26 + // TokenRefreshBuffer is how long before expiry we should refresh tokens 27 + TokenRefreshBuffer = 5 * time.Minute 28 + // DefaultSessionID is used for API key sessions since aggregators have a single session 29 + DefaultSessionID = "apikey" 30 + ) 31 + 32 + // APIKeyService handles API key generation, validation, and OAuth token management 33 + // for aggregator authentication. 34 + type APIKeyService struct { 35 + repo Repository 36 + oauthApp *oauth.ClientApp // For resuming sessions and refreshing tokens 37 + 38 + // failedLastUsedUpdates tracks the number of failed API key last_used timestamp updates. 39 + // This counter provides visibility into persistent DB issues that would otherwise be hidden 40 + // since the update is done asynchronously. Use GetFailedLastUsedUpdates() to read. 41 + failedLastUsedUpdates atomic.Int64 42 + 43 + // failedNonceUpdates tracks the number of failed OAuth nonce updates. 44 + // Nonce failures may indicate DB issues and could lead to DPoP replay protection issues. 45 + // Use GetFailedNonceUpdates() to read. 46 + failedNonceUpdates atomic.Int64 47 + } 48 + 49 + // NewAPIKeyService creates a new API key service. 50 + // Panics if repo or oauthApp are nil, as these are required dependencies. 51 + func NewAPIKeyService(repo Repository, oauthApp *oauth.ClientApp) *APIKeyService { 52 + if repo == nil { 53 + panic("aggregators.NewAPIKeyService: repo cannot be nil") 54 + } 55 + if oauthApp == nil { 56 + panic("aggregators.NewAPIKeyService: oauthApp cannot be nil") 57 + } 58 + return &APIKeyService{ 59 + repo: repo, 60 + oauthApp: oauthApp, 61 + } 62 + } 63 + 64 + // GenerateKey creates a new API key for an aggregator. 65 + // The aggregator must have completed OAuth authentication first. 66 + // Returns the plain-text key (only shown once) and the key prefix for reference. 67 + func (s *APIKeyService) GenerateKey(ctx context.Context, aggregatorDID string, oauthSession *oauth.ClientSessionData) (plainKey string, keyPrefix string, err error) { 68 + // Validate aggregator exists 69 + aggregator, err := s.repo.GetAggregator(ctx, aggregatorDID) 70 + if err != nil { 71 + return "", "", fmt.Errorf("failed to get aggregator: %w", err) 72 + } 73 + 74 + // Validate OAuth session matches the aggregator 75 + if oauthSession.AccountDID.String() != aggregatorDID { 76 + return "", "", ErrOAuthSessionMismatch 77 + } 78 + 79 + // Generate random key 80 + randomBytes := make([]byte, APIKeyRandomBytes) 81 + if _, err := rand.Read(randomBytes); err != nil { 82 + return "", "", fmt.Errorf("failed to generate random key: %w", err) 83 + } 84 + randomHex := hex.EncodeToString(randomBytes) 85 + plainKey = APIKeyPrefix + randomHex 86 + 87 + // Create key prefix (first 12 chars including prefix for identification) 88 + keyPrefix = plainKey[:12] 89 + 90 + // Hash the key for storage (SHA-256) 91 + keyHash := hashAPIKey(plainKey) 92 + 93 + // Extract OAuth credentials from session 94 + // Note: ClientSessionData doesn't store token expiry from the OAuth response. 95 + // We use a 1-hour default which matches typical OAuth access token lifetimes. 96 + // Token refresh happens proactively before expiry via RefreshTokensIfNeeded. 97 + tokenExpiry := time.Now().Add(1 * time.Hour) 98 + oauthCreds := &OAuthCredentials{ 99 + AccessToken: oauthSession.AccessToken, 100 + RefreshToken: oauthSession.RefreshToken, 101 + TokenExpiresAt: tokenExpiry, 102 + PDSURL: oauthSession.HostURL, 103 + AuthServerIss: oauthSession.AuthServerURL, 104 + AuthServerTokenEndpoint: oauthSession.AuthServerTokenEndpoint, 105 + DPoPPrivateKeyMultibase: oauthSession.DPoPPrivateKeyMultibase, 106 + DPoPAuthServerNonce: oauthSession.DPoPAuthServerNonce, 107 + DPoPPDSNonce: oauthSession.DPoPHostNonce, 108 + } 109 + 110 + // Validate OAuth credentials before proceeding 111 + if err := oauthCreds.Validate(); err != nil { 112 + return "", "", fmt.Errorf("invalid OAuth credentials: %w", err) 113 + } 114 + 115 + // Store the OAuth session in the store FIRST (before API key) 116 + // This prevents a race condition where the API key exists but can't refresh tokens. 117 + // Order: OAuth session โ†’ API key (if session fails, no dangling API key) 118 + apiKeySession := *oauthSession // Copy session data 119 + apiKeySession.SessionID = DefaultSessionID 120 + if err := s.oauthApp.Store.SaveSession(ctx, apiKeySession); err != nil { 121 + slog.Error("failed to store OAuth session for API key - aborting key creation", 122 + "did", aggregatorDID, 123 + "error", err, 124 + ) 125 + return "", "", fmt.Errorf("failed to store OAuth session for token refresh: %w", err) 126 + } 127 + 128 + // Now store key hash and OAuth credentials in aggregators table 129 + // If this fails, we have an orphaned OAuth session, but that's less problematic 130 + // than having an API key that can't refresh tokens. 131 + if err := s.repo.SetAPIKey(ctx, aggregatorDID, keyPrefix, keyHash, oauthCreds); err != nil { 132 + // Best effort cleanup of the OAuth session we just stored 133 + if deleteErr := s.oauthApp.Store.DeleteSession(ctx, oauthSession.AccountDID, DefaultSessionID); deleteErr != nil { 134 + slog.Warn("failed to cleanup OAuth session after API key storage failure", 135 + "did", aggregatorDID, 136 + "error", deleteErr, 137 + ) 138 + } 139 + return "", "", fmt.Errorf("failed to store API key: %w", err) 140 + } 141 + 142 + slog.Info("API key generated for aggregator", 143 + "did", aggregatorDID, 144 + "display_name", aggregator.DisplayName, 145 + "key_prefix", keyPrefix, 146 + ) 147 + 148 + return plainKey, keyPrefix, nil 149 + } 150 + 151 + // ValidateKey validates an API key and returns the associated aggregator credentials. 152 + // Returns ErrAPIKeyInvalid if the key is not found or revoked. 153 + func (s *APIKeyService) ValidateKey(ctx context.Context, plainKey string) (*AggregatorCredentials, error) { 154 + // Validate key format - log invalid attempts for security monitoring 155 + if len(plainKey) != APIKeyTotalLength || plainKey[:6] != APIKeyPrefix { 156 + // Log for security monitoring (potential brute-force detection) 157 + // Don't log the full key, just metadata about the attempt 158 + slog.Warn("[SECURITY] invalid API key format attempt", 159 + "key_length", len(plainKey), 160 + "has_valid_prefix", len(plainKey) >= 6 && plainKey[:6] == APIKeyPrefix, 161 + ) 162 + return nil, ErrAPIKeyInvalid 163 + } 164 + 165 + // Hash the provided key 166 + keyHash := hashAPIKey(plainKey) 167 + 168 + // Look up aggregator credentials by hash 169 + creds, err := s.repo.GetCredentialsByAPIKeyHash(ctx, keyHash) 170 + if err != nil { 171 + if IsNotFound(err) { 172 + return nil, ErrAPIKeyInvalid 173 + } 174 + // Check for revoked API key (returned by repo when api_key_revoked_at is set) 175 + if errors.Is(err, ErrAPIKeyRevoked) { 176 + slog.Warn("revoked API key used", 177 + "key_hash_prefix", keyHash[:8], 178 + ) 179 + return nil, ErrAPIKeyRevoked 180 + } 181 + return nil, fmt.Errorf("failed to lookup API key: %w", err) 182 + } 183 + 184 + // Update last used timestamp (async, don't block on error) 185 + // Use a bounded timeout to prevent goroutine accumulation if DB is slow/down 186 + // Extract trace info from context before spawning goroutine for log correlation 187 + aggregatorDID := creds.DID // capture for goroutine 188 + go func() { 189 + updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 190 + defer cancel() 191 + 192 + if updateErr := s.repo.UpdateAPIKeyLastUsed(updateCtx, aggregatorDID); updateErr != nil { 193 + // Increment failure counter for monitoring visibility 194 + failCount := s.failedLastUsedUpdates.Add(1) 195 + slog.Error("failed to update API key last used", 196 + "did", aggregatorDID, 197 + "error", updateErr, 198 + "total_failures", failCount, 199 + ) 200 + } 201 + }() 202 + 203 + return creds, nil 204 + } 205 + 206 + // RefreshTokensIfNeeded checks if the OAuth tokens are expired or expiring soon, 207 + // and refreshes them if necessary. 208 + func (s *APIKeyService) RefreshTokensIfNeeded(ctx context.Context, creds *AggregatorCredentials) error { 209 + // Check if tokens need refresh 210 + if creds.OAuthTokenExpiresAt != nil { 211 + if time.Until(*creds.OAuthTokenExpiresAt) > TokenRefreshBuffer { 212 + // Tokens still valid 213 + return nil 214 + } 215 + } 216 + 217 + // Need to refresh tokens 218 + slog.Info("refreshing OAuth tokens for aggregator", 219 + "did", creds.DID, 220 + "expires_at", creds.OAuthTokenExpiresAt, 221 + ) 222 + 223 + // Parse DID 224 + did, err := syntax.ParseDID(creds.DID) 225 + if err != nil { 226 + return fmt.Errorf("failed to parse aggregator DID: %w", err) 227 + } 228 + 229 + // Resume the OAuth session from the store 230 + // The session was stored when the aggregator created their API key 231 + session, err := s.oauthApp.ResumeSession(ctx, did, DefaultSessionID) 232 + if err != nil { 233 + slog.Error("failed to resume OAuth session for token refresh", 234 + "did", creds.DID, 235 + "error", err, 236 + ) 237 + return fmt.Errorf("failed to resume session: %w", err) 238 + } 239 + 240 + // Refresh tokens using indigo's OAuth library 241 + newAccessToken, err := session.RefreshTokens(ctx) 242 + if err != nil { 243 + slog.Error("failed to refresh OAuth tokens", 244 + "did", creds.DID, 245 + "error", err, 246 + ) 247 + return fmt.Errorf("failed to refresh tokens: %w", err) 248 + } 249 + 250 + // Note: ClientSessionData doesn't store token expiry from the OAuth response. 251 + // We use a 1-hour default which matches typical OAuth access token lifetimes. 252 + newExpiry := time.Now().Add(1 * time.Hour) 253 + 254 + // Update tokens in database 255 + if err := s.repo.UpdateOAuthTokens(ctx, creds.DID, newAccessToken, session.Data.RefreshToken, newExpiry); err != nil { 256 + return fmt.Errorf("failed to update tokens: %w", err) 257 + } 258 + 259 + // Update nonces in our database as a secondary copy for visibility/backup. 260 + // The authoritative nonces are in indigo's OAuth store (via SaveSession above). 261 + // Session resumption uses s.oauthApp.ResumeSession which reads from indigo's store, 262 + // so this failure is non-critical - hence warning level, not error. 263 + if err := s.repo.UpdateOAuthNonces(ctx, creds.DID, session.Data.DPoPAuthServerNonce, session.Data.DPoPHostNonce); err != nil { 264 + failCount := s.failedNonceUpdates.Add(1) 265 + slog.Warn("failed to update OAuth nonces in aggregators table", 266 + "did", creds.DID, 267 + "error", err, 268 + "total_failures", failCount, 269 + ) 270 + } 271 + 272 + // Update credentials in memory 273 + creds.OAuthAccessToken = newAccessToken 274 + creds.OAuthRefreshToken = session.Data.RefreshToken 275 + creds.OAuthTokenExpiresAt = &newExpiry 276 + creds.OAuthDPoPAuthServerNonce = session.Data.DPoPAuthServerNonce 277 + creds.OAuthDPoPPDSNonce = session.Data.DPoPHostNonce 278 + 279 + slog.Info("OAuth tokens refreshed for aggregator", 280 + "did", creds.DID, 281 + "new_expires_at", newExpiry, 282 + ) 283 + 284 + return nil 285 + } 286 + 287 + // GetAccessToken returns a valid access token for the aggregator, 288 + // refreshing if necessary. 289 + func (s *APIKeyService) GetAccessToken(ctx context.Context, creds *AggregatorCredentials) (string, error) { 290 + // Ensure tokens are fresh 291 + if err := s.RefreshTokensIfNeeded(ctx, creds); err != nil { 292 + return "", fmt.Errorf("failed to ensure fresh tokens: %w", err) 293 + } 294 + 295 + return creds.OAuthAccessToken, nil 296 + } 297 + 298 + // RevokeKey revokes an API key for an aggregator. 299 + // After revocation, the aggregator must complete OAuth flow again to get a new key. 300 + func (s *APIKeyService) RevokeKey(ctx context.Context, aggregatorDID string) error { 301 + if err := s.repo.RevokeAPIKey(ctx, aggregatorDID); err != nil { 302 + return fmt.Errorf("failed to revoke API key: %w", err) 303 + } 304 + 305 + slog.Info("API key revoked for aggregator", 306 + "did", aggregatorDID, 307 + ) 308 + 309 + return nil 310 + } 311 + 312 + // GetAggregator retrieves the public aggregator information by DID. 313 + // For credential/authentication data, use GetAggregatorCredentials instead. 314 + func (s *APIKeyService) GetAggregator(ctx context.Context, aggregatorDID string) (*Aggregator, error) { 315 + return s.repo.GetAggregator(ctx, aggregatorDID) 316 + } 317 + 318 + // GetAggregatorCredentials retrieves credentials for an aggregator by DID. 319 + func (s *APIKeyService) GetAggregatorCredentials(ctx context.Context, aggregatorDID string) (*AggregatorCredentials, error) { 320 + return s.repo.GetAggregatorCredentials(ctx, aggregatorDID) 321 + } 322 + 323 + // GetAPIKeyInfo returns information about an aggregator's API key (without the actual key). 324 + func (s *APIKeyService) GetAPIKeyInfo(ctx context.Context, aggregatorDID string) (*APIKeyInfo, error) { 325 + creds, err := s.repo.GetAggregatorCredentials(ctx, aggregatorDID) 326 + if err != nil { 327 + return nil, err 328 + } 329 + 330 + if creds.APIKeyHash == "" { 331 + return &APIKeyInfo{ 332 + HasKey: false, 333 + }, nil 334 + } 335 + 336 + return &APIKeyInfo{ 337 + HasKey: true, 338 + KeyPrefix: creds.APIKeyPrefix, 339 + CreatedAt: creds.APIKeyCreatedAt, 340 + LastUsedAt: creds.APIKeyLastUsed, 341 + IsRevoked: creds.APIKeyRevokedAt != nil, 342 + RevokedAt: creds.APIKeyRevokedAt, 343 + }, nil 344 + } 345 + 346 + // APIKeyInfo contains non-sensitive information about an API key 347 + type APIKeyInfo struct { 348 + HasKey bool 349 + KeyPrefix string 350 + CreatedAt *time.Time 351 + LastUsedAt *time.Time 352 + IsRevoked bool 353 + RevokedAt *time.Time 354 + } 355 + 356 + // hashAPIKey creates a SHA-256 hash of the API key for storage 357 + func hashAPIKey(plainKey string) string { 358 + hash := sha256.Sum256([]byte(plainKey)) 359 + return hex.EncodeToString(hash[:]) 360 + } 361 + 362 + // GetFailedLastUsedUpdates returns the count of failed API key last_used timestamp updates. 363 + // This is useful for monitoring and alerting on persistent database issues. 364 + func (s *APIKeyService) GetFailedLastUsedUpdates() int64 { 365 + return s.failedLastUsedUpdates.Load() 366 + } 367 + 368 + // GetFailedNonceUpdates returns the count of failed OAuth nonce updates. 369 + // This is useful for monitoring and alerting on persistent database issues 370 + // that could affect DPoP replay protection. 371 + func (s *APIKeyService) GetFailedNonceUpdates() int64 { 372 + return s.failedNonceUpdates.Load() 373 + } 374 + 375 + // perAggregatorRefreshTimeout is the maximum time allowed for refreshing 376 + // a single aggregator's tokens. This prevents a slow OAuth server from 377 + // blocking the entire refresh job. 378 + const perAggregatorRefreshTimeout = 30 * time.Second 379 + 380 + // RefreshExpiringTokens proactively refreshes tokens for all aggregators 381 + // whose tokens will expire within the given buffer period. 382 + // Returns count of successful refreshes and any errors encountered. 383 + // Each aggregator refresh has a 30-second timeout to prevent slow OAuth servers 384 + // from blocking the entire job. 385 + func (s *APIKeyService) RefreshExpiringTokens(ctx context.Context, expiryBuffer time.Duration) (refreshed int, errors []error) { 386 + // Get all aggregators with tokens expiring within the buffer period 387 + aggregators, err := s.repo.ListAggregatorsNeedingTokenRefresh(ctx, expiryBuffer) 388 + if err != nil { 389 + slog.Error("[TOKEN-REFRESH] Failed to list aggregators needing token refresh", 390 + "error", err, 391 + "expiry_buffer", expiryBuffer, 392 + ) 393 + return 0, []error{fmt.Errorf("failed to list aggregators needing refresh: %w", err)} 394 + } 395 + 396 + if len(aggregators) == 0 { 397 + return 0, nil 398 + } 399 + 400 + slog.Info("[TOKEN-REFRESH] Starting proactive token refresh", 401 + "aggregator_count", len(aggregators), 402 + "expiry_buffer", expiryBuffer, 403 + ) 404 + 405 + // Refresh tokens for each aggregator with per-aggregator timeout 406 + for _, creds := range aggregators { 407 + slog.Info("[TOKEN-REFRESH] Attempting token refresh for aggregator", 408 + "did", creds.DID, 409 + "token_expires_at", creds.OAuthTokenExpiresAt, 410 + ) 411 + 412 + // Create per-aggregator timeout context to prevent slow OAuth servers 413 + // from blocking the entire refresh cycle 414 + refreshCtx, cancel := context.WithTimeout(ctx, perAggregatorRefreshTimeout) 415 + err := s.RefreshTokensIfNeeded(refreshCtx, creds) 416 + cancel() 417 + 418 + if err != nil { 419 + slog.Error("[TOKEN-REFRESH] Failed to refresh tokens for aggregator", 420 + "did", creds.DID, 421 + "error", err, 422 + ) 423 + errors = append(errors, fmt.Errorf("aggregator %s: %w", creds.DID, err)) 424 + } else { 425 + slog.Info("[TOKEN-REFRESH] Successfully refreshed tokens for aggregator", 426 + "did", creds.DID, 427 + "new_expires_at", creds.OAuthTokenExpiresAt, 428 + ) 429 + refreshed++ 430 + } 431 + } 432 + 433 + return refreshed, errors 434 + }
+1362
internal/core/aggregators/apikey_service_test.go
··· 1 + package aggregators 2 + 3 + import ( 4 + "context" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "errors" 8 + "testing" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + ) 14 + 15 + // ptrTime returns a pointer to a time.Time (current time) 16 + func ptrTime() *time.Time { 17 + t := time.Now() 18 + return &t 19 + } 20 + 21 + // ptrTimeOffset returns a pointer to a time.Time offset from now 22 + func ptrTimeOffset(d time.Duration) *time.Time { 23 + t := time.Now().Add(d) 24 + return &t 25 + } 26 + 27 + // newTestAPIKeyService creates an APIKeyService with mock dependencies for testing. 28 + // This helper ensures tests don't panic from nil checks added in constructor validation. 29 + func newTestAPIKeyService(repo Repository) *APIKeyService { 30 + mockStore := &mockOAuthStore{} 31 + mockApp := &oauth.ClientApp{Store: mockStore} 32 + return NewAPIKeyService(repo, mockApp) 33 + } 34 + 35 + // mockRepository implements Repository interface for testing 36 + type mockRepository struct { 37 + getAggregatorFunc func(ctx context.Context, did string) (*Aggregator, error) 38 + getByAPIKeyHashFunc func(ctx context.Context, keyHash string) (*Aggregator, error) 39 + getCredentialsByAPIKeyHashFunc func(ctx context.Context, keyHash string) (*AggregatorCredentials, error) 40 + getAggregatorCredentialsFunc func(ctx context.Context, did string) (*AggregatorCredentials, error) 41 + setAPIKeyFunc func(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error 42 + updateOAuthTokensFunc func(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error 43 + updateOAuthNoncesFunc func(ctx context.Context, did, authServerNonce, pdsNonce string) error 44 + updateAPIKeyLastUsedFunc func(ctx context.Context, did string) error 45 + revokeAPIKeyFunc func(ctx context.Context, did string) error 46 + listAggregatorsNeedingTokenRefreshFunc func(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) 47 + } 48 + 49 + func (m *mockRepository) GetAggregator(ctx context.Context, did string) (*Aggregator, error) { 50 + if m.getAggregatorFunc != nil { 51 + return m.getAggregatorFunc(ctx, did) 52 + } 53 + return &Aggregator{DID: did, DisplayName: "Test Aggregator"}, nil 54 + } 55 + 56 + func (m *mockRepository) GetByAPIKeyHash(ctx context.Context, keyHash string) (*Aggregator, error) { 57 + if m.getByAPIKeyHashFunc != nil { 58 + return m.getByAPIKeyHashFunc(ctx, keyHash) 59 + } 60 + return nil, ErrAggregatorNotFound 61 + } 62 + 63 + func (m *mockRepository) SetAPIKey(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error { 64 + if m.setAPIKeyFunc != nil { 65 + return m.setAPIKeyFunc(ctx, did, keyPrefix, keyHash, oauthCreds) 66 + } 67 + return nil 68 + } 69 + 70 + func (m *mockRepository) UpdateOAuthTokens(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error { 71 + if m.updateOAuthTokensFunc != nil { 72 + return m.updateOAuthTokensFunc(ctx, did, accessToken, refreshToken, expiresAt) 73 + } 74 + return nil 75 + } 76 + 77 + func (m *mockRepository) UpdateOAuthNonces(ctx context.Context, did, authServerNonce, pdsNonce string) error { 78 + if m.updateOAuthNoncesFunc != nil { 79 + return m.updateOAuthNoncesFunc(ctx, did, authServerNonce, pdsNonce) 80 + } 81 + return nil 82 + } 83 + 84 + func (m *mockRepository) UpdateAPIKeyLastUsed(ctx context.Context, did string) error { 85 + if m.updateAPIKeyLastUsedFunc != nil { 86 + return m.updateAPIKeyLastUsedFunc(ctx, did) 87 + } 88 + return nil 89 + } 90 + 91 + func (m *mockRepository) RevokeAPIKey(ctx context.Context, did string) error { 92 + if m.revokeAPIKeyFunc != nil { 93 + return m.revokeAPIKeyFunc(ctx, did) 94 + } 95 + return nil 96 + } 97 + 98 + // Stub implementations for Repository interface methods not used in APIKeyService tests 99 + func (m *mockRepository) CreateAggregator(ctx context.Context, aggregator *Aggregator) error { 100 + return nil 101 + } 102 + 103 + func (m *mockRepository) GetAggregatorsByDIDs(ctx context.Context, dids []string) ([]*Aggregator, error) { 104 + return nil, nil 105 + } 106 + 107 + func (m *mockRepository) UpdateAggregator(ctx context.Context, aggregator *Aggregator) error { 108 + return nil 109 + } 110 + 111 + func (m *mockRepository) DeleteAggregator(ctx context.Context, did string) error { 112 + return nil 113 + } 114 + 115 + func (m *mockRepository) ListAggregators(ctx context.Context, limit, offset int) ([]*Aggregator, error) { 116 + return nil, nil 117 + } 118 + 119 + func (m *mockRepository) IsAggregator(ctx context.Context, did string) (bool, error) { 120 + return false, nil 121 + } 122 + 123 + func (m *mockRepository) CreateAuthorization(ctx context.Context, auth *Authorization) error { 124 + return nil 125 + } 126 + 127 + func (m *mockRepository) GetAuthorization(ctx context.Context, aggregatorDID, communityDID string) (*Authorization, error) { 128 + return nil, nil 129 + } 130 + 131 + func (m *mockRepository) GetAuthorizationByURI(ctx context.Context, recordURI string) (*Authorization, error) { 132 + return nil, nil 133 + } 134 + 135 + func (m *mockRepository) UpdateAuthorization(ctx context.Context, auth *Authorization) error { 136 + return nil 137 + } 138 + 139 + func (m *mockRepository) DeleteAuthorization(ctx context.Context, aggregatorDID, communityDID string) error { 140 + return nil 141 + } 142 + 143 + func (m *mockRepository) DeleteAuthorizationByURI(ctx context.Context, recordURI string) error { 144 + return nil 145 + } 146 + 147 + func (m *mockRepository) ListAuthorizationsForAggregator(ctx context.Context, aggregatorDID string, enabledOnly bool, limit, offset int) ([]*Authorization, error) { 148 + return nil, nil 149 + } 150 + 151 + func (m *mockRepository) ListAuthorizationsForCommunity(ctx context.Context, communityDID string, enabledOnly bool, limit, offset int) ([]*Authorization, error) { 152 + return nil, nil 153 + } 154 + 155 + func (m *mockRepository) IsAuthorized(ctx context.Context, aggregatorDID, communityDID string) (bool, error) { 156 + return false, nil 157 + } 158 + 159 + func (m *mockRepository) RecordAggregatorPost(ctx context.Context, aggregatorDID, communityDID, postURI, postCID string) error { 160 + return nil 161 + } 162 + 163 + func (m *mockRepository) CountRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) (int, error) { 164 + return 0, nil 165 + } 166 + 167 + func (m *mockRepository) GetRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) ([]*AggregatorPost, error) { 168 + return nil, nil 169 + } 170 + 171 + func (m *mockRepository) GetAggregatorCredentials(ctx context.Context, did string) (*AggregatorCredentials, error) { 172 + if m.getAggregatorCredentialsFunc != nil { 173 + return m.getAggregatorCredentialsFunc(ctx, did) 174 + } 175 + return &AggregatorCredentials{DID: did}, nil 176 + } 177 + 178 + func (m *mockRepository) GetCredentialsByAPIKeyHash(ctx context.Context, keyHash string) (*AggregatorCredentials, error) { 179 + if m.getCredentialsByAPIKeyHashFunc != nil { 180 + return m.getCredentialsByAPIKeyHashFunc(ctx, keyHash) 181 + } 182 + return nil, ErrAggregatorNotFound 183 + } 184 + 185 + func (m *mockRepository) ListAggregatorsNeedingTokenRefresh(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) { 186 + if m.listAggregatorsNeedingTokenRefreshFunc != nil { 187 + return m.listAggregatorsNeedingTokenRefreshFunc(ctx, expiryBuffer) 188 + } 189 + return nil, nil 190 + } 191 + 192 + func TestHashAPIKey(t *testing.T) { 193 + plainKey := "ckapi_abcdef1234567890abcdef1234567890" 194 + 195 + // Hash the key 196 + hash := hashAPIKey(plainKey) 197 + 198 + // Verify it's a valid hex string 199 + if len(hash) != 64 { 200 + t.Errorf("Expected 64 character hash, got %d", len(hash)) 201 + } 202 + 203 + // Verify it's consistent 204 + hash2 := hashAPIKey(plainKey) 205 + if hash != hash2 { 206 + t.Error("Hash function should be deterministic") 207 + } 208 + 209 + // Verify different keys produce different hashes 210 + differentKey := "ckapi_different1234567890abcdef12" 211 + differentHash := hashAPIKey(differentKey) 212 + if hash == differentHash { 213 + t.Error("Different keys should produce different hashes") 214 + } 215 + 216 + // Verify manually 217 + expectedHash := sha256.Sum256([]byte(plainKey)) 218 + expectedHex := hex.EncodeToString(expectedHash[:]) 219 + if hash != expectedHex { 220 + t.Errorf("Expected %s, got %s", expectedHex, hash) 221 + } 222 + } 223 + 224 + func TestAPIKeyConstants(t *testing.T) { 225 + // Verify the key prefix length assumption 226 + if len(APIKeyPrefix) != 6 { 227 + t.Errorf("Expected APIKeyPrefix to be 6 chars, got %d", len(APIKeyPrefix)) 228 + } 229 + 230 + // Verify total length calculation 231 + // Random bytes are hex-encoded, so they double in length (32 bytes -> 64 chars) 232 + expectedLength := len(APIKeyPrefix) + (APIKeyRandomBytes * 2) 233 + if APIKeyTotalLength != expectedLength { 234 + t.Errorf("APIKeyTotalLength should be %d (prefix + hex-encoded random), got %d", expectedLength, APIKeyTotalLength) 235 + } 236 + 237 + // Verify expected values explicitly 238 + if APIKeyTotalLength != 70 { 239 + t.Errorf("APIKeyTotalLength should be 70 (6 prefix + 64 hex chars), got %d", APIKeyTotalLength) 240 + } 241 + } 242 + 243 + func TestValidateKey_FormatValidation(t *testing.T) { 244 + // We can't test the full ValidateKey without mocking, but we can verify 245 + // the format validation logic by checking the constants 246 + // 32 random bytes hex-encoded = 64 characters 247 + testKey := "ckapi_" + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 248 + if len(testKey) != APIKeyTotalLength { 249 + t.Errorf("Test key length mismatch: expected %d, got %d", APIKeyTotalLength, len(testKey)) 250 + } 251 + 252 + // Test key should start with prefix 253 + if testKey[:6] != APIKeyPrefix { 254 + t.Errorf("Test key should start with %s", APIKeyPrefix) 255 + } 256 + 257 + // Verify key length is 70 characters 258 + if len(testKey) != 70 { 259 + t.Errorf("Test key should be 70 characters, got %d", len(testKey)) 260 + } 261 + } 262 + 263 + // ============================================================================= 264 + // AggregatorCredentials Tests 265 + // ============================================================================= 266 + 267 + func TestAggregatorCredentials_HasActiveAPIKey(t *testing.T) { 268 + tests := []struct { 269 + name string 270 + creds AggregatorCredentials 271 + wantActive bool 272 + }{ 273 + { 274 + name: "no key hash", 275 + creds: AggregatorCredentials{}, 276 + wantActive: false, 277 + }, 278 + { 279 + name: "has key hash, not revoked", 280 + creds: AggregatorCredentials{APIKeyHash: "somehash"}, 281 + wantActive: true, 282 + }, 283 + { 284 + name: "has key hash, revoked", 285 + creds: AggregatorCredentials{ 286 + APIKeyHash: "somehash", 287 + APIKeyRevokedAt: ptrTime(), 288 + }, 289 + wantActive: false, 290 + }, 291 + } 292 + 293 + for _, tt := range tests { 294 + t.Run(tt.name, func(t *testing.T) { 295 + got := tt.creds.HasActiveAPIKey() 296 + if got != tt.wantActive { 297 + t.Errorf("HasActiveAPIKey() = %v, want %v", got, tt.wantActive) 298 + } 299 + }) 300 + } 301 + } 302 + 303 + func TestAggregatorCredentials_IsOAuthTokenExpired(t *testing.T) { 304 + tests := []struct { 305 + name string 306 + creds AggregatorCredentials 307 + wantExpired bool 308 + }{ 309 + { 310 + name: "nil expiry", 311 + creds: AggregatorCredentials{}, 312 + wantExpired: true, 313 + }, 314 + { 315 + name: "expired in the past", 316 + creds: AggregatorCredentials{ 317 + OAuthTokenExpiresAt: ptrTimeOffset(-1 * time.Hour), 318 + }, 319 + wantExpired: true, 320 + }, 321 + { 322 + name: "within 5 minute buffer (4 minutes remaining)", 323 + creds: AggregatorCredentials{ 324 + OAuthTokenExpiresAt: ptrTimeOffset(4 * time.Minute), 325 + }, 326 + wantExpired: true, // Should be expired because within buffer 327 + }, 328 + { 329 + name: "exactly at 5 minute buffer", 330 + creds: AggregatorCredentials{ 331 + OAuthTokenExpiresAt: ptrTimeOffset(5 * time.Minute), 332 + }, 333 + wantExpired: true, // Edge case - at exactly buffer time 334 + }, 335 + { 336 + name: "beyond 5 minute buffer (6 minutes remaining)", 337 + creds: AggregatorCredentials{ 338 + OAuthTokenExpiresAt: ptrTimeOffset(6 * time.Minute), 339 + }, 340 + wantExpired: false, // Should not be expired 341 + }, 342 + { 343 + name: "well beyond buffer (1 hour remaining)", 344 + creds: AggregatorCredentials{ 345 + OAuthTokenExpiresAt: ptrTimeOffset(1 * time.Hour), 346 + }, 347 + wantExpired: false, 348 + }, 349 + } 350 + 351 + for _, tt := range tests { 352 + t.Run(tt.name, func(t *testing.T) { 353 + got := tt.creds.IsOAuthTokenExpired() 354 + if got != tt.wantExpired { 355 + t.Errorf("IsOAuthTokenExpired() = %v, want %v", got, tt.wantExpired) 356 + } 357 + }) 358 + } 359 + } 360 + 361 + // ============================================================================= 362 + // ValidateKey Tests 363 + // ============================================================================= 364 + 365 + func TestAPIKeyService_ValidateKey_InvalidFormat(t *testing.T) { 366 + repo := &mockRepository{} 367 + service := newTestAPIKeyService(repo) 368 + 369 + tests := []struct { 370 + name string 371 + key string 372 + wantErr error 373 + }{ 374 + { 375 + name: "empty key", 376 + key: "", 377 + wantErr: ErrAPIKeyInvalid, 378 + }, 379 + { 380 + name: "too short", 381 + key: "ckapi_short", 382 + wantErr: ErrAPIKeyInvalid, 383 + }, 384 + { 385 + name: "wrong prefix", 386 + key: "wrong_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 387 + wantErr: ErrAPIKeyInvalid, 388 + }, 389 + { 390 + name: "correct length but wrong prefix", 391 + key: "badpfx0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd", 392 + wantErr: ErrAPIKeyInvalid, 393 + }, 394 + } 395 + 396 + for _, tt := range tests { 397 + t.Run(tt.name, func(t *testing.T) { 398 + _, err := service.ValidateKey(context.Background(), tt.key) 399 + if !errors.Is(err, tt.wantErr) { 400 + t.Errorf("ValidateKey() error = %v, want %v", err, tt.wantErr) 401 + } 402 + }) 403 + } 404 + } 405 + 406 + func TestAPIKeyService_ValidateKey_NotFound(t *testing.T) { 407 + repo := &mockRepository{ 408 + getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*AggregatorCredentials, error) { 409 + return nil, ErrAggregatorNotFound 410 + }, 411 + } 412 + service := newTestAPIKeyService(repo) 413 + 414 + // Valid format but key not in database 415 + validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 416 + _, err := service.ValidateKey(context.Background(), validKey) 417 + if !errors.Is(err, ErrAPIKeyInvalid) { 418 + t.Errorf("ValidateKey() error = %v, want %v", err, ErrAPIKeyInvalid) 419 + } 420 + } 421 + 422 + func TestAPIKeyService_ValidateKey_Revoked(t *testing.T) { 423 + // The current implementation expects the repository to return ErrAPIKeyRevoked 424 + // when the API key has been revoked. This is done at the repository layer. 425 + repo := &mockRepository{ 426 + getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*AggregatorCredentials, error) { 427 + // Repository returns error for revoked keys 428 + return nil, ErrAPIKeyRevoked 429 + }, 430 + } 431 + service := newTestAPIKeyService(repo) 432 + 433 + validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 434 + _, err := service.ValidateKey(context.Background(), validKey) 435 + if !errors.Is(err, ErrAPIKeyRevoked) { 436 + t.Errorf("ValidateKey() error = %v, want %v", err, ErrAPIKeyRevoked) 437 + } 438 + } 439 + 440 + func TestAPIKeyService_ValidateKey_Success(t *testing.T) { 441 + expectedDID := "did:plc:aggregator123" 442 + lastUsedChan := make(chan struct{}) 443 + 444 + repo := &mockRepository{ 445 + getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*AggregatorCredentials, error) { 446 + return &AggregatorCredentials{ 447 + DID: expectedDID, 448 + APIKeyHash: keyHash, 449 + APIKeyPrefix: "ckapi_0123", 450 + }, nil 451 + }, 452 + updateAPIKeyLastUsedFunc: func(ctx context.Context, did string) error { 453 + close(lastUsedChan) 454 + return nil 455 + }, 456 + } 457 + service := newTestAPIKeyService(repo) 458 + 459 + validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 460 + creds, err := service.ValidateKey(context.Background(), validKey) 461 + if err != nil { 462 + t.Fatalf("ValidateKey() unexpected error: %v", err) 463 + } 464 + 465 + if creds.DID != expectedDID { 466 + t.Errorf("ValidateKey() DID = %s, want %s", creds.DID, expectedDID) 467 + } 468 + 469 + // Wait for async update with timeout using channel-based synchronization 470 + select { 471 + case <-lastUsedChan: 472 + // Success - UpdateAPIKeyLastUsed was called 473 + case <-time.After(1 * time.Second): 474 + t.Error("Expected UpdateAPIKeyLastUsed to be called (timeout)") 475 + } 476 + } 477 + 478 + // ============================================================================= 479 + // GenerateKey Tests 480 + // ============================================================================= 481 + 482 + func TestAPIKeyService_GenerateKey_AggregatorNotFound(t *testing.T) { 483 + repo := &mockRepository{ 484 + getAggregatorFunc: func(ctx context.Context, did string) (*Aggregator, error) { 485 + return nil, ErrAggregatorNotFound 486 + }, 487 + } 488 + service := newTestAPIKeyService(repo) 489 + 490 + did, _ := syntax.ParseDID("did:plc:test123") 491 + session := &oauth.ClientSessionData{ 492 + AccountDID: did, 493 + AccessToken: "test_token", 494 + } 495 + 496 + _, _, err := service.GenerateKey(context.Background(), "did:plc:test123", session) 497 + if err == nil { 498 + t.Error("GenerateKey() expected error, got nil") 499 + } 500 + } 501 + 502 + func TestAPIKeyService_GenerateKey_DIDMismatch(t *testing.T) { 503 + repo := &mockRepository{ 504 + getAggregatorFunc: func(ctx context.Context, did string) (*Aggregator, error) { 505 + return &Aggregator{DID: did}, nil 506 + }, 507 + } 508 + service := newTestAPIKeyService(repo) 509 + 510 + // Session DID doesn't match requested aggregator DID 511 + sessionDID, _ := syntax.ParseDID("did:plc:different") 512 + session := &oauth.ClientSessionData{ 513 + AccountDID: sessionDID, 514 + AccessToken: "test_token", 515 + } 516 + 517 + _, _, err := service.GenerateKey(context.Background(), "did:plc:aggregator123", session) 518 + if err == nil { 519 + t.Error("GenerateKey() expected DID mismatch error, got nil") 520 + } 521 + if !errors.Is(err, nil) && err.Error() == "" { 522 + // Just check there's an error for DID mismatch 523 + } 524 + } 525 + 526 + func TestAPIKeyService_GenerateKey_SetAPIKeyError(t *testing.T) { 527 + expectedError := errors.New("database error") 528 + repo := &mockRepository{ 529 + getAggregatorFunc: func(ctx context.Context, did string) (*Aggregator, error) { 530 + return &Aggregator{DID: did, DisplayName: "Test"}, nil 531 + }, 532 + setAPIKeyFunc: func(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error { 533 + return expectedError 534 + }, 535 + } 536 + 537 + // Create a minimal mock OAuth store 538 + mockStore := &mockOAuthStore{} 539 + mockApp := &oauth.ClientApp{Store: mockStore} 540 + 541 + service := NewAPIKeyService(repo, mockApp) 542 + 543 + did, _ := syntax.ParseDID("did:plc:aggregator123") 544 + session := &oauth.ClientSessionData{ 545 + AccountDID: did, 546 + AccessToken: "test_token", 547 + } 548 + 549 + _, _, err := service.GenerateKey(context.Background(), "did:plc:aggregator123", session) 550 + if err == nil { 551 + t.Error("GenerateKey() expected error, got nil") 552 + } 553 + } 554 + 555 + func TestAPIKeyService_GenerateKey_Success(t *testing.T) { 556 + aggregatorDID := "did:plc:aggregator123" 557 + var storedKeyPrefix, storedKeyHash string 558 + var storedOAuthCreds *OAuthCredentials 559 + var savedSession *oauth.ClientSessionData 560 + 561 + repo := &mockRepository{ 562 + getAggregatorFunc: func(ctx context.Context, did string) (*Aggregator, error) { 563 + if did != aggregatorDID { 564 + return nil, ErrAggregatorNotFound 565 + } 566 + return &Aggregator{ 567 + DID: did, 568 + DisplayName: "Test Aggregator", 569 + }, nil 570 + }, 571 + setAPIKeyFunc: func(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error { 572 + storedKeyPrefix = keyPrefix 573 + storedKeyHash = keyHash 574 + storedOAuthCreds = oauthCreds 575 + return nil 576 + }, 577 + } 578 + 579 + // Create mock OAuth store that tracks saved sessions 580 + mockStore := &mockOAuthStore{ 581 + saveSessionFunc: func(ctx context.Context, session oauth.ClientSessionData) error { 582 + savedSession = &session 583 + return nil 584 + }, 585 + } 586 + mockApp := &oauth.ClientApp{Store: mockStore} 587 + 588 + service := NewAPIKeyService(repo, mockApp) 589 + 590 + // Create OAuth session 591 + did, _ := syntax.ParseDID(aggregatorDID) 592 + session := &oauth.ClientSessionData{ 593 + AccountDID: did, 594 + SessionID: "original_session", 595 + AccessToken: "test_access_token", 596 + RefreshToken: "test_refresh_token", 597 + HostURL: "https://pds.example.com", 598 + AuthServerURL: "https://auth.example.com", 599 + AuthServerTokenEndpoint: "https://auth.example.com/oauth/token", 600 + DPoPPrivateKeyMultibase: "z1234567890", 601 + DPoPAuthServerNonce: "auth_nonce_123", 602 + DPoPHostNonce: "host_nonce_456", 603 + } 604 + 605 + plainKey, keyPrefix, err := service.GenerateKey(context.Background(), aggregatorDID, session) 606 + if err != nil { 607 + t.Fatalf("GenerateKey() unexpected error: %v", err) 608 + } 609 + 610 + // Verify key format 611 + if len(plainKey) != APIKeyTotalLength { 612 + t.Errorf("GenerateKey() plainKey length = %d, want %d", len(plainKey), APIKeyTotalLength) 613 + } 614 + if plainKey[:6] != APIKeyPrefix { 615 + t.Errorf("GenerateKey() plainKey prefix = %s, want %s", plainKey[:6], APIKeyPrefix) 616 + } 617 + 618 + // Verify key prefix is first 12 chars 619 + if keyPrefix != plainKey[:12] { 620 + t.Errorf("GenerateKey() keyPrefix = %s, want %s", keyPrefix, plainKey[:12]) 621 + } 622 + 623 + // Verify hash was stored (SHA-256 produces 64 hex chars) 624 + if len(storedKeyHash) != 64 { 625 + t.Errorf("GenerateKey() stored hash length = %d, want 64", len(storedKeyHash)) 626 + } 627 + 628 + // Verify hash matches the key 629 + expectedHash := hashAPIKey(plainKey) 630 + if storedKeyHash != expectedHash { 631 + t.Errorf("GenerateKey() stored hash doesn't match key hash") 632 + } 633 + 634 + // Verify stored key prefix matches returned prefix 635 + if storedKeyPrefix != keyPrefix { 636 + t.Errorf("GenerateKey() stored keyPrefix = %s, want %s", storedKeyPrefix, keyPrefix) 637 + } 638 + 639 + // Verify OAuth credentials were saved 640 + if storedOAuthCreds == nil { 641 + t.Fatal("GenerateKey() OAuth credentials not stored") 642 + } 643 + if storedOAuthCreds.AccessToken != session.AccessToken { 644 + t.Errorf("GenerateKey() stored AccessToken = %s, want %s", storedOAuthCreds.AccessToken, session.AccessToken) 645 + } 646 + if storedOAuthCreds.RefreshToken != session.RefreshToken { 647 + t.Errorf("GenerateKey() stored RefreshToken = %s, want %s", storedOAuthCreds.RefreshToken, session.RefreshToken) 648 + } 649 + if storedOAuthCreds.PDSURL != session.HostURL { 650 + t.Errorf("GenerateKey() stored PDSURL = %s, want %s", storedOAuthCreds.PDSURL, session.HostURL) 651 + } 652 + if storedOAuthCreds.AuthServerIss != session.AuthServerURL { 653 + t.Errorf("GenerateKey() stored AuthServerIss = %s, want %s", storedOAuthCreds.AuthServerIss, session.AuthServerURL) 654 + } 655 + if storedOAuthCreds.DPoPPrivateKeyMultibase != session.DPoPPrivateKeyMultibase { 656 + t.Errorf("GenerateKey() stored DPoPPrivateKeyMultibase mismatch") 657 + } 658 + if storedOAuthCreds.DPoPAuthServerNonce != session.DPoPAuthServerNonce { 659 + t.Errorf("GenerateKey() stored DPoPAuthServerNonce = %s, want %s", storedOAuthCreds.DPoPAuthServerNonce, session.DPoPAuthServerNonce) 660 + } 661 + if storedOAuthCreds.DPoPPDSNonce != session.DPoPHostNonce { 662 + t.Errorf("GenerateKey() stored DPoPPDSNonce = %s, want %s", storedOAuthCreds.DPoPPDSNonce, session.DPoPHostNonce) 663 + } 664 + 665 + // Verify session was saved to OAuth store 666 + if savedSession == nil { 667 + t.Fatal("GenerateKey() session not saved to OAuth store") 668 + } 669 + if savedSession.SessionID != DefaultSessionID { 670 + t.Errorf("GenerateKey() saved session ID = %s, want %s", savedSession.SessionID, DefaultSessionID) 671 + } 672 + if savedSession.AccessToken != session.AccessToken { 673 + t.Errorf("GenerateKey() saved session AccessToken mismatch") 674 + } 675 + } 676 + 677 + func TestAPIKeyService_GenerateKey_OAuthStoreSaveError(t *testing.T) { 678 + // Test that OAuth session save failure aborts key creation early 679 + // With the new ordering (OAuth session first, then API key), if OAuth save fails, 680 + // we abort immediately without creating an API key. 681 + aggregatorDID := "did:plc:aggregator123" 682 + setAPIKeyCalled := false 683 + 684 + repo := &mockRepository{ 685 + getAggregatorFunc: func(ctx context.Context, did string) (*Aggregator, error) { 686 + return &Aggregator{DID: did, DisplayName: "Test"}, nil 687 + }, 688 + setAPIKeyFunc: func(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error { 689 + setAPIKeyCalled = true 690 + return nil 691 + }, 692 + } 693 + 694 + // Create mock OAuth store that fails on save 695 + mockStore := &mockOAuthStore{ 696 + saveSessionFunc: func(ctx context.Context, session oauth.ClientSessionData) error { 697 + return errors.New("failed to save session") 698 + }, 699 + } 700 + mockApp := &oauth.ClientApp{Store: mockStore} 701 + 702 + service := NewAPIKeyService(repo, mockApp) 703 + 704 + did, _ := syntax.ParseDID(aggregatorDID) 705 + session := &oauth.ClientSessionData{ 706 + AccountDID: did, 707 + AccessToken: "test_token", 708 + } 709 + 710 + _, _, err := service.GenerateKey(context.Background(), aggregatorDID, session) 711 + if err == nil { 712 + t.Error("GenerateKey() expected error when OAuth store save fails, got nil") 713 + } 714 + 715 + // Verify SetAPIKey was NOT called - we should abort before storing the key 716 + // This prevents the race condition where an API key exists but can't refresh tokens 717 + if setAPIKeyCalled { 718 + t.Error("GenerateKey() should NOT call SetAPIKey when OAuth session save fails") 719 + } 720 + } 721 + 722 + // mockOAuthStore implements oauth.ClientAuthStore for testing 723 + type mockOAuthStore struct { 724 + getSessionFunc func(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) 725 + saveSessionFunc func(ctx context.Context, session oauth.ClientSessionData) error 726 + deleteSessionFunc func(ctx context.Context, did syntax.DID, sessionID string) error 727 + getAuthRequestInfoFunc func(ctx context.Context, state string) (*oauth.AuthRequestData, error) 728 + saveAuthRequestInfoFunc func(ctx context.Context, info oauth.AuthRequestData) error 729 + deleteAuthRequestInfoFunc func(ctx context.Context, state string) error 730 + } 731 + 732 + func (m *mockOAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 733 + if m.getSessionFunc != nil { 734 + return m.getSessionFunc(ctx, did, sessionID) 735 + } 736 + return nil, errors.New("session not found") 737 + } 738 + 739 + func (m *mockOAuthStore) SaveSession(ctx context.Context, session oauth.ClientSessionData) error { 740 + if m.saveSessionFunc != nil { 741 + return m.saveSessionFunc(ctx, session) 742 + } 743 + return nil 744 + } 745 + 746 + func (m *mockOAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 747 + if m.deleteSessionFunc != nil { 748 + return m.deleteSessionFunc(ctx, did, sessionID) 749 + } 750 + return nil 751 + } 752 + 753 + func (m *mockOAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 754 + if m.getAuthRequestInfoFunc != nil { 755 + return m.getAuthRequestInfoFunc(ctx, state) 756 + } 757 + return nil, errors.New("not found") 758 + } 759 + 760 + func (m *mockOAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 761 + if m.saveAuthRequestInfoFunc != nil { 762 + return m.saveAuthRequestInfoFunc(ctx, info) 763 + } 764 + return nil 765 + } 766 + 767 + func (m *mockOAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 768 + if m.deleteAuthRequestInfoFunc != nil { 769 + return m.deleteAuthRequestInfoFunc(ctx, state) 770 + } 771 + return nil 772 + } 773 + 774 + // ============================================================================= 775 + // RevokeKey Tests 776 + // ============================================================================= 777 + 778 + func TestAPIKeyService_RevokeKey_Success(t *testing.T) { 779 + revokeCalled := false 780 + revokedDID := "" 781 + 782 + repo := &mockRepository{ 783 + revokeAPIKeyFunc: func(ctx context.Context, did string) error { 784 + revokeCalled = true 785 + revokedDID = did 786 + return nil 787 + }, 788 + } 789 + service := newTestAPIKeyService(repo) 790 + 791 + err := service.RevokeKey(context.Background(), "did:plc:aggregator123") 792 + if err != nil { 793 + t.Fatalf("RevokeKey() unexpected error: %v", err) 794 + } 795 + 796 + if !revokeCalled { 797 + t.Error("Expected RevokeAPIKey to be called on repository") 798 + } 799 + if revokedDID != "did:plc:aggregator123" { 800 + t.Errorf("RevokeKey() called with DID = %s, want did:plc:aggregator123", revokedDID) 801 + } 802 + } 803 + 804 + func TestAPIKeyService_RevokeKey_Error(t *testing.T) { 805 + expectedError := errors.New("database error") 806 + repo := &mockRepository{ 807 + revokeAPIKeyFunc: func(ctx context.Context, did string) error { 808 + return expectedError 809 + }, 810 + } 811 + service := newTestAPIKeyService(repo) 812 + 813 + err := service.RevokeKey(context.Background(), "did:plc:aggregator123") 814 + if err == nil { 815 + t.Error("RevokeKey() expected error, got nil") 816 + } 817 + } 818 + 819 + // ============================================================================= 820 + // GetAPIKeyInfo Tests 821 + // ============================================================================= 822 + 823 + func TestAPIKeyService_GetAPIKeyInfo_NoKey(t *testing.T) { 824 + repo := &mockRepository{ 825 + getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*AggregatorCredentials, error) { 826 + return &AggregatorCredentials{ 827 + DID: did, 828 + APIKeyHash: "", // No key 829 + }, nil 830 + }, 831 + } 832 + service := newTestAPIKeyService(repo) 833 + 834 + info, err := service.GetAPIKeyInfo(context.Background(), "did:plc:aggregator123") 835 + if err != nil { 836 + t.Fatalf("GetAPIKeyInfo() unexpected error: %v", err) 837 + } 838 + 839 + if info.HasKey { 840 + t.Error("GetAPIKeyInfo() HasKey = true, want false") 841 + } 842 + } 843 + 844 + func TestAPIKeyService_GetAPIKeyInfo_HasActiveKey(t *testing.T) { 845 + createdAt := time.Now().Add(-24 * time.Hour) 846 + lastUsed := time.Now().Add(-1 * time.Hour) 847 + 848 + repo := &mockRepository{ 849 + getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*AggregatorCredentials, error) { 850 + return &AggregatorCredentials{ 851 + DID: did, 852 + APIKeyHash: "somehash", 853 + APIKeyPrefix: "ckapi_test12", 854 + APIKeyCreatedAt: &createdAt, 855 + APIKeyLastUsed: &lastUsed, 856 + }, nil 857 + }, 858 + } 859 + service := newTestAPIKeyService(repo) 860 + 861 + info, err := service.GetAPIKeyInfo(context.Background(), "did:plc:aggregator123") 862 + if err != nil { 863 + t.Fatalf("GetAPIKeyInfo() unexpected error: %v", err) 864 + } 865 + 866 + if !info.HasKey { 867 + t.Error("GetAPIKeyInfo() HasKey = false, want true") 868 + } 869 + if info.KeyPrefix != "ckapi_test12" { 870 + t.Errorf("GetAPIKeyInfo() KeyPrefix = %s, want ckapi_test12", info.KeyPrefix) 871 + } 872 + if info.IsRevoked { 873 + t.Error("GetAPIKeyInfo() IsRevoked = true, want false") 874 + } 875 + if info.CreatedAt == nil || !info.CreatedAt.Equal(createdAt) { 876 + t.Error("GetAPIKeyInfo() CreatedAt mismatch") 877 + } 878 + if info.LastUsedAt == nil || !info.LastUsedAt.Equal(lastUsed) { 879 + t.Error("GetAPIKeyInfo() LastUsedAt mismatch") 880 + } 881 + } 882 + 883 + func TestAPIKeyService_GetAPIKeyInfo_RevokedKey(t *testing.T) { 884 + revokedAt := time.Now().Add(-1 * time.Hour) 885 + 886 + repo := &mockRepository{ 887 + getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*AggregatorCredentials, error) { 888 + return &AggregatorCredentials{ 889 + DID: did, 890 + APIKeyHash: "somehash", 891 + APIKeyPrefix: "ckapi_test12", 892 + APIKeyRevokedAt: &revokedAt, 893 + }, nil 894 + }, 895 + } 896 + service := newTestAPIKeyService(repo) 897 + 898 + info, err := service.GetAPIKeyInfo(context.Background(), "did:plc:aggregator123") 899 + if err != nil { 900 + t.Fatalf("GetAPIKeyInfo() unexpected error: %v", err) 901 + } 902 + 903 + if !info.HasKey { 904 + t.Error("GetAPIKeyInfo() HasKey = false, want true (revoked keys still exist)") 905 + } 906 + if !info.IsRevoked { 907 + t.Error("GetAPIKeyInfo() IsRevoked = false, want true") 908 + } 909 + if info.RevokedAt == nil || !info.RevokedAt.Equal(revokedAt) { 910 + t.Error("GetAPIKeyInfo() RevokedAt mismatch") 911 + } 912 + } 913 + 914 + func TestAPIKeyService_GetAPIKeyInfo_NotFound(t *testing.T) { 915 + repo := &mockRepository{ 916 + getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*AggregatorCredentials, error) { 917 + return nil, ErrAggregatorNotFound 918 + }, 919 + } 920 + service := newTestAPIKeyService(repo) 921 + 922 + _, err := service.GetAPIKeyInfo(context.Background(), "did:plc:nonexistent") 923 + if !errors.Is(err, ErrAggregatorNotFound) { 924 + t.Errorf("GetAPIKeyInfo() error = %v, want ErrAggregatorNotFound", err) 925 + } 926 + } 927 + 928 + // ============================================================================= 929 + // RefreshTokensIfNeeded Tests 930 + // ============================================================================= 931 + 932 + func TestAPIKeyService_RefreshTokensIfNeeded_TokensStillValid(t *testing.T) { 933 + // Tokens expire in 1 hour - well beyond the 5 minute buffer 934 + expiresAt := time.Now().Add(1 * time.Hour) 935 + 936 + creds := &AggregatorCredentials{ 937 + DID: "did:plc:aggregator123", 938 + OAuthTokenExpiresAt: &expiresAt, 939 + } 940 + 941 + repo := &mockRepository{} 942 + service := newTestAPIKeyService(repo) 943 + 944 + err := service.RefreshTokensIfNeeded(context.Background(), creds) 945 + if err != nil { 946 + t.Fatalf("RefreshTokensIfNeeded() unexpected error: %v", err) 947 + } 948 + 949 + // No refresh should have happened - we can't easily verify this without 950 + // more complex mocking, but the absence of error is the key indicator 951 + } 952 + 953 + func TestAPIKeyService_RefreshTokensIfNeeded_WithinBuffer(t *testing.T) { 954 + // Token expires in 4 minutes - within the 5 minute buffer, so needs refresh 955 + // This test verifies that when tokens are within the buffer, the service 956 + // attempts to refresh them. 957 + // 958 + // Note: Full integration testing of token refresh requires a real OAuth app. 959 + // This test is intentionally skipped as it would require extensive mocking 960 + // of the indigo OAuth library internals. 961 + t.Skip("RefreshTokensIfNeeded requires fully configured OAuth app - covered by integration tests") 962 + } 963 + 964 + func TestAPIKeyService_RefreshTokensIfNeeded_ExpiredNilTokens(t *testing.T) { 965 + // When OAuthTokenExpiresAt is nil, tokens need refresh 966 + // This should also attempt to refresh (and fail with nil OAuth app) 967 + t.Skip("RefreshTokensIfNeeded requires fully configured OAuth app - covered by integration tests") 968 + } 969 + 970 + // ============================================================================= 971 + // GetAccessToken Tests 972 + // ============================================================================= 973 + 974 + func TestAPIKeyService_GetAccessToken_ValidAggregatorTokensNotExpired(t *testing.T) { 975 + // Tokens expire in 1 hour - well beyond the 5 minute buffer 976 + expiresAt := time.Now().Add(1 * time.Hour) 977 + expectedToken := "valid_access_token_123" 978 + 979 + creds := &AggregatorCredentials{ 980 + DID: "did:plc:aggregator123", 981 + OAuthAccessToken: expectedToken, 982 + OAuthTokenExpiresAt: &expiresAt, 983 + } 984 + 985 + repo := &mockRepository{} 986 + service := newTestAPIKeyService(repo) 987 + 988 + token, err := service.GetAccessToken(context.Background(), creds) 989 + if err != nil { 990 + t.Fatalf("GetAccessToken() unexpected error: %v", err) 991 + } 992 + 993 + if token != expectedToken { 994 + t.Errorf("GetAccessToken() = %s, want %s", token, expectedToken) 995 + } 996 + } 997 + 998 + func TestAPIKeyService_GetAccessToken_ExpiredTokens(t *testing.T) { 999 + // Tokens expired 1 hour ago - requires refresh 1000 + // Since refresh requires a real OAuth app, this test verifies the error path 1001 + expiresAt := time.Now().Add(-1 * time.Hour) 1002 + 1003 + creds := &AggregatorCredentials{ 1004 + DID: "did:plc:aggregator123", 1005 + OAuthAccessToken: "expired_token", 1006 + OAuthRefreshToken: "refresh_token", 1007 + OAuthTokenExpiresAt: &expiresAt, 1008 + } 1009 + 1010 + repo := &mockRepository{} 1011 + // Service has nil OAuth app, so refresh will fail 1012 + service := newTestAPIKeyService(repo) 1013 + 1014 + _, err := service.GetAccessToken(context.Background(), creds) 1015 + if err == nil { 1016 + t.Error("GetAccessToken() expected error when tokens are expired and no OAuth app configured, got nil") 1017 + } 1018 + } 1019 + 1020 + func TestAPIKeyService_GetAccessToken_NilExpiry(t *testing.T) { 1021 + // Nil expiry means tokens need refresh 1022 + creds := &AggregatorCredentials{ 1023 + DID: "did:plc:aggregator123", 1024 + OAuthAccessToken: "some_token", 1025 + OAuthTokenExpiresAt: nil, // nil means needs refresh 1026 + } 1027 + 1028 + repo := &mockRepository{} 1029 + service := newTestAPIKeyService(repo) 1030 + 1031 + _, err := service.GetAccessToken(context.Background(), creds) 1032 + if err == nil { 1033 + t.Error("GetAccessToken() expected error when expiry is nil and no OAuth app configured, got nil") 1034 + } 1035 + } 1036 + 1037 + func TestAPIKeyService_GetAccessToken_WithinExpiryBuffer(t *testing.T) { 1038 + // Tokens expire in 4 minutes - within the 5 minute buffer, so needs refresh 1039 + expiresAt := time.Now().Add(4 * time.Minute) 1040 + 1041 + creds := &AggregatorCredentials{ 1042 + DID: "did:plc:aggregator123", 1043 + OAuthAccessToken: "soon_to_expire_token", 1044 + OAuthRefreshToken: "refresh_token", 1045 + OAuthTokenExpiresAt: &expiresAt, 1046 + } 1047 + 1048 + repo := &mockRepository{} 1049 + service := newTestAPIKeyService(repo) 1050 + 1051 + // Should attempt refresh and fail since no OAuth app is configured 1052 + _, err := service.GetAccessToken(context.Background(), creds) 1053 + if err == nil { 1054 + t.Error("GetAccessToken() expected error when tokens are within buffer and no OAuth app configured, got nil") 1055 + } 1056 + } 1057 + 1058 + func TestAPIKeyService_GetAccessToken_RevokedKey(t *testing.T) { 1059 + // Test behavior when aggregator has a revoked key 1060 + // The API key check happens in ValidateKey, but GetAccessToken should still work 1061 + // if called directly with a valid aggregator (before revocation is detected) 1062 + expiresAt := time.Now().Add(1 * time.Hour) 1063 + revokedAt := time.Now().Add(-30 * time.Minute) 1064 + expectedToken := "valid_access_token" 1065 + 1066 + creds := &AggregatorCredentials{ 1067 + DID: "did:plc:aggregator123", 1068 + APIKeyRevokedAt: &revokedAt, // Key is revoked 1069 + OAuthAccessToken: expectedToken, 1070 + OAuthTokenExpiresAt: &expiresAt, 1071 + } 1072 + 1073 + repo := &mockRepository{} 1074 + service := newTestAPIKeyService(repo) 1075 + 1076 + // GetAccessToken doesn't check revocation - that's done at ValidateKey level 1077 + // It just returns the token if valid 1078 + token, err := service.GetAccessToken(context.Background(), creds) 1079 + if err != nil { 1080 + t.Fatalf("GetAccessToken() unexpected error: %v", err) 1081 + } 1082 + 1083 + if token != expectedToken { 1084 + t.Errorf("GetAccessToken() = %s, want %s", token, expectedToken) 1085 + } 1086 + } 1087 + 1088 + func TestAPIKeyService_FailureCounters_InitiallyZero(t *testing.T) { 1089 + repo := &mockRepository{} 1090 + service := newTestAPIKeyService(repo) 1091 + 1092 + if got := service.GetFailedLastUsedUpdates(); got != 0 { 1093 + t.Errorf("GetFailedLastUsedUpdates() = %d, want 0", got) 1094 + } 1095 + 1096 + if got := service.GetFailedNonceUpdates(); got != 0 { 1097 + t.Errorf("GetFailedNonceUpdates() = %d, want 0", got) 1098 + } 1099 + } 1100 + 1101 + func TestAPIKeyService_FailedLastUsedUpdates_IncrementsOnError(t *testing.T) { 1102 + // Create a valid API key 1103 + plainKey := APIKeyPrefix + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" 1104 + keyHash := hashAPIKey(plainKey) 1105 + 1106 + updateCalled := make(chan struct{}, 1) 1107 + repo := &mockRepository{ 1108 + getCredentialsByAPIKeyHashFunc: func(ctx context.Context, hash string) (*AggregatorCredentials, error) { 1109 + if hash == keyHash { 1110 + return &AggregatorCredentials{ 1111 + DID: "did:plc:aggregator123", 1112 + APIKeyHash: keyHash, 1113 + }, nil 1114 + } 1115 + return nil, ErrAPIKeyInvalid 1116 + }, 1117 + updateAPIKeyLastUsedFunc: func(ctx context.Context, did string) error { 1118 + defer func() { updateCalled <- struct{}{} }() 1119 + return errors.New("database connection failed") 1120 + }, 1121 + } 1122 + 1123 + service := newTestAPIKeyService(repo) 1124 + 1125 + // Initial count should be 0 1126 + if got := service.GetFailedLastUsedUpdates(); got != 0 { 1127 + t.Errorf("GetFailedLastUsedUpdates() initial = %d, want 0", got) 1128 + } 1129 + 1130 + // Validate the key (triggers async last_used update) 1131 + _, err := service.ValidateKey(context.Background(), plainKey) 1132 + if err != nil { 1133 + t.Fatalf("ValidateKey() unexpected error: %v", err) 1134 + } 1135 + 1136 + // Wait for async update to complete 1137 + select { 1138 + case <-updateCalled: 1139 + // Update was called 1140 + case <-time.After(2 * time.Second): 1141 + t.Fatal("timeout waiting for async UpdateAPIKeyLastUsed call") 1142 + } 1143 + 1144 + // Give a moment for the counter to be incremented 1145 + time.Sleep(10 * time.Millisecond) 1146 + 1147 + // Counter should now be 1 1148 + if got := service.GetFailedLastUsedUpdates(); got != 1 { 1149 + t.Errorf("GetFailedLastUsedUpdates() after failure = %d, want 1", got) 1150 + } 1151 + } 1152 + 1153 + // ============================================================================= 1154 + // RefreshExpiringTokens Tests 1155 + // ============================================================================= 1156 + 1157 + func TestAPIKeyService_RefreshExpiringTokens_DatabaseError(t *testing.T) { 1158 + expectedError := errors.New("database connection failed") 1159 + repo := &mockRepository{ 1160 + listAggregatorsNeedingTokenRefreshFunc: func(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) { 1161 + return nil, expectedError 1162 + }, 1163 + } 1164 + service := newTestAPIKeyService(repo) 1165 + 1166 + refreshed, errs := service.RefreshExpiringTokens(context.Background(), 1*time.Hour) 1167 + 1168 + if refreshed != 0 { 1169 + t.Errorf("RefreshExpiringTokens() refreshed = %d, want 0", refreshed) 1170 + } 1171 + if len(errs) != 1 { 1172 + t.Fatalf("RefreshExpiringTokens() errors count = %d, want 1", len(errs)) 1173 + } 1174 + if !errors.Is(errs[0], expectedError) { 1175 + t.Errorf("RefreshExpiringTokens() error = %v, want %v", errs[0], expectedError) 1176 + } 1177 + } 1178 + 1179 + func TestAPIKeyService_RefreshExpiringTokens_EmptyList(t *testing.T) { 1180 + repo := &mockRepository{ 1181 + listAggregatorsNeedingTokenRefreshFunc: func(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) { 1182 + return []*AggregatorCredentials{}, nil 1183 + }, 1184 + } 1185 + service := newTestAPIKeyService(repo) 1186 + 1187 + refreshed, errs := service.RefreshExpiringTokens(context.Background(), 1*time.Hour) 1188 + 1189 + if refreshed != 0 { 1190 + t.Errorf("RefreshExpiringTokens() refreshed = %d, want 0", refreshed) 1191 + } 1192 + if len(errs) != 0 { 1193 + t.Errorf("RefreshExpiringTokens() errors count = %d, want 0", len(errs)) 1194 + } 1195 + } 1196 + 1197 + func TestAPIKeyService_RefreshExpiringTokens_NilList(t *testing.T) { 1198 + repo := &mockRepository{ 1199 + listAggregatorsNeedingTokenRefreshFunc: func(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) { 1200 + return nil, nil 1201 + }, 1202 + } 1203 + service := newTestAPIKeyService(repo) 1204 + 1205 + refreshed, errs := service.RefreshExpiringTokens(context.Background(), 1*time.Hour) 1206 + 1207 + if refreshed != 0 { 1208 + t.Errorf("RefreshExpiringTokens() refreshed = %d, want 0", refreshed) 1209 + } 1210 + if len(errs) != 0 { 1211 + t.Errorf("RefreshExpiringTokens() errors count = %d, want 0", len(errs)) 1212 + } 1213 + } 1214 + 1215 + func TestAPIKeyService_RefreshExpiringTokens_PassesCorrectExpiryBuffer(t *testing.T) { 1216 + expectedBuffer := 2 * time.Hour 1217 + var capturedBuffer time.Duration 1218 + 1219 + repo := &mockRepository{ 1220 + listAggregatorsNeedingTokenRefreshFunc: func(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) { 1221 + capturedBuffer = expiryBuffer 1222 + return nil, nil 1223 + }, 1224 + } 1225 + service := newTestAPIKeyService(repo) 1226 + 1227 + service.RefreshExpiringTokens(context.Background(), expectedBuffer) 1228 + 1229 + if capturedBuffer != expectedBuffer { 1230 + t.Errorf("RefreshExpiringTokens() passed expiryBuffer = %v, want %v", capturedBuffer, expectedBuffer) 1231 + } 1232 + } 1233 + 1234 + func TestAPIKeyService_RefreshExpiringTokens_TokensStillValid(t *testing.T) { 1235 + // When tokens are still valid (not within refresh buffer), no refresh should happen 1236 + // This tests the case where RefreshTokensIfNeeded returns early because tokens are valid 1237 + expiresAt := time.Now().Add(1 * time.Hour) // Well beyond the 5 minute buffer 1238 + 1239 + repo := &mockRepository{ 1240 + listAggregatorsNeedingTokenRefreshFunc: func(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) { 1241 + return []*AggregatorCredentials{ 1242 + { 1243 + DID: "did:plc:aggregator1", 1244 + OAuthTokenExpiresAt: &expiresAt, 1245 + OAuthAccessToken: "valid_token", 1246 + OAuthRefreshToken: "refresh_token", 1247 + }, 1248 + }, nil 1249 + }, 1250 + } 1251 + service := newTestAPIKeyService(repo) 1252 + 1253 + refreshed, errs := service.RefreshExpiringTokens(context.Background(), 1*time.Hour) 1254 + 1255 + // Tokens are valid, so RefreshTokensIfNeeded returns early without error 1256 + // and counts as "refreshed" (even though no actual refresh was needed) 1257 + if refreshed != 1 { 1258 + t.Errorf("RefreshExpiringTokens() refreshed = %d, want 1", refreshed) 1259 + } 1260 + if len(errs) != 0 { 1261 + t.Errorf("RefreshExpiringTokens() errors count = %d, want 0", len(errs)) 1262 + } 1263 + } 1264 + 1265 + func TestAPIKeyService_RefreshExpiringTokens_TokensExpired_RefreshFails(t *testing.T) { 1266 + // When tokens are expired and refresh fails (no OAuth app configured) 1267 + expiresAt := time.Now().Add(-1 * time.Hour) // Already expired 1268 + 1269 + repo := &mockRepository{ 1270 + listAggregatorsNeedingTokenRefreshFunc: func(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) { 1271 + return []*AggregatorCredentials{ 1272 + { 1273 + DID: "did:plc:aggregator1", 1274 + OAuthTokenExpiresAt: &expiresAt, 1275 + OAuthAccessToken: "expired_token", 1276 + OAuthRefreshToken: "refresh_token", 1277 + }, 1278 + }, nil 1279 + }, 1280 + } 1281 + service := newTestAPIKeyService(repo) 1282 + 1283 + refreshed, errs := service.RefreshExpiringTokens(context.Background(), 1*time.Hour) 1284 + 1285 + if refreshed != 0 { 1286 + t.Errorf("RefreshExpiringTokens() refreshed = %d, want 0", refreshed) 1287 + } 1288 + if len(errs) != 1 { 1289 + t.Errorf("RefreshExpiringTokens() errors count = %d, want 1", len(errs)) 1290 + } 1291 + } 1292 + 1293 + func TestAPIKeyService_RefreshExpiringTokens_MixedResults(t *testing.T) { 1294 + // Multiple aggregators: some with valid tokens, some with expired tokens 1295 + validExpiry := time.Now().Add(1 * time.Hour) 1296 + expiredExpiry := time.Now().Add(-1 * time.Hour) 1297 + 1298 + repo := &mockRepository{ 1299 + listAggregatorsNeedingTokenRefreshFunc: func(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) { 1300 + return []*AggregatorCredentials{ 1301 + { 1302 + DID: "did:plc:valid1", 1303 + OAuthTokenExpiresAt: &validExpiry, 1304 + OAuthAccessToken: "valid_token", 1305 + }, 1306 + { 1307 + DID: "did:plc:expired1", 1308 + OAuthTokenExpiresAt: &expiredExpiry, 1309 + OAuthAccessToken: "expired_token", 1310 + OAuthRefreshToken: "refresh_token", 1311 + }, 1312 + { 1313 + DID: "did:plc:valid2", 1314 + OAuthTokenExpiresAt: &validExpiry, 1315 + OAuthAccessToken: "valid_token2", 1316 + }, 1317 + }, nil 1318 + }, 1319 + } 1320 + service := newTestAPIKeyService(repo) 1321 + 1322 + refreshed, errs := service.RefreshExpiringTokens(context.Background(), 1*time.Hour) 1323 + 1324 + // 2 valid tokens should count as refreshed, 1 expired token should fail 1325 + if refreshed != 2 { 1326 + t.Errorf("RefreshExpiringTokens() refreshed = %d, want 2", refreshed) 1327 + } 1328 + if len(errs) != 1 { 1329 + t.Errorf("RefreshExpiringTokens() errors count = %d, want 1", len(errs)) 1330 + } 1331 + } 1332 + 1333 + func TestAPIKeyService_RefreshExpiringTokens_ContextCancellation(t *testing.T) { 1334 + // Test that context cancellation is respected 1335 + ctx, cancel := context.WithCancel(context.Background()) 1336 + cancel() // Cancel immediately 1337 + 1338 + repo := &mockRepository{ 1339 + listAggregatorsNeedingTokenRefreshFunc: func(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) { 1340 + // Check if context is already cancelled 1341 + select { 1342 + case <-ctx.Done(): 1343 + return nil, ctx.Err() 1344 + default: 1345 + return []*AggregatorCredentials{ 1346 + {DID: "did:plc:test"}, 1347 + }, nil 1348 + } 1349 + }, 1350 + } 1351 + service := newTestAPIKeyService(repo) 1352 + 1353 + refreshed, errs := service.RefreshExpiringTokens(ctx, 1*time.Hour) 1354 + 1355 + // Should fail due to context cancellation 1356 + if refreshed != 0 { 1357 + t.Errorf("RefreshExpiringTokens() refreshed = %d, want 0", refreshed) 1358 + } 1359 + if len(errs) != 1 { 1360 + t.Errorf("RefreshExpiringTokens() errors count = %d, want 1", len(errs)) 1361 + } 1362 + }
+22 -1
internal/core/aggregators/errors.go
··· 16 16 ErrConfigSchemaValidation = errors.New("configuration does not match aggregator's schema") 17 17 ErrNotModerator = errors.New("user is not a moderator of this community") 18 18 ErrNotImplemented = errors.New("feature not yet implemented") // For Phase 2 write-forward operations 19 + 20 + // API Key authentication errors 21 + ErrAPIKeyRevoked = errors.New("API key has been revoked") 22 + ErrAPIKeyInvalid = errors.New("invalid API key") 23 + ErrAPIKeyNotFound = errors.New("API key not found for this aggregator") 24 + ErrOAuthTokenExpired = errors.New("OAuth token has expired and needs refresh") 25 + ErrOAuthRefreshFailed = errors.New("failed to refresh OAuth token") 26 + ErrOAuthSessionMismatch = errors.New("OAuth session DID does not match aggregator DID") 19 27 ) 20 28 21 29 // ValidationError represents a validation error with field details ··· 38 46 39 47 // Error classification helpers for handlers to map to HTTP status codes 40 48 func IsNotFound(err error) bool { 41 - return errors.Is(err, ErrAggregatorNotFound) || errors.Is(err, ErrAuthorizationNotFound) 49 + return errors.Is(err, ErrAggregatorNotFound) || 50 + errors.Is(err, ErrAuthorizationNotFound) || 51 + errors.Is(err, ErrAPIKeyNotFound) 42 52 } 43 53 44 54 func IsValidationError(err error) bool { ··· 61 71 func IsNotImplemented(err error) bool { 62 72 return errors.Is(err, ErrNotImplemented) 63 73 } 74 + 75 + func IsAPIKeyError(err error) bool { 76 + return errors.Is(err, ErrAPIKeyRevoked) || 77 + errors.Is(err, ErrAPIKeyInvalid) || 78 + errors.Is(err, ErrAPIKeyNotFound) 79 + } 80 + 81 + func IsOAuthError(err error) bool { 82 + return errors.Is(err, ErrOAuthTokenExpired) || 83 + errors.Is(err, ErrOAuthRefreshFailed) 84 + }
+73 -3
internal/core/aggregators/interfaces.go
··· 1 1 2 2 3 + import ( 4 + "context" 5 + "time" 3 6 7 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 8 + ) 4 9 10 + // Repository defines the interface for aggregator data persistence 5 11 6 12 7 13 ··· 27 33 28 34 29 35 36 + RecordAggregatorPost(ctx context.Context, aggregatorDID, communityDID, postURI, postCID string) error 37 + CountRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) (int, error) 38 + GetRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) ([]*AggregatorPost, error) 39 + 40 + // API Key Authentication 41 + // GetByAPIKeyHash looks up an aggregator by their API key hash for authentication 42 + GetByAPIKeyHash(ctx context.Context, keyHash string) (*Aggregator, error) 43 + // GetAggregatorCredentials retrieves only the credential fields for an aggregator. 44 + // Used by APIKeyService for authentication operations where full aggregator is not needed. 45 + GetAggregatorCredentials(ctx context.Context, did string) (*AggregatorCredentials, error) 46 + // GetCredentialsByAPIKeyHash looks up aggregator credentials by their API key hash. 47 + // Returns ErrAPIKeyRevoked if the key has been revoked. 48 + // Returns ErrAPIKeyInvalid if no aggregator found with that hash. 49 + GetCredentialsByAPIKeyHash(ctx context.Context, keyHash string) (*AggregatorCredentials, error) 50 + // SetAPIKey stores API key credentials and OAuth session for an aggregator 51 + SetAPIKey(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error 52 + // UpdateOAuthTokens updates OAuth tokens after a refresh operation 53 + UpdateOAuthTokens(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error 54 + // UpdateOAuthNonces updates DPoP nonces after token operations 55 + UpdateOAuthNonces(ctx context.Context, did, authServerNonce, pdsNonce string) error 56 + // UpdateAPIKeyLastUsed updates the last_used_at timestamp for audit purposes 57 + UpdateAPIKeyLastUsed(ctx context.Context, did string) error 58 + // RevokeAPIKey marks an API key as revoked (sets api_key_revoked_at) 59 + RevokeAPIKey(ctx context.Context, did string) error 60 + 61 + // ListAggregatorsNeedingTokenRefresh returns aggregators with active API keys 62 + // whose OAuth tokens expire within the given buffer period 63 + ListAggregatorsNeedingTokenRefresh(ctx context.Context, expiryBuffer time.Duration) ([]*AggregatorCredentials, error) 64 + } 65 + 66 + // Service defines the interface for aggregator business logic 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 30 75 31 76 32 77 33 78 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + // Post tracking (called after successful post creation) 34 88 RecordAggregatorPost(ctx context.Context, aggregatorDID, communityDID, postURI, postCID string) error 35 - CountRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) (int, error) 36 - GetRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) ([]*AggregatorPost, error) 37 89 } 38 90 39 - // Service defines the interface for aggregator business logic 91 + // APIKeyServiceInterface defines the interface for API key operations used by handlers. 92 + // This interface enables easier testing by allowing mock implementations. 93 + type APIKeyServiceInterface interface { 94 + // GenerateKey creates a new API key for an aggregator. 95 + // Returns the plain-text key (only shown once) and the key prefix for reference. 96 + GenerateKey(ctx context.Context, aggregatorDID string, oauthSession *oauth.ClientSessionData) (plainKey string, keyPrefix string, err error) 97 + 98 + // GetAPIKeyInfo returns information about an aggregator's API key (without the actual key). 99 + GetAPIKeyInfo(ctx context.Context, aggregatorDID string) (*APIKeyInfo, error) 100 + 101 + // RevokeKey revokes an API key for an aggregator. 102 + RevokeKey(ctx context.Context, aggregatorDID string) error 103 + 104 + // GetFailedLastUsedUpdates returns the count of failed last_used timestamp updates. 105 + GetFailedLastUsedUpdates() int64 106 + 107 + // GetFailedNonceUpdates returns the count of failed OAuth nonce updates. 108 + GetFailedNonceUpdates() int64 109 + }
+77
internal/db/migrations/024_add_aggregator_api_keys.sql
··· 1 + -- +goose Up 2 + -- Add API key authentication and OAuth credential storage for aggregators 3 + -- This enables aggregators to authenticate using API keys backed by OAuth sessions 4 + 5 + -- ============================================================================ 6 + -- Add API key columns to aggregators table 7 + -- ============================================================================ 8 + ALTER TABLE aggregators 9 + -- API key identification (prefix for log correlation, hash for auth) 10 + ADD COLUMN api_key_prefix VARCHAR(12), 11 + ADD COLUMN api_key_hash VARCHAR(64) UNIQUE, 12 + 13 + -- OAuth credentials (encrypted at application layer before storage) 14 + -- SECURITY: These columns contain sensitive OAuth tokens 15 + ADD COLUMN oauth_access_token TEXT, 16 + ADD COLUMN oauth_refresh_token TEXT, 17 + ADD COLUMN oauth_token_expires_at TIMESTAMPTZ, 18 + 19 + -- OAuth session metadata for token refresh 20 + ADD COLUMN oauth_pds_url TEXT, 21 + ADD COLUMN oauth_auth_server_iss TEXT, 22 + ADD COLUMN oauth_auth_server_token_endpoint TEXT, 23 + 24 + -- DPoP keys and nonces for token refresh (multibase encoded) 25 + -- SECURITY: Contains private key material 26 + ADD COLUMN oauth_dpop_private_key_multibase TEXT, 27 + ADD COLUMN oauth_dpop_authserver_nonce TEXT, 28 + ADD COLUMN oauth_dpop_pds_nonce TEXT, 29 + 30 + -- API key lifecycle timestamps 31 + ADD COLUMN api_key_created_at TIMESTAMPTZ, 32 + ADD COLUMN api_key_revoked_at TIMESTAMPTZ, 33 + ADD COLUMN api_key_last_used_at TIMESTAMPTZ; 34 + 35 + -- Index for API key lookup during authentication 36 + -- Partial index excludes NULL values since not all aggregators have API keys 37 + CREATE INDEX idx_aggregators_api_key_hash 38 + ON aggregators(api_key_hash) 39 + WHERE api_key_hash IS NOT NULL; 40 + 41 + -- ============================================================================ 42 + -- Security comments on sensitive columns 43 + -- ============================================================================ 44 + COMMENT ON COLUMN aggregators.api_key_prefix IS 'First 12 characters of API key for identification in logs (not secret)'; 45 + COMMENT ON COLUMN aggregators.api_key_hash IS 'SHA-256 hash of full API key for authentication lookup'; 46 + COMMENT ON COLUMN aggregators.oauth_access_token IS 'SENSITIVE: Encrypted OAuth access token for PDS operations'; 47 + COMMENT ON COLUMN aggregators.oauth_refresh_token IS 'SENSITIVE: Encrypted OAuth refresh token for session renewal'; 48 + COMMENT ON COLUMN aggregators.oauth_token_expires_at IS 'When the OAuth access token expires (triggers refresh)'; 49 + COMMENT ON COLUMN aggregators.oauth_pds_url IS 'PDS URL for this aggregators OAuth session'; 50 + COMMENT ON COLUMN aggregators.oauth_auth_server_iss IS 'OAuth authorization server issuer URL'; 51 + COMMENT ON COLUMN aggregators.oauth_auth_server_token_endpoint IS 'OAuth token refresh endpoint URL'; 52 + COMMENT ON COLUMN aggregators.oauth_dpop_private_key_multibase IS 'SENSITIVE: DPoP private key in multibase format for token refresh'; 53 + COMMENT ON COLUMN aggregators.oauth_dpop_authserver_nonce IS 'Latest DPoP nonce from authorization server'; 54 + COMMENT ON COLUMN aggregators.oauth_dpop_pds_nonce IS 'Latest DPoP nonce from PDS'; 55 + COMMENT ON COLUMN aggregators.api_key_created_at IS 'When the API key was generated'; 56 + COMMENT ON COLUMN aggregators.api_key_revoked_at IS 'When the API key was revoked (NULL = active)'; 57 + COMMENT ON COLUMN aggregators.api_key_last_used_at IS 'Last successful authentication using this API key'; 58 + 59 + -- +goose Down 60 + -- Remove API key columns from aggregators table 61 + DROP INDEX IF EXISTS idx_aggregators_api_key_hash; 62 + 63 + ALTER TABLE aggregators 64 + DROP COLUMN IF EXISTS api_key_prefix, 65 + DROP COLUMN IF EXISTS api_key_hash, 66 + DROP COLUMN IF EXISTS oauth_access_token, 67 + DROP COLUMN IF EXISTS oauth_refresh_token, 68 + DROP COLUMN IF EXISTS oauth_token_expires_at, 69 + DROP COLUMN IF EXISTS oauth_pds_url, 70 + DROP COLUMN IF EXISTS oauth_auth_server_iss, 71 + DROP COLUMN IF EXISTS oauth_auth_server_token_endpoint, 72 + DROP COLUMN IF EXISTS oauth_dpop_private_key_multibase, 73 + DROP COLUMN IF EXISTS oauth_dpop_authserver_nonce, 74 + DROP COLUMN IF EXISTS oauth_dpop_pds_nonce, 75 + DROP COLUMN IF EXISTS api_key_created_at, 76 + DROP COLUMN IF EXISTS api_key_revoked_at, 77 + DROP COLUMN IF EXISTS api_key_last_used_at;
+92
internal/db/migrations/025_encrypt_aggregator_oauth_tokens.sql
··· 1 + -- +goose Up 2 + -- Encrypt aggregator OAuth tokens at rest using pgp_sym_encrypt 3 + -- This addresses the security issue where OAuth tokens were stored in plaintext 4 + -- despite migration 024 claiming "encrypted at application layer before storage" 5 + 6 + -- +goose StatementBegin 7 + 8 + -- Step 1: Add new encrypted columns for OAuth tokens and DPoP private key 9 + ALTER TABLE aggregators 10 + ADD COLUMN oauth_access_token_encrypted BYTEA, 11 + ADD COLUMN oauth_refresh_token_encrypted BYTEA, 12 + ADD COLUMN oauth_dpop_private_key_encrypted BYTEA; 13 + 14 + -- Step 2: Migrate existing plaintext data to encrypted columns 15 + -- Uses the same encryption key table as community credentials (migration 006) 16 + UPDATE aggregators 17 + SET 18 + oauth_access_token_encrypted = CASE 19 + WHEN oauth_access_token IS NOT NULL AND oauth_access_token != '' 20 + THEN pgp_sym_encrypt(oauth_access_token, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 21 + ELSE NULL 22 + END, 23 + oauth_refresh_token_encrypted = CASE 24 + WHEN oauth_refresh_token IS NOT NULL AND oauth_refresh_token != '' 25 + THEN pgp_sym_encrypt(oauth_refresh_token, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 26 + ELSE NULL 27 + END, 28 + oauth_dpop_private_key_encrypted = CASE 29 + WHEN oauth_dpop_private_key_multibase IS NOT NULL AND oauth_dpop_private_key_multibase != '' 30 + THEN pgp_sym_encrypt(oauth_dpop_private_key_multibase, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 31 + ELSE NULL 32 + END 33 + WHERE oauth_access_token IS NOT NULL 34 + OR oauth_refresh_token IS NOT NULL 35 + OR oauth_dpop_private_key_multibase IS NOT NULL; 36 + 37 + -- Step 3: Drop the old plaintext columns 38 + ALTER TABLE aggregators 39 + DROP COLUMN oauth_access_token, 40 + DROP COLUMN oauth_refresh_token, 41 + DROP COLUMN oauth_dpop_private_key_multibase; 42 + 43 + -- Step 4: Add security comments 44 + COMMENT ON COLUMN aggregators.oauth_access_token_encrypted IS 'SENSITIVE: Encrypted OAuth access token (pgp_sym_encrypt) for PDS operations'; 45 + COMMENT ON COLUMN aggregators.oauth_refresh_token_encrypted IS 'SENSITIVE: Encrypted OAuth refresh token (pgp_sym_encrypt) for session renewal'; 46 + COMMENT ON COLUMN aggregators.oauth_dpop_private_key_encrypted IS 'SENSITIVE: Encrypted DPoP private key (pgp_sym_encrypt) for token refresh'; 47 + 48 + -- +goose StatementEnd 49 + 50 + -- +goose Down 51 + -- +goose StatementBegin 52 + 53 + -- Restore plaintext columns 54 + ALTER TABLE aggregators 55 + ADD COLUMN oauth_access_token TEXT, 56 + ADD COLUMN oauth_refresh_token TEXT, 57 + ADD COLUMN oauth_dpop_private_key_multibase TEXT; 58 + 59 + -- Decrypt data back to plaintext (for rollback) 60 + UPDATE aggregators 61 + SET 62 + oauth_access_token = CASE 63 + WHEN oauth_access_token_encrypted IS NOT NULL 64 + THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 65 + ELSE NULL 66 + END, 67 + oauth_refresh_token = CASE 68 + WHEN oauth_refresh_token_encrypted IS NOT NULL 69 + THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 70 + ELSE NULL 71 + END, 72 + oauth_dpop_private_key_multibase = CASE 73 + WHEN oauth_dpop_private_key_encrypted IS NOT NULL 74 + THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 75 + ELSE NULL 76 + END 77 + WHERE oauth_access_token_encrypted IS NOT NULL 78 + OR oauth_refresh_token_encrypted IS NOT NULL 79 + OR oauth_dpop_private_key_encrypted IS NOT NULL; 80 + 81 + -- Drop encrypted columns 82 + ALTER TABLE aggregators 83 + DROP COLUMN oauth_access_token_encrypted, 84 + DROP COLUMN oauth_refresh_token_encrypted, 85 + DROP COLUMN oauth_dpop_private_key_encrypted; 86 + 87 + -- Restore comments 88 + COMMENT ON COLUMN aggregators.oauth_access_token IS 'SENSITIVE: OAuth access token for PDS operations'; 89 + COMMENT ON COLUMN aggregators.oauth_refresh_token IS 'SENSITIVE: OAuth refresh token for session renewal'; 90 + COMMENT ON COLUMN aggregators.oauth_dpop_private_key_multibase IS 'SENSITIVE: DPoP private key in multibase format for token refresh'; 91 + 92 + -- +goose StatementEnd
+523 -6
internal/db/postgres/aggregator_repo.go
··· 69 69 } 70 70 71 71 // GetAggregator retrieves an aggregator by DID 72 + // Returns only public/display fields - use GetAggregatorCredentials for authentication data 72 73 func (r *postgresAggregatorRepo) GetAggregator(ctx context.Context, did string) (*aggregators.Aggregator, error) { 73 74 query := ` 74 75 SELECT ··· 79 80 WHERE did = $1` 80 81 81 82 agg := &aggregators.Aggregator{} 82 - var description, avatarCID, maintainerDID, homepageURL, recordURI, recordCID sql.NullString 83 + var description, avatarURL, maintainerDID, sourceURL, recordURI, recordCID sql.NullString 83 84 var configSchema []byte 84 85 85 86 err := r.db.QueryRowContext(ctx, query, did).Scan( 86 87 &agg.DID, 87 88 &agg.DisplayName, 88 89 &description, 89 - &avatarCID, 90 + &avatarURL, 90 91 &configSchema, 91 92 &maintainerDID, 92 - &homepageURL, 93 + &sourceURL, 93 94 &agg.CommunitiesUsing, 94 95 &agg.PostsCreated, 95 96 &agg.CreatedAt, ··· 105 106 return nil, fmt.Errorf("failed to get aggregator: %w", err) 106 107 } 107 108 108 - // Map nullable fields 109 + // Map nullable string fields 109 110 agg.Description = description.String 110 - agg.AvatarURL = avatarCID.String 111 + agg.AvatarURL = avatarURL.String 111 112 agg.MaintainerDID = maintainerDID.String 112 - agg.SourceURL = homepageURL.String 113 + agg.SourceURL = sourceURL.String 113 114 agg.RecordURI = recordURI.String 114 115 agg.RecordCID = recordCID.String 116 + 115 117 if configSchema != nil { 116 118 agg.ConfigSchema = configSchema 117 119 } ··· 755 757 return posts, nil 756 758 } 757 759 760 + // ===== API Key Authentication Operations ===== 761 + 762 + // GetByAPIKeyHash looks up an aggregator by their API key hash for authentication 763 + // Returns ErrAggregatorNotFound if no aggregator exists with that key hash 764 + // Returns ErrAPIKeyRevoked if the API key has been revoked 765 + // Note: Returns only public Aggregator fields - use GetCredentialsByAPIKeyHash for credentials 766 + func (r *postgresAggregatorRepo) GetByAPIKeyHash(ctx context.Context, keyHash string) (*aggregators.Aggregator, error) { 767 + query := ` 768 + SELECT 769 + did, display_name, description, avatar_url, config_schema, 770 + maintainer_did, source_url, communities_using, posts_created, 771 + created_at, indexed_at, record_uri, record_cid, 772 + api_key_revoked_at 773 + FROM aggregators 774 + WHERE api_key_hash = $1` 775 + 776 + agg := &aggregators.Aggregator{} 777 + var description, avatarURL, maintainerDID, sourceURL, recordURI, recordCID sql.NullString 778 + var configSchema []byte 779 + var apiKeyRevokedAt sql.NullTime 780 + 781 + err := r.db.QueryRowContext(ctx, query, keyHash).Scan( 782 + &agg.DID, 783 + &agg.DisplayName, 784 + &description, 785 + &avatarURL, 786 + &configSchema, 787 + &maintainerDID, 788 + &sourceURL, 789 + &agg.CommunitiesUsing, 790 + &agg.PostsCreated, 791 + &agg.CreatedAt, 792 + &agg.IndexedAt, 793 + &recordURI, 794 + &recordCID, 795 + &apiKeyRevokedAt, 796 + ) 797 + 798 + if err == sql.ErrNoRows { 799 + return nil, aggregators.ErrAggregatorNotFound 800 + } 801 + if err != nil { 802 + return nil, fmt.Errorf("failed to get aggregator by API key hash: %w", err) 803 + } 804 + 805 + // Check if API key is revoked before returning 806 + if apiKeyRevokedAt.Valid { 807 + return nil, aggregators.ErrAPIKeyRevoked 808 + } 809 + 810 + // Map nullable string fields 811 + agg.Description = description.String 812 + agg.AvatarURL = avatarURL.String 813 + agg.MaintainerDID = maintainerDID.String 814 + agg.SourceURL = sourceURL.String 815 + agg.RecordURI = recordURI.String 816 + agg.RecordCID = recordCID.String 817 + 818 + if configSchema != nil { 819 + agg.ConfigSchema = configSchema 820 + } 821 + 822 + return agg, nil 823 + } 824 + 825 + // SetAPIKey stores API key credentials and OAuth session for an aggregator 826 + // This is called after successful OAuth flow to generate the API key 827 + // SECURITY: OAuth tokens and DPoP private key are encrypted at rest using pgp_sym_encrypt 828 + func (r *postgresAggregatorRepo) SetAPIKey(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *aggregators.OAuthCredentials) error { 829 + query := ` 830 + UPDATE aggregators SET 831 + api_key_prefix = $2, 832 + api_key_hash = $3, 833 + api_key_created_at = NOW(), 834 + api_key_revoked_at = NULL, 835 + oauth_access_token_encrypted = CASE WHEN $4 != '' THEN pgp_sym_encrypt($4, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END, 836 + oauth_refresh_token_encrypted = CASE WHEN $5 != '' THEN pgp_sym_encrypt($5, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END, 837 + oauth_token_expires_at = $6, 838 + oauth_pds_url = $7, 839 + oauth_auth_server_iss = $8, 840 + oauth_auth_server_token_endpoint = $9, 841 + oauth_dpop_private_key_encrypted = CASE WHEN $10 != '' THEN pgp_sym_encrypt($10, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END, 842 + oauth_dpop_authserver_nonce = $11, 843 + oauth_dpop_pds_nonce = $12 844 + WHERE did = $1` 845 + 846 + result, err := r.db.ExecContext(ctx, query, 847 + did, 848 + keyPrefix, 849 + keyHash, 850 + oauthCreds.AccessToken, 851 + oauthCreds.RefreshToken, 852 + oauthCreds.TokenExpiresAt, 853 + oauthCreds.PDSURL, 854 + oauthCreds.AuthServerIss, 855 + oauthCreds.AuthServerTokenEndpoint, 856 + oauthCreds.DPoPPrivateKeyMultibase, 857 + oauthCreds.DPoPAuthServerNonce, 858 + oauthCreds.DPoPPDSNonce, 859 + ) 860 + if err != nil { 861 + return fmt.Errorf("failed to set API key: %w", err) 862 + } 863 + 864 + rows, err := result.RowsAffected() 865 + if err != nil { 866 + return fmt.Errorf("failed to get rows affected: %w", err) 867 + } 868 + if rows == 0 { 869 + return aggregators.ErrAggregatorNotFound 870 + } 871 + 872 + return nil 873 + } 874 + 875 + // UpdateOAuthTokens updates OAuth tokens after a refresh operation 876 + // Called after successfully refreshing an expired access token 877 + // SECURITY: OAuth tokens are encrypted at rest using pgp_sym_encrypt 878 + func (r *postgresAggregatorRepo) UpdateOAuthTokens(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error { 879 + query := ` 880 + UPDATE aggregators SET 881 + oauth_access_token_encrypted = pgp_sym_encrypt($2, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)), 882 + oauth_refresh_token_encrypted = pgp_sym_encrypt($3, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)), 883 + oauth_token_expires_at = $4 884 + WHERE did = $1` 885 + 886 + result, err := r.db.ExecContext(ctx, query, did, accessToken, refreshToken, expiresAt) 887 + if err != nil { 888 + return fmt.Errorf("failed to update OAuth tokens: %w", err) 889 + } 890 + 891 + rows, err := result.RowsAffected() 892 + if err != nil { 893 + return fmt.Errorf("failed to get rows affected: %w", err) 894 + } 895 + if rows == 0 { 896 + return aggregators.ErrAggregatorNotFound 897 + } 898 + 899 + return nil 900 + } 901 + 902 + // UpdateOAuthNonces updates DPoP nonces after token operations 903 + // Nonces are updated after each request to the auth server or PDS 904 + func (r *postgresAggregatorRepo) UpdateOAuthNonces(ctx context.Context, did, authServerNonce, pdsNonce string) error { 905 + query := ` 906 + UPDATE aggregators SET 907 + oauth_dpop_authserver_nonce = COALESCE(NULLIF($2, ''), oauth_dpop_authserver_nonce), 908 + oauth_dpop_pds_nonce = COALESCE(NULLIF($3, ''), oauth_dpop_pds_nonce) 909 + WHERE did = $1` 910 + 911 + result, err := r.db.ExecContext(ctx, query, did, authServerNonce, pdsNonce) 912 + if err != nil { 913 + return fmt.Errorf("failed to update OAuth nonces: %w", err) 914 + } 915 + 916 + rows, err := result.RowsAffected() 917 + if err != nil { 918 + return fmt.Errorf("failed to get rows affected: %w", err) 919 + } 920 + if rows == 0 { 921 + return aggregators.ErrAggregatorNotFound 922 + } 923 + 924 + return nil 925 + } 926 + 927 + // UpdateAPIKeyLastUsed updates the last_used_at timestamp for audit purposes 928 + // Called on each successful authentication to track API key usage 929 + func (r *postgresAggregatorRepo) UpdateAPIKeyLastUsed(ctx context.Context, did string) error { 930 + query := ` 931 + UPDATE aggregators SET 932 + api_key_last_used_at = NOW() 933 + WHERE did = $1` 934 + 935 + result, err := r.db.ExecContext(ctx, query, did) 936 + if err != nil { 937 + return fmt.Errorf("failed to update API key last used: %w", err) 938 + } 939 + 940 + rows, err := result.RowsAffected() 941 + if err != nil { 942 + return fmt.Errorf("failed to get rows affected: %w", err) 943 + } 944 + if rows == 0 { 945 + return aggregators.ErrAggregatorNotFound 946 + } 947 + 948 + return nil 949 + } 950 + 951 + // RevokeAPIKey marks an API key as revoked (sets api_key_revoked_at) 952 + // After revocation, the aggregator must complete OAuth flow again to get a new key 953 + func (r *postgresAggregatorRepo) RevokeAPIKey(ctx context.Context, did string) error { 954 + query := ` 955 + UPDATE aggregators SET 956 + api_key_revoked_at = NOW() 957 + WHERE did = $1 AND api_key_hash IS NOT NULL` 958 + 959 + result, err := r.db.ExecContext(ctx, query, did) 960 + if err != nil { 961 + return fmt.Errorf("failed to revoke API key: %w", err) 962 + } 963 + 964 + rows, err := result.RowsAffected() 965 + if err != nil { 966 + return fmt.Errorf("failed to get rows affected: %w", err) 967 + } 968 + if rows == 0 { 969 + return aggregators.ErrAggregatorNotFound 970 + } 971 + 972 + return nil 973 + } 974 + 975 + // GetAggregatorCredentials retrieves only credential data for an aggregator 976 + // Used by APIKeyService for authentication operations where full aggregator is not needed 977 + func (r *postgresAggregatorRepo) GetAggregatorCredentials(ctx context.Context, did string) (*aggregators.AggregatorCredentials, error) { 978 + query := ` 979 + SELECT 980 + did, 981 + api_key_prefix, api_key_hash, api_key_created_at, api_key_revoked_at, api_key_last_used_at, 982 + CASE 983 + WHEN oauth_access_token_encrypted IS NOT NULL 984 + THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 985 + ELSE NULL 986 + END as oauth_access_token, 987 + CASE 988 + WHEN oauth_refresh_token_encrypted IS NOT NULL 989 + THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 990 + ELSE NULL 991 + END as oauth_refresh_token, 992 + oauth_token_expires_at, 993 + oauth_pds_url, oauth_auth_server_iss, oauth_auth_server_token_endpoint, 994 + CASE 995 + WHEN oauth_dpop_private_key_encrypted IS NOT NULL 996 + THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 997 + ELSE NULL 998 + END as oauth_dpop_private_key_multibase, 999 + oauth_dpop_authserver_nonce, oauth_dpop_pds_nonce 1000 + FROM aggregators 1001 + WHERE did = $1` 1002 + 1003 + creds := &aggregators.AggregatorCredentials{} 1004 + var apiKeyPrefix, apiKeyHash sql.NullString 1005 + var oauthAccessToken, oauthRefreshToken sql.NullString 1006 + var oauthPDSURL, oauthAuthServerIss, oauthAuthServerTokenEndpoint sql.NullString 1007 + var oauthDPoPPrivateKey, oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString 1008 + var apiKeyCreatedAt, apiKeyRevokedAt, apiKeyLastUsed, oauthTokenExpiresAt sql.NullTime 1009 + 1010 + err := r.db.QueryRowContext(ctx, query, did).Scan( 1011 + &creds.DID, 1012 + &apiKeyPrefix, 1013 + &apiKeyHash, 1014 + &apiKeyCreatedAt, 1015 + &apiKeyRevokedAt, 1016 + &apiKeyLastUsed, 1017 + &oauthAccessToken, 1018 + &oauthRefreshToken, 1019 + &oauthTokenExpiresAt, 1020 + &oauthPDSURL, 1021 + &oauthAuthServerIss, 1022 + &oauthAuthServerTokenEndpoint, 1023 + &oauthDPoPPrivateKey, 1024 + &oauthDPoPAuthServerNonce, 1025 + &oauthDPoPPDSNonce, 1026 + ) 1027 + 1028 + if err == sql.ErrNoRows { 1029 + return nil, aggregators.ErrAggregatorNotFound 1030 + } 1031 + if err != nil { 1032 + return nil, fmt.Errorf("failed to get aggregator credentials: %w", err) 1033 + } 1034 + 1035 + // Map nullable string fields 1036 + creds.APIKeyPrefix = apiKeyPrefix.String 1037 + creds.APIKeyHash = apiKeyHash.String 1038 + creds.OAuthAccessToken = oauthAccessToken.String 1039 + creds.OAuthRefreshToken = oauthRefreshToken.String 1040 + creds.OAuthPDSURL = oauthPDSURL.String 1041 + creds.OAuthAuthServerIss = oauthAuthServerIss.String 1042 + creds.OAuthAuthServerTokenEndpoint = oauthAuthServerTokenEndpoint.String 1043 + creds.OAuthDPoPPrivateKeyMultibase = oauthDPoPPrivateKey.String 1044 + creds.OAuthDPoPAuthServerNonce = oauthDPoPAuthServerNonce.String 1045 + creds.OAuthDPoPPDSNonce = oauthDPoPPDSNonce.String 1046 + 1047 + // Map nullable time fields 1048 + if apiKeyCreatedAt.Valid { 1049 + t := apiKeyCreatedAt.Time 1050 + creds.APIKeyCreatedAt = &t 1051 + } 1052 + if apiKeyRevokedAt.Valid { 1053 + t := apiKeyRevokedAt.Time 1054 + creds.APIKeyRevokedAt = &t 1055 + } 1056 + if apiKeyLastUsed.Valid { 1057 + t := apiKeyLastUsed.Time 1058 + creds.APIKeyLastUsed = &t 1059 + } 1060 + if oauthTokenExpiresAt.Valid { 1061 + t := oauthTokenExpiresAt.Time 1062 + creds.OAuthTokenExpiresAt = &t 1063 + } 1064 + 1065 + return creds, nil 1066 + } 1067 + 1068 + // GetCredentialsByAPIKeyHash looks up credentials by API key hash for authentication 1069 + // Returns ErrAPIKeyRevoked if the API key has been revoked 1070 + // Returns ErrAPIKeyInvalid if no aggregator found with that hash 1071 + func (r *postgresAggregatorRepo) GetCredentialsByAPIKeyHash(ctx context.Context, keyHash string) (*aggregators.AggregatorCredentials, error) { 1072 + query := ` 1073 + SELECT 1074 + did, 1075 + api_key_prefix, api_key_hash, api_key_created_at, api_key_revoked_at, api_key_last_used_at, 1076 + CASE 1077 + WHEN oauth_access_token_encrypted IS NOT NULL 1078 + THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 1079 + ELSE NULL 1080 + END as oauth_access_token, 1081 + CASE 1082 + WHEN oauth_refresh_token_encrypted IS NOT NULL 1083 + THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 1084 + ELSE NULL 1085 + END as oauth_refresh_token, 1086 + oauth_token_expires_at, 1087 + oauth_pds_url, oauth_auth_server_iss, oauth_auth_server_token_endpoint, 1088 + CASE 1089 + WHEN oauth_dpop_private_key_encrypted IS NOT NULL 1090 + THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 1091 + ELSE NULL 1092 + END as oauth_dpop_private_key_multibase, 1093 + oauth_dpop_authserver_nonce, oauth_dpop_pds_nonce 1094 + FROM aggregators 1095 + WHERE api_key_hash = $1` 1096 + 1097 + creds := &aggregators.AggregatorCredentials{} 1098 + var apiKeyPrefix, apiKeyHash sql.NullString 1099 + var oauthAccessToken, oauthRefreshToken sql.NullString 1100 + var oauthPDSURL, oauthAuthServerIss, oauthAuthServerTokenEndpoint sql.NullString 1101 + var oauthDPoPPrivateKey, oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString 1102 + var apiKeyCreatedAt, apiKeyRevokedAt, apiKeyLastUsed, oauthTokenExpiresAt sql.NullTime 1103 + 1104 + err := r.db.QueryRowContext(ctx, query, keyHash).Scan( 1105 + &creds.DID, 1106 + &apiKeyPrefix, 1107 + &apiKeyHash, 1108 + &apiKeyCreatedAt, 1109 + &apiKeyRevokedAt, 1110 + &apiKeyLastUsed, 1111 + &oauthAccessToken, 1112 + &oauthRefreshToken, 1113 + &oauthTokenExpiresAt, 1114 + &oauthPDSURL, 1115 + &oauthAuthServerIss, 1116 + &oauthAuthServerTokenEndpoint, 1117 + &oauthDPoPPrivateKey, 1118 + &oauthDPoPAuthServerNonce, 1119 + &oauthDPoPPDSNonce, 1120 + ) 1121 + 1122 + if err == sql.ErrNoRows { 1123 + return nil, aggregators.ErrAPIKeyInvalid 1124 + } 1125 + if err != nil { 1126 + return nil, fmt.Errorf("failed to get credentials by API key hash: %w", err) 1127 + } 1128 + 1129 + // Map nullable string fields 1130 + creds.APIKeyPrefix = apiKeyPrefix.String 1131 + creds.APIKeyHash = apiKeyHash.String 1132 + creds.OAuthAccessToken = oauthAccessToken.String 1133 + creds.OAuthRefreshToken = oauthRefreshToken.String 1134 + creds.OAuthPDSURL = oauthPDSURL.String 1135 + creds.OAuthAuthServerIss = oauthAuthServerIss.String 1136 + creds.OAuthAuthServerTokenEndpoint = oauthAuthServerTokenEndpoint.String 1137 + creds.OAuthDPoPPrivateKeyMultibase = oauthDPoPPrivateKey.String 1138 + creds.OAuthDPoPAuthServerNonce = oauthDPoPAuthServerNonce.String 1139 + creds.OAuthDPoPPDSNonce = oauthDPoPPDSNonce.String 1140 + 1141 + // Map nullable time fields 1142 + if apiKeyCreatedAt.Valid { 1143 + t := apiKeyCreatedAt.Time 1144 + creds.APIKeyCreatedAt = &t 1145 + } 1146 + if apiKeyRevokedAt.Valid { 1147 + t := apiKeyRevokedAt.Time 1148 + creds.APIKeyRevokedAt = &t 1149 + } 1150 + if apiKeyLastUsed.Valid { 1151 + t := apiKeyLastUsed.Time 1152 + creds.APIKeyLastUsed = &t 1153 + } 1154 + if oauthTokenExpiresAt.Valid { 1155 + t := oauthTokenExpiresAt.Time 1156 + creds.OAuthTokenExpiresAt = &t 1157 + } 1158 + 1159 + // Check if API key is revoked 1160 + if creds.APIKeyRevokedAt != nil { 1161 + return nil, aggregators.ErrAPIKeyRevoked 1162 + } 1163 + 1164 + return creds, nil 1165 + } 1166 + 1167 + // ListAggregatorsNeedingTokenRefresh returns aggregators with active API keys 1168 + // whose OAuth tokens expire within the given buffer period. 1169 + // Used by background job to proactively refresh tokens before they expire. 1170 + func (r *postgresAggregatorRepo) ListAggregatorsNeedingTokenRefresh(ctx context.Context, expiryBuffer time.Duration) ([]*aggregators.AggregatorCredentials, error) { 1171 + query := ` 1172 + SELECT 1173 + did, 1174 + api_key_prefix, api_key_hash, api_key_created_at, api_key_revoked_at, api_key_last_used_at, 1175 + CASE 1176 + WHEN oauth_access_token_encrypted IS NOT NULL 1177 + THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 1178 + ELSE NULL 1179 + END as oauth_access_token, 1180 + CASE 1181 + WHEN oauth_refresh_token_encrypted IS NOT NULL 1182 + THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 1183 + ELSE NULL 1184 + END as oauth_refresh_token, 1185 + oauth_token_expires_at, 1186 + oauth_pds_url, oauth_auth_server_iss, oauth_auth_server_token_endpoint, 1187 + CASE 1188 + WHEN oauth_dpop_private_key_encrypted IS NOT NULL 1189 + THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 1190 + ELSE NULL 1191 + END as oauth_dpop_private_key_multibase, 1192 + oauth_dpop_authserver_nonce, oauth_dpop_pds_nonce 1193 + FROM aggregators 1194 + WHERE api_key_hash IS NOT NULL 1195 + AND api_key_revoked_at IS NULL 1196 + AND oauth_token_expires_at IS NOT NULL 1197 + AND oauth_token_expires_at <= NOW() + $1` 1198 + 1199 + rows, err := r.db.QueryContext(ctx, query, expiryBuffer) 1200 + if err != nil { 1201 + return nil, fmt.Errorf("failed to list aggregators needing token refresh: %w", err) 1202 + } 1203 + defer func() { _ = rows.Close() }() 1204 + 1205 + var results []*aggregators.AggregatorCredentials 1206 + for rows.Next() { 1207 + creds := &aggregators.AggregatorCredentials{} 1208 + var apiKeyPrefix, apiKeyHash sql.NullString 1209 + var oauthAccessToken, oauthRefreshToken sql.NullString 1210 + var oauthPDSURL, oauthAuthServerIss, oauthAuthServerTokenEndpoint sql.NullString 1211 + var oauthDPoPPrivateKey, oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString 1212 + var apiKeyCreatedAt, apiKeyRevokedAt, apiKeyLastUsed, oauthTokenExpiresAt sql.NullTime 1213 + 1214 + err := rows.Scan( 1215 + &creds.DID, 1216 + &apiKeyPrefix, 1217 + &apiKeyHash, 1218 + &apiKeyCreatedAt, 1219 + &apiKeyRevokedAt, 1220 + &apiKeyLastUsed, 1221 + &oauthAccessToken, 1222 + &oauthRefreshToken, 1223 + &oauthTokenExpiresAt, 1224 + &oauthPDSURL, 1225 + &oauthAuthServerIss, 1226 + &oauthAuthServerTokenEndpoint, 1227 + &oauthDPoPPrivateKey, 1228 + &oauthDPoPAuthServerNonce, 1229 + &oauthDPoPPDSNonce, 1230 + ) 1231 + if err != nil { 1232 + return nil, fmt.Errorf("failed to scan aggregator credentials: %w", err) 1233 + } 1234 + 1235 + // Map nullable string fields 1236 + creds.APIKeyPrefix = apiKeyPrefix.String 1237 + creds.APIKeyHash = apiKeyHash.String 1238 + creds.OAuthAccessToken = oauthAccessToken.String 1239 + creds.OAuthRefreshToken = oauthRefreshToken.String 1240 + creds.OAuthPDSURL = oauthPDSURL.String 1241 + creds.OAuthAuthServerIss = oauthAuthServerIss.String 1242 + creds.OAuthAuthServerTokenEndpoint = oauthAuthServerTokenEndpoint.String 1243 + creds.OAuthDPoPPrivateKeyMultibase = oauthDPoPPrivateKey.String 1244 + creds.OAuthDPoPAuthServerNonce = oauthDPoPAuthServerNonce.String 1245 + creds.OAuthDPoPPDSNonce = oauthDPoPPDSNonce.String 1246 + 1247 + // Map nullable time fields 1248 + if apiKeyCreatedAt.Valid { 1249 + t := apiKeyCreatedAt.Time 1250 + creds.APIKeyCreatedAt = &t 1251 + } 1252 + if apiKeyRevokedAt.Valid { 1253 + t := apiKeyRevokedAt.Time 1254 + creds.APIKeyRevokedAt = &t 1255 + } 1256 + if apiKeyLastUsed.Valid { 1257 + t := apiKeyLastUsed.Time 1258 + creds.APIKeyLastUsed = &t 1259 + } 1260 + if oauthTokenExpiresAt.Valid { 1261 + t := oauthTokenExpiresAt.Time 1262 + creds.OAuthTokenExpiresAt = &t 1263 + } 1264 + 1265 + results = append(results, creds) 1266 + } 1267 + 1268 + if err = rows.Err(); err != nil { 1269 + return nil, fmt.Errorf("error iterating aggregators needing token refresh: %w", err) 1270 + } 1271 + 1272 + return results, nil 1273 + } 1274 + 758 1275 // ===== Helper Functions ===== 759 1276 760 1277 // scanAuthorizations is a helper to scan multiple authorization rows
+9 -5
docker-compose.prod.yml
··· 103 103 104 104 105 105 106 + # Restrict community creation to instance DID only 107 + COMMUNITY_CREATORS: did:web:coves.social 106 108 107 - 108 - 109 - 110 - 109 + # Trusted aggregators (bypass per-community authorization check) 110 + # Comma-separated list of DIDs 111 + TRUSTED_AGGREGATOR_DIDS: ${TRUSTED_AGGREGATOR_DIDS:-} 112 + networks: 113 + - coves-internal 114 + depends_on: 111 115 112 116 113 117 ··· 167 171 168 172 # Production mode 169 173 PDS_DEV_MODE: "false" 170 - PDS_INVITE_REQUIRED: "false" # Set to true if you want invite-only 174 + PDS_INVITE_REQUIRED: "true" 171 175 172 176 # Logging 173 177 NODE_ENV: production
+28 -6
internal/api/handlers/aggregator/errors.go
··· 3 3 import ( 4 4 "Coves/internal/core/aggregators" 5 5 "Coves/internal/core/communities" 6 + "bytes" 6 7 "encoding/json" 7 8 "log" 8 9 "net/http" ··· 14 15 Message string `json:"message"` 15 16 } 16 17 17 - // writeError writes a JSON error response 18 - func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { 18 + // writeJSONResponse buffers the JSON encoding before sending headers. 19 + // This ensures that encoding failures don't result in partial responses 20 + // with already-sent headers. Returns true if the response was written 21 + // successfully, false otherwise. 22 + func writeJSONResponse(w http.ResponseWriter, statusCode int, data interface{}) bool { 23 + // Buffer the JSON first to detect encoding errors before sending headers 24 + var buf bytes.Buffer 25 + if err := json.NewEncoder(&buf).Encode(data); err != nil { 26 + log.Printf("ERROR: Failed to encode JSON response: %v", err) 27 + // Send a proper error response since we haven't sent headers yet 28 + w.Header().Set("Content-Type", "application/json") 29 + w.WriteHeader(http.StatusInternalServerError) 30 + _, _ = w.Write([]byte(`{"error":"InternalServerError","message":"Failed to encode response"}`)) 31 + return false 32 + } 33 + 19 34 w.Header().Set("Content-Type", "application/json") 20 35 w.WriteHeader(statusCode) 21 - if err := json.NewEncoder(w).Encode(ErrorResponse{ 36 + if _, err := w.Write(buf.Bytes()); err != nil { 37 + log.Printf("ERROR: Failed to write response body: %v", err) 38 + return false 39 + } 40 + return true 41 + } 42 + 43 + // writeError writes a JSON error response with proper buffering 44 + func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { 45 + writeJSONResponse(w, statusCode, ErrorResponse{ 22 46 Error: errorType, 23 47 Message: message, 24 - }); err != nil { 25 - log.Printf("ERROR: Failed to encode error response: %v", err) 26 - } 48 + }) 27 49 } 28 50 29 51 // handleServiceError maps service errors to HTTP responses
+42
internal/api/handlers/aggregator/metrics.go
··· 1 + package aggregator 2 + 3 + import ( 4 + "net/http" 5 + 6 + "Coves/internal/core/aggregators" 7 + ) 8 + 9 + // MetricsHandler provides API key service metrics for monitoring 10 + type MetricsHandler struct { 11 + apiKeyService aggregators.APIKeyServiceInterface 12 + } 13 + 14 + // NewMetricsHandler creates a new metrics handler 15 + func NewMetricsHandler(apiKeyService aggregators.APIKeyServiceInterface) *MetricsHandler { 16 + return &MetricsHandler{ 17 + apiKeyService: apiKeyService, 18 + } 19 + } 20 + 21 + // MetricsResponse contains API key service operational metrics 22 + type MetricsResponse struct { 23 + FailedLastUsedUpdates int64 `json:"failedLastUsedUpdates"` 24 + FailedNonceUpdates int64 `json:"failedNonceUpdates"` 25 + } 26 + 27 + // HandleMetrics handles GET /xrpc/social.coves.aggregator.getMetrics 28 + // Returns operational metrics for the API key service. 29 + // This endpoint is intended for internal monitoring and health checks. 30 + func (h *MetricsHandler) HandleMetrics(w http.ResponseWriter, r *http.Request) { 31 + if r.Method != http.MethodGet { 32 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 33 + return 34 + } 35 + 36 + response := MetricsResponse{ 37 + FailedLastUsedUpdates: h.apiKeyService.GetFailedLastUsedUpdates(), 38 + FailedNonceUpdates: h.apiKeyService.GetFailedNonceUpdates(), 39 + } 40 + 41 + writeJSONResponse(w, http.StatusOK, response) 42 + }
+98 -7
.env.dev
··· 38 38 PDS_ADMIN_PASSWORD=admin 39 39 40 40 # Handle domains (users will get handles like alice.local.coves.dev) 41 - # Communities will use .community.coves.social (singular per atProto conventions) 42 - PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.community.coves.social 41 + # Communities will use c-{name}.coves.social (3-level format with c- prefix) 42 + PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.coves.social 43 43 44 44 # PLC Rotation Key (k256 private key in hex format - for local dev only) 45 45 # This is a randomly generated key for testing - DO NOT use in production ··· 74 74 75 75 76 76 77 + # Local E2E Testing: Use local Jetstream (indexes only local PDS) 78 + # 1. Start local Jetstream: docker-compose --profile jetstream up pds jetstream 79 + # 2. Use this URL: 80 + JETSTREAM_URL=ws://localhost:6008/subscribe?wantedCollections=app.bsky.actor.profile 77 81 82 + # Optional: Filter events to specific PDS 83 + # JETSTREAM_PDS_FILTER=http://localhost:3001 78 84 79 85 80 86 ··· 124 130 125 131 126 132 133 + PDS_INSTANCE_HANDLE=testuser123.local.coves.dev 134 + PDS_INSTANCE_PASSWORD=test-password-123 135 + 136 + # Trusted Aggregator DIDs (bypasses community authorization check) 137 + # Comma-separated list of DIDs 138 + # - did:plc:yyf34padpfjknejyutxtionr = kagi-news.coves.social (production) 139 + # - did:plc:igjbg5cex7poojsniebvmafb = test-aggregator.local.coves.dev (dev) 140 + TRUSTED_AGGREGATOR_DIDS=did:plc:yyf34padpfjknejyutxtionr,did:plc:igjbg5cex7poojsniebvmafb,did:plc:jn4tlbpkdms5tahfrylct5g7 141 + 142 + # ============================================================================= 143 + # Development Settings 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 127 173 128 174 129 175 130 176 131 177 132 178 133 - PDS_INSTANCE_HANDLE=testuser123.local.coves.dev 134 - PDS_INSTANCE_PASSWORD=test-password-123 135 179 136 - # Kagi News Aggregator DID (for trusted thumbnail URLs) 137 - KAGI_AGGREGATOR_DID=did:plc:yyf34padpfjknejyutxtionr 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + # - PostgreSQL is only for Coves AppView indexing 215 + # - AppView subscribes directly to PDS firehose (no relay needed) 216 + # - PDS firehose: ws://localhost:3001/xrpc/com.atproto.sync.subscribeRepos 217 + 138 218 139 219 # ============================================================================= 140 - # Development Settings 220 + # Image Proxy Configuration 221 + # ============================================================================= 222 + # On-the-fly image resizing with disk caching 223 + # Defaults to enabled - falls back to direct PDS URLs if proxy fails 224 + IMAGE_PROXY_ENABLED=true 225 + IMAGE_PROXY_BASE_URL=http://127.0.0.1:8081 226 + IMAGE_PROXY_CACHE_PATH=./cache/images 227 + IMAGE_PROXY_CACHE_MAX_GB=5 228 + # Optional: CDN URL for production (leave empty for local dev) 229 + # IMAGE_PROXY_CDN_URL= 230 + IMAGE_PROXY_FETCH_TIMEOUT_SECONDS=30 231 + IMAGE_PROXY_MAX_SOURCE_SIZE_MB=10
+56 -1
.env.dev.example
··· 46 46 PDS_DID_PLC_URL=http://plc-directory:3000 47 47 PDS_JWT_SECRET=local-dev-jwt-secret-change-in-production 48 48 PDS_ADMIN_PASSWORD=admin 49 - PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.community.coves.social 49 + PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.coves.social 50 50 PDS_PLC_ROTATION_KEY=<generate-a-random-hex-key> 51 51 52 52 # ============================================================================= 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + # ============================================================================= 61 + # Jetstream Configuration 62 + # ============================================================================= 63 + # User profile indexing - wantedCollections filters to profile events only 64 + JETSTREAM_URL=ws://localhost:6008/subscribe?wantedCollections=app.bsky.actor.profile 65 + 66 + # ============================================================================= 67 + # Identity Resolution 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + SKIP_DID_WEB_VERIFICATION=true 92 + AUTH_SKIP_VERIFY=true 93 + HS256_ISSUERS=http://localhost:3001 94 + 95 + # ============================================================================= 96 + # Image Proxy Configuration 97 + # ============================================================================= 98 + # On-the-fly image resizing with disk caching 99 + # Defaults to enabled - falls back to direct PDS URLs if proxy fails 100 + IMAGE_PROXY_ENABLED=true 101 + IMAGE_PROXY_BASE_URL=http://127.0.0.1:8081 102 + IMAGE_PROXY_CACHE_PATH=./cache/images 103 + IMAGE_PROXY_CACHE_MAX_GB=5 104 + # Optional: CDN URL for production (leave empty for local dev) 105 + # IMAGE_PROXY_CDN_URL= 106 + IMAGE_PROXY_FETCH_TIMEOUT_SECONDS=30 107 + IMAGE_PROXY_MAX_SOURCE_SIZE_MB=10
+2 -3
aggregators/kagi-news/.env.example
··· 1 - # Aggregator Identity (pre-created account credentials) 2 - AGGREGATOR_HANDLE=kagi-news.local.coves.dev 3 - AGGREGATOR_PASSWORD=your-secure-password-here 1 + # Coves API Key (get from https://coves.social after OAuth login) 2 + COVES_API_KEY=ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 4 3 5 4 # Optional: Override Coves API URL (defaults to config.yaml) 6 5 # COVES_API_URL=http://localhost:3001
-1
aggregators/kagi-news/requirements.txt
··· 2 2 feedparser==6.0.11 3 3 beautifulsoup4==4.12.3 4 4 requests==2.31.0 5 - atproto==0.0.55 6 5 pyyaml==6.0.1 7 6 8 7 # Testing
+133 -55
aggregators/kagi-news/src/coves_client.py
··· 1 1 """ 2 2 Coves API Client for posting to communities. 3 3 4 - Handles authentication and posting via XRPC. 4 + Handles API key authentication and posting via XRPC. 5 5 """ 6 6 import logging 7 7 import requests 8 8 from typing import Dict, List, Optional 9 - from atproto import Client 10 9 11 10 logger = logging.getLogger(__name__) 12 11 13 12 13 + class CovesAPIError(Exception): 14 + """Base exception for Coves API errors.""" 15 + 16 + def __init__(self, message: str, status_code: int = None, response_body: str = None): 17 + super().__init__(message) 18 + self.status_code = status_code 19 + self.response_body = response_body 20 + 21 + 22 + class CovesAuthenticationError(CovesAPIError): 23 + """Raised when authentication fails (401 Unauthorized).""" 24 + pass 25 + 26 + 27 + class CovesNotFoundError(CovesAPIError): 28 + """Raised when a resource is not found (404 Not Found).""" 29 + pass 30 + 31 + 32 + class CovesRateLimitError(CovesAPIError): 33 + """Raised when rate limit is exceeded (429 Too Many Requests).""" 34 + pass 35 + 36 + 37 + class CovesForbiddenError(CovesAPIError): 38 + """Raised when access is forbidden (403 Forbidden).""" 39 + pass 40 + 41 + 14 42 class CovesClient: 15 43 """ 16 44 Client for posting to Coves communities via XRPC. 17 45 18 46 Handles: 19 - - Authentication with aggregator credentials 47 + - API key authentication 20 48 - Creating posts in communities (social.coves.community.post.create) 21 49 - External embed formatting 22 50 """ 23 51 24 - def __init__(self, api_url: str, handle: str, password: str, pds_url: Optional[str] = None): 25 - """ 26 - Initialize Coves client. 27 - 28 - Args: 29 - api_url: Coves AppView URL for posting (e.g., "http://localhost:8081") 30 - handle: Aggregator handle (e.g., "kagi-news.coves.social") 31 - password: Aggregator password/app password 32 - pds_url: Optional PDS URL for authentication (defaults to api_url) 33 - """ 34 - self.api_url = api_url 35 - self.pds_url = pds_url or api_url # Auth through PDS, post through AppView 36 - self.handle = handle 37 - self.password = password 38 - self.client = Client(base_url=self.pds_url) # Use PDS for auth 39 - self._authenticated = False 52 + # API key format constants (must match Go constants in apikey_service.go) 53 + API_KEY_PREFIX = "ckapi_" 54 + API_KEY_TOTAL_LENGTH = 70 # 6 (prefix) + 64 (32 bytes hex-encoded) 40 55 41 - def authenticate(self): 56 + def __init__(self, api_url: str, api_key: str): 42 57 """ 43 - Authenticate with Coves API. 58 + Initialize Coves client with API key authentication. 44 59 45 - Uses com.atproto.server.createSession directly to avoid 46 - Bluesky-specific endpoints that don't exist on Coves PDS. 60 + Args: 61 + api_url: Coves API URL for posting (e.g., "https://coves.social") 62 + api_key: Coves API key (e.g., "ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") 47 63 48 64 Raises: 49 - Exception: If authentication fails 65 + ValueError: If api_key format is invalid 50 66 """ 51 - try: 52 - logger.info(f"Authenticating as {self.handle}") 53 - 54 - # Use createSession directly (avoid app.bsky.actor.getProfile) 55 - session = self.client.com.atproto.server.create_session( 56 - {"identifier": self.handle, "password": self.password} 67 + # Validate API key format for early failure with clear error 68 + if not api_key: 69 + raise ValueError("API key cannot be empty") 70 + if not api_key.startswith(self.API_KEY_PREFIX): 71 + raise ValueError(f"API key must start with '{self.API_KEY_PREFIX}'") 72 + if len(api_key) != self.API_KEY_TOTAL_LENGTH: 73 + raise ValueError( 74 + f"API key must be {self.API_KEY_TOTAL_LENGTH} characters " 75 + f"(got {len(api_key)})" 57 76 ) 58 77 59 - # Manually set session (skip profile fetch) 60 - self.client._session = session 61 - self._authenticated = True 62 - self.did = session.did 78 + self.api_url = api_url.rstrip('/') 79 + self.api_key = api_key 80 + self.session = requests.Session() 81 + self.session.headers['Authorization'] = f'Bearer {api_key}' 82 + self.session.headers['Content-Type'] = 'application/json' 63 83 64 - logger.info(f"Authentication successful (DID: {self.did})") 65 - except Exception as e: 66 - logger.error(f"Authentication failed: {e}") 67 - raise 84 + def authenticate(self): 85 + """ 86 + No-op for API key authentication. 87 + 88 + API key is set in the session headers during initialization. 89 + This method is kept for backward compatibility with existing code 90 + that calls authenticate() before making requests. 91 + """ 92 + logger.info("Using API key authentication (no session creation needed)") 68 93 69 94 def create_post( 70 95 self, ··· 90 115 AT Proto URI of created post (e.g., "at://did:plc:.../social.coves.post/...") 91 116 92 117 Raises: 93 - Exception: If post creation fails 118 + requests.HTTPError: If post creation fails 94 119 """ 95 - if not self._authenticated: 96 - self.authenticate() 97 - 98 120 try: 99 121 # Prepare post data for social.coves.community.post.create endpoint 100 122 post_data = { ··· 119 141 # This provides validation, authorization, and business logic 120 142 logger.info(f"Creating post in community: {community_handle}") 121 143 122 - # Make direct HTTP request to XRPC endpoint 144 + # Make HTTP request to XRPC endpoint using session with API key 123 145 url = f"{self.api_url}/xrpc/social.coves.community.post.create" 124 - headers = { 125 - "Authorization": f"Bearer {self.client._session.access_jwt}", 126 - "Content-Type": "application/json" 127 - } 128 - 129 - response = requests.post(url, json=post_data, headers=headers, timeout=30) 146 + response = self.session.post(url, json=post_data, timeout=30) 130 147 131 - # Log detailed error if request fails 148 + # Handle specific error cases 132 149 if not response.ok: 133 150 error_body = response.text 134 151 logger.error(f"Post creation failed ({response.status_code}): {error_body}") 135 - response.raise_for_status() 152 + self._raise_for_status(response) 153 + 154 + try: 155 + result = response.json() 156 + post_uri = result["uri"] 157 + except (ValueError, KeyError) as e: 158 + # ValueError for invalid JSON, KeyError for missing 'uri' field 159 + logger.error(f"Failed to parse post creation response: {e}") 160 + raise CovesAPIError( 161 + f"Invalid response from server: {e}", 162 + status_code=response.status_code, 163 + response_body=response.text 164 + ) 136 165 137 - result = response.json() 138 - post_uri = result["uri"] 139 166 logger.info(f"Post created: {post_uri}") 140 167 return post_uri 141 168 142 - except Exception as e: 143 - logger.error(f"Failed to create post: {e}") 169 + except requests.RequestException as e: 170 + # Network errors, timeouts, etc. 171 + logger.error(f"Network error creating post: {e}") 172 + raise 173 + except CovesAPIError: 174 + # Re-raise our custom exceptions as-is 144 175 raise 145 176 146 177 def create_external_embed( ··· 176 207 "external": external 177 208 } 178 209 210 + def _raise_for_status(self, response: requests.Response) -> None: 211 + """ 212 + Raise specific exceptions based on HTTP status code. 213 + 214 + Args: 215 + response: The HTTP response object 216 + 217 + Raises: 218 + CovesAuthenticationError: For 401 Unauthorized 219 + CovesNotFoundError: For 404 Not Found 220 + CovesRateLimitError: For 429 Too Many Requests 221 + CovesAPIError: For other 4xx/5xx errors 222 + """ 223 + status_code = response.status_code 224 + error_body = response.text 225 + 226 + if status_code == 401: 227 + raise CovesAuthenticationError( 228 + f"Authentication failed: {error_body}", 229 + status_code=status_code, 230 + response_body=error_body 231 + ) 232 + elif status_code == 403: 233 + raise CovesForbiddenError( 234 + f"Access forbidden: {error_body}", 235 + status_code=status_code, 236 + response_body=error_body 237 + ) 238 + elif status_code == 404: 239 + raise CovesNotFoundError( 240 + f"Resource not found: {error_body}", 241 + status_code=status_code, 242 + response_body=error_body 243 + ) 244 + elif status_code == 429: 245 + raise CovesRateLimitError( 246 + f"Rate limit exceeded: {error_body}", 247 + status_code=status_code, 248 + response_body=error_body 249 + ) 250 + else: 251 + raise CovesAPIError( 252 + f"API request failed ({status_code}): {error_body}", 253 + status_code=status_code, 254 + response_body=error_body 255 + ) 256 + 179 257 def _get_timestamp(self) -> str: 180 258 """ 181 259 Get current timestamp in ISO 8601 format.
+5 -9
aggregators/kagi-news/src/main.py
··· 71 71 if coves_client: 72 72 self.coves_client = coves_client 73 73 else: 74 - # Get credentials from environment 75 - aggregator_handle = os.getenv('AGGREGATOR_HANDLE') 76 - aggregator_password = os.getenv('AGGREGATOR_PASSWORD') 77 - pds_url = os.getenv('PDS_URL') # Optional: separate PDS for auth 74 + # Get API key from environment 75 + api_key = os.getenv('COVES_API_KEY') 78 76 79 - if not aggregator_handle or not aggregator_password: 77 + if not api_key: 80 78 raise ValueError( 81 - "Missing AGGREGATOR_HANDLE or AGGREGATOR_PASSWORD environment variables" 79 + "COVES_API_KEY environment variable required" 82 80 ) 83 81 84 82 self.coves_client = CovesClient( 85 83 api_url=self.config.coves_api_url, 86 - handle=aggregator_handle, 87 - password=aggregator_password, 88 - pds_url=pds_url # Auth through PDS if specified 84 + api_key=api_key 89 85 ) 90 86 91 87 def run(self):
+127 -3
aggregators/kagi-news/tests/test_coves_client.py
··· 4 4 Tests the client's local functionality without requiring live infrastructure. 5 5 """ 6 6 import pytest 7 - from src.coves_client import CovesClient 7 + from unittest.mock import Mock 8 + from src.coves_client import ( 9 + CovesClient, 10 + CovesAPIError, 11 + CovesAuthenticationError, 12 + CovesForbiddenError, 13 + CovesNotFoundError, 14 + CovesRateLimitError, 15 + ) 16 + 17 + 18 + # Valid test API key (70 chars total: 6 prefix + 64 hex chars) 19 + VALID_TEST_API_KEY = "ckapi_" + "a" * 64 20 + 21 + 22 + class TestAPIKeyValidation: 23 + """Tests for API key format validation in constructor.""" 24 + 25 + def test_rejects_empty_api_key(self): 26 + """Empty API key should raise ValueError.""" 27 + with pytest.raises(ValueError, match="cannot be empty"): 28 + CovesClient(api_url="http://localhost", api_key="") 29 + 30 + def test_rejects_wrong_prefix(self): 31 + """API key with wrong prefix should raise ValueError.""" 32 + wrong_prefix_key = "wrong_" + "a" * 64 33 + with pytest.raises(ValueError, match="must start with 'ckapi_'"): 34 + CovesClient(api_url="http://localhost", api_key=wrong_prefix_key) 35 + 36 + def test_rejects_short_api_key(self): 37 + """API key that is too short should raise ValueError.""" 38 + short_key = "ckapi_tooshort" 39 + with pytest.raises(ValueError, match="must be 70 characters"): 40 + CovesClient(api_url="http://localhost", api_key=short_key) 41 + 42 + def test_rejects_long_api_key(self): 43 + """API key that is too long should raise ValueError.""" 44 + long_key = "ckapi_" + "a" * 100 45 + with pytest.raises(ValueError, match="must be 70 characters"): 46 + CovesClient(api_url="http://localhost", api_key=long_key) 47 + 48 + def test_accepts_valid_api_key(self): 49 + """Valid API key format should be accepted.""" 50 + client = CovesClient(api_url="http://localhost", api_key=VALID_TEST_API_KEY) 51 + assert client.api_key == VALID_TEST_API_KEY 52 + 53 + 54 + class TestRaiseForStatus: 55 + """Tests for _raise_for_status method.""" 56 + 57 + @pytest.fixture 58 + def client(self): 59 + """Create a CovesClient instance for testing.""" 60 + return CovesClient(api_url="http://localhost", api_key=VALID_TEST_API_KEY) 61 + 62 + def test_raises_authentication_error_for_401(self, client): 63 + """401 response should raise CovesAuthenticationError.""" 64 + mock_response = Mock() 65 + mock_response.status_code = 401 66 + mock_response.text = "Invalid API key" 67 + 68 + with pytest.raises(CovesAuthenticationError) as exc_info: 69 + client._raise_for_status(mock_response) 70 + 71 + assert exc_info.value.status_code == 401 72 + assert "Authentication failed" in str(exc_info.value) 73 + 74 + def test_raises_forbidden_error_for_403(self, client): 75 + """403 response should raise CovesForbiddenError.""" 76 + mock_response = Mock() 77 + mock_response.status_code = 403 78 + mock_response.text = "Not authorized for this community" 79 + 80 + with pytest.raises(CovesForbiddenError) as exc_info: 81 + client._raise_for_status(mock_response) 82 + 83 + assert exc_info.value.status_code == 403 84 + assert "Access forbidden" in str(exc_info.value) 85 + 86 + def test_raises_not_found_error_for_404(self, client): 87 + """404 response should raise CovesNotFoundError.""" 88 + mock_response = Mock() 89 + mock_response.status_code = 404 90 + mock_response.text = "Community not found" 91 + 92 + with pytest.raises(CovesNotFoundError) as exc_info: 93 + client._raise_for_status(mock_response) 94 + 95 + assert exc_info.value.status_code == 404 96 + assert "Resource not found" in str(exc_info.value) 97 + 98 + def test_raises_rate_limit_error_for_429(self, client): 99 + """429 response should raise CovesRateLimitError.""" 100 + mock_response = Mock() 101 + mock_response.status_code = 429 102 + mock_response.text = "Rate limit exceeded" 103 + 104 + with pytest.raises(CovesRateLimitError) as exc_info: 105 + client._raise_for_status(mock_response) 106 + 107 + assert exc_info.value.status_code == 429 108 + assert "Rate limit exceeded" in str(exc_info.value) 109 + 110 + def test_raises_generic_api_error_for_500(self, client): 111 + """500 response should raise generic CovesAPIError.""" 112 + mock_response = Mock() 113 + mock_response.status_code = 500 114 + mock_response.text = "Internal server error" 115 + 116 + with pytest.raises(CovesAPIError) as exc_info: 117 + client._raise_for_status(mock_response) 118 + 119 + assert exc_info.value.status_code == 500 120 + assert not isinstance(exc_info.value, CovesAuthenticationError) 121 + assert not isinstance(exc_info.value, CovesNotFoundError) 122 + 123 + def test_exception_includes_response_body(self, client): 124 + """Exception should include the response body.""" 125 + mock_response = Mock() 126 + mock_response.status_code = 400 127 + mock_response.text = '{"error": "Bad request details"}' 128 + 129 + with pytest.raises(CovesAPIError) as exc_info: 130 + client._raise_for_status(mock_response) 131 + 132 + assert exc_info.value.response_body == '{"error": "Bad request details"}' 8 133 9 134 10 135 class TestCreateExternalEmbed: ··· 15 140 """Create a CovesClient instance for testing.""" 16 141 return CovesClient( 17 142 api_url="http://localhost:8081", 18 - handle="test.handle", 19 - password="test_password" 143 + api_key=VALID_TEST_API_KEY 20 144 ) 21 145 22 146 def test_creates_embed_without_sources(self, client):
+11 -4
aggregators/kagi-news/docker-entrypoint.sh
··· 11 11 fi 12 12 13 13 # Validate required environment variables 14 - if [ -z "$AGGREGATOR_HANDLE" ] || [ -z "$AGGREGATOR_PASSWORD" ]; then 15 - echo "ERROR: Missing required environment variables!" 16 - echo "Please set AGGREGATOR_HANDLE and AGGREGATOR_PASSWORD" 14 + if [ -z "$COVES_API_KEY" ]; then 15 + echo "ERROR: Missing required environment variable!" 16 + echo "Please set COVES_API_KEY (format: ckapi_...)" 17 17 exit 1 18 18 fi 19 19 20 - echo "Aggregator Handle: $AGGREGATOR_HANDLE" 20 + echo "API Key prefix: ${COVES_API_KEY:0:12}..." 21 21 echo "Cron schedule loaded from /etc/cron.d/kagi-aggregator" 22 22 23 + # Export environment variables for cron 24 + # Cron runs in a separate environment and doesn't inherit container env vars 25 + echo "Exporting environment variables for cron..." 26 + printenv | grep -E '^(COVES_|PATH=)' > /etc/environment 27 + 23 28 # Start cron in the background 29 + echo "Starting cron daemon..." 30 + cron
+24 -15
aggregators/kagi-news/README.md
··· 47 47 48 48 Before running the aggregator, you must register it with a Coves instance. This creates a DID for your aggregator and registers it with Coves. 49 49 50 + ### Handle Options 51 + 52 + You have two choices: 53 + 54 + 1. **PDS-assigned handle** (simpler): Use `my-aggregator.bsky.social`. No domain verification needed. 55 + 2. **Custom domain** (branded): Use `news.example.com`. Requires hosting a `.well-known/atproto-did` file. 56 + 50 57 ### Quick Setup (Automated) 51 58 52 59 The automated setup script handles the entire registration process: ··· 59 66 60 67 This will: 61 68 1. **Create a PDS account** for your aggregator (generates a DID) 62 - 2. **Generate `.well-known/atproto-did`** file for domain verification 63 - 3. **Pause for manual upload** - you'll upload the file to your web server 64 - 4. **Register with Coves** instance via XRPC 65 - 5. **Create service declaration** record (indexed by Jetstream) 69 + 2. **(Optional)** Generate `.well-known/atproto-did` file for custom domain handle 70 + 3. **(Optional)** Pause for manual upload if using custom domain 71 + 4. **Create service declaration** record (indexed by Jetstream) 72 + 5. **Generate an API key** for authentication (requires browser OAuth) 66 73 67 - **Manual step required:** During the process, you'll need to upload the `.well-known/atproto-did` file to your domain so it's accessible at `https://yourdomain.com/.well-known/atproto-did`. 74 + **Manual steps required:** 75 + - **(If using custom domain)** Upload `.well-known/atproto-did` to your domain 76 + - Complete OAuth login in browser to generate API key 68 77 69 - After completion, you'll have a `kagi-aggregator-config.env` file with: 70 - - Aggregator DID and credentials 71 - - Access/refresh JWTs 72 - - Service declaration URI 78 + After completion, you'll have: 79 + - `kagi-aggregator-config.env` - Full configuration with API key 80 + - `COVES_API_KEY` - Your authentication token for posting 73 81 74 - **Keep this file secure!** It contains your aggregator's credentials. 82 + **Keep the API key secure!** It cannot be retrieved after generation. 75 83 76 84 ### Manual Setup (Step-by-step) 77 85 ··· 81 89 # From the Coves project root 82 90 cd scripts/aggregator-setup 83 91 84 - # Follow the 4-step process 92 + # Follow the 5-step process 85 93 ./1-create-pds-account.sh 86 94 ./2-setup-wellknown.sh 87 95 ./3-register-with-coves.sh 88 96 ./4-create-service-declaration.sh 97 + ./5-create-api-key.sh 89 98 ``` 90 99 91 100 See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed documentation on each step. ··· 96 105 2. **Domain Verification**: Proves you control your aggregator's domain 97 106 3. **Coves Registration**: Inserts your DID into the Coves instance's `users` table 98 107 4. **Service Declaration**: Creates a record that gets indexed into the `aggregators` table 99 - 5. **Ready for Authorization**: Community moderators can now authorize your aggregator 108 + 5. **API Key Generation**: Creates a secure API key for authentication 109 + 6. **Ready for Authorization**: Community moderators can now authorize your aggregator 100 110 101 111 Once registered and authorized by a community, your aggregator can post content. 102 112 ··· 128 138 ``` 129 139 130 140 4. Edit `config.yaml` to map RSS feeds to communities 131 - 5. Set environment variables in `.env` (aggregator DID and private key) 141 + 5. Set `COVES_API_KEY` in `.env` (from registration step 5) 132 142 133 143 ## Running Tests 134 144 ··· 189 199 190 200 The `docker-compose.yml` file supports these environment variables: 191 201 192 - - **`AGGREGATOR_HANDLE`** (required): Your aggregator's handle 193 - - **`AGGREGATOR_PASSWORD`** (required): Your aggregator's password 202 + - **`COVES_API_KEY`** (required): Your aggregator's API key (format: `ckapi_...`) 194 203 - **`COVES_API_URL`** (optional): Override Coves API endpoint (defaults to `https://api.coves.social`) 195 204 - **`RUN_ON_STARTUP`** (optional): Set to `true` to run immediately on container start (useful for testing) 196 205
+148
scripts/aggregator-setup/5-create-api-key.sh
··· 1 + #!/bin/bash 2 + # 3 + # Step 5: Create API Key for Aggregator 4 + # 5 + # This script guides you through generating an API key for your aggregator. 6 + # API keys are used for authentication instead of PDS JWTs. 7 + # 8 + # Prerequisites: 9 + # - Completed steps 1-4 (PDS account, .well-known, Coves registration, service declaration) 10 + # - Aggregator indexed by Coves (check: curl https://coves.social/xrpc/social.coves.aggregator.get?did=YOUR_DID) 11 + # 12 + # Usage: ./5-create-api-key.sh 13 + # 14 + 15 + set -e 16 + 17 + # Colors for output 18 + RED='\033[0;31m' 19 + GREEN='\033[0;32m' 20 + YELLOW='\033[1;33m' 21 + BLUE='\033[0;34m' 22 + NC='\033[0m' # No Color 23 + 24 + echo -e "${BLUE}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}" 25 + echo -e "${BLUE}โ•‘ Coves Aggregator - Step 5: Create API Key โ•‘${NC}" 26 + echo -e "${BLUE}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 27 + echo 28 + 29 + # Load existing configuration 30 + CONFIG_FILE="aggregator-config.env" 31 + if [ -f "$CONFIG_FILE" ]; then 32 + echo -e "${GREEN}โœ“${NC} Loading existing configuration from $CONFIG_FILE" 33 + source "$CONFIG_FILE" 34 + else 35 + echo -e "${YELLOW}โš ${NC} No $CONFIG_FILE found. Please run steps 1-4 first." 36 + echo 37 + read -p "Enter your Coves instance URL [https://coves.social]: " COVES_INSTANCE_URL 38 + COVES_INSTANCE_URL=${COVES_INSTANCE_URL:-https://coves.social} 39 + fi 40 + 41 + echo 42 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 43 + echo -e "${YELLOW} API Key Generation Process${NC}" 44 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 45 + echo 46 + echo "API keys allow your aggregator to authenticate without managing" 47 + echo "OAuth token refresh. The key is shown ONCE and cannot be retrieved later." 48 + echo 49 + echo -e "${BLUE}Steps:${NC}" 50 + echo "1. Complete OAuth login in your browser" 51 + echo "2. Call the createApiKey endpoint" 52 + echo "3. Save the key securely" 53 + echo 54 + 55 + # Check if aggregator is indexed 56 + echo -e "${BLUE}Checking if aggregator is indexed...${NC}" 57 + if [ -n "$AGGREGATOR_DID" ]; then 58 + AGGREGATOR_CHECK=$(curl -s "${COVES_INSTANCE_URL}/xrpc/social.coves.aggregator.get?did=${AGGREGATOR_DID}" 2>/dev/null || echo "error") 59 + if echo "$AGGREGATOR_CHECK" | grep -q "error"; then 60 + echo -e "${YELLOW}โš ${NC} Could not verify aggregator status. Proceeding anyway..." 61 + else 62 + echo -e "${GREEN}โœ“${NC} Aggregator found in Coves instance" 63 + fi 64 + fi 65 + 66 + echo 67 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 68 + echo -e "${YELLOW} Step 5.1: OAuth Login${NC}" 69 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 70 + echo 71 + echo "Open this URL in your browser to authenticate:" 72 + echo 73 + AGGREGATOR_HANDLE=${AGGREGATOR_HANDLE:-"your-aggregator.example.com"} 74 + echo -e " ${BLUE}${COVES_INSTANCE_URL}/oauth/login?handle=${AGGREGATOR_HANDLE}${NC}" 75 + echo 76 + echo "This will:" 77 + echo " 1. Redirect you to your PDS for authentication" 78 + echo " 2. Return you to Coves with an OAuth session" 79 + echo 80 + echo -e "${YELLOW}After authenticating, press Enter to continue...${NC}" 81 + read 82 + 83 + echo 84 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 85 + echo -e "${YELLOW} Step 5.2: Create API Key${NC}" 86 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 87 + echo 88 + echo "In your browser's Developer Console (F12 โ†’ Console), run:" 89 + echo 90 + echo -e "${GREEN}fetch('/xrpc/social.coves.aggregator.createApiKey', {" 91 + echo " method: 'POST'," 92 + echo " credentials: 'include'" 93 + echo "})" 94 + echo ".then(r => r.json())" 95 + echo -e ".then(data => console.log('API Key:', data.key))${NC}" 96 + echo 97 + echo "This will return your API key. It looks like:" 98 + echo " ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 99 + echo 100 + echo -e "${RED}โš  IMPORTANT: Save this key immediately! It cannot be retrieved again.${NC}" 101 + echo 102 + read -p "Enter the API key you received: " API_KEY 103 + 104 + # Validate API key format 105 + if [[ ! $API_KEY =~ ^ckapi_[a-f0-9]{64}$ ]]; then 106 + echo -e "${RED}โœ— Invalid API key format. Expected: ckapi_ followed by 64 hex characters${NC}" 107 + echo " Example: ckapi_dcbdec0a0d1b3c440125547d21fe582bbf1587d2dcd364c56ad285af841cc934" 108 + exit 1 109 + fi 110 + 111 + echo -e "${GREEN}โœ“${NC} API key format valid" 112 + 113 + # Save to config 114 + echo 115 + echo "COVES_API_KEY=\"$API_KEY\"" >> "$CONFIG_FILE" 116 + echo -e "${GREEN}โœ“${NC} API key saved to $CONFIG_FILE" 117 + 118 + echo 119 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 120 + echo -e "${YELLOW} Step 5.3: Update Your .env File${NC}" 121 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 122 + echo 123 + echo "Update your aggregator's .env file with:" 124 + echo 125 + echo -e "${GREEN}COVES_API_KEY=${API_KEY}${NC}" 126 + echo -e "${GREEN}COVES_API_URL=${COVES_INSTANCE_URL}${NC}" 127 + echo 128 + echo "You can remove the old AGGREGATOR_HANDLE and AGGREGATOR_PASSWORD variables." 129 + echo 130 + 131 + echo 132 + echo -e "${GREEN}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}" 133 + echo -e "${GREEN}โ•‘ Setup Complete! โ•‘${NC}" 134 + echo -e "${GREEN}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 135 + echo 136 + echo "Your aggregator is now configured with API key authentication." 137 + echo 138 + echo "Next steps:" 139 + echo " 1. Update your aggregator's .env file with COVES_API_KEY" 140 + echo " 2. Rebuild your Docker container: docker compose build --no-cache" 141 + echo " 3. Start the aggregator: docker compose up -d" 142 + echo " 4. Check logs: docker compose logs -f" 143 + echo 144 + echo -e "${YELLOW}Security Reminders:${NC}" 145 + echo " - Never commit your API key to version control" 146 + echo " - Store it securely (environment variables or secrets manager)" 147 + echo " - Rotate periodically by generating a new key (revokes the old one)" 148 + echo
+73 -28
scripts/aggregator-setup/README.md
··· 7 7 Aggregators are automated services that post content to Coves communities. They are similar to Bluesky's feed generators and labelers. To use aggregators with Coves, you need to: 8 8 9 9 1. Create a PDS account for your aggregator (gets you a DID) 10 - 2. Prove you own a domain via `.well-known/atproto-did` 10 + 2. **(Optional)** Verify a custom domain via `.well-known/atproto-did` 11 11 3. Register with a Coves instance 12 12 4. Create a service declaration record 13 + 5. **Generate an API key** for authentication 13 14 14 15 These scripts automate this process for you. 15 16 17 + ### Handle Options 18 + 19 + You have two choices for your aggregator's handle: 20 + 21 + 1. **PDS-assigned handle** (simpler): Use the handle from your PDS, e.g., `my-aggregator.bsky.social`. No domain verification neededโ€”skip steps 2-3. 22 + 23 + 2. **Custom domain handle** (branded): Use your own domain, e.g., `news.example.com`. Requires hosting a `.well-known/atproto-did` file on your domain. 24 + 16 25 ## Prerequisites 17 26 18 - - **Domain ownership**: You must own a domain where you can host the `.well-known/atproto-did` file 19 - - **Web server**: Ability to serve static files over HTTPS 20 27 - **Tools**: `curl`, `jq` (for JSON processing) 21 28 - **Account**: Email address for creating the PDS account 29 + - **(For custom domain only)**: Domain ownership and ability to serve HTTPS files 22 30 23 31 ## Quick Start 24 32 ··· 33 41 # Step 1: Create PDS account 34 42 ./1-create-pds-account.sh 35 43 36 - # Step 2: Generate .well-known file 37 - ./2-setup-wellknown.sh 38 - 39 - # Step 3: Register with Coves (after uploading .well-known) 40 - ./3-register-with-coves.sh 44 + # Steps 2-3: OPTIONAL - Only if you want a custom domain handle 45 + # ./2-setup-wellknown.sh 46 + # ./3-register-with-coves.sh (after uploading .well-known) 41 47 42 48 # Step 4: Create service declaration 43 49 ./4-create-service-declaration.sh 50 + 51 + # Step 5: Generate API key (requires browser for OAuth) 52 + ./5-create-api-key.sh 44 53 ``` 45 54 55 + **Minimal setup** (PDS handle only): Steps 1, 4, 5 56 + **Custom domain**: Steps 1, 2, 3, 4, 5 57 + 46 58 ### Automated Setup Example 47 59 48 60 For a reference implementation of automated setup, see the Kagi News aggregator at [aggregators/kagi-news/scripts/setup.sh](../../aggregators/kagi-news/scripts/setup.sh). ··· 134 146 - Updates `aggregator-config.env` with record URI and CID 135 147 - Prints record details 136 148 149 + ### 5-create-api-key.sh 150 + 151 + **Purpose**: Generates an API key for aggregator authentication 152 + 153 + **Prerequisites**: 154 + - Steps 1-4 completed 155 + - Aggregator indexed by Coves (usually takes a few seconds after step 4) 156 + - Web browser for OAuth login 157 + 158 + **What it does**: 159 + 1. Guides you through OAuth login in your browser 160 + 2. Provides the JavaScript to call the `createApiKey` endpoint 161 + 3. Validates the API key format 162 + 4. Saves the key to your config file 163 + 164 + **Outputs**: 165 + - Updates `aggregator-config.env` with `COVES_API_KEY` 166 + - Provides instructions for updating your `.env` file 167 + 168 + **Important Notes**: 169 + - The API key is shown **ONCE** and cannot be retrieved later 170 + - API keys replace password-based authentication 171 + - Keys can be revoked and regenerated at any time 172 + - Store securely - never commit to version control 173 + 137 174 ## Configuration File 138 175 139 - After running the scripts, you'll have an `aggregator-config.env` file with: 176 + After running all scripts, you'll have an `aggregator-config.env` file with: 140 177 141 178 ```bash 179 + # Identity 142 180 AGGREGATOR_DID="did:plc:..." 143 - AGGREGATOR_HANDLE="mynewsbot.bsky.social" 181 + AGGREGATOR_HANDLE="mynewsbot.example.com" 144 182 AGGREGATOR_PDS_URL="https://bsky.social" 145 - AGGREGATOR_EMAIL="bot@example.com" 146 - AGGREGATOR_PASSWORD="..." 147 - AGGREGATOR_ACCESS_JWT="..." 148 - AGGREGATOR_REFRESH_JWT="..." 149 - AGGREGATOR_DOMAIN="rss-bot.example.com" 150 - COVES_INSTANCE_URL="https://api.coves.social" 183 + AGGREGATOR_DOMAIN="mynewsbot.example.com" 184 + 185 + # Coves Instance 186 + COVES_INSTANCE_URL="https://coves.social" 151 187 SERVICE_DECLARATION_URI="at://did:plc:.../social.coves.aggregator.service/self" 152 188 SERVICE_DECLARATION_CID="..." 189 + 190 + # API Key (from Step 5) 191 + COVES_API_KEY="ckapi_..." 153 192 ``` 154 193 155 - **Use this in your aggregator code** to authenticate and post. 194 + **For your aggregator's `.env` file, you only need:** 195 + 196 + ```bash 197 + COVES_API_KEY=ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 198 + COVES_API_URL=https://coves.social 199 + ``` 156 200 157 201 ## What Happens Next? 158 202 159 - After completing all 4 steps: 203 + After completing all 5 steps: 160 204 161 205 1. **Your aggregator is registered** in the Coves instance's `users` table 162 206 2. **Your service declaration is indexed** in the `aggregators` table (takes a few seconds) 163 - 3. **Community moderators can now authorize** your aggregator for their communities 164 - 4. **Once authorized**, your aggregator can post to those communities 207 + 3. **Your API key is stored** and can be used for authentication 208 + 4. **Community moderators can authorize** your aggregator for their communities 209 + 5. **Your aggregator can post** to authorized communities (or all if you're a trusted aggregator) 165 210 166 211 ## Creating an Authorization 167 212 ··· 171 216 172 217 ## Posting to Communities 173 218 174 - Once authorized, your aggregator can post using: 219 + Once authorized, your aggregator can post using your API key: 175 220 176 221 ```bash 177 - curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \ 178 - -H "Authorization: DPoP $AGGREGATOR_ACCESS_JWT" \ 222 + curl -X POST https://coves.social/xrpc/social.coves.community.post.create \ 223 + -H "Authorization: Bearer $COVES_API_KEY" \ 179 224 -H "Content-Type: application/json" \ 180 225 -d '{ 181 - "communityDid": "did:plc:...", 182 - "post": { 183 - "text": "Your post content", 184 - "createdAt": "2024-01-15T12:00:00Z" 185 - } 226 + "community": "c-worldnews.coves.social", 227 + "content": "Your post content", 228 + "facets": [] 186 229 }' 187 230 ``` 188 231 232 + The API key handles all authentication - no OAuth token refresh needed. 233 + 189 234 ## Troubleshooting 190 235 191 236 ### Error: "DomainVerificationFailed"
+200
scripts/setup_dev_aggregator.go
··· 1 + // setup_dev_aggregator.go - Creates a local test aggregator on the local PDS 2 + // 3 + // This script creates an aggregator account on the local PDS for development testing. 4 + // After running, you'll need to: 5 + // 1. Register the aggregator via OAuth UI 6 + // 2. Generate an API key via the createApiKey endpoint 7 + // 8 + // Usage: go run scripts/setup_dev_aggregator.go 9 + package main 10 + 11 + import ( 12 + "bytes" 13 + "context" 14 + "database/sql" 15 + "encoding/json" 16 + "fmt" 17 + "io" 18 + "log" 19 + "net/http" 20 + 21 + _ "github.com/lib/pq" 22 + ) 23 + 24 + const ( 25 + PDSURL = "http://localhost:3001" 26 + DatabaseURL = "postgres://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable" 27 + ) 28 + 29 + type CreateAccountRequest struct { 30 + Email string `json:"email"` 31 + Handle string `json:"handle"` 32 + Password string `json:"password"` 33 + } 34 + 35 + type CreateAccountResponse struct { 36 + DID string `json:"did"` 37 + Handle string `json:"handle"` 38 + AccessJWT string `json:"accessJwt"` 39 + } 40 + 41 + type CreateSessionRequest struct { 42 + Identifier string `json:"identifier"` 43 + Password string `json:"password"` 44 + } 45 + 46 + type CreateSessionResponse struct { 47 + DID string `json:"did"` 48 + Handle string `json:"handle"` 49 + AccessJWT string `json:"accessJwt"` 50 + } 51 + 52 + func main() { 53 + ctx := context.Background() 54 + 55 + // Configuration 56 + handle := "test-aggregator.local.coves.dev" 57 + email := "test-aggregator@example.com" 58 + password := "test-password-12345" 59 + displayName := "Test Aggregator (Dev)" 60 + 61 + log.Printf("Setting up dev aggregator: %s", handle) 62 + 63 + // Connect to database 64 + db, err := sql.Open("postgres", DatabaseURL) 65 + if err != nil { 66 + log.Fatalf("Failed to connect to database: %v", err) 67 + } 68 + defer db.Close() 69 + 70 + // Step 1: Try to create account on PDS (or get existing session) 71 + log.Printf("Creating account on PDS: %s", PDSURL) 72 + 73 + var did string 74 + 75 + // First try to create account 76 + createResp, err := createAccount(handle, email, password) 77 + if err != nil { 78 + log.Printf("Account creation failed (may already exist): %v", err) 79 + log.Printf("Trying to create session with existing account...") 80 + 81 + // Try to login instead 82 + sessionResp, err := createSession(handle, password) 83 + if err != nil { 84 + log.Fatalf("Failed to create session: %v", err) 85 + } 86 + did = sessionResp.DID 87 + log.Printf("Logged in as existing account: %s", did) 88 + } else { 89 + did = createResp.DID 90 + log.Printf("Created new account: %s", did) 91 + } 92 + 93 + // Step 2: Check if already in users table 94 + var existingHandle string 95 + err = db.QueryRowContext(ctx, "SELECT handle FROM users WHERE did = $1", did).Scan(&existingHandle) 96 + if err == nil { 97 + log.Printf("User already exists in users table: %s", existingHandle) 98 + } else { 99 + // Insert into users table 100 + log.Printf("Inserting user into users table...") 101 + _, err = db.ExecContext(ctx, ` 102 + INSERT INTO users (did, handle, pds_url) 103 + VALUES ($1, $2, $3) 104 + ON CONFLICT (did) DO UPDATE SET handle = $2 105 + `, did, handle, PDSURL) 106 + if err != nil { 107 + log.Fatalf("Failed to insert user: %v", err) 108 + } 109 + } 110 + 111 + // Step 3: Check if already in aggregators table 112 + var existingAggDID string 113 + err = db.QueryRowContext(ctx, "SELECT did FROM aggregators WHERE did = $1", did).Scan(&existingAggDID) 114 + if err == nil { 115 + log.Printf("Aggregator already exists in aggregators table") 116 + } else { 117 + // Insert into aggregators table 118 + log.Printf("Inserting aggregator into aggregators table...") 119 + recordURI := fmt.Sprintf("at://%s/social.coves.aggregator.declaration/self", did) 120 + recordCID := "dev-placeholder-cid" 121 + 122 + _, err = db.ExecContext(ctx, ` 123 + INSERT INTO aggregators (did, display_name, description, record_uri, record_cid, created_at, indexed_at) 124 + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) 125 + `, did, displayName, "Development test aggregator", recordURI, recordCID) 126 + if err != nil { 127 + log.Fatalf("Failed to insert aggregator: %v", err) 128 + } 129 + } 130 + 131 + fmt.Println() 132 + fmt.Println("========================================") 133 + fmt.Println(" DEV AGGREGATOR ACCOUNT CREATED") 134 + fmt.Println("========================================") 135 + fmt.Println() 136 + fmt.Printf(" DID: %s\n", did) 137 + fmt.Printf(" Handle: %s\n", handle) 138 + fmt.Printf(" Password: %s\n", password) 139 + fmt.Println() 140 + fmt.Println(" Next steps:") 141 + fmt.Println(" 1. Start Coves server: make run") 142 + fmt.Println(" 2. Authenticate as this account via OAuth UI") 143 + fmt.Println(" 3. Call POST /xrpc/social.coves.aggregator.createApiKey") 144 + fmt.Println(" 4. Save the API key and add to aggregators/kagi-news/.env") 145 + fmt.Println() 146 + fmt.Println("========================================") 147 + } 148 + 149 + func createAccount(handle, email, password string) (*CreateAccountResponse, error) { 150 + reqBody := CreateAccountRequest{ 151 + Email: email, 152 + Handle: handle, 153 + Password: password, 154 + } 155 + 156 + body, _ := json.Marshal(reqBody) 157 + resp, err := http.Post(PDSURL+"/xrpc/com.atproto.server.createAccount", "application/json", bytes.NewReader(body)) 158 + if err != nil { 159 + return nil, fmt.Errorf("request failed: %w", err) 160 + } 161 + defer resp.Body.Close() 162 + 163 + respBody, _ := io.ReadAll(resp.Body) 164 + if resp.StatusCode != http.StatusOK { 165 + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) 166 + } 167 + 168 + var result CreateAccountResponse 169 + if err := json.Unmarshal(respBody, &result); err != nil { 170 + return nil, fmt.Errorf("failed to parse response: %w", err) 171 + } 172 + 173 + return &result, nil 174 + } 175 + 176 + func createSession(identifier, password string) (*CreateSessionResponse, error) { 177 + reqBody := CreateSessionRequest{ 178 + Identifier: identifier, 179 + Password: password, 180 + } 181 + 182 + body, _ := json.Marshal(reqBody) 183 + resp, err := http.Post(PDSURL+"/xrpc/com.atproto.server.createSession", "application/json", bytes.NewReader(body)) 184 + if err != nil { 185 + return nil, fmt.Errorf("request failed: %w", err) 186 + } 187 + defer resp.Body.Close() 188 + 189 + respBody, _ := io.ReadAll(resp.Body) 190 + if resp.StatusCode != http.StatusOK { 191 + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) 192 + } 193 + 194 + var result CreateSessionResponse 195 + if err := json.Unmarshal(respBody, &result); err != nil { 196 + return nil, fmt.Errorf("failed to parse response: %w", err) 197 + } 198 + 199 + return &result, nil 200 + }
+3 -2
aggregators/kagi-news/crontab
··· 1 - # Run Kagi News aggregator daily at 1 PM UTC (after Kagi updates around noon) 2 - 0 13 * * * cd /app && /usr/local/bin/python -m src.main >> /var/log/cron.log 2>&1 1 + # Run Kagi News aggregator every 2 hours to catch feed updates throughout the day 2 + # Source environment variables exported by docker-entrypoint.sh 3 + 0 */2 * * * . /etc/environment; cd /app && /usr/local/bin/python -m src.main >> /var/log/cron.log 2>&1 3 4 4 5 # Blank line required at end of crontab 5 6
+1 -1
internal/atproto/lexicon/social/coves/embed/external.json
··· 38 38 "thumb": { 39 39 "type": "blob", 40 40 "accept": ["image/png", "image/jpeg", "image/webp"], 41 - "maxSize": 1000000, 41 + "maxSize": 6000000, 42 42 "description": "Thumbnail image for the post (applies to primary link)" 43 43 }, 44 44 "domain": {
+267 -115
tests/integration/feed_test.go
··· 26 26 27 27 28 28 29 + // Setup services 30 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 31 + communityRepo := postgres.NewCommunityRepository(db) 32 + communityService := communities.NewCommunityServiceWithPDSFactory( 33 + communityRepo, 34 + "http://localhost:3001", 35 + "did:web:test.coves.social", 36 + "test.coves.social", 37 + nil, 38 + nil, 39 + nil, 40 + ) 41 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 42 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 29 43 30 44 31 45 ··· 91 105 92 106 93 107 108 + // Setup services 109 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 110 + communityRepo := postgres.NewCommunityRepository(db) 111 + communityService := communities.NewCommunityServiceWithPDSFactory( 112 + communityRepo, 113 + "http://localhost:3001", 114 + "did:web:test.coves.social", 115 + "test.coves.social", 116 + nil, 117 + nil, 118 + nil, 119 + ) 120 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 121 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 94 122 95 123 96 124 ··· 155 183 156 184 157 185 186 + // Setup services 187 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 188 + communityRepo := postgres.NewCommunityRepository(db) 189 + communityService := communities.NewCommunityServiceWithPDSFactory( 190 + communityRepo, 191 + "http://localhost:3001", 192 + "did:web:test.coves.social", 193 + "test.coves.social", 194 + nil, 195 + nil, 196 + nil, 197 + ) 198 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 199 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 158 200 159 201 160 202 ··· 199 241 200 242 201 243 244 + // Setup services 245 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 246 + communityRepo := postgres.NewCommunityRepository(db) 247 + communityService := communities.NewCommunityServiceWithPDSFactory( 248 + communityRepo, 249 + "http://localhost:3001", 250 + "did:web:test.coves.social", 251 + "test.coves.social", 252 + nil, 253 + nil, 254 + nil, 255 + ) 256 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 257 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 202 258 203 259 204 260 ··· 278 334 279 335 280 336 337 + // Setup services 338 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 339 + communityRepo := postgres.NewCommunityRepository(db) 340 + communityService := communities.NewCommunityServiceWithPDSFactory( 341 + communityRepo, 342 + "http://localhost:3001", 343 + "did:web:test.coves.social", 344 + "test.coves.social", 345 + nil, 346 + nil, 347 + nil, 348 + ) 349 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 350 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 281 351 282 352 283 353 ··· 302 372 303 373 304 374 375 + // Setup services 376 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 377 + communityRepo := postgres.NewCommunityRepository(db) 378 + communityService := communities.NewCommunityServiceWithPDSFactory( 379 + communityRepo, 380 + "http://localhost:3001", 381 + "did:web:test.coves.social", 382 + "test.coves.social", 383 + nil, 384 + nil, 385 + nil, 386 + ) 387 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 388 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 305 389 306 390 307 391 ··· 346 430 347 431 348 432 433 + // Setup services 434 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 435 + communityRepo := postgres.NewCommunityRepository(db) 436 + communityService := communities.NewCommunityServiceWithPDSFactory( 437 + communityRepo, 438 + "http://localhost:3001", 439 + "did:web:test.coves.social", 440 + "test.coves.social", 441 + nil, 442 + nil, 443 + nil, 444 + ) 445 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 446 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 349 447 350 448 351 449 ··· 378 476 379 477 380 478 479 + // Setup services 480 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 481 + communityRepo := postgres.NewCommunityRepository(db) 482 + communityService := communities.NewCommunityServiceWithPDSFactory( 483 + communityRepo, 484 + "http://localhost:3001", 485 + "did:web:test.coves.social", 486 + "test.coves.social", 487 + nil, 488 + nil, 489 + nil, 490 + ) 491 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 492 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 381 493 382 494 383 495 ··· 419 531 420 532 421 533 534 + // Setup services 535 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 536 + communityRepo := postgres.NewCommunityRepository(db) 537 + communityService := communities.NewCommunityServiceWithPDSFactory( 538 + communityRepo, 539 + "http://localhost:3001", 540 + "did:web:test.coves.social", 541 + "test.coves.social", 542 + nil, 543 + nil, 544 + nil, 545 + ) 546 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 547 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 422 548 423 549 424 550 ··· 508 634 509 635 510 636 637 + // Setup services 638 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 639 + communityRepo := postgres.NewCommunityRepository(db) 640 + communityService := communities.NewCommunityServiceWithPDSFactory( 641 + communityRepo, 642 + "http://localhost:3001", 643 + "did:web:test.coves.social", 644 + "test.coves.social", 645 + nil, 646 + nil, 647 + nil, 648 + ) 649 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 650 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 511 651 512 652 513 653 ··· 577 717 578 718 579 719 720 + t.Logf("SUCCESS: All posts with similar hot ranks preserved (precision bug fixed)") 721 + } 580 722 723 + // TestGetCommunityFeed_HotCursorTimeDrift tests that hot sort pagination is stable across time drift. 724 + // Regression test for a bug where posts would appear multiple times or be skipped when: 725 + // 1. Time passes between page 1 and page 2 requests 726 + // 2. Many posts have similar hot ranks 727 + // 728 + // Root cause: The cursor stored a hot_rank computed with NOW(), but the next query 729 + // also used NOW() (which had advanced). This caused posts to drift across the cursor boundary. 730 + // 731 + // Fix: Store the cursor creation timestamp in the cursor and use it for subsequent comparisons, 732 + // ensuring stable hot_rank computation across pagination requests. 733 + func TestGetCommunityFeed_HotCursorTimeDrift(t *testing.T) { 734 + if testing.Short() { 735 + t.Skip("Skipping integration test in short mode") 736 + } 581 737 738 + db := setupTestDB(t) 739 + t.Cleanup(func() { _ = db.Close() }) 582 740 741 + // Setup services 742 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 743 + communityRepo := postgres.NewCommunityRepository(db) 744 + communityService := communities.NewCommunityServiceWithPDSFactory( 745 + communityRepo, 746 + "http://localhost:3001", 747 + "did:web:test.coves.social", 748 + "test.coves.social", 749 + nil, 750 + nil, 751 + nil, 752 + ) 753 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 754 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) 755 + 756 + // Setup test data 757 + ctx := context.Background() 758 + testID := time.Now().UnixNano() 759 + communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("timedrift-%d", testID), fmt.Sprintf("timedrift-%d.test", testID)) 760 + require.NoError(t, err) 761 + 762 + // Create 15 posts all with the SAME score and created at the SAME time 763 + // This maximizes the chance of time drift causing duplicates: 764 + // - All posts have nearly identical hot ranks 765 + // - Any small change in NOW() could cause posts to swap order 766 + baseTime := time.Now().Add(-1 * time.Hour) 767 + var allPostURIs []string 768 + for i := 0; i < 15; i++ { 769 + // Add tiny offsets (1ms) to created_at for deterministic ordering 770 + postURI := createTestPost(t, db, communityDID, fmt.Sprintf("did:plc:user%d", i), 771 + fmt.Sprintf("Post %d", i), 10, baseTime.Add(time.Duration(i)*time.Millisecond)) 772 + allPostURIs = append(allPostURIs, postURI) 773 + } 774 + 775 + // Paginate through all posts with limit=5 776 + seenURIs := make(map[string]int) 777 + var cursor *string 778 + pageNum := 0 779 + 780 + for { 781 + pageNum++ 782 + url := fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=5", communityDID) 783 + if cursor != nil { 784 + url += "&cursor=" + *cursor 785 + } 786 + 787 + req := httptest.NewRequest(http.MethodGet, url, nil) 788 + rec := httptest.NewRecorder() 789 + handler.HandleGetCommunity(rec, req) 790 + 791 + require.Equal(t, http.StatusOK, rec.Code, "Page %d failed: %s", pageNum, rec.Body.String()) 792 + 793 + var page communityFeeds.FeedResponse 794 + err = json.Unmarshal(rec.Body.Bytes(), &page) 795 + require.NoError(t, err) 796 + 797 + if len(page.Feed) == 0 { 798 + break 799 + } 800 + 801 + for _, p := range page.Feed { 802 + seenURIs[p.Post.URI]++ 803 + if seenURIs[p.Post.URI] > 1 { 804 + t.Errorf("DUPLICATE on page %d: %s (seen %d times)", pageNum, p.Post.URI, seenURIs[p.Post.URI]) 805 + } 806 + } 807 + 808 + cursor = page.Cursor 809 + if cursor == nil { 810 + break 811 + } 812 + 813 + // Prevent infinite loops 814 + if pageNum > 10 { 815 + t.Fatal("Too many pages - possible infinite loop") 816 + } 817 + } 818 + 819 + // Verify we saw all posts exactly once 820 + assert.Equal(t, 15, len(seenURIs), "Should see all 15 posts") 821 + for uri, count := range seenURIs { 822 + if count != 1 { 823 + t.Errorf("Post %s seen %d times (expected 1)", uri, count) 824 + } 825 + } 826 + 827 + // Verify we saw all the posts we created 828 + for _, uri := range allPostURIs { 829 + assert.Contains(t, seenURIs, uri, "Missing post: %s", uri) 830 + } 583 831 832 + t.Logf("SUCCESS: All 15 posts seen exactly once across %d pages (time drift bug fixed)", pageNum) 833 + } 584 834 835 + // TestGetCommunityFeed_BlobURLTransformation tests that blob refs are transformed to URLs 836 + func TestGetCommunityFeed_BlobURLTransformation(t *testing.T) { 837 + if testing.Short() { 585 838 586 839 587 840 588 841 589 842 590 843 591 - 592 - 593 - 594 - 595 - 596 - 597 - 598 - 599 - 600 - 601 - 602 - 603 - 604 - 605 - 606 - 607 - 608 - 609 - 610 - 611 - 612 - 613 - 614 - 615 - 616 - 617 - 618 - 619 - 620 - 621 - 622 - 623 - 624 - 625 - 626 - 627 - 628 - 629 - 630 - 631 - 632 - 633 - 634 - 635 - 636 - 637 - 638 - 639 - 640 - 641 - 642 - 643 - 644 - 645 - 646 - 647 - 648 - 649 - 650 - 651 - 652 - 653 - 654 - 655 - 656 - 657 - 658 - 659 - 660 - 661 - 662 - 663 - 664 - 665 - 666 - 667 - 668 - 669 - 670 - 671 - 672 - 673 - 674 - 675 - 676 - 677 - 678 - 679 - 680 - 681 - 682 - 683 - 684 - 685 - 686 - 687 - 688 - 689 - 690 - 691 - 692 - 693 - 694 - 695 - 696 - 697 - 698 - 699 - 700 - t.Logf("SUCCESS: All posts with similar hot ranks preserved (precision bug fixed)") 701 - } 702 - 703 - // TestGetCommunityFeed_BlobURLTransformation tests that blob refs are transformed to URLs 704 - func TestGetCommunityFeed_BlobURLTransformation(t *testing.T) { 705 - if testing.Short() { 844 + // Setup services 845 + feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret") 846 + communityRepo := postgres.NewCommunityRepository(db) 847 + communityService := communities.NewCommunityServiceWithPDSFactory( 848 + communityRepo, 849 + "http://localhost:3001", 850 + "did:web:test.coves.social", 851 + "test.coves.social", 852 + nil, 853 + nil, 854 + nil, 855 + ) 856 + feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 857 + handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil)
+125 -49
internal/atproto/jetstream/user_consumer.go
··· 5 5 "Coves/internal/core/users" 6 6 "context" 7 7 "encoding/json" 8 + "errors" 8 9 "fmt" 9 10 "log" 11 + "log/slog" 10 12 "sync" 13 + "time" 11 14 12 15 13 16 ··· 191 194 192 195 193 196 197 + return fmt.Errorf("failed to parse event: %w", err) 198 + } 194 199 195 - 196 - 197 - 198 - 199 - 200 - 201 - 202 - 203 - 204 - 205 - 206 - 207 - 208 - 200 + // We're interested in identity events (handle updates), account events (new users), 201 + // and commit events (profile updates from app.bsky.actor.profile) 202 + switch event.Kind { 203 + case "identity": 204 + return c.handleIdentityEvent(ctx, &event) 205 + case "account": 206 + return c.handleAccountEvent(ctx, &event) 207 + case "commit": 208 + return c.handleCommitEvent(ctx, &event) 209 + default: 210 + // Ignore other event types 211 + return nil 212 + } 213 + } 209 214 210 215 211 216 ··· 213 218 } 214 219 215 220 // handleIdentityEvent processes identity events (handle changes) 221 + // NOTE: This only UPDATES existing users - it does NOT create new users. 222 + // Users are created during OAuth login or signup, not from Jetstream events. 223 + // This prevents indexing millions of Bluesky users who never interact with Coves. 216 224 func (c *UserEventConsumer) handleIdentityEvent(ctx context.Context, event *JetstreamEvent) error { 217 225 if event.Identity == nil { 218 226 return fmt.Errorf("identity event missing identity data") ··· 225 233 return fmt.Errorf("identity event missing did or handle") 226 234 } 227 235 228 - log.Printf("Identity event: %s โ†’ %s", did, handle) 229 - 230 - // Get existing user to check if handle changed 236 + // Only process users who exist in our database (i.e., have used Coves before) 231 237 existingUser, err := c.userService.GetUserByDID(ctx, did) 232 238 if err != nil { 233 - // User doesn't exist - create new user 234 - pdsURL := "https://bsky.social" // Default Bluesky PDS 235 - // TODO: Resolve PDS URL from DID document via PLC directory 236 - 237 - _, createErr := c.userService.CreateUser(ctx, users.CreateUserRequest{ 238 - DID: did, 239 - Handle: handle, 240 - PDSURL: pdsURL, 241 - }) 242 - 243 - if createErr != nil && !isDuplicateError(createErr) { 244 - return fmt.Errorf("failed to create user: %w", createErr) 239 + if errors.Is(err, users.ErrUserNotFound) { 240 + // User doesn't exist in our database - skip this event 241 + // They'll be indexed when they actually interact with Coves (OAuth login, signup, etc.) 242 + // This prevents us from indexing millions of Bluesky users we don't care about 243 + return nil 245 244 } 246 - 247 - log.Printf("Indexed new user: %s (%s)", handle, did) 248 - return nil 245 + // Database error - propagate so it can be retried 246 + return fmt.Errorf("failed to check if user exists: %w", err) 249 247 } 250 248 249 + log.Printf("Identity event for known user: %s (%s)", handle, did) 250 + 251 251 // User exists - check if handle changed 252 252 if existingUser.Handle != handle { 253 253 log.Printf("Handle changed: %s โ†’ %s (DID: %s)", existingUser.Handle, handle, did) ··· 298 298 return fmt.Errorf("account event missing did") 299 299 } 300 300 301 - // Account events don't include handle, so we can't index yet 302 - // We'll wait for the corresponding identity event 303 - log.Printf("Account event for %s (waiting for identity event)", did) 301 + // Account events don't include handle, so we skip them. 302 + // Users are indexed via OAuth login or signup, not from account events. 304 303 return nil 305 304 } 306 305 307 - // isDuplicateError checks if error is due to duplicate DID/handle 308 - func isDuplicateError(err error) bool { 309 - if err == nil { 310 - return false 306 + // handleCommitEvent processes commit events for user profile updates 307 + // Only handles app.bsky.actor.profile collection for users already in our database. 308 + // This syncs profile data (displayName, bio, avatar, banner) from Bluesky profiles. 309 + func (c *UserEventConsumer) handleCommitEvent(ctx context.Context, event *JetstreamEvent) error { 310 + if event.Commit == nil { 311 + slog.Debug("received nil commit in handleCommitEvent", slog.String("did", event.Did)) 312 + return nil 313 + } 314 + 315 + // Only handle app.bsky.actor.profile collection 316 + if event.Commit.Collection != "app.bsky.actor.profile" { 317 + return nil 311 318 } 312 - errStr := err.Error() 313 - return contains(errStr, "already exists") || contains(errStr, "already taken") || contains(errStr, "duplicate") 314 - } 315 319 316 - func contains(s, substr string) bool { 317 - return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && anySubstring(s, substr)) 320 + // Only process users who exist in our database 321 + _, err := c.userService.GetUserByDID(ctx, event.Did) 322 + if err != nil { 323 + if errors.Is(err, users.ErrUserNotFound) { 324 + // User doesn't exist in our database - skip this event 325 + // They'll be indexed when they actually interact with Coves 326 + return nil 327 + } 328 + // Database error - propagate so it can be retried 329 + return fmt.Errorf("failed to check if user exists: %w", err) 330 + } 331 + 332 + switch event.Commit.Operation { 333 + case "create", "update": 334 + return c.handleProfileUpdate(ctx, event.Did, event.Commit) 335 + case "delete": 336 + return c.handleProfileDelete(ctx, event.Did) 337 + default: 338 + return nil 339 + } 318 340 } 319 341 320 - func anySubstring(s, substr string) bool { 321 - for i := 0; i <= len(s)-len(substr); i++ { 322 - if s[i:i+len(substr)] == substr { 323 - return true 342 + // handleProfileUpdate processes profile create/update operations 343 + // Extracts displayName, description (bio), avatar, and banner from the record 344 + func (c *UserEventConsumer) handleProfileUpdate(ctx context.Context, did string, commit *CommitEvent) error { 345 + if commit.Record == nil { 346 + slog.Debug("received nil record in profile commit", 347 + slog.String("did", did), 348 + slog.String("operation", string(commit.Operation))) 349 + return nil 350 + } 351 + 352 + input := users.UpdateProfileInput{} 353 + 354 + // Extract displayName 355 + if dn, ok := commit.Record["displayName"].(string); ok { 356 + input.DisplayName = &dn 357 + } 358 + 359 + // Extract description (bio) 360 + if desc, ok := commit.Record["description"].(string); ok { 361 + input.Bio = &desc 362 + } 363 + 364 + // Extract avatar CID from blob ref structure 365 + if avatarMap, ok := commit.Record["avatar"].(map[string]interface{}); ok { 366 + if cid, ok := extractBlobCID(avatarMap); ok { 367 + input.AvatarCID = &cid 324 368 } 325 369 } 326 - return false 370 + 371 + // Extract banner CID from blob ref structure 372 + if bannerMap, ok := commit.Record["banner"].(map[string]interface{}); ok { 373 + if cid, ok := extractBlobCID(bannerMap); ok { 374 + input.BannerCID = &cid 375 + } 376 + } 377 + 378 + _, err := c.userService.UpdateProfile(ctx, did, input) 379 + if err != nil { 380 + return fmt.Errorf("failed to update user profile: %w", err) 381 + } 382 + 383 + log.Printf("Updated profile for user %s", did) 384 + return nil 385 + } 386 + 387 + // handleProfileDelete processes profile delete operations 388 + // Clears all profile fields by passing empty strings 389 + func (c *UserEventConsumer) handleProfileDelete(ctx context.Context, did string) error { 390 + empty := "" 391 + input := users.UpdateProfileInput{ 392 + DisplayName: &empty, 393 + Bio: &empty, 394 + AvatarCID: &empty, 395 + BannerCID: &empty, 396 + } 397 + _, err := c.userService.UpdateProfile(ctx, did, input) 398 + if err != nil { 399 + return fmt.Errorf("failed to clear user profile: %w", err) 400 + } 401 + log.Printf("Cleared profile for user %s", did) 402 + return nil 327 403 }
+240 -10
internal/atproto/oauth/handlers.go
··· 11 11 "strings" 12 12 13 13 "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 + "github.com/bluesky-social/indigo/atproto/identity" 14 15 "github.com/bluesky-social/indigo/atproto/syntax" 15 16 ) 16 17 ··· 145 146 GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error) 146 147 } 147 148 149 + // UserIndexer is the minimal interface for indexing users after OAuth login. 150 + // This decouples the OAuth handler from the full UserService. 151 + type UserIndexer interface { 152 + // IndexUser creates or updates a user in the local database. 153 + // This is idempotent - calling it multiple times with the same DID is safe. 154 + IndexUser(ctx context.Context, did, handle, pdsURL string) error 155 + } 156 + 148 157 // OAuthHandler handles OAuth-related HTTP endpoints 149 158 type OAuthHandler struct { 150 159 client *OAuthClient 151 160 store oauth.ClientAuthStore 152 161 mobileStore MobileOAuthStore // For server-side CSRF validation 162 + userIndexer UserIndexer // For indexing users after OAuth login 153 163 devResolver *DevHandleResolver // For dev mode: resolve handles via local PDS 154 164 devAuthResolver *DevAuthResolver // For dev mode: bypass HTTPS validation for localhost OAuth 155 165 } 156 166 167 + // OAuthHandlerOption is a functional option for configuring OAuthHandler 168 + type OAuthHandlerOption func(*OAuthHandler) 169 + 170 + // WithUserIndexer sets the user indexer for indexing users after OAuth login. 171 + // When set, users are automatically indexed into the local database after successful authentication. 172 + func WithUserIndexer(indexer UserIndexer) OAuthHandlerOption { 173 + return func(h *OAuthHandler) { 174 + h.userIndexer = indexer 175 + } 176 + } 177 + 157 178 // NewOAuthHandler creates a new OAuth handler 158 - func NewOAuthHandler(client *OAuthClient, store oauth.ClientAuthStore) *OAuthHandler { 179 + func NewOAuthHandler(client *OAuthClient, store oauth.ClientAuthStore, opts ...OAuthHandlerOption) *OAuthHandler { 159 180 handler := &OAuthHandler{ 160 181 client: client, 161 182 store: store, 162 183 } 163 184 185 + // Apply functional options 186 + for _, opt := range opts { 187 + opt(handler) 188 + } 189 + 164 190 // Check if the store implements MobileOAuthStore for server-side CSRF 165 191 if mobileStore, ok := store.(MobileOAuthStore); ok { 166 192 handler.mobileStore = mobileStore ··· 181 207 182 208 183 209 210 + } 184 211 212 + // HandleClientMetadata serves the OAuth client metadata document 213 + // GET /oauth-client-metadata.json 214 + func (h *OAuthHandler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) { 215 + metadata := h.client.ClientMetadata() 185 216 186 217 187 218 ··· 240 271 241 272 242 273 274 + // Log OAuth flow initiation (sanitized - no full URL to avoid leaking state) 275 + slog.Info("redirecting to PDS for OAuth", "identifier", identifier) 243 276 277 + // Store post-login redirect URL in cookie if provided 278 + // This allows redirecting to a specific page after OAuth completes (e.g., /delete-account) 279 + if postLoginRedirect := r.URL.Query().Get("redirect"); postLoginRedirect != "" { 280 + // Only allow relative paths to prevent open redirect vulnerabilities 281 + if len(postLoginRedirect) > 0 && postLoginRedirect[0] == '/' { 282 + http.SetCookie(w, &http.Cookie{ 283 + Name: "oauth_redirect", 284 + Value: postLoginRedirect, 285 + Path: "/", 286 + HttpOnly: true, 287 + Secure: !h.client.Config.DevMode, 288 + SameSite: http.SameSiteLaxMode, 289 + MaxAge: 300, // 5 minutes - enough for OAuth flow 290 + }) 291 + } 292 + } 244 293 245 - 246 - 247 - 248 - 249 - 250 - 251 - 252 - 253 - 294 + // Redirect to PDS 295 + http.Redirect(w, r, redirectURL, http.StatusFound) 296 + } 254 297 255 298 256 299 ··· 434 477 // This prevents impersonation via compromised PDS that issues tokens with invalid handle mappings 435 478 // Per AT Protocol spec: "Bidirectional verification required; confirm DID document claims handle" 436 479 // verifiedHandle stores the successfully verified handle for use in mobile callback 480 + // verifiedIdent stores the identity for reuse (PDS URL extraction, etc.) 437 481 verifiedHandle := "" 482 + var verifiedIdent *identity.Identity 438 483 if h.client.ClientApp.Dir != nil { 439 484 ident, err := h.client.ClientApp.Dir.LookupDID(ctx, sessData.AccountDID) 440 485 if err != nil { ··· 470 515 slog.Info("OAuth callback successful (dev mode: handle verified via PDS)", 471 516 "did", sessData.AccountDID, "handle", declaredHandle) 472 517 verifiedHandle = declaredHandle 518 + verifiedIdent = ident // Reuse the identity for PDS URL extraction 473 519 goto handleVerificationPassed 474 520 } 475 521 slog.Warn("dev mode: PDS handle verification failed", ··· 489 535 // Success: handle is valid and bidirectionally verified 490 536 slog.Info("OAuth callback successful", "did", sessData.AccountDID, "handle", ident.Handle) 491 537 verifiedHandle = ident.Handle.String() 538 + verifiedIdent = ident 492 539 } else { 493 540 // No directory client available - log warning but proceed 494 541 // This should only happen in testing scenarios ··· 498 545 } 499 546 handleVerificationPassed: 500 547 548 + // Index user in local database after successful OAuth login 549 + // This ensures users are available for profile lookups immediately after authentication 550 + if h.userIndexer != nil && verifiedHandle != "" && verifiedIdent != nil { 551 + pdsURL := verifiedIdent.PDSEndpoint() 552 + if pdsURL == "" { 553 + // No PDS URL available - skip indexing, user will be indexed on next login 554 + // We don't fallback to bsky.social since not all users are on Bluesky 555 + slog.Warn("skipping user indexing: no PDS URL in identity", 556 + "did", sessData.AccountDID, "handle", verifiedHandle) 557 + } else if indexErr := h.userIndexer.IndexUser(ctx, sessData.AccountDID.String(), verifiedHandle, pdsURL); indexErr != nil { 558 + // Log but don't fail - user can still proceed with their session 559 + // They'll be indexed on next login or via Jetstream identity event 560 + slog.Warn("failed to index user after OAuth login", 561 + "did", sessData.AccountDID, "handle", verifiedHandle, "error", indexErr) 562 + } else { 563 + slog.Info("indexed user after OAuth login", "did", sessData.AccountDID, "handle", verifiedHandle) 564 + } 565 + } 566 + 501 567 // Check if this is a mobile callback (check for mobile_redirect_uri cookie) 502 568 mobileRedirect, err := r.Cookie("mobile_redirect_uri") 503 569 if err == nil && mobileRedirect.Value != "" { 570 + 571 + 572 + 573 + 574 + 575 + 576 + 577 + 578 + 579 + 580 + 581 + 582 + 583 + 584 + 585 + 586 + 587 + 588 + 589 + 590 + 591 + 592 + 593 + 594 + 595 + 596 + 597 + 598 + 599 + 600 + 601 + 602 + 603 + 604 + 605 + 606 + 607 + 608 + 609 + 610 + 611 + 612 + 613 + 614 + 615 + 616 + 617 + 618 + 619 + 620 + 621 + 622 + 623 + 624 + 625 + 626 + 627 + 628 + 629 + 630 + 631 + 632 + 633 + 634 + 635 + 636 + 637 + 638 + 639 + 640 + 641 + 642 + 643 + 644 + 645 + 646 + 647 + 648 + 649 + 650 + 651 + 652 + 653 + 654 + 655 + 656 + 657 + 658 + 659 + 660 + 661 + 662 + 663 + 664 + 665 + 666 + 667 + 668 + 669 + 670 + 671 + 672 + 673 + 674 + 675 + 676 + 677 + 678 + 679 + 680 + 681 + 682 + 683 + 684 + 685 + 686 + 687 + 688 + 689 + 690 + 691 + 692 + 693 + 694 + 695 + 696 + 697 + 698 + 699 + 700 + 701 + 702 + 703 + 704 + 705 + 706 + 707 + 708 + 709 + 710 + // Clear all mobile cookies if they exist (defense in depth) 711 + clearMobileCookies(w) 712 + 713 + // Check for post-login redirect cookie 714 + redirectURL := "/" 715 + if redirectCookie, err := r.Cookie("oauth_redirect"); err == nil && redirectCookie.Value != "" { 716 + // Validate it's a relative path (security check) 717 + if len(redirectCookie.Value) > 0 && redirectCookie.Value[0] == '/' { 718 + redirectURL = redirectCookie.Value 719 + } 720 + // Clear the redirect cookie 721 + http.SetCookie(w, &http.Cookie{ 722 + Name: "oauth_redirect", 723 + Value: "", 724 + Path: "/", 725 + MaxAge: -1, 726 + }) 727 + } 728 + 729 + // Add base URL for production 730 + if !h.client.Config.DevMode && redirectURL == "/" { 731 + redirectURL = h.client.Config.PublicURL + "/" 732 + } 733 + http.Redirect(w, r, redirectURL, http.StatusFound)
+77 -1
internal/core/users/errors.go
··· 1 1 package users 2 2 3 - import "fmt" 3 + import ( 4 + "errors" 5 + "fmt" 6 + ) 7 + 8 + // Sentinel errors for common user operations 9 + var ( 10 + // ErrUserNotFound is returned when a user lookup finds no matching record 11 + ErrUserNotFound = errors.New("user not found") 12 + 13 + // ErrHandleAlreadyTaken is returned when attempting to use a handle that belongs to another user 14 + ErrHandleAlreadyTaken = errors.New("handle already taken") 15 + ) 4 16 5 17 // Domain errors for user service operations 6 18 // These map to lexicon error types defined in social.coves.actor.signup 19 + 20 + 21 + 22 + 23 + 24 + 25 + 26 + 27 + 28 + 29 + 30 + 31 + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + 44 + 45 + 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + func (e *PDSError) Error() string { 68 + return fmt.Sprintf("PDS error (%d): %s", e.StatusCode, e.Message) 69 + } 70 + 71 + // InvalidDIDError is returned when a DID does not meet format requirements 72 + type InvalidDIDError struct { 73 + DID string 74 + Reason string 75 + } 76 + 77 + func (e *InvalidDIDError) Error() string { 78 + if e.Reason != "" { 79 + return fmt.Sprintf("invalid DID %q: %s", e.DID, e.Reason) 80 + } 81 + return fmt.Sprintf("invalid DID %q: must start with 'did:'", e.DID) 82 + }
+81 -5
internal/core/users/interfaces.go
··· 1 1 2 2 3 + import "context" 3 4 5 + // UpdateProfileInput contains the fields that can be updated on a user's profile. 6 + // Nil values mean "don't change this field" - only non-nil values are updated. 7 + // Empty string values (*string pointing to "") will clear the field in the database. 8 + type UpdateProfileInput struct { 9 + DisplayName *string 10 + Bio *string 11 + AvatarCID *string 12 + BannerCID *string 13 + } 4 14 15 + // UserRepository defines the interface for user data persistence 16 + type UserRepository interface { 17 + Create(ctx context.Context, user *User) (*User, error) 5 18 6 19 7 20 ··· 23 36 24 37 25 38 39 + // // Use user 40 + // } 41 + GetByDIDs(ctx context.Context, dids []string) (map[string]*User, error) 26 42 43 + // GetProfileStats retrieves aggregated statistics for a user profile. 44 + // Returns counts of posts, comments, subscriptions, memberships, and total reputation. 45 + GetProfileStats(ctx context.Context, did string) (*ProfileStats, error) 27 46 47 + // UpdateProfile updates a user's profile fields (display name, bio, avatar, banner). 48 + // Nil values in the input mean "don't change this field" - only non-nil values are updated. 49 + // Empty string values will clear the field in the database. 50 + // Returns the updated user with all fields populated. 51 + // Returns ErrUserNotFound if the user does not exist. 52 + UpdateProfile(ctx context.Context, did string, input UpdateProfileInput) (*User, error) 28 53 54 + // Delete removes a user and all associated data from the AppView database. 55 + // This performs a cascading delete across all tables that reference the user's DID. 56 + // The operation is atomic - either all data is deleted or none. 57 + // 58 + // This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. 59 + // The user's identity remains intact for use with other atProto apps. 60 + // 61 + // Tables cleaned up (in order): 62 + // 1. oauth_sessions (explicit DELETE) 63 + // 2. oauth_requests (explicit DELETE) 64 + // 3. community_subscriptions (explicit DELETE) 65 + // 4. community_memberships (explicit DELETE) 66 + // 5. community_blocks (explicit DELETE) 67 + // 6. comments (explicit DELETE) 68 + // 7. votes (explicit DELETE - FK removed in migration 014) 69 + // 8. users (FK CASCADE deletes posts) 70 + // 71 + // Returns ErrUserNotFound if the user does not exist. 72 + // Returns InvalidDIDError if the DID format is invalid. 73 + Delete(ctx context.Context, did string) error 74 + } 29 75 30 - 31 - 32 - 33 - 34 - 76 + // UserService defines the interface for user business logic 35 77 36 78 37 79 ··· 39 81 UpdateHandle(ctx context.Context, did, newHandle string) (*User, error) 40 82 ResolveHandleToDID(ctx context.Context, handle string) (string, error) 41 83 RegisterAccount(ctx context.Context, req RegisterAccountRequest) (*RegisterAccountResponse, error) 84 + 85 + // IndexUser creates or updates a user in the local database. 86 + // This is idempotent - calling it multiple times with the same DID is safe. 87 + // Used after OAuth login to ensure users are immediately available for profile lookups. 88 + IndexUser(ctx context.Context, did, handle, pdsURL string) error 89 + 90 + // GetProfile retrieves a user's full profile with aggregated statistics. 91 + // Returns a ProfileViewDetailed matching the social.coves.actor.defs#profileViewDetailed lexicon. 92 + // Avatar and Banner CIDs are transformed to URLs using the user's PDS URL. 93 + GetProfile(ctx context.Context, did string) (*ProfileViewDetailed, error) 94 + 95 + // UpdateProfile updates a user's profile fields (display name, bio, avatar, banner). 96 + // Nil values in the input mean "don't change this field" - only non-nil values are updated. 97 + // Empty string values will clear the field in the database. 98 + // Returns the updated user with all fields populated. 99 + // Returns ErrUserNotFound if the user does not exist. 100 + UpdateProfile(ctx context.Context, did string, input UpdateProfileInput) (*User, error) 101 + 102 + // DeleteAccount removes a user and all associated data from the Coves AppView. 103 + // This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. 104 + // The user's identity remains intact for use with other atProto apps. 105 + // 106 + // Authorization: The caller must be the account owner. The XRPC handler extracts 107 + // the authenticated user's DID from the OAuth session context and passes it here. 108 + // This ensures users can ONLY delete their own accounts. 109 + // 110 + // This operation is required for Google Play compliance (account deletion requirement). 111 + // 112 + // The operation is atomic - either all data is deleted or none. 113 + // Logs the deletion event for audit trail (DID, handle, timestamp). 114 + // 115 + // Returns ErrUserNotFound if the user does not exist. 116 + // Returns InvalidDIDError if the DID format is invalid. 117 + DeleteAccount(ctx context.Context, did string) error 42 118 }
+243 -6
internal/core/users/service.go
··· 1 1 2 2 3 - 4 - 3 + import ( 4 + "Coves/internal/atproto/identity" 5 + "Coves/internal/core/blobs" 6 + "Coves/internal/core/communities" 5 7 "bytes" 6 8 "context" 7 9 "encoding/json" 10 + "errors" 8 11 "fmt" 9 12 "io" 10 13 "log" 14 + "log/slog" 15 + "net/http" 16 + "regexp" 17 + "strings" 11 18 12 19 13 20 ··· 127 134 128 135 129 136 137 + // ResolveHandleToDID resolves a handle to a DID 138 + // This is critical for login: users enter their handle, we resolve to DID 139 + // First checks local database for indexed users (fast path), then falls back 140 + // to external DNS TXT record lookup and HTTPS .well-known/atproto-did resolution 141 + func (s *userService) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { 142 + handle = strings.TrimSpace(strings.ToLower(handle)) 143 + if handle == "" { 144 + return "", fmt.Errorf("handle is required") 145 + } 130 146 147 + // Fast path: check local database first for users we've already indexed 148 + // This avoids external network calls for known users 149 + user, err := s.userRepo.GetByHandle(ctx, handle) 150 + if err == nil && user != nil { 151 + return user.DID, nil 152 + } 153 + // Log database errors (but not "not found" which is expected for unindexed users) 154 + if err != nil && !errors.Is(err, ErrUserNotFound) { 155 + log.Printf("Warning: database error during handle lookup for %s (falling back to external resolution): %v", handle, err) 156 + } 157 + // If not found locally or error, fall through to external resolution 131 158 159 + // Slow path: use identity resolver for external DNS/HTTPS resolution 160 + did, _, err := s.identityResolver.ResolveHandle(ctx, handle) 161 + if err != nil { 162 + return "", fmt.Errorf("failed to resolve handle %s: %w", handle, err) 132 163 133 164 134 165 ··· 193 224 194 225 195 226 227 + // Set the PDS URL in the response (PDS doesn't return this) 228 + pdsResp.PDSURL = s.defaultPDS 196 229 230 + // Index the new user in local database so they're immediately available for profile lookups 231 + // This is idempotent - safe to call even if user somehow already exists 232 + if indexErr := s.IndexUser(ctx, pdsResp.DID, pdsResp.Handle, s.defaultPDS); indexErr != nil { 233 + // Log but don't fail - the account was created successfully on PDS 234 + // They'll be indexed on first OAuth login if this fails 235 + log.Printf("Warning: failed to index new user after signup (DID: %s): %v", pdsResp.DID, indexErr) 236 + } 197 237 238 + return &pdsResp, nil 239 + } 198 240 241 + // IndexUser creates or updates a user in the local database. 242 + // This is idempotent and safe to call multiple times for the same user. 243 + // If the user exists, their handle is updated if it changed. 244 + func (s *userService) IndexUser(ctx context.Context, did, handle, pdsURL string) error { 245 + // Try to create the user (idempotent - CreateUser returns existing user if DID exists) 246 + _, err := s.CreateUser(ctx, CreateUserRequest{ 247 + DID: did, 248 + Handle: handle, 249 + PDSURL: pdsURL, 250 + }) 251 + 252 + if err != nil { 253 + // Check if it's a handle conflict (user exists with different handle) 254 + // In this case, update the handle instead 255 + if errors.Is(err, ErrHandleAlreadyTaken) { 256 + // User exists but handle changed - update it 257 + _, updateErr := s.UpdateHandle(ctx, did, handle) 258 + if updateErr != nil { 259 + return fmt.Errorf("failed to update handle for existing user: %w", updateErr) 260 + } 261 + return nil 262 + } 263 + return err 264 + } 199 265 266 + return nil 267 + } 200 268 269 + // GetProfile retrieves a user's full profile with aggregated statistics. 270 + // Returns a ProfileViewDetailed matching the social.coves.actor.defs#profileViewDetailed lexicon. 271 + // Avatar and Banner CIDs are transformed to URLs using the user's PDS URL. 272 + func (s *userService) GetProfile(ctx context.Context, did string) (*ProfileViewDetailed, error) { 273 + did = strings.TrimSpace(did) 274 + if did == "" { 275 + return nil, fmt.Errorf("DID is required") 276 + } 277 + 278 + // Get the user first 279 + user, err := s.userRepo.GetByDID(ctx, did) 280 + if err != nil { 281 + return nil, fmt.Errorf("failed to get user: %w", err) 282 + } 283 + 284 + // Get aggregated stats 285 + stats, err := s.userRepo.GetProfileStats(ctx, did) 286 + if err != nil { 287 + return nil, fmt.Errorf("failed to get profile stats: %w", err) 288 + } 289 + 290 + profile := &ProfileViewDetailed{ 291 + DID: user.DID, 292 + Handle: user.Handle, 293 + CreatedAt: user.CreatedAt, 294 + Stats: stats, 295 + DisplayName: user.DisplayName, 296 + Bio: user.Bio, 297 + } 298 + 299 + // Transform avatar/banner CIDs to URLs using image proxy config 300 + // Uses 'avatar' preset (160x160) for profile detail view 301 + config := communities.GetImageProxyConfig() 302 + profile.Avatar = blobs.HydrateImageURL(config, user.PDSURL, user.DID, user.AvatarCID, "avatar") 303 + profile.Banner = blobs.HydrateImageURL(config, user.PDSURL, user.DID, user.BannerCID, "banner") 201 304 305 + return profile, nil 306 + } 202 307 308 + // UpdateProfile updates a user's profile fields (display name, bio, avatar, banner). 309 + // Nil values in the input mean "don't change this field" - only non-nil values are updated. 310 + // Empty string values will clear the field in the database. 311 + // Returns the updated user with all fields populated. 312 + // Returns ErrUserNotFound if the user does not exist. 313 + func (s *userService) UpdateProfile(ctx context.Context, did string, input UpdateProfileInput) (*User, error) { 314 + did = strings.TrimSpace(did) 315 + if did == "" { 316 + return nil, fmt.Errorf("DID is required") 317 + } 203 318 319 + return s.userRepo.UpdateProfile(ctx, did, input) 320 + } 204 321 322 + func (s *userService) validateCreateRequest(req CreateUserRequest) error { 205 323 206 324 207 325 208 326 209 327 210 - // Set the PDS URL in the response (PDS doesn't return this) 211 - pdsResp.PDSURL = s.defaultPDS 212 328 213 - return &pdsResp, nil 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + 343 + 344 + 345 + 346 + 347 + 348 + 349 + 350 + 351 + 352 + 353 + 354 + 355 + 356 + 357 + 358 + 359 + 360 + 361 + 362 + 363 + 364 + 365 + 366 + 367 + 368 + 369 + 370 + 371 + 372 + 373 + 374 + 375 + 376 + 377 + 378 + 379 + 380 + 381 + 382 + 383 + 384 + 385 + 386 + 387 + 388 + 389 + 390 + 391 + 392 + 393 + 394 + 395 + 396 + 397 + 398 + 399 + 400 + 401 + 402 + 403 + 404 + 405 + return nil 214 406 } 215 407 216 - func (s *userService) validateCreateRequest(req CreateUserRequest) error { 408 + // DeleteAccount removes a user and all associated data from the Coves AppView. 409 + // This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. 410 + // The user's identity remains intact for use with other atProto apps. 411 + func (s *userService) DeleteAccount(ctx context.Context, did string) error { 412 + did = strings.TrimSpace(did) 413 + if did == "" { 414 + return &InvalidDIDError{DID: did, Reason: "DID is required"} 415 + } 416 + 417 + // Validate DID format 418 + if !strings.HasPrefix(did, "did:") { 419 + return &InvalidDIDError{DID: did, Reason: "must start with 'did:'"} 420 + } 421 + 422 + // Get user handle for audit log (before deletion) 423 + // We fetch the user first to include handle in the audit log 424 + user, err := s.userRepo.GetByDID(ctx, did) 425 + if err != nil { 426 + // If user not found, return that error 427 + if errors.Is(err, ErrUserNotFound) { 428 + return ErrUserNotFound 429 + } 430 + return fmt.Errorf("failed to get user for deletion: %w", err) 431 + } 432 + 433 + // Perform the deletion 434 + if err := s.userRepo.Delete(ctx, did); err != nil { 435 + // Log failed deletion attempt 436 + slog.Error("account deletion failed", 437 + slog.String("did", did), 438 + slog.String("handle", user.Handle), 439 + slog.String("error", err.Error()), 440 + ) 441 + return fmt.Errorf("failed to delete account: %w", err) 442 + } 443 + 444 + // Log successful deletion for audit trail 445 + // SECURITY: Only log DID and handle (non-sensitive identifiers), never tokens 446 + slog.Info("account deleted successfully", 447 + slog.String("did", did), 448 + slog.String("handle", user.Handle), 449 + slog.Time("deleted_at", time.Now().UTC()), 450 + ) 451 + 452 + return nil 453 + }
+94
internal/api/handlers/actor/errors.go
··· 1 + package actor 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + 10 + "Coves/internal/core/posts" 11 + ) 12 + 13 + // ErrorResponse represents an XRPC error response 14 + type ErrorResponse struct { 15 + Error string `json:"error"` 16 + Message string `json:"message"` 17 + } 18 + 19 + // writeError writes a JSON error response 20 + func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { 21 + w.Header().Set("Content-Type", "application/json") 22 + w.WriteHeader(statusCode) 23 + if err := json.NewEncoder(w).Encode(ErrorResponse{ 24 + Error: errorType, 25 + Message: message, 26 + }); err != nil { 27 + // Log encoding errors but can't send error response (headers already sent) 28 + log.Printf("ERROR: Failed to encode error response: %v", err) 29 + } 30 + } 31 + 32 + // handleServiceError maps service errors to HTTP responses 33 + func handleServiceError(w http.ResponseWriter, err error) { 34 + // Check for handler-level errors first 35 + var actorNotFound *actorNotFoundError 36 + if errors.As(err, &actorNotFound) { 37 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 38 + return 39 + } 40 + 41 + // Check for service-level errors 42 + switch { 43 + case errors.Is(err, posts.ErrNotFound): 44 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 45 + 46 + case errors.Is(err, posts.ErrActorNotFound): 47 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 48 + 49 + case errors.Is(err, posts.ErrCommunityNotFound): 50 + writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found") 51 + 52 + case errors.Is(err, posts.ErrInvalidCursor): 53 + writeError(w, http.StatusBadRequest, "InvalidCursor", "Invalid pagination cursor") 54 + 55 + case posts.IsValidationError(err): 56 + // Extract message from ValidationError for cleaner response 57 + var valErr *posts.ValidationError 58 + if errors.As(err, &valErr) { 59 + writeError(w, http.StatusBadRequest, "InvalidRequest", valErr.Message) 60 + } else { 61 + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 62 + } 63 + 64 + default: 65 + // Internal server error - don't leak details 66 + log.Printf("ERROR: Actor posts service error: %v", err) 67 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 68 + } 69 + } 70 + 71 + // actorNotFoundError represents an actor not found error 72 + type actorNotFoundError struct { 73 + actor string 74 + } 75 + 76 + func (e *actorNotFoundError) Error() string { 77 + return fmt.Sprintf("actor not found: %s", e.actor) 78 + } 79 + 80 + // resolutionFailedError represents an infrastructure failure during resolution 81 + // (database down, DNS failures, TLS errors, etc.) 82 + // This is distinct from actorNotFoundError to avoid masking real problems as "not found" 83 + type resolutionFailedError struct { 84 + actor string 85 + cause error 86 + } 87 + 88 + func (e *resolutionFailedError) Error() string { 89 + return fmt.Sprintf("failed to resolve actor %s: %v", e.actor, e.cause) 90 + } 91 + 92 + func (e *resolutionFailedError) Unwrap() error { 93 + return e.cause 94 + }
+185
internal/api/handlers/actor/get_posts.go
··· 1 + package actor 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "log" 7 + "net/http" 8 + "strconv" 9 + "strings" 10 + 11 + "Coves/internal/api/handlers/common" 12 + "Coves/internal/api/middleware" 13 + "Coves/internal/core/blueskypost" 14 + "Coves/internal/core/posts" 15 + "Coves/internal/core/users" 16 + "Coves/internal/core/votes" 17 + ) 18 + 19 + // GetPostsHandler handles actor post retrieval 20 + type GetPostsHandler struct { 21 + postService posts.Service 22 + userService users.UserService 23 + voteService votes.Service 24 + blueskyService blueskypost.Service 25 + } 26 + 27 + // NewGetPostsHandler creates a new actor posts handler 28 + func NewGetPostsHandler( 29 + postService posts.Service, 30 + userService users.UserService, 31 + voteService votes.Service, 32 + blueskyService blueskypost.Service, 33 + ) *GetPostsHandler { 34 + if blueskyService == nil { 35 + log.Printf("[ACTOR-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved") 36 + } 37 + return &GetPostsHandler{ 38 + postService: postService, 39 + userService: userService, 40 + voteService: voteService, 41 + blueskyService: blueskyService, 42 + } 43 + } 44 + 45 + // HandleGetPosts retrieves posts by an actor (user) 46 + // GET /xrpc/social.coves.actor.getPosts?actor={did_or_handle}&filter=posts_with_replies&community=...&limit=50&cursor=... 47 + func (h *GetPostsHandler) HandleGetPosts(w http.ResponseWriter, r *http.Request) { 48 + if r.Method != http.MethodGet { 49 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 50 + return 51 + } 52 + 53 + // Parse query parameters 54 + req, err := h.parseRequest(r) 55 + if err != nil { 56 + // Check if it's an actor not found error (from handle resolution) 57 + var actorNotFound *actorNotFoundError 58 + if errors.As(err, &actorNotFound) { 59 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 60 + return 61 + } 62 + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 63 + return 64 + } 65 + 66 + // Get viewer DID for populating viewer state (optional) 67 + viewerDID := middleware.GetUserDID(r) 68 + req.ViewerDID = viewerDID 69 + 70 + // Get actor posts from service 71 + response, err := h.postService.GetAuthorPosts(r.Context(), req) 72 + if err != nil { 73 + handleServiceError(w, err) 74 + return 75 + } 76 + 77 + // Populate viewer vote state if authenticated 78 + common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed) 79 + 80 + // Transform blob refs to URLs and resolve post embeds for all posts 81 + for _, feedPost := range response.Feed { 82 + if feedPost.Post != nil { 83 + posts.TransformBlobRefsToURLs(feedPost.Post) 84 + posts.TransformPostEmbeds(r.Context(), feedPost.Post, h.blueskyService) 85 + } 86 + } 87 + 88 + // Pre-encode response to buffer before writing headers 89 + // This ensures we can return a proper error if encoding fails 90 + responseBytes, err := json.Marshal(response) 91 + if err != nil { 92 + log.Printf("ERROR: Failed to encode actor posts response: %v", err) 93 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") 94 + return 95 + } 96 + 97 + // Return feed 98 + w.Header().Set("Content-Type", "application/json") 99 + w.WriteHeader(http.StatusOK) 100 + if _, err := w.Write(responseBytes); err != nil { 101 + log.Printf("ERROR: Failed to write actor posts response: %v", err) 102 + } 103 + } 104 + 105 + // parseRequest parses query parameters into GetAuthorPostsRequest 106 + func (h *GetPostsHandler) parseRequest(r *http.Request) (posts.GetAuthorPostsRequest, error) { 107 + req := posts.GetAuthorPostsRequest{} 108 + 109 + // Required: actor (handle or DID) 110 + actor := r.URL.Query().Get("actor") 111 + if actor == "" { 112 + return req, posts.NewValidationError("actor", "actor parameter is required") 113 + } 114 + // Validate actor length to prevent DoS via massive strings 115 + // Max DID length is ~2048 chars (did:plc: is 8 + 24 base32 = 32, but did:web: can be longer) 116 + // Max handle length is 253 chars (DNS limit) 117 + const maxActorLength = 2048 118 + if len(actor) > maxActorLength { 119 + return req, posts.NewValidationError("actor", "actor parameter exceeds maximum length") 120 + } 121 + 122 + // Resolve actor to DID if it's a handle 123 + actorDID, err := h.resolveActor(r, actor) 124 + if err != nil { 125 + return req, err 126 + } 127 + req.ActorDID = actorDID 128 + 129 + // Optional: filter (default: posts_with_replies) 130 + req.Filter = r.URL.Query().Get("filter") 131 + 132 + // Optional: community (handle or DID) 133 + req.Community = r.URL.Query().Get("community") 134 + 135 + // Optional: limit (default: 50, max: 100) 136 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 137 + limit, err := strconv.Atoi(limitStr) 138 + if err != nil { 139 + return req, posts.NewValidationError("limit", "limit must be a valid integer") 140 + } 141 + req.Limit = limit 142 + } 143 + 144 + // Optional: cursor 145 + if cursor := r.URL.Query().Get("cursor"); cursor != "" { 146 + req.Cursor = &cursor 147 + } 148 + 149 + return req, nil 150 + } 151 + 152 + // resolveActor converts an actor identifier (handle or DID) to a DID 153 + func (h *GetPostsHandler) resolveActor(r *http.Request, actor string) (string, error) { 154 + // If it's already a DID, return it 155 + if strings.HasPrefix(actor, "did:") { 156 + return actor, nil 157 + } 158 + 159 + // It's a handle - resolve to DID using user service 160 + did, err := h.userService.ResolveHandleToDID(r.Context(), actor) 161 + if err != nil { 162 + // Check for context errors (timeouts, cancellation) - these are infrastructure errors 163 + if r.Context().Err() != nil { 164 + log.Printf("WARN: Handle resolution failed due to context error for %s: %v", actor, err) 165 + return "", &resolutionFailedError{actor: actor, cause: r.Context().Err()} 166 + } 167 + 168 + // Check for common "not found" patterns in error message 169 + errStr := err.Error() 170 + isNotFound := strings.Contains(errStr, "not found") || 171 + strings.Contains(errStr, "no rows") || 172 + strings.Contains(errStr, "unable to resolve") 173 + 174 + if isNotFound { 175 + return "", &actorNotFoundError{actor: actor} 176 + } 177 + 178 + // For other errors (network, database, DNS failures), return infrastructure error 179 + // This ensures users see "internal error" not "actor not found" for real problems 180 + log.Printf("WARN: Handle resolution infrastructure failure for %s: %v", actor, err) 181 + return "", &resolutionFailedError{actor: actor, cause: err} 182 + } 183 + 184 + return did, nil 185 + }
+343
internal/api/handlers/actor/get_posts_test.go
··· 1 + package actor 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + 10 + "Coves/internal/core/blueskypost" 11 + "Coves/internal/core/posts" 12 + "Coves/internal/core/users" 13 + "Coves/internal/core/votes" 14 + 15 + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 16 + ) 17 + 18 + // mockPostService implements posts.Service for testing 19 + type mockPostService struct { 20 + getAuthorPostsFunc func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) 21 + } 22 + 23 + func (m *mockPostService) GetAuthorPosts(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { 24 + if m.getAuthorPostsFunc != nil { 25 + return m.getAuthorPostsFunc(ctx, req) 26 + } 27 + return &posts.GetAuthorPostsResponse{ 28 + Feed: []*posts.FeedViewPost{}, 29 + Cursor: nil, 30 + }, nil 31 + } 32 + 33 + func (m *mockPostService) CreatePost(ctx context.Context, req posts.CreatePostRequest) (*posts.CreatePostResponse, error) { 34 + return nil, nil 35 + } 36 + 37 + // mockUserService implements users.UserService for testing 38 + type mockUserService struct { 39 + resolveHandleToDIDFunc func(ctx context.Context, handle string) (string, error) 40 + } 41 + 42 + func (m *mockUserService) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) { 43 + return nil, nil 44 + } 45 + 46 + func (m *mockUserService) GetUserByDID(ctx context.Context, did string) (*users.User, error) { 47 + return nil, nil 48 + } 49 + 50 + func (m *mockUserService) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) { 51 + return nil, nil 52 + } 53 + 54 + func (m *mockUserService) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) { 55 + return nil, nil 56 + } 57 + 58 + func (m *mockUserService) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { 59 + if m.resolveHandleToDIDFunc != nil { 60 + return m.resolveHandleToDIDFunc(ctx, handle) 61 + } 62 + return "did:plc:testuser", nil 63 + } 64 + 65 + func (m *mockUserService) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) { 66 + return nil, nil 67 + } 68 + 69 + func (m *mockUserService) IndexUser(ctx context.Context, did, handle, pdsURL string) error { 70 + return nil 71 + } 72 + 73 + func (m *mockUserService) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) { 74 + return nil, nil 75 + } 76 + 77 + func (m *mockUserService) DeleteAccount(ctx context.Context, did string) error { 78 + return nil 79 + } 80 + 81 + func (m *mockUserService) UpdateProfile(ctx context.Context, did string, input users.UpdateProfileInput) (*users.User, error) { 82 + return nil, nil 83 + } 84 + 85 + // mockVoteService implements votes.Service for testing 86 + type mockVoteService struct{} 87 + 88 + func (m *mockVoteService) CreateVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) { 89 + return nil, nil 90 + } 91 + 92 + func (m *mockVoteService) DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error { 93 + return nil 94 + } 95 + 96 + func (m *mockVoteService) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error { 97 + return nil 98 + } 99 + 100 + func (m *mockVoteService) GetViewerVote(userDID, subjectURI string) *votes.CachedVote { 101 + return nil 102 + } 103 + 104 + func (m *mockVoteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote { 105 + return nil 106 + } 107 + 108 + // mockBlueskyService implements blueskypost.Service for testing 109 + type mockBlueskyService struct{} 110 + 111 + func (m *mockBlueskyService) ResolvePost(ctx context.Context, atURI string) (*blueskypost.BlueskyPostResult, error) { 112 + return nil, nil 113 + } 114 + 115 + func (m *mockBlueskyService) ParseBlueskyURL(ctx context.Context, url string) (string, error) { 116 + return "", nil 117 + } 118 + 119 + func (m *mockBlueskyService) IsBlueskyURL(url string) bool { 120 + return false 121 + } 122 + 123 + func TestGetPostsHandler_Success(t *testing.T) { 124 + mockPosts := &mockPostService{ 125 + getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { 126 + return &posts.GetAuthorPostsResponse{ 127 + Feed: []*posts.FeedViewPost{ 128 + { 129 + Post: &posts.PostView{ 130 + URI: "at://did:plc:testuser/social.coves.community.post/abc123", 131 + CID: "bafytest123", 132 + }, 133 + }, 134 + }, 135 + }, nil 136 + }, 137 + } 138 + mockUsers := &mockUserService{} 139 + mockVotes := &mockVoteService{} 140 + mockBluesky := &mockBlueskyService{} 141 + 142 + handler := NewGetPostsHandler(mockPosts, mockUsers, mockVotes, mockBluesky) 143 + 144 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:testuser", nil) 145 + rec := httptest.NewRecorder() 146 + 147 + handler.HandleGetPosts(rec, req) 148 + 149 + if rec.Code != http.StatusOK { 150 + t.Errorf("Expected status 200, got %d", rec.Code) 151 + } 152 + 153 + var response posts.GetAuthorPostsResponse 154 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 155 + t.Fatalf("Failed to decode response: %v", err) 156 + } 157 + 158 + if len(response.Feed) != 1 { 159 + t.Errorf("Expected 1 post in feed, got %d", len(response.Feed)) 160 + } 161 + } 162 + 163 + func TestGetPostsHandler_MissingActorParameter(t *testing.T) { 164 + handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) 165 + 166 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts", nil) 167 + rec := httptest.NewRecorder() 168 + 169 + handler.HandleGetPosts(rec, req) 170 + 171 + if rec.Code != http.StatusBadRequest { 172 + t.Errorf("Expected status 400, got %d", rec.Code) 173 + } 174 + 175 + var response ErrorResponse 176 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 177 + t.Fatalf("Failed to decode response: %v", err) 178 + } 179 + 180 + if response.Error != "InvalidRequest" { 181 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 182 + } 183 + } 184 + 185 + func TestGetPostsHandler_InvalidLimitParameter(t *testing.T) { 186 + handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) 187 + 188 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:test&limit=abc", nil) 189 + rec := httptest.NewRecorder() 190 + 191 + handler.HandleGetPosts(rec, req) 192 + 193 + if rec.Code != http.StatusBadRequest { 194 + t.Errorf("Expected status 400, got %d", rec.Code) 195 + } 196 + 197 + var response ErrorResponse 198 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 199 + t.Fatalf("Failed to decode response: %v", err) 200 + } 201 + 202 + if response.Error != "InvalidRequest" { 203 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 204 + } 205 + } 206 + 207 + func TestGetPostsHandler_ActorNotFound(t *testing.T) { 208 + mockUsers := &mockUserService{ 209 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 210 + return "", posts.ErrActorNotFound 211 + }, 212 + } 213 + 214 + handler := NewGetPostsHandler(&mockPostService{}, mockUsers, &mockVoteService{}, &mockBlueskyService{}) 215 + 216 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=nonexistent.user", nil) 217 + rec := httptest.NewRecorder() 218 + 219 + handler.HandleGetPosts(rec, req) 220 + 221 + if rec.Code != http.StatusNotFound { 222 + t.Errorf("Expected status 404, got %d", rec.Code) 223 + } 224 + } 225 + 226 + func TestGetPostsHandler_ActorLengthExceedsMax(t *testing.T) { 227 + handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) 228 + 229 + // Create an actor parameter that exceeds 2048 characters using valid URL characters 230 + longActorBytes := make([]byte, 2100) 231 + for i := range longActorBytes { 232 + longActorBytes[i] = 'a' 233 + } 234 + longActor := "did:plc:" + string(longActorBytes) 235 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor="+longActor, nil) 236 + rec := httptest.NewRecorder() 237 + 238 + handler.HandleGetPosts(rec, req) 239 + 240 + if rec.Code != http.StatusBadRequest { 241 + t.Errorf("Expected status 400, got %d", rec.Code) 242 + } 243 + } 244 + 245 + func TestGetPostsHandler_InvalidCursor(t *testing.T) { 246 + mockPosts := &mockPostService{ 247 + getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { 248 + return nil, posts.ErrInvalidCursor 249 + }, 250 + } 251 + 252 + handler := NewGetPostsHandler(mockPosts, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) 253 + 254 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:test&cursor=invalid", nil) 255 + rec := httptest.NewRecorder() 256 + 257 + handler.HandleGetPosts(rec, req) 258 + 259 + if rec.Code != http.StatusBadRequest { 260 + t.Errorf("Expected status 400, got %d", rec.Code) 261 + } 262 + 263 + var response ErrorResponse 264 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 265 + t.Fatalf("Failed to decode response: %v", err) 266 + } 267 + 268 + if response.Error != "InvalidCursor" { 269 + t.Errorf("Expected error 'InvalidCursor', got '%s'", response.Error) 270 + } 271 + } 272 + 273 + func TestGetPostsHandler_MethodNotAllowed(t *testing.T) { 274 + handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) 275 + 276 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.getPosts", nil) 277 + rec := httptest.NewRecorder() 278 + 279 + handler.HandleGetPosts(rec, req) 280 + 281 + if rec.Code != http.StatusMethodNotAllowed { 282 + t.Errorf("Expected status 405, got %d", rec.Code) 283 + } 284 + } 285 + 286 + func TestGetPostsHandler_HandleResolution(t *testing.T) { 287 + resolvedDID := "" 288 + mockPosts := &mockPostService{ 289 + getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { 290 + resolvedDID = req.ActorDID 291 + return &posts.GetAuthorPostsResponse{Feed: []*posts.FeedViewPost{}}, nil 292 + }, 293 + } 294 + mockUsers := &mockUserService{ 295 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 296 + if handle == "test.user" { 297 + return "did:plc:resolveduser123", nil 298 + } 299 + return "", posts.ErrActorNotFound 300 + }, 301 + } 302 + 303 + handler := NewGetPostsHandler(mockPosts, mockUsers, &mockVoteService{}, &mockBlueskyService{}) 304 + 305 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=test.user", nil) 306 + rec := httptest.NewRecorder() 307 + 308 + handler.HandleGetPosts(rec, req) 309 + 310 + if rec.Code != http.StatusOK { 311 + t.Errorf("Expected status 200, got %d", rec.Code) 312 + } 313 + 314 + if resolvedDID != "did:plc:resolveduser123" { 315 + t.Errorf("Expected resolved DID 'did:plc:resolveduser123', got '%s'", resolvedDID) 316 + } 317 + } 318 + 319 + func TestGetPostsHandler_DirectDIDPassthrough(t *testing.T) { 320 + receivedDID := "" 321 + mockPosts := &mockPostService{ 322 + getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { 323 + receivedDID = req.ActorDID 324 + return &posts.GetAuthorPostsResponse{Feed: []*posts.FeedViewPost{}}, nil 325 + }, 326 + } 327 + 328 + handler := NewGetPostsHandler(mockPosts, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) 329 + 330 + // When actor is already a DID, it should pass through without resolution 331 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:directuser", nil) 332 + rec := httptest.NewRecorder() 333 + 334 + handler.HandleGetPosts(rec, req) 335 + 336 + if rec.Code != http.StatusOK { 337 + t.Errorf("Expected status 200, got %d", rec.Code) 338 + } 339 + 340 + if receivedDID != "did:plc:directuser" { 341 + t.Errorf("Expected DID 'did:plc:directuser', got '%s'", receivedDID) 342 + } 343 + }
+36
internal/api/routes/actor.go
··· 1 + package routes 2 + 3 + import ( 4 + "Coves/internal/api/handlers/actor" 5 + "Coves/internal/api/middleware" 6 + "Coves/internal/core/blueskypost" 7 + "Coves/internal/core/comments" 8 + "Coves/internal/core/posts" 9 + "Coves/internal/core/users" 10 + "Coves/internal/core/votes" 11 + 12 + "github.com/go-chi/chi/v5" 13 + ) 14 + 15 + // RegisterActorRoutes registers actor-related XRPC endpoints 16 + func RegisterActorRoutes( 17 + r chi.Router, 18 + postService posts.Service, 19 + userService users.UserService, 20 + voteService votes.Service, 21 + blueskyService blueskypost.Service, 22 + commentService comments.Service, 23 + authMiddleware *middleware.OAuthAuthMiddleware, 24 + ) { 25 + // Create handlers 26 + getPostsHandler := actor.NewGetPostsHandler(postService, userService, voteService, blueskyService) 27 + getCommentsHandler := actor.NewGetCommentsHandler(commentService, userService, voteService) 28 + 29 + // GET /xrpc/social.coves.actor.getPosts 30 + // Public endpoint with optional auth for viewer-specific state (vote state) 31 + r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.actor.getPosts", getPostsHandler.HandleGetPosts) 32 + 33 + // GET /xrpc/social.coves.actor.getComments 34 + // Public endpoint with optional auth for viewer-specific state (vote state) 35 + r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.actor.getComments", getCommentsHandler.HandleGetComments) 36 + }
+66
internal/atproto/lexicon/social/coves/actor/getPosts.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.actor.getPosts", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a user's posts for their profile page.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "DID or handle of the user" 16 + }, 17 + "filter": { 18 + "type": "string", 19 + "knownValues": ["posts_with_replies", "posts_no_replies", "posts_with_media"], 20 + "default": "posts_with_replies", 21 + "description": "Filter for post types" 22 + }, 23 + "community": { 24 + "type": "string", 25 + "format": "at-identifier", 26 + "description": "Filter to posts in a specific community" 27 + }, 28 + "limit": { 29 + "type": "integer", 30 + "minimum": 1, 31 + "maximum": 100, 32 + "default": 50 33 + }, 34 + "cursor": { 35 + "type": "string" 36 + } 37 + } 38 + }, 39 + "output": { 40 + "encoding": "application/json", 41 + "schema": { 42 + "type": "object", 43 + "required": ["feed"], 44 + "properties": { 45 + "feed": { 46 + "type": "array", 47 + "items": { 48 + "type": "ref", 49 + "ref": "social.coves.feed.defs#feedViewPost" 50 + } 51 + }, 52 + "cursor": { 53 + "type": "string" 54 + } 55 + } 56 + } 57 + }, 58 + "errors": [ 59 + { 60 + "name": "NotFound", 61 + "description": "Actor not found" 62 + } 63 + ] 64 + } 65 + } 66 + }
+6
internal/core/posts/errors.go
··· 25 25 26 26 // ErrRateLimitExceeded is returned when an aggregator exceeds rate limits 27 27 ErrRateLimitExceeded = errors.New("rate limit exceeded") 28 + 29 + // ErrInvalidCursor is returned when a pagination cursor is malformed 30 + ErrInvalidCursor = errors.New("invalid pagination cursor") 31 + 32 + // ErrActorNotFound is returned when the requested actor does not exist 33 + ErrActorNotFound = errors.New("actor not found") 28 34 ) 29 35 30 36 // ValidationError represents a validation error with field context
+10
internal/core/posts/interfaces.go
··· 16 16 // AppView indexing happens asynchronously via Jetstream consumer 17 17 CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) 18 18 19 + // GetAuthorPosts retrieves posts authored by a specific user for their profile page 20 + // Supports filtering by post type (with/without replies, media only) and community 21 + // Returns paginated feed with cursor 22 + GetAuthorPosts(ctx context.Context, req GetAuthorPostsRequest) (*GetAuthorPostsResponse, error) 23 + 19 24 // Future methods (Beta): 20 25 // GetPost(ctx context.Context, uri string, viewerDID *string) (*Post, error) 21 26 // UpdatePost(ctx context.Context, req UpdatePostRequest) (*Post, error) ··· 34 39 // Used for E2E test verification and future GET endpoint 35 40 GetByURI(ctx context.Context, uri string) (*Post, error) 36 41 42 + // GetByAuthor retrieves posts authored by a specific user 43 + // Supports filtering by post type and community 44 + // Returns posts, cursor for pagination, and error 45 + GetByAuthor(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error) 46 + 37 47 // Future methods (Beta): 38 48 // Update(ctx context.Context, post *Post) error 39 49 // Delete(ctx context.Context, uri string) error
+71
internal/core/posts/post.go
··· 143 143 Tags []string `json:"tags,omitempty"` 144 144 Saved bool `json:"saved"` 145 145 } 146 + 147 + // Filter constants for GetAuthorPosts 148 + const ( 149 + FilterPostsWithReplies = "posts_with_replies" 150 + FilterPostsNoReplies = "posts_no_replies" 151 + FilterPostsWithMedia = "posts_with_media" 152 + ) 153 + 154 + // GetAuthorPostsRequest represents input for fetching author's posts 155 + // Matches social.coves.actor.getPosts lexicon input 156 + type GetAuthorPostsRequest struct { 157 + ActorDID string // Resolved DID from actor param (handle or DID) 158 + Filter string // FilterPostsWithReplies, FilterPostsNoReplies, FilterPostsWithMedia 159 + Community string // Optional community DID filter 160 + Limit int // Number of posts to return (1-100, default 50) 161 + Cursor *string // Pagination cursor 162 + ViewerDID string // Viewer's DID for enriching viewer state 163 + } 164 + 165 + // GetAuthorPostsResponse represents author posts response 166 + // Matches social.coves.actor.getPosts lexicon output 167 + type GetAuthorPostsResponse struct { 168 + Feed []*FeedViewPost `json:"feed"` 169 + Cursor *string `json:"cursor,omitempty"` 170 + } 171 + 172 + // FeedViewPost matches social.coves.feed.defs#feedViewPost 173 + // Wraps a post with optional context about why it appears in a feed 174 + type FeedViewPost struct { 175 + Post *PostView `json:"post"` 176 + Reason *FeedReason `json:"reason,omitempty"` // Context for why post appears in feed 177 + Reply *ReplyRef `json:"reply,omitempty"` // Reply context if post is a reply 178 + } 179 + 180 + // GetPost returns the underlying PostView for viewer state enrichment 181 + func (f *FeedViewPost) GetPost() *PostView { 182 + return f.Post 183 + } 184 + 185 + // FeedReason represents the reason a post appears in a feed 186 + // Matches social.coves.feed.defs union type for feed context 187 + type FeedReason struct { 188 + Type string `json:"$type"` 189 + Repost *ReasonRepost `json:"repost,omitempty"` 190 + Pin *ReasonPin `json:"pin,omitempty"` 191 + } 192 + 193 + // ReasonRepost indicates the post was reposted by another user 194 + type ReasonRepost struct { 195 + By *AuthorView `json:"by"` 196 + IndexedAt string `json:"indexedAt"` 197 + } 198 + 199 + // ReasonPin indicates the post is pinned by the community 200 + type ReasonPin struct { 201 + Community *CommunityRef `json:"community"` 202 + } 203 + 204 + // ReplyRef contains context about post replies 205 + // Matches social.coves.feed.defs#replyRef 206 + type ReplyRef struct { 207 + Root *PostRef `json:"root"` 208 + Parent *PostRef `json:"parent"` 209 + } 210 + 211 + // PostRef is a minimal reference to a post (URI + CID) 212 + // Matches social.coves.feed.defs#postRef 213 + type PostRef struct { 214 + URI string `json:"uri"` 215 + CID string `json:"cid"` 216 + }
+166 -32
internal/core/posts/service.go
··· 249 249 250 250 251 251 252 + } 252 253 254 + // Unfurl enhancement (optional, only if URL is supported) 255 + // For trusted aggregators: only unfurl for thumbnail if they didn't provide one 256 + // For regular users: full unfurl for all metadata 257 + needsThumbnailUnfurl := isTrustedAggregator && external["thumb"] == nil && (req.ThumbnailURL == nil || *req.ThumbnailURL == "") 258 + needsFullUnfurl := !isTrustedAggregator 253 259 260 + if needsThumbnailUnfurl || needsFullUnfurl { 261 + if uri, ok := external["uri"].(string); ok && uri != "" { 262 + // Check if we support unfurling this URL 263 + if s.unfurlService != nil && s.unfurlService.IsSupported(uri) { 264 + log.Printf("[POST-CREATE] Unfurling URL: %s (thumbnailOnly=%v)", uri, needsThumbnailUnfurl) 254 265 266 + // Unfurl with timeout (non-fatal if it fails) 267 + unfurlCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 255 268 256 269 257 270 258 271 272 + // Log but don't fail - user can still post with manual metadata 273 + log.Printf("[POST-CREATE] Warning: Failed to unfurl URL %s: %v", uri, err) 274 + } else { 275 + // For regular users: enhance embed with fetched metadata 276 + // For trusted aggregators: skip metadata, they provide their own 277 + if needsFullUnfurl { 278 + // Enhance embed with fetched metadata (only if client didn't provide) 279 + // Note: We respect client-provided values, even empty strings 280 + // If client sends title="", we assume they want no title 281 + if external["title"] == nil { 282 + external["title"] = result.Title 283 + } 284 + if external["description"] == nil { 285 + external["description"] = result.Description 286 + } 287 + // Always set metadata fields (provider, domain, type) 288 + external["embedType"] = result.Type 289 + external["provider"] = result.Provider 290 + external["domain"] = result.Domain 291 + } 259 292 293 + // Upload thumbnail from unfurl if client didn't provide one 294 + // (Thumb validation already happened above) 260 295 261 296 262 297 ··· 269 304 270 305 271 306 307 + } 308 + } 272 309 310 + if needsFullUnfurl { 311 + log.Printf("[POST-CREATE] Successfully enhanced embed with unfurl data (provider: %s, type: %s)", 312 + result.Provider, result.Type) 313 + } else { 314 + log.Printf("[POST-CREATE] Fetched thumbnail via unfurl for trusted aggregator") 315 + } 316 + } 317 + } 318 + } 273 319 274 320 275 321 ··· 521 567 522 568 523 569 570 + log.Printf("[POST-CREATE] Converted Bluesky URL to post embed: %s (cid: %s)", result.URI, result.CID) 571 + return true 572 + } 524 573 574 + // GetAuthorPosts retrieves posts by a specific author with optional filtering 575 + // Supports filtering by: posts_with_replies, posts_no_replies, posts_with_media 576 + // Optionally filter to a specific community 577 + func (s *postService) GetAuthorPosts(ctx context.Context, req GetAuthorPostsRequest) (*GetAuthorPostsResponse, error) { 578 + // 1. Validate request 579 + if err := s.validateGetAuthorPostsRequest(&req); err != nil { 580 + return nil, err 581 + } 582 + 583 + // 2. If community is provided, resolve it to DID 584 + if req.Community != "" { 585 + communityDID, err := s.communityService.ResolveCommunityIdentifier(ctx, req.Community) 586 + if err != nil { 587 + if communities.IsNotFound(err) { 588 + return nil, ErrCommunityNotFound 589 + } 590 + if communities.IsValidationError(err) { 591 + return nil, NewValidationError("community", err.Error()) 592 + } 593 + return nil, fmt.Errorf("failed to resolve community identifier: %w", err) 594 + } 595 + req.Community = communityDID 596 + } 597 + 598 + // 3. Fetch posts from repository 599 + postViews, cursor, err := s.repo.GetByAuthor(ctx, req) 600 + if err != nil { 601 + return nil, fmt.Errorf("failed to get author posts: %w", err) 602 + } 603 + 604 + // 4. Wrap PostViews in FeedViewPost 605 + feed := make([]*FeedViewPost, len(postViews)) 606 + for i, postView := range postViews { 607 + feed[i] = &FeedViewPost{ 608 + Post: postView, 609 + } 610 + } 611 + 612 + // 5. Return response 613 + return &GetAuthorPostsResponse{ 614 + Feed: feed, 615 + Cursor: cursor, 616 + }, nil 617 + } 525 618 619 + // validateGetAuthorPostsRequest validates the GetAuthorPosts request 620 + func (s *postService) validateGetAuthorPostsRequest(req *GetAuthorPostsRequest) error { 621 + // Validate actor DID is set 622 + if req.ActorDID == "" { 623 + return NewValidationError("actor", "actor is required") 624 + } 625 + 626 + // Validate DID format - AT Protocol supports did:plc and did:web 627 + if err := validateDIDFormat(req.ActorDID); err != nil { 628 + return NewValidationError("actor", err.Error()) 629 + } 630 + 631 + // Validate and set defaults for filter 632 + validFilters := map[string]bool{ 633 + FilterPostsWithReplies: true, 634 + FilterPostsNoReplies: true, 635 + FilterPostsWithMedia: true, 636 + } 637 + if req.Filter == "" { 638 + req.Filter = FilterPostsWithReplies // Default 639 + } 640 + if !validFilters[req.Filter] { 641 + return NewValidationError("filter", "filter must be one of: posts_with_replies, posts_no_replies, posts_with_media") 642 + } 643 + 644 + // Validate and set defaults for limit 645 + if req.Limit <= 0 { 646 + req.Limit = 50 // Default 647 + } 648 + if req.Limit > 100 { 649 + req.Limit = 100 // Max 650 + } 526 651 652 + return nil 653 + } 527 654 528 - 529 - 530 - 531 - 532 - 533 - 534 - 535 - 536 - 537 - 538 - 539 - 540 - 541 - 542 - 543 - 544 - 545 - 546 - 547 - 548 - 549 - 550 - 551 - 552 - 553 - 554 - 555 - 556 - 557 - 558 - log.Printf("[POST-CREATE] Converted Bluesky URL to post embed: %s (cid: %s)", result.URI, result.CID) 559 - return true 655 + // validateDIDFormat validates that a string is a properly formatted DID 656 + // Supports did:plc: (24 char base32 identifier) and did:web: (domain-based) 657 + func validateDIDFormat(did string) error { 658 + const maxDIDLength = 2048 659 + 660 + if len(did) > maxDIDLength { 661 + return fmt.Errorf("DID exceeds maximum length") 662 + } 663 + 664 + switch { 665 + case strings.HasPrefix(did, "did:plc:"): 666 + // did:plc: format - identifier is 24 lowercase alphanumeric chars 667 + identifier := strings.TrimPrefix(did, "did:plc:") 668 + if len(identifier) == 0 { 669 + return fmt.Errorf("invalid did:plc format: missing identifier") 670 + } 671 + // Base32 uses lowercase a-z and 2-7 672 + for _, c := range identifier { 673 + if !((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7')) { 674 + return fmt.Errorf("invalid did:plc format: identifier contains invalid characters") 675 + } 676 + } 677 + return nil 678 + 679 + case strings.HasPrefix(did, "did:web:"): 680 + // did:web: format - domain-based identifier 681 + domain := strings.TrimPrefix(did, "did:web:") 682 + if len(domain) == 0 { 683 + return fmt.Errorf("invalid did:web format: missing domain") 684 + } 685 + // Basic domain validation - must contain at least one dot or be localhost 686 + if !strings.Contains(domain, ".") && domain != "localhost" { 687 + return fmt.Errorf("invalid did:web format: invalid domain") 688 + } 689 + return nil 690 + 691 + default: 692 + return fmt.Errorf("unsupported DID method: must be did:plc or did:web") 693 + } 560 694 }
+277
internal/core/posts/service_author_posts_test.go
··· 1 + package posts 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + ) 7 + 8 + // mockRepository implements Repository for testing 9 + type mockRepository struct { 10 + getByAuthorFunc func(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error) 11 + } 12 + 13 + func (m *mockRepository) Create(ctx context.Context, post *Post) error { 14 + return nil 15 + } 16 + 17 + func (m *mockRepository) GetByURI(ctx context.Context, uri string) (*Post, error) { 18 + return nil, nil 19 + } 20 + 21 + func (m *mockRepository) GetByAuthor(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error) { 22 + if m.getByAuthorFunc != nil { 23 + return m.getByAuthorFunc(ctx, req) 24 + } 25 + return []*PostView{}, nil, nil 26 + } 27 + 28 + func (m *mockRepository) SoftDelete(ctx context.Context, uri string) error { 29 + return nil 30 + } 31 + 32 + func (m *mockRepository) Update(ctx context.Context, post *Post) error { 33 + return nil 34 + } 35 + 36 + func (m *mockRepository) UpdateVoteCounts(ctx context.Context, uri string, upvotes, downvotes int) error { 37 + return nil 38 + } 39 + 40 + func TestValidateDIDFormat(t *testing.T) { 41 + tests := []struct { 42 + name string 43 + did string 44 + wantErr bool 45 + errMsg string 46 + }{ 47 + { 48 + name: "valid did:plc", 49 + did: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 50 + wantErr: false, 51 + }, 52 + { 53 + name: "valid did:web", 54 + did: "did:web:example.com", 55 + wantErr: false, 56 + }, 57 + { 58 + name: "valid did:web with subdomain", 59 + did: "did:web:bsky.social", 60 + wantErr: false, 61 + }, 62 + { 63 + name: "valid did:web localhost", 64 + did: "did:web:localhost", 65 + wantErr: false, 66 + }, 67 + { 68 + name: "invalid - missing method", 69 + did: "did:", 70 + wantErr: true, 71 + errMsg: "unsupported DID method", 72 + }, 73 + { 74 + name: "invalid - unsupported method", 75 + did: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 76 + wantErr: true, 77 + errMsg: "unsupported DID method", 78 + }, 79 + { 80 + name: "invalid did:plc - empty identifier", 81 + did: "did:plc:", 82 + wantErr: true, 83 + errMsg: "missing identifier", 84 + }, 85 + { 86 + name: "invalid did:plc - uppercase chars", 87 + did: "did:plc:UPPERCASE", 88 + wantErr: true, 89 + errMsg: "invalid characters", 90 + }, 91 + { 92 + name: "invalid did:plc - numbers outside base32", 93 + did: "did:plc:abc0189", 94 + wantErr: true, 95 + errMsg: "invalid characters", 96 + }, 97 + { 98 + name: "invalid did:web - empty domain", 99 + did: "did:web:", 100 + wantErr: true, 101 + errMsg: "missing domain", 102 + }, 103 + { 104 + name: "invalid did:web - no dot in domain", 105 + did: "did:web:nodot", 106 + wantErr: true, 107 + errMsg: "invalid domain", 108 + }, 109 + { 110 + name: "invalid - not a DID", 111 + did: "notadid", 112 + wantErr: true, 113 + errMsg: "unsupported DID method", 114 + }, 115 + { 116 + name: "invalid - too long", 117 + did: "did:plc:" + string(make([]byte, 2100)), 118 + wantErr: true, 119 + errMsg: "exceeds maximum length", 120 + }, 121 + } 122 + 123 + for _, tt := range tests { 124 + t.Run(tt.name, func(t *testing.T) { 125 + err := validateDIDFormat(tt.did) 126 + if tt.wantErr { 127 + if err == nil { 128 + t.Errorf("validateDIDFormat(%q) = nil, want error containing %q", tt.did, tt.errMsg) 129 + } else if tt.errMsg != "" && !testContains(err.Error(), tt.errMsg) { 130 + t.Errorf("validateDIDFormat(%q) = %v, want error containing %q", tt.did, err, tt.errMsg) 131 + } 132 + } else { 133 + if err != nil { 134 + t.Errorf("validateDIDFormat(%q) = %v, want nil", tt.did, err) 135 + } 136 + } 137 + }) 138 + } 139 + } 140 + 141 + // helper function for contains check (named testContains to avoid conflict with package function) 142 + func testContains(s, substr string) bool { 143 + for i := 0; i <= len(s)-len(substr); i++ { 144 + if s[i:i+len(substr)] == substr { 145 + return true 146 + } 147 + } 148 + return false 149 + } 150 + 151 + func TestValidateGetAuthorPostsRequest(t *testing.T) { 152 + // Create a minimal service for testing validation 153 + // We only need to test the validation logic, not the full service 154 + 155 + tests := []struct { 156 + name string 157 + req GetAuthorPostsRequest 158 + wantErr bool 159 + errMsg string 160 + }{ 161 + { 162 + name: "valid request - minimal", 163 + req: GetAuthorPostsRequest{ 164 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 165 + }, 166 + wantErr: false, 167 + }, 168 + { 169 + name: "valid request - with filter", 170 + req: GetAuthorPostsRequest{ 171 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 172 + Filter: FilterPostsWithMedia, 173 + }, 174 + wantErr: false, 175 + }, 176 + { 177 + name: "valid request - with limit", 178 + req: GetAuthorPostsRequest{ 179 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 180 + Limit: 25, 181 + }, 182 + wantErr: false, 183 + }, 184 + { 185 + name: "invalid - empty actor", 186 + req: GetAuthorPostsRequest{ 187 + ActorDID: "", 188 + }, 189 + wantErr: true, 190 + errMsg: "actor is required", 191 + }, 192 + { 193 + name: "invalid - bad DID format", 194 + req: GetAuthorPostsRequest{ 195 + ActorDID: "notadid", 196 + }, 197 + wantErr: true, 198 + errMsg: "unsupported DID method", 199 + }, 200 + { 201 + name: "invalid - unknown filter", 202 + req: GetAuthorPostsRequest{ 203 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 204 + Filter: "unknown_filter", 205 + }, 206 + wantErr: true, 207 + errMsg: "filter must be one of", 208 + }, 209 + } 210 + 211 + for _, tt := range tests { 212 + t.Run(tt.name, func(t *testing.T) { 213 + // Create service with nil dependencies - we only test validation 214 + s := &postService{} 215 + err := s.validateGetAuthorPostsRequest(&tt.req) 216 + 217 + if tt.wantErr { 218 + if err == nil { 219 + t.Errorf("validateGetAuthorPostsRequest() = nil, want error containing %q", tt.errMsg) 220 + } else if tt.errMsg != "" && !testContains(err.Error(), tt.errMsg) { 221 + t.Errorf("validateGetAuthorPostsRequest() = %v, want error containing %q", err, tt.errMsg) 222 + } 223 + } else { 224 + if err != nil { 225 + t.Errorf("validateGetAuthorPostsRequest() = %v, want nil", err) 226 + } 227 + } 228 + }) 229 + } 230 + } 231 + 232 + func TestValidateGetAuthorPostsRequest_DefaultsSet(t *testing.T) { 233 + s := &postService{} 234 + 235 + // Test that defaults are set 236 + t.Run("filter defaults to posts_with_replies", func(t *testing.T) { 237 + req := GetAuthorPostsRequest{ 238 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 239 + Filter: "", // empty 240 + } 241 + err := s.validateGetAuthorPostsRequest(&req) 242 + if err != nil { 243 + t.Fatalf("unexpected error: %v", err) 244 + } 245 + if req.Filter != FilterPostsWithReplies { 246 + t.Errorf("Filter = %q, want %q", req.Filter, FilterPostsWithReplies) 247 + } 248 + }) 249 + 250 + t.Run("limit defaults to 50 when 0", func(t *testing.T) { 251 + req := GetAuthorPostsRequest{ 252 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 253 + Limit: 0, 254 + } 255 + err := s.validateGetAuthorPostsRequest(&req) 256 + if err != nil { 257 + t.Fatalf("unexpected error: %v", err) 258 + } 259 + if req.Limit != 50 { 260 + t.Errorf("Limit = %d, want 50", req.Limit) 261 + } 262 + }) 263 + 264 + t.Run("limit capped at 100", func(t *testing.T) { 265 + req := GetAuthorPostsRequest{ 266 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 267 + Limit: 200, 268 + } 269 + err := s.validateGetAuthorPostsRequest(&req) 270 + if err != nil { 271 + t.Fatalf("unexpected error: %v", err) 272 + } 273 + if req.Limit != 100 { 274 + t.Errorf("Limit = %d, want 100", req.Limit) 275 + } 276 + }) 277 + }
+12
internal/db/migrations/026_add_author_posts_index.sql
··· 1 + -- +goose Up 2 + -- +goose NO TRANSACTION 3 + -- Add optimized index for author posts queries with soft delete filter 4 + -- This supports the social.coves.actor.getPosts endpoint which retrieves posts by author 5 + -- The existing idx_posts_author doesn't filter deleted posts, causing full index scans 6 + CREATE INDEX CONCURRENTLY idx_posts_author_created 7 + ON posts(author_did, created_at DESC) 8 + WHERE deleted_at IS NULL; 9 + 10 + -- +goose Down 11 + -- +goose NO TRANSACTION 12 + DROP INDEX CONCURRENTLY IF EXISTS idx_posts_author_created;
+292 -3
internal/db/postgres/post_repo.go
··· 1 + package postgres 1 2 2 - 3 - 4 - "Coves/internal/core/posts" 3 + import ( 5 4 "context" 6 5 "database/sql" 6 + "encoding/base64" 7 + "encoding/json" 7 8 "fmt" 9 + "log" 8 10 "strings" 11 + "time" 12 + 13 + "Coves/internal/core/blobs" 14 + "Coves/internal/core/communities" 15 + "Coves/internal/core/posts" 9 16 ) 10 17 11 18 type postgresPostRepo struct { ··· 128 135 129 136 return &post, nil 130 137 } 138 + 139 + // GetByAuthor retrieves posts by author with filtering and pagination 140 + // Supports filter options: posts_with_replies (default), posts_no_replies, posts_with_media 141 + // Uses cursor-based pagination with created_at + uri for stable ordering 142 + // Returns []*PostView, next cursor, and error 143 + func (r *postgresPostRepo) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) { 144 + // Build WHERE clauses based on filters 145 + whereConditions := []string{ 146 + "p.author_did = $1", 147 + "p.deleted_at IS NULL", 148 + } 149 + args := []interface{}{req.ActorDID} 150 + paramIndex := 2 151 + 152 + // Optional community filter 153 + if req.Community != "" { 154 + whereConditions = append(whereConditions, fmt.Sprintf("p.community_did = $%d", paramIndex)) 155 + args = append(args, req.Community) 156 + paramIndex++ 157 + } 158 + 159 + // Filter by post type 160 + // Design note: Coves architecture separates posts from comments (unlike Bluesky where 161 + // posts can be replies to other posts). The posts_no_replies filter exists for API 162 + // compatibility with Bluesky's getAuthorFeed, but is intentionally a no-op in Coves 163 + // since all Coves posts are top-level (comments are stored in a separate table). 164 + switch req.Filter { 165 + case posts.FilterPostsWithMedia: 166 + whereConditions = append(whereConditions, "p.embed IS NOT NULL") 167 + case posts.FilterPostsNoReplies: 168 + // No-op: All Coves posts are top-level; comments are in the comments table. 169 + // This filter exists for Bluesky API compatibility. 170 + case posts.FilterPostsWithReplies, "": 171 + // Default: return all posts (no additional filter needed) 172 + } 173 + 174 + // Build cursor filter for pagination 175 + cursorFilter, cursorArgs, cursorErr := r.parseAuthorPostsCursor(req.Cursor, paramIndex) 176 + if cursorErr != nil { 177 + return nil, nil, cursorErr 178 + } 179 + if cursorFilter != "" { 180 + whereConditions = append(whereConditions, cursorFilter) 181 + args = append(args, cursorArgs...) 182 + paramIndex += len(cursorArgs) 183 + } 184 + 185 + // Add limit to args 186 + limit := req.Limit 187 + if limit <= 0 { 188 + limit = 50 // default 189 + } 190 + if limit > 100 { 191 + limit = 100 // max 192 + } 193 + args = append(args, limit+1) // +1 to check for next page 194 + 195 + whereClause := strings.Join(whereConditions, " AND ") 196 + 197 + query := fmt.Sprintf(` 198 + SELECT 199 + p.uri, p.cid, p.rkey, 200 + p.author_did, u.handle as author_handle, 201 + p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar, c.pds_url as community_pds_url, 202 + p.title, p.content, p.content_facets, p.embed, p.content_labels, 203 + p.created_at, p.edited_at, p.indexed_at, 204 + p.upvote_count, p.downvote_count, p.score, p.comment_count 205 + FROM posts p 206 + INNER JOIN users u ON p.author_did = u.did 207 + INNER JOIN communities c ON p.community_did = c.did 208 + WHERE %s 209 + ORDER BY p.created_at DESC, p.uri DESC 210 + LIMIT $%d 211 + `, whereClause, paramIndex) 212 + 213 + // Execute query 214 + rows, err := r.db.QueryContext(ctx, query, args...) 215 + if err != nil { 216 + return nil, nil, fmt.Errorf("failed to query author posts: %w", err) 217 + } 218 + defer func() { 219 + if err := rows.Close(); err != nil { 220 + log.Printf("WARN: failed to close rows: %v", err) 221 + } 222 + }() 223 + 224 + // Scan results 225 + var postViews []*posts.PostView 226 + for rows.Next() { 227 + postView, err := r.scanAuthorPost(rows) 228 + if err != nil { 229 + return nil, nil, fmt.Errorf("failed to scan author post: %w", err) 230 + } 231 + postViews = append(postViews, postView) 232 + } 233 + 234 + if err := rows.Err(); err != nil { 235 + return nil, nil, fmt.Errorf("error iterating author posts results: %w", err) 236 + } 237 + 238 + // Handle pagination cursor 239 + var cursor *string 240 + if len(postViews) > limit && limit > 0 { 241 + postViews = postViews[:limit] 242 + lastPost := postViews[len(postViews)-1] 243 + cursorStr := r.buildAuthorPostsCursor(lastPost) 244 + cursor = &cursorStr 245 + } 246 + 247 + return postViews, cursor, nil 248 + } 249 + 250 + // parseAuthorPostsCursor decodes pagination cursor for author posts 251 + // Cursor format: base64(created_at|uri) 252 + // Uses simple | delimiter since this is an internal cursor (not signed like feed cursors) 253 + // Returns filter clause, arguments, and error. Error is returned for malformed cursors 254 + // to provide clear feedback rather than silently returning the first page. 255 + func (r *postgresPostRepo) parseAuthorPostsCursor(cursor *string, paramOffset int) (string, []interface{}, error) { 256 + if cursor == nil || *cursor == "" { 257 + return "", nil, nil 258 + } 259 + 260 + // Validate cursor size to prevent DoS via massive base64 strings 261 + const maxCursorSize = 512 262 + if len(*cursor) > maxCursorSize { 263 + return "", nil, fmt.Errorf("%w: cursor exceeds maximum length", posts.ErrInvalidCursor) 264 + } 265 + 266 + // Decode base64 cursor 267 + decoded, err := base64.URLEncoding.DecodeString(*cursor) 268 + if err != nil { 269 + return "", nil, fmt.Errorf("%w: invalid base64 encoding", posts.ErrInvalidCursor) 270 + } 271 + 272 + // Parse cursor: created_at|uri 273 + parts := strings.Split(string(decoded), "|") 274 + if len(parts) != 2 { 275 + return "", nil, fmt.Errorf("%w: malformed cursor format", posts.ErrInvalidCursor) 276 + } 277 + 278 + createdAt := parts[0] 279 + uri := parts[1] 280 + 281 + // Validate timestamp format 282 + if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil { 283 + return "", nil, fmt.Errorf("%w: invalid timestamp in cursor", posts.ErrInvalidCursor) 284 + } 285 + 286 + // Validate URI format (must be AT-URI) 287 + if !strings.HasPrefix(uri, "at://") { 288 + return "", nil, fmt.Errorf("%w: invalid URI format in cursor", posts.ErrInvalidCursor) 289 + } 290 + 291 + // Use composite key comparison for stable cursor pagination 292 + // (created_at, uri) < (cursor_created_at, cursor_uri) 293 + filter := fmt.Sprintf("(p.created_at < $%d OR (p.created_at = $%d AND p.uri < $%d))", 294 + paramOffset, paramOffset, paramOffset+1) 295 + return filter, []interface{}{createdAt, uri}, nil 296 + } 297 + 298 + // buildAuthorPostsCursor creates pagination cursor from last post 299 + // Cursor format: base64(created_at|uri) 300 + func (r *postgresPostRepo) buildAuthorPostsCursor(post *posts.PostView) string { 301 + cursorStr := fmt.Sprintf("%s|%s", post.CreatedAt.Format(time.RFC3339Nano), post.URI) 302 + return base64.URLEncoding.EncodeToString([]byte(cursorStr)) 303 + } 304 + 305 + // scanAuthorPost scans a database row into a PostView for author posts query 306 + func (r *postgresPostRepo) scanAuthorPost(rows *sql.Rows) (*posts.PostView, error) { 307 + var ( 308 + postView posts.PostView 309 + authorView posts.AuthorView 310 + communityRef posts.CommunityRef 311 + title, content sql.NullString 312 + facets, embed sql.NullString 313 + labelsJSON sql.NullString 314 + editedAt sql.NullTime 315 + communityHandle sql.NullString 316 + communityAvatar sql.NullString 317 + communityPDSURL sql.NullString 318 + ) 319 + 320 + err := rows.Scan( 321 + &postView.URI, &postView.CID, &postView.RKey, 322 + &authorView.DID, &authorView.Handle, 323 + &communityRef.DID, &communityHandle, &communityRef.Name, &communityAvatar, &communityPDSURL, 324 + &title, &content, &facets, &embed, &labelsJSON, 325 + &postView.CreatedAt, &editedAt, &postView.IndexedAt, 326 + &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, 327 + ) 328 + if err != nil { 329 + return nil, err 330 + } 331 + 332 + // Build author view 333 + postView.Author = &authorView 334 + 335 + // Build community ref 336 + if communityHandle.Valid { 337 + communityRef.Handle = communityHandle.String 338 + } 339 + // Hydrate avatar CID to URL using image proxy config (avatar_small preset for post views) 340 + if avatarURL := blobs.HydrateImageURL(communities.GetImageProxyConfig(), communityPDSURL.String, communityRef.DID, communityAvatar.String, "avatar_small"); avatarURL != "" { 341 + communityRef.Avatar = &avatarURL 342 + } 343 + if communityPDSURL.Valid { 344 + communityRef.PDSURL = communityPDSURL.String 345 + } 346 + postView.Community = &communityRef 347 + 348 + // Set optional fields 349 + if title.Valid { 350 + postView.Title = &title.String 351 + } 352 + if content.Valid { 353 + postView.Text = &content.String 354 + } 355 + if editedAt.Valid { 356 + postView.EditedAt = &editedAt.Time 357 + } 358 + 359 + // Parse facets JSON 360 + if facets.Valid { 361 + var facetArray []interface{} 362 + if err := json.Unmarshal([]byte(facets.String), &facetArray); err != nil { 363 + return nil, fmt.Errorf("failed to parse facets JSON for post %s: %w", postView.URI, err) 364 + } 365 + postView.TextFacets = facetArray 366 + } 367 + 368 + // Parse embed JSON 369 + if embed.Valid { 370 + var embedData interface{} 371 + if err := json.Unmarshal([]byte(embed.String), &embedData); err != nil { 372 + return nil, fmt.Errorf("failed to parse embed JSON for post %s: %w", postView.URI, err) 373 + } 374 + postView.Embed = embedData 375 + } 376 + 377 + // Build stats 378 + postView.Stats = &posts.PostStats{ 379 + Upvotes: postView.UpvoteCount, 380 + Downvotes: postView.DownvoteCount, 381 + Score: postView.Score, 382 + CommentCount: postView.CommentCount, 383 + } 384 + 385 + // Build the record (required by lexicon) 386 + record := map[string]interface{}{ 387 + "$type": "social.coves.community.post", 388 + "community": communityRef.DID, 389 + "author": authorView.DID, 390 + "createdAt": postView.CreatedAt.Format(time.RFC3339), 391 + } 392 + 393 + // Add optional fields to record if present 394 + if title.Valid { 395 + record["title"] = title.String 396 + } 397 + if content.Valid { 398 + record["content"] = content.String 399 + } 400 + // Reuse already-parsed facets and embed from PostView to avoid double parsing 401 + if facets.Valid { 402 + record["facets"] = postView.TextFacets 403 + } 404 + if embed.Valid { 405 + record["embed"] = postView.Embed 406 + } 407 + if labelsJSON.Valid { 408 + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 409 + var selfLabels posts.SelfLabels 410 + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err != nil { 411 + return nil, fmt.Errorf("failed to parse labels JSON for post %s: %w", postView.URI, err) 412 + } 413 + record["labels"] = selfLabels 414 + } 415 + 416 + postView.Record = record 417 + 418 + return &postView, nil 419 + }
+244
internal/db/postgres/post_repo_cursor_test.go
··· 1 + package postgres 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/base64" 7 + "testing" 8 + "time" 9 + 10 + "Coves/internal/core/posts" 11 + ) 12 + 13 + func TestParseAuthorPostsCursor(t *testing.T) { 14 + repo := &postgresPostRepo{db: nil} // db not needed for cursor parsing 15 + 16 + // Helper to create a valid cursor 17 + makeCursor := func(timestamp, uri string) string { 18 + return base64.URLEncoding.EncodeToString([]byte(timestamp + "|" + uri)) 19 + } 20 + 21 + validTimestamp := time.Now().Format(time.RFC3339Nano) 22 + validURI := "at://did:plc:test123/social.coves.community.post/abc123" 23 + 24 + tests := []struct { 25 + name string 26 + cursor *string 27 + wantFilter bool 28 + wantErr bool 29 + errMsg string 30 + }{ 31 + { 32 + name: "nil cursor returns empty filter", 33 + cursor: nil, 34 + wantFilter: false, 35 + wantErr: false, 36 + }, 37 + { 38 + name: "empty cursor returns empty filter", 39 + cursor: strPtr(""), 40 + wantFilter: false, 41 + wantErr: false, 42 + }, 43 + { 44 + name: "valid cursor", 45 + cursor: strPtr(makeCursor(validTimestamp, validURI)), 46 + wantFilter: true, 47 + wantErr: false, 48 + }, 49 + { 50 + name: "cursor too long", 51 + cursor: strPtr(makeCursor(validTimestamp, string(make([]byte, 600)))), 52 + wantFilter: false, 53 + wantErr: true, 54 + errMsg: "exceeds maximum length", 55 + }, 56 + { 57 + name: "invalid base64", 58 + cursor: strPtr("not-valid-base64!!!"), 59 + wantFilter: false, 60 + wantErr: true, 61 + errMsg: "invalid base64", 62 + }, 63 + { 64 + name: "missing pipe delimiter", 65 + cursor: strPtr(base64.URLEncoding.EncodeToString([]byte("no-pipe-here"))), 66 + wantFilter: false, 67 + wantErr: true, 68 + errMsg: "malformed cursor format", 69 + }, 70 + { 71 + name: "invalid timestamp", 72 + cursor: strPtr(base64.URLEncoding.EncodeToString([]byte("not-a-timestamp|" + validURI))), 73 + wantFilter: false, 74 + wantErr: true, 75 + errMsg: "invalid timestamp", 76 + }, 77 + { 78 + name: "invalid URI format", 79 + cursor: strPtr(base64.URLEncoding.EncodeToString([]byte(validTimestamp + "|not-an-at-uri"))), 80 + wantFilter: false, 81 + wantErr: true, 82 + errMsg: "invalid URI format", 83 + }, 84 + } 85 + 86 + for _, tt := range tests { 87 + t.Run(tt.name, func(t *testing.T) { 88 + filter, args, err := repo.parseAuthorPostsCursor(tt.cursor, 1) 89 + 90 + if tt.wantErr { 91 + if err == nil { 92 + t.Errorf("parseAuthorPostsCursor() = nil error, want error containing %q", tt.errMsg) 93 + } else if !posts.IsValidationError(err) && err != posts.ErrInvalidCursor { 94 + // Check if error wraps ErrInvalidCursor 95 + if tt.errMsg != "" && !containsStr(err.Error(), tt.errMsg) { 96 + t.Errorf("parseAuthorPostsCursor() error = %v, want error containing %q", err, tt.errMsg) 97 + } 98 + } 99 + } else { 100 + if err != nil { 101 + t.Errorf("parseAuthorPostsCursor() = %v, want nil error", err) 102 + } 103 + } 104 + 105 + if tt.wantFilter { 106 + if filter == "" { 107 + t.Error("parseAuthorPostsCursor() filter = empty, want non-empty filter") 108 + } 109 + if len(args) == 0 { 110 + t.Error("parseAuthorPostsCursor() args = empty, want non-empty args") 111 + } 112 + } else if !tt.wantErr { 113 + if filter != "" { 114 + t.Errorf("parseAuthorPostsCursor() filter = %q, want empty", filter) 115 + } 116 + } 117 + }) 118 + } 119 + } 120 + 121 + func TestBuildAuthorPostsCursor(t *testing.T) { 122 + repo := &postgresPostRepo{db: nil} 123 + 124 + now := time.Now() 125 + post := &posts.PostView{ 126 + URI: "at://did:plc:test123/social.coves.community.post/abc123", 127 + CreatedAt: now, 128 + } 129 + 130 + cursor := repo.buildAuthorPostsCursor(post) 131 + 132 + // Decode and verify cursor 133 + decoded, err := base64.URLEncoding.DecodeString(cursor) 134 + if err != nil { 135 + t.Fatalf("Failed to decode cursor: %v", err) 136 + } 137 + 138 + // Should contain timestamp|uri 139 + decodedStr := string(decoded) 140 + if !containsStr(decodedStr, "|") { 141 + t.Errorf("Cursor should contain '|' delimiter, got %q", decodedStr) 142 + } 143 + if !containsStr(decodedStr, post.URI) { 144 + t.Errorf("Cursor should contain URI, got %q", decodedStr) 145 + } 146 + if !containsStr(decodedStr, now.Format(time.RFC3339Nano)) { 147 + t.Errorf("Cursor should contain timestamp, got %q", decodedStr) 148 + } 149 + } 150 + 151 + func TestBuildAndParseCursorRoundTrip(t *testing.T) { 152 + repo := &postgresPostRepo{db: nil} 153 + 154 + now := time.Now() 155 + post := &posts.PostView{ 156 + URI: "at://did:plc:test123/social.coves.community.post/abc123", 157 + CreatedAt: now, 158 + } 159 + 160 + // Build cursor 161 + cursor := repo.buildAuthorPostsCursor(post) 162 + 163 + // Parse it back 164 + filter, args, err := repo.parseAuthorPostsCursor(&cursor, 1) 165 + 166 + if err != nil { 167 + t.Fatalf("Failed to parse cursor: %v", err) 168 + } 169 + 170 + if filter == "" { 171 + t.Error("Expected non-empty filter") 172 + } 173 + 174 + if len(args) != 2 { 175 + t.Errorf("Expected 2 args, got %d", len(args)) 176 + } 177 + 178 + // First arg should be timestamp string 179 + if ts, ok := args[0].(string); ok { 180 + parsedTime, err := time.Parse(time.RFC3339Nano, ts) 181 + if err != nil { 182 + t.Errorf("First arg is not a valid timestamp: %v", err) 183 + } 184 + if !parsedTime.Equal(now) { 185 + t.Errorf("Timestamp mismatch: got %v, want %v", parsedTime, now) 186 + } 187 + } else { 188 + t.Errorf("First arg should be string, got %T", args[0]) 189 + } 190 + 191 + // Second arg should be URI 192 + if uri, ok := args[1].(string); ok { 193 + if uri != post.URI { 194 + t.Errorf("URI mismatch: got %q, want %q", uri, post.URI) 195 + } 196 + } else { 197 + t.Errorf("Second arg should be string, got %T", args[1]) 198 + } 199 + } 200 + 201 + // Helper functions 202 + func strPtr(s string) *string { 203 + return &s 204 + } 205 + 206 + func containsStr(s, substr string) bool { 207 + for i := 0; i <= len(s)-len(substr); i++ { 208 + if s[i:i+len(substr)] == substr { 209 + return true 210 + } 211 + } 212 + return false 213 + } 214 + 215 + // Ensure the mock repository satisfies the interface 216 + var _ posts.Repository = (*mockPostRepository)(nil) 217 + 218 + type mockPostRepository struct { 219 + db *sql.DB 220 + } 221 + 222 + func (m *mockPostRepository) Create(ctx context.Context, post *posts.Post) error { 223 + return nil 224 + } 225 + 226 + func (m *mockPostRepository) GetByURI(ctx context.Context, uri string) (*posts.Post, error) { 227 + return nil, nil 228 + } 229 + 230 + func (m *mockPostRepository) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) { 231 + return nil, nil, nil 232 + } 233 + 234 + func (m *mockPostRepository) SoftDelete(ctx context.Context, uri string) error { 235 + return nil 236 + } 237 + 238 + func (m *mockPostRepository) Update(ctx context.Context, post *posts.Post) error { 239 + return nil 240 + } 241 + 242 + func (m *mockPostRepository) UpdateVoteCounts(ctx context.Context, uri string, upvotes, downvotes int) error { 243 + return nil 244 + }
+739
tests/integration/author_posts_e2e_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/api/routes" 5 + "Coves/internal/atproto/identity" 6 + "Coves/internal/atproto/jetstream" 7 + "Coves/internal/core/communities" 8 + "Coves/internal/core/posts" 9 + "Coves/internal/core/users" 10 + "Coves/internal/core/votes" 11 + "Coves/internal/db/postgres" 12 + "context" 13 + "database/sql" 14 + "encoding/json" 15 + "fmt" 16 + "io" 17 + "net/http" 18 + "net/http/httptest" 19 + "os" 20 + "testing" 21 + "time" 22 + 23 + "github.com/go-chi/chi/v5" 24 + _ "github.com/lib/pq" 25 + "github.com/pressly/goose/v3" 26 + ) 27 + 28 + // TestGetAuthorPosts_E2E_Success tests the full author posts flow with real PDS 29 + // Flow: Create user on PDS โ†’ Create posts โ†’ Query via XRPC โ†’ Verify response 30 + func TestGetAuthorPosts_E2E_Success(t *testing.T) { 31 + if testing.Short() { 32 + t.Skip("Skipping E2E test in short mode") 33 + } 34 + 35 + // Setup test database 36 + dbURL := os.Getenv("TEST_DATABASE_URL") 37 + if dbURL == "" { 38 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 39 + } 40 + 41 + db, err := sql.Open("postgres", dbURL) 42 + if err != nil { 43 + t.Fatalf("Failed to connect to test database: %v", err) 44 + } 45 + defer func() { _ = db.Close() }() 46 + 47 + // Run migrations 48 + if dialectErr := goose.SetDialect("postgres"); dialectErr != nil { 49 + t.Fatalf("Failed to set goose dialect: %v", dialectErr) 50 + } 51 + if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil { 52 + t.Fatalf("Failed to run migrations: %v", migrateErr) 53 + } 54 + 55 + // Check if PDS is running 56 + pdsURL := getTestPDSURL() 57 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 58 + if err != nil { 59 + t.Skipf("PDS not running at %s: %v", pdsURL, err) 60 + } 61 + _ = healthResp.Body.Close() 62 + 63 + ctx := context.Background() 64 + 65 + // Setup repositories 66 + postRepo := postgres.NewPostRepository(db) 67 + userRepo := postgres.NewUserRepository(db) 68 + communityRepo := postgres.NewCommunityRepository(db) 69 + voteRepo := postgres.NewVoteRepository(db) 70 + 71 + // Setup services 72 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 73 + userService := users.NewUserService(userRepo, resolver, pdsURL) 74 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, getTestInstanceDID(), "", nil, nil, nil) 75 + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) 76 + voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 77 + 78 + // Create test user on PDS 79 + testUserHandle := fmt.Sprintf("apt%d.local.coves.dev", time.Now().UnixNano()%1000000) 80 + testUserEmail := fmt.Sprintf("author-posts-%d@test.local", time.Now().Unix()) 81 + testUserPassword := "test-password-123" 82 + 83 + t.Logf("Creating test user on PDS: %s", testUserHandle) 84 + _, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword) 85 + if err != nil { 86 + t.Fatalf("Failed to create test user on PDS: %v", err) 87 + } 88 + t.Logf("Test user created: DID=%s", userDID) 89 + 90 + // Index user in AppView 91 + _ = createTestUser(t, db, testUserHandle, userDID) 92 + 93 + // Create test community 94 + testCommunityDID, err := createFeedTestCommunity(db, ctx, "author-posts-test", "owner.test") 95 + if err != nil { 96 + t.Fatalf("Failed to create test community: %v", err) 97 + } 98 + 99 + // Create multiple test posts for the user 100 + now := time.Now() 101 + postURIs := make([]string, 5) 102 + for i := 0; i < 5; i++ { 103 + postURIs[i] = createTestPost(t, db, testCommunityDID, userDID, fmt.Sprintf("Test Post %d", i+1), i*10, now.Add(-time.Duration(i)*time.Hour)) 104 + } 105 + t.Logf("Created %d test posts", len(postURIs)) 106 + 107 + // Setup OAuth middleware 108 + e2eAuth := NewE2EOAuthMiddleware() 109 + token := e2eAuth.AddUser(userDID) 110 + 111 + // Setup HTTP server with XRPC routes 112 + r := chi.NewRouter() 113 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 114 + httpServer := httptest.NewServer(r) 115 + defer httpServer.Close() 116 + 117 + // Test 1: Get posts by DID 118 + t.Run("Get posts by DID", func(t *testing.T) { 119 + req, _ := http.NewRequest(http.MethodGet, 120 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&limit=10", httpServer.URL, userDID), nil) 121 + req.Header.Set("Authorization", "Bearer "+token) 122 + 123 + resp, err := http.DefaultClient.Do(req) 124 + if err != nil { 125 + t.Fatalf("Failed to GET author posts: %v", err) 126 + } 127 + defer func() { _ = resp.Body.Close() }() 128 + 129 + if resp.StatusCode != http.StatusOK { 130 + body, _ := io.ReadAll(resp.Body) 131 + t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) 132 + } 133 + 134 + var response posts.GetAuthorPostsResponse 135 + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { 136 + t.Fatalf("Failed to decode response: %v", decodeErr) 137 + } 138 + 139 + if len(response.Feed) != 5 { 140 + t.Errorf("Expected 5 posts, got %d", len(response.Feed)) 141 + } 142 + 143 + // Verify posts are returned in correct order (newest first) 144 + for i, feedPost := range response.Feed { 145 + if feedPost.Post == nil { 146 + t.Errorf("Post %d is nil", i) 147 + continue 148 + } 149 + t.Logf("Post %d: %s", i, feedPost.Post.URI) 150 + } 151 + 152 + t.Logf("SUCCESS: Retrieved %d posts for author %s", len(response.Feed), userDID) 153 + }) 154 + 155 + // Test 2: Get posts by handle 156 + t.Run("Get posts by handle", func(t *testing.T) { 157 + req, _ := http.NewRequest(http.MethodGet, 158 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&limit=5", httpServer.URL, testUserHandle), nil) 159 + 160 + resp, err := http.DefaultClient.Do(req) 161 + if err != nil { 162 + t.Fatalf("Failed to GET author posts by handle: %v", err) 163 + } 164 + defer func() { _ = resp.Body.Close() }() 165 + 166 + if resp.StatusCode != http.StatusOK { 167 + body, _ := io.ReadAll(resp.Body) 168 + t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) 169 + } 170 + 171 + var response posts.GetAuthorPostsResponse 172 + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { 173 + t.Fatalf("Failed to decode response: %v", decodeErr) 174 + } 175 + 176 + if len(response.Feed) != 5 { 177 + t.Errorf("Expected 5 posts, got %d", len(response.Feed)) 178 + } 179 + 180 + t.Logf("SUCCESS: Handle resolution worked - %s โ†’ %s", testUserHandle, userDID) 181 + }) 182 + 183 + // Test 3: Pagination with cursor 184 + t.Run("Pagination with cursor", func(t *testing.T) { 185 + // First page 186 + req, _ := http.NewRequest(http.MethodGet, 187 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&limit=3", httpServer.URL, userDID), nil) 188 + 189 + resp, err := http.DefaultClient.Do(req) 190 + if err != nil { 191 + t.Fatalf("Failed to GET first page: %v", err) 192 + } 193 + 194 + var firstPage posts.GetAuthorPostsResponse 195 + if decodeErr := json.NewDecoder(resp.Body).Decode(&firstPage); decodeErr != nil { 196 + t.Fatalf("Failed to decode first page: %v", decodeErr) 197 + } 198 + _ = resp.Body.Close() 199 + 200 + if len(firstPage.Feed) != 3 { 201 + t.Errorf("Expected 3 posts on first page, got %d", len(firstPage.Feed)) 202 + } 203 + if firstPage.Cursor == nil { 204 + t.Fatal("Expected cursor for pagination") 205 + } 206 + 207 + // Second page using cursor 208 + req2, _ := http.NewRequest(http.MethodGet, 209 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&limit=3&cursor=%s", 210 + httpServer.URL, userDID, *firstPage.Cursor), nil) 211 + 212 + resp2, err := http.DefaultClient.Do(req2) 213 + if err != nil { 214 + t.Fatalf("Failed to GET second page: %v", err) 215 + } 216 + defer func() { _ = resp2.Body.Close() }() 217 + 218 + var secondPage posts.GetAuthorPostsResponse 219 + if decodeErr := json.NewDecoder(resp2.Body).Decode(&secondPage); decodeErr != nil { 220 + t.Fatalf("Failed to decode second page: %v", decodeErr) 221 + } 222 + 223 + if len(secondPage.Feed) != 2 { 224 + t.Errorf("Expected 2 posts on second page, got %d", len(secondPage.Feed)) 225 + } 226 + 227 + // Verify no overlap between pages 228 + firstPageURIs := make(map[string]bool) 229 + for _, fp := range firstPage.Feed { 230 + firstPageURIs[fp.Post.URI] = true 231 + } 232 + for _, fp := range secondPage.Feed { 233 + if firstPageURIs[fp.Post.URI] { 234 + t.Errorf("Duplicate post in second page: %s", fp.Post.URI) 235 + } 236 + } 237 + 238 + t.Logf("SUCCESS: Pagination working - page 1: %d posts, page 2: %d posts", 239 + len(firstPage.Feed), len(secondPage.Feed)) 240 + }) 241 + 242 + // Test 4: Actor not found 243 + t.Run("Actor not found", func(t *testing.T) { 244 + req, _ := http.NewRequest(http.MethodGet, 245 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s", httpServer.URL, "did:plc:nonexistent123"), nil) 246 + 247 + resp, err := http.DefaultClient.Do(req) 248 + if err != nil { 249 + t.Fatalf("Failed to send request: %v", err) 250 + } 251 + defer func() { _ = resp.Body.Close() }() 252 + 253 + // The actor exists as a valid DID format but has no posts - should return empty feed 254 + // If you want 404, you'd need a user existence check in the service 255 + // For now, we expect 200 with empty feed (Bluesky-compatible behavior) 256 + if resp.StatusCode != http.StatusOK { 257 + body, _ := io.ReadAll(resp.Body) 258 + t.Logf("Response: %s", string(body)) 259 + } 260 + 261 + t.Logf("SUCCESS: Non-existent actor handled correctly") 262 + }) 263 + 264 + t.Logf("\nE2E AUTHOR POSTS FLOW COMPLETE:") 265 + t.Logf(" Created user on PDS") 266 + t.Logf(" Indexed 5 posts in AppView") 267 + t.Logf(" Queried by DID") 268 + t.Logf(" Queried by handle (with resolution)") 269 + t.Logf(" Tested pagination") 270 + t.Logf(" Tested error handling") 271 + } 272 + 273 + // TestGetAuthorPosts_FilterLogic tests the different filter options 274 + func TestGetAuthorPosts_FilterLogic(t *testing.T) { 275 + if testing.Short() { 276 + t.Skip("Skipping integration test in short mode") 277 + } 278 + 279 + db := setupTestDB(t) 280 + defer func() { _ = db.Close() }() 281 + 282 + ctx := context.Background() 283 + 284 + // Setup repositories and services 285 + postRepo := postgres.NewPostRepository(db) 286 + userRepo := postgres.NewUserRepository(db) 287 + communityRepo := postgres.NewCommunityRepository(db) 288 + voteRepo := postgres.NewVoteRepository(db) 289 + 290 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 291 + userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) 292 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil, nil) 293 + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) 294 + voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 295 + 296 + // Create test user (did:plc uses base32: a-z, 2-7) 297 + testUserDID := "did:plc:filtertestabcd" 298 + _ = createTestUser(t, db, "filtertest.test", testUserDID) 299 + 300 + // Create test community 301 + testCommunityDID, _ := createFeedTestCommunity(db, ctx, "filter-test", "owner.test") 302 + 303 + // Create posts with and without embeds 304 + now := time.Now() 305 + 306 + // Create post without embed 307 + createTestPost(t, db, testCommunityDID, testUserDID, "Post without embed", 10, now) 308 + 309 + // Create post with embed (need to insert directly with embed field) 310 + embedJSON := `{"$type":"social.coves.embed.external","external":{"uri":"https://example.com"}}` 311 + _, err := db.ExecContext(ctx, ` 312 + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, embed, created_at, score) 313 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 20) 314 + `, 315 + fmt.Sprintf("at://%s/social.coves.community.post/embed-post", testCommunityDID), 316 + "bafyembed", "embed-post", testUserDID, testCommunityDID, 317 + "Post with embed", embedJSON, now.Add(-1*time.Hour)) 318 + if err != nil { 319 + t.Fatalf("Failed to create post with embed: %v", err) 320 + } 321 + 322 + // Setup HTTP server 323 + e2eAuth := NewE2EOAuthMiddleware() 324 + r := chi.NewRouter() 325 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 326 + httpServer := httptest.NewServer(r) 327 + defer httpServer.Close() 328 + 329 + // Test: posts_with_media filter 330 + t.Run("Filter posts_with_media", func(t *testing.T) { 331 + req, _ := http.NewRequest(http.MethodGet, 332 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&filter=posts_with_media", 333 + httpServer.URL, testUserDID), nil) 334 + 335 + resp, err := http.DefaultClient.Do(req) 336 + if err != nil { 337 + t.Fatalf("Failed to GET filtered posts: %v", err) 338 + } 339 + defer func() { _ = resp.Body.Close() }() 340 + 341 + if resp.StatusCode != http.StatusOK { 342 + body, _ := io.ReadAll(resp.Body) 343 + t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) 344 + } 345 + 346 + var response posts.GetAuthorPostsResponse 347 + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { 348 + t.Fatalf("Failed to decode response: %v", decodeErr) 349 + } 350 + 351 + // Should only return the post with embed 352 + if len(response.Feed) != 1 { 353 + t.Errorf("Expected 1 post with media, got %d", len(response.Feed)) 354 + } 355 + 356 + // Verify it's the post with embed 357 + if len(response.Feed) > 0 && response.Feed[0].Post != nil { 358 + if response.Feed[0].Post.Embed == nil { 359 + t.Error("Expected post with embed, but embed is nil") 360 + } 361 + } 362 + 363 + t.Logf("SUCCESS: posts_with_media filter returned %d posts", len(response.Feed)) 364 + }) 365 + 366 + // Test: posts_with_replies (default - returns all) 367 + t.Run("Filter posts_with_replies (default)", func(t *testing.T) { 368 + req, _ := http.NewRequest(http.MethodGet, 369 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&filter=posts_with_replies", 370 + httpServer.URL, testUserDID), nil) 371 + 372 + resp, err := http.DefaultClient.Do(req) 373 + if err != nil { 374 + t.Fatalf("Failed to GET filtered posts: %v", err) 375 + } 376 + defer func() { _ = resp.Body.Close() }() 377 + 378 + var response posts.GetAuthorPostsResponse 379 + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { 380 + t.Fatalf("Failed to decode response: %v", decodeErr) 381 + } 382 + 383 + // Should return all posts 384 + if len(response.Feed) != 2 { 385 + t.Errorf("Expected 2 posts, got %d", len(response.Feed)) 386 + } 387 + 388 + t.Logf("SUCCESS: posts_with_replies filter returned %d posts", len(response.Feed)) 389 + }) 390 + 391 + // Test: Invalid filter returns error 392 + t.Run("Invalid filter returns error", func(t *testing.T) { 393 + req, _ := http.NewRequest(http.MethodGet, 394 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&filter=invalid_filter", 395 + httpServer.URL, testUserDID), nil) 396 + 397 + resp, err := http.DefaultClient.Do(req) 398 + if err != nil { 399 + t.Fatalf("Failed to send request: %v", err) 400 + } 401 + defer func() { _ = resp.Body.Close() }() 402 + 403 + if resp.StatusCode != http.StatusBadRequest { 404 + body, _ := io.ReadAll(resp.Body) 405 + t.Errorf("Expected 400 for invalid filter, got %d: %s", resp.StatusCode, string(body)) 406 + } 407 + 408 + t.Logf("SUCCESS: Invalid filter correctly rejected") 409 + }) 410 + } 411 + 412 + // TestGetAuthorPosts_ServiceErrors tests error handling in the service layer 413 + func TestGetAuthorPosts_ServiceErrors(t *testing.T) { 414 + if testing.Short() { 415 + t.Skip("Skipping integration test in short mode") 416 + } 417 + 418 + db := setupTestDB(t) 419 + defer func() { _ = db.Close() }() 420 + 421 + ctx := context.Background() 422 + 423 + // Setup services 424 + postRepo := postgres.NewPostRepository(db) 425 + userRepo := postgres.NewUserRepository(db) 426 + communityRepo := postgres.NewCommunityRepository(db) 427 + voteRepo := postgres.NewVoteRepository(db) 428 + 429 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 430 + userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) 431 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil, nil) 432 + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) 433 + voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 434 + 435 + // Create test user and community 436 + testUserDID := "did:plc:serviceerrorabc" 437 + _ = createTestUser(t, db, "serviceerror.test", testUserDID) 438 + testCommunityDID, _ := createFeedTestCommunity(db, ctx, "serviceerror-test", "owner.test") 439 + 440 + // Create a test post 441 + createTestPost(t, db, testCommunityDID, testUserDID, "Test Post", 10, time.Now()) 442 + 443 + // Setup HTTP server 444 + e2eAuth := NewE2EOAuthMiddleware() 445 + r := chi.NewRouter() 446 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 447 + httpServer := httptest.NewServer(r) 448 + defer httpServer.Close() 449 + 450 + // Test: Missing actor parameter 451 + t.Run("Missing actor parameter", func(t *testing.T) { 452 + req, _ := http.NewRequest(http.MethodGet, 453 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts", httpServer.URL), nil) 454 + 455 + resp, err := http.DefaultClient.Do(req) 456 + if err != nil { 457 + t.Fatalf("Failed to send request: %v", err) 458 + } 459 + defer func() { _ = resp.Body.Close() }() 460 + 461 + if resp.StatusCode != http.StatusBadRequest { 462 + body, _ := io.ReadAll(resp.Body) 463 + t.Errorf("Expected 400 for missing actor, got %d: %s", resp.StatusCode, string(body)) 464 + } 465 + 466 + t.Logf("SUCCESS: Missing actor parameter correctly rejected") 467 + }) 468 + 469 + // Test: Invalid DID format 470 + t.Run("Invalid DID format", func(t *testing.T) { 471 + req, _ := http.NewRequest(http.MethodGet, 472 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s", httpServer.URL, "not-a-did"), nil) 473 + 474 + resp, err := http.DefaultClient.Do(req) 475 + if err != nil { 476 + t.Fatalf("Failed to send request: %v", err) 477 + } 478 + defer func() { _ = resp.Body.Close() }() 479 + 480 + // Invalid DIDs that don't resolve should return 404 (actor not found) 481 + if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusBadRequest { 482 + body, _ := io.ReadAll(resp.Body) 483 + t.Errorf("Expected 404 or 400 for invalid DID, got %d: %s", resp.StatusCode, string(body)) 484 + } 485 + 486 + t.Logf("SUCCESS: Invalid DID format handled") 487 + }) 488 + 489 + // Test: Invalid cursor 490 + t.Run("Invalid cursor", func(t *testing.T) { 491 + req, _ := http.NewRequest(http.MethodGet, 492 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&cursor=%s", 493 + httpServer.URL, testUserDID, "invalid-cursor-format"), nil) 494 + 495 + resp, err := http.DefaultClient.Do(req) 496 + if err != nil { 497 + t.Fatalf("Failed to send request: %v", err) 498 + } 499 + defer func() { _ = resp.Body.Close() }() 500 + 501 + if resp.StatusCode != http.StatusBadRequest { 502 + body, _ := io.ReadAll(resp.Body) 503 + t.Errorf("Expected 400 for invalid cursor, got %d: %s", resp.StatusCode, string(body)) 504 + } 505 + 506 + t.Logf("SUCCESS: Invalid cursor correctly rejected") 507 + }) 508 + 509 + // Test: Community filter with non-existent community 510 + t.Run("Non-existent community filter", func(t *testing.T) { 511 + req, _ := http.NewRequest(http.MethodGet, 512 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&community=%s", 513 + httpServer.URL, testUserDID, "did:plc:nonexistentcommunity"), nil) 514 + 515 + resp, err := http.DefaultClient.Do(req) 516 + if err != nil { 517 + t.Fatalf("Failed to send request: %v", err) 518 + } 519 + defer func() { _ = resp.Body.Close() }() 520 + 521 + if resp.StatusCode != http.StatusNotFound { 522 + body, _ := io.ReadAll(resp.Body) 523 + t.Errorf("Expected 404 for non-existent community, got %d: %s", resp.StatusCode, string(body)) 524 + } 525 + 526 + t.Logf("SUCCESS: Non-existent community correctly rejected") 527 + }) 528 + } 529 + 530 + // TestGetAuthorPosts_WithJetstreamIndexing tests the full flow including Jetstream indexing 531 + func TestGetAuthorPosts_WithJetstreamIndexing(t *testing.T) { 532 + if testing.Short() { 533 + t.Skip("Skipping E2E test in short mode") 534 + } 535 + 536 + db := setupTestDB(t) 537 + defer func() { _ = db.Close() }() 538 + 539 + ctx := context.Background() 540 + pdsURL := getTestPDSURL() 541 + 542 + // Setup repositories 543 + postRepo := postgres.NewPostRepository(db) 544 + userRepo := postgres.NewUserRepository(db) 545 + communityRepo := postgres.NewCommunityRepository(db) 546 + voteRepo := postgres.NewVoteRepository(db) 547 + 548 + // Setup services 549 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 550 + userService := users.NewUserService(userRepo, resolver, pdsURL) 551 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, getTestInstanceDID(), "", nil, nil, nil) 552 + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) 553 + voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 554 + 555 + // Create test user on PDS 556 + testUserHandle := fmt.Sprintf("jet%d.local.coves.dev", time.Now().UnixNano()%1000000) 557 + testUserEmail := fmt.Sprintf("jetstream-author-%d@test.local", time.Now().Unix()) 558 + testUserPassword := "test-password-123" 559 + 560 + _, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword) 561 + if err != nil { 562 + t.Skipf("PDS not available: %v", err) 563 + } 564 + 565 + // Index user in AppView 566 + _ = createTestUser(t, db, testUserHandle, userDID) 567 + 568 + // Create test community 569 + testCommunityDID, _ := createFeedTestCommunity(db, ctx, "jetstream-author-test", "owner.test") 570 + 571 + // Setup Jetstream consumer 572 + postConsumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService, db) 573 + 574 + // Simulate a post being indexed via Jetstream 575 + t.Run("Index post via Jetstream consumer", func(t *testing.T) { 576 + rkey := fmt.Sprintf("post-%d", time.Now().UnixNano()) 577 + postURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", testCommunityDID, rkey) 578 + 579 + postEvent := jetstream.JetstreamEvent{ 580 + Did: testCommunityDID, 581 + TimeUS: time.Now().UnixMicro(), 582 + Kind: "commit", 583 + Commit: &jetstream.CommitEvent{ 584 + Rev: "test-post-rev", 585 + Operation: "create", 586 + Collection: "social.coves.community.post", 587 + RKey: rkey, 588 + CID: "bafyjetstream", 589 + Record: map[string]interface{}{ 590 + "$type": "social.coves.community.post", 591 + "community": testCommunityDID, 592 + "author": userDID, 593 + "title": "Jetstream Indexed Post", 594 + "content": "This post was indexed via Jetstream", 595 + "createdAt": time.Now().Format(time.RFC3339), 596 + }, 597 + }, 598 + } 599 + 600 + if handleErr := postConsumer.HandleEvent(ctx, &postEvent); handleErr != nil { 601 + t.Fatalf("Failed to handle post event: %v", handleErr) 602 + } 603 + 604 + t.Logf("Post indexed via Jetstream: %s", postURI) 605 + 606 + // Verify post is now queryable via GetAuthorPosts 607 + e2eAuth := NewE2EOAuthMiddleware() 608 + r := chi.NewRouter() 609 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 610 + httpServer := httptest.NewServer(r) 611 + defer httpServer.Close() 612 + 613 + req, _ := http.NewRequest(http.MethodGet, 614 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s", httpServer.URL, userDID), nil) 615 + 616 + resp, err := http.DefaultClient.Do(req) 617 + if err != nil { 618 + t.Fatalf("Failed to GET author posts: %v", err) 619 + } 620 + defer func() { _ = resp.Body.Close() }() 621 + 622 + var response posts.GetAuthorPostsResponse 623 + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { 624 + t.Fatalf("Failed to decode response: %v", decodeErr) 625 + } 626 + 627 + if len(response.Feed) != 1 { 628 + t.Errorf("Expected 1 post, got %d", len(response.Feed)) 629 + } 630 + 631 + if len(response.Feed) > 0 && response.Feed[0].Post != nil { 632 + title := response.Feed[0].Post.Title 633 + if title == nil || *title != "Jetstream Indexed Post" { 634 + t.Errorf("Expected title 'Jetstream Indexed Post', got %v", title) 635 + } 636 + } 637 + 638 + t.Logf("SUCCESS: Post indexed via Jetstream is queryable via GetAuthorPosts") 639 + }) 640 + } 641 + 642 + // TestGetAuthorPosts_CommunityFilter tests filtering posts by community 643 + func TestGetAuthorPosts_CommunityFilter(t *testing.T) { 644 + if testing.Short() { 645 + t.Skip("Skipping integration test in short mode") 646 + } 647 + 648 + db := setupTestDB(t) 649 + defer func() { _ = db.Close() }() 650 + 651 + ctx := context.Background() 652 + 653 + // Setup services 654 + postRepo := postgres.NewPostRepository(db) 655 + userRepo := postgres.NewUserRepository(db) 656 + communityRepo := postgres.NewCommunityRepository(db) 657 + voteRepo := postgres.NewVoteRepository(db) 658 + 659 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 660 + userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) 661 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil, nil) 662 + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) 663 + voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 664 + 665 + // Create test user 666 + testUserDID := "did:plc:communityfilter" 667 + _ = createTestUser(t, db, "communityfilter.test", testUserDID) 668 + 669 + // Create two communities 670 + community1DID, _ := createFeedTestCommunity(db, ctx, "filter-community-1", "owner1.test") 671 + community2DID, _ := createFeedTestCommunity(db, ctx, "filter-community-2", "owner2.test") 672 + 673 + // Create posts in each community 674 + now := time.Now() 675 + createTestPost(t, db, community1DID, testUserDID, "Post in Community 1 - A", 10, now) 676 + createTestPost(t, db, community1DID, testUserDID, "Post in Community 1 - B", 20, now.Add(-1*time.Hour)) 677 + createTestPost(t, db, community2DID, testUserDID, "Post in Community 2", 30, now.Add(-2*time.Hour)) 678 + 679 + // Setup HTTP server 680 + e2eAuth := NewE2EOAuthMiddleware() 681 + r := chi.NewRouter() 682 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 683 + httpServer := httptest.NewServer(r) 684 + defer httpServer.Close() 685 + 686 + // Test: Filter by community 1 687 + t.Run("Filter by community 1", func(t *testing.T) { 688 + req, _ := http.NewRequest(http.MethodGet, 689 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&community=%s", 690 + httpServer.URL, testUserDID, community1DID), nil) 691 + 692 + resp, err := http.DefaultClient.Do(req) 693 + if err != nil { 694 + t.Fatalf("Failed to GET posts: %v", err) 695 + } 696 + defer func() { _ = resp.Body.Close() }() 697 + 698 + var response posts.GetAuthorPostsResponse 699 + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { 700 + t.Fatalf("Failed to decode response: %v", decodeErr) 701 + } 702 + 703 + if len(response.Feed) != 2 { 704 + t.Errorf("Expected 2 posts in community 1, got %d", len(response.Feed)) 705 + } 706 + 707 + // Verify all posts are from community 1 708 + for _, fp := range response.Feed { 709 + if fp.Post.Community.DID != community1DID { 710 + t.Errorf("Expected community DID %s, got %s", community1DID, fp.Post.Community.DID) 711 + } 712 + } 713 + 714 + t.Logf("SUCCESS: Community filter returned %d posts from community 1", len(response.Feed)) 715 + }) 716 + 717 + // Test: No filter returns all posts 718 + t.Run("No filter returns all posts", func(t *testing.T) { 719 + req, _ := http.NewRequest(http.MethodGet, 720 + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s", httpServer.URL, testUserDID), nil) 721 + 722 + resp, err := http.DefaultClient.Do(req) 723 + if err != nil { 724 + t.Fatalf("Failed to GET posts: %v", err) 725 + } 726 + defer func() { _ = resp.Body.Close() }() 727 + 728 + var response posts.GetAuthorPostsResponse 729 + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { 730 + t.Fatalf("Failed to decode response: %v", decodeErr) 731 + } 732 + 733 + if len(response.Feed) != 3 { 734 + t.Errorf("Expected 3 total posts, got %d", len(response.Feed)) 735 + } 736 + 737 + t.Logf("SUCCESS: No filter returned %d total posts", len(response.Feed)) 738 + }) 739 + }
+2
CLAUDE.md
··· 56 56 - [ ] ย **Does the Lexicon make sense?**ย (Would it work for other forums?) 57 57 - [ ] ย **AppView only indexes**: We don't write to CAR files, only read from firehose 58 58 59 + Always prefer error codes over dataintegrity boolean markers 60 + 59 61 ## Security-First Building 60 62 61 63 ### Every Feature MUST:
+57 -11
Makefile
··· 1 - .PHONY: help dev-up dev-down dev-logs dev-status dev-reset test e2e-test clean verify-stack create-test-account mobile-full-setup 1 + .PHONY: help dev-up dev-down dev-logs dev-status dev-reset test test-all e2e-test clean verify-stack create-test-account mobile-full-setup 2 2 3 3 # Default target - show help 4 4 .DEFAULT_GOAL := help ··· 8 8 RESET := \033[0m 9 9 GREEN := \033[32m 10 10 YELLOW := \033[33m 11 + RED := \033[31m 11 12 12 13 # Load test database configuration from .env.dev 13 14 include .env.dev ··· 74 75 75 76 76 77 78 + db-migrate: ## Run database migrations 79 + @echo "$(GREEN)Running database migrations...$(RESET)" 80 + @goose -dir internal/db/migrations postgres "postgresql://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable" up 81 + @echo "$(GREEN)โœ“ Migrations complete$(RESET)" 82 + 83 + db-migrate-down: ## Rollback last migration 84 + @echo "$(YELLOW)Rolling back last migration...$(RESET)" 85 + @goose -dir internal/db/migrations postgres "postgresql://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable" down 86 + @echo "$(GREEN)โœ“ Rollback complete$(RESET)" 77 87 78 - 79 - 80 - 81 - 82 - 83 - 84 - 85 - 86 - 87 - 88 + db-reset: ## Reset database (delete all data and re-run migrations) 88 89 89 90 90 91 ··· 156 157 @docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile test stop postgres-test 157 158 @echo "$(GREEN)โœ“ Test database stopped$(RESET)" 158 159 160 + test-all: ## Run ALL tests with live infrastructure (required before merge) 161 + @echo "" 162 + @echo "$(CYAN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)" 163 + @echo "$(CYAN) FULL TEST SUITE - All tests with live infrastructure $(RESET)" 164 + @echo "$(CYAN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)" 165 + @echo "" 166 + @echo "$(YELLOW)โ–ถ Checking infrastructure...$(RESET)" 167 + @echo "" 168 + @# Check dev stack is running 169 + @echo " Checking dev stack (PDS, Jetstream, PLC)..." 170 + @docker-compose -f docker-compose.dev.yml --env-file .env.dev ps 2>/dev/null | grep -q "Up" || \ 171 + (echo "$(RED) โœ— Dev stack not running. Run 'make dev-up' first.$(RESET)" && exit 1) 172 + @echo " $(GREEN)โœ“ Dev stack is running$(RESET)" 173 + @# Check AppView is running 174 + @echo " Checking AppView (port 8081)..." 175 + @curl -sf http://127.0.0.1:8081/xrpc/_health >/dev/null 2>&1 || \ 176 + curl -sf http://127.0.0.1:8081/ >/dev/null 2>&1 || \ 177 + (echo "$(RED) โœ— AppView not running. Run 'make run' in another terminal.$(RESET)" && exit 1) 178 + @echo " $(GREEN)โœ“ AppView is running$(RESET)" 179 + @# Check test database 180 + @echo " Checking test database (port 5434)..." 181 + @docker-compose -f docker-compose.dev.yml --env-file .env.dev ps postgres-test 2>/dev/null | grep -q "Up" || \ 182 + (echo "$(YELLOW) โš  Test database not running, starting it...$(RESET)" && \ 183 + docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile test up -d postgres-test && \ 184 + sleep 3 && \ 185 + goose -dir internal/db/migrations postgres "postgresql://$(POSTGRES_TEST_USER):$(POSTGRES_TEST_PASSWORD)@localhost:$(POSTGRES_TEST_PORT)/$(POSTGRES_TEST_DB)?sslmode=disable" up) 186 + @echo " $(GREEN)โœ“ Test database is running$(RESET)" 187 + @echo "" 188 + @echo "$(GREEN)โ–ถ [1/3] Unit & Package Tests (./cmd/... ./internal/...)$(RESET)" 189 + @echo "$(CYAN)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€$(RESET)" 190 + @LOG_ENABLED=false go test ./cmd/... ./internal/... -timeout 120s 191 + @echo "" 192 + @echo "$(GREEN)โ–ถ [2/3] Integration Tests (./tests/integration/...)$(RESET)" 193 + @echo "$(CYAN)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€$(RESET)" 194 + @LOG_ENABLED=false go test ./tests/integration/... -timeout 600s 195 + @echo "" 196 + @echo "$(GREEN)โ–ถ [3/3] E2E Tests (./tests/e2e/...)$(RESET)" 197 + @echo "$(CYAN)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€$(RESET)" 198 + @LOG_ENABLED=false go test ./tests/e2e/... -timeout 180s 199 + @echo "" 200 + @echo "$(GREEN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)" 201 + @echo "$(GREEN) โœ“ ALL TESTS PASSED - Safe to merge $(RESET)" 202 + @echo "$(GREEN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)" 203 + @echo "" 204 + 159 205 ##@ Code Quality 160 206 161 207 fmt: ## Format all Go code with gofmt
+388 -9
tests/e2e/user_signup_test.go
··· 1 + package e2e 1 2 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 - 3 + import ( 4 + "bytes" 10 5 "database/sql" 11 6 "encoding/json" 12 7 "fmt" 8 + "io" 9 + "log" 13 10 "net/http" 14 11 "os" 15 12 "testing" ··· 19 16 "github.com/pressly/goose/v3" 20 17 ) 21 18 19 + // TestMain controls test setup for the e2e package. 20 + // Set LOG_ENABLED=false to suppress application log output during tests. 21 + func TestMain(m *testing.M) { 22 + // Silence logs when LOG_ENABLED=false (used by make test-all) 23 + if os.Getenv("LOG_ENABLED") == "false" { 24 + log.SetOutput(io.Discard) 25 + } 26 + 27 + os.Exit(m.Run()) 28 + } 29 + 22 30 // TestE2E_UserSignup tests the full user signup flow: 23 - // Third-party client โ†’ social.coves.actor.signup XRPC โ†’ PDS account creation โ†’ Jetstream โ†’ AppView indexing 31 + // Third-party client โ†’ social.coves.actor.signup XRPC โ†’ PDS account creation + AppView indexing 32 + // 33 + // This tests the same code path that a third-party client or UI would use. 34 + // Users are indexed directly by the signup endpoint (not via Jetstream). 35 + // Jetstream is only used for handle changes on existing users. 36 + // 37 + // Prerequisites: 38 + // - AppView running on localhost:8081 39 + // - PDS running on localhost:3001 40 + // - Jetstream running on localhost:6008 (for handle change events, not required for signup) 24 41 // 42 + // Run with: 43 + // 44 + // make e2e-up # Start infrastructure 45 + // go run ./cmd/server & # Start AppView 46 + // go test ./tests/e2e -run TestE2E_UserSignup -v 47 + func TestE2E_UserSignup(t *testing.T) { 48 + if testing.Short() { 49 + t.Skip("Skipping E2E test in short mode") 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + t.Skip("PDS not available at localhost:3001 - run 'make e2e-up' first") 60 + } 61 + 62 + // Check if Jetstream is available (needed for full E2E infrastructure) 63 + if !isJetstreamAvailable(t) { 64 + t.Skip("Jetstream not available at localhost:6008 - run 'make e2e-up' first") 65 + } 66 + 67 + // Test 1: Create account on PDS 68 + t.Run("Create account on PDS and verify indexing", func(t *testing.T) { 69 + handle := fmt.Sprintf("alice-%d.local.coves.dev", time.Now().Unix()) 70 + 71 + 72 + t.Logf("Creating account: %s", handle) 73 + 74 + // Create account via AppView signup endpoint (what UI would call) 75 + did, err := createPDSAccount(t, handle, email, "test1234") 76 + if err != nil { 77 + t.Fatalf("Failed to create PDS account: %v", err) 78 + } 79 + 80 + t.Logf("Account created with DID: %s", did) 81 + 82 + // Verify user was indexed via AppView API (signup indexes immediately) 83 + t.Log("Verifying user via AppView API...") 84 + var userDID, userHandle string 85 + deadline := time.Now().Add(10 * time.Second) 86 + for time.Now().Before(deadline) { 87 + userDID, userHandle, err = getProfileViaAPI(did) 88 + if err == nil { 89 + break // Successfully found! 90 + } 91 + time.Sleep(500 * time.Millisecond) 92 + } 93 + if err != nil { 94 + t.Fatalf("User not found in AppView after 10s: %v", err) 95 + } 96 + 97 + if userHandle != handle { 98 + t.Errorf("Expected handle %s, got %s", handle, userHandle) 99 + } 100 + 101 + if userDID != did { 102 + t.Errorf("Expected DID %s, got %s", did, userDID) 103 + } 104 + 105 + t.Logf("โœ… User successfully indexed: %s โ†’ %s", handle, did) 106 + }) 107 + 108 + // Test 2: Idempotency (verify same user from multiple API calls) 109 + t.Run("Idempotent indexing on duplicate events", func(t *testing.T) { 110 + handle := fmt.Sprintf("bob-%d.local.coves.dev", time.Now().Unix()) 111 + email := fmt.Sprintf("bob-%d@test.com", time.Now().Unix()) 112 + 113 + // Create account via AppView signup endpoint 114 + did, err := createPDSAccount(t, handle, email, "test1234") 115 + if err != nil { 116 + t.Fatalf("Failed to create PDS account: %v", err) 117 + } 118 + 119 + // Wait for indexing via AppView API 120 + var userDID1 string 121 + deadline := time.Now().Add(10 * time.Second) 122 + for time.Now().Before(deadline) { 123 + userDID1, _, err = getProfileViaAPI(did) 124 + if err == nil { 125 + break 126 + } 127 + time.Sleep(500 * time.Millisecond) 128 + } 129 + if err != nil { 130 + t.Fatalf("User not found after 10s: %v", err) 131 + } 132 + 133 + // Query again - should get same user 134 + userDID2, _, err := getProfileViaAPI(did) 135 + if err != nil { 136 + t.Fatalf("Failed to get user on second query: %v", err) 137 + } 138 + 139 + if userDID1 != userDID2 { 140 + t.Errorf("Got different DIDs on repeated queries: %s vs %s", userDID1, userDID2) 141 + } 142 + 143 + t.Logf("โœ… Idempotency verified: repeated queries return same user") 144 + }) 145 + 146 + // Test 3: Multiple users 147 + t.Run("Index multiple users concurrently", func(t *testing.T) { 148 + const numUsers = 3 149 + dids := make([]string, numUsers) 150 + handles := make([]string, numUsers) 151 + 152 + for i := 0; i < numUsers; i++ { 153 + handle := fmt.Sprintf("user%d-%d.local.coves.dev", i, time.Now().Unix()) 154 + email := fmt.Sprintf("user%d-%d@test.com", i, time.Now().Unix()) 155 + 156 + did, err := createPDSAccount(t, handle, email, "test1234") 157 + if err != nil { 158 + t.Fatalf("Failed to create account %d: %v", i, err) 159 + } 160 + dids[i] = did 161 + handles[i] = handle 162 + t.Logf("Created user %d: %s", i, did) 163 + 164 + // Small delay between creations 165 + time.Sleep(500 * time.Millisecond) 166 + } 167 + 168 + // Verify all indexed via AppView API (with retry for each user) 169 + t.Log("Waiting for all users to be indexed...") 170 + for i, did := range dids { 171 + var userHandle string 172 + var err error 173 + deadline := time.Now().Add(15 * time.Second) 174 + for time.Now().Before(deadline) { 175 + _, userHandle, err = getProfileViaAPI(did) 176 + if err == nil { 177 + break 178 + } 179 + time.Sleep(500 * time.Millisecond) 180 + } 181 + if err != nil { 182 + t.Errorf("User %d not found after 15s: %v", i, err) 183 + continue 184 + } 185 + t.Logf("โœ… User %d indexed: %s", i, userHandle) 186 + } 187 + }) 188 + } 189 + 190 + // generateInviteCode generates a single-use invite code via PDS admin API 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + 216 + 217 + 218 + 219 + 220 + 221 + 222 + 223 + 224 + 225 + 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + // createPDSAccount creates an account via the coves.user.signup XRPC endpoint 241 + // This is the same code path that a third-party client or UI would use 242 + func createPDSAccount(t *testing.T, handle, email, password string) (string, error) { 243 + // Generate fresh invite code for each account 244 + inviteCode, err := generateInviteCode(t) 245 + if err != nil { 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + 254 + 255 + 256 + 257 + 258 + 259 + 260 + 261 + 262 + 263 + 264 + 265 + 266 + 267 + 268 + 269 + 270 + 271 + 272 + 273 + 274 + 275 + 276 + 277 + 278 + 279 + 280 + 281 + 282 + 283 + 284 + 285 + 286 + 287 + 288 + 289 + 290 + 291 + 292 + 293 + 294 + 295 + 296 + 297 + 298 + 299 + 300 + 301 + 302 + 303 + 304 + 305 + 306 + 307 + 308 + 309 + 310 + 311 + 312 + 313 + 314 + 315 + 316 + 317 + 318 + 319 + 320 + 321 + 322 + 323 + 324 + 325 + 326 + 327 + 328 + 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + 343 + 344 + 345 + 346 + 347 + 348 + 349 + 350 + 351 + 352 + 353 + 354 + 355 + 356 + 357 + 358 + 359 + 360 + 361 + 362 + 363 + 364 + 365 + 366 + 367 + 368 + 369 + 370 + 371 + 372 + 373 + 374 + 375 + 376 + 377 + 378 + return db 379 + } 380 + 381 + // getProfileViaAPI queries the AppView API to get a user profile by DID 382 + func getProfileViaAPI(did string) (string, string, error) { 383 + resp, err := http.Get(fmt.Sprintf("http://localhost:8081/xrpc/social.coves.actor.getprofile?actor=%s", did)) 384 + if err != nil { 385 + return "", "", fmt.Errorf("failed to call getprofile: %w", err) 386 + } 387 + defer func() { _ = resp.Body.Close() }() 388 + 389 + if resp.StatusCode != http.StatusOK { 390 + return "", "", fmt.Errorf("getprofile returned status %d", resp.StatusCode) 391 + } 392 + 393 + var result struct { 394 + DID string `json:"did"` 395 + Handle string `json:"handle"` 396 + } 397 + 398 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 399 + return "", "", fmt.Errorf("failed to decode response: %w", err) 400 + } 401 + 402 + return result.DID, result.Handle, nil 403 + }
+13
tests/lexicon_validation_test.go
··· 1 1 package tests 2 2 3 3 import ( 4 + "io" 5 + "log" 4 6 "os" 5 7 "path/filepath" 6 8 "strings" ··· 9 11 lexicon "github.com/bluesky-social/indigo/atproto/lexicon" 10 12 ) 11 13 14 + // TestMain controls test setup for the tests package. 15 + // Set LOG_ENABLED=false to suppress application log output during tests. 16 + func TestMain(m *testing.M) { 17 + // Silence logs when LOG_ENABLED=false (used by make test-all) 18 + if os.Getenv("LOG_ENABLED") == "false" { 19 + log.SetOutput(io.Discard) 20 + } 21 + 22 + os.Exit(m.Run()) 23 + } 24 + 12 25 func TestLexiconSchemaValidation(t *testing.T) { 13 26 // Create a new catalog 14 27 catalog := lexicon.NewBaseCatalog()
+14
tests/unit/community_service_test.go
··· 4 4 "Coves/internal/core/communities" 5 5 "context" 6 6 "fmt" 7 + "io" 8 + "log" 7 9 "net/http" 8 10 "net/http/httptest" 11 + "os" 9 12 "strings" 10 13 "sync/atomic" 11 14 "testing" 12 15 "time" 13 16 ) 14 17 18 + // TestMain controls test setup for the unit package. 19 + // Set LOG_ENABLED=false to suppress application log output during tests. 20 + func TestMain(m *testing.M) { 21 + // Silence logs when LOG_ENABLED=false (used by make test-all) 22 + if os.Getenv("LOG_ENABLED") == "false" { 23 + log.SetOutput(io.Discard) 24 + } 25 + 26 + os.Exit(m.Run()) 27 + } 28 + 15 29 // mockCommunityRepo is a minimal mock for testing service layer 16 30 type mockCommunityRepo struct { 17 31 communities map[string]*communities.Community
+82 -17
tests/e2e/error_recovery_test.go
··· 86 86 t.Run("Events processed successfully after connection", func(t *testing.T) { 87 87 // Even though we can't easily test WebSocket reconnection in unit tests, 88 88 // we can verify that events are processed correctly after establishing connection 89 - consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 90 89 ctx := context.Background() 91 90 91 + // Pre-create user - identity events only update existing users 92 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 93 + DID: "did:plc:reconnect123", 94 + Handle: "reconnect.old.test", 95 + PDSURL: "http://localhost:3001", 96 + }) 97 + if err != nil { 98 + t.Fatalf("Failed to create test user: %v", err) 99 + } 100 + 101 + consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 102 + 103 + // Send identity event with new handle 92 104 event := jetstream.JetstreamEvent{ 93 105 Did: "did:plc:reconnect123", 94 106 Kind: "identity", ··· 100 112 }, 101 113 } 102 114 103 - err := consumer.HandleIdentityEventPublic(ctx, &event) 115 + err = consumer.HandleIdentityEventPublic(ctx, &event) 104 116 if err != nil { 105 117 t.Fatalf("Failed to process event: %v", err) 106 118 } ··· 217 229 218 230 // Verify consumer can still process valid events after malformed ones 219 231 t.Run("Valid event after malformed events", func(t *testing.T) { 220 - consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 221 232 ctx := context.Background() 222 233 234 + // Pre-create user - identity events only update existing users 235 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 236 + DID: "did:plc:recovery123", 237 + Handle: "recovery.old.test", 238 + PDSURL: "http://localhost:3001", 239 + }) 240 + if err != nil { 241 + t.Fatalf("Failed to create test user: %v", err) 242 + } 243 + 244 + consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 245 + 246 + // Send valid identity event with new handle 223 247 validEvent := jetstream.JetstreamEvent{ 224 248 Did: "did:plc:recovery123", 225 249 Kind: "identity", ··· 231 255 }, 232 256 } 233 257 234 - err := consumer.HandleIdentityEventPublic(ctx, &validEvent) 258 + err = consumer.HandleIdentityEventPublic(ctx, &validEvent) 235 259 if err != nil { 236 260 t.Fatalf("Failed to process valid event after malformed events: %v", err) 237 261 } 238 262 239 - // Verify user was indexed 263 + // Verify user handle was updated 240 264 user, err := userService.GetUserByDID(ctx, "did:plc:recovery123") 241 265 if err != nil { 242 - t.Fatalf("User not indexed after malformed events: %v", err) 266 + t.Fatalf("User not found after valid event: %v", err) 243 267 } 244 268 245 269 if user.Handle != "recovery.test" { ··· 362 386 ctx := context.Background() 363 387 364 388 t.Run("Indexing continues during PDS unavailability", func(t *testing.T) { 365 - // Even though PDS is "unavailable", we can still index events from Jetstream 389 + // Even though PDS is "unavailable", we can still update events from Jetstream 366 390 // because we don't need to contact PDS for identity events 391 + 392 + // Pre-create user - identity events only update existing users 393 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 394 + DID: "did:plc:pdsfail123", 395 + Handle: "pdsfail.old.test", 396 + PDSURL: mockPDS.URL, 397 + }) 398 + if err != nil { 399 + t.Fatalf("Failed to create test user: %v", err) 400 + } 401 + 367 402 consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 368 403 369 404 event := jetstream.JetstreamEvent{ ··· 377 412 }, 378 413 } 379 414 380 - err := consumer.HandleIdentityEventPublic(ctx, &event) 415 + err = consumer.HandleIdentityEventPublic(ctx, &event) 381 416 if err != nil { 382 - t.Fatalf("Failed to index event during PDS unavailability: %v", err) 417 + t.Fatalf("Failed to process event during PDS unavailability: %v", err) 383 418 } 384 419 385 - // Verify user was indexed 420 + // Verify user handle was updated 386 421 user, err := userService.GetUserByDID(ctx, "did:plc:pdsfail123") 387 422 if err != nil { 388 423 t.Fatalf("Failed to get user during PDS unavailability: %v", err) ··· 392 427 t.Errorf("Expected handle pdsfail.test, got %s", user.Handle) 393 428 } 394 429 395 - t.Log("โœ“ Indexing continues successfully even when PDS is unavailable") 430 + t.Log("โœ“ Handle updates continue successfully even when PDS is unavailable") 396 431 }) 397 432 398 433 t.Run("System recovers when PDS comes back online", func(t *testing.T) { 399 434 // Mark PDS as available again 400 435 shouldFail.Store(false) 401 436 437 + // Pre-create user - identity events only update existing users 438 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 439 + DID: "did:plc:pdsrecovery123", 440 + Handle: "pdsrecovery.old.test", 441 + PDSURL: mockPDS.URL, 442 + }) 443 + if err != nil { 444 + t.Fatalf("Failed to create test user: %v", err) 445 + } 446 + 402 447 // Now operations that require PDS should work 403 448 consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 404 449 ··· 413 458 }, 414 459 } 415 460 416 - err := consumer.HandleIdentityEventPublic(ctx, &event) 461 + err = consumer.HandleIdentityEventPublic(ctx, &event) 417 462 if err != nil { 418 - t.Fatalf("Failed to index event after PDS recovery: %v", err) 463 + t.Fatalf("Failed to process event after PDS recovery: %v", err) 419 464 } 420 465 421 466 user, err := userService.GetUserByDID(ctx, "did:plc:pdsrecovery123") ··· 449 494 t.Run("Handle updates arriving out of order", func(t *testing.T) { 450 495 did := "did:plc:outoforder123" 451 496 497 + // Pre-create user - identity events only update existing users 498 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 499 + DID: did, 500 + Handle: "initial.handle", 501 + PDSURL: "http://localhost:3001", 502 + }) 503 + if err != nil { 504 + t.Fatalf("Failed to create test user: %v", err) 505 + } 506 + 452 507 // Event 3: Latest handle 453 508 event3 := jetstream.JetstreamEvent{ 454 509 Did: did, ··· 511 566 }) 512 567 513 568 t.Run("Duplicate events at different times", func(t *testing.T) { 514 - did := "did:plc:duplicate123" 569 + did := "did:plc:dupevents123" 570 + 571 + // Pre-create user - identity events only update existing users 572 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 573 + DID: did, 574 + Handle: "duplicate.handle", 575 + PDSURL: "http://localhost:3001", 576 + }) 577 + if err != nil { 578 + t.Fatalf("Failed to create test user: %v", err) 579 + } 515 580 516 - // Create user 581 + // Send identity event 517 582 event1 := jetstream.JetstreamEvent{ 518 583 Did: did, 519 584 Kind: "identity", ··· 525 590 }, 526 591 } 527 592 528 - err := consumer.HandleIdentityEventPublic(ctx, &event1) 593 + err = consumer.HandleIdentityEventPublic(ctx, &event1) 529 594 if err != nil { 530 595 t.Fatalf("Failed to process first event: %v", err) 531 596 } ··· 536 601 t.Fatalf("Failed to process duplicate event: %v", err) 537 602 } 538 603 539 - // Verify still only one user 604 + // Verify still only one user with same handle 540 605 user, err := userService.GetUserByDID(ctx, did) 541 606 if err != nil { 542 607 t.Fatalf("Failed to get user: %v", err)
+43 -35
tests/integration/jetstream_consumer_test.go
··· 25 25 26 26 ctx := context.Background() 27 27 28 - t.Run("Index new user from identity event", func(t *testing.T) { 29 - // Simulate an identity event from Jetstream 28 + t.Run("Skip identity event for non-existent user", func(t *testing.T) { 29 + // Identity events for users not in our database should be silently skipped 30 + // Users are only indexed during OAuth login/signup, not from Jetstream events 30 31 event := jetstream.JetstreamEvent{ 31 - Did: "did:plc:jetstream123", 32 + Did: "did:plc:nonexistent123", 32 33 Kind: "identity", 33 34 Identity: &jetstream.IdentityEvent{ 34 - Did: "did:plc:jetstream123", 35 - Handle: "alice.jetstream.test", 35 + Did: "did:plc:nonexistent123", 36 + Handle: "nonexistent.jetstream.test", 36 37 Seq: 12345, 37 38 Time: time.Now().Format(time.RFC3339), 38 39 }, ··· 40 41 41 42 consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 42 43 43 - // Handle the event 44 + // Handle the event - should return nil (skip silently, not error) 44 45 err := consumer.HandleIdentityEventPublic(ctx, &event) 45 46 if err != nil { 46 - t.Fatalf("failed to handle identity event: %v", err) 47 + t.Fatalf("expected nil error for non-existent user, got: %v", err) 47 48 } 48 49 49 - // Verify user was indexed 50 - user, err := userService.GetUserByDID(ctx, "did:plc:jetstream123") 51 - if err != nil { 52 - t.Fatalf("failed to get indexed user: %v", err) 53 - } 54 - 55 - if user.DID != "did:plc:jetstream123" { 56 - t.Errorf("expected DID did:plc:jetstream123, got %s", user.DID) 57 - } 58 - 59 - if user.Handle != "alice.jetstream.test" { 60 - t.Errorf("expected handle alice.jetstream.test, got %s", user.Handle) 50 + // Verify user was NOT created 51 + _, err = userService.GetUserByDID(ctx, "did:plc:nonexistent123") 52 + if err == nil { 53 + t.Fatal("expected user to NOT be created, but found in database") 61 54 } 62 55 }) 63 56 ··· 103 96 } 104 97 }) 105 98 106 - t.Run("Index multiple users", func(t *testing.T) { 107 - consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 108 - 109 - users := []struct { 110 - did string 111 - handle string 99 + t.Run("Update multiple existing users via identity events", func(t *testing.T) { 100 + // Pre-create users - identity events only update existing users 101 + testUsers := []struct { 102 + did string 103 + oldHandle string 104 + newHandle string 112 105 }{ 113 - {"did:plc:multi1", "user1.test"}, 114 - {"did:plc:multi2", "user2.test"}, 115 - {"did:plc:multi3", "user3.test"}, 106 + {"did:plc:multi1", "user1.old.test", "user1.new.test"}, 107 + {"did:plc:multi2", "user2.old.test", "user2.new.test"}, 108 + {"did:plc:multi3", "user3.old.test", "user3.new.test"}, 109 + } 110 + 111 + // Create users first 112 + for _, u := range testUsers { 113 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 114 + DID: u.did, 115 + Handle: u.oldHandle, 116 + PDSURL: "https://bsky.social", 117 + }) 118 + if err != nil { 119 + t.Fatalf("failed to create user %s: %v", u.oldHandle, err) 120 + } 116 121 } 117 122 118 - for _, u := range users { 123 + consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 124 + 125 + // Send identity events with new handles 126 + for _, u := range testUsers { 119 127 event := jetstream.JetstreamEvent{ 120 128 Did: u.did, 121 129 Kind: "identity", 122 130 Identity: &jetstream.IdentityEvent{ 123 131 Did: u.did, 124 - Handle: u.handle, 132 + Handle: u.newHandle, 125 133 Seq: 12345, 126 134 Time: time.Now().Format(time.RFC3339), 127 135 }, ··· 129 137 130 138 err := consumer.HandleIdentityEventPublic(ctx, &event) 131 139 if err != nil { 132 - t.Fatalf("failed to index user %s: %v", u.handle, err) 140 + t.Fatalf("failed to handle identity event for %s: %v", u.newHandle, err) 133 141 } 134 142 } 135 143 136 - // Verify all users indexed 137 - for _, u := range users { 144 + // Verify all users have updated handles 145 + for _, u := range testUsers { 138 146 user, err := userService.GetUserByDID(ctx, u.did) 139 147 if err != nil { 140 148 t.Fatalf("user %s not found: %v", u.did, err) 141 149 } 142 150 143 - if user.Handle != u.handle { 144 - t.Errorf("expected handle %s, got %s", u.handle, user.Handle) 151 + if user.Handle != u.newHandle { 152 + t.Errorf("expected handle %s, got %s", u.newHandle, user.Handle) 145 153 } 146 154 } 147 155 })
+72 -36
internal/api/routes/user.go
··· 1 + package routes 1 2 2 - 3 - 4 - 5 - 3 + import ( 4 + "Coves/internal/api/handlers/user" 5 + "Coves/internal/api/middleware" 6 + "Coves/internal/core/blobs" 7 + "Coves/internal/core/users" 8 + "encoding/json" 6 9 "errors" 7 10 "log" 8 11 "net/http" 9 - "time" 12 + "strings" 10 13 11 14 "github.com/go-chi/chi/v5" 12 15 ) ··· 23 26 24 27 25 28 26 - 27 - 28 - 29 - 30 - 31 - 32 - 33 - 34 - 35 - 36 - 29 + // RegisterUserRoutes registers user-related XRPC endpoints on the router 30 + // Implements social.coves.actor.* lexicon endpoints 31 + func RegisterUserRoutes(r chi.Router, service users.UserService, authMiddleware *middleware.OAuthAuthMiddleware, blobService blobs.Service) { 32 + h := NewUserHandler(service) 33 + 34 + // social.coves.actor.getprofile - query endpoint (public) 35 + r.Get("/xrpc/social.coves.actor.getprofile", h.GetProfile) 36 + 37 + // social.coves.actor.signup - procedure endpoint (public) 38 + r.Post("/xrpc/social.coves.actor.signup", h.Signup) 39 + 40 + // social.coves.actor.deleteAccount - procedure endpoint (authenticated) 41 + // Deletes the authenticated user's account from the Coves AppView. 42 + // This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. 43 + deleteHandler := user.NewDeleteHandler(service) 44 + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.actor.deleteAccount", deleteHandler.HandleDeleteAccount) 45 + 46 + // social.coves.actor.updateProfile - procedure endpoint (authenticated) 47 + // Updates the authenticated user's profile on their PDS (avatar, banner, displayName, bio). 48 + // This writes directly to the user's PDS and the Jetstream consumer will index the change. 49 + updateProfileHandler := user.NewUpdateProfileHandler(blobService, nil) 50 + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.actor.updateProfile", updateProfileHandler.ServeHTTP) 51 + } 37 52 38 53 // GetProfile handles social.coves.actor.getprofile 39 54 // Query endpoint that retrieves a user profile by DID or handle 55 + // Returns profileViewDetailed with stats per lexicon specification 40 56 func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) { 41 57 ctx := r.Context() 42 58 43 59 // Get actor parameter (DID or handle) 44 60 actor := r.URL.Query().Get("actor") 45 61 if actor == "" { 46 - http.Error(w, "actor parameter is required", http.StatusBadRequest) 62 + writeXRPCError(w, "InvalidRequest", "actor parameter is required", http.StatusBadRequest) 47 63 return 48 64 } 49 65 50 - var user *users.User 51 - var err error 52 - 53 - // Determine if actor is a DID or handle 54 - // DIDs start with "did:", handles don't 55 - if len(actor) > 4 && actor[:4] == "did:" { 56 - user, err = h.userService.GetUserByDID(ctx, actor) 66 + // Resolve actor to DID 67 + var did string 68 + if strings.HasPrefix(actor, "did:") { 69 + did = actor 57 70 } else { 58 - user, err = h.userService.GetUserByHandle(ctx, actor) 71 + // Resolve handle to DID 72 + resolvedDID, err := h.userService.ResolveHandleToDID(ctx, actor) 73 + if err != nil { 74 + writeXRPCError(w, "ProfileNotFound", "user not found", http.StatusNotFound) 75 + return 76 + } 77 + did = resolvedDID 59 78 } 60 79 80 + // Get full profile with stats 81 + profile, err := h.userService.GetProfile(ctx, did) 61 82 if err != nil { 62 - http.Error(w, "user not found", http.StatusNotFound) 83 + if errors.Is(err, users.ErrUserNotFound) { 84 + writeXRPCError(w, "ProfileNotFound", "user not found", http.StatusNotFound) 85 + return 86 + } 87 + log.Printf("Failed to get profile for %s: %v", did, err) 88 + writeXRPCError(w, "InternalError", "failed to get profile", http.StatusInternalServerError) 63 89 return 64 90 } 65 91 66 - // Minimal profile response (matching lexicon structure) 67 - response := map[string]interface{}{ 68 - "did": user.DID, 69 - "profile": map[string]interface{}{ 70 - "handle": user.Handle, 71 - "createdAt": user.CreatedAt.Format(time.RFC3339), 72 - }, 92 + // Marshal to bytes first to avoid partial writes on encoding errors 93 + responseBytes, err := json.Marshal(profile) 94 + if err != nil { 95 + log.Printf("Failed to marshal profile response: %v", err) 96 + writeXRPCError(w, "InternalError", "failed to encode response", http.StatusInternalServerError) 97 + return 73 98 } 74 99 75 100 w.Header().Set("Content-Type", "application/json") 76 - w.WriteHeader(http.StatusOK) 77 - if err := json.NewEncoder(w).Encode(response); err != nil { 78 - log.Printf("Failed to encode response: %v", err) 101 + if _, err := w.Write(responseBytes); err != nil { 102 + log.Printf("Failed to write response: %v", err) 103 + } 104 + } 105 + 106 + // writeXRPCError writes a standardized XRPC error response 107 + func writeXRPCError(w http.ResponseWriter, errorName, message string, statusCode int) { 108 + w.Header().Set("Content-Type", "application/json") 109 + w.WriteHeader(statusCode) 110 + if err := json.NewEncoder(w).Encode(map[string]interface{}{ 111 + "error": errorName, 112 + "message": message, 113 + }); err != nil { 114 + log.Printf("Failed to encode error response: %v", err) 79 115 } 80 116 } 81 117
+39 -10
internal/core/users/user.go
··· 5 5 6 6 7 7 8 + // This is NOT the user's repository - that lives in the PDS 9 + // This table only tracks metadata for efficient AppView queries 10 + type User struct { 11 + CreatedAt time.Time `json:"createdAt" db:"created_at"` 12 + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` 13 + DID string `json:"did" db:"did"` 14 + Handle string `json:"handle" db:"handle"` 15 + PDSURL string `json:"pdsUrl" db:"pds_url"` 16 + DisplayName string `json:"displayName,omitempty" db:"display_name"` 17 + Bio string `json:"bio,omitempty" db:"bio"` 18 + AvatarCID string `json:"avatarCid,omitempty" db:"avatar_cid"` 19 + BannerCID string `json:"bannerCid,omitempty" db:"banner_cid"` 20 + } 8 21 9 - 10 - 11 - 12 - 13 - 14 - 15 - 16 - 17 - 18 - 22 + // CreateUserRequest represents the input for creating a new user 19 23 20 24 21 25 ··· 38 42 RefreshJwt string `json:"refreshJwt"` 39 43 PDSURL string `json:"pdsUrl"` 40 44 } 45 + 46 + // ProfileStats contains aggregated user statistics 47 + // Matches the social.coves.actor.defs#profileStats lexicon 48 + type ProfileStats struct { 49 + PostCount int `json:"postCount"` 50 + CommentCount int `json:"commentCount"` 51 + CommunityCount int `json:"communityCount"` // Number of communities subscribed to 52 + Reputation int `json:"reputation"` // Global reputation score (sum across communities) 53 + MembershipCount int `json:"membershipCount"` // Number of communities with active membership 54 + } 55 + 56 + // ProfileViewDetailed is the full profile response 57 + // Matches the social.coves.actor.defs#profileViewDetailed lexicon 58 + type ProfileViewDetailed struct { 59 + DID string `json:"did"` 60 + Handle string `json:"handle,omitempty"` 61 + CreatedAt time.Time `json:"createdAt"` 62 + Stats *ProfileStats `json:"stats,omitempty"` 63 + DisplayName string `json:"displayName,omitempty"` 64 + // Bio is the user's biography/description. Maps to JSON "description" for atProto lexicon compatibility. 65 + Bio string `json:"description,omitempty"` 66 + Avatar string `json:"avatar,omitempty"` // URL, not CID 67 + Banner string `json:"banner,omitempty"` // URL, not CID 68 + // Viewer (requires user-to-user blocking infrastructure) 69 + }
+265
internal/api/handlers/actor/get_comments.go
··· 1 + package actor 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "log" 7 + "net/http" 8 + "strconv" 9 + "strings" 10 + 11 + "Coves/internal/api/middleware" 12 + "Coves/internal/core/comments" 13 + "Coves/internal/core/users" 14 + "Coves/internal/core/votes" 15 + ) 16 + 17 + // GetCommentsHandler handles actor comment retrieval 18 + type GetCommentsHandler struct { 19 + commentService comments.Service 20 + userService users.UserService 21 + voteService votes.Service 22 + } 23 + 24 + // NewGetCommentsHandler creates a new actor comments handler 25 + func NewGetCommentsHandler( 26 + commentService comments.Service, 27 + userService users.UserService, 28 + voteService votes.Service, 29 + ) *GetCommentsHandler { 30 + return &GetCommentsHandler{ 31 + commentService: commentService, 32 + userService: userService, 33 + voteService: voteService, 34 + } 35 + } 36 + 37 + // HandleGetComments retrieves comments by an actor (user) 38 + // GET /xrpc/social.coves.actor.getComments?actor={did_or_handle}&community=...&limit=50&cursor=... 39 + func (h *GetCommentsHandler) HandleGetComments(w http.ResponseWriter, r *http.Request) { 40 + if r.Method != http.MethodGet { 41 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 42 + return 43 + } 44 + 45 + // Parse query parameters 46 + req, err := h.parseRequest(r) 47 + if err != nil { 48 + // Check if it's an actor not found error (from handle resolution) 49 + var actorNotFound *actorNotFoundError 50 + if errors.As(err, &actorNotFound) { 51 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 52 + return 53 + } 54 + 55 + // Check if it's an infrastructure failure during resolution 56 + // (database down, DNS failures, network errors, etc.) 57 + var resolutionFailed *resolutionFailedError 58 + if errors.As(err, &resolutionFailed) { 59 + log.Printf("ERROR: Actor resolution infrastructure failure: %v", err) 60 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to resolve actor identity") 61 + return 62 + } 63 + 64 + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 65 + return 66 + } 67 + 68 + // Get viewer DID for populating viewer state (optional) 69 + viewerDID := middleware.GetUserDID(r) 70 + if viewerDID != "" { 71 + req.ViewerDID = &viewerDID 72 + } 73 + 74 + // Get actor comments from service 75 + response, err := h.commentService.GetActorComments(r.Context(), req) 76 + if err != nil { 77 + handleCommentServiceError(w, err) 78 + return 79 + } 80 + 81 + // Populate viewer vote state if authenticated 82 + h.populateViewerVoteState(r, response) 83 + 84 + // Pre-encode response to buffer before writing headers 85 + // This ensures we can return a proper error if encoding fails 86 + responseBytes, err := json.Marshal(response) 87 + if err != nil { 88 + log.Printf("ERROR: Failed to encode actor comments response: %v", err) 89 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") 90 + return 91 + } 92 + 93 + // Return comments 94 + w.Header().Set("Content-Type", "application/json") 95 + w.WriteHeader(http.StatusOK) 96 + if _, err := w.Write(responseBytes); err != nil { 97 + log.Printf("ERROR: Failed to write actor comments response: %v", err) 98 + } 99 + } 100 + 101 + // parseRequest parses query parameters into GetActorCommentsRequest 102 + func (h *GetCommentsHandler) parseRequest(r *http.Request) (*comments.GetActorCommentsRequest, error) { 103 + req := &comments.GetActorCommentsRequest{} 104 + 105 + // Required: actor (handle or DID) 106 + actor := r.URL.Query().Get("actor") 107 + if actor == "" { 108 + return nil, &validationError{field: "actor", message: "actor parameter is required"} 109 + } 110 + // Validate actor length to prevent DoS via massive strings 111 + // Max DID length is ~2048 chars (did:plc: is 8 + 24 base32 = 32, but did:web: can be longer) 112 + // Max handle length is 253 chars (DNS limit) 113 + const maxActorLength = 2048 114 + if len(actor) > maxActorLength { 115 + return nil, &validationError{field: "actor", message: "actor parameter exceeds maximum length"} 116 + } 117 + 118 + // Resolve actor to DID if it's a handle 119 + actorDID, err := h.resolveActor(r, actor) 120 + if err != nil { 121 + return nil, err 122 + } 123 + req.ActorDID = actorDID 124 + 125 + // Optional: community (handle or DID) 126 + req.Community = r.URL.Query().Get("community") 127 + 128 + // Optional: limit (default: 50, max: 100) 129 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 130 + limit, err := strconv.Atoi(limitStr) 131 + if err != nil { 132 + return nil, &validationError{field: "limit", message: "limit must be a valid integer"} 133 + } 134 + req.Limit = limit 135 + } 136 + 137 + // Optional: cursor 138 + if cursor := r.URL.Query().Get("cursor"); cursor != "" { 139 + req.Cursor = &cursor 140 + } 141 + 142 + return req, nil 143 + } 144 + 145 + // resolveActor converts an actor identifier (handle or DID) to a DID 146 + func (h *GetCommentsHandler) resolveActor(r *http.Request, actor string) (string, error) { 147 + // If it's already a DID, return it 148 + if strings.HasPrefix(actor, "did:") { 149 + return actor, nil 150 + } 151 + 152 + // It's a handle - resolve to DID using user service 153 + did, err := h.userService.ResolveHandleToDID(r.Context(), actor) 154 + if err != nil { 155 + // Check for context errors (timeouts, cancellation) - these are infrastructure errors 156 + if r.Context().Err() != nil { 157 + log.Printf("WARN: Handle resolution failed due to context error for %s: %v", actor, err) 158 + return "", &resolutionFailedError{actor: actor, cause: r.Context().Err()} 159 + } 160 + 161 + // Check for common "not found" patterns in error message 162 + errStr := err.Error() 163 + isNotFound := strings.Contains(errStr, "not found") || 164 + strings.Contains(errStr, "no rows") || 165 + strings.Contains(errStr, "unable to resolve") 166 + 167 + if isNotFound { 168 + return "", &actorNotFoundError{actor: actor} 169 + } 170 + 171 + // For other errors (network, database, DNS failures), return infrastructure error 172 + // This ensures users see "internal error" not "actor not found" for real problems 173 + log.Printf("WARN: Handle resolution infrastructure failure for %s: %v", actor, err) 174 + return "", &resolutionFailedError{actor: actor, cause: err} 175 + } 176 + 177 + return did, nil 178 + } 179 + 180 + // populateViewerVoteState enriches comment views with the authenticated user's vote state 181 + func (h *GetCommentsHandler) populateViewerVoteState(r *http.Request, response *comments.GetActorCommentsResponse) { 182 + if h.voteService == nil || response == nil || len(response.Comments) == 0 { 183 + return 184 + } 185 + 186 + session := middleware.GetOAuthSession(r) 187 + if session == nil { 188 + return 189 + } 190 + 191 + userDID := middleware.GetUserDID(r) 192 + if userDID == "" { 193 + return 194 + } 195 + 196 + // Ensure vote cache is populated from PDS 197 + if err := h.voteService.EnsureCachePopulated(r.Context(), session); err != nil { 198 + log.Printf("Warning: failed to populate vote cache for actor comments: %v", err) 199 + return 200 + } 201 + 202 + // Collect comment URIs to batch lookup 203 + commentURIs := make([]string, 0, len(response.Comments)) 204 + for _, comment := range response.Comments { 205 + if comment != nil { 206 + commentURIs = append(commentURIs, comment.URI) 207 + } 208 + } 209 + 210 + // Get viewer votes for all comments 211 + viewerVotes := h.voteService.GetViewerVotesForSubjects(userDID, commentURIs) 212 + 213 + // Populate viewer state on each comment 214 + for _, comment := range response.Comments { 215 + if comment != nil { 216 + if vote, exists := viewerVotes[comment.URI]; exists { 217 + comment.Viewer = &comments.CommentViewerState{ 218 + Vote: &vote.Direction, 219 + VoteURI: &vote.URI, 220 + } 221 + } 222 + } 223 + } 224 + } 225 + 226 + // handleCommentServiceError maps service errors to HTTP responses 227 + func handleCommentServiceError(w http.ResponseWriter, err error) { 228 + if err == nil { 229 + return 230 + } 231 + 232 + errStr := err.Error() 233 + 234 + // Check for validation errors 235 + if strings.Contains(errStr, "invalid request") { 236 + writeError(w, http.StatusBadRequest, "InvalidRequest", errStr) 237 + return 238 + } 239 + 240 + // Check for not found errors 241 + if comments.IsNotFound(err) || strings.Contains(errStr, "not found") { 242 + writeError(w, http.StatusNotFound, "NotFound", "Resource not found") 243 + return 244 + } 245 + 246 + // Check for authorization errors 247 + if errors.Is(err, comments.ErrNotAuthorized) { 248 + writeError(w, http.StatusForbidden, "NotAuthorized", "Not authorized") 249 + return 250 + } 251 + 252 + // Default to internal server error 253 + log.Printf("ERROR: Comment service error: %v", err) 254 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") 255 + } 256 + 257 + // validationError represents a validation error for a specific field 258 + type validationError struct { 259 + field string 260 + message string 261 + } 262 + 263 + func (e *validationError) Error() string { 264 + return e.message 265 + }
+625
internal/api/handlers/actor/get_comments_test.go
··· 1 + package actor 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "net/http" 8 + "net/http/httptest" 9 + "testing" 10 + "time" 11 + 12 + "Coves/internal/core/comments" 13 + "Coves/internal/core/posts" 14 + "Coves/internal/core/users" 15 + "Coves/internal/core/votes" 16 + 17 + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 18 + ) 19 + 20 + // mockCommentService implements a comment service interface for testing 21 + type mockCommentService struct { 22 + getActorCommentsFunc func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) 23 + } 24 + 25 + func (m *mockCommentService) GetActorComments(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 26 + if m.getActorCommentsFunc != nil { 27 + return m.getActorCommentsFunc(ctx, req) 28 + } 29 + return &comments.GetActorCommentsResponse{ 30 + Comments: []*comments.CommentView{}, 31 + Cursor: nil, 32 + }, nil 33 + } 34 + 35 + // Implement other Service methods as no-ops 36 + func (m *mockCommentService) GetComments(ctx context.Context, req *comments.GetCommentsRequest) (*comments.GetCommentsResponse, error) { 37 + return nil, nil 38 + } 39 + 40 + func (m *mockCommentService) CreateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.CreateCommentRequest) (*comments.CreateCommentResponse, error) { 41 + return nil, nil 42 + } 43 + 44 + func (m *mockCommentService) UpdateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.UpdateCommentRequest) (*comments.UpdateCommentResponse, error) { 45 + return nil, nil 46 + } 47 + 48 + func (m *mockCommentService) DeleteComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.DeleteCommentRequest) error { 49 + return nil 50 + } 51 + 52 + // mockUserServiceForComments implements users.UserService for testing getComments 53 + type mockUserServiceForComments struct { 54 + resolveHandleToDIDFunc func(ctx context.Context, handle string) (string, error) 55 + } 56 + 57 + func (m *mockUserServiceForComments) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) { 58 + return nil, nil 59 + } 60 + 61 + func (m *mockUserServiceForComments) GetUserByDID(ctx context.Context, did string) (*users.User, error) { 62 + return nil, nil 63 + } 64 + 65 + func (m *mockUserServiceForComments) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) { 66 + return nil, nil 67 + } 68 + 69 + func (m *mockUserServiceForComments) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) { 70 + return nil, nil 71 + } 72 + 73 + func (m *mockUserServiceForComments) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { 74 + if m.resolveHandleToDIDFunc != nil { 75 + return m.resolveHandleToDIDFunc(ctx, handle) 76 + } 77 + return "did:plc:testuser", nil 78 + } 79 + 80 + func (m *mockUserServiceForComments) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) { 81 + return nil, nil 82 + } 83 + 84 + func (m *mockUserServiceForComments) IndexUser(ctx context.Context, did, handle, pdsURL string) error { 85 + return nil 86 + } 87 + 88 + func (m *mockUserServiceForComments) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) { 89 + return nil, nil 90 + } 91 + 92 + func (m *mockUserServiceForComments) DeleteAccount(ctx context.Context, did string) error { 93 + return nil 94 + } 95 + 96 + func (m *mockUserServiceForComments) UpdateProfile(ctx context.Context, did string, input users.UpdateProfileInput) (*users.User, error) { 97 + return nil, nil 98 + } 99 + 100 + // mockVoteServiceForComments implements votes.Service for testing getComments 101 + type mockVoteServiceForComments struct{} 102 + 103 + func (m *mockVoteServiceForComments) CreateVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) { 104 + return nil, nil 105 + } 106 + 107 + func (m *mockVoteServiceForComments) DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error { 108 + return nil 109 + } 110 + 111 + func (m *mockVoteServiceForComments) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error { 112 + return nil 113 + } 114 + 115 + func (m *mockVoteServiceForComments) GetViewerVote(userDID, subjectURI string) *votes.CachedVote { 116 + return nil 117 + } 118 + 119 + func (m *mockVoteServiceForComments) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote { 120 + return nil 121 + } 122 + 123 + func TestGetCommentsHandler_Success(t *testing.T) { 124 + createdAt := time.Now().Format(time.RFC3339) 125 + indexedAt := time.Now().Format(time.RFC3339) 126 + 127 + mockComments := &mockCommentService{ 128 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 129 + return &comments.GetActorCommentsResponse{ 130 + Comments: []*comments.CommentView{ 131 + { 132 + URI: "at://did:plc:testuser/social.coves.community.comment/abc123", 133 + CID: "bafytest123", 134 + Content: "Test comment content", 135 + CreatedAt: createdAt, 136 + IndexedAt: indexedAt, 137 + Author: &posts.AuthorView{ 138 + DID: "did:plc:testuser", 139 + Handle: "test.user", 140 + }, 141 + Stats: &comments.CommentStats{ 142 + Upvotes: 5, 143 + Downvotes: 1, 144 + Score: 4, 145 + ReplyCount: 2, 146 + }, 147 + }, 148 + }, 149 + }, nil 150 + }, 151 + } 152 + mockUsers := &mockUserServiceForComments{} 153 + mockVotes := &mockVoteServiceForComments{} 154 + 155 + handler := NewGetCommentsHandler(mockComments, mockUsers, mockVotes) 156 + 157 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:testuser", nil) 158 + rec := httptest.NewRecorder() 159 + 160 + handler.HandleGetComments(rec, req) 161 + 162 + if rec.Code != http.StatusOK { 163 + t.Errorf("Expected status 200, got %d", rec.Code) 164 + } 165 + 166 + var response comments.GetActorCommentsResponse 167 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 168 + t.Fatalf("Failed to decode response: %v", err) 169 + } 170 + 171 + if len(response.Comments) != 1 { 172 + t.Errorf("Expected 1 comment in response, got %d", len(response.Comments)) 173 + } 174 + 175 + if response.Comments[0].URI != "at://did:plc:testuser/social.coves.community.comment/abc123" { 176 + t.Errorf("Expected correct comment URI, got '%s'", response.Comments[0].URI) 177 + } 178 + 179 + if response.Comments[0].Content != "Test comment content" { 180 + t.Errorf("Expected correct comment content, got '%s'", response.Comments[0].Content) 181 + } 182 + } 183 + 184 + func TestGetCommentsHandler_MissingActor(t *testing.T) { 185 + handler := NewGetCommentsHandler( 186 + &mockCommentService{}, 187 + &mockUserServiceForComments{}, 188 + &mockVoteServiceForComments{}, 189 + ) 190 + 191 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments", nil) 192 + rec := httptest.NewRecorder() 193 + 194 + handler.HandleGetComments(rec, req) 195 + 196 + if rec.Code != http.StatusBadRequest { 197 + t.Errorf("Expected status 400, got %d", rec.Code) 198 + } 199 + 200 + var response ErrorResponse 201 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 202 + t.Fatalf("Failed to decode response: %v", err) 203 + } 204 + 205 + if response.Error != "InvalidRequest" { 206 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 207 + } 208 + } 209 + 210 + func TestGetCommentsHandler_InvalidLimit(t *testing.T) { 211 + handler := NewGetCommentsHandler( 212 + &mockCommentService{}, 213 + &mockUserServiceForComments{}, 214 + &mockVoteServiceForComments{}, 215 + ) 216 + 217 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=abc", nil) 218 + rec := httptest.NewRecorder() 219 + 220 + handler.HandleGetComments(rec, req) 221 + 222 + if rec.Code != http.StatusBadRequest { 223 + t.Errorf("Expected status 400, got %d", rec.Code) 224 + } 225 + 226 + var response ErrorResponse 227 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 228 + t.Fatalf("Failed to decode response: %v", err) 229 + } 230 + 231 + if response.Error != "InvalidRequest" { 232 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 233 + } 234 + } 235 + 236 + func TestGetCommentsHandler_ActorNotFound(t *testing.T) { 237 + mockUsers := &mockUserServiceForComments{ 238 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 239 + return "", posts.ErrActorNotFound 240 + }, 241 + } 242 + 243 + handler := NewGetCommentsHandler( 244 + &mockCommentService{}, 245 + mockUsers, 246 + &mockVoteServiceForComments{}, 247 + ) 248 + 249 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=nonexistent.user", nil) 250 + rec := httptest.NewRecorder() 251 + 252 + handler.HandleGetComments(rec, req) 253 + 254 + if rec.Code != http.StatusNotFound { 255 + t.Errorf("Expected status 404, got %d", rec.Code) 256 + } 257 + 258 + var response ErrorResponse 259 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 260 + t.Fatalf("Failed to decode response: %v", err) 261 + } 262 + 263 + if response.Error != "ActorNotFound" { 264 + t.Errorf("Expected error 'ActorNotFound', got '%s'", response.Error) 265 + } 266 + } 267 + 268 + func TestGetCommentsHandler_ActorLengthExceedsMax(t *testing.T) { 269 + handler := NewGetCommentsHandler( 270 + &mockCommentService{}, 271 + &mockUserServiceForComments{}, 272 + &mockVoteServiceForComments{}, 273 + ) 274 + 275 + // Create an actor parameter that exceeds 2048 characters using valid URL characters 276 + longActorBytes := make([]byte, 2100) 277 + for i := range longActorBytes { 278 + longActorBytes[i] = 'a' 279 + } 280 + longActor := "did:plc:" + string(longActorBytes) 281 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor="+longActor, nil) 282 + rec := httptest.NewRecorder() 283 + 284 + handler.HandleGetComments(rec, req) 285 + 286 + if rec.Code != http.StatusBadRequest { 287 + t.Errorf("Expected status 400, got %d", rec.Code) 288 + } 289 + } 290 + 291 + func TestGetCommentsHandler_InvalidCursor(t *testing.T) { 292 + // The handleCommentServiceError function checks for "invalid request" in error message 293 + // to return a BadRequest. An invalid cursor error falls under this category. 294 + mockComments := &mockCommentService{ 295 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 296 + return nil, errors.New("invalid request: invalid cursor format") 297 + }, 298 + } 299 + 300 + handler := NewGetCommentsHandler( 301 + mockComments, 302 + &mockUserServiceForComments{}, 303 + &mockVoteServiceForComments{}, 304 + ) 305 + 306 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=invalid", nil) 307 + rec := httptest.NewRecorder() 308 + 309 + handler.HandleGetComments(rec, req) 310 + 311 + if rec.Code != http.StatusBadRequest { 312 + t.Errorf("Expected status 400, got %d", rec.Code) 313 + } 314 + 315 + var response ErrorResponse 316 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 317 + t.Fatalf("Failed to decode response: %v", err) 318 + } 319 + 320 + if response.Error != "InvalidRequest" { 321 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 322 + } 323 + } 324 + 325 + func TestGetCommentsHandler_MethodNotAllowed(t *testing.T) { 326 + handler := NewGetCommentsHandler( 327 + &mockCommentService{}, 328 + &mockUserServiceForComments{}, 329 + &mockVoteServiceForComments{}, 330 + ) 331 + 332 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.getComments", nil) 333 + rec := httptest.NewRecorder() 334 + 335 + handler.HandleGetComments(rec, req) 336 + 337 + if rec.Code != http.StatusMethodNotAllowed { 338 + t.Errorf("Expected status 405, got %d", rec.Code) 339 + } 340 + } 341 + 342 + func TestGetCommentsHandler_HandleResolution(t *testing.T) { 343 + resolvedDID := "" 344 + mockComments := &mockCommentService{ 345 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 346 + resolvedDID = req.ActorDID 347 + return &comments.GetActorCommentsResponse{Comments: []*comments.CommentView{}}, nil 348 + }, 349 + } 350 + mockUsers := &mockUserServiceForComments{ 351 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 352 + if handle == "test.user" { 353 + return "did:plc:resolveduser123", nil 354 + } 355 + return "", posts.ErrActorNotFound 356 + }, 357 + } 358 + 359 + handler := NewGetCommentsHandler( 360 + mockComments, 361 + mockUsers, 362 + &mockVoteServiceForComments{}, 363 + ) 364 + 365 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil) 366 + rec := httptest.NewRecorder() 367 + 368 + handler.HandleGetComments(rec, req) 369 + 370 + if rec.Code != http.StatusOK { 371 + t.Errorf("Expected status 200, got %d", rec.Code) 372 + } 373 + 374 + if resolvedDID != "did:plc:resolveduser123" { 375 + t.Errorf("Expected resolved DID 'did:plc:resolveduser123', got '%s'", resolvedDID) 376 + } 377 + } 378 + 379 + func TestGetCommentsHandler_DIDPassThrough(t *testing.T) { 380 + receivedDID := "" 381 + mockComments := &mockCommentService{ 382 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 383 + receivedDID = req.ActorDID 384 + return &comments.GetActorCommentsResponse{Comments: []*comments.CommentView{}}, nil 385 + }, 386 + } 387 + 388 + handler := NewGetCommentsHandler( 389 + mockComments, 390 + &mockUserServiceForComments{}, 391 + &mockVoteServiceForComments{}, 392 + ) 393 + 394 + // When actor is already a DID, it should pass through without resolution 395 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:directuser", nil) 396 + rec := httptest.NewRecorder() 397 + 398 + handler.HandleGetComments(rec, req) 399 + 400 + if rec.Code != http.StatusOK { 401 + t.Errorf("Expected status 200, got %d", rec.Code) 402 + } 403 + 404 + if receivedDID != "did:plc:directuser" { 405 + t.Errorf("Expected DID 'did:plc:directuser', got '%s'", receivedDID) 406 + } 407 + } 408 + 409 + func TestGetCommentsHandler_EmptyCommentsArray(t *testing.T) { 410 + mockComments := &mockCommentService{ 411 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 412 + return &comments.GetActorCommentsResponse{ 413 + Comments: []*comments.CommentView{}, 414 + }, nil 415 + }, 416 + } 417 + 418 + handler := NewGetCommentsHandler( 419 + mockComments, 420 + &mockUserServiceForComments{}, 421 + &mockVoteServiceForComments{}, 422 + ) 423 + 424 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:newuser", nil) 425 + rec := httptest.NewRecorder() 426 + 427 + handler.HandleGetComments(rec, req) 428 + 429 + if rec.Code != http.StatusOK { 430 + t.Errorf("Expected status 200, got %d", rec.Code) 431 + } 432 + 433 + var response comments.GetActorCommentsResponse 434 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 435 + t.Fatalf("Failed to decode response: %v", err) 436 + } 437 + 438 + if response.Comments == nil { 439 + t.Error("Expected comments array to be non-nil (empty array), got nil") 440 + } 441 + 442 + if len(response.Comments) != 0 { 443 + t.Errorf("Expected 0 comments for new user, got %d", len(response.Comments)) 444 + } 445 + } 446 + 447 + func TestGetCommentsHandler_WithCursor(t *testing.T) { 448 + receivedCursor := "" 449 + mockComments := &mockCommentService{ 450 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 451 + if req.Cursor != nil { 452 + receivedCursor = *req.Cursor 453 + } 454 + nextCursor := "page2cursor" 455 + return &comments.GetActorCommentsResponse{ 456 + Comments: []*comments.CommentView{}, 457 + Cursor: &nextCursor, 458 + }, nil 459 + }, 460 + } 461 + 462 + handler := NewGetCommentsHandler( 463 + mockComments, 464 + &mockUserServiceForComments{}, 465 + &mockVoteServiceForComments{}, 466 + ) 467 + 468 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=testcursor123", nil) 469 + rec := httptest.NewRecorder() 470 + 471 + handler.HandleGetComments(rec, req) 472 + 473 + if rec.Code != http.StatusOK { 474 + t.Errorf("Expected status 200, got %d", rec.Code) 475 + } 476 + 477 + if receivedCursor != "testcursor123" { 478 + t.Errorf("Expected cursor 'testcursor123', got '%s'", receivedCursor) 479 + } 480 + 481 + var response comments.GetActorCommentsResponse 482 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 483 + t.Fatalf("Failed to decode response: %v", err) 484 + } 485 + 486 + if response.Cursor == nil || *response.Cursor != "page2cursor" { 487 + t.Error("Expected response to include next cursor") 488 + } 489 + } 490 + 491 + func TestGetCommentsHandler_WithLimit(t *testing.T) { 492 + receivedLimit := 0 493 + mockComments := &mockCommentService{ 494 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 495 + receivedLimit = req.Limit 496 + return &comments.GetActorCommentsResponse{ 497 + Comments: []*comments.CommentView{}, 498 + }, nil 499 + }, 500 + } 501 + 502 + handler := NewGetCommentsHandler( 503 + mockComments, 504 + &mockUserServiceForComments{}, 505 + &mockVoteServiceForComments{}, 506 + ) 507 + 508 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=25", nil) 509 + rec := httptest.NewRecorder() 510 + 511 + handler.HandleGetComments(rec, req) 512 + 513 + if rec.Code != http.StatusOK { 514 + t.Errorf("Expected status 200, got %d", rec.Code) 515 + } 516 + 517 + if receivedLimit != 25 { 518 + t.Errorf("Expected limit 25, got %d", receivedLimit) 519 + } 520 + } 521 + 522 + func TestGetCommentsHandler_WithCommunityFilter(t *testing.T) { 523 + receivedCommunity := "" 524 + mockComments := &mockCommentService{ 525 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 526 + receivedCommunity = req.Community 527 + return &comments.GetActorCommentsResponse{ 528 + Comments: []*comments.CommentView{}, 529 + }, nil 530 + }, 531 + } 532 + 533 + handler := NewGetCommentsHandler( 534 + mockComments, 535 + &mockUserServiceForComments{}, 536 + &mockVoteServiceForComments{}, 537 + ) 538 + 539 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&community=did:plc:community123", nil) 540 + rec := httptest.NewRecorder() 541 + 542 + handler.HandleGetComments(rec, req) 543 + 544 + if rec.Code != http.StatusOK { 545 + t.Errorf("Expected status 200, got %d", rec.Code) 546 + } 547 + 548 + if receivedCommunity != "did:plc:community123" { 549 + t.Errorf("Expected community 'did:plc:community123', got '%s'", receivedCommunity) 550 + } 551 + } 552 + 553 + func TestGetCommentsHandler_ServiceError_Returns500(t *testing.T) { 554 + // Test that generic service errors (database failures, etc.) return 500 555 + mockComments := &mockCommentService{ 556 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 557 + return nil, errors.New("database connection failed") 558 + }, 559 + } 560 + 561 + handler := NewGetCommentsHandler( 562 + mockComments, 563 + &mockUserServiceForComments{}, 564 + &mockVoteServiceForComments{}, 565 + ) 566 + 567 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test", nil) 568 + rec := httptest.NewRecorder() 569 + 570 + handler.HandleGetComments(rec, req) 571 + 572 + if rec.Code != http.StatusInternalServerError { 573 + t.Errorf("Expected status 500, got %d", rec.Code) 574 + } 575 + 576 + var response ErrorResponse 577 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 578 + t.Fatalf("Failed to decode response: %v", err) 579 + } 580 + 581 + if response.Error != "InternalServerError" { 582 + t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error) 583 + } 584 + 585 + // Verify error message doesn't leak internal details 586 + if response.Message == "database connection failed" { 587 + t.Error("Error message should not leak internal error details") 588 + } 589 + } 590 + 591 + func TestGetCommentsHandler_ResolutionFailedError_Returns500(t *testing.T) { 592 + // Test that infrastructure failures during handle resolution return 500, not 400 593 + mockUsers := &mockUserServiceForComments{ 594 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 595 + // Simulate a database failure during resolution 596 + return "", errors.New("connection refused") 597 + }, 598 + } 599 + 600 + handler := NewGetCommentsHandler( 601 + &mockCommentService{}, 602 + mockUsers, 603 + &mockVoteServiceForComments{}, 604 + ) 605 + 606 + // Use a handle (not a DID) to trigger resolution 607 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil) 608 + rec := httptest.NewRecorder() 609 + 610 + handler.HandleGetComments(rec, req) 611 + 612 + // Infrastructure failures should return 500, not 400 or 404 613 + if rec.Code != http.StatusInternalServerError { 614 + t.Errorf("Expected status 500 for infrastructure failure, got %d", rec.Code) 615 + } 616 + 617 + var response ErrorResponse 618 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 619 + t.Fatalf("Failed to decode response: %v", err) 620 + } 621 + 622 + if response.Error != "InternalServerError" { 623 + t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error) 624 + } 625 + }
+60
internal/atproto/lexicon/social/coves/actor/getComments.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.actor.getComments", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a user's comments for their profile page.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "DID or handle of the user" 16 + }, 17 + "community": { 18 + "type": "string", 19 + "format": "at-identifier", 20 + "description": "Filter to comments in a specific community" 21 + }, 22 + "limit": { 23 + "type": "integer", 24 + "minimum": 1, 25 + "maximum": 100, 26 + "default": 50 27 + }, 28 + "cursor": { 29 + "type": "string" 30 + } 31 + } 32 + }, 33 + "output": { 34 + "encoding": "application/json", 35 + "schema": { 36 + "type": "object", 37 + "required": ["comments"], 38 + "properties": { 39 + "comments": { 40 + "type": "array", 41 + "items": { 42 + "type": "ref", 43 + "ref": "social.coves.community.comment.defs#commentView" 44 + } 45 + }, 46 + "cursor": { 47 + "type": "string" 48 + } 49 + } 50 + } 51 + }, 52 + "errors": [ 53 + { 54 + "name": "NotFound", 55 + "description": "Actor not found" 56 + } 57 + ] 58 + } 59 + } 60 + }
+9
internal/core/comments/comment.go
··· 79 79 Neg *bool `json:"neg,omitempty"` 80 80 Val string `json:"val"` 81 81 } 82 + 83 + // ListByCommenterRequest defines the parameters for fetching a user's comments 84 + // Used by social.coves.actor.getComments endpoint 85 + type ListByCommenterRequest struct { 86 + CommenterDID string // Required: DID of the commenter 87 + CommunityDID *string // Optional: filter to comments in a specific community 88 + Limit int // Max comments to return (1-100) 89 + Cursor *string // Pagination cursor from previous response 90 + }
+132
internal/core/comments/comment_service.go
··· 46 46 // Supports hot, top, and new sorting with configurable depth and pagination 47 47 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 48 48 49 + // GetActorComments retrieves comments by a user for their profile page 50 + // Supports optional community filtering and cursor-based pagination 51 + GetActorComments(ctx context.Context, req *GetActorCommentsRequest) (*GetActorCommentsResponse, error) 52 + 49 53 // CreateComment creates a new comment or reply 50 54 CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) 51 55 ··· 1016 1020 return record 1017 1021 } 1018 1022 1023 + // GetActorComments retrieves comments by a user for their profile page 1024 + // Supports optional community filtering and cursor-based pagination 1025 + // Algorithm: 1026 + // 1. Validate and normalize request parameters (limit bounds) 1027 + // 2. Resolve community identifier to DID if provided 1028 + // 3. Fetch comments from repository with cursor-based pagination 1029 + // 4. Build CommentView for each comment with author info and stats 1030 + // 5. Return response with pagination cursor 1031 + func (s *commentService) GetActorComments(ctx context.Context, req *GetActorCommentsRequest) (*GetActorCommentsResponse, error) { 1032 + // 1. Validate and normalize request 1033 + if err := validateGetActorCommentsRequest(req); err != nil { 1034 + return nil, fmt.Errorf("invalid request: %w", err) 1035 + } 1036 + 1037 + // Add timeout to prevent runaway queries 1038 + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 1039 + defer cancel() 1040 + 1041 + // 2. Resolve community identifier to DID if provided 1042 + var communityDID *string 1043 + if req.Community != "" { 1044 + // Check if it's already a DID 1045 + if strings.HasPrefix(req.Community, "did:") { 1046 + communityDID = &req.Community 1047 + } else { 1048 + // It's a handle - resolve to DID via community repository 1049 + community, err := s.communityRepo.GetByHandle(ctx, req.Community) 1050 + if err != nil { 1051 + // If community not found, return empty results rather than error 1052 + // This matches behavior of other endpoints 1053 + if errors.Is(err, communities.ErrCommunityNotFound) { 1054 + return &GetActorCommentsResponse{ 1055 + Comments: []*CommentView{}, 1056 + Cursor: nil, 1057 + }, nil 1058 + } 1059 + return nil, fmt.Errorf("failed to resolve community: %w", err) 1060 + } 1061 + communityDID = &community.DID 1062 + } 1063 + } 1064 + 1065 + // 3. Fetch comments from repository 1066 + repoReq := ListByCommenterRequest{ 1067 + CommenterDID: req.ActorDID, 1068 + CommunityDID: communityDID, 1069 + Limit: req.Limit, 1070 + Cursor: req.Cursor, 1071 + } 1072 + 1073 + dbComments, nextCursor, err := s.commentRepo.ListByCommenterWithCursor(ctx, repoReq) 1074 + if err != nil { 1075 + return nil, fmt.Errorf("failed to fetch comments: %w", err) 1076 + } 1077 + 1078 + // 4. Build CommentViews for each comment 1079 + // Batch fetch vote states if viewer is authenticated 1080 + var voteStates map[string]interface{} 1081 + if req.ViewerDID != nil && len(dbComments) > 0 { 1082 + commentURIs := make([]string, 0, len(dbComments)) 1083 + for _, comment := range dbComments { 1084 + commentURIs = append(commentURIs, comment.URI) 1085 + } 1086 + 1087 + var err error 1088 + voteStates, err = s.commentRepo.GetVoteStateForComments(ctx, *req.ViewerDID, commentURIs) 1089 + if err != nil { 1090 + // Log error but don't fail the request - vote state is optional 1091 + log.Printf("Warning: Failed to fetch vote states for actor comments: %v", err) 1092 + } 1093 + } 1094 + 1095 + // Batch fetch user data for comment authors (should all be the same user, but handle consistently) 1096 + usersByDID := make(map[string]*users.User) 1097 + if len(dbComments) > 0 { 1098 + // For actor comments, all comments are by the same user 1099 + // But we still use the batch pattern for consistency with other methods 1100 + user, err := s.userRepo.GetByDID(ctx, req.ActorDID) 1101 + if err != nil { 1102 + // Log error but don't fail request - user data is optional 1103 + log.Printf("Warning: Failed to fetch user for actor %s: %v", req.ActorDID, err) 1104 + } else if user != nil { 1105 + usersByDID[user.DID] = user 1106 + } 1107 + } 1108 + 1109 + // Build comment views 1110 + commentViews := make([]*CommentView, 0, len(dbComments)) 1111 + for _, comment := range dbComments { 1112 + commentView := s.buildCommentView(comment, req.ViewerDID, voteStates, usersByDID) 1113 + commentViews = append(commentViews, commentView) 1114 + } 1115 + 1116 + // 5. Return response with comments and cursor 1117 + return &GetActorCommentsResponse{ 1118 + Comments: commentViews, 1119 + Cursor: nextCursor, 1120 + }, nil 1121 + } 1122 + 1123 + // validateGetActorCommentsRequest validates and normalizes request parameters 1124 + // Applies default values and enforces bounds per API specification 1125 + func validateGetActorCommentsRequest(req *GetActorCommentsRequest) error { 1126 + if req == nil { 1127 + return errors.New("request cannot be nil") 1128 + } 1129 + 1130 + // ActorDID is required 1131 + if req.ActorDID == "" { 1132 + return errors.New("actor DID is required") 1133 + } 1134 + 1135 + // Validate DID format 1136 + if !strings.HasPrefix(req.ActorDID, "did:") { 1137 + return errors.New("invalid actor DID format") 1138 + } 1139 + 1140 + // Apply limit defaults and bounds (1-100, default 50) 1141 + if req.Limit <= 0 { 1142 + req.Limit = 50 1143 + } 1144 + if req.Limit > 100 { 1145 + req.Limit = 100 1146 + } 1147 + 1148 + return nil 1149 + } 1150 + 1019 1151 // validateGetCommentsRequest validates and normalizes request parameters 1020 1152 // Applies default values and enforces bounds per API specification 1021 1153 func validateGetCommentsRequest(req *GetCommentsRequest) error {
+6 -1
internal/core/comments/interfaces.go
··· 50 50 CountByParent(ctx context.Context, parentURI string) (int, error) 51 51 52 52 // ListByCommenter retrieves all comments by a specific user 53 - // Future: Used for user comment history 53 + // Deprecated: Use ListByCommenterWithCursor for cursor-based pagination 54 54 ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) 55 55 56 + // ListByCommenterWithCursor retrieves comments by a user with cursor-based pagination 57 + // Used for user profile comment history (social.coves.actor.getComments) 58 + // Supports optional community filtering and returns next page cursor 59 + ListByCommenterWithCursor(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) 60 + 56 61 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 57 62 // Supports hot, top, and new sorting with cursor-based pagination 58 63 // Returns comments with author info hydrated and next page cursor
+17
internal/core/comments/view_models.go
··· 67 67 Cursor *string `json:"cursor,omitempty"` 68 68 Comments []*ThreadViewComment `json:"comments"` 69 69 } 70 + 71 + // GetActorCommentsRequest defines the parameters for fetching a user's comments 72 + // Used by social.coves.actor.getComments endpoint 73 + type GetActorCommentsRequest struct { 74 + ActorDID string // Required: DID of the commenter 75 + Community string // Optional: filter to comments in a specific community (handle or DID) 76 + Limit int // Max comments to return (1-100, default 50) 77 + Cursor *string // Pagination cursor from previous response 78 + ViewerDID *string // Optional: DID of the viewer for populating viewer state 79 + } 80 + 81 + // GetActorCommentsResponse represents the response for fetching a user's comments 82 + // Matches social.coves.actor.getComments lexicon output 83 + type GetActorCommentsResponse struct { 84 + Comments []*CommentView `json:"comments"` 85 + Cursor *string `json:"cursor,omitempty"` 86 + }
+151
internal/db/postgres/comment_repo.go
··· 410 410 return result, nil 411 411 } 412 412 413 + // ListByCommenterWithCursor retrieves comments by a user with cursor-based pagination 414 + // Used for user profile comment history (social.coves.actor.getComments) 415 + // Supports optional community filtering and returns next page cursor 416 + // Uses chronological ordering (newest first) with composite key cursor for stable pagination 417 + func (r *postgresCommentRepo) ListByCommenterWithCursor(ctx context.Context, req comments.ListByCommenterRequest) ([]*comments.Comment, *string, error) { 418 + // Parse cursor for pagination 419 + cursorFilter, cursorValues, err := r.parseCommenterCursor(req.Cursor) 420 + if err != nil { 421 + return nil, nil, fmt.Errorf("invalid cursor: %w", err) 422 + } 423 + 424 + // Build community filter if provided 425 + // Parameter numbering: $1=commenterDID, $2=limit+1 (for pagination detection) 426 + // Cursor values (if present) use $3 and $4, community DID comes after 427 + var communityFilter string 428 + var communityValue []interface{} 429 + paramOffset := 2 + len(cursorValues) // Start after $1, $2, and any cursor params 430 + if req.CommunityDID != nil && *req.CommunityDID != "" { 431 + paramOffset++ 432 + communityFilter = fmt.Sprintf("AND c.root_uri IN (SELECT uri FROM posts WHERE community_did = $%d)", paramOffset) 433 + communityValue = append(communityValue, *req.CommunityDID) 434 + } 435 + 436 + // Build complete query with JOINs and filters 437 + // LEFT JOIN prevents data loss when user record hasn't been indexed yet 438 + query := fmt.Sprintf(` 439 + SELECT 440 + c.id, c.uri, c.cid, c.rkey, c.commenter_did, 441 + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 442 + c.content, c.content_facets, c.embed, c.content_labels, c.langs, 443 + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, 444 + c.upvote_count, c.downvote_count, c.score, c.reply_count, 445 + COALESCE(u.handle, c.commenter_did) as author_handle 446 + FROM comments c 447 + LEFT JOIN users u ON c.commenter_did = u.did 448 + WHERE c.commenter_did = $1 449 + AND c.deleted_at IS NULL 450 + %s 451 + %s 452 + ORDER BY c.created_at DESC, c.uri DESC 453 + LIMIT $2 454 + `, communityFilter, cursorFilter) 455 + 456 + // Prepare query arguments 457 + args := []interface{}{req.CommenterDID, req.Limit + 1} // +1 to detect next page 458 + args = append(args, cursorValues...) 459 + args = append(args, communityValue...) 460 + 461 + // Execute query 462 + rows, err := r.db.QueryContext(ctx, query, args...) 463 + if err != nil { 464 + return nil, nil, fmt.Errorf("failed to query comments by commenter: %w", err) 465 + } 466 + defer func() { 467 + if err := rows.Close(); err != nil { 468 + log.Printf("Failed to close rows: %v", err) 469 + } 470 + }() 471 + 472 + // Scan results 473 + var result []*comments.Comment 474 + for rows.Next() { 475 + var comment comments.Comment 476 + var langs pq.StringArray 477 + var authorHandle string 478 + 479 + err := rows.Scan( 480 + &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 481 + &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 482 + &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 483 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, 484 + &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 485 + &authorHandle, 486 + ) 487 + if err != nil { 488 + return nil, nil, fmt.Errorf("failed to scan comment: %w", err) 489 + } 490 + 491 + comment.Langs = langs 492 + comment.CommenterHandle = authorHandle 493 + result = append(result, &comment) 494 + } 495 + 496 + if err = rows.Err(); err != nil { 497 + return nil, nil, fmt.Errorf("error iterating comments: %w", err) 498 + } 499 + 500 + // Handle pagination cursor 501 + var nextCursor *string 502 + if len(result) > req.Limit && req.Limit > 0 { 503 + result = result[:req.Limit] 504 + lastComment := result[len(result)-1] 505 + cursorStr := r.buildCommenterCursor(lastComment) 506 + nextCursor = &cursorStr 507 + } 508 + 509 + return result, nextCursor, nil 510 + } 511 + 512 + // parseCommenterCursor decodes pagination cursor for commenter comments 513 + // Cursor format: createdAt|uri (same as "new" sort for other comment queries) 514 + // 515 + // IMPORTANT: This function returns a filter string with hardcoded parameter numbers ($3, $4). 516 + // The caller (ListByCommenterWithCursor) must ensure parameters are ordered as: 517 + // $1=commenterDID, $2=limit+1, $3=createdAt, $4=uri, then community DID if present. 518 + // If you modify the parameter order in the caller, you must update the filter here. 519 + func (r *postgresCommentRepo) parseCommenterCursor(cursor *string) (string, []interface{}, error) { 520 + if cursor == nil || *cursor == "" { 521 + return "", nil, nil 522 + } 523 + 524 + // Validate cursor size to prevent DoS via massive base64 strings 525 + const maxCursorSize = 1024 526 + if len(*cursor) > maxCursorSize { 527 + return "", nil, fmt.Errorf("cursor too large: maximum %d bytes", maxCursorSize) 528 + } 529 + 530 + // Decode base64 cursor 531 + decoded, err := base64.URLEncoding.DecodeString(*cursor) 532 + if err != nil { 533 + return "", nil, fmt.Errorf("invalid cursor encoding") 534 + } 535 + 536 + // Parse cursor: createdAt|uri 537 + parts := strings.Split(string(decoded), "|") 538 + if len(parts) != 2 { 539 + return "", nil, fmt.Errorf("invalid cursor format") 540 + } 541 + 542 + createdAt := parts[0] 543 + uri := parts[1] 544 + 545 + // Validate AT-URI format 546 + if !strings.HasPrefix(uri, "at://") { 547 + return "", nil, fmt.Errorf("invalid cursor URI") 548 + } 549 + 550 + filter := `AND (c.created_at < $3 OR (c.created_at = $3 AND c.uri < $4))` 551 + return filter, []interface{}{createdAt, uri}, nil 552 + } 553 + 554 + // buildCommenterCursor creates pagination cursor from last comment 555 + // Uses createdAt|uri format for stable pagination 556 + func (r *postgresCommentRepo) buildCommenterCursor(comment *comments.Comment) string { 557 + cursorStr := fmt.Sprintf("%s|%s", 558 + comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"), 559 + comment.URI) 560 + return base64.URLEncoding.EncodeToString([]byte(cursorStr)) 561 + } 562 + 413 563 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 414 564 // Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at) 415 565 // Uses cursor-based pagination with composite keys for consistent ordering ··· 964 1114 // If votes table doesn't exist yet, return empty map instead of error 965 1115 // This allows the API to work before votes indexing is fully implemented 966 1116 if strings.Contains(err.Error(), "does not exist") { 1117 + log.Printf("WARN: Votes table does not exist, returning empty vote state for %d comments", len(commentURIs)) 967 1118 return make(map[string]interface{}), nil 968 1119 } 969 1120 return nil, fmt.Errorf("failed to get vote state for comments: %w", err)
+14 -56
internal/api/handlers/community/block.go
··· 47 47 return 48 48 } 49 49 50 - // Extract authenticated user DID and access token from request context (injected by auth middleware) 51 - userDID := middleware.GetUserDID(r) 52 - if userDID == "" { 50 + // Get OAuth session from context (injected by auth middleware) 51 + // The session contains the user's DID and credentials needed for DPoP authentication 52 + session := middleware.GetOAuthSession(r) 53 + if session == nil { 53 54 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 54 55 return 55 56 } 56 57 57 - userAccessToken := middleware.GetUserAccessToken(r) 58 - if userAccessToken == "" { 59 - writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token") 60 - return 61 - } 62 - 63 - // Resolve community identifier (handle or DID) to DID 64 - // This allows users to block by handle: @gaming.community.coves.social or !gaming@coves.social 65 - communityDID, err := h.service.ResolveCommunityIdentifier(r.Context(), req.Community) 66 - if err != nil { 67 - if communities.IsNotFound(err) { 68 - writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found") 69 - return 70 - } 71 - if communities.IsValidationError(err) { 72 - writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 73 - return 74 - } 75 - log.Printf("Failed to resolve community identifier %s: %v", req.Community, err) 76 - writeError(w, http.StatusInternalServerError, "InternalError", "Failed to resolve community") 77 - return 78 - } 79 - 80 - // Block via service (write-forward to PDS) using resolved DID 81 - block, err := h.service.BlockCommunity(r.Context(), userDID, userAccessToken, communityDID) 58 + // Block via service (write-forward to PDS with DPoP authentication) 59 + // Service handles identifier resolution (DIDs, handles, scoped identifiers) 60 + block, err := h.service.BlockCommunity(r.Context(), session, req.Community) 82 61 if err != nil { 83 62 handleServiceError(w, err) 84 63 return ··· 125 104 return 126 105 } 127 106 128 - // Extract authenticated user DID and access token from request context (injected by auth middleware) 129 - userDID := middleware.GetUserDID(r) 130 - if userDID == "" { 107 + // Get OAuth session from context (injected by auth middleware) 108 + // The session contains the user's DID and credentials needed for DPoP authentication 109 + session := middleware.GetOAuthSession(r) 110 + if session == nil { 131 111 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 132 112 return 133 113 } 134 114 135 - userAccessToken := middleware.GetUserAccessToken(r) 136 - if userAccessToken == "" { 137 - writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token") 138 - return 139 - } 140 - 141 - // Resolve community identifier (handle or DID) to DID 142 - // This allows users to unblock by handle: @gaming.community.coves.social or !gaming@coves.social 143 - communityDID, err := h.service.ResolveCommunityIdentifier(r.Context(), req.Community) 144 - if err != nil { 145 - if communities.IsNotFound(err) { 146 - writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found") 147 - return 148 - } 149 - if communities.IsValidationError(err) { 150 - writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 151 - return 152 - } 153 - log.Printf("Failed to resolve community identifier %s: %v", req.Community, err) 154 - writeError(w, http.StatusInternalServerError, "InternalError", "Failed to resolve community") 155 - return 156 - } 157 - 158 - // Unblock via service (delete record on PDS) using resolved DID 159 - err = h.service.UnblockCommunity(r.Context(), userDID, userAccessToken, communityDID) 115 + // Unblock via service (delete record on PDS with DPoP authentication) 116 + // Service handles identifier resolution (DIDs, handles, scoped identifiers) 117 + err := h.service.UnblockCommunity(r.Context(), session, req.Community) 160 118 if err != nil { 161 119 handleServiceError(w, err) 162 120 return
+6 -4
internal/api/handlers/community/create_test.go
··· 10 10 "net/http/httptest" 11 11 "testing" 12 12 "time" 13 + 14 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 15 ) 14 16 15 17 // mockCommunityService implements communities.Service for testing ··· 49 51 return nil, 0, nil 50 52 } 51 53 52 - func (m *mockCommunityService) SubscribeToCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 54 + func (m *mockCommunityService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 53 55 return nil, nil 54 56 } 55 57 56 - func (m *mockCommunityService) UnsubscribeFromCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) error { 58 + func (m *mockCommunityService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 57 59 return nil 58 60 } 59 61 ··· 65 67 return nil, nil 66 68 } 67 69 68 - func (m *mockCommunityService) BlockCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) (*communities.CommunityBlock, error) { 70 + func (m *mockCommunityService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 69 71 return nil, nil 70 72 } 71 73 72 - func (m *mockCommunityService) UnblockCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) error { 74 + func (m *mockCommunityService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 73 75 return nil 74 76 } 75 77
+12 -23
internal/api/handlers/community/subscribe.go
··· 51 51 return 52 52 } 53 53 54 - // Extract authenticated user DID and access token from request context (injected by auth middleware) 55 - // Note: contentVisibility defaults and clamping handled by service layer 56 - userDID := middleware.GetUserDID(r) 57 - if userDID == "" { 54 + // Get OAuth session from context (injected by auth middleware) 55 + // The session contains the user's DID and credentials needed for DPoP authentication 56 + session := middleware.GetOAuthSession(r) 57 + if session == nil { 58 58 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 59 59 return 60 60 } 61 61 62 - userAccessToken := middleware.GetUserAccessToken(r) 63 - if userAccessToken == "" { 64 - writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token") 65 - return 66 - } 67 - 68 - // Subscribe via service (write-forward to PDS) 62 + // Subscribe via service (write-forward to PDS with DPoP authentication) 69 63 // Service handles identifier resolution (DIDs, handles, scoped identifiers) 70 - subscription, err := h.service.SubscribeToCommunity(r.Context(), userDID, userAccessToken, req.Community, req.ContentVisibility) 64 + subscription, err := h.service.SubscribeToCommunity(r.Context(), session, req.Community, req.ContentVisibility) 71 65 if err != nil { 72 66 handleServiceError(w, err) 73 67 return ··· 117 111 return 118 112 } 119 113 120 - // Extract authenticated user DID and access token from request context (injected by auth middleware) 121 - userDID := middleware.GetUserDID(r) 122 - if userDID == "" { 114 + // Get OAuth session from context (injected by auth middleware) 115 + // The session contains the user's DID and credentials needed for DPoP authentication 116 + session := middleware.GetOAuthSession(r) 117 + if session == nil { 123 118 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 124 119 return 125 120 } 126 121 127 - userAccessToken := middleware.GetUserAccessToken(r) 128 - if userAccessToken == "" { 129 - writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token") 130 - return 131 - } 132 - 133 - // Unsubscribe via service (delete record on PDS) 122 + // Unsubscribe via service (delete record on PDS with DPoP authentication) 134 123 // Service handles identifier resolution (DIDs, handles, scoped identifiers) 135 - err := h.service.UnsubscribeFromCommunity(r.Context(), userDID, userAccessToken, req.Community) 124 + err := h.service.UnsubscribeFromCommunity(r.Context(), session, req.Community) 136 125 if err != nil { 137 126 handleServiceError(w, err) 138 127 return
+89 -29
internal/api/handlers/community/subscribe_test.go
··· 11 11 "net/http/httptest" 12 12 "testing" 13 13 "time" 14 + 15 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 14 17 ) 15 18 19 + // createTestOAuthSession creates a mock OAuth session for testing 20 + func createTestOAuthSession(did string) *oauth.ClientSessionData { 21 + parsedDID, _ := syntax.ParseDID(did) 22 + return &oauth.ClientSessionData{ 23 + AccountDID: parsedDID, 24 + SessionID: "test-session", 25 + HostURL: "http://localhost:3001", 26 + AccessToken: "test-access-token", 27 + } 28 + } 29 + 16 30 // subscribeTestService implements communities.Service for subscribe handler tests 17 31 type subscribeTestService struct { 18 - subscribeFunc func(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) 19 - unsubscribeFunc func(ctx context.Context, userDID, accessToken, communityIdentifier string) error 32 + subscribeFunc func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) 33 + unsubscribeFunc func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error 20 34 } 21 35 22 36 func (m *subscribeTestService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) { ··· 39 53 return nil, 0, nil 40 54 } 41 55 42 - func (m *subscribeTestService) SubscribeToCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 56 + func (m *subscribeTestService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 43 57 if m.subscribeFunc != nil { 44 - return m.subscribeFunc(ctx, userDID, accessToken, communityIdentifier, contentVisibility) 58 + return m.subscribeFunc(ctx, session, communityIdentifier, contentVisibility) 59 + } 60 + userDID := "" 61 + if session != nil { 62 + userDID = session.AccountDID.String() 45 63 } 46 64 return &communities.Subscription{ 47 65 UserDID: userDID, ··· 52 70 }, nil 53 71 } 54 72 55 - func (m *subscribeTestService) UnsubscribeFromCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) error { 73 + func (m *subscribeTestService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 56 74 if m.unsubscribeFunc != nil { 57 - return m.unsubscribeFunc(ctx, userDID, accessToken, communityIdentifier) 75 + return m.unsubscribeFunc(ctx, session, communityIdentifier) 58 76 } 59 77 return nil 60 78 } ··· 67 85 return nil, nil 68 86 } 69 87 70 - func (m *subscribeTestService) BlockCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) (*communities.CommunityBlock, error) { 88 + func (m *subscribeTestService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 71 89 return nil, nil 72 90 } 73 91 74 - func (m *subscribeTestService) UnblockCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) error { 92 + func (m *subscribeTestService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 75 93 return nil 76 94 } 77 95 ··· 144 162 t.Run(tc.name, func(t *testing.T) { 145 163 var receivedIdentifier string 146 164 mockService := &subscribeTestService{ 147 - subscribeFunc: func(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 165 + subscribeFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 148 166 receivedIdentifier = communityIdentifier 167 + userDID := "" 168 + if session != nil { 169 + userDID = session.AccountDID.String() 170 + } 149 171 return &communities.Subscription{ 150 172 UserDID: userDID, 151 173 CommunityDID: "did:plc:resolved", ··· 167 189 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBuffer(bodyBytes)) 168 190 req.Header.Set("Content-Type", "application/json") 169 191 170 - // Inject auth context 171 - ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser") 172 - ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token") 192 + // Inject OAuth session into context 193 + session := createTestOAuthSession("did:plc:testuser") 194 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 173 195 req = req.WithContext(ctx) 174 196 175 197 w := httptest.NewRecorder() ··· 244 266 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBuffer(bodyBytes)) 245 267 req.Header.Set("Content-Type", "application/json") 246 268 247 - ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser") 248 - ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token") 269 + session := createTestOAuthSession("did:plc:testuser") 270 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 249 271 req = req.WithContext(ctx) 250 272 251 273 w := httptest.NewRecorder() ··· 286 308 for _, tc := range tests { 287 309 t.Run(tc.name, func(t *testing.T) { 288 310 mockService := &subscribeTestService{ 289 - subscribeFunc: func(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 311 + subscribeFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 290 312 return nil, tc.serviceErr 291 313 }, 292 314 } ··· 302 324 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBuffer(bodyBytes)) 303 325 req.Header.Set("Content-Type", "application/json") 304 326 305 - ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser") 306 - ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token") 327 + session := createTestOAuthSession("did:plc:testuser") 328 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 307 329 req = req.WithContext(ctx) 308 330 309 331 w := httptest.NewRecorder() ··· 353 375 t.Run(tc.name, func(t *testing.T) { 354 376 var receivedIdentifier string 355 377 mockService := &subscribeTestService{ 356 - unsubscribeFunc: func(ctx context.Context, userDID, accessToken, communityIdentifier string) error { 378 + unsubscribeFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 357 379 receivedIdentifier = communityIdentifier 358 380 return nil 359 381 }, ··· 369 391 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unsubscribe", bytes.NewBuffer(bodyBytes)) 370 392 req.Header.Set("Content-Type", "application/json") 371 393 372 - ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser") 373 - ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token") 394 + session := createTestOAuthSession("did:plc:testuser") 395 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 374 396 req = req.WithContext(ctx) 375 397 376 398 w := httptest.NewRecorder() ··· 399 421 400 422 func TestSubscribeHandler_Unsubscribe_SubscriptionNotFound(t *testing.T) { 401 423 mockService := &subscribeTestService{ 402 - unsubscribeFunc: func(ctx context.Context, userDID, accessToken, communityIdentifier string) error { 424 + unsubscribeFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 403 425 return communities.ErrSubscriptionNotFound 404 426 }, 405 427 } ··· 414 436 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unsubscribe", bytes.NewBuffer(bodyBytes)) 415 437 req.Header.Set("Content-Type", "application/json") 416 438 417 - ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser") 418 - ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token") 439 + session := createTestOAuthSession("did:plc:testuser") 440 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 419 441 req = req.WithContext(ctx) 420 442 421 443 w := httptest.NewRecorder() ··· 456 478 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBufferString("invalid json")) 457 479 req.Header.Set("Content-Type", "application/json") 458 480 459 - ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser") 460 - ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token") 481 + session := createTestOAuthSession("did:plc:testuser") 482 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 461 483 req = req.WithContext(ctx) 462 484 463 485 w := httptest.NewRecorder() ··· 468 490 } 469 491 } 470 492 471 - func TestSubscribeHandler_RequiresAccessToken(t *testing.T) { 493 + func TestSubscribeHandler_RequiresOAuthSession(t *testing.T) { 472 494 mockService := &subscribeTestService{} 473 495 handler := NewSubscribeHandler(mockService) 474 496 ··· 480 502 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBuffer(bodyBytes)) 481 503 req.Header.Set("Content-Type", "application/json") 482 504 483 - // User DID but no access token 484 - ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser") 485 - req = req.WithContext(ctx) 505 + // No OAuth session in context 486 506 487 507 w := httptest.NewRecorder() 488 508 handler.HandleSubscribe(w, req) 509 + 510 + 511 + 512 + } 513 + } 514 + 515 + func TestUnsubscribeHandler_RequiresOAuthSession(t *testing.T) { 516 + mockService := &subscribeTestService{} 517 + handler := NewSubscribeHandler(mockService) 518 + 519 + reqBody := map[string]interface{}{ 520 + "community": "did:plc:test", 521 + } 522 + bodyBytes, _ := json.Marshal(reqBody) 523 + 524 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unsubscribe", bytes.NewBuffer(bodyBytes)) 525 + req.Header.Set("Content-Type", "application/json") 526 + 527 + // No OAuth session in context 528 + 529 + w := httptest.NewRecorder() 530 + handler.HandleUnsubscribe(w, req) 531 + 532 + if w.Code != http.StatusUnauthorized { 533 + t.Errorf("Expected status 401, got %d", w.Code) 534 + } 535 + 536 + var errResp struct { 537 + Error string `json:"error"` 538 + } 539 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 540 + t.Fatalf("Failed to decode error response: %v", err) 541 + } 542 + if errResp.Error != "AuthRequired" { 543 + t.Errorf("Expected error AuthRequired, got %s", errResp.Error) 544 + } 545 + } 546 + 547 + // Ensure unused import is used 548 + var _ = errors.New
+5
internal/atproto/pds/errors.go
··· 27 27 func IsAuthError(err error) bool { 28 28 return errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden) 29 29 } 30 + 31 + // IsConflictError returns true if the error indicates a conflict (e.g., duplicate record). 32 + func IsConflictError(err error) bool { 33 + return errors.Is(err, ErrConflict) 34 + }
+17 -10
internal/core/communities/interfaces.go
··· 1 1 package communities 2 2 3 - import "context" 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 7 + ) 4 8 5 9 // Repository defines the interface for community data persistence 6 10 // This is the AppView's indexed view of communities from the firehose ··· 25 29 26 30 27 31 32 + GetSubscriptionByURI(ctx context.Context, recordURI string) (*Subscription, error) // For Jetstream delete operations 33 + ListSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*Subscription, error) 34 + ListSubscribers(ctx context.Context, communityDID string, limit, offset int) ([]*Subscription, error) 35 + GetSubscribedCommunityDIDs(ctx context.Context, userDID string, communityDIDs []string) (map[string]bool, error) 28 36 29 - 30 - 31 - 32 - 33 - 37 + // Community Blocks 38 + BlockCommunity(ctx context.Context, block *CommunityBlock) (*CommunityBlock, error) 34 39 35 40 36 41 ··· 66 71 SearchCommunities(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error) 67 72 68 73 // Subscription operations (write-forward: creates record in user's PDS) 69 - SubscribeToCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string, contentVisibility int) (*Subscription, error) 70 - UnsubscribeFromCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) error 74 + // OAuth session is passed for DPoP authentication to the user's PDS 75 + SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*Subscription, error) 76 + UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error 71 77 GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*Subscription, error) 72 78 GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*Subscription, error) 73 79 74 80 // Block operations (write-forward: creates record in user's PDS) 75 - BlockCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) (*CommunityBlock, error) 76 - UnblockCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) error 81 + // OAuth session is passed for DPoP authentication to the user's PDS 82 + BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*CommunityBlock, error) 83 + UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error 77 84 GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*CommunityBlock, error) 78 85 IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) 79 86
+1 -1
tests/integration/aggregator_e2e_test.go
··· 68 68 identityConfig := identity.DefaultConfig() 69 69 identityResolver := identity.NewResolver(db, identityConfig) 70 70 userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001") 71 - communityService := communities.NewCommunityService(communityRepo, "http://localhost:3001", "did:web:test.coves.social", "coves.social", nil) 71 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, "http://localhost:3001", "did:web:test.coves.social", "coves.social", nil, nil, nil) 72 72 aggregatorService := aggregators.NewAggregatorService(aggregatorRepo, communityService) 73 73 postService := posts.NewPostService(postRepo, communityService, aggregatorService, nil, nil, nil, "http://localhost:3001") 74 74
+23 -5
tests/integration/block_handle_resolution_test.go
··· 13 13 "testing" 14 14 15 15 postgresRepo "Coves/internal/db/postgres" 16 + 17 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 16 19 ) 17 20 21 + // createTestOAuthSessionForBlock creates a mock OAuth session for block handler tests 22 + func createTestOAuthSessionForBlock(did string) *oauth.ClientSessionData { 23 + parsedDID, _ := syntax.ParseDID(did) 24 + return &oauth.ClientSessionData{ 25 + AccountDID: parsedDID, 26 + SessionID: "test-session", 27 + HostURL: "http://localhost:3001", 28 + AccessToken: "test-access-token", 29 + } 30 + } 31 + 18 32 // TestBlockHandler_HandleResolution tests that the block handler accepts handles 19 33 // in addition to DIDs and resolves them correctly 20 34 func TestBlockHandler_HandleResolution(t *testing.T) { ··· 29 43 30 44 // Set up repositories and services 31 45 communityRepo := postgresRepo.NewCommunityRepository(db) 32 - communityService := communities.NewCommunityService( 46 + communityService := communities.NewCommunityServiceWithPDSFactory( 33 47 communityRepo, 34 48 getTestPDSURL(), 35 49 getTestInstanceDID(), 36 50 "coves.social", 37 51 nil, // No PDS HTTP client for this test 52 + nil, // No PDS factory needed for this test 53 + nil, // No blob service for this test 38 54 ) 39 55 40 56 blockHandler := community.NewBlockHandler(communityService) ··· 193 209 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(reqJSON)) 194 210 req.Header.Set("Content-Type", "application/json") 195 211 196 - // Add auth context so we get past auth checks and test resolution validation 197 - ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:test123") 198 - ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token") 212 + // Add OAuth session context so we get past auth checks and test resolution validation 213 + session := createTestOAuthSessionForBlock("did:plc:test123") 214 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 199 215 req = req.WithContext(ctx) 200 216 201 217 w := httptest.NewRecorder() ··· 265 281 266 282 // Set up repositories and services 267 283 communityRepo := postgresRepo.NewCommunityRepository(db) 268 - communityService := communities.NewCommunityService( 284 + communityService := communities.NewCommunityServiceWithPDSFactory( 269 285 communityRepo, 270 286 getTestPDSURL(), 271 287 getTestInstanceDID(), 272 288 "coves.social", 273 289 nil, 290 + nil, // No PDS factory needed for this test 291 + nil, // No blob service for this test 274 292 ) 275 293 276 294 blockHandler := community.NewBlockHandler(communityService)
+12 -4
tests/integration/community_identifier_resolution_test.go
··· 50 50 instanceDID = "did:web:" + instanceDomain 51 51 } 52 52 53 - service := communities.NewCommunityService( 53 + service := communities.NewCommunityServiceWithPDSFactory( 54 54 repo, 55 55 pdsURL, 56 56 instanceDID, 57 57 instanceDomain, 58 58 provisioner, 59 + nil, 60 + nil, 59 61 ) 60 62 61 63 // Create a test community to resolve ··· 244 246 } 245 247 246 248 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 247 - service := communities.NewCommunityService( 249 + service := communities.NewCommunityServiceWithPDSFactory( 248 250 repo, 249 251 pdsURL, 250 252 instanceDID, 251 253 instanceDomain, 252 254 provisioner, 255 + nil, 256 + nil, 253 257 ) 254 258 255 259 tests := []struct { ··· 421 425 } 422 426 423 427 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 424 - service := communities.NewCommunityService( 428 + service := communities.NewCommunityServiceWithPDSFactory( 425 429 repo, 426 430 pdsURL, 427 431 instanceDID, 428 432 instanceDomain, 429 433 provisioner, 434 + nil, 435 + nil, 430 436 ) 431 437 432 438 t.Run("DID error includes identifier", func(t *testing.T) { ··· 486 492 } 487 493 488 494 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 489 - service := communities.NewCommunityService( 495 + service := communities.NewCommunityServiceWithPDSFactory( 490 496 repo, 491 497 pdsURL, 492 498 instanceDID, 493 499 instanceDomain, 494 500 provisioner, 501 + nil, 502 + nil, 495 503 ) 496 504 497 505 // Create a test community
+3 -1
tests/integration/community_provisioning_test.go
··· 146 146 147 147 repo := postgres.NewCommunityRepository(db) 148 148 provisioner := communities.NewPDSAccountProvisioner("test.local", "http://localhost:3001") 149 - service := communities.NewCommunityService( 149 + service := communities.NewCommunityServiceWithPDSFactory( 150 150 repo, 151 151 "http://localhost:3001", // pdsURL 152 152 "did:web:test.local", // instanceDID 153 153 "test.local", // instanceDomain 154 154 provisioner, 155 + nil, 156 + nil, 155 157 ) 156 158 ctx := context.Background() 157 159
+41 -18
tests/integration/community_service_integration_test.go
··· 57 57 // Create provisioner and service (production code path) 58 58 // Use coves.social domain (configured in PDS_SERVICE_HANDLE_DOMAINS as c-{name}.coves.social) 59 59 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 60 - service := communities.NewCommunityService( 60 + service := communities.NewCommunityServiceWithPDSFactory( 61 61 repo, 62 62 pdsURL, 63 63 "did:web:coves.social", 64 64 "coves.social", 65 65 provisioner, 66 + nil, 67 + nil, 66 68 ) 67 69 68 70 // Generate unique community name (keep short for DNS label limit) 71 + // Must start with letter, can contain alphanumeric and hyphens 72 + // Use full Unix seconds + nanoseconds remainder for better uniqueness across runs 73 + now := time.Now() 74 + uniqueName := fmt.Sprintf("svc%d%d", now.Unix()%100000, now.UnixNano()%10000) 69 75 70 - 71 - 72 - 73 - 76 + // Create community via service (FULL PRODUCTION CODE PATH) 77 + t.Logf("Creating community via service.CreateCommunity()...") 74 78 75 79 76 80 ··· 201 205 202 206 t.Run("handles PDS errors gracefully", func(t *testing.T) { 203 207 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 204 - service := communities.NewCommunityService( 208 + service := communities.NewCommunityServiceWithPDSFactory( 205 209 repo, 206 210 pdsURL, 207 211 "did:web:coves.social", 208 212 "coves.social", 209 213 provisioner, 214 + nil, 215 + nil, 210 216 ) 211 217 212 218 // Try to create community with invalid name (should fail validation before PDS) ··· 232 238 233 239 t.Run("validates DNS label limits", func(t *testing.T) { 234 240 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 235 - service := communities.NewCommunityService( 241 + service := communities.NewCommunityServiceWithPDSFactory( 236 242 repo, 237 243 pdsURL, 238 244 "did:web:coves.social", 239 245 "coves.social", 240 246 provisioner, 247 + nil, 248 + nil, 241 249 ) 242 250 243 251 // Try 64-char name (exceeds DNS limit of 63) ··· 301 309 repo := postgres.NewCommunityRepository(db) 302 310 303 311 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 304 - service := communities.NewCommunityService( 312 + service := communities.NewCommunityServiceWithPDSFactory( 305 313 repo, 306 314 pdsURL, 307 315 "did:web:coves.social", 308 316 "coves.social", 309 317 provisioner, 318 + nil, 319 + nil, 310 320 ) 311 321 312 322 t.Run("updates community with real PDS", func(t *testing.T) { 323 + // First, create a community 324 + // Use full Unix seconds + nanoseconds remainder for better uniqueness across runs 325 + now := time.Now() 326 + uniqueName := fmt.Sprintf("upd%d%d", now.Unix()%100000, now.UnixNano()%10000) 327 + creatorDID := "did:plc:updatetestuser" 313 328 329 + t.Logf("Creating community to update...") 314 330 315 331 316 332 ··· 375 391 376 392 377 393 394 + t.Run("rejects unauthorized updates", func(t *testing.T) { 395 + // Create a community 396 + // Use full Unix seconds + nanoseconds remainder for better uniqueness across runs 397 + now := time.Now() 398 + uniqueName := fmt.Sprintf("auth%d%d", now.Unix()%100000, now.UnixNano()%10000) 399 + creatorDID := "did:plc:creator123" 378 400 379 - 380 - 381 - 382 - 383 - 384 - 385 - 386 - 387 - 401 + community, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ 388 402 389 403 390 404 ··· 492 506 repo := postgres.NewCommunityRepository(db) 493 507 494 508 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 495 - service := communities.NewCommunityService( 509 + service := communities.NewCommunityServiceWithPDSFactory( 496 510 repo, 497 511 pdsURL, 498 512 "did:web:coves.social", 499 513 "coves.social", 500 514 provisioner, 515 + nil, 516 + nil, 501 517 ) 502 518 503 519 t.Run("generated password works for session creation", func(t *testing.T) { 520 + // Create a community with PDS-generated password 521 + // Use full Unix seconds + nanoseconds remainder for better uniqueness across runs 522 + now := time.Now() 523 + uniqueName := fmt.Sprintf("pwd%d%d", now.Unix()%100000, now.UnixNano()%10000) 524 + 525 + t.Logf("Creating community with generated password...") 526 + community, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{
+38 -4
tests/integration/helpers.go
··· 4 4 "Coves/internal/api/middleware" 5 5 "Coves/internal/atproto/oauth" 6 6 "Coves/internal/atproto/pds" 7 + "Coves/internal/core/communities" 7 8 "Coves/internal/core/users" 8 9 "Coves/internal/core/votes" 9 10 "bytes" ··· 115 116 116 117 117 118 119 + return sessionResp.AccessJwt, sessionResp.DID, nil 120 + } 118 121 122 + // tidCounter is used to ensure unique TIDs even when generateTID is called rapidly 123 + var tidCounter uint64 119 124 125 + // testIDCounter is used to ensure unique test identifiers across all tests 126 + var testIDCounter uint64 120 127 128 + // generateTID generates a simple timestamp-based identifier for testing 129 + // In production, PDS generates proper TIDs 130 + // Uses an atomic counter to ensure uniqueness even when called in rapid succession 131 + func generateTID() string { 132 + tidCounter++ 133 + return fmt.Sprintf("3k%d%d", time.Now().UnixNano()/1000, tidCounter) 134 + } 135 + 136 + // uniqueTestID generates a unique identifier for test resources (handles, emails, etc.) 137 + // Uses Unix timestamp (seconds) + atomic counter to ensure uniqueness across test runs 138 + // Keeps IDs short enough to fit within PDS handle limits (max 20 chars for label) 139 + // Returns a ~14 char string (10-digit timestamp + up to 4-digit counter) 140 + func uniqueTestID() string { 141 + testIDCounter++ 142 + return fmt.Sprintf("%d%d", time.Now().Unix(), testIDCounter) 143 + } 121 144 145 + // createPDSAccount creates a new account on PDS and returns access token + DID 122 146 123 147 124 148 ··· 434 458 435 459 436 460 461 + return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 462 + } 463 + } 437 464 438 - 439 - 440 - 441 - 465 + // CommunityPasswordAuthPDSClientFactory creates a PDSClientFactory for communities that uses password-based Bearer auth. 466 + // This is for E2E tests that use createSession instead of OAuth. 467 + // The factory extracts the access token and host URL from the session data. 468 + func CommunityPasswordAuthPDSClientFactory() communities.PDSClientFactory { 469 + return func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 470 + if session.AccessToken == "" { 471 + return nil, fmt.Errorf("session has no access token") 472 + } 473 + if session.HostURL == "" { 474 + return nil, fmt.Errorf("session has no host URL") 475 + } 442 476 443 477 return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 444 478 }
+3 -1
tests/integration/post_creation_test.go
··· 35 35 36 36 communityRepo := postgres.NewCommunityRepository(db) 37 37 // Note: Provisioner not needed for this test (we're not actually creating communities) 38 - communityService := communities.NewCommunityService( 38 + communityService := communities.NewCommunityServiceWithPDSFactory( 39 39 communityRepo, 40 40 "http://localhost:3001", 41 41 "did:web:test.coves.social", 42 42 "test.coves.social", 43 43 nil, // provisioner 44 + nil, // pdsClientFactory 45 + nil, // blobService 44 46 ) 45 47 46 48 postRepo := postgres.NewPostRepository(db)
+4 -2
tests/integration/post_e2e_test.go
··· 394 394 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 395 395 396 396 // Setup community service with real PDS provisioner 397 - communityService := communities.NewCommunityService( 397 + communityService := communities.NewCommunityServiceWithPDSFactory( 398 398 communityRepo, 399 399 pdsURL, 400 400 instanceDID, 401 401 instanceDomain, 402 - provisioner, // โœ… Real provisioner for creating communities on PDS 402 + provisioner, // Real provisioner for creating communities on PDS 403 + nil, // No PDS factory needed - no subscribe/block in this test 404 + nil, // No blob service for this test 403 405 ) 404 406 405 407 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) // nil aggregatorService, blobService, unfurlService, blueskyService for user-only tests
+9 -3
tests/integration/post_handler_test.go
··· 32 32 33 33 // Setup services 34 34 communityRepo := postgres.NewCommunityRepository(db) 35 - communityService := communities.NewCommunityService( 35 + communityService := communities.NewCommunityServiceWithPDSFactory( 36 36 communityRepo, 37 37 "http://localhost:3001", 38 38 "did:web:test.coves.social", 39 39 "test.coves.social", 40 40 nil, 41 + nil, 42 + nil, 41 43 ) 42 44 43 45 postRepo := postgres.NewPostRepository(db) ··· 400 402 401 403 // Setup services 402 404 communityRepo := postgres.NewCommunityRepository(db) 403 - communityService := communities.NewCommunityService( 405 + communityService := communities.NewCommunityServiceWithPDSFactory( 404 406 communityRepo, 405 407 "http://localhost:3001", 406 408 "did:web:test.coves.social", 407 409 "test.coves.social", 408 410 nil, 411 + nil, 412 + nil, 409 413 ) 410 414 411 415 postRepo := postgres.NewPostRepository(db) ··· 484 488 485 489 // Setup services 486 490 communityRepo := postgres.NewCommunityRepository(db) 487 - communityService := communities.NewCommunityService( 491 + communityService := communities.NewCommunityServiceWithPDSFactory( 488 492 communityRepo, 489 493 "http://localhost:3001", 490 494 "did:web:test.coves.social", 491 495 "test.coves.social", 492 496 nil, 497 + nil, 498 + nil, 493 499 ) 494 500 495 501 postRepo := postgres.NewPostRepository(db)
+3 -1
tests/integration/post_thumb_validation_test.go
··· 55 55 56 56 // Setup services 57 57 communityRepo := postgres.NewCommunityRepository(db) 58 - communityService := communities.NewCommunityService( 58 + communityService := communities.NewCommunityServiceWithPDSFactory( 59 59 communityRepo, 60 60 "http://localhost:3001", 61 61 "did:web:test.coves.social", 62 62 "test.coves.social", 63 63 nil, 64 + nil, 65 + nil, 64 66 ) 65 67 66 68 postRepo := postgres.NewPostRepository(db)
+12 -4
tests/integration/post_unfurl_test.go
··· 51 51 unfurl.WithCacheTTL(24*time.Hour), 52 52 ) 53 53 54 - communityService := communities.NewCommunityService( 54 + communityService := communities.NewCommunityServiceWithPDSFactory( 55 55 communityRepo, 56 56 "http://localhost:3001", 57 57 "did:web:test.coves.social", 58 58 "test.coves.social", 59 59 nil, 60 + nil, 61 + nil, 60 62 ) 61 63 62 64 postService := posts.NewPostService( ··· 348 350 identityResolver := identity.NewResolver(db, identityConfig) 349 351 userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001") 350 352 351 - communityService := communities.NewCommunityService( 353 + communityService := communities.NewCommunityServiceWithPDSFactory( 352 354 communityRepo, 353 355 "http://localhost:3001", 354 356 "did:web:test.coves.social", 355 357 "test.coves.social", 356 358 nil, 359 + nil, 360 + nil, 357 361 ) 358 362 359 363 // Create post service WITHOUT unfurl service ··· 456 460 unfurl.WithCacheTTL(24*time.Hour), 457 461 ) 458 462 459 - communityService := communities.NewCommunityService( 463 + communityService := communities.NewCommunityServiceWithPDSFactory( 460 464 communityRepo, 461 465 "http://localhost:3001", 462 466 "did:web:test.coves.social", 463 467 "test.coves.social", 464 468 nil, 469 + nil, 470 + nil, 465 471 ) 466 472 467 473 postService := posts.NewPostService( ··· 568 574 unfurl.WithTimeout(30*time.Second), 569 575 ) 570 576 571 - communityService := communities.NewCommunityService( 577 + communityService := communities.NewCommunityServiceWithPDSFactory( 572 578 communityRepo, 573 579 "http://localhost:3001", 574 580 "did:web:test.coves.social", 575 581 "test.coves.social", 576 582 nil, 583 + nil, 584 + nil, 577 585 ) 578 586 579 587 postService := posts.NewPostService(
+520
internal/api/handlers/community/block_test.go
··· 1 + package community 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/communities" 6 + "bytes" 7 + "context" 8 + "encoding/json" 9 + "net/http" 10 + "net/http/httptest" 11 + "testing" 12 + "time" 13 + 14 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + ) 17 + 18 + // blockTestService implements communities.Service for block handler tests 19 + type blockTestService struct { 20 + blockFunc func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) 21 + unblockFunc func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error 22 + } 23 + 24 + func (m *blockTestService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) { 25 + return nil, nil 26 + } 27 + 28 + func (m *blockTestService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) { 29 + return nil, nil 30 + } 31 + 32 + func (m *blockTestService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) { 33 + return nil, nil 34 + } 35 + 36 + func (m *blockTestService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 37 + return nil, nil 38 + } 39 + 40 + func (m *blockTestService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) { 41 + return nil, 0, nil 42 + } 43 + 44 + func (m *blockTestService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 45 + return nil, nil 46 + } 47 + 48 + func (m *blockTestService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 49 + return nil 50 + } 51 + 52 + func (m *blockTestService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) { 53 + return nil, nil 54 + } 55 + 56 + func (m *blockTestService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) { 57 + return nil, nil 58 + } 59 + 60 + func (m *blockTestService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 61 + if m.blockFunc != nil { 62 + return m.blockFunc(ctx, session, communityIdentifier) 63 + } 64 + userDID := "" 65 + if session != nil { 66 + userDID = session.AccountDID.String() 67 + } 68 + return &communities.CommunityBlock{ 69 + UserDID: userDID, 70 + CommunityDID: "did:plc:community123", 71 + RecordURI: "at://did:plc:user/social.coves.community.block/abc123", 72 + RecordCID: "bafytest123", 73 + BlockedAt: time.Now(), 74 + }, nil 75 + } 76 + 77 + func (m *blockTestService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 78 + if m.unblockFunc != nil { 79 + return m.unblockFunc(ctx, session, communityIdentifier) 80 + } 81 + return nil 82 + } 83 + 84 + func (m *blockTestService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) { 85 + return nil, nil 86 + } 87 + 88 + func (m *blockTestService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) { 89 + return false, nil 90 + } 91 + 92 + func (m *blockTestService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) { 93 + return nil, nil 94 + } 95 + 96 + func (m *blockTestService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) { 97 + return nil, nil 98 + } 99 + 100 + func (m *blockTestService) ValidateHandle(handle string) error { 101 + return nil 102 + } 103 + 104 + func (m *blockTestService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) { 105 + return identifier, nil 106 + } 107 + 108 + func (m *blockTestService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) { 109 + return community, nil 110 + } 111 + 112 + func (m *blockTestService) GetByDID(ctx context.Context, did string) (*communities.Community, error) { 113 + return nil, nil 114 + } 115 + 116 + // createBlockTestOAuthSession creates a mock OAuth session for block handler tests 117 + func createBlockTestOAuthSession(did string) *oauth.ClientSessionData { 118 + parsedDID, _ := syntax.ParseDID(did) 119 + return &oauth.ClientSessionData{ 120 + AccountDID: parsedDID, 121 + SessionID: "test-session", 122 + HostURL: "http://localhost:3001", 123 + AccessToken: "test-access-token", 124 + } 125 + } 126 + 127 + func TestBlockHandler_Block_Success(t *testing.T) { 128 + tests := []struct { 129 + name string 130 + community string 131 + expectedCommunity string 132 + }{ 133 + { 134 + name: "block with DID", 135 + community: "did:plc:community123", 136 + expectedCommunity: "did:plc:community123", 137 + }, 138 + { 139 + name: "block with canonical handle", 140 + community: "c-worldnews.coves.social", 141 + expectedCommunity: "c-worldnews.coves.social", 142 + }, 143 + { 144 + name: "block with scoped identifier", 145 + community: "!worldnews@coves.social", 146 + expectedCommunity: "!worldnews@coves.social", 147 + }, 148 + { 149 + name: "block with at-identifier", 150 + community: "@c-tech.coves.social", 151 + expectedCommunity: "@c-tech.coves.social", 152 + }, 153 + } 154 + 155 + for _, tc := range tests { 156 + t.Run(tc.name, func(t *testing.T) { 157 + var receivedIdentifier string 158 + mockService := &blockTestService{ 159 + blockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 160 + receivedIdentifier = communityIdentifier 161 + userDID := "" 162 + if session != nil { 163 + userDID = session.AccountDID.String() 164 + } 165 + return &communities.CommunityBlock{ 166 + UserDID: userDID, 167 + CommunityDID: "did:plc:resolved", 168 + RecordURI: "at://did:plc:user/social.coves.community.block/abc123", 169 + RecordCID: "bafytest123", 170 + BlockedAt: time.Now(), 171 + }, nil 172 + }, 173 + } 174 + 175 + handler := NewBlockHandler(mockService) 176 + 177 + reqBody := map[string]interface{}{ 178 + "community": tc.community, 179 + } 180 + bodyBytes, _ := json.Marshal(reqBody) 181 + 182 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes)) 183 + req.Header.Set("Content-Type", "application/json") 184 + 185 + // Inject OAuth session into context 186 + session := createBlockTestOAuthSession("did:plc:testuser") 187 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 188 + req = req.WithContext(ctx) 189 + 190 + w := httptest.NewRecorder() 191 + handler.HandleBlock(w, req) 192 + 193 + if w.Code != http.StatusOK { 194 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 195 + } 196 + 197 + // Verify the community identifier was passed through correctly 198 + if receivedIdentifier != tc.expectedCommunity { 199 + t.Errorf("Expected community %q to be passed to service, got %q", tc.expectedCommunity, receivedIdentifier) 200 + } 201 + 202 + // Verify response structure 203 + var resp struct { 204 + Block struct { 205 + RecordURI string `json:"recordUri"` 206 + RecordCID string `json:"recordCid"` 207 + } `json:"block"` 208 + } 209 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 210 + t.Fatalf("Failed to decode response: %v", err) 211 + } 212 + if resp.Block.RecordURI == "" || resp.Block.RecordCID == "" { 213 + t.Errorf("Expected recordUri and recordCid in response, got %+v", resp) 214 + } 215 + }) 216 + } 217 + } 218 + 219 + func TestBlockHandler_Block_RequiresOAuthSession(t *testing.T) { 220 + mockService := &blockTestService{} 221 + handler := NewBlockHandler(mockService) 222 + 223 + reqBody := map[string]interface{}{ 224 + "community": "did:plc:test", 225 + } 226 + bodyBytes, _ := json.Marshal(reqBody) 227 + 228 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes)) 229 + req.Header.Set("Content-Type", "application/json") 230 + 231 + // No OAuth session in context 232 + 233 + w := httptest.NewRecorder() 234 + handler.HandleBlock(w, req) 235 + 236 + if w.Code != http.StatusUnauthorized { 237 + t.Errorf("Expected status 401, got %d", w.Code) 238 + } 239 + 240 + var errResp struct { 241 + Error string `json:"error"` 242 + } 243 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 244 + t.Fatalf("Failed to decode error response: %v", err) 245 + } 246 + if errResp.Error != "AuthRequired" { 247 + t.Errorf("Expected error AuthRequired, got %s", errResp.Error) 248 + } 249 + } 250 + 251 + func TestBlockHandler_Block_RequiresCommunity(t *testing.T) { 252 + mockService := &blockTestService{} 253 + handler := NewBlockHandler(mockService) 254 + 255 + reqBody := map[string]interface{}{} 256 + bodyBytes, _ := json.Marshal(reqBody) 257 + 258 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes)) 259 + req.Header.Set("Content-Type", "application/json") 260 + 261 + session := createBlockTestOAuthSession("did:plc:testuser") 262 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 263 + req = req.WithContext(ctx) 264 + 265 + w := httptest.NewRecorder() 266 + handler.HandleBlock(w, req) 267 + 268 + if w.Code != http.StatusBadRequest { 269 + t.Errorf("Expected status 400, got %d", w.Code) 270 + } 271 + } 272 + 273 + func TestBlockHandler_Block_ServiceErrors(t *testing.T) { 274 + tests := []struct { 275 + name string 276 + serviceErr error 277 + expectedStatus int 278 + expectedError string 279 + }{ 280 + { 281 + name: "community not found", 282 + serviceErr: communities.ErrCommunityNotFound, 283 + expectedStatus: http.StatusNotFound, 284 + expectedError: "NotFound", 285 + }, 286 + { 287 + name: "validation error", 288 + serviceErr: communities.NewValidationError("community", "invalid format"), 289 + expectedStatus: http.StatusBadRequest, 290 + expectedError: "InvalidRequest", 291 + }, 292 + { 293 + name: "already blocked", 294 + serviceErr: communities.ErrBlockAlreadyExists, 295 + expectedStatus: http.StatusConflict, 296 + expectedError: "AlreadyExists", 297 + }, 298 + { 299 + name: "unauthorized", 300 + serviceErr: communities.ErrUnauthorized, 301 + expectedStatus: http.StatusForbidden, 302 + expectedError: "Forbidden", 303 + }, 304 + } 305 + 306 + for _, tc := range tests { 307 + t.Run(tc.name, func(t *testing.T) { 308 + mockService := &blockTestService{ 309 + blockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 310 + return nil, tc.serviceErr 311 + }, 312 + } 313 + 314 + handler := NewBlockHandler(mockService) 315 + 316 + reqBody := map[string]interface{}{ 317 + "community": "did:plc:test", 318 + } 319 + bodyBytes, _ := json.Marshal(reqBody) 320 + 321 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes)) 322 + req.Header.Set("Content-Type", "application/json") 323 + 324 + session := createBlockTestOAuthSession("did:plc:testuser") 325 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 326 + req = req.WithContext(ctx) 327 + 328 + w := httptest.NewRecorder() 329 + handler.HandleBlock(w, req) 330 + 331 + if w.Code != tc.expectedStatus { 332 + t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, w.Code, w.Body.String()) 333 + } 334 + 335 + var errResp struct { 336 + Error string `json:"error"` 337 + } 338 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 339 + t.Fatalf("Failed to decode error response: %v", err) 340 + } 341 + if errResp.Error != tc.expectedError { 342 + t.Errorf("Expected error %s, got %s", tc.expectedError, errResp.Error) 343 + } 344 + }) 345 + } 346 + } 347 + 348 + func TestBlockHandler_Unblock_Success(t *testing.T) { 349 + tests := []struct { 350 + name string 351 + community string 352 + expectedCommunity string 353 + }{ 354 + { 355 + name: "unblock with DID", 356 + community: "did:plc:community123", 357 + expectedCommunity: "did:plc:community123", 358 + }, 359 + { 360 + name: "unblock with canonical handle", 361 + community: "c-worldnews.coves.social", 362 + expectedCommunity: "c-worldnews.coves.social", 363 + }, 364 + { 365 + name: "unblock with scoped identifier", 366 + community: "!worldnews@coves.social", 367 + expectedCommunity: "!worldnews@coves.social", 368 + }, 369 + } 370 + 371 + for _, tc := range tests { 372 + t.Run(tc.name, func(t *testing.T) { 373 + var receivedIdentifier string 374 + mockService := &blockTestService{ 375 + unblockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 376 + receivedIdentifier = communityIdentifier 377 + return nil 378 + }, 379 + } 380 + 381 + handler := NewBlockHandler(mockService) 382 + 383 + reqBody := map[string]interface{}{ 384 + "community": tc.community, 385 + } 386 + bodyBytes, _ := json.Marshal(reqBody) 387 + 388 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes)) 389 + req.Header.Set("Content-Type", "application/json") 390 + 391 + session := createBlockTestOAuthSession("did:plc:testuser") 392 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 393 + req = req.WithContext(ctx) 394 + 395 + w := httptest.NewRecorder() 396 + handler.HandleUnblock(w, req) 397 + 398 + if w.Code != http.StatusOK { 399 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 400 + } 401 + 402 + if receivedIdentifier != tc.expectedCommunity { 403 + t.Errorf("Expected community %q to be passed to service, got %q", tc.expectedCommunity, receivedIdentifier) 404 + } 405 + 406 + var resp struct { 407 + Success bool `json:"success"` 408 + } 409 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 410 + t.Fatalf("Failed to decode response: %v", err) 411 + } 412 + if !resp.Success { 413 + t.Errorf("Expected success: true in response") 414 + } 415 + }) 416 + } 417 + } 418 + 419 + func TestBlockHandler_Unblock_RequiresOAuthSession(t *testing.T) { 420 + mockService := &blockTestService{} 421 + handler := NewBlockHandler(mockService) 422 + 423 + reqBody := map[string]interface{}{ 424 + "community": "did:plc:test", 425 + } 426 + bodyBytes, _ := json.Marshal(reqBody) 427 + 428 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes)) 429 + req.Header.Set("Content-Type", "application/json") 430 + 431 + // No OAuth session in context 432 + 433 + w := httptest.NewRecorder() 434 + handler.HandleUnblock(w, req) 435 + 436 + if w.Code != http.StatusUnauthorized { 437 + t.Errorf("Expected status 401, got %d", w.Code) 438 + } 439 + 440 + var errResp struct { 441 + Error string `json:"error"` 442 + } 443 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 444 + t.Fatalf("Failed to decode error response: %v", err) 445 + } 446 + if errResp.Error != "AuthRequired" { 447 + t.Errorf("Expected error AuthRequired, got %s", errResp.Error) 448 + } 449 + } 450 + 451 + func TestBlockHandler_Unblock_BlockNotFound(t *testing.T) { 452 + mockService := &blockTestService{ 453 + unblockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 454 + return communities.ErrBlockNotFound 455 + }, 456 + } 457 + 458 + handler := NewBlockHandler(mockService) 459 + 460 + reqBody := map[string]interface{}{ 461 + "community": "did:plc:test", 462 + } 463 + bodyBytes, _ := json.Marshal(reqBody) 464 + 465 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes)) 466 + req.Header.Set("Content-Type", "application/json") 467 + 468 + session := createBlockTestOAuthSession("did:plc:testuser") 469 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 470 + req = req.WithContext(ctx) 471 + 472 + w := httptest.NewRecorder() 473 + handler.HandleUnblock(w, req) 474 + 475 + if w.Code != http.StatusNotFound { 476 + t.Errorf("Expected status 404, got %d. Body: %s", w.Code, w.Body.String()) 477 + } 478 + } 479 + 480 + func TestBlockHandler_MethodNotAllowed(t *testing.T) { 481 + mockService := &blockTestService{} 482 + handler := NewBlockHandler(mockService) 483 + 484 + // Test GET on block endpoint 485 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.blockCommunity", nil) 486 + w := httptest.NewRecorder() 487 + handler.HandleBlock(w, req) 488 + 489 + if w.Code != http.StatusMethodNotAllowed { 490 + t.Errorf("Expected status 405, got %d", w.Code) 491 + } 492 + 493 + // Test GET on unblock endpoint 494 + req = httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.unblockCommunity", nil) 495 + w = httptest.NewRecorder() 496 + handler.HandleUnblock(w, req) 497 + 498 + if w.Code != http.StatusMethodNotAllowed { 499 + t.Errorf("Expected status 405, got %d", w.Code) 500 + } 501 + } 502 + 503 + func TestBlockHandler_InvalidJSON(t *testing.T) { 504 + mockService := &blockTestService{} 505 + handler := NewBlockHandler(mockService) 506 + 507 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBufferString("invalid json")) 508 + req.Header.Set("Content-Type", "application/json") 509 + 510 + session := createBlockTestOAuthSession("did:plc:testuser") 511 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 512 + req = req.WithContext(ctx) 513 + 514 + w := httptest.NewRecorder() 515 + handler.HandleBlock(w, req) 516 + 517 + if w.Code != http.StatusBadRequest { 518 + t.Errorf("Expected status 400, got %d", w.Code) 519 + } 520 + }
+12 -1
internal/api/handlers/community/errors.go
··· 1 1 package community 2 2 3 3 import ( 4 + "Coves/internal/atproto/pds" 4 5 "Coves/internal/core/communities" 5 6 "encoding/json" 7 + "errors" 6 8 "log" 7 9 "net/http" 8 10 ) ··· 42 44 writeError(w, http.StatusForbidden, "Forbidden", "You do not have permission to perform this action") 43 45 case err == communities.ErrMemberBanned: 44 46 writeError(w, http.StatusForbidden, "Blocked", "You are blocked from this community") 47 + // PDS-specific errors (from DPoP authentication or PDS API calls) 48 + case errors.Is(err, pds.ErrBadRequest): 49 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request to PDS") 50 + case errors.Is(err, pds.ErrNotFound): 51 + writeError(w, http.StatusNotFound, "NotFound", "Record not found on PDS") 52 + case errors.Is(err, pds.ErrConflict): 53 + writeError(w, http.StatusConflict, "Conflict", "Record was modified by another operation") 54 + case errors.Is(err, pds.ErrUnauthorized), errors.Is(err, pds.ErrForbidden): 55 + // PDS auth errors should prompt re-authentication 56 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required or session expired") 45 57 default: 46 58 // Internal server error - log the actual error for debugging 47 - // TODO: Use proper logger instead of log package 48 59 log.Printf("XRPC handler error: %v", err) 49 60 writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 50 61 }
+9 -2
internal/atproto/pds/factory.go
··· 24 24 } 25 25 26 26 // ResumeSession reconstructs the OAuth session with DPoP key 27 - // and returns a ClientSession that can generate authenticated requests 27 + // and returns a ClientSession that can generate authenticated requests. 28 + // Common failure modes: 29 + // - Expired access/refresh tokens โ†’ User needs to re-authenticate 30 + // - Session revoked on PDS โ†’ User needs to re-authenticate 31 + // - DPoP nonce mismatch โ†’ Retry may help (transient) 32 + // - DPoP key mismatch โ†’ Session data corrupted, re-authenticate 28 33 sess, err := oauthClient.ResumeSession(ctx, sessionData.AccountDID, sessionData.SessionID) 29 34 if err != nil { 30 - return nil, fmt.Errorf("failed to resume OAuth session: %w", err) 35 + // Include DID and session context for debugging 36 + return nil, fmt.Errorf("failed to resume OAuth session for DID=%s, sessionID=%s: %w", 37 + sessionData.AccountDID.String(), sessionData.SessionID, err) 31 38 } 32 39 33 40 // APIClient() returns an *atclient.APIClient configured with DPoP auth
+41
internal/api/handlers/common/viewer_state.go
··· 2 2 3 3 import ( 4 4 "Coves/internal/api/middleware" 5 + "Coves/internal/core/communities" 5 6 "Coves/internal/core/posts" 6 7 "Coves/internal/core/votes" 7 8 "context" ··· 71 72 } 72 73 } 73 74 } 75 + 76 + // PopulateCommunityViewerState enriches communities with the authenticated user's subscription state. 77 + // This is a no-op if the request is unauthenticated. 78 + func PopulateCommunityViewerState( 79 + ctx context.Context, 80 + r *http.Request, 81 + repo communities.Repository, 82 + communityList []*communities.Community, 83 + ) { 84 + if repo == nil || len(communityList) == 0 { 85 + return 86 + } 87 + 88 + userDID := middleware.GetUserDID(r) 89 + if userDID == "" { 90 + return // Not authenticated, leave viewer state nil 91 + } 92 + 93 + // Collect community DIDs 94 + communityDIDs := make([]string, len(communityList)) 95 + for i, c := range communityList { 96 + communityDIDs[i] = c.DID 97 + } 98 + 99 + // Batch query subscriptions 100 + subscribed, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, communityDIDs) 101 + if err != nil { 102 + log.Printf("Warning: failed to get subscription state for user %s (%d communities): %v", 103 + userDID, len(communityDIDs), err) 104 + return 105 + } 106 + 107 + // Populate viewer state on each community 108 + for _, c := range communityList { 109 + isSubscribed := subscribed[c.DID] 110 + c.Viewer = &communities.CommunityViewerState{ 111 + Subscribed: &isSubscribed, 112 + } 113 + } 114 + }
+13 -2
internal/api/routes/community.go
··· 11 11 // RegisterCommunityRoutes registers community-related XRPC endpoints on the router 12 12 // Implements social.coves.community.* lexicon endpoints 13 13 // allowedCommunityCreators restricts who can create communities. If empty, anyone can create. 14 - func RegisterCommunityRoutes(r chi.Router, service communities.Service, authMiddleware *middleware.OAuthAuthMiddleware, allowedCommunityCreators []string) { 14 + func RegisterCommunityRoutes(r chi.Router, service communities.Service, repo communities.Repository, authMiddleware *middleware.OAuthAuthMiddleware, allowedCommunityCreators []string) { 15 15 // Initialize handlers 16 16 createHandler := community.NewCreateHandler(service, allowedCommunityCreators) 17 17 getHandler := community.NewGetHandler(service) 18 18 updateHandler := community.NewUpdateHandler(service) 19 - listHandler := community.NewListHandler(service) 19 + listHandler := community.NewListHandler(service, repo) 20 20 searchHandler := community.NewSearchHandler(service) 21 21 subscribeHandler := community.NewSubscribeHandler(service) 22 22 blockHandler := community.NewBlockHandler(service) 23 + 24 + // Query endpoints (GET) - public access, optional auth for viewer state 25 + // social.coves.community.get - get a single community by identifier 26 + r.Get("/xrpc/social.coves.community.get", getHandler.HandleGet) 27 + 28 + // social.coves.community.list - list communities with filters 29 + // Uses OptionalAuth to populate viewer.subscribed when authenticated 30 + r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.community.list", listHandler.HandleList) 31 + 32 + // social.coves.community.search - search communities 33 + r.Get("/xrpc/social.coves.community.search", searchHandler.HandleSearch)
+48
internal/db/postgres/community_repo_subscriptions.go
··· 344 344 345 345 return result, nil 346 346 } 347 + 348 + // GetSubscribedCommunityDIDs returns a map of community DIDs that the user is subscribed to 349 + // This is optimized for batch lookups when populating viewer state 350 + func (r *postgresCommunityRepo) GetSubscribedCommunityDIDs(ctx context.Context, userDID string, communityDIDs []string) (map[string]bool, error) { 351 + if len(communityDIDs) == 0 { 352 + return map[string]bool{}, nil 353 + } 354 + 355 + // Build query with placeholders for IN clause 356 + placeholders := make([]string, len(communityDIDs)) 357 + args := make([]interface{}, len(communityDIDs)+1) 358 + args[0] = userDID 359 + for i, did := range communityDIDs { 360 + placeholders[i] = fmt.Sprintf("$%d", i+2) 361 + args[i+1] = did 362 + } 363 + 364 + query := fmt.Sprintf(` 365 + SELECT community_did 366 + FROM community_subscriptions 367 + WHERE user_did = $1 AND community_did IN (%s)`, 368 + strings.Join(placeholders, ", ")) 369 + 370 + rows, err := r.db.QueryContext(ctx, query, args...) 371 + if err != nil { 372 + return nil, fmt.Errorf("failed to get subscribed communities: %w", err) 373 + } 374 + defer func() { 375 + if closeErr := rows.Close(); closeErr != nil { 376 + log.Printf("Failed to close rows: %v", closeErr) 377 + } 378 + }() 379 + 380 + result := make(map[string]bool) 381 + for rows.Next() { 382 + var communityDID string 383 + if err := rows.Scan(&communityDID); err != nil { 384 + return nil, fmt.Errorf("failed to scan community DID: %w", err) 385 + } 386 + result[communityDID] = true 387 + } 388 + 389 + if err = rows.Err(); err != nil { 390 + return nil, fmt.Errorf("error iterating subscribed communities: %w", err) 391 + } 392 + 393 + return result, nil 394 + }
+268
tests/integration/community_list_viewer_state_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/api/handlers/community" 5 + "Coves/internal/api/middleware" 6 + "Coves/internal/core/communities" 7 + "Coves/internal/db/postgres" 8 + "context" 9 + "encoding/json" 10 + "fmt" 11 + "net/http" 12 + "net/http/httptest" 13 + "testing" 14 + "time" 15 + 16 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 + "github.com/go-chi/chi/v5" 18 + ) 19 + 20 + // TestCommunityList_ViewerState tests that the list communities endpoint 21 + // correctly populates viewer.subscribed field for authenticated users 22 + func TestCommunityList_ViewerState(t *testing.T) { 23 + db := setupTestDB(t) 24 + defer func() { 25 + if err := db.Close(); err != nil { 26 + t.Logf("Failed to close database: %v", err) 27 + } 28 + }() 29 + 30 + repo := postgres.NewCommunityRepository(db) 31 + ctx := context.Background() 32 + 33 + // Create test communities 34 + baseSuffix := time.Now().UnixNano() 35 + communityDIDs := make([]string, 3) 36 + for i := 0; i < 3; i++ { 37 + uniqueSuffix := fmt.Sprintf("%d%d", baseSuffix, i) 38 + communityDID := generateTestDID(uniqueSuffix) 39 + communityDIDs[i] = communityDID 40 + comm := &communities.Community{ 41 + DID: communityDID, 42 + Handle: fmt.Sprintf("c-viewer-test-%d-%d.coves.local", baseSuffix, i), 43 + Name: fmt.Sprintf("viewer-test-%d", i), 44 + DisplayName: fmt.Sprintf("Viewer Test Community %d", i), 45 + OwnerDID: "did:web:coves.local", 46 + CreatedByDID: "did:plc:testcreator", 47 + HostedByDID: "did:web:coves.local", 48 + Visibility: "public", 49 + CreatedAt: time.Now(), 50 + UpdatedAt: time.Now(), 51 + } 52 + if _, err := repo.Create(ctx, comm); err != nil { 53 + t.Fatalf("Failed to create community %d: %v", i, err) 54 + } 55 + } 56 + 57 + // Create a test user and subscribe them to community 0 and 2 58 + testUserDID := fmt.Sprintf("did:plc:viewertestuser%d", baseSuffix) 59 + 60 + sub1 := &communities.Subscription{ 61 + UserDID: testUserDID, 62 + CommunityDID: communityDIDs[0], 63 + ContentVisibility: 3, 64 + SubscribedAt: time.Now(), 65 + } 66 + if _, err := repo.Subscribe(ctx, sub1); err != nil { 67 + t.Fatalf("Failed to subscribe to community 0: %v", err) 68 + } 69 + 70 + sub2 := &communities.Subscription{ 71 + UserDID: testUserDID, 72 + CommunityDID: communityDIDs[2], 73 + ContentVisibility: 3, 74 + SubscribedAt: time.Now(), 75 + } 76 + if _, err := repo.Subscribe(ctx, sub2); err != nil { 77 + t.Fatalf("Failed to subscribe to community 2: %v", err) 78 + } 79 + 80 + // Create mock service that returns our communities 81 + mockService := &mockCommunityService{ 82 + repo: repo, 83 + } 84 + 85 + // Create handler with real repo for viewer state population 86 + listHandler := community.NewListHandler(mockService, repo) 87 + 88 + t.Run("authenticated user sees viewer.subscribed correctly", func(t *testing.T) { 89 + // Setup router with middleware that injects user DID 90 + r := chi.NewRouter() 91 + 92 + // Use test middleware that sets user DID in context 93 + r.Use(func(next http.Handler) http.Handler { 94 + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 95 + ctx := middleware.SetTestUserDID(req.Context(), testUserDID) 96 + next.ServeHTTP(w, req.WithContext(ctx)) 97 + }) 98 + }) 99 + r.Get("/xrpc/social.coves.community.list", listHandler.HandleList) 100 + 101 + req := httptest.NewRequest("GET", "/xrpc/social.coves.community.list?limit=50", nil) 102 + rec := httptest.NewRecorder() 103 + 104 + r.ServeHTTP(rec, req) 105 + 106 + if rec.Code != http.StatusOK { 107 + t.Fatalf("Expected status 200, got %d: %s", rec.Code, rec.Body.String()) 108 + } 109 + 110 + var response struct { 111 + Communities []struct { 112 + DID string `json:"did"` 113 + Viewer *struct { 114 + Subscribed *bool `json:"subscribed"` 115 + } `json:"viewer"` 116 + } `json:"communities"` 117 + } 118 + 119 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 120 + t.Fatalf("Failed to decode response: %v", err) 121 + } 122 + 123 + // Check that viewer state is populated correctly 124 + subscriptionMap := map[string]bool{ 125 + communityDIDs[0]: true, 126 + communityDIDs[1]: false, 127 + communityDIDs[2]: true, 128 + } 129 + 130 + for _, comm := range response.Communities { 131 + expectedSubscribed, inTestSet := subscriptionMap[comm.DID] 132 + if !inTestSet { 133 + continue // Skip communities not in our test set 134 + } 135 + 136 + if comm.Viewer == nil { 137 + t.Errorf("Community %s has nil Viewer, expected populated", comm.DID) 138 + continue 139 + } 140 + 141 + if comm.Viewer.Subscribed == nil { 142 + t.Errorf("Community %s has nil Viewer.Subscribed, expected populated", comm.DID) 143 + continue 144 + } 145 + 146 + if *comm.Viewer.Subscribed != expectedSubscribed { 147 + t.Errorf("Community %s: expected subscribed=%v, got %v", 148 + comm.DID, expectedSubscribed, *comm.Viewer.Subscribed) 149 + } 150 + } 151 + }) 152 + 153 + t.Run("unauthenticated request has nil viewer state", func(t *testing.T) { 154 + // Setup router WITHOUT middleware that sets user DID 155 + r := chi.NewRouter() 156 + r.Get("/xrpc/social.coves.community.list", listHandler.HandleList) 157 + 158 + req := httptest.NewRequest("GET", "/xrpc/social.coves.community.list?limit=50", nil) 159 + rec := httptest.NewRecorder() 160 + 161 + r.ServeHTTP(rec, req) 162 + 163 + if rec.Code != http.StatusOK { 164 + t.Fatalf("Expected status 200, got %d: %s", rec.Code, rec.Body.String()) 165 + } 166 + 167 + var response struct { 168 + Communities []struct { 169 + DID string `json:"did"` 170 + Viewer *struct { 171 + Subscribed *bool `json:"subscribed"` 172 + } `json:"viewer"` 173 + } `json:"communities"` 174 + } 175 + 176 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 177 + t.Fatalf("Failed to decode response: %v", err) 178 + } 179 + 180 + // For unauthenticated requests, viewer should be nil for all communities 181 + for _, comm := range response.Communities { 182 + if comm.Viewer != nil { 183 + t.Errorf("Community %s has non-nil Viewer for unauthenticated request", comm.DID) 184 + } 185 + } 186 + }) 187 + } 188 + 189 + // mockCommunityService implements communities.Service for testing 190 + type mockCommunityService struct { 191 + repo communities.Repository 192 + } 193 + 194 + func (m *mockCommunityService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) { 195 + return nil, fmt.Errorf("not implemented") 196 + } 197 + 198 + func (m *mockCommunityService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) { 199 + return nil, fmt.Errorf("not implemented") 200 + } 201 + 202 + func (m *mockCommunityService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) { 203 + return nil, fmt.Errorf("not implemented") 204 + } 205 + 206 + func (m *mockCommunityService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 207 + return m.repo.List(ctx, req) 208 + } 209 + 210 + func (m *mockCommunityService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) { 211 + return nil, 0, fmt.Errorf("not implemented") 212 + } 213 + 214 + func (m *mockCommunityService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 215 + return nil, fmt.Errorf("not implemented") 216 + } 217 + 218 + func (m *mockCommunityService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 219 + return fmt.Errorf("not implemented") 220 + } 221 + 222 + func (m *mockCommunityService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) { 223 + return nil, fmt.Errorf("not implemented") 224 + } 225 + 226 + func (m *mockCommunityService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) { 227 + return nil, fmt.Errorf("not implemented") 228 + } 229 + 230 + func (m *mockCommunityService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 231 + return nil, fmt.Errorf("not implemented") 232 + } 233 + 234 + func (m *mockCommunityService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 235 + return fmt.Errorf("not implemented") 236 + } 237 + 238 + func (m *mockCommunityService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) { 239 + return nil, fmt.Errorf("not implemented") 240 + } 241 + 242 + func (m *mockCommunityService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) { 243 + return false, fmt.Errorf("not implemented") 244 + } 245 + 246 + func (m *mockCommunityService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) { 247 + return nil, fmt.Errorf("not implemented") 248 + } 249 + 250 + func (m *mockCommunityService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) { 251 + return nil, fmt.Errorf("not implemented") 252 + } 253 + 254 + func (m *mockCommunityService) ValidateHandle(handle string) error { 255 + return nil 256 + } 257 + 258 + func (m *mockCommunityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) { 259 + return identifier, nil 260 + } 261 + 262 + func (m *mockCommunityService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) { 263 + return community, nil 264 + } 265 + 266 + func (m *mockCommunityService) GetByDID(ctx context.Context, did string) (*communities.Community, error) { 267 + return m.repo.GetByDID(ctx, did) 268 + }
+117
tests/integration/community_repo_test.go
··· 409 409 }) 410 410 } 411 411 412 + func TestCommunityRepository_GetSubscribedCommunityDIDs(t *testing.T) { 413 + db := setupTestDB(t) 414 + defer func() { 415 + if err := db.Close(); err != nil { 416 + t.Logf("Failed to close database: %v", err) 417 + } 418 + }() 419 + 420 + repo := postgres.NewCommunityRepository(db) 421 + ctx := context.Background() 422 + 423 + // Create test communities 424 + baseSuffix := time.Now().UnixNano() 425 + communityDIDs := make([]string, 3) 426 + for i := 0; i < 3; i++ { 427 + uniqueSuffix := fmt.Sprintf("%d%d", baseSuffix, i) 428 + communityDID := generateTestDID(uniqueSuffix) 429 + communityDIDs[i] = communityDID 430 + community := &communities.Community{ 431 + DID: communityDID, 432 + Handle: fmt.Sprintf("!batch-sub-test-%d-%d@coves.local", baseSuffix, i), 433 + Name: fmt.Sprintf("batch-sub-test-%d", i), 434 + OwnerDID: "did:web:coves.local", 435 + CreatedByDID: "did:plc:user123", 436 + HostedByDID: "did:web:coves.local", 437 + Visibility: "public", 438 + CreatedAt: time.Now(), 439 + UpdatedAt: time.Now(), 440 + } 441 + if _, err := repo.Create(ctx, community); err != nil { 442 + t.Fatalf("Failed to create community %d: %v", i, err) 443 + } 444 + } 445 + 446 + userDID := fmt.Sprintf("did:plc:batchsubuser%d", baseSuffix) 447 + 448 + t.Run("returns empty map when user has no subscriptions", func(t *testing.T) { 449 + result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, communityDIDs) 450 + if err != nil { 451 + t.Fatalf("Failed to get subscribed community DIDs: %v", err) 452 + } 453 + 454 + if len(result) != 0 { 455 + t.Errorf("Expected empty map, got %d entries", len(result)) 456 + } 457 + }) 458 + 459 + t.Run("returns subscribed communities only", func(t *testing.T) { 460 + // Subscribe to first and third community 461 + sub1 := &communities.Subscription{ 462 + UserDID: userDID, 463 + CommunityDID: communityDIDs[0], 464 + ContentVisibility: 3, 465 + SubscribedAt: time.Now(), 466 + } 467 + if _, err := repo.Subscribe(ctx, sub1); err != nil { 468 + t.Fatalf("Failed to subscribe to community 0: %v", err) 469 + } 470 + 471 + sub3 := &communities.Subscription{ 472 + UserDID: userDID, 473 + CommunityDID: communityDIDs[2], 474 + ContentVisibility: 3, 475 + SubscribedAt: time.Now(), 476 + } 477 + if _, err := repo.Subscribe(ctx, sub3); err != nil { 478 + t.Fatalf("Failed to subscribe to community 2: %v", err) 479 + } 480 + 481 + result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, communityDIDs) 482 + if err != nil { 483 + t.Fatalf("Failed to get subscribed community DIDs: %v", err) 484 + } 485 + 486 + if len(result) != 2 { 487 + t.Errorf("Expected 2 subscribed communities, got %d", len(result)) 488 + } 489 + 490 + if !result[communityDIDs[0]] { 491 + t.Errorf("Expected community 0 to be subscribed") 492 + } 493 + if result[communityDIDs[1]] { 494 + t.Errorf("Expected community 1 to NOT be subscribed") 495 + } 496 + if !result[communityDIDs[2]] { 497 + t.Errorf("Expected community 2 to be subscribed") 498 + } 499 + }) 500 + 501 + t.Run("returns empty map for empty community DIDs slice", func(t *testing.T) { 502 + result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, []string{}) 503 + if err != nil { 504 + t.Fatalf("Failed to get subscribed community DIDs: %v", err) 505 + } 506 + 507 + if len(result) != 0 { 508 + t.Errorf("Expected empty map for empty input, got %d entries", len(result)) 509 + } 510 + }) 511 + 512 + t.Run("handles non-existent community DIDs gracefully", func(t *testing.T) { 513 + nonExistentDIDs := []string{ 514 + "did:plc:nonexistent1", 515 + "did:plc:nonexistent2", 516 + } 517 + 518 + result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, nonExistentDIDs) 519 + if err != nil { 520 + t.Fatalf("Failed to get subscribed community DIDs: %v", err) 521 + } 522 + 523 + if len(result) != 0 { 524 + t.Errorf("Expected empty map for non-existent DIDs, got %d entries", len(result)) 525 + } 526 + }) 527 + } 528 + 412 529 // TODO: Implement search functionality before re-enabling this test 413 530 // func TestCommunityRepository_Search(t *testing.T) { 414 531 // db := setupTestDB(t)
+2 -1
internal/api/routes/oauth.go
··· 27 27 logoutLimiter := middleware.NewRateLimiter(10, 1*time.Minute) 28 28 29 29 // OAuth metadata endpoints - public, no extra rate limiting (use global limit) 30 - r.Get("/oauth/client-metadata.json", handler.HandleClientMetadata) 30 + // Serve at root /oauth-client-metadata.json so OAuth screens show clean brand domain 31 + r.Get("/oauth-client-metadata.json", handler.HandleClientMetadata) 31 32 r.Get("/.well-known/oauth-protected-resource", handler.HandleProtectedResourceMetadata) 32 33 33 34 // OAuth flow endpoints - stricter rate limiting for authentication attempts
+1 -1
internal/atproto/oauth/client.go
··· 90 90 } else { 91 91 // Production mode: public OAuth client with HTTPS 92 92 // client_id must be the URL of the client metadata document per atproto OAuth spec 93 - clientID := config.PublicURL + "/oauth/client-metadata.json" 93 + clientID := config.PublicURL + "/oauth-client-metadata.json" 94 94 callbackURL := config.PublicURL + "/oauth/callback" 95 95 clientConfig = oauth.NewPublicConfig(clientID, callbackURL, config.Scopes) 96 96 }
+1 -1
internal/atproto/oauth/handlers_test.go
··· 50 50 51 51 // Validate metadata 52 52 // Per atproto OAuth spec, client_id for public clients is the client metadata URL 53 - assert.Equal(t, "https://coves.social/oauth/client-metadata.json", metadata.ClientID) 53 + assert.Equal(t, "https://coves.social/oauth-client-metadata.json", metadata.ClientID) 54 54 assert.Contains(t, metadata.RedirectURIs, "https://coves.social/oauth/callback") 55 55 assert.Contains(t, metadata.GrantTypes, "authorization_code") 56 56 assert.Contains(t, metadata.GrantTypes, "refresh_token")
+41
aggregators/reddit-highlights/.gitignore
··· 1 + # Environment and config 2 + .env 3 + config.yaml 4 + venv/ 5 + 6 + # State files 7 + data/*.json 8 + data/world.xml 9 + 10 + # Python 11 + __pycache__/ 12 + *.py[cod] 13 + *$py.class 14 + *.so 15 + .Python 16 + build/ 17 + develop-eggs/ 18 + dist/ 19 + downloads/ 20 + eggs/ 21 + .eggs/ 22 + lib/ 23 + lib64/ 24 + parts/ 25 + sdist/ 26 + var/ 27 + wheels/ 28 + *.egg-info/ 29 + .installed.cfg 30 + *.egg 31 + 32 + # Testing 33 + .pytest_cache/ 34 + .coverage 35 + htmlcov/ 36 + 37 + # IDE 38 + .vscode/ 39 + .idea/ 40 + *.swp 41 + *.swo
+140
aggregators/reddit-highlights/README.md
··· 1 + # Reddit Highlights Aggregator 2 + 3 + Aggregates video highlights from Reddit subreddits (e.g., r/nba) and posts them to Coves communities. 4 + 5 + ## Features 6 + 7 + - Fetches posts from Reddit via RSS (no API key required) 8 + - Extracts streamable.com video links 9 + - Posts to configured Coves communities with proper attribution 10 + - Anti-detection jitter (randomized polling intervals) 11 + - State tracking for deduplication 12 + - Docker deployment with cron scheduler 13 + 14 + ## Quick Start 15 + 16 + 1. **Copy environment file:** 17 + ```bash 18 + cp .env.example .env 19 + ``` 20 + 21 + 2. **Configure your Coves API key:** 22 + ```bash 23 + # Edit .env and set your API key 24 + COVES_API_KEY=ckapi_your_key_here 25 + ``` 26 + 27 + 3. **Build and run:** 28 + ```bash 29 + docker-compose up -d 30 + ``` 31 + 32 + 4. **View logs:** 33 + ```bash 34 + docker-compose logs -f 35 + ``` 36 + 37 + ## Configuration 38 + 39 + ### config.yaml 40 + 41 + ```yaml 42 + coves_api_url: "https://coves.social" 43 + 44 + subreddits: 45 + - name: "nba" 46 + community_handle: "nba.coves.social" 47 + enabled: true 48 + 49 + allowed_domains: 50 + - streamable.com 51 + ``` 52 + 53 + ### Adding More Subreddits 54 + 55 + 1. Add entry to `config.yaml`: 56 + ```yaml 57 + - name: "soccer" 58 + community_handle: "soccer.coves.social" 59 + enabled: true 60 + ``` 61 + 62 + 2. Authorize the aggregator for the new community in Coves 63 + 64 + 3. Restart the container: 65 + ```bash 66 + docker-compose restart 67 + ``` 68 + 69 + ## Polling Schedule 70 + 71 + - Cron runs every **10 minutes** 72 + - Python script adds **0-10 minutes random jitter** 73 + - Effective polling interval: **10-20 minutes** (varies each run) 74 + 75 + This randomization helps avoid bot detection patterns. 76 + 77 + ## Development 78 + 79 + ### Setup 80 + 81 + ```bash 82 + # Create virtual environment 83 + python -m venv venv 84 + source venv/bin/activate 85 + 86 + # Install dependencies 87 + pip install -r requirements.txt 88 + ``` 89 + 90 + ### Run Tests 91 + 92 + ```bash 93 + pytest 94 + ``` 95 + 96 + ### Run Manually 97 + 98 + ```bash 99 + # Set environment variables 100 + export COVES_API_KEY=ckapi_your_key 101 + export SKIP_JITTER=true # Skip delay for testing 102 + 103 + # Run 104 + python -m src.main 105 + ``` 106 + 107 + ## Architecture 108 + 109 + ``` 110 + src/ 111 + โ”œโ”€โ”€ main.py # Orchestration (CRON entry point) 112 + โ”œโ”€โ”€ rss_fetcher.py # RSS feed fetching with retry 113 + โ”œโ”€โ”€ link_extractor.py # Streamable URL detection 114 + โ”œโ”€โ”€ coves_client.py # Coves API client 115 + โ”œโ”€โ”€ state_manager.py # Deduplication state tracking 116 + โ”œโ”€โ”€ config.py # YAML config loader 117 + โ””โ”€โ”€ models.py # Data models 118 + ``` 119 + 120 + ## Post Format 121 + 122 + Posts are created with: 123 + - **Title**: Reddit post title 124 + - **Embed**: Streamable video link with metadata 125 + - **Sources**: Link back to original Reddit post 126 + 127 + Example embed: 128 + ```json 129 + { 130 + "$type": "social.coves.embed.external", 131 + "external": { 132 + "uri": "https://streamable.com/abc123", 133 + "title": "LeBron with the chase-down block!", 134 + "description": "From r/nba", 135 + "sources": [ 136 + {"uri": "https://reddit.com/...", "title": "r/nba", "domain": "reddit.com"} 137 + ] 138 + } 139 + } 140 + ```
+44
aggregators/reddit-highlights/config.example.yaml
··· 1 + # Reddit Highlights Aggregator Configuration 2 + # 3 + # Copy this file to config.yaml and customize for your setup. 4 + # 5 + # This file configures which subreddits to fetch video highlights from 6 + # and which Coves communities to post them to. 7 + 8 + # Coves API endpoint (can be overridden with COVES_API_URL env var) 9 + coves_api_url: "https://coves.social" 10 + 11 + # Subreddit-to-community mappings 12 + # Add entries here for each subreddit you want to aggregate 13 + subreddits: 14 + # NBA highlights 15 + - name: "nba" 16 + community_handle: "nba.coves.social" 17 + enabled: true 18 + 19 + # Example: Soccer/football highlights (disabled by default) 20 + # - name: "soccer" 21 + # community_handle: "soccer.coves.social" 22 + # enabled: false 23 + 24 + # Example: NHL highlights (disabled by default) 25 + # - name: "hockey" 26 + # community_handle: "hockey.coves.social" 27 + # enabled: false 28 + 29 + # Allowed video hosting domains 30 + # Only posts with links to these domains will be imported 31 + # Add more domains here as needed 32 + allowed_domains: 33 + - streamable.com 34 + # Future options: 35 + # - streamff.com 36 + # - streamja.com 37 + 38 + # Maximum streamable posts to process per run 39 + # Scans the feed for the top N posts with streamable links, skipping non-video posts. 40 + # This limits volume while ensuring the most popular highlights are posted. 41 + max_posts_per_run: 3 42 + 43 + # Logging level (debug, info, warning, error) 44 + log_level: "info"
+6
aggregators/reddit-highlights/pytest.ini
··· 1 + [pytest] 2 + testpaths = tests 3 + python_files = test_*.py 4 + python_classes = Test* 5 + python_functions = test_* 6 + addopts = -v --tb=short
+15
aggregators/reddit-highlights/requirements.txt
··· 1 + # Core dependencies 2 + feedparser==6.0.11 3 + requests==2.31.0 4 + pyyaml==6.0.1 5 + 6 + # Testing 7 + pytest==8.1.1 8 + pytest-cov==5.0.0 9 + responses==0.25.0 10 + 11 + # Development 12 + black==24.3.0 13 + mypy==1.9.0 14 + types-PyYAML==6.0.12.12 15 + types-requests==2.31.0.20240311
+1
aggregators/reddit-highlights/src/__init__.py
··· 1 + """Reddit Highlights Aggregator for Coves."""
+187
aggregators/reddit-highlights/src/config.py
··· 1 + """ 2 + Configuration Loader for Reddit Highlights Aggregator. 3 + 4 + Loads and validates configuration from YAML files. 5 + """ 6 + import os 7 + import logging 8 + from pathlib import Path 9 + from typing import Dict, Any, List 10 + import yaml 11 + from urllib.parse import urlparse 12 + 13 + from src.models import AggregatorConfig, SubredditConfig, LogLevel 14 + 15 + logger = logging.getLogger(__name__) 16 + 17 + 18 + class ConfigError(Exception): 19 + """Configuration error.""" 20 + 21 + pass 22 + 23 + 24 + class ConfigLoader: 25 + """ 26 + Loads and validates aggregator configuration. 27 + 28 + Supports: 29 + - Loading from YAML file 30 + - Environment variable overrides 31 + - Validation of required fields 32 + """ 33 + 34 + def __init__(self, config_path: Path): 35 + """ 36 + Initialize config loader. 37 + 38 + Args: 39 + config_path: Path to config.yaml file 40 + """ 41 + self.config_path = Path(config_path) 42 + 43 + def load(self) -> AggregatorConfig: 44 + """ 45 + Load and validate configuration. 46 + 47 + Returns: 48 + AggregatorConfig object 49 + 50 + Raises: 51 + ConfigError: If config is invalid or missing 52 + """ 53 + if not self.config_path.exists(): 54 + raise ConfigError(f"Configuration file not found: {self.config_path}") 55 + 56 + try: 57 + with open(self.config_path, "r") as f: 58 + config_data = yaml.safe_load(f) 59 + except yaml.YAMLError as e: 60 + raise ConfigError(f"Failed to parse YAML: {e}") 61 + 62 + if not config_data: 63 + raise ConfigError("Configuration file is empty") 64 + 65 + try: 66 + return self._parse_config(config_data) 67 + except ConfigError: 68 + raise 69 + except Exception as e: 70 + raise ConfigError(f"Invalid configuration: {e}") 71 + 72 + def _parse_config(self, data: Dict[str, Any]) -> AggregatorConfig: 73 + """ 74 + Parse and validate configuration data. 75 + 76 + Args: 77 + data: Parsed YAML data 78 + 79 + Returns: 80 + AggregatorConfig object 81 + 82 + Raises: 83 + ConfigError: If validation fails 84 + """ 85 + coves_api_url = os.getenv("COVES_API_URL", data.get("coves_api_url")) 86 + if not coves_api_url: 87 + raise ConfigError("Missing required field: coves_api_url") 88 + 89 + if not self._is_valid_url(coves_api_url): 90 + raise ConfigError(f"Invalid URL for coves_api_url: {coves_api_url}") 91 + 92 + # Parse log level with validation 93 + log_level_str = data.get("log_level", "info").lower() 94 + try: 95 + log_level = LogLevel(log_level_str) 96 + except ValueError: 97 + valid_levels = [level.value for level in LogLevel] 98 + raise ConfigError(f"Invalid log_level '{log_level_str}'. Valid values: {valid_levels}") 99 + 100 + subreddits_data = data.get("subreddits", []) 101 + if not subreddits_data: 102 + raise ConfigError("Configuration must include at least one subreddit") 103 + 104 + subreddits = [] 105 + for sub_data in subreddits_data: 106 + subreddit = self._parse_subreddit(sub_data) 107 + subreddits.append(subreddit) 108 + 109 + allowed_domains = tuple(data.get("allowed_domains", ["streamable.com"])) 110 + 111 + max_posts_per_run = data.get("max_posts_per_run", 3) 112 + if type(max_posts_per_run) is not int or max_posts_per_run < 1: 113 + raise ConfigError(f"max_posts_per_run must be a positive integer, got: {max_posts_per_run}") 114 + 115 + enabled_count = sum(1 for s in subreddits if s.enabled) 116 + logger.info( 117 + f"Loaded configuration with {len(subreddits)} subreddits ({enabled_count} enabled), " 118 + f"max {max_posts_per_run} posts per run" 119 + ) 120 + 121 + return AggregatorConfig( 122 + coves_api_url=coves_api_url, 123 + subreddits=tuple(subreddits), # Convert to tuple for immutability 124 + allowed_domains=allowed_domains, 125 + log_level=log_level, 126 + max_posts_per_run=max_posts_per_run, 127 + ) 128 + 129 + def _parse_subreddit(self, data: Dict[str, Any]) -> SubredditConfig: 130 + """ 131 + Parse and validate a single subreddit configuration. 132 + 133 + Args: 134 + data: Subreddit configuration data 135 + 136 + Returns: 137 + SubredditConfig object 138 + 139 + Raises: 140 + ConfigError: If validation fails 141 + """ 142 + required_fields = ["name", "community_handle"] 143 + for field in required_fields: 144 + if field not in data: 145 + raise ConfigError( 146 + f"Missing required field in subreddit config: {field}" 147 + ) 148 + 149 + name = data["name"] 150 + community_handle = data["community_handle"] 151 + enabled = data.get("enabled", True) 152 + 153 + if not name or not name.strip(): 154 + raise ConfigError("Subreddit name cannot be empty") 155 + 156 + if not community_handle or not community_handle.strip(): 157 + raise ConfigError(f"Community handle cannot be empty for subreddit '{name}'") 158 + 159 + return SubredditConfig( 160 + name=name.strip().lower(), 161 + community_handle=community_handle.strip(), 162 + enabled=enabled, 163 + ) 164 + 165 + def _is_valid_url(self, url: str) -> bool: 166 + """ 167 + Validate URL format. 168 + 169 + Only allows http and https schemes to prevent dangerous schemes 170 + like file://, javascript://, or data:// URIs. 171 + 172 + Args: 173 + url: URL to validate 174 + 175 + Returns: 176 + True if valid HTTP/HTTPS URL, False otherwise 177 + """ 178 + try: 179 + result = urlparse(url) 180 + # Only allow http and https schemes 181 + if result.scheme not in ("http", "https"): 182 + logger.warning(f"URL has invalid scheme '{result.scheme}': {url}") 183 + return False 184 + return bool(result.netloc) 185 + except ValueError as e: 186 + logger.warning(f"Failed to parse URL '{url}': {e}") 187 + return False
+285
aggregators/reddit-highlights/src/coves_client.py
··· 1 + """ 2 + Coves API Client for posting to communities. 3 + 4 + Handles API key authentication and posting via XRPC. 5 + """ 6 + import logging 7 + import requests 8 + from typing import Dict, List, Optional 9 + 10 + logger = logging.getLogger(__name__) 11 + 12 + 13 + class CovesAPIError(Exception): 14 + """Base exception for Coves API errors.""" 15 + 16 + def __init__(self, message: str, status_code: int = None, response_body: str = None): 17 + super().__init__(message) 18 + self.status_code = status_code 19 + self.response_body = response_body 20 + 21 + 22 + class CovesAuthenticationError(CovesAPIError): 23 + """Raised when authentication fails (401 Unauthorized).""" 24 + pass 25 + 26 + 27 + class CovesNotFoundError(CovesAPIError): 28 + """Raised when a resource is not found (404 Not Found).""" 29 + pass 30 + 31 + 32 + class CovesRateLimitError(CovesAPIError): 33 + """Raised when rate limit is exceeded (429 Too Many Requests).""" 34 + pass 35 + 36 + 37 + class CovesForbiddenError(CovesAPIError): 38 + """Raised when access is forbidden (403 Forbidden).""" 39 + pass 40 + 41 + 42 + class CovesClient: 43 + """ 44 + Client for posting to Coves communities via XRPC. 45 + 46 + Handles: 47 + - API key authentication 48 + - Creating posts in communities (social.coves.community.post.create) 49 + - External embed formatting 50 + """ 51 + 52 + # API key format constants (must match Go constants in apikey_service.go) 53 + API_KEY_PREFIX = "ckapi_" 54 + API_KEY_TOTAL_LENGTH = 70 # 6 (prefix) + 64 (32 bytes hex-encoded) 55 + 56 + def __init__(self, api_url: str, api_key: str): 57 + """ 58 + Initialize Coves client with API key authentication. 59 + 60 + Args: 61 + api_url: Coves API URL for posting (e.g., "https://coves.social") 62 + api_key: Coves API key, 70 characters total (6-char prefix + 64-char hex token) 63 + 64 + Raises: 65 + ValueError: If api_key is empty, has wrong prefix, or wrong length 66 + """ 67 + # Validate API key format for early failure with clear error 68 + if not api_key: 69 + raise ValueError("API key cannot be empty") 70 + if not api_key.startswith(self.API_KEY_PREFIX): 71 + raise ValueError(f"API key must start with '{self.API_KEY_PREFIX}'") 72 + if len(api_key) != self.API_KEY_TOTAL_LENGTH: 73 + raise ValueError( 74 + f"API key must be {self.API_KEY_TOTAL_LENGTH} characters " 75 + f"(got {len(api_key)})" 76 + ) 77 + 78 + self.api_url = api_url.rstrip('/') 79 + self.api_key = api_key 80 + self.session = requests.Session() 81 + self.session.headers['Authorization'] = f'Bearer {api_key}' 82 + self.session.headers['Content-Type'] = 'application/json' 83 + 84 + def authenticate(self): 85 + """ 86 + No-op for API key authentication. 87 + 88 + API key is set in the session headers during initialization. 89 + This method is kept for backward compatibility with existing code 90 + that calls authenticate() before making requests. 91 + """ 92 + logger.info("Using API key authentication (no session creation needed)") 93 + 94 + def create_post( 95 + self, 96 + community_handle: str, 97 + content: str, 98 + facets: List[Dict], 99 + title: Optional[str] = None, 100 + embed: Optional[Dict] = None, 101 + thumbnail_url: Optional[str] = None 102 + ) -> str: 103 + """ 104 + Create a post in a community. 105 + 106 + Args: 107 + community_handle: Community handle (e.g., "world-news.coves.social") 108 + content: Post content (rich text) 109 + facets: Rich text facets (formatting, links) 110 + title: Optional post title 111 + embed: Optional external embed 112 + thumbnail_url: Optional thumbnail URL (for trusted aggregators only) 113 + 114 + Returns: 115 + AT Proto URI of created post (e.g., "at://did:plc:.../social.coves.post/...") 116 + 117 + Raises: 118 + CovesAuthenticationError: If authentication fails (401) 119 + CovesForbiddenError: If access is denied (403) 120 + CovesNotFoundError: If community not found (404) 121 + CovesRateLimitError: If rate limit exceeded (429) 122 + CovesAPIError: For other API errors or invalid responses 123 + requests.RequestException: For network-level errors 124 + """ 125 + try: 126 + # Prepare post data for social.coves.community.post.create endpoint 127 + post_data = { 128 + "community": community_handle, 129 + "content": content, 130 + "facets": facets 131 + } 132 + 133 + # Add title if provided 134 + if title: 135 + post_data["title"] = title 136 + 137 + # Add embed if provided 138 + if embed: 139 + post_data["embed"] = embed 140 + 141 + # Add thumbnail URL at top level if provided (for trusted aggregators) 142 + if thumbnail_url: 143 + post_data["thumbnailUrl"] = thumbnail_url 144 + 145 + # Use Coves-specific endpoint (not direct PDS write) 146 + # This provides validation, authorization, and business logic 147 + logger.info(f"Creating post in community: {community_handle}") 148 + 149 + # Make HTTP request to XRPC endpoint using session with API key 150 + url = f"{self.api_url}/xrpc/social.coves.community.post.create" 151 + response = self.session.post(url, json=post_data, timeout=30) 152 + 153 + # Handle specific error cases 154 + if not response.ok: 155 + # Log status code but not full response body (may contain sensitive data) 156 + logger.error(f"Post creation failed with status {response.status_code}") 157 + self._raise_for_status(response) 158 + 159 + try: 160 + result = response.json() 161 + post_uri = result["uri"] 162 + except (ValueError, KeyError) as e: 163 + # ValueError for invalid JSON, KeyError for missing 'uri' field 164 + logger.error(f"Failed to parse post creation response: {e}") 165 + raise CovesAPIError( 166 + f"Invalid response from server: {e}", 167 + status_code=response.status_code, 168 + response_body=response.text 169 + ) 170 + 171 + logger.info(f"Post created: {post_uri}") 172 + return post_uri 173 + 174 + except requests.RequestException as e: 175 + # Network errors, timeouts, etc. 176 + logger.error(f"Network error creating post: {e}") 177 + raise 178 + except CovesAPIError: 179 + # Re-raise our custom exceptions as-is 180 + raise 181 + 182 + def create_external_embed( 183 + self, 184 + uri: str, 185 + title: str, 186 + description: str, 187 + sources: Optional[List[Dict]] = None, 188 + embed_type: Optional[str] = None, 189 + provider: Optional[str] = None, 190 + domain: Optional[str] = None 191 + ) -> Dict: 192 + """ 193 + Create external embed object for hot-linked content. 194 + 195 + Args: 196 + uri: URL of the external content 197 + title: Title of the content 198 + description: Description/summary 199 + sources: Optional list of source dicts with uri, title, domain 200 + embed_type: Type hint for rendering (article, image, video, website) 201 + provider: Service provider name (e.g., streamable, imgur) 202 + domain: Domain of the linked content (e.g., streamable.com) 203 + 204 + Returns: 205 + Embed dictionary ready for post creation 206 + """ 207 + external = { 208 + "uri": uri, 209 + "title": title, 210 + "description": description 211 + } 212 + 213 + if sources: 214 + external["sources"] = sources 215 + 216 + if embed_type: 217 + external["embedType"] = embed_type 218 + 219 + if provider: 220 + external["provider"] = provider 221 + 222 + if domain: 223 + external["domain"] = domain 224 + 225 + return { 226 + "$type": "social.coves.embed.external", 227 + "external": external 228 + } 229 + 230 + def _raise_for_status(self, response: requests.Response) -> None: 231 + """ 232 + Raise specific exceptions based on HTTP status code. 233 + 234 + Args: 235 + response: The HTTP response object 236 + 237 + Raises: 238 + CovesAuthenticationError: For 401 Unauthorized 239 + CovesNotFoundError: For 404 Not Found 240 + CovesRateLimitError: For 429 Too Many Requests 241 + CovesAPIError: For other 4xx/5xx errors 242 + """ 243 + status_code = response.status_code 244 + error_body = response.text 245 + 246 + if status_code == 401: 247 + raise CovesAuthenticationError( 248 + f"Authentication failed: {error_body}", 249 + status_code=status_code, 250 + response_body=error_body 251 + ) 252 + elif status_code == 403: 253 + raise CovesForbiddenError( 254 + f"Access forbidden: {error_body}", 255 + status_code=status_code, 256 + response_body=error_body 257 + ) 258 + elif status_code == 404: 259 + raise CovesNotFoundError( 260 + f"Resource not found: {error_body}", 261 + status_code=status_code, 262 + response_body=error_body 263 + ) 264 + elif status_code == 429: 265 + raise CovesRateLimitError( 266 + f"Rate limit exceeded: {error_body}", 267 + status_code=status_code, 268 + response_body=error_body 269 + ) 270 + else: 271 + raise CovesAPIError( 272 + f"API request failed ({status_code}): {error_body}", 273 + status_code=status_code, 274 + response_body=error_body 275 + ) 276 + 277 + def _get_timestamp(self) -> str: 278 + """ 279 + Get current timestamp in ISO 8601 format. 280 + 281 + Returns: 282 + ISO timestamp string 283 + """ 284 + from datetime import datetime, timezone 285 + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+374
aggregators/reddit-highlights/src/main.py
··· 1 + """ 2 + Main Orchestration Script for Reddit Highlights Aggregator. 3 + 4 + Coordinates all components to: 5 + 1. Apply anti-detection jitter delay 6 + 2. Fetch Reddit RSS feeds 7 + 3. Extract streamable video links 8 + 4. Deduplicate via state tracking 9 + 5. Post to Coves communities 10 + 11 + Designed to run via CRON (single execution, then exit). 12 + """ 13 + import os 14 + import re 15 + import sys 16 + import time 17 + import random 18 + import logging 19 + from pathlib import Path 20 + from datetime import datetime 21 + from typing import Optional 22 + 23 + from src.config import ConfigLoader 24 + from src.rss_fetcher import RSSFetcher 25 + from src.link_extractor import LinkExtractor 26 + from src.state_manager import StateManager 27 + from src.coves_client import CovesClient 28 + from src.models import RedditPost 29 + 30 + # Setup logging 31 + logging.basicConfig( 32 + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 33 + ) 34 + logger = logging.getLogger(__name__) 35 + 36 + # Reddit RSS URL template 37 + REDDIT_RSS_URL = "https://www.reddit.com/r/{subreddit}/.rss" 38 + 39 + # Anti-detection jitter range (0-10 minutes in seconds) 40 + JITTER_MIN_SECONDS = 0 41 + JITTER_MAX_SECONDS = 600 42 + 43 + 44 + class Aggregator: 45 + """ 46 + Main aggregator orchestration. 47 + 48 + Coordinates all components to fetch, filter, and post video highlights. 49 + """ 50 + 51 + def __init__( 52 + self, 53 + config_path: Path, 54 + state_file: Path, 55 + coves_client: Optional[CovesClient] = None, 56 + skip_jitter: bool = False, 57 + ): 58 + """ 59 + Initialize aggregator. 60 + 61 + Args: 62 + config_path: Path to config.yaml 63 + state_file: Path to state.json 64 + coves_client: Optional CovesClient (for testing) 65 + skip_jitter: Skip anti-detection delay (for testing) 66 + """ 67 + self.skip_jitter = skip_jitter 68 + 69 + # Load configuration 70 + logger.info("Loading configuration...") 71 + config_loader = ConfigLoader(config_path) 72 + self.config = config_loader.load() 73 + 74 + # Initialize components 75 + logger.info("Initializing components...") 76 + self.rss_fetcher = RSSFetcher() 77 + self.link_extractor = LinkExtractor( 78 + allowed_domains=self.config.allowed_domains 79 + ) 80 + self.state_manager = StateManager(state_file) 81 + self.state_file = state_file 82 + 83 + # Initialize Coves client (or use provided one for testing) 84 + if coves_client: 85 + self.coves_client = coves_client 86 + else: 87 + api_key = os.getenv("COVES_API_KEY") 88 + if not api_key: 89 + raise ValueError("COVES_API_KEY environment variable required") 90 + 91 + self.coves_client = CovesClient( 92 + api_url=self.config.coves_api_url, api_key=api_key 93 + ) 94 + 95 + def run(self): 96 + """ 97 + Run aggregator: apply jitter, fetch, filter, post, and update state. 98 + 99 + This is the main entry point for CRON execution. 100 + """ 101 + logger.info("=" * 60) 102 + logger.info("Starting Reddit Highlights Aggregator") 103 + logger.info("=" * 60) 104 + 105 + # Anti-detection jitter: random delay before starting 106 + if not self.skip_jitter: 107 + jitter_seconds = random.uniform(JITTER_MIN_SECONDS, JITTER_MAX_SECONDS) 108 + logger.info( 109 + f"Applying anti-detection jitter: sleeping for {jitter_seconds:.1f} seconds " 110 + f"({jitter_seconds/60:.1f} minutes)" 111 + ) 112 + time.sleep(jitter_seconds) 113 + 114 + # Get enabled subreddits only 115 + enabled_subreddits = [s for s in self.config.subreddits if s.enabled] 116 + logger.info(f"Processing {len(enabled_subreddits)} enabled subreddits") 117 + 118 + # Authenticate once at the start 119 + try: 120 + self.coves_client.authenticate() 121 + except Exception as e: 122 + logger.error(f"Failed to authenticate: {e}") 123 + logger.error("Cannot continue without authentication") 124 + raise RuntimeError("Authentication failed") from e 125 + 126 + # Process each subreddit 127 + for subreddit_config in enabled_subreddits: 128 + try: 129 + self._process_subreddit(subreddit_config) 130 + except KeyboardInterrupt: 131 + # Re-raise interrupt signals - don't suppress user abort 132 + logger.info("Received interrupt signal, stopping...") 133 + raise 134 + except Exception as e: 135 + # Log error but continue with other subreddits 136 + logger.error( 137 + f"Error processing subreddit '{subreddit_config.name}': {e}", 138 + exc_info=True, 139 + ) 140 + continue 141 + 142 + logger.info("=" * 60) 143 + logger.info("Aggregator run completed") 144 + logger.info("=" * 60) 145 + 146 + def _process_subreddit(self, subreddit_config): 147 + """ 148 + Process a single subreddit. 149 + 150 + Args: 151 + subreddit_config: SubredditConfig object 152 + """ 153 + subreddit_name = subreddit_config.name 154 + community_handle = subreddit_config.community_handle 155 + 156 + # Sanitize subreddit name to prevent URL injection 157 + # Only allow alphanumeric, underscores, and hyphens 158 + if not re.match(r'^[a-zA-Z0-9_-]+$', subreddit_name): 159 + raise ValueError(f"Invalid subreddit name: {subreddit_name}") 160 + 161 + logger.info(f"Processing subreddit: r/{subreddit_name} -> {community_handle}") 162 + 163 + # Build RSS URL 164 + rss_url = REDDIT_RSS_URL.format(subreddit=subreddit_name) 165 + 166 + # Fetch RSS feed 167 + try: 168 + feed = self.rss_fetcher.fetch_feed(rss_url) 169 + except Exception as e: 170 + logger.error(f"Failed to fetch feed for r/{subreddit_name}: {e}") 171 + raise 172 + 173 + # Check for feed errors 174 + if feed.bozo: 175 + bozo_exception = getattr(feed, 'bozo_exception', None) 176 + logger.warning( 177 + f"Feed for r/{subreddit_name} has parsing issues (bozo flag set): {bozo_exception}" 178 + ) 179 + 180 + # Find top N entries with streamable links (filter first, then limit) 181 + max_posts = self.config.max_posts_per_run 182 + new_posts = 0 183 + skipped_posts = 0 184 + no_video_count = 0 185 + entries_checked = 0 186 + 187 + logger.info(f"Scanning feed for top {max_posts} streamable entries") 188 + 189 + for entry in feed.entries: 190 + # Stop once we've found enough posts to process 191 + if new_posts + skipped_posts >= max_posts: 192 + break 193 + 194 + entries_checked += 1 195 + 196 + try: 197 + # Extract video URL - skip if not a streamable link 198 + video_url = self.link_extractor.extract_video_url(entry) 199 + if not video_url: 200 + no_video_count += 1 201 + continue # Skip posts without video links 202 + 203 + # Get entry ID for deduplication 204 + entry_id = self._get_entry_id(entry) 205 + if self.state_manager.is_posted(subreddit_name, entry_id): 206 + skipped_posts += 1 207 + logger.debug(f"Skipping already-posted entry: {entry_id}") 208 + continue 209 + 210 + # Parse entry into RedditPost 211 + reddit_post = self._parse_entry(entry, subreddit_name, video_url) 212 + 213 + # Create embed with sources and video metadata 214 + # Note: Thumbnail is fetched by backend via unfurl service 215 + embed = self.coves_client.create_external_embed( 216 + uri=reddit_post.streamable_url, 217 + title=reddit_post.title, 218 + description=f"From r/{subreddit_name}", 219 + sources=[ 220 + { 221 + "uri": reddit_post.reddit_url, 222 + "title": f"r/{subreddit_name}", 223 + "domain": "reddit.com", 224 + } 225 + ], 226 + embed_type="video", 227 + provider="streamable", 228 + domain="streamable.com", 229 + ) 230 + 231 + # Post to community 232 + try: 233 + post_uri = self.coves_client.create_post( 234 + community_handle=community_handle, 235 + title=reddit_post.title, 236 + content="", # No additional content needed 237 + facets=[], 238 + embed=embed, 239 + ) 240 + 241 + # Mark as posted (only if successful) 242 + self.state_manager.mark_posted(subreddit_name, entry_id, post_uri) 243 + new_posts += 1 244 + logger.info(f"Posted: {reddit_post.title[:50]}... -> {post_uri}") 245 + 246 + except Exception as e: 247 + # Don't update state if posting failed 248 + logger.error(f"Failed to post '{reddit_post.title}': {e}") 249 + continue 250 + 251 + except Exception as e: 252 + # Log error but continue with other entries 253 + logger.error(f"Error processing entry: {e}", exc_info=True) 254 + continue 255 + 256 + # Update last run timestamp 257 + self.state_manager.update_last_run(subreddit_name, datetime.now()) 258 + 259 + logger.info( 260 + f"r/{subreddit_name}: {new_posts} new posts, {skipped_posts} duplicates " 261 + f"(checked {entries_checked} entries, skipped {no_video_count} without streamable link)" 262 + ) 263 + 264 + def _get_entry_id(self, entry) -> str: 265 + """ 266 + Get unique identifier for RSS entry. 267 + 268 + Args: 269 + entry: feedparser entry 270 + 271 + Returns: 272 + Unique ID string 273 + """ 274 + # Reddit RSS uses 'id' field with format like 't3_abc123' 275 + if hasattr(entry, "id") and entry.id: 276 + return entry.id 277 + 278 + # Fallback to link 279 + if hasattr(entry, "link") and entry.link: 280 + return entry.link 281 + 282 + # Last resort: title hash (using SHA-256 for security) 283 + if hasattr(entry, "title") and entry.title: 284 + import hashlib 285 + 286 + logger.warning(f"Using fallback hash for entry ID (no id or link found)") 287 + return hashlib.sha256(entry.title.encode()).hexdigest() 288 + 289 + raise ValueError("Cannot determine entry ID") 290 + 291 + def _parse_entry(self, entry, subreddit: str, video_url: str) -> RedditPost: 292 + """ 293 + Parse RSS entry into RedditPost object. 294 + 295 + Args: 296 + entry: feedparser entry 297 + subreddit: Subreddit name 298 + video_url: Extracted video URL 299 + 300 + Returns: 301 + RedditPost object 302 + """ 303 + # Get entry ID 304 + entry_id = self._get_entry_id(entry) 305 + 306 + # Get title 307 + title = entry.title if hasattr(entry, "title") else "Untitled" 308 + 309 + # Get Reddit permalink 310 + reddit_url = entry.link if hasattr(entry, "link") else "" 311 + 312 + # Get author (Reddit RSS uses 'author' field) 313 + author = "" 314 + if hasattr(entry, "author"): 315 + author = entry.author 316 + elif hasattr(entry, "author_detail") and hasattr(entry.author_detail, "name"): 317 + author = entry.author_detail.name 318 + 319 + # Get published date 320 + published = None 321 + if hasattr(entry, "published_parsed") and entry.published_parsed: 322 + try: 323 + published = datetime(*entry.published_parsed[:6]) 324 + except (TypeError, ValueError) as e: 325 + logger.warning(f"Failed to parse published date for entry: {e}") 326 + 327 + return RedditPost( 328 + id=entry_id, 329 + title=title, 330 + link=entry.link if hasattr(entry, "link") else "", 331 + reddit_url=reddit_url, 332 + subreddit=subreddit, 333 + author=author, 334 + published=published, 335 + streamable_url=video_url, 336 + ) 337 + 338 + 339 + def main(): 340 + """ 341 + Main entry point for command-line execution. 342 + 343 + Usage: 344 + python -m src.main 345 + """ 346 + # Get paths from environment or use defaults 347 + config_path = Path(os.getenv("CONFIG_PATH", "config.yaml")) 348 + state_file = Path(os.getenv("STATE_FILE", "data/state.json")) 349 + 350 + # Check for skip jitter flag (for testing) 351 + skip_jitter = os.getenv("SKIP_JITTER", "").lower() in ("true", "1", "yes") 352 + 353 + # Validate config file exists 354 + if not config_path.exists(): 355 + logger.error(f"Configuration file not found: {config_path}") 356 + logger.error("Please create config.yaml (see config.example.yaml)") 357 + sys.exit(1) 358 + 359 + # Create aggregator and run 360 + try: 361 + aggregator = Aggregator( 362 + config_path=config_path, 363 + state_file=state_file, 364 + skip_jitter=skip_jitter, 365 + ) 366 + aggregator.run() 367 + sys.exit(0) 368 + except Exception as e: 369 + logger.error(f"Aggregator failed: {e}", exc_info=True) 370 + sys.exit(1) 371 + 372 + 373 + if __name__ == "__main__": 374 + main()
+95
aggregators/reddit-highlights/src/models.py
··· 1 + """ 2 + Data models for Reddit Highlights Aggregator. 3 + """ 4 + from dataclasses import dataclass, field 5 + from enum import Enum 6 + from typing import List, Optional, Tuple 7 + from datetime import datetime 8 + import re 9 + 10 + 11 + class LogLevel(Enum): 12 + """Valid log levels for aggregator configuration.""" 13 + DEBUG = "debug" 14 + INFO = "info" 15 + WARNING = "warning" 16 + ERROR = "error" 17 + CRITICAL = "critical" 18 + 19 + 20 + @dataclass 21 + class RedditPost: 22 + """ 23 + Represents a Reddit post with video content. 24 + 25 + Parsed from Reddit RSS feed entries. 26 + """ 27 + 28 + id: str # Reddit post ID (e.g., "t3_1abc123" or just the rkey) 29 + title: str # Post title 30 + link: str # Direct link to content (may be streamable URL) 31 + reddit_url: str # Permalink to Reddit post 32 + subreddit: str # Subreddit name (without r/) 33 + author: str # Reddit username 34 + published: Optional[datetime] = None # Post publication time 35 + streamable_url: Optional[str] = None # Extracted streamable URL (if found) 36 + 37 + def __post_init__(self): 38 + """Validate required fields.""" 39 + if not self.id: 40 + raise ValueError("RedditPost.id cannot be empty") 41 + if not self.title: 42 + raise ValueError("RedditPost.title cannot be empty") 43 + if not self.subreddit: 44 + raise ValueError("RedditPost.subreddit cannot be empty") 45 + 46 + 47 + @dataclass(frozen=True) 48 + class SubredditConfig: 49 + """ 50 + Configuration for a single subreddit source. 51 + 52 + Maps a subreddit to a Coves community. 53 + Immutable (frozen) to prevent accidental modification. 54 + """ 55 + 56 + name: str # Subreddit name (e.g., "nba") 57 + community_handle: str # Coves community (e.g., "nba.coves.social") 58 + enabled: bool = True # Whether to fetch from this subreddit 59 + 60 + def __post_init__(self): 61 + """Validate configuration fields.""" 62 + if not self.name or not self.name.strip(): 63 + raise ValueError("SubredditConfig.name cannot be empty") 64 + if not self.community_handle or not self.community_handle.strip(): 65 + raise ValueError("SubredditConfig.community_handle cannot be empty") 66 + # Validate subreddit name format (alphanumeric, underscores, hyphens only) 67 + if not re.match(r'^[a-zA-Z0-9_-]+$', self.name): 68 + raise ValueError(f"Invalid subreddit name format: {self.name}") 69 + 70 + 71 + @dataclass(frozen=True) 72 + class AggregatorConfig: 73 + """ 74 + Full aggregator configuration. 75 + 76 + Loaded from config.yaml. 77 + Immutable (frozen) to prevent accidental modification after loading. 78 + """ 79 + 80 + coves_api_url: str 81 + subreddits: Tuple[SubredditConfig, ...] # Use tuple for immutability 82 + allowed_domains: Tuple[str, ...] = ("streamable.com",) # Default tuple 83 + log_level: LogLevel = LogLevel.INFO 84 + max_posts_per_run: int = 3 # Max streamable posts to process per run 85 + 86 + def __post_init__(self): 87 + """Validate configuration.""" 88 + if not self.coves_api_url: 89 + raise ValueError("AggregatorConfig.coves_api_url cannot be empty") 90 + if not self.subreddits: 91 + raise ValueError("AggregatorConfig.subreddits cannot be empty") 92 + if type(self.max_posts_per_run) is not int or self.max_posts_per_run < 1: 93 + raise ValueError( 94 + f"AggregatorConfig.max_posts_per_run must be a positive integer, got: {self.max_posts_per_run}" 95 + )
+84
aggregators/reddit-highlights/src/rss_fetcher.py
··· 1 + """ 2 + RSS feed fetcher with retry logic and error handling. 3 + """ 4 + import time 5 + import logging 6 + import requests 7 + import feedparser 8 + from typing import Optional 9 + 10 + logger = logging.getLogger(__name__) 11 + 12 + 13 + class RSSFetcher: 14 + """ 15 + Fetches and parses RSS feeds with retry logic and error handling. 16 + 17 + Features: 18 + - Configurable timeout and retry count 19 + - Exponential backoff on failures 20 + - Custom User-Agent header (required by Reddit) 21 + - Automatic HTTP to HTTPS upgrade handling 22 + """ 23 + 24 + DEFAULT_USER_AGENT = "Coves-Reddit-Aggregator/1.0 (https://coves.social; contact@coves.social)" 25 + 26 + def __init__(self, timeout: int = 30, max_retries: int = 3, user_agent: Optional[str] = None): 27 + """ 28 + Initialize RSS fetcher. 29 + 30 + Args: 31 + timeout: Request timeout in seconds 32 + max_retries: Maximum number of retry attempts 33 + user_agent: Custom User-Agent string (Reddit requires this) 34 + """ 35 + self.timeout = timeout 36 + self.max_retries = max_retries 37 + self.user_agent = user_agent or self.DEFAULT_USER_AGENT 38 + 39 + def fetch_feed(self, url: str) -> feedparser.FeedParserDict: 40 + """ 41 + Fetch and parse an RSS feed. 42 + 43 + Args: 44 + url: RSS feed URL 45 + 46 + Returns: 47 + Parsed feed object 48 + 49 + Raises: 50 + ValueError: If URL is empty 51 + requests.RequestException: If all retry attempts fail 52 + """ 53 + if not url: 54 + raise ValueError("URL cannot be empty") 55 + 56 + last_error = None 57 + 58 + for attempt in range(self.max_retries): 59 + try: 60 + logger.info(f"Fetching feed from {url} (attempt {attempt + 1}/{self.max_retries})") 61 + 62 + headers = {"User-Agent": self.user_agent} 63 + response = requests.get(url, timeout=self.timeout, headers=headers) 64 + response.raise_for_status() 65 + 66 + # Parse with feedparser 67 + feed = feedparser.parse(response.content) 68 + 69 + logger.info(f"Successfully fetched feed: {feed.feed.get('title', 'Unknown')}") 70 + return feed 71 + 72 + except requests.RequestException as e: 73 + last_error = e 74 + logger.warning(f"Fetch attempt {attempt + 1} failed: {e}") 75 + 76 + if attempt < self.max_retries - 1: 77 + # Exponential backoff 78 + sleep_time = 2 ** attempt 79 + logger.info(f"Retrying in {sleep_time} seconds...") 80 + time.sleep(sleep_time) 81 + 82 + # All retries exhausted 83 + logger.error(f"Failed to fetch feed after {self.max_retries} attempts") 84 + raise last_error
+255
aggregators/reddit-highlights/src/state_manager.py
··· 1 + """ 2 + State Manager for tracking posted stories. 3 + 4 + Handles deduplication by tracking which stories have already been posted. 5 + Uses JSON file for persistence. 6 + """ 7 + import json 8 + import logging 9 + from pathlib import Path 10 + from datetime import datetime, timedelta 11 + from typing import Optional, Dict, List 12 + 13 + logger = logging.getLogger(__name__) 14 + 15 + 16 + class StateManager: 17 + """ 18 + Manages aggregator state for deduplication. 19 + 20 + Tracks posted Reddit entries per subreddit to prevent duplicate posting. 21 + 22 + Attributes tracked per subreddit: 23 + - Posted GUIDs (with timestamps and Coves post URIs) 24 + - Last successful run timestamp 25 + - Automatic cleanup of old entries to prevent state file bloat 26 + 27 + Note: The 'feed_url' parameter in methods refers to the subreddit name 28 + (e.g., 'nba'), not a full RSS URL. This naming is historical but the 29 + functionality uses subreddit names as keys. 30 + """ 31 + 32 + def __init__(self, state_file: Path, max_guids_per_feed: int = 100, max_age_days: int = 30): 33 + """ 34 + Initialize state manager. 35 + 36 + Args: 37 + state_file: Path to JSON state file 38 + max_guids_per_feed: Maximum GUIDs to keep per feed (default: 100) 39 + max_age_days: Maximum age in days for GUIDs (default: 30) 40 + """ 41 + self.state_file = Path(state_file) 42 + self.max_guids_per_feed = max_guids_per_feed 43 + self.max_age_days = max_age_days 44 + self.state = self._load_state() 45 + 46 + def _load_state(self) -> Dict: 47 + """Load state from file, or create new state if file doesn't exist.""" 48 + if not self.state_file.exists(): 49 + logger.info(f"Creating new state file at {self.state_file}") 50 + state = {'feeds': {}} 51 + self._save_state(state) 52 + return state 53 + 54 + try: 55 + with open(self.state_file, 'r') as f: 56 + state = json.load(f) 57 + logger.info(f"Loaded state from {self.state_file}") 58 + return state 59 + except json.JSONDecodeError as e: 60 + # Backup corrupted file before overwriting 61 + backup_path = self.state_file.with_suffix('.json.corrupted') 62 + logger.error(f"State file corrupted: {e}. Backing up to {backup_path}") 63 + try: 64 + import shutil 65 + shutil.copy2(self.state_file, backup_path) 66 + logger.info(f"Corrupted state file backed up to {backup_path}") 67 + except OSError as backup_error: 68 + logger.warning(f"Failed to backup corrupted state file: {backup_error}") 69 + state = {'feeds': {}} 70 + self._save_state(state) 71 + return state 72 + 73 + def _save_state(self, state: Optional[Dict] = None): 74 + """ 75 + Save state to file atomically. 76 + 77 + Uses write-to-temp-then-rename pattern to prevent corruption 78 + if the process is interrupted during write. 79 + 80 + Raises: 81 + OSError: If write fails (after logging the error) 82 + """ 83 + if state is None: 84 + state = self.state 85 + 86 + # Ensure parent directory exists 87 + self.state_file.parent.mkdir(parents=True, exist_ok=True) 88 + 89 + # Write to temp file first for atomic update 90 + temp_file = self.state_file.with_suffix('.json.tmp') 91 + try: 92 + with open(temp_file, 'w') as f: 93 + json.dump(state, f, indent=2) 94 + # Atomic rename (on POSIX systems) 95 + temp_file.rename(self.state_file) 96 + except OSError as e: 97 + logger.error(f"Failed to save state file: {e}") 98 + # Clean up temp file if it exists 99 + if temp_file.exists(): 100 + try: 101 + temp_file.unlink() 102 + except OSError: 103 + pass 104 + raise 105 + 106 + def _ensure_feed_exists(self, feed_url: str): 107 + """Ensure feed entry exists in state.""" 108 + if feed_url not in self.state['feeds']: 109 + self.state['feeds'][feed_url] = { 110 + 'posted_guids': [], 111 + 'last_successful_run': None 112 + } 113 + 114 + def is_posted(self, feed_url: str, guid: str) -> bool: 115 + """ 116 + Check if a story has already been posted. 117 + 118 + Args: 119 + feed_url: RSS feed URL 120 + guid: Story GUID 121 + 122 + Returns: 123 + True if already posted, False otherwise 124 + """ 125 + self._ensure_feed_exists(feed_url) 126 + 127 + posted_guids = self.state['feeds'][feed_url]['posted_guids'] 128 + return any(entry['guid'] == guid for entry in posted_guids) 129 + 130 + def mark_posted(self, feed_url: str, guid: str, post_uri: str): 131 + """ 132 + Mark a story as posted. 133 + 134 + Args: 135 + feed_url: RSS feed URL 136 + guid: Story GUID 137 + post_uri: AT Proto URI of created post 138 + """ 139 + self._ensure_feed_exists(feed_url) 140 + 141 + # Add to posted list 142 + entry = { 143 + 'guid': guid, 144 + 'post_uri': post_uri, 145 + 'posted_at': datetime.now().isoformat() 146 + } 147 + self.state['feeds'][feed_url]['posted_guids'].append(entry) 148 + 149 + # Auto-cleanup to keep state file manageable 150 + self.cleanup_old_entries(feed_url) 151 + 152 + # Save state 153 + self._save_state() 154 + 155 + logger.info(f"Marked as posted: {guid} -> {post_uri}") 156 + 157 + def get_last_run(self, feed_url: str) -> Optional[datetime]: 158 + """ 159 + Get last successful run timestamp for a feed. 160 + 161 + Args: 162 + feed_url: RSS feed URL 163 + 164 + Returns: 165 + Datetime of last run, or None if never run 166 + """ 167 + self._ensure_feed_exists(feed_url) 168 + 169 + timestamp_str = self.state['feeds'][feed_url]['last_successful_run'] 170 + if timestamp_str is None: 171 + return None 172 + 173 + return datetime.fromisoformat(timestamp_str) 174 + 175 + def update_last_run(self, feed_url: str, timestamp: datetime): 176 + """ 177 + Update last successful run timestamp. 178 + 179 + Args: 180 + feed_url: RSS feed URL 181 + timestamp: Timestamp of successful run 182 + """ 183 + self._ensure_feed_exists(feed_url) 184 + 185 + self.state['feeds'][feed_url]['last_successful_run'] = timestamp.isoformat() 186 + self._save_state() 187 + 188 + logger.info(f"Updated last run for {feed_url}: {timestamp}") 189 + 190 + def cleanup_old_entries(self, feed_url: str): 191 + """ 192 + Remove old entries from state. 193 + 194 + Removes entries that are: 195 + - Older than max_age_days 196 + - Beyond max_guids_per_feed limit (keeps most recent) 197 + 198 + Args: 199 + feed_url: RSS feed URL 200 + """ 201 + self._ensure_feed_exists(feed_url) 202 + 203 + posted_guids = self.state['feeds'][feed_url]['posted_guids'] 204 + 205 + # Filter out entries older than max_age_days 206 + cutoff_date = datetime.now() - timedelta(days=self.max_age_days) 207 + filtered = [] 208 + for entry in posted_guids: 209 + try: 210 + posted_at = datetime.fromisoformat(entry['posted_at']) 211 + if posted_at > cutoff_date: 212 + filtered.append(entry) 213 + except (KeyError, ValueError) as e: 214 + # Skip entries with malformed or missing timestamps 215 + logger.warning(f"Skipping entry with invalid timestamp: {e}") 216 + continue 217 + 218 + # Keep only most recent max_guids_per_feed entries 219 + # Sort by posted_at (most recent first) 220 + filtered.sort(key=lambda x: x['posted_at'], reverse=True) 221 + filtered = filtered[:self.max_guids_per_feed] 222 + 223 + # Update state 224 + old_count = len(posted_guids) 225 + new_count = len(filtered) 226 + self.state['feeds'][feed_url]['posted_guids'] = filtered 227 + 228 + if old_count != new_count: 229 + logger.info(f"Cleaned up {old_count - new_count} old entries for {feed_url}") 230 + 231 + def get_posted_count(self, feed_url: str) -> int: 232 + """ 233 + Get count of posted items for a feed. 234 + 235 + Args: 236 + feed_url: RSS feed URL 237 + 238 + Returns: 239 + Number of posted items 240 + """ 241 + self._ensure_feed_exists(feed_url) 242 + return len(self.state['feeds'][feed_url]['posted_guids']) 243 + 244 + def get_all_posted_guids(self, feed_url: str) -> List[str]: 245 + """ 246 + Get all posted GUIDs for a feed. 247 + 248 + Args: 249 + feed_url: RSS feed URL 250 + 251 + Returns: 252 + List of GUIDs 253 + """ 254 + self._ensure_feed_exists(feed_url) 255 + return [entry['guid'] for entry in self.state['feeds'][feed_url]['posted_guids']]
+1
aggregators/reddit-highlights/tests/__init__.py
··· 1 + """Tests for Reddit Highlights Aggregator."""
+352
aggregators/reddit-highlights/tests/test_config.py
··· 1 + """ 2 + Tests for config module. 3 + """ 4 + import os 5 + import pytest 6 + from pathlib import Path 7 + 8 + from src.config import ConfigLoader, ConfigError 9 + from src.models import LogLevel 10 + 11 + 12 + class TestConfigLoaderInit: 13 + """Tests for ConfigLoader initialization.""" 14 + 15 + def test_stores_config_path(self, tmp_path): 16 + """Test that config path is stored.""" 17 + config_path = tmp_path / "config.yaml" 18 + loader = ConfigLoader(config_path) 19 + assert loader.config_path == config_path 20 + 21 + 22 + class TestConfigLoaderLoad: 23 + """Tests for ConfigLoader.load method.""" 24 + 25 + def test_raises_if_file_not_found(self, tmp_path): 26 + """Test that ConfigError is raised if file doesn't exist.""" 27 + config_path = tmp_path / "nonexistent.yaml" 28 + loader = ConfigLoader(config_path) 29 + 30 + with pytest.raises(ConfigError, match="not found"): 31 + loader.load() 32 + 33 + def test_raises_on_invalid_yaml(self, tmp_path): 34 + """Test that ConfigError is raised for invalid YAML.""" 35 + config_path = tmp_path / "config.yaml" 36 + config_path.write_text("invalid: yaml: ::::") 37 + loader = ConfigLoader(config_path) 38 + 39 + with pytest.raises(ConfigError, match="Failed to parse YAML"): 40 + loader.load() 41 + 42 + def test_raises_on_empty_file(self, tmp_path): 43 + """Test that ConfigError is raised for empty file.""" 44 + config_path = tmp_path / "config.yaml" 45 + config_path.write_text("") 46 + loader = ConfigLoader(config_path) 47 + 48 + with pytest.raises(ConfigError, match="empty"): 49 + loader.load() 50 + 51 + def test_raises_if_coves_api_url_missing(self, tmp_path): 52 + """Test that ConfigError is raised if coves_api_url is missing.""" 53 + config_path = tmp_path / "config.yaml" 54 + config_path.write_text(""" 55 + subreddits: 56 + - name: nba 57 + community_handle: nba.coves.social 58 + """) 59 + loader = ConfigLoader(config_path) 60 + 61 + with pytest.raises(ConfigError, match="coves_api_url"): 62 + loader.load() 63 + 64 + def test_raises_on_invalid_url(self, tmp_path): 65 + """Test that ConfigError is raised for invalid URL.""" 66 + config_path = tmp_path / "config.yaml" 67 + config_path.write_text(""" 68 + coves_api_url: "not-a-valid-url" 69 + subreddits: 70 + - name: nba 71 + community_handle: nba.coves.social 72 + """) 73 + loader = ConfigLoader(config_path) 74 + 75 + with pytest.raises(ConfigError, match="Invalid URL"): 76 + loader.load() 77 + 78 + def test_raises_on_file_url_scheme(self, tmp_path): 79 + """Test that ConfigError is raised for file:// URL scheme.""" 80 + config_path = tmp_path / "config.yaml" 81 + config_path.write_text(""" 82 + coves_api_url: "file:///etc/passwd" 83 + subreddits: 84 + - name: nba 85 + community_handle: nba.coves.social 86 + """) 87 + loader = ConfigLoader(config_path) 88 + 89 + with pytest.raises(ConfigError, match="Invalid URL"): 90 + loader.load() 91 + 92 + def test_raises_if_no_subreddits(self, tmp_path): 93 + """Test that ConfigError is raised if no subreddits defined.""" 94 + config_path = tmp_path / "config.yaml" 95 + config_path.write_text(""" 96 + coves_api_url: "https://coves.social" 97 + subreddits: [] 98 + """) 99 + loader = ConfigLoader(config_path) 100 + 101 + with pytest.raises(ConfigError, match="at least one subreddit"): 102 + loader.load() 103 + 104 + def test_loads_valid_config(self, tmp_path): 105 + """Test successful loading of valid config.""" 106 + config_path = tmp_path / "config.yaml" 107 + config_path.write_text(""" 108 + coves_api_url: "https://coves.social" 109 + subreddits: 110 + - name: nba 111 + community_handle: nba.coves.social 112 + enabled: true 113 + - name: soccer 114 + community_handle: soccer.coves.social 115 + enabled: false 116 + allowed_domains: 117 + - streamable.com 118 + - gfycat.com 119 + log_level: debug 120 + """) 121 + loader = ConfigLoader(config_path) 122 + config = loader.load() 123 + 124 + assert config.coves_api_url == "https://coves.social" 125 + assert len(config.subreddits) == 2 126 + assert config.subreddits[0].name == "nba" 127 + assert config.subreddits[0].community_handle == "nba.coves.social" 128 + assert config.subreddits[0].enabled is True 129 + assert config.subreddits[1].name == "soccer" 130 + assert config.subreddits[1].enabled is False 131 + assert "streamable.com" in config.allowed_domains 132 + assert "gfycat.com" in config.allowed_domains 133 + assert config.log_level == LogLevel.DEBUG 134 + 135 + def test_uses_default_allowed_domains(self, tmp_path): 136 + """Test that default allowed_domains is used when not specified.""" 137 + config_path = tmp_path / "config.yaml" 138 + config_path.write_text(""" 139 + coves_api_url: "https://coves.social" 140 + subreddits: 141 + - name: nba 142 + community_handle: nba.coves.social 143 + """) 144 + loader = ConfigLoader(config_path) 145 + config = loader.load() 146 + 147 + assert "streamable.com" in config.allowed_domains 148 + 149 + def test_uses_default_log_level(self, tmp_path): 150 + """Test that default log_level is used when not specified.""" 151 + config_path = tmp_path / "config.yaml" 152 + config_path.write_text(""" 153 + coves_api_url: "https://coves.social" 154 + subreddits: 155 + - name: nba 156 + community_handle: nba.coves.social 157 + """) 158 + loader = ConfigLoader(config_path) 159 + config = loader.load() 160 + 161 + assert config.log_level == LogLevel.INFO 162 + 163 + def test_invalid_log_level_raises(self, tmp_path): 164 + """Test that invalid log_level raises ConfigError.""" 165 + config_path = tmp_path / "config.yaml" 166 + config_path.write_text(""" 167 + coves_api_url: "https://coves.social" 168 + subreddits: 169 + - name: nba 170 + community_handle: nba.coves.social 171 + log_level: invalid_level 172 + """) 173 + loader = ConfigLoader(config_path) 174 + 175 + with pytest.raises(ConfigError, match="Invalid log_level"): 176 + loader.load() 177 + 178 + def test_environment_variable_override(self, tmp_path, monkeypatch): 179 + """Test that COVES_API_URL env var overrides config file.""" 180 + config_path = tmp_path / "config.yaml" 181 + config_path.write_text(""" 182 + coves_api_url: "https://coves.social" 183 + subreddits: 184 + - name: nba 185 + community_handle: nba.coves.social 186 + """) 187 + monkeypatch.setenv("COVES_API_URL", "https://custom.coves.social") 188 + loader = ConfigLoader(config_path) 189 + config = loader.load() 190 + 191 + assert config.coves_api_url == "https://custom.coves.social" 192 + 193 + 194 + class TestSubredditParsing: 195 + """Tests for subreddit configuration parsing.""" 196 + 197 + def test_missing_name_raises(self, tmp_path): 198 + """Test that missing subreddit name raises ConfigError.""" 199 + config_path = tmp_path / "config.yaml" 200 + config_path.write_text(""" 201 + coves_api_url: "https://coves.social" 202 + subreddits: 203 + - community_handle: nba.coves.social 204 + """) 205 + loader = ConfigLoader(config_path) 206 + 207 + with pytest.raises(ConfigError, match="name"): 208 + loader.load() 209 + 210 + def test_missing_community_handle_raises(self, tmp_path): 211 + """Test that missing community_handle raises ConfigError.""" 212 + config_path = tmp_path / "config.yaml" 213 + config_path.write_text(""" 214 + coves_api_url: "https://coves.social" 215 + subreddits: 216 + - name: nba 217 + """) 218 + loader = ConfigLoader(config_path) 219 + 220 + with pytest.raises(ConfigError, match="community_handle"): 221 + loader.load() 222 + 223 + def test_empty_name_raises(self, tmp_path): 224 + """Test that empty subreddit name raises ConfigError.""" 225 + config_path = tmp_path / "config.yaml" 226 + config_path.write_text(""" 227 + coves_api_url: "https://coves.social" 228 + subreddits: 229 + - name: "" 230 + community_handle: nba.coves.social 231 + """) 232 + loader = ConfigLoader(config_path) 233 + 234 + with pytest.raises(ConfigError, match="cannot be empty"): 235 + loader.load() 236 + 237 + def test_empty_community_handle_raises(self, tmp_path): 238 + """Test that empty community_handle raises ConfigError.""" 239 + config_path = tmp_path / "config.yaml" 240 + config_path.write_text(""" 241 + coves_api_url: "https://coves.social" 242 + subreddits: 243 + - name: nba 244 + community_handle: "" 245 + """) 246 + loader = ConfigLoader(config_path) 247 + 248 + with pytest.raises(ConfigError, match="cannot be empty"): 249 + loader.load() 250 + 251 + def test_defaults_enabled_to_true(self, tmp_path): 252 + """Test that enabled defaults to True.""" 253 + config_path = tmp_path / "config.yaml" 254 + config_path.write_text(""" 255 + coves_api_url: "https://coves.social" 256 + subreddits: 257 + - name: nba 258 + community_handle: nba.coves.social 259 + """) 260 + loader = ConfigLoader(config_path) 261 + config = loader.load() 262 + 263 + assert config.subreddits[0].enabled is True 264 + 265 + def test_normalizes_name_to_lowercase(self, tmp_path): 266 + """Test that subreddit name is normalized to lowercase.""" 267 + config_path = tmp_path / "config.yaml" 268 + config_path.write_text(""" 269 + coves_api_url: "https://coves.social" 270 + subreddits: 271 + - name: NBA 272 + community_handle: nba.coves.social 273 + """) 274 + loader = ConfigLoader(config_path) 275 + config = loader.load() 276 + 277 + assert config.subreddits[0].name == "nba" 278 + 279 + def test_strips_whitespace(self, tmp_path): 280 + """Test that whitespace is stripped from names.""" 281 + config_path = tmp_path / "config.yaml" 282 + config_path.write_text(""" 283 + coves_api_url: "https://coves.social" 284 + subreddits: 285 + - name: " nba " 286 + community_handle: " nba.coves.social " 287 + """) 288 + loader = ConfigLoader(config_path) 289 + config = loader.load() 290 + 291 + assert config.subreddits[0].name == "nba" 292 + assert config.subreddits[0].community_handle == "nba.coves.social" 293 + 294 + 295 + class TestUrlValidation: 296 + """Tests for URL validation.""" 297 + 298 + def test_accepts_https_url(self, tmp_path): 299 + """Test that HTTPS URLs are accepted.""" 300 + config_path = tmp_path / "config.yaml" 301 + config_path.write_text(""" 302 + coves_api_url: "https://coves.social" 303 + subreddits: 304 + - name: nba 305 + community_handle: nba.coves.social 306 + """) 307 + loader = ConfigLoader(config_path) 308 + config = loader.load() 309 + 310 + assert config.coves_api_url == "https://coves.social" 311 + 312 + def test_accepts_http_url(self, tmp_path): 313 + """Test that HTTP URLs are accepted (for local dev).""" 314 + config_path = tmp_path / "config.yaml" 315 + config_path.write_text(""" 316 + coves_api_url: "http://localhost:8080" 317 + subreddits: 318 + - name: nba 319 + community_handle: nba.coves.social 320 + """) 321 + loader = ConfigLoader(config_path) 322 + config = loader.load() 323 + 324 + assert config.coves_api_url == "http://localhost:8080" 325 + 326 + def test_rejects_javascript_url(self, tmp_path): 327 + """Test that javascript: URLs are rejected.""" 328 + config_path = tmp_path / "config.yaml" 329 + config_path.write_text(""" 330 + coves_api_url: "javascript:alert(1)" 331 + subreddits: 332 + - name: nba 333 + community_handle: nba.coves.social 334 + """) 335 + loader = ConfigLoader(config_path) 336 + 337 + with pytest.raises(ConfigError, match="Invalid URL"): 338 + loader.load() 339 + 340 + def test_rejects_data_url(self, tmp_path): 341 + """Test that data: URLs are rejected.""" 342 + config_path = tmp_path / "config.yaml" 343 + config_path.write_text(""" 344 + coves_api_url: "data:text/html,<script>alert(1)</script>" 345 + subreddits: 346 + - name: nba 347 + community_handle: nba.coves.social 348 + """) 349 + loader = ConfigLoader(config_path) 350 + 351 + with pytest.raises(ConfigError, match="Invalid URL"): 352 + loader.load()
+286
aggregators/reddit-highlights/tests/test_coves_client.py
··· 1 + """ 2 + Tests for coves_client module. 3 + """ 4 + import pytest 5 + from unittest.mock import MagicMock, patch 6 + import requests 7 + 8 + from src.coves_client import ( 9 + CovesClient, 10 + CovesAPIError, 11 + CovesAuthenticationError, 12 + CovesForbiddenError, 13 + CovesNotFoundError, 14 + CovesRateLimitError, 15 + ) 16 + 17 + 18 + class TestCovesClientInit: 19 + """Tests for CovesClient initialization.""" 20 + 21 + def test_valid_api_key(self): 22 + """Test initialization with valid API key.""" 23 + # Generate a valid 70-character API key 24 + api_key = "ckapi_" + "a" * 64 25 + client = CovesClient("https://coves.social", api_key) 26 + assert client.api_key == api_key 27 + assert client.api_url == "https://coves.social" 28 + 29 + def test_strips_trailing_slash_from_url(self): 30 + """Test that trailing slash is stripped from API URL.""" 31 + api_key = "ckapi_" + "a" * 64 32 + client = CovesClient("https://coves.social/", api_key) 33 + assert client.api_url == "https://coves.social" 34 + 35 + def test_empty_api_key_raises(self): 36 + """Test that empty API key raises ValueError.""" 37 + with pytest.raises(ValueError, match="cannot be empty"): 38 + CovesClient("https://coves.social", "") 39 + 40 + def test_wrong_prefix_raises(self): 41 + """Test that API key with wrong prefix raises ValueError.""" 42 + with pytest.raises(ValueError, match="must start with"): 43 + CovesClient("https://coves.social", "invalid_" + "a" * 63) 44 + 45 + def test_wrong_length_raises(self): 46 + """Test that API key with wrong length raises ValueError.""" 47 + with pytest.raises(ValueError, match="must be 70 characters"): 48 + CovesClient("https://coves.social", "ckapi_tooshort") 49 + 50 + def test_session_headers_set(self): 51 + """Test that session headers are properly set.""" 52 + api_key = "ckapi_" + "b" * 64 53 + client = CovesClient("https://coves.social", api_key) 54 + assert client.session.headers["Authorization"] == f"Bearer {api_key}" 55 + assert client.session.headers["Content-Type"] == "application/json" 56 + 57 + 58 + class TestCovesClientAuthenticate: 59 + """Tests for authenticate method.""" 60 + 61 + def test_authenticate_is_noop(self): 62 + """Test that authenticate is a no-op for API key auth.""" 63 + api_key = "ckapi_" + "c" * 64 64 + client = CovesClient("https://coves.social", api_key) 65 + # Should not raise any exceptions 66 + client.authenticate() 67 + 68 + 69 + class TestCovesClientCreatePost: 70 + """Tests for create_post method.""" 71 + 72 + @pytest.fixture 73 + def client(self): 74 + api_key = "ckapi_" + "d" * 64 75 + return CovesClient("https://coves.social", api_key) 76 + 77 + def test_successful_post_creation(self, client): 78 + """Test successful post creation.""" 79 + mock_response = MagicMock() 80 + mock_response.ok = True 81 + mock_response.status_code = 200 82 + mock_response.json.return_value = {"uri": "at://did:plc:test/social.coves.post/abc123"} 83 + 84 + with patch.object(client.session, "post", return_value=mock_response): 85 + uri = client.create_post( 86 + community_handle="test.coves.social", 87 + content="Test content", 88 + facets=[], 89 + title="Test Title", 90 + ) 91 + assert uri == "at://did:plc:test/social.coves.post/abc123" 92 + 93 + def test_post_with_embed(self, client): 94 + """Test post creation with embed.""" 95 + mock_response = MagicMock() 96 + mock_response.ok = True 97 + mock_response.json.return_value = {"uri": "at://did:plc:test/social.coves.post/xyz"} 98 + 99 + with patch.object(client.session, "post", return_value=mock_response) as mock_post: 100 + embed = {"$type": "social.coves.embed.external", "external": {"uri": "https://example.com"}} 101 + client.create_post( 102 + community_handle="test.coves.social", 103 + content="", 104 + facets=[], 105 + embed=embed, 106 + ) 107 + # Verify embed was included in request 108 + call_args = mock_post.call_args 109 + assert call_args[1]["json"]["embed"] == embed 110 + 111 + def test_post_with_thumbnail_url(self, client): 112 + """Test post creation with thumbnail URL.""" 113 + mock_response = MagicMock() 114 + mock_response.ok = True 115 + mock_response.json.return_value = {"uri": "at://did:plc:test/social.coves.post/thumb"} 116 + 117 + with patch.object(client.session, "post", return_value=mock_response) as mock_post: 118 + client.create_post( 119 + community_handle="test.coves.social", 120 + content="", 121 + facets=[], 122 + thumbnail_url="https://example.com/thumb.jpg", 123 + ) 124 + call_args = mock_post.call_args 125 + assert call_args[1]["json"]["thumbnailUrl"] == "https://example.com/thumb.jpg" 126 + 127 + def test_401_raises_authentication_error(self, client): 128 + """Test that 401 response raises CovesAuthenticationError.""" 129 + mock_response = MagicMock() 130 + mock_response.ok = False 131 + mock_response.status_code = 401 132 + mock_response.text = "Unauthorized" 133 + 134 + with patch.object(client.session, "post", return_value=mock_response): 135 + with pytest.raises(CovesAuthenticationError): 136 + client.create_post("test.coves.social", "", []) 137 + 138 + def test_403_raises_forbidden_error(self, client): 139 + """Test that 403 response raises CovesForbiddenError.""" 140 + mock_response = MagicMock() 141 + mock_response.ok = False 142 + mock_response.status_code = 403 143 + mock_response.text = "Forbidden" 144 + 145 + with patch.object(client.session, "post", return_value=mock_response): 146 + with pytest.raises(CovesForbiddenError): 147 + client.create_post("test.coves.social", "", []) 148 + 149 + def test_404_raises_not_found_error(self, client): 150 + """Test that 404 response raises CovesNotFoundError.""" 151 + mock_response = MagicMock() 152 + mock_response.ok = False 153 + mock_response.status_code = 404 154 + mock_response.text = "Not Found" 155 + 156 + with patch.object(client.session, "post", return_value=mock_response): 157 + with pytest.raises(CovesNotFoundError): 158 + client.create_post("test.coves.social", "", []) 159 + 160 + def test_429_raises_rate_limit_error(self, client): 161 + """Test that 429 response raises CovesRateLimitError.""" 162 + mock_response = MagicMock() 163 + mock_response.ok = False 164 + mock_response.status_code = 429 165 + mock_response.text = "Rate Limited" 166 + 167 + with patch.object(client.session, "post", return_value=mock_response): 168 + with pytest.raises(CovesRateLimitError): 169 + client.create_post("test.coves.social", "", []) 170 + 171 + def test_500_raises_api_error(self, client): 172 + """Test that 500 response raises CovesAPIError.""" 173 + mock_response = MagicMock() 174 + mock_response.ok = False 175 + mock_response.status_code = 500 176 + mock_response.text = "Internal Server Error" 177 + 178 + with patch.object(client.session, "post", return_value=mock_response): 179 + with pytest.raises(CovesAPIError): 180 + client.create_post("test.coves.social", "", []) 181 + 182 + def test_invalid_json_response_raises_api_error(self, client): 183 + """Test that invalid JSON response raises CovesAPIError.""" 184 + mock_response = MagicMock() 185 + mock_response.ok = True 186 + mock_response.status_code = 200 187 + mock_response.text = "not json" 188 + mock_response.json.side_effect = ValueError("Invalid JSON") 189 + 190 + with patch.object(client.session, "post", return_value=mock_response): 191 + with pytest.raises(CovesAPIError, match="Invalid response"): 192 + client.create_post("test.coves.social", "", []) 193 + 194 + def test_missing_uri_in_response_raises_api_error(self, client): 195 + """Test that missing uri in response raises CovesAPIError.""" 196 + mock_response = MagicMock() 197 + mock_response.ok = True 198 + mock_response.status_code = 200 199 + mock_response.text = '{"cid": "abc"}' 200 + mock_response.json.return_value = {"cid": "abc"} # No uri field 201 + 202 + with patch.object(client.session, "post", return_value=mock_response): 203 + with pytest.raises(CovesAPIError, match="Invalid response"): 204 + client.create_post("test.coves.social", "", []) 205 + 206 + def test_network_error_propagates(self, client): 207 + """Test that network errors propagate.""" 208 + with patch.object(client.session, "post", side_effect=requests.ConnectionError("Network error")): 209 + with pytest.raises(requests.RequestException): 210 + client.create_post("test.coves.social", "", []) 211 + 212 + 213 + class TestCreateExternalEmbed: 214 + """Tests for create_external_embed method.""" 215 + 216 + @pytest.fixture 217 + def client(self): 218 + api_key = "ckapi_" + "e" * 64 219 + return CovesClient("https://coves.social", api_key) 220 + 221 + def test_basic_embed(self, client): 222 + """Test basic external embed creation.""" 223 + embed = client.create_external_embed( 224 + uri="https://streamable.com/abc123", 225 + title="Test Video", 226 + description="A test video", 227 + ) 228 + assert embed["$type"] == "social.coves.embed.external" 229 + assert embed["external"]["uri"] == "https://streamable.com/abc123" 230 + assert embed["external"]["title"] == "Test Video" 231 + assert embed["external"]["description"] == "A test video" 232 + 233 + def test_embed_with_sources(self, client): 234 + """Test embed with sources.""" 235 + sources = [{"uri": "https://reddit.com/r/test", "title": "r/test", "domain": "reddit.com"}] 236 + embed = client.create_external_embed( 237 + uri="https://streamable.com/abc123", 238 + title="Test", 239 + description="Test", 240 + sources=sources, 241 + ) 242 + assert embed["external"]["sources"] == sources 243 + 244 + def test_embed_with_video_metadata(self, client): 245 + """Test embed with video metadata.""" 246 + embed = client.create_external_embed( 247 + uri="https://streamable.com/abc123", 248 + title="Test", 249 + description="Test", 250 + embed_type="video", 251 + provider="streamable", 252 + domain="streamable.com", 253 + ) 254 + assert embed["external"]["embedType"] == "video" 255 + assert embed["external"]["provider"] == "streamable" 256 + assert embed["external"]["domain"] == "streamable.com" 257 + 258 + def test_optional_fields_not_included_when_none(self, client): 259 + """Test that optional fields are not included when None.""" 260 + embed = client.create_external_embed( 261 + uri="https://example.com", 262 + title="Test", 263 + description="Test", 264 + ) 265 + assert "sources" not in embed["external"] 266 + assert "embedType" not in embed["external"] 267 + assert "provider" not in embed["external"] 268 + assert "domain" not in embed["external"] 269 + 270 + 271 + class TestGetTimestamp: 272 + """Tests for _get_timestamp method.""" 273 + 274 + @pytest.fixture 275 + def client(self): 276 + api_key = "ckapi_" + "f" * 64 277 + return CovesClient("https://coves.social", api_key) 278 + 279 + def test_returns_iso_format(self, client): 280 + """Test that timestamp is in ISO 8601 format.""" 281 + timestamp = client._get_timestamp() 282 + # Should end with Z (UTC) 283 + assert timestamp.endswith("Z") 284 + # Should be parseable as ISO format 285 + from datetime import datetime 286 + datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
+273
aggregators/reddit-highlights/tests/test_models.py
··· 1 + """ 2 + Tests for models module. 3 + """ 4 + import pytest 5 + from datetime import datetime 6 + 7 + from src.models import RedditPost, SubredditConfig, AggregatorConfig, LogLevel 8 + 9 + 10 + class TestLogLevel: 11 + """Tests for LogLevel enum.""" 12 + 13 + def test_all_values(self): 14 + """Test all log level values.""" 15 + assert LogLevel.DEBUG.value == "debug" 16 + assert LogLevel.INFO.value == "info" 17 + assert LogLevel.WARNING.value == "warning" 18 + assert LogLevel.ERROR.value == "error" 19 + assert LogLevel.CRITICAL.value == "critical" 20 + 21 + def test_from_string(self): 22 + """Test creating LogLevel from string.""" 23 + assert LogLevel("debug") == LogLevel.DEBUG 24 + assert LogLevel("info") == LogLevel.INFO 25 + assert LogLevel("warning") == LogLevel.WARNING 26 + 27 + def test_invalid_value_raises(self): 28 + """Test that invalid value raises ValueError.""" 29 + with pytest.raises(ValueError): 30 + LogLevel("invalid") 31 + 32 + 33 + class TestRedditPost: 34 + """Tests for RedditPost dataclass.""" 35 + 36 + def test_valid_post(self): 37 + """Test creating a valid RedditPost.""" 38 + post = RedditPost( 39 + id="t3_abc123", 40 + title="Test Post", 41 + link="https://streamable.com/xyz", 42 + reddit_url="https://reddit.com/r/nba/comments/abc123", 43 + subreddit="nba", 44 + author="testuser", 45 + ) 46 + assert post.id == "t3_abc123" 47 + assert post.title == "Test Post" 48 + assert post.subreddit == "nba" 49 + 50 + def test_optional_fields(self): 51 + """Test optional fields have correct defaults.""" 52 + post = RedditPost( 53 + id="t3_abc123", 54 + title="Test Post", 55 + link="https://example.com", 56 + reddit_url="https://reddit.com/r/nba", 57 + subreddit="nba", 58 + author="testuser", 59 + ) 60 + assert post.published is None 61 + assert post.streamable_url is None 62 + 63 + def test_with_optional_fields(self): 64 + """Test creating post with optional fields.""" 65 + now = datetime.now() 66 + post = RedditPost( 67 + id="t3_abc123", 68 + title="Test Post", 69 + link="https://example.com", 70 + reddit_url="https://reddit.com/r/nba", 71 + subreddit="nba", 72 + author="testuser", 73 + published=now, 74 + streamable_url="https://streamable.com/xyz", 75 + ) 76 + assert post.published == now 77 + assert post.streamable_url == "https://streamable.com/xyz" 78 + 79 + def test_empty_id_raises(self): 80 + """Test that empty id raises ValueError.""" 81 + with pytest.raises(ValueError, match="id cannot be empty"): 82 + RedditPost( 83 + id="", 84 + title="Test", 85 + link="https://example.com", 86 + reddit_url="https://reddit.com", 87 + subreddit="nba", 88 + author="test", 89 + ) 90 + 91 + def test_empty_title_raises(self): 92 + """Test that empty title raises ValueError.""" 93 + with pytest.raises(ValueError, match="title cannot be empty"): 94 + RedditPost( 95 + id="t3_abc", 96 + title="", 97 + link="https://example.com", 98 + reddit_url="https://reddit.com", 99 + subreddit="nba", 100 + author="test", 101 + ) 102 + 103 + def test_empty_subreddit_raises(self): 104 + """Test that empty subreddit raises ValueError.""" 105 + with pytest.raises(ValueError, match="subreddit cannot be empty"): 106 + RedditPost( 107 + id="t3_abc", 108 + title="Test", 109 + link="https://example.com", 110 + reddit_url="https://reddit.com", 111 + subreddit="", 112 + author="test", 113 + ) 114 + 115 + 116 + class TestSubredditConfig: 117 + """Tests for SubredditConfig dataclass.""" 118 + 119 + def test_valid_config(self): 120 + """Test creating valid SubredditConfig.""" 121 + config = SubredditConfig( 122 + name="nba", 123 + community_handle="nba.coves.social", 124 + ) 125 + assert config.name == "nba" 126 + assert config.community_handle == "nba.coves.social" 127 + assert config.enabled is True # Default 128 + 129 + def test_disabled_config(self): 130 + """Test creating disabled SubredditConfig.""" 131 + config = SubredditConfig( 132 + name="nba", 133 + community_handle="nba.coves.social", 134 + enabled=False, 135 + ) 136 + assert config.enabled is False 137 + 138 + def test_empty_name_raises(self): 139 + """Test that empty name raises ValueError.""" 140 + with pytest.raises(ValueError, match="name cannot be empty"): 141 + SubredditConfig( 142 + name="", 143 + community_handle="nba.coves.social", 144 + ) 145 + 146 + def test_whitespace_name_raises(self): 147 + """Test that whitespace-only name raises ValueError.""" 148 + with pytest.raises(ValueError, match="name cannot be empty"): 149 + SubredditConfig( 150 + name=" ", 151 + community_handle="nba.coves.social", 152 + ) 153 + 154 + def test_empty_community_handle_raises(self): 155 + """Test that empty community_handle raises ValueError.""" 156 + with pytest.raises(ValueError, match="community_handle cannot be empty"): 157 + SubredditConfig( 158 + name="nba", 159 + community_handle="", 160 + ) 161 + 162 + def test_invalid_subreddit_name_format_raises(self): 163 + """Test that invalid subreddit name format raises ValueError.""" 164 + with pytest.raises(ValueError, match="Invalid subreddit name format"): 165 + SubredditConfig( 166 + name="nba/../../../etc/passwd", 167 + community_handle="nba.coves.social", 168 + ) 169 + 170 + def test_special_chars_in_name_raises(self): 171 + """Test that special characters in name raise ValueError.""" 172 + with pytest.raises(ValueError, match="Invalid subreddit name format"): 173 + SubredditConfig( 174 + name="nba<script>", 175 + community_handle="nba.coves.social", 176 + ) 177 + 178 + def test_valid_name_with_underscore(self): 179 + """Test that underscores in name are allowed.""" 180 + config = SubredditConfig( 181 + name="nba_discussion", 182 + community_handle="nba.coves.social", 183 + ) 184 + assert config.name == "nba_discussion" 185 + 186 + def test_valid_name_with_hyphen(self): 187 + """Test that hyphens in name are allowed.""" 188 + config = SubredditConfig( 189 + name="nba-highlights", 190 + community_handle="nba.coves.social", 191 + ) 192 + assert config.name == "nba-highlights" 193 + 194 + def test_is_frozen(self): 195 + """Test that SubredditConfig is immutable.""" 196 + config = SubredditConfig( 197 + name="nba", 198 + community_handle="nba.coves.social", 199 + ) 200 + with pytest.raises(AttributeError): 201 + config.name = "soccer" 202 + 203 + 204 + class TestAggregatorConfig: 205 + """Tests for AggregatorConfig dataclass.""" 206 + 207 + def test_valid_config(self): 208 + """Test creating valid AggregatorConfig.""" 209 + subreddit = SubredditConfig(name="nba", community_handle="nba.coves.social") 210 + config = AggregatorConfig( 211 + coves_api_url="https://coves.social", 212 + subreddits=(subreddit,), 213 + ) 214 + assert config.coves_api_url == "https://coves.social" 215 + assert len(config.subreddits) == 1 216 + assert config.log_level == LogLevel.INFO # Default 217 + 218 + def test_custom_log_level(self): 219 + """Test custom log level.""" 220 + subreddit = SubredditConfig(name="nba", community_handle="nba.coves.social") 221 + config = AggregatorConfig( 222 + coves_api_url="https://coves.social", 223 + subreddits=(subreddit,), 224 + log_level=LogLevel.DEBUG, 225 + ) 226 + assert config.log_level == LogLevel.DEBUG 227 + 228 + def test_custom_allowed_domains(self): 229 + """Test custom allowed domains.""" 230 + subreddit = SubredditConfig(name="nba", community_handle="nba.coves.social") 231 + config = AggregatorConfig( 232 + coves_api_url="https://coves.social", 233 + subreddits=(subreddit,), 234 + allowed_domains=("streamable.com", "gfycat.com"), 235 + ) 236 + assert "streamable.com" in config.allowed_domains 237 + assert "gfycat.com" in config.allowed_domains 238 + 239 + def test_empty_coves_api_url_raises(self): 240 + """Test that empty coves_api_url raises ValueError.""" 241 + subreddit = SubredditConfig(name="nba", community_handle="nba.coves.social") 242 + with pytest.raises(ValueError, match="coves_api_url cannot be empty"): 243 + AggregatorConfig( 244 + coves_api_url="", 245 + subreddits=(subreddit,), 246 + ) 247 + 248 + def test_empty_subreddits_raises(self): 249 + """Test that empty subreddits raises ValueError.""" 250 + with pytest.raises(ValueError, match="subreddits cannot be empty"): 251 + AggregatorConfig( 252 + coves_api_url="https://coves.social", 253 + subreddits=(), 254 + ) 255 + 256 + def test_is_frozen(self): 257 + """Test that AggregatorConfig is immutable.""" 258 + subreddit = SubredditConfig(name="nba", community_handle="nba.coves.social") 259 + config = AggregatorConfig( 260 + coves_api_url="https://coves.social", 261 + subreddits=(subreddit,), 262 + ) 263 + with pytest.raises(AttributeError): 264 + config.coves_api_url = "https://other.com" 265 + 266 + def test_default_allowed_domains(self): 267 + """Test default allowed domains.""" 268 + subreddit = SubredditConfig(name="nba", community_handle="nba.coves.social") 269 + config = AggregatorConfig( 270 + coves_api_url="https://coves.social", 271 + subreddits=(subreddit,), 272 + ) 273 + assert "streamable.com" in config.allowed_domains
+178
aggregators/reddit-highlights/tests/test_rss_fetcher.py
··· 1 + """ 2 + Tests for rss_fetcher module. 3 + """ 4 + import pytest 5 + from unittest.mock import MagicMock, patch 6 + import requests 7 + 8 + from src.rss_fetcher import RSSFetcher 9 + 10 + 11 + class TestRSSFetcherInit: 12 + """Tests for RSSFetcher initialization.""" 13 + 14 + def test_default_timeout(self): 15 + """Test default timeout value.""" 16 + fetcher = RSSFetcher() 17 + assert fetcher.timeout == 30 18 + 19 + def test_default_max_retries(self): 20 + """Test default max_retries value.""" 21 + fetcher = RSSFetcher() 22 + assert fetcher.max_retries == 3 23 + 24 + def test_default_user_agent(self): 25 + """Test default user agent is set.""" 26 + fetcher = RSSFetcher() 27 + assert "Coves-Reddit-Aggregator" in fetcher.user_agent 28 + assert "coves.social" in fetcher.user_agent 29 + 30 + def test_custom_timeout(self): 31 + """Test custom timeout value.""" 32 + fetcher = RSSFetcher(timeout=60) 33 + assert fetcher.timeout == 60 34 + 35 + def test_custom_max_retries(self): 36 + """Test custom max_retries value.""" 37 + fetcher = RSSFetcher(max_retries=5) 38 + assert fetcher.max_retries == 5 39 + 40 + def test_custom_user_agent(self): 41 + """Test custom user agent.""" 42 + fetcher = RSSFetcher(user_agent="CustomBot/1.0") 43 + assert fetcher.user_agent == "CustomBot/1.0" 44 + 45 + 46 + class TestRSSFetcherFetchFeed: 47 + """Tests for fetch_feed method.""" 48 + 49 + @pytest.fixture 50 + def fetcher(self): 51 + return RSSFetcher(max_retries=2) 52 + 53 + def test_raises_on_empty_url(self, fetcher): 54 + """Test that ValueError is raised for empty URL.""" 55 + with pytest.raises(ValueError, match="URL cannot be empty"): 56 + fetcher.fetch_feed("") 57 + 58 + def test_successful_fetch(self, fetcher): 59 + """Test successful feed fetch.""" 60 + mock_response = MagicMock() 61 + mock_response.status_code = 200 62 + mock_response.content = b"""<?xml version="1.0" encoding="UTF-8"?> 63 + <rss version="2.0"> 64 + <channel> 65 + <title>Test Feed</title> 66 + <item> 67 + <title>Test Post</title> 68 + <link>https://reddit.com/r/test/post1</link> 69 + </item> 70 + </channel> 71 + </rss>""" 72 + mock_response.raise_for_status = MagicMock() 73 + 74 + with patch("requests.get", return_value=mock_response): 75 + feed = fetcher.fetch_feed("https://reddit.com/r/test/.rss") 76 + 77 + assert feed.feed.get("title") == "Test Feed" 78 + assert len(feed.entries) == 1 79 + assert feed.entries[0].title == "Test Post" 80 + 81 + def test_sends_user_agent_header(self, fetcher): 82 + """Test that User-Agent header is sent.""" 83 + mock_response = MagicMock() 84 + mock_response.status_code = 200 85 + mock_response.content = b"<rss><channel></channel></rss>" 86 + mock_response.raise_for_status = MagicMock() 87 + 88 + with patch("requests.get", return_value=mock_response) as mock_get: 89 + fetcher.fetch_feed("https://reddit.com/r/test/.rss") 90 + 91 + call_kwargs = mock_get.call_args[1] 92 + assert "User-Agent" in call_kwargs["headers"] 93 + assert call_kwargs["headers"]["User-Agent"] == fetcher.user_agent 94 + 95 + def test_retries_on_failure(self, fetcher): 96 + """Test that fetch is retried on failure.""" 97 + mock_response = MagicMock() 98 + mock_response.status_code = 200 99 + mock_response.content = b"<rss><channel></channel></rss>" 100 + mock_response.raise_for_status = MagicMock() 101 + 102 + # First call fails, second succeeds 103 + with patch("requests.get", side_effect=[ 104 + requests.ConnectionError("Connection failed"), 105 + mock_response 106 + ]) as mock_get: 107 + with patch("time.sleep"): # Don't actually sleep in tests 108 + feed = fetcher.fetch_feed("https://reddit.com/r/test/.rss") 109 + 110 + assert mock_get.call_count == 2 111 + assert feed is not None 112 + 113 + def test_raises_after_max_retries(self, fetcher): 114 + """Test that exception is raised after max retries exhausted.""" 115 + error = requests.ConnectionError("Connection failed") 116 + 117 + with patch("requests.get", side_effect=error): 118 + with patch("time.sleep"): 119 + with pytest.raises(requests.ConnectionError): 120 + fetcher.fetch_feed("https://reddit.com/r/test/.rss") 121 + 122 + def test_exponential_backoff(self, fetcher): 123 + """Test that exponential backoff is used between retries.""" 124 + mock_response = MagicMock() 125 + mock_response.status_code = 200 126 + mock_response.content = b"<rss><channel></channel></rss>" 127 + mock_response.raise_for_status = MagicMock() 128 + 129 + with patch("requests.get", side_effect=[ 130 + requests.Timeout("Timeout"), 131 + mock_response 132 + ]): 133 + with patch("time.sleep") as mock_sleep: 134 + fetcher.fetch_feed("https://reddit.com/r/test/.rss") 135 + 136 + # First retry should have 1 second delay (2^0) 137 + mock_sleep.assert_called_once_with(1) 138 + 139 + def test_uses_timeout(self, fetcher): 140 + """Test that timeout is passed to requests.""" 141 + mock_response = MagicMock() 142 + mock_response.status_code = 200 143 + mock_response.content = b"<rss><channel></channel></rss>" 144 + mock_response.raise_for_status = MagicMock() 145 + 146 + with patch("requests.get", return_value=mock_response) as mock_get: 147 + fetcher.fetch_feed("https://reddit.com/r/test/.rss") 148 + 149 + call_kwargs = mock_get.call_args[1] 150 + assert call_kwargs["timeout"] == fetcher.timeout 151 + 152 + def test_raises_for_http_error(self, fetcher): 153 + """Test that HTTP errors are propagated.""" 154 + mock_response = MagicMock() 155 + mock_response.status_code = 404 156 + mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") 157 + 158 + with patch("requests.get", return_value=mock_response): 159 + with patch("time.sleep"): 160 + with pytest.raises(requests.HTTPError): 161 + fetcher.fetch_feed("https://reddit.com/r/nonexistent/.rss") 162 + 163 + def test_handles_rate_limit(self, fetcher): 164 + """Test that 429 Too Many Requests is retried.""" 165 + mock_response_429 = MagicMock() 166 + mock_response_429.status_code = 429 167 + mock_response_429.raise_for_status.side_effect = requests.HTTPError("429 Too Many Requests") 168 + 169 + mock_response_ok = MagicMock() 170 + mock_response_ok.status_code = 200 171 + mock_response_ok.content = b"<rss><channel><title>Success</title></channel></rss>" 172 + mock_response_ok.raise_for_status = MagicMock() 173 + 174 + with patch("requests.get", side_effect=[mock_response_429, mock_response_ok]): 175 + with patch("time.sleep"): 176 + feed = fetcher.fetch_feed("https://reddit.com/r/test/.rss") 177 + 178 + assert feed.feed.get("title") == "Success"
+299
aggregators/reddit-highlights/tests/test_state_manager.py
··· 1 + """ 2 + Tests for state_manager module. 3 + """ 4 + import json 5 + import pytest 6 + from datetime import datetime, timedelta 7 + from pathlib import Path 8 + from unittest.mock import patch, MagicMock 9 + 10 + from src.state_manager import StateManager 11 + 12 + 13 + class TestStateManagerInit: 14 + """Tests for StateManager initialization.""" 15 + 16 + def test_creates_new_state_file(self, tmp_path): 17 + """Test that new state file is created when it doesn't exist.""" 18 + state_file = tmp_path / "state.json" 19 + manager = StateManager(state_file) 20 + 21 + assert state_file.exists() 22 + assert manager.state == {"feeds": {}} 23 + 24 + def test_loads_existing_state_file(self, tmp_path): 25 + """Test that existing state file is loaded.""" 26 + state_file = tmp_path / "state.json" 27 + existing_state = { 28 + "feeds": { 29 + "nba": { 30 + "posted_guids": [{"guid": "test123", "post_uri": "at://...", "posted_at": "2024-01-01T00:00:00"}], 31 + "last_successful_run": "2024-01-01T00:00:00", 32 + } 33 + } 34 + } 35 + state_file.write_text(json.dumps(existing_state)) 36 + 37 + manager = StateManager(state_file) 38 + assert manager.state == existing_state 39 + 40 + def test_handles_corrupted_state_file(self, tmp_path): 41 + """Test that corrupted state file is handled gracefully.""" 42 + state_file = tmp_path / "state.json" 43 + state_file.write_text("not valid json {{{") 44 + 45 + manager = StateManager(state_file) 46 + 47 + # Should create new state and backup corrupted file 48 + assert manager.state == {"feeds": {}} 49 + backup_file = tmp_path / "state.json.corrupted" 50 + assert backup_file.exists() 51 + assert backup_file.read_text() == "not valid json {{{" 52 + 53 + def test_creates_parent_directories(self, tmp_path): 54 + """Test that parent directories are created if needed.""" 55 + state_file = tmp_path / "nested" / "deep" / "state.json" 56 + manager = StateManager(state_file) 57 + 58 + assert state_file.exists() 59 + assert manager.state == {"feeds": {}} 60 + 61 + 62 + class TestStateManagerIsPosted: 63 + """Tests for is_posted method.""" 64 + 65 + def test_returns_false_for_new_feed(self, tmp_path): 66 + """Test that is_posted returns False for new feed.""" 67 + state_file = tmp_path / "state.json" 68 + manager = StateManager(state_file) 69 + 70 + assert not manager.is_posted("nba", "newguid123") 71 + 72 + def test_returns_false_for_unposted_guid(self, tmp_path): 73 + """Test that is_posted returns False for unposted GUID.""" 74 + state_file = tmp_path / "state.json" 75 + manager = StateManager(state_file) 76 + manager.mark_posted("nba", "existingguid", "at://test") 77 + 78 + assert not manager.is_posted("nba", "differentguid") 79 + 80 + def test_returns_true_for_posted_guid(self, tmp_path): 81 + """Test that is_posted returns True for posted GUID.""" 82 + state_file = tmp_path / "state.json" 83 + manager = StateManager(state_file) 84 + manager.mark_posted("nba", "postedguid", "at://test") 85 + 86 + assert manager.is_posted("nba", "postedguid") 87 + 88 + 89 + class TestStateManagerMarkPosted: 90 + """Tests for mark_posted method.""" 91 + 92 + def test_marks_guid_as_posted(self, tmp_path): 93 + """Test that GUID is marked as posted.""" 94 + state_file = tmp_path / "state.json" 95 + manager = StateManager(state_file) 96 + 97 + manager.mark_posted("nba", "testguid", "at://did:plc:test/post/abc") 98 + 99 + assert manager.is_posted("nba", "testguid") 100 + 101 + def test_saves_state_to_file(self, tmp_path): 102 + """Test that state is persisted to file.""" 103 + state_file = tmp_path / "state.json" 104 + manager = StateManager(state_file) 105 + 106 + manager.mark_posted("nba", "testguid", "at://test") 107 + 108 + # Create new manager from same file 109 + manager2 = StateManager(state_file) 110 + assert manager2.is_posted("nba", "testguid") 111 + 112 + def test_stores_post_uri(self, tmp_path): 113 + """Test that post URI is stored.""" 114 + state_file = tmp_path / "state.json" 115 + manager = StateManager(state_file) 116 + 117 + manager.mark_posted("nba", "testguid", "at://did:plc:test/post/xyz") 118 + 119 + # Check the stored data 120 + posted_guids = manager.state["feeds"]["nba"]["posted_guids"] 121 + entry = next(e for e in posted_guids if e["guid"] == "testguid") 122 + assert entry["post_uri"] == "at://did:plc:test/post/xyz" 123 + 124 + def test_stores_timestamp(self, tmp_path): 125 + """Test that posted_at timestamp is stored.""" 126 + state_file = tmp_path / "state.json" 127 + manager = StateManager(state_file) 128 + 129 + manager.mark_posted("nba", "testguid", "at://test") 130 + 131 + posted_guids = manager.state["feeds"]["nba"]["posted_guids"] 132 + entry = next(e for e in posted_guids if e["guid"] == "testguid") 133 + # Should be a valid ISO timestamp 134 + datetime.fromisoformat(entry["posted_at"]) 135 + 136 + 137 + class TestStateManagerAtomicSave: 138 + """Tests for atomic save functionality.""" 139 + 140 + def test_atomic_write_uses_temp_file(self, tmp_path): 141 + """Test that atomic write uses temp file.""" 142 + state_file = tmp_path / "state.json" 143 + manager = StateManager(state_file) 144 + 145 + # The temp file should not exist after successful save 146 + temp_file = tmp_path / "state.json.tmp" 147 + manager.mark_posted("nba", "test", "at://test") 148 + assert not temp_file.exists() 149 + 150 + def test_write_error_cleans_up_temp_file(self, tmp_path): 151 + """Test that temp file is cleaned up on write error.""" 152 + state_file = tmp_path / "state.json" 153 + manager = StateManager(state_file) 154 + 155 + temp_file = tmp_path / "state.json.tmp" 156 + 157 + # Mock rename to fail 158 + with patch("pathlib.Path.rename", side_effect=OSError("Mock error")): 159 + with pytest.raises(OSError): 160 + manager._save_state({"feeds": {}}) 161 + 162 + # Temp file should be cleaned up 163 + assert not temp_file.exists() 164 + 165 + 166 + class TestStateManagerCleanup: 167 + """Tests for cleanup_old_entries method.""" 168 + 169 + def test_removes_entries_older_than_max_age(self, tmp_path): 170 + """Test that entries older than max_age_days are removed.""" 171 + state_file = tmp_path / "state.json" 172 + manager = StateManager(state_file, max_age_days=7) 173 + 174 + # Add old entry manually 175 + old_date = (datetime.now() - timedelta(days=10)).isoformat() 176 + manager.state["feeds"]["nba"] = { 177 + "posted_guids": [{"guid": "old", "post_uri": "at://old", "posted_at": old_date}], 178 + "last_successful_run": None, 179 + } 180 + 181 + # Add new entry 182 + manager.mark_posted("nba", "new", "at://new") 183 + 184 + # Old entry should be removed after cleanup (triggered by mark_posted) 185 + assert not manager.is_posted("nba", "old") 186 + assert manager.is_posted("nba", "new") 187 + 188 + def test_keeps_entries_within_max_guids(self, tmp_path): 189 + """Test that only max_guids_per_feed entries are kept.""" 190 + state_file = tmp_path / "state.json" 191 + manager = StateManager(state_file, max_guids_per_feed=3) 192 + 193 + # Add 5 entries 194 + for i in range(5): 195 + manager.mark_posted("nba", f"guid{i}", f"at://uri{i}") 196 + 197 + # Only most recent 3 should remain 198 + assert manager.get_posted_count("nba") == 3 199 + 200 + def test_handles_malformed_timestamps(self, tmp_path): 201 + """Test that malformed timestamps are handled gracefully.""" 202 + state_file = tmp_path / "state.json" 203 + manager = StateManager(state_file) 204 + 205 + # Add entry with malformed timestamp 206 + manager.state["feeds"]["nba"] = { 207 + "posted_guids": [ 208 + {"guid": "malformed", "post_uri": "at://test", "posted_at": "not-a-date"}, 209 + {"guid": "valid", "post_uri": "at://test2", "posted_at": datetime.now().isoformat()}, 210 + ], 211 + "last_successful_run": None, 212 + } 213 + 214 + # Cleanup should handle malformed entry without crashing 215 + manager.cleanup_old_entries("nba") 216 + 217 + # Malformed entry should be removed, valid should remain 218 + assert not manager.is_posted("nba", "malformed") 219 + assert manager.is_posted("nba", "valid") 220 + 221 + 222 + class TestStateManagerLastRun: 223 + """Tests for get_last_run and update_last_run methods.""" 224 + 225 + def test_get_last_run_returns_none_for_new_feed(self, tmp_path): 226 + """Test that get_last_run returns None for new feed.""" 227 + state_file = tmp_path / "state.json" 228 + manager = StateManager(state_file) 229 + 230 + assert manager.get_last_run("nba") is None 231 + 232 + def test_update_and_get_last_run(self, tmp_path): 233 + """Test updating and retrieving last run timestamp.""" 234 + state_file = tmp_path / "state.json" 235 + manager = StateManager(state_file) 236 + 237 + now = datetime.now() 238 + manager.update_last_run("nba", now) 239 + 240 + result = manager.get_last_run("nba") 241 + # Compare without microseconds (ISO format may lose precision) 242 + assert result.replace(microsecond=0) == now.replace(microsecond=0) 243 + 244 + def test_last_run_persisted_to_file(self, tmp_path): 245 + """Test that last run is persisted to file.""" 246 + state_file = tmp_path / "state.json" 247 + manager = StateManager(state_file) 248 + 249 + now = datetime.now() 250 + manager.update_last_run("nba", now) 251 + 252 + # Create new manager from same file 253 + manager2 = StateManager(state_file) 254 + result = manager2.get_last_run("nba") 255 + assert result.replace(microsecond=0) == now.replace(microsecond=0) 256 + 257 + 258 + class TestStateManagerGetPostedGuids: 259 + """Tests for get_all_posted_guids method.""" 260 + 261 + def test_returns_empty_list_for_new_feed(self, tmp_path): 262 + """Test that empty list is returned for new feed.""" 263 + state_file = tmp_path / "state.json" 264 + manager = StateManager(state_file) 265 + 266 + assert manager.get_all_posted_guids("nba") == [] 267 + 268 + def test_returns_all_guids(self, tmp_path): 269 + """Test that all posted GUIDs are returned.""" 270 + state_file = tmp_path / "state.json" 271 + manager = StateManager(state_file) 272 + 273 + manager.mark_posted("nba", "guid1", "at://1") 274 + manager.mark_posted("nba", "guid2", "at://2") 275 + manager.mark_posted("nba", "guid3", "at://3") 276 + 277 + guids = manager.get_all_posted_guids("nba") 278 + assert set(guids) == {"guid1", "guid2", "guid3"} 279 + 280 + 281 + class TestStateManagerGetPostedCount: 282 + """Tests for get_posted_count method.""" 283 + 284 + def test_returns_zero_for_new_feed(self, tmp_path): 285 + """Test that zero is returned for new feed.""" 286 + state_file = tmp_path / "state.json" 287 + manager = StateManager(state_file) 288 + 289 + assert manager.get_posted_count("nba") == 0 290 + 291 + def test_returns_correct_count(self, tmp_path): 292 + """Test that correct count is returned.""" 293 + state_file = tmp_path / "state.json" 294 + manager = StateManager(state_file) 295 + 296 + manager.mark_posted("nba", "guid1", "at://1") 297 + manager.mark_posted("nba", "guid2", "at://2") 298 + 299 + assert manager.get_posted_count("nba") == 2
+19
aggregators/reddit-highlights/.env.example
··· 1 + # Reddit Highlights Aggregator Environment Variables 2 + # 3 + # Copy this file to .env and fill in the values. 4 + 5 + # Coves API Key (required) 6 + # Get this from your Coves aggregator registration 7 + COVES_API_KEY=ckapi_your_api_key_here 8 + 9 + # Optional: Override Coves API URL from config.yaml 10 + # COVES_API_URL=https://coves.social 11 + 12 + # Optional: Override config file path 13 + # CONFIG_PATH=config.yaml 14 + 15 + # Optional: Override state file path 16 + # STATE_FILE=data/state.json 17 + 18 + # Optional: Skip anti-detection jitter (for testing only) 19 + # SKIP_JITTER=false
+54
aggregators/reddit-highlights/Dockerfile
··· 1 + # Reddit Highlights Aggregator 2 + # Production-ready Docker image with cron scheduler 3 + 4 + FROM python:3.11-slim 5 + 6 + # Install cron and other utilities 7 + RUN apt-get update && apt-get install -y \ 8 + cron \ 9 + curl \ 10 + procps \ 11 + && rm -rf /var/lib/apt/lists/* 12 + 13 + # Set working directory 14 + WORKDIR /app 15 + 16 + # Copy requirements first for better caching 17 + COPY requirements.txt . 18 + 19 + # Install Python dependencies (production only) 20 + RUN pip install --no-cache-dir \ 21 + feedparser==6.0.11 \ 22 + requests==2.31.0 \ 23 + pyyaml==6.0.1 24 + 25 + # Copy application code 26 + COPY src/ ./src/ 27 + COPY config.yaml ./ 28 + 29 + # Create data directory for state persistence 30 + RUN mkdir -p /app/data 31 + 32 + # Copy crontab file 33 + COPY crontab /etc/cron.d/reddit-aggregator 34 + 35 + # Give execution rights on the cron job and apply it 36 + RUN chmod 0644 /etc/cron.d/reddit-aggregator && \ 37 + crontab /etc/cron.d/reddit-aggregator 38 + 39 + # Create log file to be able to run tail 40 + RUN touch /var/log/cron.log 41 + 42 + # Copy entrypoint script 43 + COPY docker-entrypoint.sh /usr/local/bin/ 44 + RUN chmod +x /usr/local/bin/docker-entrypoint.sh 45 + 46 + # Health check - verify cron is running 47 + HEALTHCHECK --interval=60s --timeout=10s --start-period=10s --retries=3 \ 48 + CMD pgrep cron || exit 1 49 + 50 + # Run the entrypoint script 51 + ENTRYPOINT ["docker-entrypoint.sh"] 52 + 53 + # Default command: tail the cron log 54 + CMD ["tail", "-f", "/var/log/cron.log"]
+8
aggregators/reddit-highlights/crontab
··· 1 + # Run Reddit Highlights aggregator every hour 2 + # The Python script adds 0-10 min random jitter for anti-detection 3 + # Effective polling interval: 60-70 minutes (varies each run) 4 + # Source environment variables exported by docker-entrypoint.sh 5 + 0 * * * * . /etc/environment; cd /app && /usr/local/bin/python -m src.main >> /var/log/cron.log 2>&1 6 + 7 + # Blank line required at end of crontab 8 +
+45
aggregators/reddit-highlights/docker-compose.yml
··· 1 + services: 2 + reddit-aggregator: 3 + build: 4 + context: . 5 + dockerfile: Dockerfile 6 + container_name: reddit-highlights-aggregator 7 + restart: unless-stopped 8 + 9 + # Environment variables 10 + environment: 11 + # Required: Coves API Key 12 + - COVES_API_KEY=${COVES_API_KEY} 13 + 14 + # Optional: Override Coves API URL 15 + - COVES_API_URL=${COVES_API_URL:-https://coves.social} 16 + 17 + # Optional: Run immediately on startup (useful for testing) 18 + - RUN_ON_STARTUP=${RUN_ON_STARTUP:-false} 19 + 20 + # Optional: Skip jitter (for testing only) 21 + - SKIP_JITTER=${SKIP_JITTER:-false} 22 + 23 + # Mount config file and state directory 24 + volumes: 25 + - ./config.yaml:/app/config.yaml:ro 26 + - ./data:/app/data # State persistence for deduplication 27 + 28 + # Load credentials from .env 29 + env_file: 30 + - .env 31 + 32 + # Logging configuration 33 + logging: 34 + driver: "json-file" 35 + options: 36 + max-size: "10m" 37 + max-file: "3" 38 + 39 + # Health check 40 + healthcheck: 41 + test: ["CMD", "pgrep", "cron"] 42 + interval: 60s 43 + timeout: 10s 44 + retries: 3 45 + start_period: 10s
+47
aggregators/reddit-highlights/docker-entrypoint.sh
··· 1 + #!/bin/bash 2 + set -e 3 + 4 + echo "Starting Reddit Highlights Aggregator..." 5 + echo "=========================================" 6 + 7 + # Load environment variables if .env file exists 8 + if [ -f /app/.env ]; then 9 + echo "Loading environment variables from .env" 10 + export $(grep -v '^#' /app/.env | xargs) 11 + fi 12 + 13 + # Validate required environment variables 14 + if [ -z "$COVES_API_KEY" ]; then 15 + echo "ERROR: Missing required environment variable!" 16 + echo "Please set COVES_API_KEY (format: ckapi_...)" 17 + exit 1 18 + fi 19 + 20 + echo "API Key prefix: ${COVES_API_KEY:0:12}..." 21 + echo "Cron schedule: Every 30 minutes (with 0-10 min jitter)" 22 + 23 + # Export environment variables for cron 24 + # Cron runs in a separate environment and doesn't inherit container env vars 25 + echo "Exporting environment variables for cron..." 26 + printenv | grep -E '^(COVES_|SKIP_|PATH=)' > /etc/environment 27 + 28 + # Start cron in the background 29 + echo "Starting cron daemon..." 30 + cron 31 + 32 + # Optional: Run aggregator immediately on startup (for testing) 33 + if [ "$RUN_ON_STARTUP" = "true" ]; then 34 + echo "Running aggregator immediately (RUN_ON_STARTUP=true)..." 35 + # Skip jitter for immediate run 36 + cd /app && SKIP_JITTER=true python -m src.main 37 + fi 38 + 39 + echo "=========================================" 40 + echo "Reddit Highlights Aggregator is running!" 41 + echo "Polling r/nba for streamable links" 42 + echo "Logs will appear below:" 43 + echo "=========================================" 44 + echo "" 45 + 46 + # Execute the command passed to docker run (defaults to tail -f /var/log/cron.log) 47 + exec "$@"
+150
internal/api/handlers/user/delete.go
··· 1 + package user 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "log/slog" 8 + "net/http" 9 + 10 + "Coves/internal/api/middleware" 11 + "Coves/internal/core/users" 12 + ) 13 + 14 + // DeleteHandler handles account deletion requests 15 + type DeleteHandler struct { 16 + userService users.UserService 17 + } 18 + 19 + // NewDeleteHandler creates a new delete handler 20 + func NewDeleteHandler(userService users.UserService) *DeleteHandler { 21 + return &DeleteHandler{ 22 + userService: userService, 23 + } 24 + } 25 + 26 + // DeleteAccountResponse represents the response for account deletion 27 + type DeleteAccountResponse struct { 28 + Success bool `json:"success"` 29 + Message string `json:"message,omitempty"` 30 + } 31 + 32 + // HandleDeleteAccount handles POST /xrpc/social.coves.actor.deleteAccount 33 + // Deletes the authenticated user's account from the Coves AppView. 34 + // This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. 35 + // The user's identity remains intact for use with other atProto apps. 36 + // 37 + // Security: 38 + // - Requires OAuth authentication 39 + // - Users can ONLY delete their own account (DID from auth context) 40 + // - No request body required - DID is derived from authenticated session 41 + func (h *DeleteHandler) HandleDeleteAccount(w http.ResponseWriter, r *http.Request) { 42 + // 1. Check HTTP method 43 + if r.Method != http.MethodPost { 44 + writeJSONError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") 45 + return 46 + } 47 + 48 + // 2. Extract authenticated user DID from request context (injected by auth middleware) 49 + // SECURITY: This ensures users can ONLY delete their own account 50 + userDID := middleware.GetUserDID(r) 51 + if userDID == "" { 52 + writeJSONError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 53 + return 54 + } 55 + 56 + // 3. Delete the account 57 + // The service handles validation, logging, and atomic deletion 58 + err := h.userService.DeleteAccount(r.Context(), userDID) 59 + if err != nil { 60 + handleServiceError(w, err, userDID) 61 + return 62 + } 63 + 64 + // 4. Return success response 65 + // Marshal JSON before writing headers to catch encoding errors early 66 + response := DeleteAccountResponse{ 67 + Success: true, 68 + Message: "Account deleted successfully. Your atProto identity remains intact on your PDS.", 69 + } 70 + 71 + responseBytes, err := json.Marshal(response) 72 + if err != nil { 73 + slog.Error("failed to marshal delete account response", 74 + slog.String("did", userDID), 75 + slog.String("error", err.Error()), 76 + ) 77 + writeJSONError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") 78 + return 79 + } 80 + 81 + w.Header().Set("Content-Type", "application/json") 82 + w.WriteHeader(http.StatusOK) 83 + if _, writeErr := w.Write(responseBytes); writeErr != nil { 84 + slog.Warn("failed to write delete account response", 85 + slog.String("did", userDID), 86 + slog.String("error", writeErr.Error()), 87 + ) 88 + } 89 + } 90 + 91 + // writeJSONError writes a JSON error response 92 + // Marshals JSON before writing headers to catch encoding errors 93 + func writeJSONError(w http.ResponseWriter, statusCode int, errorType, message string) { 94 + responseBytes, err := json.Marshal(map[string]interface{}{ 95 + "error": errorType, 96 + "message": message, 97 + }) 98 + if err != nil { 99 + // Fallback to plain text if JSON encoding fails (should never happen with simple strings) 100 + slog.Error("failed to marshal error response", slog.String("error", err.Error())) 101 + w.Header().Set("Content-Type", "text/plain") 102 + w.WriteHeader(statusCode) 103 + _, _ = w.Write([]byte(message)) 104 + return 105 + } 106 + 107 + w.Header().Set("Content-Type", "application/json") 108 + w.WriteHeader(statusCode) 109 + if _, writeErr := w.Write(responseBytes); writeErr != nil { 110 + slog.Warn("failed to write error response", slog.String("error", writeErr.Error())) 111 + } 112 + } 113 + 114 + // handleServiceError maps service errors to HTTP responses 115 + func handleServiceError(w http.ResponseWriter, err error, userDID string) { 116 + // Check for specific error types 117 + switch { 118 + case errors.Is(err, users.ErrUserNotFound): 119 + writeJSONError(w, http.StatusNotFound, "AccountNotFound", "Account not found") 120 + 121 + case errors.Is(err, context.DeadlineExceeded): 122 + slog.Error("account deletion timed out", 123 + slog.String("did", userDID), 124 + slog.String("error", err.Error()), 125 + ) 126 + writeJSONError(w, http.StatusGatewayTimeout, "Timeout", "Request timed out") 127 + 128 + case errors.Is(err, context.Canceled): 129 + slog.Info("account deletion canceled", 130 + slog.String("did", userDID), 131 + slog.String("error", err.Error()), 132 + ) 133 + writeJSONError(w, http.StatusBadRequest, "RequestCanceled", "Request was canceled") 134 + 135 + default: 136 + // Check for InvalidDIDError 137 + var invalidDIDErr *users.InvalidDIDError 138 + if errors.As(err, &invalidDIDErr) { 139 + writeJSONError(w, http.StatusBadRequest, "InvalidDID", invalidDIDErr.Error()) 140 + return 141 + } 142 + 143 + // Internal server error - don't leak details 144 + slog.Error("account deletion failed", 145 + slog.String("did", userDID), 146 + slog.String("error", err.Error()), 147 + ) 148 + writeJSONError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 149 + } 150 + }
+366
internal/api/handlers/user/delete_test.go
··· 1 + package user 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "net/url" 8 + "strings" 9 + "testing" 10 + 11 + "Coves/internal/api/middleware" 12 + "Coves/internal/core/users" 13 + 14 + "github.com/stretchr/testify/assert" 15 + "github.com/stretchr/testify/mock" 16 + ) 17 + 18 + // MockUserService is a mock implementation of users.UserService 19 + type MockUserService struct { 20 + mock.Mock 21 + } 22 + 23 + func (m *MockUserService) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) { 24 + args := m.Called(ctx, req) 25 + if args.Get(0) == nil { 26 + return nil, args.Error(1) 27 + } 28 + return args.Get(0).(*users.User), args.Error(1) 29 + } 30 + 31 + func (m *MockUserService) GetUserByDID(ctx context.Context, did string) (*users.User, error) { 32 + args := m.Called(ctx, did) 33 + if args.Get(0) == nil { 34 + return nil, args.Error(1) 35 + } 36 + return args.Get(0).(*users.User), args.Error(1) 37 + } 38 + 39 + func (m *MockUserService) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) { 40 + args := m.Called(ctx, handle) 41 + if args.Get(0) == nil { 42 + return nil, args.Error(1) 43 + } 44 + return args.Get(0).(*users.User), args.Error(1) 45 + } 46 + 47 + func (m *MockUserService) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) { 48 + args := m.Called(ctx, did, newHandle) 49 + if args.Get(0) == nil { 50 + return nil, args.Error(1) 51 + } 52 + return args.Get(0).(*users.User), args.Error(1) 53 + } 54 + 55 + func (m *MockUserService) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { 56 + args := m.Called(ctx, handle) 57 + return args.String(0), args.Error(1) 58 + } 59 + 60 + func (m *MockUserService) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) { 61 + args := m.Called(ctx, req) 62 + if args.Get(0) == nil { 63 + return nil, args.Error(1) 64 + } 65 + return args.Get(0).(*users.RegisterAccountResponse), args.Error(1) 66 + } 67 + 68 + func (m *MockUserService) IndexUser(ctx context.Context, did, handle, pdsURL string) error { 69 + args := m.Called(ctx, did, handle, pdsURL) 70 + return args.Error(0) 71 + } 72 + 73 + func (m *MockUserService) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) { 74 + args := m.Called(ctx, did) 75 + if args.Get(0) == nil { 76 + return nil, args.Error(1) 77 + } 78 + return args.Get(0).(*users.ProfileViewDetailed), args.Error(1) 79 + } 80 + 81 + func (m *MockUserService) DeleteAccount(ctx context.Context, did string) error { 82 + args := m.Called(ctx, did) 83 + return args.Error(0) 84 + } 85 + 86 + func (m *MockUserService) UpdateProfile(ctx context.Context, did string, input users.UpdateProfileInput) (*users.User, error) { 87 + args := m.Called(ctx, did, input) 88 + if args.Get(0) == nil { 89 + return nil, args.Error(1) 90 + } 91 + return args.Get(0).(*users.User), args.Error(1) 92 + } 93 + 94 + // TestDeleteAccountHandler_Success tests successful account deletion via XRPC 95 + // Uses the actual production handler with middleware context injection 96 + func TestDeleteAccountHandler_Success(t *testing.T) { 97 + mockService := new(MockUserService) 98 + handler := NewDeleteHandler(mockService) 99 + 100 + testDID := "did:plc:testdelete123" 101 + mockService.On("DeleteAccount", mock.Anything, testDID).Return(nil) 102 + 103 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) 104 + // Use middleware context injection instead of X-User-DID header 105 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 106 + req = req.WithContext(ctx) 107 + 108 + w := httptest.NewRecorder() 109 + handler.HandleDeleteAccount(w, req) 110 + 111 + assert.Equal(t, http.StatusOK, w.Code) 112 + assert.Contains(t, w.Body.String(), `"success":true`) 113 + assert.Contains(t, w.Body.String(), "atProto identity remains intact") 114 + 115 + mockService.AssertExpectations(t) 116 + } 117 + 118 + // TestDeleteAccountHandler_Unauthenticated tests deletion without authentication 119 + func TestDeleteAccountHandler_Unauthenticated(t *testing.T) { 120 + mockService := new(MockUserService) 121 + handler := NewDeleteHandler(mockService) 122 + 123 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) 124 + // No context injection - simulates unauthenticated request 125 + 126 + w := httptest.NewRecorder() 127 + handler.HandleDeleteAccount(w, req) 128 + 129 + assert.Equal(t, http.StatusUnauthorized, w.Code) 130 + assert.Contains(t, w.Body.String(), "AuthRequired") 131 + 132 + mockService.AssertNotCalled(t, "DeleteAccount", mock.Anything, mock.Anything) 133 + } 134 + 135 + // TestDeleteAccountHandler_UserNotFound tests deletion of non-existent user 136 + func TestDeleteAccountHandler_UserNotFound(t *testing.T) { 137 + mockService := new(MockUserService) 138 + handler := NewDeleteHandler(mockService) 139 + 140 + testDID := "did:plc:nonexistent" 141 + mockService.On("DeleteAccount", mock.Anything, testDID).Return(users.ErrUserNotFound) 142 + 143 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) 144 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 145 + req = req.WithContext(ctx) 146 + 147 + w := httptest.NewRecorder() 148 + handler.HandleDeleteAccount(w, req) 149 + 150 + assert.Equal(t, http.StatusNotFound, w.Code) 151 + assert.Contains(t, w.Body.String(), "AccountNotFound") 152 + 153 + mockService.AssertExpectations(t) 154 + } 155 + 156 + // TestDeleteAccountHandler_MethodNotAllowed tests that only POST is accepted 157 + func TestDeleteAccountHandler_MethodNotAllowed(t *testing.T) { 158 + mockService := new(MockUserService) 159 + handler := NewDeleteHandler(mockService) 160 + 161 + methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} 162 + 163 + for _, method := range methods { 164 + t.Run(method, func(t *testing.T) { 165 + req := httptest.NewRequest(method, "/xrpc/social.coves.actor.deleteAccount", nil) 166 + ctx := middleware.SetTestUserDID(req.Context(), "did:plc:test") 167 + req = req.WithContext(ctx) 168 + 169 + w := httptest.NewRecorder() 170 + handler.HandleDeleteAccount(w, req) 171 + 172 + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) 173 + }) 174 + } 175 + 176 + mockService.AssertNotCalled(t, "DeleteAccount", mock.Anything, mock.Anything) 177 + } 178 + 179 + // TestDeleteAccountHandler_InternalError tests handling of internal errors 180 + func TestDeleteAccountHandler_InternalError(t *testing.T) { 181 + mockService := new(MockUserService) 182 + handler := NewDeleteHandler(mockService) 183 + 184 + testDID := "did:plc:erroruser" 185 + mockService.On("DeleteAccount", mock.Anything, testDID).Return(assert.AnError) 186 + 187 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) 188 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 189 + req = req.WithContext(ctx) 190 + 191 + w := httptest.NewRecorder() 192 + handler.HandleDeleteAccount(w, req) 193 + 194 + assert.Equal(t, http.StatusInternalServerError, w.Code) 195 + assert.Contains(t, w.Body.String(), "InternalServerError") 196 + 197 + mockService.AssertExpectations(t) 198 + } 199 + 200 + // TestDeleteAccountHandler_ContextTimeout tests handling of context timeout 201 + func TestDeleteAccountHandler_ContextTimeout(t *testing.T) { 202 + mockService := new(MockUserService) 203 + handler := NewDeleteHandler(mockService) 204 + 205 + testDID := "did:plc:timeoutuser" 206 + mockService.On("DeleteAccount", mock.Anything, testDID).Return(context.DeadlineExceeded) 207 + 208 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) 209 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 210 + req = req.WithContext(ctx) 211 + 212 + w := httptest.NewRecorder() 213 + handler.HandleDeleteAccount(w, req) 214 + 215 + assert.Equal(t, http.StatusGatewayTimeout, w.Code) 216 + assert.Contains(t, w.Body.String(), "Timeout") 217 + 218 + mockService.AssertExpectations(t) 219 + } 220 + 221 + // TestDeleteAccountHandler_ContextCanceled tests handling of context cancellation 222 + func TestDeleteAccountHandler_ContextCanceled(t *testing.T) { 223 + mockService := new(MockUserService) 224 + handler := NewDeleteHandler(mockService) 225 + 226 + testDID := "did:plc:canceluser" 227 + mockService.On("DeleteAccount", mock.Anything, testDID).Return(context.Canceled) 228 + 229 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) 230 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 231 + req = req.WithContext(ctx) 232 + 233 + w := httptest.NewRecorder() 234 + handler.HandleDeleteAccount(w, req) 235 + 236 + assert.Equal(t, http.StatusBadRequest, w.Code) 237 + assert.Contains(t, w.Body.String(), "RequestCanceled") 238 + 239 + mockService.AssertExpectations(t) 240 + } 241 + 242 + // TestDeleteAccountHandler_InvalidDID tests handling of invalid DID format 243 + func TestDeleteAccountHandler_InvalidDID(t *testing.T) { 244 + mockService := new(MockUserService) 245 + handler := NewDeleteHandler(mockService) 246 + 247 + testDID := "did:plc:invaliddid" 248 + mockService.On("DeleteAccount", mock.Anything, testDID).Return(&users.InvalidDIDError{DID: "invalid", Reason: "must start with 'did:'"}) 249 + 250 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) 251 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 252 + req = req.WithContext(ctx) 253 + 254 + w := httptest.NewRecorder() 255 + handler.HandleDeleteAccount(w, req) 256 + 257 + assert.Equal(t, http.StatusBadRequest, w.Code) 258 + assert.Contains(t, w.Body.String(), "InvalidDID") 259 + 260 + mockService.AssertExpectations(t) 261 + } 262 + 263 + // TestWebDeleteAccount_FormSubmission tests the web form-based deletion flow 264 + func TestWebDeleteAccount_FormSubmission(t *testing.T) { 265 + mockService := new(MockUserService) 266 + 267 + testDID := "did:plc:webdeleteuser" 268 + mockService.On("DeleteAccount", mock.Anything, testDID).Return(nil) 269 + 270 + // Simulate form submission 271 + form := url.Values{} 272 + form.Add("confirm", "true") 273 + 274 + req := httptest.NewRequest(http.MethodPost, "/delete-account", strings.NewReader(form.Encode())) 275 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 276 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 277 + req = req.WithContext(ctx) 278 + 279 + // The web handler would parse the form and call DeleteAccount 280 + // This test verifies the service layer is called correctly 281 + err := mockService.DeleteAccount(ctx, testDID) 282 + assert.NoError(t, err) 283 + 284 + mockService.AssertExpectations(t) 285 + } 286 + 287 + // TestWebDeleteAccount_MissingConfirmation tests that confirmation is required 288 + func TestWebDeleteAccount_MissingConfirmation(t *testing.T) { 289 + form := url.Values{} 290 + // NOT adding confirm=true 291 + 292 + req := httptest.NewRequest(http.MethodPost, "/delete-account", strings.NewReader(form.Encode())) 293 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 294 + 295 + err := req.ParseForm() 296 + assert.NoError(t, err) 297 + assert.NotEqual(t, "true", req.FormValue("confirm")) 298 + } 299 + 300 + // TestWebDeleteAccount_ConfirmationPresent tests confirmation checkbox validation 301 + func TestWebDeleteAccount_ConfirmationPresent(t *testing.T) { 302 + form := url.Values{} 303 + form.Add("confirm", "true") 304 + 305 + req := httptest.NewRequest(http.MethodPost, "/delete-account", strings.NewReader(form.Encode())) 306 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 307 + 308 + err := req.ParseForm() 309 + assert.NoError(t, err) 310 + assert.Equal(t, "true", req.FormValue("confirm")) 311 + } 312 + 313 + // TestDeleteAccountHandler_DIDWithWhitespace tests DID handling with whitespace 314 + // The service layer should handle trimming whitespace from DIDs 315 + func TestDeleteAccountHandler_DIDWithWhitespace(t *testing.T) { 316 + mockService := new(MockUserService) 317 + handler := NewDeleteHandler(mockService) 318 + 319 + // In reality, the middleware would provide a clean DID from the OAuth session. 320 + // This test verifies the handler correctly passes the DID to the service. 321 + trimmedDID := "did:plc:whitespaceuser" 322 + mockService.On("DeleteAccount", mock.Anything, trimmedDID).Return(nil) 323 + 324 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) 325 + ctx := middleware.SetTestUserDID(req.Context(), trimmedDID) 326 + req = req.WithContext(ctx) 327 + 328 + w := httptest.NewRecorder() 329 + handler.HandleDeleteAccount(w, req) 330 + 331 + assert.Equal(t, http.StatusOK, w.Code) 332 + mockService.AssertExpectations(t) 333 + } 334 + 335 + // TestDeleteAccountHandler_ConcurrentRequests tests handling of concurrent deletion attempts 336 + // Verifies that repeated deletion attempts are handled gracefully 337 + func TestDeleteAccountHandler_ConcurrentRequests(t *testing.T) { 338 + mockService := new(MockUserService) 339 + handler := NewDeleteHandler(mockService) 340 + 341 + testDID := "did:plc:concurrentuser" 342 + 343 + // First call succeeds, second call returns not found (already deleted) 344 + mockService.On("DeleteAccount", mock.Anything, testDID).Return(nil).Once() 345 + mockService.On("DeleteAccount", mock.Anything, testDID).Return(users.ErrUserNotFound).Once() 346 + 347 + // First request 348 + req1 := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) 349 + ctx1 := middleware.SetTestUserDID(req1.Context(), testDID) 350 + req1 = req1.WithContext(ctx1) 351 + 352 + w1 := httptest.NewRecorder() 353 + handler.HandleDeleteAccount(w1, req1) 354 + assert.Equal(t, http.StatusOK, w1.Code) 355 + 356 + // Second request (simulating concurrent attempt that arrives after first completes) 357 + req2 := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) 358 + ctx2 := middleware.SetTestUserDID(req2.Context(), testDID) 359 + req2 = req2.WithContext(ctx2) 360 + 361 + w2 := httptest.NewRecorder() 362 + handler.HandleDeleteAccount(w2, req2) 363 + assert.Equal(t, http.StatusNotFound, w2.Code) 364 + 365 + mockService.AssertExpectations(t) 366 + }
+843
internal/core/users/service_test.go
··· 1 + package users 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + "time" 8 + 9 + "Coves/internal/atproto/identity" 10 + 11 + "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/mock" 13 + "github.com/stretchr/testify/require" 14 + ) 15 + 16 + // MockUserRepository is a mock implementation of UserRepository 17 + type MockUserRepository struct { 18 + mock.Mock 19 + } 20 + 21 + func (m *MockUserRepository) Create(ctx context.Context, user *User) (*User, error) { 22 + args := m.Called(ctx, user) 23 + if args.Get(0) == nil { 24 + return nil, args.Error(1) 25 + } 26 + return args.Get(0).(*User), args.Error(1) 27 + } 28 + 29 + func (m *MockUserRepository) GetByDID(ctx context.Context, did string) (*User, error) { 30 + args := m.Called(ctx, did) 31 + if args.Get(0) == nil { 32 + return nil, args.Error(1) 33 + } 34 + return args.Get(0).(*User), args.Error(1) 35 + } 36 + 37 + func (m *MockUserRepository) GetByHandle(ctx context.Context, handle string) (*User, error) { 38 + args := m.Called(ctx, handle) 39 + if args.Get(0) == nil { 40 + return nil, args.Error(1) 41 + } 42 + return args.Get(0).(*User), args.Error(1) 43 + } 44 + 45 + func (m *MockUserRepository) UpdateHandle(ctx context.Context, did, newHandle string) (*User, error) { 46 + args := m.Called(ctx, did, newHandle) 47 + if args.Get(0) == nil { 48 + return nil, args.Error(1) 49 + } 50 + return args.Get(0).(*User), args.Error(1) 51 + } 52 + 53 + func (m *MockUserRepository) GetByDIDs(ctx context.Context, dids []string) (map[string]*User, error) { 54 + args := m.Called(ctx, dids) 55 + if args.Get(0) == nil { 56 + return nil, args.Error(1) 57 + } 58 + return args.Get(0).(map[string]*User), args.Error(1) 59 + } 60 + 61 + func (m *MockUserRepository) GetProfileStats(ctx context.Context, did string) (*ProfileStats, error) { 62 + args := m.Called(ctx, did) 63 + if args.Get(0) == nil { 64 + return nil, args.Error(1) 65 + } 66 + return args.Get(0).(*ProfileStats), args.Error(1) 67 + } 68 + 69 + func (m *MockUserRepository) Delete(ctx context.Context, did string) error { 70 + args := m.Called(ctx, did) 71 + return args.Error(0) 72 + } 73 + 74 + func (m *MockUserRepository) UpdateProfile(ctx context.Context, did string, input UpdateProfileInput) (*User, error) { 75 + args := m.Called(ctx, did, input) 76 + if args.Get(0) == nil { 77 + return nil, args.Error(1) 78 + } 79 + return args.Get(0).(*User), args.Error(1) 80 + } 81 + 82 + // MockIdentityResolver is a mock implementation of identity.Resolver 83 + type MockIdentityResolver struct { 84 + mock.Mock 85 + } 86 + 87 + func (m *MockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 88 + args := m.Called(ctx, identifier) 89 + if args.Get(0) == nil { 90 + return nil, args.Error(1) 91 + } 92 + return args.Get(0).(*identity.Identity), args.Error(1) 93 + } 94 + 95 + func (m *MockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (string, string, error) { 96 + args := m.Called(ctx, handle) 97 + return args.String(0), args.String(1), args.Error(2) 98 + } 99 + 100 + func (m *MockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 101 + args := m.Called(ctx, did) 102 + if args.Get(0) == nil { 103 + return nil, args.Error(1) 104 + } 105 + return args.Get(0).(*identity.DIDDocument), args.Error(1) 106 + } 107 + 108 + func (m *MockIdentityResolver) Purge(ctx context.Context, identifier string) error { 109 + args := m.Called(ctx, identifier) 110 + return args.Error(0) 111 + } 112 + 113 + // TestDeleteAccount_Success tests successful account deletion 114 + func TestDeleteAccount_Success(t *testing.T) { 115 + mockRepo := new(MockUserRepository) 116 + mockResolver := new(MockIdentityResolver) 117 + 118 + testDID := "did:plc:testuser123" 119 + testHandle := "testuser.test" 120 + testUser := &User{ 121 + DID: testDID, 122 + Handle: testHandle, 123 + PDSURL: "https://test.pds", 124 + CreatedAt: time.Now(), 125 + } 126 + 127 + // Setup expectations 128 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) 129 + mockRepo.On("Delete", mock.Anything, testDID).Return(nil) 130 + 131 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 132 + ctx := context.Background() 133 + 134 + err := service.DeleteAccount(ctx, testDID) 135 + assert.NoError(t, err) 136 + 137 + mockRepo.AssertExpectations(t) 138 + } 139 + 140 + // TestDeleteAccount_UserNotFound tests deletion of non-existent user 141 + func TestDeleteAccount_UserNotFound(t *testing.T) { 142 + mockRepo := new(MockUserRepository) 143 + mockResolver := new(MockIdentityResolver) 144 + 145 + testDID := "did:plc:nonexistent" 146 + 147 + // Setup expectations 148 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(nil, ErrUserNotFound) 149 + 150 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 151 + ctx := context.Background() 152 + 153 + err := service.DeleteAccount(ctx, testDID) 154 + assert.ErrorIs(t, err, ErrUserNotFound) 155 + 156 + mockRepo.AssertExpectations(t) 157 + mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything) 158 + } 159 + 160 + // TestDeleteAccount_EmptyDID tests deletion with empty DID 161 + func TestDeleteAccount_EmptyDID(t *testing.T) { 162 + mockRepo := new(MockUserRepository) 163 + mockResolver := new(MockIdentityResolver) 164 + 165 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 166 + ctx := context.Background() 167 + 168 + err := service.DeleteAccount(ctx, "") 169 + assert.Error(t, err) 170 + 171 + // Verify it's an InvalidDIDError 172 + var invalidDIDErr *InvalidDIDError 173 + assert.True(t, errors.As(err, &invalidDIDErr), "expected InvalidDIDError") 174 + assert.Contains(t, err.Error(), "DID is required") 175 + 176 + mockRepo.AssertNotCalled(t, "GetByDID", mock.Anything, mock.Anything) 177 + mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything) 178 + } 179 + 180 + // TestDeleteAccount_WhitespaceDID tests deletion with whitespace-only DID 181 + func TestDeleteAccount_WhitespaceDID(t *testing.T) { 182 + mockRepo := new(MockUserRepository) 183 + mockResolver := new(MockIdentityResolver) 184 + 185 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 186 + ctx := context.Background() 187 + 188 + err := service.DeleteAccount(ctx, " ") 189 + assert.Error(t, err) 190 + 191 + // Verify it's an InvalidDIDError 192 + var invalidDIDErr *InvalidDIDError 193 + assert.True(t, errors.As(err, &invalidDIDErr), "expected InvalidDIDError") 194 + assert.Contains(t, err.Error(), "DID is required") 195 + } 196 + 197 + // TestDeleteAccount_LeadingTrailingWhitespace tests that DIDs are trimmed 198 + func TestDeleteAccount_LeadingTrailingWhitespace(t *testing.T) { 199 + mockRepo := new(MockUserRepository) 200 + mockResolver := new(MockIdentityResolver) 201 + 202 + // The input has whitespace but after trimming should be a valid DID 203 + inputDID := " did:plc:whitespacetest " 204 + trimmedDID := "did:plc:whitespacetest" 205 + 206 + testUser := &User{ 207 + DID: trimmedDID, 208 + Handle: "whitespacetest.test", 209 + PDSURL: "https://test.pds", 210 + CreatedAt: time.Now(), 211 + } 212 + 213 + // Expectations should use the trimmed DID 214 + mockRepo.On("GetByDID", mock.Anything, trimmedDID).Return(testUser, nil) 215 + mockRepo.On("Delete", mock.Anything, trimmedDID).Return(nil) 216 + 217 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 218 + ctx := context.Background() 219 + 220 + err := service.DeleteAccount(ctx, inputDID) 221 + assert.NoError(t, err) 222 + 223 + mockRepo.AssertExpectations(t) 224 + } 225 + 226 + // TestDeleteAccount_InvalidDIDFormat tests deletion with invalid DID format 227 + func TestDeleteAccount_InvalidDIDFormat(t *testing.T) { 228 + mockRepo := new(MockUserRepository) 229 + mockResolver := new(MockIdentityResolver) 230 + 231 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 232 + ctx := context.Background() 233 + 234 + err := service.DeleteAccount(ctx, "invalid-did-format") 235 + assert.Error(t, err) 236 + 237 + // Verify it's an InvalidDIDError 238 + var invalidDIDErr *InvalidDIDError 239 + assert.True(t, errors.As(err, &invalidDIDErr), "expected InvalidDIDError") 240 + assert.Contains(t, err.Error(), "must start with 'did:'") 241 + } 242 + 243 + // TestDeleteAccount_RepoDeleteFails tests handling when repository delete fails 244 + func TestDeleteAccount_RepoDeleteFails(t *testing.T) { 245 + mockRepo := new(MockUserRepository) 246 + mockResolver := new(MockIdentityResolver) 247 + 248 + testDID := "did:plc:testuser456" 249 + testUser := &User{ 250 + DID: testDID, 251 + Handle: "testuser456.test", 252 + PDSURL: "https://test.pds", 253 + CreatedAt: time.Now(), 254 + } 255 + 256 + // Setup expectations 257 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) 258 + mockRepo.On("Delete", mock.Anything, testDID).Return(errors.New("database error")) 259 + 260 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 261 + ctx := context.Background() 262 + 263 + err := service.DeleteAccount(ctx, testDID) 264 + assert.Error(t, err) 265 + assert.Contains(t, err.Error(), "failed to delete account") 266 + 267 + mockRepo.AssertExpectations(t) 268 + } 269 + 270 + // TestDeleteAccount_GetByDIDFails tests handling when GetByDID fails (non-NotFound error) 271 + func TestDeleteAccount_GetByDIDFails(t *testing.T) { 272 + mockRepo := new(MockUserRepository) 273 + mockResolver := new(MockIdentityResolver) 274 + 275 + testDID := "did:plc:testuser789" 276 + 277 + // Setup expectations 278 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(nil, errors.New("database connection error")) 279 + 280 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 281 + ctx := context.Background() 282 + 283 + err := service.DeleteAccount(ctx, testDID) 284 + assert.Error(t, err) 285 + assert.Contains(t, err.Error(), "failed to get user for deletion") 286 + 287 + mockRepo.AssertExpectations(t) 288 + mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything) 289 + } 290 + 291 + // TestDeleteAccount_ContextCancellation tests behavior with cancelled context 292 + func TestDeleteAccount_ContextCancellation(t *testing.T) { 293 + mockRepo := new(MockUserRepository) 294 + mockResolver := new(MockIdentityResolver) 295 + 296 + testDID := "did:plc:testcontextcancel" 297 + 298 + // Create a cancelled context 299 + ctx, cancel := context.WithCancel(context.Background()) 300 + cancel() 301 + 302 + // Setup expectations - GetByDID should fail due to cancelled context 303 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(nil, context.Canceled) 304 + 305 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 306 + 307 + err := service.DeleteAccount(ctx, testDID) 308 + assert.Error(t, err) 309 + 310 + mockRepo.AssertExpectations(t) 311 + } 312 + 313 + // TestDeleteAccount_PLCAndWebDID tests deletion works with both did:plc and did:web 314 + func TestDeleteAccount_PLCAndWebDID(t *testing.T) { 315 + tests := []struct { 316 + name string 317 + did string 318 + }{ 319 + { 320 + name: "did:plc format", 321 + did: "did:plc:abc123xyz", 322 + }, 323 + { 324 + name: "did:web format", 325 + did: "did:web:example.com", 326 + }, 327 + } 328 + 329 + for _, tc := range tests { 330 + t.Run(tc.name, func(t *testing.T) { 331 + mockRepo := new(MockUserRepository) 332 + mockResolver := new(MockIdentityResolver) 333 + 334 + testUser := &User{ 335 + DID: tc.did, 336 + Handle: "testuser.test", 337 + PDSURL: "https://test.pds", 338 + CreatedAt: time.Now(), 339 + } 340 + 341 + mockRepo.On("GetByDID", mock.Anything, tc.did).Return(testUser, nil) 342 + mockRepo.On("Delete", mock.Anything, tc.did).Return(nil) 343 + 344 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 345 + ctx := context.Background() 346 + 347 + err := service.DeleteAccount(ctx, tc.did) 348 + assert.NoError(t, err) 349 + 350 + mockRepo.AssertExpectations(t) 351 + }) 352 + } 353 + } 354 + 355 + // TestGetUserByDID tests retrieving a user by DID 356 + func TestGetUserByDID(t *testing.T) { 357 + mockRepo := new(MockUserRepository) 358 + mockResolver := new(MockIdentityResolver) 359 + 360 + testDID := "did:plc:testuser" 361 + testUser := &User{ 362 + DID: testDID, 363 + Handle: "testuser.test", 364 + PDSURL: "https://test.pds", 365 + CreatedAt: time.Now(), 366 + } 367 + 368 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) 369 + 370 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 371 + ctx := context.Background() 372 + 373 + user, err := service.GetUserByDID(ctx, testDID) 374 + require.NoError(t, err) 375 + assert.Equal(t, testDID, user.DID) 376 + assert.Equal(t, "testuser.test", user.Handle) 377 + } 378 + 379 + // TestGetUserByDID_EmptyDID tests GetUserByDID with empty DID 380 + func TestGetUserByDID_EmptyDID(t *testing.T) { 381 + mockRepo := new(MockUserRepository) 382 + mockResolver := new(MockIdentityResolver) 383 + 384 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 385 + ctx := context.Background() 386 + 387 + _, err := service.GetUserByDID(ctx, "") 388 + assert.Error(t, err) 389 + assert.Contains(t, err.Error(), "DID is required") 390 + } 391 + 392 + // TestGetUserByHandle tests retrieving a user by handle 393 + func TestGetUserByHandle(t *testing.T) { 394 + mockRepo := new(MockUserRepository) 395 + mockResolver := new(MockIdentityResolver) 396 + 397 + testHandle := "testuser.test" 398 + testUser := &User{ 399 + DID: "did:plc:testuser", 400 + Handle: testHandle, 401 + PDSURL: "https://test.pds", 402 + CreatedAt: time.Now(), 403 + } 404 + 405 + mockRepo.On("GetByHandle", mock.Anything, testHandle).Return(testUser, nil) 406 + 407 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 408 + ctx := context.Background() 409 + 410 + user, err := service.GetUserByHandle(ctx, testHandle) 411 + require.NoError(t, err) 412 + assert.Equal(t, testHandle, user.Handle) 413 + } 414 + 415 + // TestGetProfile tests retrieving a user's profile with stats 416 + func TestGetProfile(t *testing.T) { 417 + mockRepo := new(MockUserRepository) 418 + mockResolver := new(MockIdentityResolver) 419 + 420 + testDID := "did:plc:profileuser" 421 + testUser := &User{ 422 + DID: testDID, 423 + Handle: "profileuser.test", 424 + PDSURL: "https://test.pds", 425 + CreatedAt: time.Now(), 426 + } 427 + testStats := &ProfileStats{ 428 + PostCount: 10, 429 + CommentCount: 25, 430 + CommunityCount: 5, 431 + MembershipCount: 3, 432 + Reputation: 150, 433 + } 434 + 435 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) 436 + mockRepo.On("GetProfileStats", mock.Anything, testDID).Return(testStats, nil) 437 + 438 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 439 + ctx := context.Background() 440 + 441 + profile, err := service.GetProfile(ctx, testDID) 442 + require.NoError(t, err) 443 + assert.Equal(t, testDID, profile.DID) 444 + assert.Equal(t, 10, profile.Stats.PostCount) 445 + assert.Equal(t, 150, profile.Stats.Reputation) 446 + } 447 + 448 + // TestIndexUser tests indexing a new user 449 + func TestIndexUser(t *testing.T) { 450 + mockRepo := new(MockUserRepository) 451 + mockResolver := new(MockIdentityResolver) 452 + 453 + testDID := "did:plc:newuser" 454 + testHandle := "newuser.test" 455 + testPDSURL := "https://test.pds" 456 + 457 + testUser := &User{ 458 + DID: testDID, 459 + Handle: testHandle, 460 + PDSURL: testPDSURL, 461 + CreatedAt: time.Now(), 462 + } 463 + 464 + mockRepo.On("Create", mock.Anything, mock.MatchedBy(func(u *User) bool { 465 + return u.DID == testDID && u.Handle == testHandle 466 + })).Return(testUser, nil) 467 + 468 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 469 + ctx := context.Background() 470 + 471 + err := service.IndexUser(ctx, testDID, testHandle, testPDSURL) 472 + assert.NoError(t, err) 473 + 474 + mockRepo.AssertExpectations(t) 475 + } 476 + 477 + // TestGetProfile_WithAvatarAndBanner tests that GetProfile transforms CIDs to URLs 478 + func TestGetProfile_WithAvatarAndBanner(t *testing.T) { 479 + mockRepo := new(MockUserRepository) 480 + mockResolver := new(MockIdentityResolver) 481 + 482 + testDID := "did:plc:avataruser" 483 + testUser := &User{ 484 + DID: testDID, 485 + Handle: "avataruser.test", 486 + PDSURL: "https://test.pds", 487 + DisplayName: "Avatar User", 488 + Bio: "Test bio for avatar user", 489 + AvatarCID: "bafkreiabc123avatar", 490 + BannerCID: "bafkreixyz789banner", 491 + CreatedAt: time.Now(), 492 + } 493 + testStats := &ProfileStats{ 494 + PostCount: 5, 495 + CommentCount: 10, 496 + CommunityCount: 2, 497 + MembershipCount: 1, 498 + Reputation: 50, 499 + } 500 + 501 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) 502 + mockRepo.On("GetProfileStats", mock.Anything, testDID).Return(testStats, nil) 503 + 504 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 505 + ctx := context.Background() 506 + 507 + profile, err := service.GetProfile(ctx, testDID) 508 + require.NoError(t, err) 509 + 510 + // Verify basic fields 511 + assert.Equal(t, testDID, profile.DID) 512 + assert.Equal(t, "avataruser.test", profile.Handle) 513 + assert.Equal(t, "Avatar User", profile.DisplayName) 514 + assert.Equal(t, "Test bio for avatar user", profile.Bio) 515 + 516 + // Verify CID-to-URL transformation (DID is URL-encoded in query params) 517 + expectedAvatarURL := "https://test.pds/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aavataruser&cid=bafkreiabc123avatar" 518 + expectedBannerURL := "https://test.pds/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aavataruser&cid=bafkreixyz789banner" 519 + assert.Equal(t, expectedAvatarURL, profile.Avatar) 520 + assert.Equal(t, expectedBannerURL, profile.Banner) 521 + 522 + mockRepo.AssertExpectations(t) 523 + } 524 + 525 + // TestGetProfile_WithAvatarOnly tests GetProfile with only avatar CID set 526 + func TestGetProfile_WithAvatarOnly(t *testing.T) { 527 + mockRepo := new(MockUserRepository) 528 + mockResolver := new(MockIdentityResolver) 529 + 530 + testDID := "did:plc:avataronly" 531 + testUser := &User{ 532 + DID: testDID, 533 + Handle: "avataronly.test", 534 + PDSURL: "https://test.pds", 535 + DisplayName: "Avatar Only User", 536 + Bio: "", 537 + AvatarCID: "bafkreiavataronly", 538 + BannerCID: "", // No banner 539 + CreatedAt: time.Now(), 540 + } 541 + testStats := &ProfileStats{} 542 + 543 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) 544 + mockRepo.On("GetProfileStats", mock.Anything, testDID).Return(testStats, nil) 545 + 546 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 547 + ctx := context.Background() 548 + 549 + profile, err := service.GetProfile(ctx, testDID) 550 + require.NoError(t, err) 551 + 552 + // Avatar should be transformed to URL (DID is URL-encoded in query params) 553 + expectedAvatarURL := "https://test.pds/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aavataronly&cid=bafkreiavataronly" 554 + assert.Equal(t, expectedAvatarURL, profile.Avatar) 555 + 556 + // Banner should be empty 557 + assert.Empty(t, profile.Banner) 558 + 559 + mockRepo.AssertExpectations(t) 560 + } 561 + 562 + // TestGetProfile_WithNoCIDsOrProfile tests GetProfile with no avatar/banner/display name/bio 563 + func TestGetProfile_WithNoCIDsOrProfile(t *testing.T) { 564 + mockRepo := new(MockUserRepository) 565 + mockResolver := new(MockIdentityResolver) 566 + 567 + testDID := "did:plc:basicuser" 568 + testUser := &User{ 569 + DID: testDID, 570 + Handle: "basicuser.test", 571 + PDSURL: "https://test.pds", 572 + DisplayName: "", 573 + Bio: "", 574 + AvatarCID: "", 575 + BannerCID: "", 576 + CreatedAt: time.Now(), 577 + } 578 + testStats := &ProfileStats{} 579 + 580 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) 581 + mockRepo.On("GetProfileStats", mock.Anything, testDID).Return(testStats, nil) 582 + 583 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 584 + ctx := context.Background() 585 + 586 + profile, err := service.GetProfile(ctx, testDID) 587 + require.NoError(t, err) 588 + 589 + // All profile fields should be empty 590 + assert.Empty(t, profile.DisplayName) 591 + assert.Empty(t, profile.Bio) 592 + assert.Empty(t, profile.Avatar) 593 + assert.Empty(t, profile.Banner) 594 + 595 + mockRepo.AssertExpectations(t) 596 + } 597 + 598 + // TestGetProfile_WithEmptyPDSURL tests GetProfile does not create URLs when PDSURL is empty 599 + func TestGetProfile_WithEmptyPDSURL(t *testing.T) { 600 + mockRepo := new(MockUserRepository) 601 + mockResolver := new(MockIdentityResolver) 602 + 603 + testDID := "did:plc:nopdsurl" 604 + testUser := &User{ 605 + DID: testDID, 606 + Handle: "nopdsurl.test", 607 + PDSURL: "", // No PDS URL 608 + DisplayName: "No PDS URL User", 609 + Bio: "Test bio", 610 + AvatarCID: "bafkreiavatarcid", // Has CID but no PDS URL 611 + BannerCID: "bafkreibannercid", 612 + CreatedAt: time.Now(), 613 + } 614 + testStats := &ProfileStats{} 615 + 616 + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) 617 + mockRepo.On("GetProfileStats", mock.Anything, testDID).Return(testStats, nil) 618 + 619 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 620 + ctx := context.Background() 621 + 622 + profile, err := service.GetProfile(ctx, testDID) 623 + require.NoError(t, err) 624 + 625 + // Avatar and Banner should be empty since we can't construct URLs without PDS URL 626 + assert.Empty(t, profile.Avatar) 627 + assert.Empty(t, profile.Banner) 628 + 629 + // But display name and bio should still be set 630 + assert.Equal(t, "No PDS URL User", profile.DisplayName) 631 + assert.Equal(t, "Test bio", profile.Bio) 632 + 633 + mockRepo.AssertExpectations(t) 634 + } 635 + 636 + // TestUpdateProfile_Success tests successful profile update 637 + func TestUpdateProfile_Success(t *testing.T) { 638 + mockRepo := new(MockUserRepository) 639 + mockResolver := new(MockIdentityResolver) 640 + 641 + testDID := "did:plc:updateuser" 642 + displayName := "Updated Name" 643 + bio := "Updated bio" 644 + avatarCID := "bafkreinewavatar" 645 + bannerCID := "bafkreinewbanner" 646 + 647 + updatedUser := &User{ 648 + DID: testDID, 649 + Handle: "updateuser.test", 650 + PDSURL: "https://test.pds", 651 + DisplayName: displayName, 652 + Bio: bio, 653 + AvatarCID: avatarCID, 654 + BannerCID: bannerCID, 655 + CreatedAt: time.Now(), 656 + UpdatedAt: time.Now(), 657 + } 658 + 659 + input := UpdateProfileInput{ 660 + DisplayName: &displayName, 661 + Bio: &bio, 662 + AvatarCID: &avatarCID, 663 + BannerCID: &bannerCID, 664 + } 665 + mockRepo.On("UpdateProfile", mock.Anything, testDID, input).Return(updatedUser, nil) 666 + 667 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 668 + ctx := context.Background() 669 + 670 + user, err := service.UpdateProfile(ctx, testDID, input) 671 + require.NoError(t, err) 672 + 673 + assert.Equal(t, displayName, user.DisplayName) 674 + assert.Equal(t, bio, user.Bio) 675 + assert.Equal(t, avatarCID, user.AvatarCID) 676 + assert.Equal(t, bannerCID, user.BannerCID) 677 + 678 + mockRepo.AssertExpectations(t) 679 + } 680 + 681 + // TestUpdateProfile_PartialUpdate tests updating only some fields 682 + func TestUpdateProfile_PartialUpdate(t *testing.T) { 683 + mockRepo := new(MockUserRepository) 684 + mockResolver := new(MockIdentityResolver) 685 + 686 + testDID := "did:plc:partialupdate" 687 + displayName := "Partial Update Name" 688 + // Other fields are nil (don't change) 689 + 690 + updatedUser := &User{ 691 + DID: testDID, 692 + Handle: "partialupdate.test", 693 + PDSURL: "https://test.pds", 694 + DisplayName: displayName, 695 + Bio: "existing bio", 696 + AvatarCID: "existingavatar", 697 + BannerCID: "existingbanner", 698 + CreatedAt: time.Now(), 699 + UpdatedAt: time.Now(), 700 + } 701 + 702 + // Only displayName is provided, others are nil 703 + input := UpdateProfileInput{ 704 + DisplayName: &displayName, 705 + } 706 + mockRepo.On("UpdateProfile", mock.Anything, testDID, input).Return(updatedUser, nil) 707 + 708 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 709 + ctx := context.Background() 710 + 711 + user, err := service.UpdateProfile(ctx, testDID, input) 712 + require.NoError(t, err) 713 + 714 + assert.Equal(t, displayName, user.DisplayName) 715 + // Existing values should be preserved 716 + assert.Equal(t, "existing bio", user.Bio) 717 + assert.Equal(t, "existingavatar", user.AvatarCID) 718 + 719 + mockRepo.AssertExpectations(t) 720 + } 721 + 722 + // TestUpdateProfile_ClearFields tests clearing fields with empty strings 723 + func TestUpdateProfile_ClearFields(t *testing.T) { 724 + mockRepo := new(MockUserRepository) 725 + mockResolver := new(MockIdentityResolver) 726 + 727 + testDID := "did:plc:clearfields" 728 + emptyDisplayName := "" 729 + emptyBio := "" 730 + 731 + updatedUser := &User{ 732 + DID: testDID, 733 + Handle: "clearfields.test", 734 + PDSURL: "https://test.pds", 735 + DisplayName: "", 736 + Bio: "", 737 + AvatarCID: "existingavatar", 738 + BannerCID: "existingbanner", 739 + CreatedAt: time.Now(), 740 + UpdatedAt: time.Now(), 741 + } 742 + 743 + input := UpdateProfileInput{ 744 + DisplayName: &emptyDisplayName, 745 + Bio: &emptyBio, 746 + } 747 + mockRepo.On("UpdateProfile", mock.Anything, testDID, input).Return(updatedUser, nil) 748 + 749 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 750 + ctx := context.Background() 751 + 752 + user, err := service.UpdateProfile(ctx, testDID, input) 753 + require.NoError(t, err) 754 + 755 + assert.Empty(t, user.DisplayName) 756 + assert.Empty(t, user.Bio) 757 + 758 + mockRepo.AssertExpectations(t) 759 + } 760 + 761 + // TestUpdateProfile_RepoError tests UpdateProfile returns error on repo failure 762 + func TestUpdateProfile_RepoError(t *testing.T) { 763 + mockRepo := new(MockUserRepository) 764 + mockResolver := new(MockIdentityResolver) 765 + 766 + testDID := "did:plc:erroruser" 767 + displayName := "Error User" 768 + 769 + input := UpdateProfileInput{ 770 + DisplayName: &displayName, 771 + } 772 + mockRepo.On("UpdateProfile", mock.Anything, testDID, input).Return(nil, errors.New("database error")) 773 + 774 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 775 + ctx := context.Background() 776 + 777 + _, err := service.UpdateProfile(ctx, testDID, input) 778 + assert.Error(t, err) 779 + assert.Contains(t, err.Error(), "database error") 780 + 781 + mockRepo.AssertExpectations(t) 782 + } 783 + 784 + // TestUpdateProfile_UserNotFound tests UpdateProfile with non-existent user 785 + func TestUpdateProfile_UserNotFound(t *testing.T) { 786 + mockRepo := new(MockUserRepository) 787 + mockResolver := new(MockIdentityResolver) 788 + 789 + testDID := "did:plc:notfound" 790 + displayName := "Not Found User" 791 + 792 + input := UpdateProfileInput{ 793 + DisplayName: &displayName, 794 + } 795 + mockRepo.On("UpdateProfile", mock.Anything, testDID, input).Return(nil, ErrUserNotFound) 796 + 797 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 798 + ctx := context.Background() 799 + 800 + _, err := service.UpdateProfile(ctx, testDID, input) 801 + assert.ErrorIs(t, err, ErrUserNotFound) 802 + 803 + mockRepo.AssertExpectations(t) 804 + } 805 + 806 + // TestUpdateProfile_EmptyDID tests UpdateProfile with empty DID 807 + func TestUpdateProfile_EmptyDID(t *testing.T) { 808 + mockRepo := new(MockUserRepository) 809 + mockResolver := new(MockIdentityResolver) 810 + 811 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 812 + ctx := context.Background() 813 + 814 + displayName := "Test Name" 815 + input := UpdateProfileInput{ 816 + DisplayName: &displayName, 817 + } 818 + _, err := service.UpdateProfile(ctx, "", input) 819 + assert.Error(t, err) 820 + assert.Contains(t, err.Error(), "DID is required") 821 + 822 + // Repo should not be called with empty DID 823 + mockRepo.AssertNotCalled(t, "UpdateProfile", mock.Anything, mock.Anything, mock.Anything) 824 + } 825 + 826 + // TestUpdateProfile_WhitespaceDID tests UpdateProfile with whitespace-only DID 827 + func TestUpdateProfile_WhitespaceDID(t *testing.T) { 828 + mockRepo := new(MockUserRepository) 829 + mockResolver := new(MockIdentityResolver) 830 + 831 + service := NewUserService(mockRepo, mockResolver, "https://default.pds") 832 + ctx := context.Background() 833 + 834 + displayName := "Test Name" 835 + input := UpdateProfileInput{ 836 + DisplayName: &displayName, 837 + } 838 + _, err := service.UpdateProfile(ctx, " ", input) 839 + assert.Error(t, err) 840 + assert.Contains(t, err.Error(), "DID is required") 841 + 842 + mockRepo.AssertNotCalled(t, "UpdateProfile", mock.Anything, mock.Anything, mock.Anything) 843 + }
+1091
internal/db/postgres/user_repo_test.go
··· 1 + package postgres 2 + 3 + import ( 4 + "Coves/internal/core/users" 5 + "context" 6 + "database/sql" 7 + "fmt" 8 + "os" 9 + "testing" 10 + "time" 11 + 12 + _ "github.com/lib/pq" 13 + "github.com/pressly/goose/v3" 14 + "github.com/stretchr/testify/assert" 15 + "github.com/stretchr/testify/require" 16 + ) 17 + 18 + // setupUserTestDB creates a test database connection and runs migrations 19 + func setupUserTestDB(t *testing.T) *sql.DB { 20 + dsn := os.Getenv("TEST_DATABASE_URL") 21 + if dsn == "" { 22 + dsn = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 23 + } 24 + 25 + db, err := sql.Open("postgres", dsn) 26 + require.NoError(t, err, "Failed to connect to test database") 27 + 28 + // Run migrations 29 + require.NoError(t, goose.Up(db, "../../db/migrations"), "Failed to run migrations") 30 + 31 + return db 32 + } 33 + 34 + // cleanupUserData removes all test data related to users 35 + func cleanupUserData(t *testing.T, db *sql.DB, did string) { 36 + // Clean up in reverse order of foreign key dependencies 37 + _, err := db.Exec("DELETE FROM votes WHERE voter_did = $1", did) 38 + require.NoError(t, err) 39 + 40 + _, err = db.Exec("DELETE FROM comments WHERE commenter_did = $1", did) 41 + require.NoError(t, err) 42 + 43 + _, err = db.Exec("DELETE FROM community_blocks WHERE user_did = $1", did) 44 + require.NoError(t, err) 45 + 46 + _, err = db.Exec("DELETE FROM community_memberships WHERE user_did = $1", did) 47 + require.NoError(t, err) 48 + 49 + _, err = db.Exec("DELETE FROM community_subscriptions WHERE user_did = $1", did) 50 + require.NoError(t, err) 51 + 52 + _, err = db.Exec("DELETE FROM oauth_requests WHERE did = $1", did) 53 + require.NoError(t, err) 54 + 55 + _, err = db.Exec("DELETE FROM oauth_sessions WHERE did = $1", did) 56 + require.NoError(t, err) 57 + 58 + // Posts are deleted by CASCADE when user is deleted 59 + _, err = db.Exec("DELETE FROM users WHERE did = $1", did) 60 + require.NoError(t, err) 61 + } 62 + 63 + // createTestCommunity creates a minimal test community for foreign key constraints 64 + func createTestCommunity(t *testing.T, db *sql.DB, did, handle, ownerDID string) { 65 + query := ` 66 + INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) 67 + VALUES ($1, $2, $3, $4, $4, $4, NOW()) 68 + ON CONFLICT (did) DO NOTHING 69 + ` 70 + _, err := db.Exec(query, did, handle, "Test Community", ownerDID) 71 + require.NoError(t, err, "Failed to create test community") 72 + } 73 + 74 + func TestUserRepo_Delete_Success(t *testing.T) { 75 + db := setupUserTestDB(t) 76 + defer func() { _ = db.Close() }() 77 + 78 + testDID := "did:plc:testdeleteuser123" 79 + testHandle := "testdeleteuser123.test" 80 + communityDID := "did:plc:testdeletecommunity" 81 + 82 + defer cleanupUserData(t, db, testDID) 83 + defer func() { 84 + // Cleanup community 85 + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) 86 + }() 87 + 88 + repo := NewUserRepository(db) 89 + ctx := context.Background() 90 + 91 + // Create test user 92 + user := &users.User{ 93 + DID: testDID, 94 + Handle: testHandle, 95 + PDSURL: "https://test.pds", 96 + } 97 + _, err := repo.Create(ctx, user) 98 + require.NoError(t, err) 99 + 100 + // Create test community (needed for subscriptions/memberships) 101 + createTestCommunity(t, db, communityDID, "c.testdeletecommunity", testDID) 102 + 103 + // Add related data to verify cascade deletion 104 + 105 + // 1. OAuth session 106 + _, err = db.Exec(` 107 + INSERT INTO oauth_sessions (did, handle, pds_url, access_token, refresh_token, dpop_private_jwk, auth_server_iss, expires_at, session_id) 108 + VALUES ($1, $2, $3, 'test_access', 'test_refresh', '{}', 'https://auth.test', NOW() + INTERVAL '1 day', 'test_session_id') 109 + `, testDID, testHandle, "https://test.pds") 110 + require.NoError(t, err) 111 + 112 + // 2. Community subscription 113 + _, err = db.Exec(` 114 + INSERT INTO community_subscriptions (user_did, community_did, record_uri, record_cid) 115 + VALUES ($1, $2, 'at://test/sub', 'bafytest') 116 + `, testDID, communityDID) 117 + require.NoError(t, err) 118 + 119 + // 3. Community membership 120 + _, err = db.Exec(` 121 + INSERT INTO community_memberships (user_did, community_did) 122 + VALUES ($1, $2) 123 + `, testDID, communityDID) 124 + require.NoError(t, err) 125 + 126 + // 4. Comment (no FK constraint) 127 + _, err = db.Exec(` 128 + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) 129 + VALUES ($1, 'bafycomment', 'rkey123', $2, 'at://test/post', 'bafyroot', 'at://test/post', 'bafyparent', 'Test comment', NOW()) 130 + `, "at://"+testDID+"/social.coves.community.comment/test123", testDID) 131 + require.NoError(t, err) 132 + 133 + // 5. Vote (no FK constraint) 134 + _, err = db.Exec(` 135 + INSERT INTO votes (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at) 136 + VALUES ($1, 'bafyvote', 'rkey456', $2, 'at://test/post', 'bafysubject', 'up', NOW()) 137 + `, "at://"+testDID+"/social.coves.feed.vote/test456", testDID) 138 + require.NoError(t, err) 139 + 140 + // Verify user exists before deletion 141 + _, err = repo.GetByDID(ctx, testDID) 142 + require.NoError(t, err) 143 + 144 + // Delete the user 145 + err = repo.Delete(ctx, testDID) 146 + assert.NoError(t, err) 147 + 148 + // Verify user is deleted 149 + _, err = repo.GetByDID(ctx, testDID) 150 + assert.ErrorIs(t, err, users.ErrUserNotFound) 151 + 152 + // Verify related data is cleaned up 153 + var count int 154 + 155 + // OAuth sessions should be deleted 156 + err = db.QueryRow("SELECT COUNT(*) FROM oauth_sessions WHERE did = $1", testDID).Scan(&count) 157 + require.NoError(t, err) 158 + assert.Equal(t, 0, count, "OAuth sessions should be deleted") 159 + 160 + // Community subscriptions should be deleted 161 + err = db.QueryRow("SELECT COUNT(*) FROM community_subscriptions WHERE user_did = $1", testDID).Scan(&count) 162 + require.NoError(t, err) 163 + assert.Equal(t, 0, count, "Community subscriptions should be deleted") 164 + 165 + // Community memberships should be deleted 166 + err = db.QueryRow("SELECT COUNT(*) FROM community_memberships WHERE user_did = $1", testDID).Scan(&count) 167 + require.NoError(t, err) 168 + assert.Equal(t, 0, count, "Community memberships should be deleted") 169 + 170 + // Comments should be deleted 171 + err = db.QueryRow("SELECT COUNT(*) FROM comments WHERE commenter_did = $1", testDID).Scan(&count) 172 + require.NoError(t, err) 173 + assert.Equal(t, 0, count, "Comments should be deleted") 174 + 175 + // Votes should be deleted (note: the delete happens through transaction, not FK) 176 + err = db.QueryRow("SELECT COUNT(*) FROM votes WHERE voter_did = $1", testDID).Scan(&count) 177 + require.NoError(t, err) 178 + assert.Equal(t, 0, count, "Votes should be deleted") 179 + } 180 + 181 + func TestUserRepo_Delete_NonExistentUser(t *testing.T) { 182 + db := setupUserTestDB(t) 183 + defer func() { _ = db.Close() }() 184 + 185 + repo := NewUserRepository(db) 186 + ctx := context.Background() 187 + 188 + // Try to delete a user that doesn't exist 189 + err := repo.Delete(ctx, "did:plc:nonexistentuser999") 190 + assert.ErrorIs(t, err, users.ErrUserNotFound) 191 + } 192 + 193 + func TestUserRepo_Delete_InvalidDID(t *testing.T) { 194 + db := setupUserTestDB(t) 195 + defer func() { _ = db.Close() }() 196 + 197 + repo := NewUserRepository(db) 198 + ctx := context.Background() 199 + 200 + // Try to delete with invalid DID format 201 + err := repo.Delete(ctx, "invalid-did-format") 202 + assert.Error(t, err) 203 + assert.Contains(t, err.Error(), "must start with 'did:'") 204 + } 205 + 206 + func TestUserRepo_Delete_Idempotent(t *testing.T) { 207 + db := setupUserTestDB(t) 208 + defer func() { _ = db.Close() }() 209 + 210 + testDID := "did:plc:testdeletetwice" 211 + testHandle := "testdeletetwice.test" 212 + 213 + defer cleanupUserData(t, db, testDID) 214 + 215 + repo := NewUserRepository(db) 216 + ctx := context.Background() 217 + 218 + // Create test user 219 + user := &users.User{ 220 + DID: testDID, 221 + Handle: testHandle, 222 + PDSURL: "https://test.pds", 223 + } 224 + _, err := repo.Create(ctx, user) 225 + require.NoError(t, err) 226 + 227 + // Delete the user first time 228 + err = repo.Delete(ctx, testDID) 229 + assert.NoError(t, err) 230 + 231 + // Delete again - should return ErrUserNotFound (not crash) 232 + err = repo.Delete(ctx, testDID) 233 + assert.ErrorIs(t, err, users.ErrUserNotFound) 234 + } 235 + 236 + func TestUserRepo_Delete_WithPosts_CascadeDeletes(t *testing.T) { 237 + db := setupUserTestDB(t) 238 + defer func() { _ = db.Close() }() 239 + 240 + testDID := "did:plc:testdeletewithposts" 241 + testHandle := "testdeletewithposts.test" 242 + communityDID := "did:plc:testpostcommunity" 243 + 244 + defer cleanupUserData(t, db, testDID) 245 + defer func() { 246 + // Cleanup posts and community 247 + _, _ = db.Exec("DELETE FROM posts WHERE author_did = $1", testDID) 248 + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) 249 + }() 250 + 251 + repo := NewUserRepository(db) 252 + ctx := context.Background() 253 + 254 + // Create test user 255 + user := &users.User{ 256 + DID: testDID, 257 + Handle: testHandle, 258 + PDSURL: "https://test.pds", 259 + } 260 + _, err := repo.Create(ctx, user) 261 + require.NoError(t, err) 262 + 263 + // Create test community (needed for post FK) 264 + createTestCommunity(t, db, communityDID, "c.testpostcommunity", testDID) 265 + 266 + // Create post (has FK constraint with CASCADE delete) 267 + _, err = db.Exec(` 268 + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) 269 + VALUES ($1, 'bafypost', 'postkey', $2, $3, 'Test Post', NOW()) 270 + `, "at://"+communityDID+"/social.coves.community.post/testpost", testDID, communityDID) 271 + require.NoError(t, err) 272 + 273 + // Verify post exists 274 + var postCount int 275 + err = db.QueryRow("SELECT COUNT(*) FROM posts WHERE author_did = $1", testDID).Scan(&postCount) 276 + require.NoError(t, err) 277 + assert.Equal(t, 1, postCount) 278 + 279 + // Delete the user 280 + err = repo.Delete(ctx, testDID) 281 + assert.NoError(t, err) 282 + 283 + // Verify user is deleted 284 + _, err = repo.GetByDID(ctx, testDID) 285 + assert.ErrorIs(t, err, users.ErrUserNotFound) 286 + 287 + // Verify posts are cascade deleted (FK ON DELETE CASCADE) 288 + err = db.QueryRow("SELECT COUNT(*) FROM posts WHERE author_did = $1", testDID).Scan(&postCount) 289 + require.NoError(t, err) 290 + assert.Equal(t, 0, postCount, "Posts should be cascade deleted with user") 291 + } 292 + 293 + func TestUserRepo_Delete_TransactionRollback(t *testing.T) { 294 + // This test verifies that if any part of the deletion fails, 295 + // the entire transaction is rolled back and no partial deletions occur. 296 + // We can't easily simulate a failure in the middle of the transaction, 297 + // but we verify that the function properly handles the transaction. 298 + db := setupUserTestDB(t) 299 + defer func() { _ = db.Close() }() 300 + 301 + testDID := "did:plc:testtransaction" 302 + testHandle := "testtransaction.test" 303 + 304 + defer cleanupUserData(t, db, testDID) 305 + 306 + repo := NewUserRepository(db) 307 + ctx := context.Background() 308 + 309 + // Create test user 310 + user := &users.User{ 311 + DID: testDID, 312 + Handle: testHandle, 313 + PDSURL: "https://test.pds", 314 + } 315 + _, err := repo.Create(ctx, user) 316 + require.NoError(t, err) 317 + 318 + // Create a cancelled context to simulate a failure 319 + cancelledCtx, cancel := context.WithCancel(ctx) 320 + cancel() // Cancel immediately 321 + 322 + // Try to delete with cancelled context 323 + err = repo.Delete(cancelledCtx, testDID) 324 + assert.Error(t, err, "Should fail with cancelled context") 325 + 326 + // Verify user still exists (transaction was rolled back) 327 + _, err = repo.GetByDID(ctx, testDID) 328 + assert.NoError(t, err, "User should still exist after failed deletion") 329 + } 330 + 331 + func TestUserRepo_Create(t *testing.T) { 332 + db := setupUserTestDB(t) 333 + defer func() { _ = db.Close() }() 334 + 335 + testDID := "did:plc:testcreateuser" 336 + testHandle := "testcreateuser.test" 337 + 338 + defer cleanupUserData(t, db, testDID) 339 + 340 + repo := NewUserRepository(db) 341 + ctx := context.Background() 342 + 343 + user := &users.User{ 344 + DID: testDID, 345 + Handle: testHandle, 346 + PDSURL: "https://test.pds", 347 + } 348 + 349 + created, err := repo.Create(ctx, user) 350 + assert.NoError(t, err) 351 + assert.Equal(t, testDID, created.DID) 352 + assert.Equal(t, testHandle, created.Handle) 353 + assert.NotZero(t, created.CreatedAt) 354 + } 355 + 356 + func TestUserRepo_Create_DuplicateDID(t *testing.T) { 357 + db := setupUserTestDB(t) 358 + defer func() { _ = db.Close() }() 359 + 360 + testDID := "did:plc:testduplicatedid" 361 + testHandle := "testduplicatedid.test" 362 + 363 + defer cleanupUserData(t, db, testDID) 364 + 365 + repo := NewUserRepository(db) 366 + ctx := context.Background() 367 + 368 + user := &users.User{ 369 + DID: testDID, 370 + Handle: testHandle, 371 + PDSURL: "https://test.pds", 372 + } 373 + 374 + // Create first time 375 + _, err := repo.Create(ctx, user) 376 + require.NoError(t, err) 377 + 378 + // Try to create again with same DID 379 + user2 := &users.User{ 380 + DID: testDID, 381 + Handle: "different.handle.test", 382 + PDSURL: "https://test.pds", 383 + } 384 + 385 + _, err = repo.Create(ctx, user2) 386 + assert.Error(t, err) 387 + assert.Contains(t, err.Error(), "user with DID already exists") 388 + } 389 + 390 + func TestUserRepo_GetByDID(t *testing.T) { 391 + db := setupUserTestDB(t) 392 + defer func() { _ = db.Close() }() 393 + 394 + testDID := "did:plc:testgetbydid" 395 + testHandle := "testgetbydid.test" 396 + 397 + defer cleanupUserData(t, db, testDID) 398 + 399 + repo := NewUserRepository(db) 400 + ctx := context.Background() 401 + 402 + // Create user first 403 + user := &users.User{ 404 + DID: testDID, 405 + Handle: testHandle, 406 + PDSURL: "https://test.pds", 407 + } 408 + _, err := repo.Create(ctx, user) 409 + require.NoError(t, err) 410 + 411 + // Get by DID 412 + retrieved, err := repo.GetByDID(ctx, testDID) 413 + assert.NoError(t, err) 414 + assert.Equal(t, testDID, retrieved.DID) 415 + assert.Equal(t, testHandle, retrieved.Handle) 416 + } 417 + 418 + func TestUserRepo_GetByDID_NotFound(t *testing.T) { 419 + db := setupUserTestDB(t) 420 + defer func() { _ = db.Close() }() 421 + 422 + repo := NewUserRepository(db) 423 + ctx := context.Background() 424 + 425 + _, err := repo.GetByDID(ctx, "did:plc:nonexistent") 426 + assert.ErrorIs(t, err, users.ErrUserNotFound) 427 + } 428 + 429 + func TestUserRepo_GetByHandle(t *testing.T) { 430 + db := setupUserTestDB(t) 431 + defer func() { _ = db.Close() }() 432 + 433 + testDID := "did:plc:testgetbyhandle" 434 + testHandle := "testgetbyhandle.test" 435 + 436 + defer cleanupUserData(t, db, testDID) 437 + 438 + repo := NewUserRepository(db) 439 + ctx := context.Background() 440 + 441 + // Create user first 442 + user := &users.User{ 443 + DID: testDID, 444 + Handle: testHandle, 445 + PDSURL: "https://test.pds", 446 + } 447 + _, err := repo.Create(ctx, user) 448 + require.NoError(t, err) 449 + 450 + // Get by handle 451 + retrieved, err := repo.GetByHandle(ctx, testHandle) 452 + assert.NoError(t, err) 453 + assert.Equal(t, testDID, retrieved.DID) 454 + assert.Equal(t, testHandle, retrieved.Handle) 455 + } 456 + 457 + func TestUserRepo_UpdateHandle(t *testing.T) { 458 + db := setupUserTestDB(t) 459 + defer func() { _ = db.Close() }() 460 + 461 + testDID := "did:plc:testupdatehandle" 462 + oldHandle := "testupdatehandle.test" 463 + newHandle := "newhandle.test" 464 + 465 + defer cleanupUserData(t, db, testDID) 466 + 467 + repo := NewUserRepository(db) 468 + ctx := context.Background() 469 + 470 + // Create user first 471 + user := &users.User{ 472 + DID: testDID, 473 + Handle: oldHandle, 474 + PDSURL: "https://test.pds", 475 + } 476 + _, err := repo.Create(ctx, user) 477 + require.NoError(t, err) 478 + 479 + // Update handle 480 + updated, err := repo.UpdateHandle(ctx, testDID, newHandle) 481 + assert.NoError(t, err) 482 + assert.Equal(t, newHandle, updated.Handle) 483 + 484 + // Verify by fetching again 485 + retrieved, err := repo.GetByDID(ctx, testDID) 486 + assert.NoError(t, err) 487 + assert.Equal(t, newHandle, retrieved.Handle) 488 + } 489 + 490 + func TestUserRepo_GetProfileStats(t *testing.T) { 491 + db := setupUserTestDB(t) 492 + defer func() { _ = db.Close() }() 493 + 494 + testDID := "did:plc:testprofilestats" 495 + testHandle := "testprofilestats.test" 496 + communityDID := "did:plc:teststatscommunity" 497 + 498 + defer cleanupUserData(t, db, testDID) 499 + defer func() { 500 + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) 501 + }() 502 + 503 + repo := NewUserRepository(db) 504 + ctx := context.Background() 505 + 506 + // Create user first 507 + user := &users.User{ 508 + DID: testDID, 509 + Handle: testHandle, 510 + PDSURL: "https://test.pds", 511 + } 512 + _, err := repo.Create(ctx, user) 513 + require.NoError(t, err) 514 + 515 + // Create test community 516 + createTestCommunity(t, db, communityDID, "c.teststatscommunity", testDID) 517 + 518 + // Add subscription 519 + _, err = db.Exec(` 520 + INSERT INTO community_subscriptions (user_did, community_did, record_uri, record_cid) 521 + VALUES ($1, $2, 'at://test/sub', 'bafytest') 522 + `, testDID, communityDID) 523 + require.NoError(t, err) 524 + 525 + // Add membership 526 + _, err = db.Exec(` 527 + INSERT INTO community_memberships (user_did, community_did, reputation_score) 528 + VALUES ($1, $2, 100) 529 + `, testDID, communityDID) 530 + require.NoError(t, err) 531 + 532 + // Add post 533 + _, err = db.Exec(` 534 + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) 535 + VALUES ($1, 'bafystatpost', 'statpostkey', $2, $3, 'Stats Test Post', NOW()) 536 + `, "at://"+communityDID+"/social.coves.community.post/statspost", testDID, communityDID) 537 + require.NoError(t, err) 538 + 539 + // Add comment 540 + _, err = db.Exec(` 541 + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) 542 + VALUES ($1, 'bafystatcomment', 'statcommentkey', $2, 'at://test/post', 'bafyroot', 'at://test/post', 'bafyparent', 'Stats Test Comment', NOW()) 543 + `, "at://"+testDID+"/social.coves.community.comment/statscomment", testDID) 544 + require.NoError(t, err) 545 + 546 + // Get profile stats 547 + stats, err := repo.GetProfileStats(ctx, testDID) 548 + assert.NoError(t, err) 549 + assert.Equal(t, 1, stats.PostCount) 550 + assert.Equal(t, 1, stats.CommentCount) 551 + assert.Equal(t, 1, stats.CommunityCount) 552 + assert.Equal(t, 1, stats.MembershipCount) 553 + assert.Equal(t, 100, stats.Reputation) 554 + } 555 + 556 + func TestUserRepo_Delete_WithOAuthRequests(t *testing.T) { 557 + db := setupUserTestDB(t) 558 + defer func() { _ = db.Close() }() 559 + 560 + testDID := "did:plc:testoauthrequests" 561 + testHandle := "testoauthrequests.test" 562 + 563 + defer cleanupUserData(t, db, testDID) 564 + 565 + repo := NewUserRepository(db) 566 + ctx := context.Background() 567 + 568 + // Create test user 569 + user := &users.User{ 570 + DID: testDID, 571 + Handle: testHandle, 572 + PDSURL: "https://test.pds", 573 + } 574 + _, err := repo.Create(ctx, user) 575 + require.NoError(t, err) 576 + 577 + // Add OAuth request (pending authorization) 578 + _, err = db.Exec(` 579 + INSERT INTO oauth_requests (state, did, handle, pds_url, pkce_verifier, dpop_private_jwk, auth_server_iss) 580 + VALUES ($1, $2, $3, $4, 'verifier', '{}', 'https://auth.test') 581 + `, "test_state_"+testDID, testDID, testHandle, "https://test.pds") 582 + require.NoError(t, err) 583 + 584 + // Delete the user 585 + err = repo.Delete(ctx, testDID) 586 + assert.NoError(t, err) 587 + 588 + // Verify OAuth requests are deleted 589 + var count int 590 + err = db.QueryRow("SELECT COUNT(*) FROM oauth_requests WHERE did = $1", testDID).Scan(&count) 591 + require.NoError(t, err) 592 + assert.Equal(t, 0, count, "OAuth requests should be deleted") 593 + } 594 + 595 + func TestUserRepo_Delete_WithCommunityBlocks(t *testing.T) { 596 + db := setupUserTestDB(t) 597 + defer func() { _ = db.Close() }() 598 + 599 + testDID := "did:plc:testcommunityblocks" 600 + testHandle := "testcommunityblocks.test" 601 + communityDID := "did:plc:testblockcommunity" 602 + 603 + defer cleanupUserData(t, db, testDID) 604 + defer func() { 605 + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) 606 + }() 607 + 608 + repo := NewUserRepository(db) 609 + ctx := context.Background() 610 + 611 + // Create test user 612 + user := &users.User{ 613 + DID: testDID, 614 + Handle: testHandle, 615 + PDSURL: "https://test.pds", 616 + } 617 + _, err := repo.Create(ctx, user) 618 + require.NoError(t, err) 619 + 620 + // Create test community 621 + createTestCommunity(t, db, communityDID, "c.testblockcommunity", testDID) 622 + 623 + // Add community block 624 + _, err = db.Exec(` 625 + INSERT INTO community_blocks (user_did, community_did, record_uri, record_cid) 626 + VALUES ($1, $2, 'at://test/block', 'bafyblock') 627 + `, testDID, communityDID) 628 + require.NoError(t, err) 629 + 630 + // Delete the user 631 + err = repo.Delete(ctx, testDID) 632 + assert.NoError(t, err) 633 + 634 + // Verify community blocks are deleted 635 + var count int 636 + err = db.QueryRow("SELECT COUNT(*) FROM community_blocks WHERE user_did = $1", testDID).Scan(&count) 637 + require.NoError(t, err) 638 + assert.Equal(t, 0, count, "Community blocks should be deleted") 639 + } 640 + 641 + func TestUserRepo_Delete_TimingPerformance(t *testing.T) { 642 + // This test ensures deletion completes in a reasonable time 643 + // even with multiple related records 644 + db := setupUserTestDB(t) 645 + defer func() { _ = db.Close() }() 646 + 647 + testDID := "did:plc:testperformance" 648 + testHandle := "testperformance.test" 649 + communityDID := "did:plc:testperfcommunity" 650 + 651 + // Clean up any leftover data from previous test runs 652 + cleanupUserData(t, db, testDID) 653 + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) 654 + 655 + defer cleanupUserData(t, db, testDID) 656 + defer func() { 657 + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) 658 + }() 659 + 660 + repo := NewUserRepository(db) 661 + ctx := context.Background() 662 + 663 + // Create test user 664 + user := &users.User{ 665 + DID: testDID, 666 + Handle: testHandle, 667 + PDSURL: "https://test.pds", 668 + } 669 + _, err := repo.Create(ctx, user) 670 + require.NoError(t, err) 671 + 672 + // Create test community 673 + createTestCommunity(t, db, communityDID, "c.testperfcommunity", testDID) 674 + 675 + // Add multiple comments 676 + for i := 0; i < 10; i++ { 677 + _, err = db.Exec(` 678 + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) 679 + VALUES ($1, $2, $3, $4, 'at://test/post', 'bafyroot', 'at://test/post', 'bafyparent', 'Test comment', NOW()) 680 + `, "at://"+testDID+"/social.coves.community.comment/perf"+string(rune('0'+i)), "bafyperf"+string(rune('0'+i)), "perfkey"+string(rune('0'+i)), testDID) 681 + require.NoError(t, err) 682 + } 683 + 684 + // Add multiple votes (each must have unique subject_uri due to unique_voter_subject_active constraint) 685 + for i := 0; i < 10; i++ { 686 + subjectURI := fmt.Sprintf("at://test/post/perf%d", i) 687 + _, err = db.Exec(` 688 + INSERT INTO votes (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at) 689 + VALUES ($1, $2, $3, $4, $5, 'bafysubject', 'up', NOW()) 690 + `, "at://"+testDID+"/social.coves.feed.vote/perf"+string(rune('0'+i)), "bafyvoteperf"+string(rune('0'+i)), "voteperfkey"+string(rune('0'+i)), testDID, subjectURI) 691 + require.NoError(t, err) 692 + } 693 + 694 + // Time the deletion 695 + start := time.Now() 696 + err = repo.Delete(ctx, testDID) 697 + elapsed := time.Since(start) 698 + 699 + assert.NoError(t, err) 700 + assert.Less(t, elapsed, 5*time.Second, "Deletion should complete in under 5 seconds") 701 + 702 + t.Logf("Deletion of user with %d comments and %d votes took %v", 10, 10, elapsed) 703 + } 704 + 705 + // ============================================================================ 706 + // Profile Update Tests (Phase 2: User Profile Avatar & Banner) 707 + // ============================================================================ 708 + 709 + // stringPtr returns a pointer to the provided string (helper for optional params) 710 + func stringPtr(s string) *string { 711 + return &s 712 + } 713 + 714 + func TestUserRepo_UpdateProfile(t *testing.T) { 715 + db := setupUserTestDB(t) 716 + defer func() { _ = db.Close() }() 717 + 718 + testDID := "did:plc:testupdateprofile" 719 + testHandle := "testupdateprofile.test" 720 + 721 + defer cleanupUserData(t, db, testDID) 722 + 723 + repo := NewUserRepository(db) 724 + ctx := context.Background() 725 + 726 + // Create user first 727 + user := &users.User{ 728 + DID: testDID, 729 + Handle: testHandle, 730 + PDSURL: "https://test.pds", 731 + } 732 + _, err := repo.Create(ctx, user) 733 + require.NoError(t, err) 734 + 735 + // Update profile with all fields 736 + displayName := "Test User" 737 + bio := "A test user biography" 738 + avatarCID := "bafyavatarcid123" 739 + bannerCID := "bafybannercid456" 740 + 741 + input := users.UpdateProfileInput{ 742 + DisplayName: &displayName, 743 + Bio: &bio, 744 + AvatarCID: &avatarCID, 745 + BannerCID: &bannerCID, 746 + } 747 + updated, err := repo.UpdateProfile(ctx, testDID, input) 748 + assert.NoError(t, err) 749 + require.NotNil(t, updated) 750 + 751 + // Verify all fields were updated 752 + assert.Equal(t, testDID, updated.DID) 753 + assert.Equal(t, testHandle, updated.Handle) 754 + assert.Equal(t, displayName, updated.DisplayName) 755 + assert.Equal(t, bio, updated.Bio) 756 + assert.Equal(t, avatarCID, updated.AvatarCID) 757 + assert.Equal(t, bannerCID, updated.BannerCID) 758 + } 759 + 760 + func TestUserRepo_UpdateProfile_PartialUpdate(t *testing.T) { 761 + db := setupUserTestDB(t) 762 + defer func() { _ = db.Close() }() 763 + 764 + testDID := "did:plc:testpartialupdate" 765 + testHandle := "testpartialupdate.test" 766 + 767 + defer cleanupUserData(t, db, testDID) 768 + 769 + repo := NewUserRepository(db) 770 + ctx := context.Background() 771 + 772 + // Create user first 773 + user := &users.User{ 774 + DID: testDID, 775 + Handle: testHandle, 776 + PDSURL: "https://test.pds", 777 + } 778 + _, err := repo.Create(ctx, user) 779 + require.NoError(t, err) 780 + 781 + // First update: set display name and avatar 782 + displayName := "Initial Name" 783 + avatarCID := "bafyinitialavatar" 784 + input1 := users.UpdateProfileInput{ 785 + DisplayName: &displayName, 786 + AvatarCID: &avatarCID, 787 + } 788 + _, err = repo.UpdateProfile(ctx, testDID, input1) 789 + require.NoError(t, err) 790 + 791 + // Second update: only update bio (leave other fields alone) 792 + bio := "New bio text" 793 + input2 := users.UpdateProfileInput{ 794 + Bio: &bio, 795 + } 796 + updated, err := repo.UpdateProfile(ctx, testDID, input2) 797 + assert.NoError(t, err) 798 + require.NotNil(t, updated) 799 + 800 + // Verify bio was updated 801 + assert.Equal(t, bio, updated.Bio) 802 + 803 + // Verify previous values are preserved (nil means "don't change") 804 + assert.Equal(t, displayName, updated.DisplayName) 805 + assert.Equal(t, avatarCID, updated.AvatarCID) 806 + assert.Empty(t, updated.BannerCID) // Was never set 807 + } 808 + 809 + func TestUserRepo_UpdateProfile_ReturnsUpdatedUser(t *testing.T) { 810 + db := setupUserTestDB(t) 811 + defer func() { _ = db.Close() }() 812 + 813 + testDID := "did:plc:testreturnsupdated" 814 + testHandle := "testreturnsupdated.test" 815 + 816 + defer cleanupUserData(t, db, testDID) 817 + 818 + repo := NewUserRepository(db) 819 + ctx := context.Background() 820 + 821 + // Create user first 822 + user := &users.User{ 823 + DID: testDID, 824 + Handle: testHandle, 825 + PDSURL: "https://test.pds", 826 + } 827 + created, err := repo.Create(ctx, user) 828 + require.NoError(t, err) 829 + 830 + // Update profile 831 + displayName := "Updated Name" 832 + input := users.UpdateProfileInput{ 833 + DisplayName: &displayName, 834 + } 835 + updated, err := repo.UpdateProfile(ctx, testDID, input) 836 + assert.NoError(t, err) 837 + require.NotNil(t, updated) 838 + 839 + // Verify the returned user has all core fields populated 840 + assert.Equal(t, testDID, updated.DID) 841 + assert.Equal(t, testHandle, updated.Handle) 842 + assert.Equal(t, "https://test.pds", updated.PDSURL) 843 + assert.Equal(t, displayName, updated.DisplayName) 844 + assert.NotZero(t, updated.CreatedAt) 845 + assert.NotZero(t, updated.UpdatedAt) 846 + 847 + // UpdatedAt should be after CreatedAt (or equal if very fast) 848 + assert.True(t, updated.UpdatedAt.After(created.CreatedAt) || updated.UpdatedAt.Equal(created.CreatedAt)) 849 + } 850 + 851 + func TestUserRepo_UpdateProfile_UserNotFound(t *testing.T) { 852 + db := setupUserTestDB(t) 853 + defer func() { _ = db.Close() }() 854 + 855 + repo := NewUserRepository(db) 856 + ctx := context.Background() 857 + 858 + // Try to update a non-existent user 859 + displayName := "Ghost User" 860 + input := users.UpdateProfileInput{ 861 + DisplayName: &displayName, 862 + } 863 + _, err := repo.UpdateProfile(ctx, "did:plc:nonexistentuserprofile", input) 864 + assert.ErrorIs(t, err, users.ErrUserNotFound) 865 + } 866 + 867 + func TestUserRepo_UpdateProfile_ClearFields(t *testing.T) { 868 + db := setupUserTestDB(t) 869 + defer func() { _ = db.Close() }() 870 + 871 + testDID := "did:plc:testclearfields" 872 + testHandle := "testclearfields.test" 873 + 874 + defer cleanupUserData(t, db, testDID) 875 + 876 + repo := NewUserRepository(db) 877 + ctx := context.Background() 878 + 879 + // Create user first 880 + user := &users.User{ 881 + DID: testDID, 882 + Handle: testHandle, 883 + PDSURL: "https://test.pds", 884 + } 885 + _, err := repo.Create(ctx, user) 886 + require.NoError(t, err) 887 + 888 + // Set profile fields 889 + displayName := "Has Name" 890 + bio := "Has Bio" 891 + avatarCID := "bafyhasavatar" 892 + input1 := users.UpdateProfileInput{ 893 + DisplayName: &displayName, 894 + Bio: &bio, 895 + AvatarCID: &avatarCID, 896 + } 897 + _, err = repo.UpdateProfile(ctx, testDID, input1) 898 + require.NoError(t, err) 899 + 900 + // Clear display name by passing empty string 901 + emptyName := "" 902 + input2 := users.UpdateProfileInput{ 903 + DisplayName: &emptyName, 904 + } 905 + updated, err := repo.UpdateProfile(ctx, testDID, input2) 906 + assert.NoError(t, err) 907 + require.NotNil(t, updated) 908 + 909 + // Verify display name was cleared 910 + assert.Empty(t, updated.DisplayName) 911 + // Other fields should remain 912 + assert.Equal(t, bio, updated.Bio) 913 + assert.Equal(t, avatarCID, updated.AvatarCID) 914 + } 915 + 916 + func TestUserRepo_GetByDID_ReturnsNewFields(t *testing.T) { 917 + db := setupUserTestDB(t) 918 + defer func() { _ = db.Close() }() 919 + 920 + testDID := "did:plc:testgetbydidnewfields" 921 + testHandle := "testgetbydidnewfields.test" 922 + 923 + defer cleanupUserData(t, db, testDID) 924 + 925 + repo := NewUserRepository(db) 926 + ctx := context.Background() 927 + 928 + // Create user first 929 + user := &users.User{ 930 + DID: testDID, 931 + Handle: testHandle, 932 + PDSURL: "https://test.pds", 933 + } 934 + _, err := repo.Create(ctx, user) 935 + require.NoError(t, err) 936 + 937 + // Update profile with all fields 938 + displayName := "Profile Name" 939 + bio := "Profile bio for testing" 940 + avatarCID := "bafyprofileavatar" 941 + bannerCID := "bafyprofilebanner" 942 + input := users.UpdateProfileInput{ 943 + DisplayName: &displayName, 944 + Bio: &bio, 945 + AvatarCID: &avatarCID, 946 + BannerCID: &bannerCID, 947 + } 948 + _, err = repo.UpdateProfile(ctx, testDID, input) 949 + require.NoError(t, err) 950 + 951 + // Retrieve user with GetByDID 952 + retrieved, err := repo.GetByDID(ctx, testDID) 953 + assert.NoError(t, err) 954 + require.NotNil(t, retrieved) 955 + 956 + // Verify all profile fields are returned 957 + assert.Equal(t, testDID, retrieved.DID) 958 + assert.Equal(t, testHandle, retrieved.Handle) 959 + assert.Equal(t, displayName, retrieved.DisplayName) 960 + assert.Equal(t, bio, retrieved.Bio) 961 + assert.Equal(t, avatarCID, retrieved.AvatarCID) 962 + assert.Equal(t, bannerCID, retrieved.BannerCID) 963 + } 964 + 965 + func TestUserRepo_GetByHandle_ReturnsNewFields(t *testing.T) { 966 + db := setupUserTestDB(t) 967 + defer func() { _ = db.Close() }() 968 + 969 + testDID := "did:plc:testgetbyhandlenewfields" 970 + testHandle := "testgetbyhandlenewfields.test" 971 + 972 + defer cleanupUserData(t, db, testDID) 973 + 974 + repo := NewUserRepository(db) 975 + ctx := context.Background() 976 + 977 + // Create user first 978 + user := &users.User{ 979 + DID: testDID, 980 + Handle: testHandle, 981 + PDSURL: "https://test.pds", 982 + } 983 + _, err := repo.Create(ctx, user) 984 + require.NoError(t, err) 985 + 986 + // Update profile with all fields 987 + displayName := "Handle Test Name" 988 + bio := "Handle test bio" 989 + avatarCID := "bafyhandleavatar" 990 + bannerCID := "bafyhandlebanner" 991 + input := users.UpdateProfileInput{ 992 + DisplayName: &displayName, 993 + Bio: &bio, 994 + AvatarCID: &avatarCID, 995 + BannerCID: &bannerCID, 996 + } 997 + _, err = repo.UpdateProfile(ctx, testDID, input) 998 + require.NoError(t, err) 999 + 1000 + // Retrieve user with GetByHandle 1001 + retrieved, err := repo.GetByHandle(ctx, testHandle) 1002 + assert.NoError(t, err) 1003 + require.NotNil(t, retrieved) 1004 + 1005 + // Verify all profile fields are returned 1006 + assert.Equal(t, testDID, retrieved.DID) 1007 + assert.Equal(t, testHandle, retrieved.Handle) 1008 + assert.Equal(t, displayName, retrieved.DisplayName) 1009 + assert.Equal(t, bio, retrieved.Bio) 1010 + assert.Equal(t, avatarCID, retrieved.AvatarCID) 1011 + assert.Equal(t, bannerCID, retrieved.BannerCID) 1012 + } 1013 + 1014 + func TestUpdateProfile_InvalidDID(t *testing.T) { 1015 + db := setupUserTestDB(t) 1016 + defer func() { _ = db.Close() }() 1017 + 1018 + repo := NewUserRepository(db) 1019 + ctx := context.Background() 1020 + 1021 + displayName := "Test" 1022 + input := users.UpdateProfileInput{DisplayName: &displayName} 1023 + 1024 + _, err := repo.UpdateProfile(ctx, "invalid-did", input) 1025 + 1026 + require.Error(t, err) 1027 + var didErr *users.InvalidDIDError 1028 + require.ErrorAs(t, err, &didErr) 1029 + assert.Equal(t, "invalid-did", didErr.DID) 1030 + } 1031 + 1032 + func TestUserRepo_GetByDIDs_ReturnsNewFields(t *testing.T) { 1033 + db := setupUserTestDB(t) 1034 + defer func() { _ = db.Close() }() 1035 + 1036 + testDID1 := "did:plc:testgetbydidsbatch1" 1037 + testHandle1 := "testgetbydidsbatch1.test" 1038 + testDID2 := "did:plc:testgetbydidsbatch2" 1039 + testHandle2 := "testgetbydidsbatch2.test" 1040 + 1041 + defer cleanupUserData(t, db, testDID1) 1042 + defer cleanupUserData(t, db, testDID2) 1043 + 1044 + repo := NewUserRepository(db) 1045 + ctx := context.Background() 1046 + 1047 + // Create users 1048 + user1 := &users.User{DID: testDID1, Handle: testHandle1, PDSURL: "https://test.pds"} 1049 + user2 := &users.User{DID: testDID2, Handle: testHandle2, PDSURL: "https://test.pds"} 1050 + _, err := repo.Create(ctx, user1) 1051 + require.NoError(t, err) 1052 + _, err = repo.Create(ctx, user2) 1053 + require.NoError(t, err) 1054 + 1055 + // Update profiles 1056 + displayName1 := "Batch User 1" 1057 + avatarCID1 := "bafybatchavatar1" 1058 + displayName2 := "Batch User 2" 1059 + bio2 := "Batch user 2 bio" 1060 + input1 := users.UpdateProfileInput{ 1061 + DisplayName: &displayName1, 1062 + AvatarCID: &avatarCID1, 1063 + } 1064 + _, err = repo.UpdateProfile(ctx, testDID1, input1) 1065 + require.NoError(t, err) 1066 + input2 := users.UpdateProfileInput{ 1067 + DisplayName: &displayName2, 1068 + Bio: &bio2, 1069 + } 1070 + _, err = repo.UpdateProfile(ctx, testDID2, input2) 1071 + require.NoError(t, err) 1072 + 1073 + // Retrieve with GetByDIDs 1074 + userMap, err := repo.GetByDIDs(ctx, []string{testDID1, testDID2}) 1075 + assert.NoError(t, err) 1076 + assert.Len(t, userMap, 2) 1077 + 1078 + // Verify user 1 1079 + u1 := userMap[testDID1] 1080 + require.NotNil(t, u1) 1081 + assert.Equal(t, displayName1, u1.DisplayName) 1082 + assert.Equal(t, avatarCID1, u1.AvatarCID) 1083 + assert.Empty(t, u1.Bio) 1084 + 1085 + // Verify user 2 1086 + u2 := userMap[testDID2] 1087 + require.NotNil(t, u2) 1088 + assert.Equal(t, displayName2, u2.DisplayName) 1089 + assert.Equal(t, bio2, u2.Bio) 1090 + assert.Empty(t, u2.AvatarCID) 1091 + }
+278 -22
tests/integration/user_test.go
··· 1 1 2 2 3 + import ( 4 + "Coves/internal/api/routes" 5 + "Coves/internal/atproto/identity" 6 + "Coves/internal/core/blobs" 7 + "Coves/internal/core/users" 8 + "Coves/internal/db/postgres" 9 + "context" 3 10 4 11 5 12 ··· 14 21 15 22 16 23 24 + "github.com/pressly/goose/v3" 25 + ) 17 26 27 + // stubBlobService is a minimal blob service implementation for tests that don't need it 28 + type stubBlobService struct{} 18 29 30 + func (s *stubBlobService) UploadBlobFromURL(ctx context.Context, owner blobs.BlobOwner, imageURL string) (*blobs.BlobRef, error) { 31 + return nil, fmt.Errorf("stub blob service: UploadBlobFromURL not implemented") 32 + } 19 33 34 + func (s *stubBlobService) UploadBlob(ctx context.Context, owner blobs.BlobOwner, data []byte, mimeType string) (*blobs.BlobRef, error) { 35 + return nil, fmt.Errorf("stub blob service: UploadBlob not implemented") 36 + } 20 37 38 + // TestMain controls test setup for the integration package. 39 + // Set LOG_ENABLED=false to suppress application log output during tests. 40 + func TestMain(m *testing.M) { 21 41 22 42 23 43 ··· 71 91 72 92 73 93 74 - 75 - 76 - 77 - 78 - 79 - 80 - 81 - 82 - 83 - 84 - 85 - 86 - 87 - 88 - 89 - 90 - 94 + // Clean up any existing test data (order matters due to FK constraints) 95 + // Delete subscriptions first (references communities and users) 96 + _, err = db.Exec("DELETE FROM community_subscriptions") 97 + if err != nil { 98 + t.Logf("Warning: Failed to clean up subscriptions: %v", err) 99 + } 100 + // Delete comments (references posts) 101 + _, err = db.Exec("DELETE FROM comments") 102 + if err != nil { 103 + t.Logf("Warning: Failed to clean up comments: %v", err) 104 + } 105 + // Delete posts (references communities) 106 + _, err = db.Exec("DELETE FROM posts") 107 + if err != nil { 91 108 92 109 93 110 ··· 217 234 t.Fatalf("Failed to create test user: %v", err) 218 235 } 219 236 220 - // Set up HTTP router 237 + // Set up HTTP router with auth middleware 221 238 r := chi.NewRouter() 222 - routes.RegisterUserRoutes(r, userService) 239 + authMiddleware, _ := CreateTestOAuthMiddleware("did:plc:testuser") 240 + routes.RegisterUserRoutes(r, userService, authMiddleware, &stubBlobService{}) 223 241 224 242 // Test 1: Get profile by DID 225 243 t.Run("Get Profile By DID", func(t *testing.T) { ··· 847 865 848 866 t.Run("HTTP endpoint returns 404 for non-existent DID", func(t *testing.T) { 849 867 r := chi.NewRouter() 850 - routes.RegisterUserRoutes(r, userService) 868 + authMiddleware, _ := CreateTestOAuthMiddleware("did:plc:testuser") 869 + routes.RegisterUserRoutes(r, userService, authMiddleware, &stubBlobService{}) 851 870 852 871 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor=did:plc:nonexistentuser12345", nil) 853 872 w := httptest.NewRecorder() ··· 894 913 t.Fatalf("Failed to create test user: %v", err) 895 914 } 896 915 897 - // Set up HTTP router 916 + // Set up HTTP router with auth middleware 898 917 r := chi.NewRouter() 899 - routes.RegisterUserRoutes(r, userService) 918 + authMiddleware, _ := CreateTestOAuthMiddleware("did:plc:testuser") 919 + routes.RegisterUserRoutes(r, userService, authMiddleware, &stubBlobService{}) 900 920 901 921 t.Run("Response includes stats object", func(t *testing.T) { 902 922 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor="+testDID, nil) ··· 1087 1107 }) 1088 1108 } 1089 1109 } 1110 + 1111 + // TestAccountDeletion_Integration tests the complete account deletion flow 1112 + // from handler โ†’ service โ†’ repository with a real database 1113 + func TestAccountDeletion_Integration(t *testing.T) { 1114 + db := setupTestDB(t) 1115 + defer func() { 1116 + if err := db.Close(); err != nil { 1117 + t.Logf("Failed to close database: %v", err) 1118 + } 1119 + }() 1120 + 1121 + uniqueSuffix := time.Now().UnixNano() 1122 + testDID := fmt.Sprintf("did:plc:deletetest%d", uniqueSuffix) 1123 + testHandle := fmt.Sprintf("deletetest%d.test", uniqueSuffix) 1124 + 1125 + // Wire up dependencies 1126 + userRepo := postgres.NewUserRepository(db) 1127 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 1128 + userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 1129 + 1130 + ctx := context.Background() 1131 + 1132 + // Create test user 1133 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 1134 + DID: testDID, 1135 + Handle: testHandle, 1136 + PDSURL: "http://localhost:3001", 1137 + }) 1138 + if err != nil { 1139 + t.Fatalf("Failed to create test user: %v", err) 1140 + } 1141 + 1142 + // Create test community for FK relationships 1143 + testCommunityDID := fmt.Sprintf("did:plc:deletetestcommunity%d", uniqueSuffix) 1144 + _, err = db.Exec(` 1145 + INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) 1146 + VALUES ($1, $2, 'Delete Test Community', 'did:plc:owner1', 'did:plc:owner1', 'did:plc:owner1', NOW()) 1147 + `, testCommunityDID, fmt.Sprintf("deletetestcommunity%d.test", uniqueSuffix)) 1148 + if err != nil { 1149 + t.Fatalf("Failed to insert test community: %v", err) 1150 + } 1151 + 1152 + // Create related data across all tables 1153 + t.Run("Setup test data", func(t *testing.T) { 1154 + // Posts 1155 + for i := 1; i <= 3; i++ { 1156 + _, err = db.Exec(` 1157 + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, content, created_at, indexed_at) 1158 + VALUES ($1, $2, $3, $4, $5, 'Test Post', 'Content', NOW(), NOW()) 1159 + `, fmt.Sprintf("at://%s/social.coves.post/delete%d", testDID, i), 1160 + fmt.Sprintf("deletecid%d", i), 1161 + fmt.Sprintf("delete%d", i), 1162 + testDID, 1163 + testCommunityDID) 1164 + if err != nil { 1165 + t.Fatalf("Failed to insert post %d: %v", i, err) 1166 + } 1167 + } 1168 + 1169 + // Community subscription 1170 + _, err = db.Exec(` 1171 + INSERT INTO community_subscriptions (user_did, community_did, subscribed_at) 1172 + VALUES ($1, $2, NOW()) 1173 + `, testDID, testCommunityDID) 1174 + if err != nil { 1175 + t.Fatalf("Failed to insert subscription: %v", err) 1176 + } 1177 + 1178 + // Community membership 1179 + _, err = db.Exec(` 1180 + INSERT INTO community_memberships (user_did, community_did, reputation_score, contribution_count, is_banned, is_moderator, joined_at, last_active_at) 1181 + VALUES ($1, $2, 100, 5, false, false, NOW(), NOW()) 1182 + `, testDID, testCommunityDID) 1183 + if err != nil { 1184 + t.Fatalf("Failed to insert membership: %v", err) 1185 + } 1186 + 1187 + // Vote (using one of the posts) 1188 + postURI := fmt.Sprintf("at://%s/social.coves.post/delete1", testDID) 1189 + _, err = db.Exec(` 1190 + INSERT INTO votes (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at) 1191 + VALUES ($1, 'votecid', 'vote1', $2, $3, 'postcid', 'up', NOW()) 1192 + `, fmt.Sprintf("at://%s/social.coves.vote/delete1", testDID), testDID, postURI) 1193 + if err != nil { 1194 + t.Fatalf("Failed to insert vote: %v", err) 1195 + } 1196 + 1197 + // Comments 1198 + postCID := "deletecid1" 1199 + for i := 1; i <= 2; i++ { 1200 + _, err = db.Exec(` 1201 + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at, indexed_at) 1202 + VALUES ($1, $2, $3, $4, $5, $6, $5, $6, 'Test comment', NOW(), NOW()) 1203 + `, fmt.Sprintf("at://%s/social.coves.comment/delete%d", testDID, i), 1204 + fmt.Sprintf("deletecommentcid%d", i), 1205 + fmt.Sprintf("deletecomment%d", i), 1206 + testDID, 1207 + postURI, 1208 + postCID) 1209 + if err != nil { 1210 + t.Fatalf("Failed to insert comment %d: %v", i, err) 1211 + } 1212 + } 1213 + }) 1214 + 1215 + // Verify data exists before deletion 1216 + t.Run("Verify data exists before deletion", func(t *testing.T) { 1217 + var count int 1218 + 1219 + // Check user exists 1220 + err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE did = $1`, testDID).Scan(&count) 1221 + if err != nil || count != 1 { 1222 + t.Fatalf("Expected 1 user, got %d (err: %v)", count, err) 1223 + } 1224 + 1225 + // Check posts exist 1226 + err = db.QueryRow(`SELECT COUNT(*) FROM posts WHERE author_did = $1`, testDID).Scan(&count) 1227 + if err != nil || count != 3 { 1228 + t.Fatalf("Expected 3 posts, got %d (err: %v)", count, err) 1229 + } 1230 + 1231 + // Check subscription exists 1232 + err = db.QueryRow(`SELECT COUNT(*) FROM community_subscriptions WHERE user_did = $1`, testDID).Scan(&count) 1233 + if err != nil || count != 1 { 1234 + t.Fatalf("Expected 1 subscription, got %d (err: %v)", count, err) 1235 + } 1236 + 1237 + // Check membership exists 1238 + err = db.QueryRow(`SELECT COUNT(*) FROM community_memberships WHERE user_did = $1`, testDID).Scan(&count) 1239 + if err != nil || count != 1 { 1240 + t.Fatalf("Expected 1 membership, got %d (err: %v)", count, err) 1241 + } 1242 + 1243 + // Check vote exists 1244 + err = db.QueryRow(`SELECT COUNT(*) FROM votes WHERE voter_did = $1`, testDID).Scan(&count) 1245 + if err != nil || count != 1 { 1246 + t.Fatalf("Expected 1 vote, got %d (err: %v)", count, err) 1247 + } 1248 + 1249 + // Check comments exist 1250 + err = db.QueryRow(`SELECT COUNT(*) FROM comments WHERE commenter_did = $1`, testDID).Scan(&count) 1251 + if err != nil || count != 2 { 1252 + t.Fatalf("Expected 2 comments, got %d (err: %v)", count, err) 1253 + } 1254 + }) 1255 + 1256 + // Delete account 1257 + t.Run("Delete account via service", func(t *testing.T) { 1258 + err := userService.DeleteAccount(ctx, testDID) 1259 + if err != nil { 1260 + t.Fatalf("Failed to delete account: %v", err) 1261 + } 1262 + }) 1263 + 1264 + // Verify all data is deleted 1265 + t.Run("Verify all data deleted after deletion", func(t *testing.T) { 1266 + var count int 1267 + 1268 + // Check user deleted 1269 + err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE did = $1`, testDID).Scan(&count) 1270 + if err != nil { 1271 + t.Fatalf("Error checking users: %v", err) 1272 + } 1273 + if count != 0 { 1274 + t.Errorf("Expected 0 users after deletion, got %d", count) 1275 + } 1276 + 1277 + // Check posts deleted (via FK CASCADE) 1278 + err = db.QueryRow(`SELECT COUNT(*) FROM posts WHERE author_did = $1`, testDID).Scan(&count) 1279 + if err != nil { 1280 + t.Fatalf("Error checking posts: %v", err) 1281 + } 1282 + if count != 0 { 1283 + t.Errorf("Expected 0 posts after deletion, got %d", count) 1284 + } 1285 + 1286 + // Check subscription deleted 1287 + err = db.QueryRow(`SELECT COUNT(*) FROM community_subscriptions WHERE user_did = $1`, testDID).Scan(&count) 1288 + if err != nil { 1289 + t.Fatalf("Error checking subscriptions: %v", err) 1290 + } 1291 + if count != 0 { 1292 + t.Errorf("Expected 0 subscriptions after deletion, got %d", count) 1293 + } 1294 + 1295 + // Check membership deleted 1296 + err = db.QueryRow(`SELECT COUNT(*) FROM community_memberships WHERE user_did = $1`, testDID).Scan(&count) 1297 + if err != nil { 1298 + t.Fatalf("Error checking memberships: %v", err) 1299 + } 1300 + if count != 0 { 1301 + t.Errorf("Expected 0 memberships after deletion, got %d", count) 1302 + } 1303 + 1304 + // Check vote deleted 1305 + err = db.QueryRow(`SELECT COUNT(*) FROM votes WHERE voter_did = $1`, testDID).Scan(&count) 1306 + if err != nil { 1307 + t.Fatalf("Error checking votes: %v", err) 1308 + } 1309 + if count != 0 { 1310 + t.Errorf("Expected 0 votes after deletion, got %d", count) 1311 + } 1312 + 1313 + // Check comments deleted 1314 + err = db.QueryRow(`SELECT COUNT(*) FROM comments WHERE commenter_did = $1`, testDID).Scan(&count) 1315 + if err != nil { 1316 + t.Fatalf("Error checking comments: %v", err) 1317 + } 1318 + if count != 0 { 1319 + t.Errorf("Expected 0 comments after deletion, got %d", count) 1320 + } 1321 + }) 1322 + 1323 + // Verify second delete returns ErrUserNotFound 1324 + t.Run("Delete non-existent account returns error", func(t *testing.T) { 1325 + err := userService.DeleteAccount(ctx, testDID) 1326 + if err == nil { 1327 + t.Error("Expected error when deleting already-deleted account") 1328 + } 1329 + if err != users.ErrUserNotFound { 1330 + t.Errorf("Expected ErrUserNotFound, got: %v", err) 1331 + } 1332 + }) 1333 + 1334 + // Verify community still exists (only user data deleted) 1335 + t.Run("Community still exists after user deletion", func(t *testing.T) { 1336 + var count int 1337 + err := db.QueryRow(`SELECT COUNT(*) FROM communities WHERE did = $1`, testCommunityDID).Scan(&count) 1338 + if err != nil { 1339 + t.Fatalf("Error checking community: %v", err) 1340 + } 1341 + if count != 1 { 1342 + t.Errorf("Expected community to still exist, got count %d", count) 1343 + } 1344 + }) 1345 + }
+42
internal/api/routes/web.go
··· 1 + package routes 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + 8 + "Coves/internal/atproto/oauth" 9 + "Coves/internal/core/users" 10 + "Coves/internal/web" 11 + ) 12 + 13 + // RegisterWebRoutes registers all web page routes for the Coves frontend. 14 + // This includes the landing page, account deletion flow, and static assets. 15 + func RegisterWebRoutes(r chi.Router, oauthClient *oauth.OAuthClient, userService users.UserService) { 16 + // Initialize templates 17 + templates, err := web.NewTemplates() 18 + if err != nil { 19 + panic("failed to load web templates: " + err.Error()) 20 + } 21 + 22 + // Create handlers 23 + handlers := web.NewHandlers(templates, oauthClient, userService) 24 + 25 + // Landing page 26 + r.Get("/", handlers.LandingHandler) 27 + 28 + // Account deletion flow 29 + r.Get("/delete-account", handlers.DeleteAccountPageHandler) 30 + r.Post("/delete-account", handlers.DeleteAccountSubmitHandler) 31 + r.Get("/delete-account/success", handlers.DeleteAccountSuccessHandler) 32 + 33 + // Legal pages 34 + r.Get("/privacy", handlers.PrivacyHandler) 35 + 36 + // Static files (images, etc.) 37 + r.Get("/static/*", func(w http.ResponseWriter, r *http.Request) { 38 + // Serve from project's static directory 39 + fs := http.StripPrefix("/static/", http.FileServer(http.Dir("static"))) 40 + fs.ServeHTTP(w, r) 41 + }) 42 + }
+183
internal/web/handlers.go
··· 1 + package web 2 + 3 + import ( 4 + "log" 5 + "log/slog" 6 + "net/http" 7 + 8 + "Coves/internal/atproto/oauth" 9 + "Coves/internal/core/users" 10 + ) 11 + 12 + // Handlers provides HTTP handlers for the Coves web interface. 13 + // This includes the landing page, static files, and account management. 14 + type Handlers struct { 15 + templates *Templates 16 + oauthClient *oauth.OAuthClient 17 + userService users.UserService 18 + } 19 + 20 + // NewHandlers creates a new Handlers instance with the provided dependencies. 21 + func NewHandlers(templates *Templates, oauthClient *oauth.OAuthClient, userService users.UserService) *Handlers { 22 + return &Handlers{ 23 + templates: templates, 24 + oauthClient: oauthClient, 25 + userService: userService, 26 + } 27 + } 28 + 29 + // LandingPageData holds data for the landing page template. 30 + type LandingPageData struct { 31 + // Title is the page title 32 + Title string 33 + // Description is the meta description for SEO 34 + Description string 35 + // AppStoreURL is the URL for the iOS App Store listing 36 + AppStoreURL string 37 + // PlayStoreURL is the URL for the Google Play Store listing 38 + PlayStoreURL string 39 + } 40 + 41 + // LandingHandler handles GET / requests and renders the landing page. 42 + func (h *Handlers) LandingHandler(w http.ResponseWriter, r *http.Request) { 43 + // Only handle exact root path - let other routes handle their own paths 44 + if r.URL.Path != "/" { 45 + http.NotFound(w, r) 46 + return 47 + } 48 + 49 + data := LandingPageData{ 50 + Title: "Coves - Community-Driven Forums on atProto", 51 + Description: "Coves is a forum-like social app built on the AT Protocol. Join communities, share content, and own your data.", 52 + // App store URLs - update these when apps are published 53 + AppStoreURL: "https://apps.apple.com/app/coves", 54 + PlayStoreURL: "https://play.google.com/store/apps/details?id=social.coves.app", 55 + } 56 + 57 + if err := h.templates.Render(w, "landing.html", data); err != nil { 58 + log.Printf("Failed to render landing page: %v", err) 59 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 60 + } 61 + } 62 + 63 + // DeleteAccountPageData contains the data for the delete account template 64 + type DeleteAccountPageData struct { 65 + LoggedIn bool 66 + Handle string 67 + DID string 68 + } 69 + 70 + // DeleteAccountPageHandler renders the delete account page 71 + // GET /delete-account 72 + func (h *Handlers) DeleteAccountPageHandler(w http.ResponseWriter, r *http.Request) { 73 + data := DeleteAccountPageData{ 74 + LoggedIn: false, 75 + } 76 + 77 + // Check for session cookie 78 + cookie, err := r.Cookie("coves_session") 79 + if err == nil && cookie.Value != "" { 80 + // Try to unseal the session 81 + sealed, err := h.oauthClient.UnsealSession(cookie.Value) 82 + if err == nil && sealed != nil { 83 + // Session is valid, get user info 84 + user, err := h.userService.GetUserByDID(r.Context(), sealed.DID) 85 + if err == nil && user != nil { 86 + data.LoggedIn = true 87 + data.Handle = user.Handle 88 + data.DID = user.DID 89 + } else { 90 + slog.Warn("delete account: failed to get user by DID", 91 + "did", sealed.DID, "error", err) 92 + } 93 + } else { 94 + slog.Debug("delete account: invalid or expired session", "error", err) 95 + } 96 + } 97 + 98 + if err := h.templates.Render(w, "delete_account.html", data); err != nil { 99 + slog.Error("failed to render delete account template", "error", err) 100 + http.Error(w, "Internal server error", http.StatusInternalServerError) 101 + } 102 + } 103 + 104 + // DeleteAccountSubmitHandler processes the account deletion request 105 + // POST /delete-account 106 + func (h *Handlers) DeleteAccountSubmitHandler(w http.ResponseWriter, r *http.Request) { 107 + ctx := r.Context() 108 + 109 + // Verify session 110 + cookie, err := r.Cookie("coves_session") 111 + if err != nil || cookie.Value == "" { 112 + slog.Warn("delete account submit: no session cookie") 113 + http.Redirect(w, r, "/delete-account", http.StatusFound) 114 + return 115 + } 116 + 117 + // Unseal the session 118 + sealed, err := h.oauthClient.UnsealSession(cookie.Value) 119 + if err != nil || sealed == nil { 120 + slog.Warn("delete account submit: invalid session", "error", err) 121 + h.clearSessionCookie(w) 122 + http.Redirect(w, r, "/delete-account", http.StatusFound) 123 + return 124 + } 125 + 126 + // Parse form to check confirmation checkbox 127 + if err := r.ParseForm(); err != nil { 128 + slog.Error("delete account submit: failed to parse form", "error", err) 129 + http.Error(w, "Bad request", http.StatusBadRequest) 130 + return 131 + } 132 + 133 + // Verify confirmation checkbox was checked 134 + if r.FormValue("confirm") != "true" { 135 + slog.Warn("delete account submit: confirmation not checked", "did", sealed.DID) 136 + http.Redirect(w, r, "/delete-account", http.StatusFound) 137 + return 138 + } 139 + 140 + // Delete the user's account 141 + err = h.userService.DeleteAccount(ctx, sealed.DID) 142 + if err != nil { 143 + slog.Error("delete account submit: failed to delete account", 144 + "did", sealed.DID, "error", err) 145 + http.Error(w, "Failed to delete account", http.StatusInternalServerError) 146 + return 147 + } 148 + 149 + slog.Info("account deleted successfully via web", "did", sealed.DID) 150 + 151 + // Clear the session cookie 152 + h.clearSessionCookie(w) 153 + 154 + // Redirect to success page 155 + http.Redirect(w, r, "/delete-account/success", http.StatusFound) 156 + } 157 + 158 + // DeleteAccountSuccessHandler renders the deletion success page 159 + // GET /delete-account/success 160 + func (h *Handlers) DeleteAccountSuccessHandler(w http.ResponseWriter, r *http.Request) { 161 + if err := h.templates.Render(w, "delete_success.html", nil); err != nil { 162 + slog.Error("failed to render delete success template", "error", err) 163 + http.Error(w, "Internal server error", http.StatusInternalServerError) 164 + } 165 + } 166 + 167 + // clearSessionCookie clears the session cookie 168 + func (h *Handlers) clearSessionCookie(w http.ResponseWriter) { 169 + http.SetCookie(w, &http.Cookie{ 170 + Name: "coves_session", 171 + Value: "", 172 + Path: "/", 173 + MaxAge: -1, 174 + }) 175 + } 176 + 177 + // PrivacyHandler handles GET /privacy requests and renders the privacy policy page. 178 + func (h *Handlers) PrivacyHandler(w http.ResponseWriter, r *http.Request) { 179 + if err := h.templates.Render(w, "privacy.html", nil); err != nil { 180 + slog.Error("failed to render privacy policy template", "error", err) 181 + http.Error(w, "Internal server error", http.StatusInternalServerError) 182 + } 183 + }
+58
internal/web/templates.go
··· 1 + // Package web provides HTTP handlers and templates for the Coves web interface. 2 + // This includes the landing page and static file serving for the coves.social website. 3 + package web 4 + 5 + import ( 6 + "embed" 7 + "fmt" 8 + "html/template" 9 + "net/http" 10 + "path/filepath" 11 + ) 12 + 13 + //go:embed templates/*.html 14 + var templatesFS embed.FS 15 + 16 + // Templates holds the parsed HTML templates for the web interface. 17 + type Templates struct { 18 + templates *template.Template 19 + } 20 + 21 + // NewTemplates creates a new Templates instance by parsing all embedded templates. 22 + func NewTemplates() (*Templates, error) { 23 + tmpl, err := template.ParseFS(templatesFS, "templates/*.html") 24 + if err != nil { 25 + return nil, fmt.Errorf("failed to parse templates: %w", err) 26 + } 27 + return &Templates{templates: tmpl}, nil 28 + } 29 + 30 + // Render renders a named template with the provided data to the response writer. 31 + // Returns an error if the template doesn't exist or rendering fails. 32 + func (t *Templates) Render(w http.ResponseWriter, name string, data interface{}) error { 33 + // Set content type before writing 34 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 35 + 36 + // Check if template exists 37 + tmpl := t.templates.Lookup(name) 38 + if tmpl == nil { 39 + return fmt.Errorf("template %q not found", name) 40 + } 41 + 42 + // Execute template 43 + if err := tmpl.Execute(w, data); err != nil { 44 + return fmt.Errorf("failed to execute template %q: %w", name, err) 45 + } 46 + 47 + return nil 48 + } 49 + 50 + // ProjectStaticFileServer returns an http.Handler that serves static files from the project root. 51 + // This is used for files that live outside the web package (e.g., /static/images/). 52 + func ProjectStaticFileServer(staticDir string) http.Handler { 53 + absPath, err := filepath.Abs(staticDir) 54 + if err != nil { 55 + panic(fmt.Sprintf("failed to get absolute path for static directory: %v", err)) 56 + } 57 + return http.StripPrefix("/static/", http.FileServer(http.Dir(absPath))) 58 + }
+335
internal/web/templates/delete_account.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>Delete Your Account - Coves</title> 7 + <!-- Favicon --> 8 + <link rel="icon" type="image/png" href="/static/images/lil_dude.png"> 9 + <style> 10 + * { box-sizing: border-box; margin: 0; padding: 0; } 11 + body { 12 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 13 + background: #0B0F14; 14 + color: #e4e6e7; 15 + min-height: 100vh; 16 + display: flex; 17 + justify-content: center; 18 + align-items: center; 19 + padding: 24px; 20 + } 21 + .card { 22 + background: #1A1F26; 23 + border-radius: 16px; 24 + padding: 32px; 25 + max-width: 480px; 26 + width: 100%; 27 + } 28 + h1 { 29 + font-size: 24px; 30 + font-weight: 600; 31 + margin-bottom: 16px; 32 + color: #e4e6e7; 33 + } 34 + .subtitle { 35 + font-size: 16px; 36 + color: #B6C2D2; 37 + margin-bottom: 24px; 38 + line-height: 1.5; 39 + } 40 + .handle-display { 41 + font-size: 16px; 42 + color: #7CB9E8; 43 + background: #0B0F14; 44 + padding: 12px 16px; 45 + border-radius: 8px; 46 + margin-bottom: 24px; 47 + display: inline-block; 48 + } 49 + .warning-list { 50 + background: rgba(255, 107, 53, 0.1); 51 + border: 1px solid rgba(255, 107, 53, 0.3); 52 + border-radius: 8px; 53 + padding: 16px; 54 + margin-bottom: 24px; 55 + } 56 + .warning-list h3 { 57 + color: #FF6B35; 58 + font-size: 14px; 59 + font-weight: 600; 60 + margin-bottom: 12px; 61 + } 62 + .warning-list ul { 63 + list-style: disc; 64 + padding-left: 20px; 65 + } 66 + .warning-list li { 67 + color: #B6C2D2; 68 + font-size: 14px; 69 + margin-bottom: 8px; 70 + line-height: 1.4; 71 + } 72 + .info-box { 73 + background: rgba(124, 185, 232, 0.1); 74 + border: 1px solid rgba(124, 185, 232, 0.3); 75 + border-radius: 8px; 76 + padding: 16px; 77 + margin-bottom: 24px; 78 + } 79 + .info-box p { 80 + color: #B6C2D2; 81 + font-size: 14px; 82 + line-height: 1.5; 83 + } 84 + .checkbox-container { 85 + display: flex; 86 + align-items: flex-start; 87 + gap: 12px; 88 + margin-bottom: 24px; 89 + } 90 + .checkbox-container input[type="checkbox"] { 91 + width: 20px; 92 + height: 20px; 93 + margin-top: 2px; 94 + accent-color: #FF6B35; 95 + cursor: pointer; 96 + } 97 + .checkbox-container label { 98 + color: #e4e6e7; 99 + font-size: 14px; 100 + cursor: pointer; 101 + line-height: 1.4; 102 + } 103 + .btn { 104 + display: inline-block; 105 + padding: 14px 28px; 106 + border-radius: 8px; 107 + font-size: 16px; 108 + font-weight: 600; 109 + text-decoration: none; 110 + cursor: pointer; 111 + border: none; 112 + transition: all 0.2s ease; 113 + } 114 + .btn-primary { 115 + background: #FF6B35; 116 + color: white; 117 + } 118 + .btn-primary:hover:not(:disabled) { 119 + background: #e55a2b; 120 + } 121 + .btn-primary:disabled { 122 + background: #4A4F56; 123 + color: #6B7280; 124 + cursor: not-allowed; 125 + } 126 + .btn-secondary { 127 + background: transparent; 128 + color: #B6C2D2; 129 + border: 1px solid #2A2F36; 130 + } 131 + .btn-secondary:hover { 132 + background: #2A2F36; 133 + } 134 + .btn-danger { 135 + background: #DC2626; 136 + color: white; 137 + } 138 + .btn-danger:hover:not(:disabled) { 139 + background: #B91C1C; 140 + } 141 + .btn-danger:disabled { 142 + background: #4A4F56; 143 + color: #6B7280; 144 + cursor: not-allowed; 145 + } 146 + .button-row { 147 + display: flex; 148 + gap: 12px; 149 + flex-wrap: wrap; 150 + } 151 + .divider { 152 + height: 1px; 153 + background: #2A2F36; 154 + margin: 24px 0; 155 + } 156 + .bluesky-icon { 157 + width: 20px; 158 + height: 20px; 159 + margin-right: 8px; 160 + vertical-align: middle; 161 + } 162 + </style> 163 + </head> 164 + <body> 165 + <div class="card"> 166 + <h1>Delete Your Account</h1> 167 + 168 + {{if .LoggedIn}} 169 + <!-- Logged in state --> 170 + <p class="subtitle">You are signed in as:</p> 171 + <div class="handle-display">@{{.Handle}}</div> 172 + 173 + <div class="warning-list"> 174 + <h3>This will permanently delete:</h3> 175 + <ul> 176 + <li>All your posts on Coves</li> 177 + <li>All your comments</li> 178 + <li>All your votes</li> 179 + <li>Your community subscriptions and memberships</li> 180 + <li>Your profile data on Coves</li> 181 + </ul> 182 + </div> 183 + 184 + <div class="info-box"> 185 + <p>Your atProto identity (DID and handle) will be preserved. You can still use your Bluesky account and other atProto apps. Only your Coves-specific data will be removed.</p> 186 + </div> 187 + 188 + <form method="POST" action="/delete-account" id="delete-form"> 189 + <div class="checkbox-container"> 190 + <input type="checkbox" id="confirm-checkbox" name="confirm" value="true" onchange="toggleDeleteButton()"> 191 + <label for="confirm-checkbox">I understand this action cannot be undone and I want to permanently delete my Coves account</label> 192 + </div> 193 + 194 + <div class="button-row"> 195 + <button type="submit" class="btn btn-danger" id="delete-btn" disabled>Delete My Account</button> 196 + <a href="/" class="btn btn-secondary">Cancel</a> 197 + </div> 198 + </form> 199 + 200 + <script> 201 + function toggleDeleteButton() { 202 + var checkbox = document.getElementById('confirm-checkbox'); 203 + var button = document.getElementById('delete-btn'); 204 + button.disabled = !checkbox.checked; 205 + } 206 + </script> 207 + 208 + {{else}} 209 + <!-- Not logged in state --> 210 + <p class="subtitle">To delete your Coves account, you'll need to sign in first to verify your identity.</p> 211 + 212 + <div class="info-box"> 213 + <p style="margin-bottom: 12px;">When you delete your account:</p> 214 + <ul style="list-style: disc; padding-left: 20px; margin: 0;"> 215 + <li style="margin-bottom: 8px; line-height: 1.4;">All your Coves data will be permanently removed (posts, comments, votes, subscriptions)</li> 216 + <li style="margin-bottom: 8px; line-height: 1.4;">Your atProto identity (DID and handle) will be preserved</li> 217 + <li style="line-height: 1.4;">You can still use your Bluesky account and other atProto apps</li> 218 + </ul> 219 + </div> 220 + 221 + <div class="divider"></div> 222 + 223 + <div class="handle-input-container" style="margin-bottom: 24px;"> 224 + <label for="handle-input" style="display: block; font-size: 16px; font-weight: 600; color: #e4e6e7; margin-bottom: 12px;">Enter your atproto handle</label> 225 + <div style="position: relative;"> 226 + <span style="position: absolute; left: 16px; top: 50%; transform: translateY(-50%); color: #5A6B7F; font-size: 16px;">@</span> 227 + <input 228 + type="text" 229 + id="handle-input" 230 + placeholder="alice.bsky.social" 231 + style="width: 100%; padding: 14px 16px 14px 36px; background: #1A2028; border: 2px solid #2A3441; border-radius: 12px; color: #e4e6e7; font-size: 16px; outline: none; transition: border-color 0.2s;" 232 + onfocus="this.style.borderColor='#FF6B35'" 233 + onblur="this.style.borderColor='#2A3441'" 234 + onkeyup="validateHandle()" 235 + > 236 + </div> 237 + <p id="handle-error" style="color: #FF6B35; font-size: 12px; margin-top: 8px; display: none;">Please enter your handle to continue</p> 238 + </div> 239 + 240 + <button type="button" id="sign-in-btn" class="btn btn-primary" onclick="signIn()" disabled style="width: 100%; display: flex; align-items: center; justify-content: center; border-radius: 9999px;"> 241 + Sign In 242 + </button> 243 + 244 + <p style="color: #5A6B7F; font-size: 13px; text-align: center; margin-top: 16px; line-height: 1.5;"> 245 + You'll be redirected to your atproto provider to authorize this action. 246 + </p> 247 + 248 + <p style="text-align: center; margin-top: 24px;"> 249 + <a href="#" onclick="showHandleHelp(); return false;" style="color: #FF6B35; font-size: 14px; text-decoration: underline;">What is a handle?</a> 250 + </p> 251 + 252 + <!-- Handle Help Modal --> 253 + <div id="help-modal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); z-index: 1000; justify-content: center; align-items: center; padding: 24px;"> 254 + <div style="background: #1A1F26; border-radius: 16px; padding: 32px; max-width: 400px; width: 100%; text-align: center;"> 255 + <div style="width: 48px; height: 48px; background: rgba(124, 185, 232, 0.15); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px;"> 256 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#7CB9E8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 257 + <circle cx="12" cy="12" r="10"></circle> 258 + <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path> 259 + <line x1="12" y1="17" x2="12.01" y2="17"></line> 260 + </svg> 261 + </div> 262 + <h3 style="color: #e4e6e7; font-size: 18px; font-weight: 600; margin-bottom: 12px;">What is a handle?</h3> 263 + <p style="color: #B6C2D2; font-size: 14px; line-height: 1.6; margin-bottom: 16px;"> 264 + Your handle is your unique identifier on the atproto network, like <span style="color: #7CB9E8;">alice.bsky.social</span>. 265 + </p> 266 + <p style="color: #B6C2D2; font-size: 14px; line-height: 1.6; margin-bottom: 24px;"> 267 + If you don't have one yet, you can create an account at <a href="https://bsky.app" target="_blank" style="color: #FF6B35; text-decoration: underline;">bsky.app</a>. 268 + </p> 269 + <button type="button" onclick="closeHelpModal()" style="width: 100%; padding: 12px 20px; background: #FF6B35; border: none; border-radius: 8px; color: white; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='#e55a2b'" onmouseout="this.style.background='#FF6B35'"> 270 + Got it 271 + </button> 272 + </div> 273 + </div> 274 + 275 + <script> 276 + function validateHandle() { 277 + var input = document.getElementById('handle-input'); 278 + var button = document.getElementById('sign-in-btn'); 279 + var error = document.getElementById('handle-error'); 280 + var handle = input.value.trim(); 281 + 282 + if (handle.length > 0) { 283 + button.disabled = false; 284 + error.style.display = 'none'; 285 + } else { 286 + button.disabled = true; 287 + } 288 + } 289 + 290 + function signIn() { 291 + var input = document.getElementById('handle-input'); 292 + var error = document.getElementById('handle-error'); 293 + var handle = input.value.trim(); 294 + 295 + if (!handle) { 296 + error.style.display = 'block'; 297 + return; 298 + } 299 + 300 + window.location.href = '/oauth/login?handle=' + encodeURIComponent(handle) + '&redirect=/delete-account'; 301 + } 302 + 303 + function showHandleHelp() { 304 + document.getElementById('help-modal').style.display = 'flex'; 305 + } 306 + 307 + function closeHelpModal() { 308 + document.getElementById('help-modal').style.display = 'none'; 309 + } 310 + 311 + // Close modal on backdrop click 312 + document.getElementById('help-modal').addEventListener('click', function(e) { 313 + if (e.target === this) { 314 + closeHelpModal(); 315 + } 316 + }); 317 + 318 + // Close modal on Escape key 319 + document.addEventListener('keydown', function(e) { 320 + if (e.key === 'Escape') { 321 + closeHelpModal(); 322 + } 323 + }); 324 + 325 + // Allow Enter key to submit 326 + document.getElementById('handle-input').addEventListener('keypress', function(e) { 327 + if (e.key === 'Enter' && !document.getElementById('sign-in-btn').disabled) { 328 + signIn(); 329 + } 330 + }); 331 + </script> 332 + {{end}} 333 + </div> 334 + </body> 335 + </html>
+114
internal/web/templates/delete_success.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>Account Deleted - Coves</title> 7 + <!-- Favicon --> 8 + <link rel="icon" type="image/png" href="/static/images/lil_dude.png"> 9 + <style> 10 + * { box-sizing: border-box; margin: 0; padding: 0; } 11 + body { 12 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 13 + background: #0B0F14; 14 + color: #e4e6e7; 15 + min-height: 100vh; 16 + display: flex; 17 + justify-content: center; 18 + align-items: center; 19 + padding: 24px; 20 + } 21 + .card { 22 + background: #1A1F26; 23 + border-radius: 16px; 24 + padding: 32px; 25 + max-width: 480px; 26 + width: 100%; 27 + text-align: center; 28 + } 29 + .checkmark { 30 + width: 64px; 31 + height: 64px; 32 + margin: 0 auto 24px; 33 + background: #22C55E; 34 + border-radius: 50%; 35 + display: flex; 36 + align-items: center; 37 + justify-content: center; 38 + animation: scale-in 0.3s ease-out; 39 + } 40 + .checkmark svg { 41 + width: 32px; 42 + height: 32px; 43 + stroke: white; 44 + stroke-width: 3; 45 + fill: none; 46 + } 47 + @keyframes scale-in { 48 + 0% { transform: scale(0); } 49 + 50% { transform: scale(1.1); } 50 + 100% { transform: scale(1); } 51 + } 52 + h1 { 53 + font-size: 24px; 54 + font-weight: 600; 55 + margin-bottom: 16px; 56 + color: #e4e6e7; 57 + } 58 + .message { 59 + font-size: 16px; 60 + color: #B6C2D2; 61 + margin-bottom: 24px; 62 + line-height: 1.5; 63 + } 64 + .info-box { 65 + background: rgba(124, 185, 232, 0.1); 66 + border: 1px solid rgba(124, 185, 232, 0.3); 67 + border-radius: 8px; 68 + padding: 16px; 69 + margin-bottom: 24px; 70 + text-align: left; 71 + } 72 + .info-box p { 73 + color: #B6C2D2; 74 + font-size: 14px; 75 + line-height: 1.5; 76 + } 77 + .btn { 78 + display: inline-block; 79 + padding: 14px 28px; 80 + border-radius: 8px; 81 + font-size: 16px; 82 + font-weight: 600; 83 + text-decoration: none; 84 + cursor: pointer; 85 + border: none; 86 + transition: all 0.2s ease; 87 + } 88 + .btn-primary { 89 + background: #FF6B35; 90 + color: white; 91 + } 92 + .btn-primary:hover { 93 + background: #e55a2b; 94 + } 95 + </style> 96 + </head> 97 + <body> 98 + <div class="card"> 99 + <div class="checkmark"> 100 + <svg viewBox="0 0 24 24"> 101 + <polyline points="20 6 9 17 4 12"></polyline> 102 + </svg> 103 + </div> 104 + <h1>Account Deleted</h1> 105 + <p class="message">Your Coves account has been successfully deleted.</p> 106 + 107 + <div class="info-box"> 108 + <p>Your atProto identity has been preserved. You can continue using your Bluesky account and other atProto applications. If you ever want to return to Coves, you can create a new account using the same identity.</p> 109 + </div> 110 + 111 + <a href="/" class="btn btn-primary">Return to Homepage</a> 112 + </div> 113 + </body> 114 + </html>
+628
internal/web/templates/landing.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>{{.Title}}</title> 7 + <meta name="description" content="{{.Description}}"> 8 + 9 + <!-- Open Graph / Social --> 10 + <meta property="og:type" content="website"> 11 + <meta property="og:title" content="{{.Title}}"> 12 + <meta property="og:description" content="{{.Description}}"> 13 + <meta property="og:image" content="/static/images/app-icon.png"> 14 + 15 + <!-- Twitter Card --> 16 + <meta name="twitter:card" content="summary"> 17 + <meta name="twitter:title" content="{{.Title}}"> 18 + <meta name="twitter:description" content="{{.Description}}"> 19 + 20 + <!-- Favicon --> 21 + <link rel="icon" type="image/png" href="/static/images/lil_dude.png"> 22 + 23 + <!-- Fonts: Shrikhand for playful display, Nunito for friendly body text --> 24 + <link rel="preconnect" href="https://fonts.googleapis.com"> 25 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 26 + <link href="https://fonts.googleapis.com/css2?family=Shrikhand&family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet"> 27 + 28 + <style> 29 + :root { 30 + /* Brand colors from the SVGs */ 31 + --color-coral: #F08C59; 32 + --color-coral-light: #FFB468; 33 + --color-coral-dark: #DF7E40; 34 + --color-teal: #63B5B1; 35 + --color-teal-dark: #4A9994; 36 + --color-deep: #1B2A2E; 37 + --color-sand: #FFF8F0; 38 + --color-sand-warm: #FFEFE0; 39 + --color-cream: #FFFCF7; 40 + --color-text: #1B2A2E; 41 + --color-text-muted: #5A6B70; 42 + --color-red-accent: #EC7558; 43 + 44 + --font-display: 'Shrikhand', cursive; 45 + --font-body: 'Nunito', system-ui, sans-serif; 46 + } 47 + 48 + * { 49 + box-sizing: border-box; 50 + margin: 0; 51 + padding: 0; 52 + } 53 + 54 + html { 55 + scroll-behavior: smooth; 56 + } 57 + 58 + body { 59 + font-family: var(--font-body); 60 + background: var(--color-sand); 61 + color: var(--color-text); 62 + min-height: 100vh; 63 + display: flex; 64 + flex-direction: column; 65 + overflow-x: hidden; 66 + position: relative; 67 + } 68 + 69 + /* Wavy ocean background at bottom */ 70 + body::before { 71 + content: ''; 72 + position: fixed; 73 + bottom: 0; 74 + left: 0; 75 + right: 0; 76 + height: 40vh; 77 + background: linear-gradient( 78 + to bottom, 79 + transparent 0%, 80 + rgba(99, 181, 177, 0.08) 30%, 81 + rgba(99, 181, 177, 0.15) 60%, 82 + rgba(99, 181, 177, 0.25) 100% 83 + ); 84 + pointer-events: none; 85 + z-index: 0; 86 + } 87 + 88 + /* Subtle texture overlay */ 89 + body::after { 90 + content: ''; 91 + position: fixed; 92 + top: 0; 93 + left: 0; 94 + right: 0; 95 + bottom: 0; 96 + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); 97 + opacity: 0.03; 98 + pointer-events: none; 99 + z-index: 1; 100 + } 101 + 102 + /* Main content */ 103 + main { 104 + flex: 1; 105 + display: flex; 106 + flex-direction: column; 107 + justify-content: center; 108 + align-items: center; 109 + padding: 60px 24px 80px; 110 + position: relative; 111 + z-index: 2; 112 + } 113 + 114 + /* Floating bubble decorations */ 115 + .bubble { 116 + position: absolute; 117 + border-radius: 50%; 118 + background: linear-gradient(135deg, var(--color-teal) 0%, rgba(99, 181, 177, 0.3) 100%); 119 + opacity: 0.15; 120 + animation: float 8s ease-in-out infinite; 121 + pointer-events: none; 122 + } 123 + 124 + .bubble-1 { 125 + width: 120px; 126 + height: 120px; 127 + top: 15%; 128 + left: 8%; 129 + animation-delay: 0s; 130 + } 131 + 132 + .bubble-2 { 133 + width: 80px; 134 + height: 80px; 135 + top: 60%; 136 + right: 10%; 137 + animation-delay: -2s; 138 + background: linear-gradient(135deg, var(--color-coral) 0%, rgba(240, 140, 89, 0.3) 100%); 139 + } 140 + 141 + .bubble-3 { 142 + width: 60px; 143 + height: 60px; 144 + bottom: 25%; 145 + left: 15%; 146 + animation-delay: -4s; 147 + } 148 + 149 + .bubble-4 { 150 + width: 40px; 151 + height: 40px; 152 + top: 30%; 153 + right: 20%; 154 + animation-delay: -1s; 155 + background: linear-gradient(135deg, var(--color-coral-light) 0%, rgba(255, 180, 104, 0.3) 100%); 156 + } 157 + 158 + @keyframes float { 159 + 0%, 100% { transform: translateY(0) scale(1); } 160 + 50% { transform: translateY(-20px) scale(1.05); } 161 + } 162 + 163 + /* Hero section */ 164 + .hero { 165 + text-align: center; 166 + max-width: 720px; 167 + position: relative; 168 + } 169 + 170 + /* Brand lockup: mascot + logo */ 171 + .brand-lockup { 172 + display: flex; 173 + align-items: center; 174 + justify-content: center; 175 + margin-bottom: 40px; 176 + position: relative; 177 + } 178 + 179 + /* Mascot with playful hover */ 180 + .mascot-container { 181 + position: relative; 182 + display: flex; 183 + align-items: center; 184 + justify-content: center; 185 + margin-right: -12px; 186 + z-index: 2; 187 + } 188 + 189 + .mascot { 190 + width: 90px; 191 + height: 83px; 192 + position: relative; 193 + z-index: 1; 194 + filter: drop-shadow(0 8px 16px rgba(27, 42, 46, 0.15)); 195 + transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); 196 + cursor: default; 197 + transform-origin: bottom center; 198 + animation: wave 2.5s ease-in-out infinite; 199 + } 200 + 201 + @keyframes wave { 202 + 0%, 100% { transform: rotate(-3deg); } 203 + 50% { transform: rotate(3deg); } 204 + } 205 + 206 + .mascot:hover { 207 + animation: excited 0.5s ease-in-out; 208 + } 209 + 210 + @keyframes excited { 211 + 0%, 100% { transform: scale(1) rotate(0deg); } 212 + 25% { transform: scale(1.1) rotate(-5deg); } 213 + 50% { transform: scale(1.15) rotate(5deg); } 214 + 75% { transform: scale(1.1) rotate(-3deg); } 215 + } 216 + 217 + .logo { 218 + height: 80px; 219 + position: relative; 220 + z-index: 1; 221 + opacity: 0; 222 + animation: fade-in-scale 0.6s ease-out 0.15s forwards; 223 + } 224 + 225 + @keyframes fade-in-scale { 226 + from { 227 + opacity: 0; 228 + transform: scale(0.9); 229 + } 230 + to { 231 + opacity: 1; 232 + transform: scale(1); 233 + } 234 + } 235 + 236 + @keyframes fade-up { 237 + from { 238 + opacity: 0; 239 + transform: translateY(20px); 240 + } 241 + to { 242 + opacity: 1; 243 + transform: translateY(0); 244 + } 245 + } 246 + 247 + .tagline { 248 + font-family: var(--font-display); 249 + font-size: clamp(1.75rem, 5vw, 2.75rem); 250 + font-weight: 400; 251 + color: var(--color-deep); 252 + margin-bottom: 20px; 253 + line-height: 1.3; 254 + letter-spacing: 0.01em; 255 + opacity: 0; 256 + animation: fade-up 0.8s ease-out 0.35s forwards; 257 + } 258 + 259 + .tagline .highlight { 260 + color: var(--color-coral); 261 + position: relative; 262 + } 263 + 264 + .tagline .highlight::after { 265 + content: ''; 266 + position: absolute; 267 + bottom: 2px; 268 + left: 0; 269 + right: 0; 270 + height: 6px; 271 + background: var(--color-teal); 272 + opacity: 0.3; 273 + border-radius: 3px; 274 + z-index: -1; 275 + } 276 + 277 + .description { 278 + font-size: 1.15rem; 279 + font-weight: 500; 280 + color: var(--color-text-muted); 281 + line-height: 1.7; 282 + margin-bottom: 44px; 283 + max-width: 540px; 284 + margin-left: auto; 285 + margin-right: auto; 286 + opacity: 0; 287 + animation: fade-up 0.8s ease-out 0.5s forwards; 288 + } 289 + 290 + /* App store buttons - warm and inviting */ 291 + .app-buttons { 292 + display: flex; 293 + flex-wrap: wrap; 294 + gap: 16px; 295 + justify-content: center; 296 + margin-bottom: 44px; 297 + opacity: 0; 298 + animation: fade-up 0.8s ease-out 0.65s forwards; 299 + } 300 + 301 + .app-button { 302 + display: inline-flex; 303 + align-items: center; 304 + background: var(--color-deep); 305 + border: 2px solid var(--color-deep); 306 + border-radius: 16px; 307 + padding: 14px 28px; 308 + text-decoration: none; 309 + color: var(--color-sand); 310 + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 311 + position: relative; 312 + overflow: hidden; 313 + } 314 + 315 + .app-button::before { 316 + content: ''; 317 + position: absolute; 318 + top: 0; 319 + left: -100%; 320 + width: 100%; 321 + height: 100%; 322 + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent); 323 + transition: left 0.5s ease; 324 + } 325 + 326 + .app-button:hover { 327 + background: var(--color-coral-dark); 328 + border-color: var(--color-coral-dark); 329 + transform: translateY(-4px); 330 + box-shadow: 0 12px 32px -8px rgba(240, 140, 89, 0.4); 331 + } 332 + 333 + .app-button:hover::before { 334 + left: 100%; 335 + } 336 + 337 + .app-button svg { 338 + width: 26px; 339 + height: 26px; 340 + margin-right: 14px; 341 + position: relative; 342 + z-index: 1; 343 + transition: transform 0.3s ease; 344 + } 345 + 346 + .app-button:hover svg { 347 + transform: scale(1.1); 348 + } 349 + 350 + .app-button-text { 351 + text-align: left; 352 + position: relative; 353 + z-index: 1; 354 + } 355 + 356 + .app-button-label { 357 + font-size: 0.7rem; 358 + text-transform: uppercase; 359 + letter-spacing: 0.1em; 360 + color: rgba(255, 248, 240, 0.7); 361 + display: block; 362 + margin-bottom: 2px; 363 + font-weight: 600; 364 + } 365 + 366 + .app-button-store { 367 + font-size: 1.1rem; 368 + font-weight: 700; 369 + letter-spacing: -0.01em; 370 + } 371 + 372 + /* Placeholder state for app buttons */ 373 + .app-button-placeholder { 374 + cursor: default; 375 + background: transparent; 376 + border-color: var(--color-text-muted); 377 + color: var(--color-text-muted); 378 + } 379 + 380 + .app-button-placeholder svg { 381 + opacity: 0.6; 382 + } 383 + 384 + .app-button-placeholder:hover { 385 + transform: none; 386 + background: transparent; 387 + border-color: var(--color-text-muted); 388 + box-shadow: none; 389 + } 390 + 391 + .app-button-placeholder:hover::before { 392 + left: -100%; 393 + } 394 + 395 + .app-button-placeholder .app-button-label { 396 + color: var(--color-coral); 397 + font-weight: 700; 398 + letter-spacing: 0.08em; 399 + } 400 + 401 + .app-button-placeholder .app-button-store { 402 + color: var(--color-text-muted); 403 + } 404 + 405 + /* Built on AT Protocol badge - teal accent */ 406 + .built-on { 407 + display: inline-flex; 408 + align-items: center; 409 + gap: 10px; 410 + padding: 12px 24px; 411 + background: var(--color-cream); 412 + border: 2px solid var(--color-teal); 413 + border-radius: 100px; 414 + color: var(--color-text-muted); 415 + font-size: 0.9rem; 416 + font-weight: 600; 417 + opacity: 0; 418 + animation: fade-up 0.8s ease-out 0.8s forwards; 419 + transition: all 0.3s ease; 420 + } 421 + 422 + .built-on:hover { 423 + background: var(--color-sand-warm); 424 + transform: translateY(-2px); 425 + } 426 + 427 + .built-on svg { 428 + width: 18px; 429 + height: 18px; 430 + color: var(--color-teal); 431 + } 432 + 433 + .built-on a { 434 + color: var(--color-teal-dark); 435 + text-decoration: none; 436 + font-weight: 700; 437 + transition: color 0.2s ease; 438 + } 439 + 440 + .built-on a:hover { 441 + color: var(--color-coral); 442 + } 443 + 444 + /* Footer - warm sandy tone */ 445 + footer { 446 + background: linear-gradient(to top, var(--color-sand-warm) 0%, transparent 100%); 447 + padding: 48px 24px 36px; 448 + text-align: center; 449 + position: relative; 450 + z-index: 2; 451 + } 452 + 453 + .footer-links { 454 + display: flex; 455 + flex-wrap: wrap; 456 + justify-content: center; 457 + gap: 36px; 458 + } 459 + 460 + .footer-links a { 461 + color: var(--color-text-muted); 462 + text-decoration: none; 463 + font-size: 0.9rem; 464 + font-weight: 600; 465 + transition: all 0.2s ease; 466 + position: relative; 467 + } 468 + 469 + .footer-links a::after { 470 + content: ''; 471 + position: absolute; 472 + bottom: -4px; 473 + left: 0; 474 + width: 0; 475 + height: 2px; 476 + background: var(--color-coral); 477 + border-radius: 1px; 478 + transition: width 0.3s ease; 479 + } 480 + 481 + .footer-links a:hover { 482 + color: var(--color-coral-dark); 483 + } 484 + 485 + .footer-links a:hover::after { 486 + width: 100%; 487 + } 488 + 489 + /* Responsive */ 490 + @media (max-width: 640px) { 491 + main { 492 + padding: 40px 20px 60px; 493 + } 494 + 495 + .brand-lockup { 496 + flex-direction: column; 497 + margin-bottom: 32px; 498 + } 499 + 500 + .mascot-container { 501 + margin-right: 0; 502 + margin-bottom: 8px; 503 + } 504 + 505 + .mascot { 506 + width: 80px; 507 + height: 74px; 508 + } 509 + 510 + .logo { 511 + height: 64px; 512 + } 513 + 514 + .description { 515 + font-size: 1.05rem; 516 + margin-bottom: 36px; 517 + } 518 + 519 + .app-buttons { 520 + flex-direction: column; 521 + align-items: center; 522 + gap: 12px; 523 + } 524 + 525 + .app-button { 526 + width: 100%; 527 + max-width: 280px; 528 + justify-content: center; 529 + padding: 12px 24px; 530 + } 531 + 532 + .footer-links { 533 + gap: 24px; 534 + } 535 + 536 + .bubble-1, .bubble-4 { 537 + display: none; 538 + } 539 + 540 + .bubble-2 { 541 + width: 60px; 542 + height: 60px; 543 + top: 70%; 544 + } 545 + 546 + .bubble-3 { 547 + width: 40px; 548 + height: 40px; 549 + } 550 + } 551 + 552 + /* Reduce motion for accessibility */ 553 + @media (prefers-reduced-motion: reduce) { 554 + *, *::before, *::after { 555 + animation-duration: 0.01ms !important; 556 + animation-iteration-count: 1 !important; 557 + transition-duration: 0.01ms !important; 558 + } 559 + } 560 + </style> 561 + </head> 562 + <body> 563 + <!-- Floating bubble decorations --> 564 + <div class="bubble bubble-1" aria-hidden="true"></div> 565 + <div class="bubble bubble-2" aria-hidden="true"></div> 566 + <div class="bubble bubble-3" aria-hidden="true"></div> 567 + <div class="bubble bubble-4" aria-hidden="true"></div> 568 + 569 + <main> 570 + <section class="hero"> 571 + <!-- Brand lockup: mascot + logo integrated --> 572 + <div class="brand-lockup"> 573 + <div class="mascot-container"> 574 + <img src="/static/images/lil_dude.svg" alt="Lil Dude - Coves Mascot" class="mascot"> 575 + </div> 576 + <img src="/static/images/coves_logo_text.svg" alt="Coves" class="logo"> 577 + </div> 578 + 579 + <h1 class="tagline"> 580 + Community-Driven Forums,<br> 581 + <span class="highlight">Community-Governed</span> 582 + </h1> 583 + 584 + <p class="description"> 585 + Find your people, join the conversation. Cooperatively run communities on the ATmosphere. Federated and <a href="https://tangled.org/bretton.dev/coves" target="_blank" rel="noopener">open source</a>. 586 + </p> 587 + 588 + <div class="app-buttons"> 589 + <div class="app-button app-button-placeholder" aria-label="App Store - Coming Soon"> 590 + <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 591 + <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/> 592 + </svg> 593 + <span class="app-button-text"> 594 + <span class="app-button-label">Coming soon to</span> 595 + <span class="app-button-store">App Store</span> 596 + </span> 597 + </div> 598 + 599 + <div class="app-button app-button-placeholder" aria-label="Google Play - Coming Soon"> 600 + <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 601 + <path d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.5,12.92 20.16,13.19L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z"/> 602 + </svg> 603 + <span class="app-button-text"> 604 + <span class="app-button-label">Coming soon to</span> 605 + <span class="app-button-store">Google Play</span> 606 + </span> 607 + </div> 608 + </div> 609 + 610 + <div class="built-on"> 611 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 612 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> 613 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> 614 + </svg> 615 + <span>Built on <a href="https://atproto.com" target="_blank" rel="noopener">AT Protocol</a></span> 616 + </div> 617 + </section> 618 + </main> 619 + 620 + <footer> 621 + <nav class="footer-links"> 622 + <a href="/privacy">Privacy Policy</a> 623 + <a href="/terms">Terms of Service</a> 624 + <a href="/delete-account">Delete Account</a> 625 + </nav> 626 + </footer> 627 + </body> 628 + </html>
+168
internal/web/templates_test.go
··· 1 + package web 2 + 3 + import ( 4 + "bytes" 5 + "net/http/httptest" 6 + "testing" 7 + ) 8 + 9 + func TestNewTemplates(t *testing.T) { 10 + templates, err := NewTemplates() 11 + if err != nil { 12 + t.Fatalf("NewTemplates() error = %v", err) 13 + } 14 + if templates == nil { 15 + t.Fatal("NewTemplates() returned nil") 16 + } 17 + } 18 + 19 + func TestTemplatesRender_LandingPage(t *testing.T) { 20 + templates, err := NewTemplates() 21 + if err != nil { 22 + t.Fatalf("NewTemplates() error = %v", err) 23 + } 24 + 25 + data := LandingPageData{ 26 + Title: "Test Title", 27 + Description: "Test Description", 28 + AppStoreURL: "https://example.com/appstore", 29 + PlayStoreURL: "https://example.com/playstore", 30 + } 31 + 32 + w := httptest.NewRecorder() 33 + err = templates.Render(w, "landing.html", data) 34 + if err != nil { 35 + t.Fatalf("Render() error = %v", err) 36 + } 37 + 38 + body := w.Body.String() 39 + 40 + // Check that key elements are present 41 + if !bytes.Contains([]byte(body), []byte("Test Title")) { 42 + t.Error("Rendered output does not contain title") 43 + } 44 + if !bytes.Contains([]byte(body), []byte("Test Description")) { 45 + t.Error("Rendered output does not contain description") 46 + } 47 + // App store buttons show "Coming soon" placeholder text (not links yet) 48 + if !bytes.Contains([]byte(body), []byte("Coming soon to")) { 49 + t.Error("Rendered output does not contain App Store coming soon text") 50 + } 51 + if !bytes.Contains([]byte(body), []byte("App Store")) { 52 + t.Error("Rendered output does not contain App Store text") 53 + } 54 + if !bytes.Contains([]byte(body), []byte("Google Play")) { 55 + t.Error("Rendered output does not contain Google Play text") 56 + } 57 + if !bytes.Contains([]byte(body), []byte("/static/images/lil_dude.png")) { 58 + t.Error("Rendered output does not contain mascot image path") 59 + } 60 + } 61 + 62 + func TestTemplatesRender_DeleteAccount(t *testing.T) { 63 + templates, err := NewTemplates() 64 + if err != nil { 65 + t.Fatalf("NewTemplates() error = %v", err) 66 + } 67 + 68 + // Test logged out state 69 + data := DeleteAccountPageData{ 70 + LoggedIn: false, 71 + } 72 + 73 + w := httptest.NewRecorder() 74 + err = templates.Render(w, "delete_account.html", data) 75 + if err != nil { 76 + t.Fatalf("Render() error = %v", err) 77 + } 78 + 79 + body := w.Body.String() 80 + if !bytes.Contains([]byte(body), []byte("Sign In")) { 81 + t.Error("Logged out state does not show sign in button") 82 + } 83 + 84 + // Test logged in state 85 + dataLoggedIn := DeleteAccountPageData{ 86 + LoggedIn: true, 87 + Handle: "testuser.bsky.social", 88 + DID: "did:plc:test123", 89 + } 90 + 91 + w2 := httptest.NewRecorder() 92 + err = templates.Render(w2, "delete_account.html", dataLoggedIn) 93 + if err != nil { 94 + t.Fatalf("Render() error = %v", err) 95 + } 96 + 97 + body2 := w2.Body.String() 98 + if !bytes.Contains([]byte(body2), []byte("@testuser.bsky.social")) { 99 + t.Error("Logged in state does not show user handle") 100 + } 101 + if !bytes.Contains([]byte(body2), []byte("Delete My Account")) { 102 + t.Error("Logged in state does not show delete button") 103 + } 104 + } 105 + 106 + func TestTemplatesRender_DeleteSuccess(t *testing.T) { 107 + templates, err := NewTemplates() 108 + if err != nil { 109 + t.Fatalf("NewTemplates() error = %v", err) 110 + } 111 + 112 + w := httptest.NewRecorder() 113 + err = templates.Render(w, "delete_success.html", nil) 114 + if err != nil { 115 + t.Fatalf("Render() error = %v", err) 116 + } 117 + 118 + body := w.Body.String() 119 + if !bytes.Contains([]byte(body), []byte("Account Deleted")) { 120 + t.Error("Success page does not contain confirmation message") 121 + } 122 + if !bytes.Contains([]byte(body), []byte("Return to Homepage")) { 123 + t.Error("Success page does not contain return link") 124 + } 125 + } 126 + 127 + func TestTemplatesRender_NotFound(t *testing.T) { 128 + templates, err := NewTemplates() 129 + if err != nil { 130 + t.Fatalf("NewTemplates() error = %v", err) 131 + } 132 + 133 + w := httptest.NewRecorder() 134 + err = templates.Render(w, "nonexistent.html", nil) 135 + if err == nil { 136 + t.Fatal("Render() should return error for nonexistent template") 137 + } 138 + } 139 + 140 + func TestTemplatesRender_Privacy(t *testing.T) { 141 + templates, err := NewTemplates() 142 + if err != nil { 143 + t.Fatalf("NewTemplates() error = %v", err) 144 + } 145 + 146 + w := httptest.NewRecorder() 147 + err = templates.Render(w, "privacy.html", nil) 148 + if err != nil { 149 + t.Fatalf("Render() error = %v", err) 150 + } 151 + 152 + body := w.Body.String() 153 + if !bytes.Contains([]byte(body), []byte("Privacy Policy")) { 154 + t.Error("Privacy page does not contain title") 155 + } 156 + if !bytes.Contains([]byte(body), []byte("Coves Team")) { 157 + t.Error("Privacy page does not contain company name") 158 + } 159 + if !bytes.Contains([]byte(body), []byte("support@coves.social")) { 160 + t.Error("Privacy page does not contain contact email") 161 + } 162 + if !bytes.Contains([]byte(body), []byte("atProto")) { 163 + t.Error("Privacy page does not mention atProto") 164 + } 165 + if !bytes.Contains([]byte(body), []byte("18 years of age or older")) { 166 + t.Error("Privacy page does not contain age requirement") 167 + } 168 + }
+14
static/images/coves_bubble.svg
··· 1 + <svg width="1096" height="362" viewBox="0 0 1096 362" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M851.184 35.2918C887.459 -1.98182 938.123 -6.86801 986.765 7.37694C997.048 10.3869 1010.63 16.8788 1018.91 23.839C1026.28 30.0334 1030.64 42.4002 1037.45 49.5248C1061.81 77.4918 1098.5 101.792 1083.41 144.277C1077.62 160.586 1071.3 168.903 1055.58 176.668C1073.47 193.146 1088.36 211.726 1093.46 235.787C1104.39 287.43 1079.56 338.131 1028.11 354.446C981.985 369.084 929.116 363.027 892.069 331.195C883.143 361.338 865.612 356.724 839.269 356.699L795.495 356.668L754.334 356.743C747.054 356.749 734.441 357.145 727.776 355.883C715.164 352.186 708.776 340.473 700.717 330.862C689.247 317.19 678.745 302.589 666.245 289.79C659.801 304.039 646.145 338.106 635.982 347.139C625.141 356.781 608.326 360.491 594.155 359.687C584.105 359.116 572.636 356.36 564.401 350.296C554.558 343.046 537.379 318.734 528.445 307.831C520.226 297.8 511.364 288.233 502.738 278.554C494.119 298.829 487.782 310.769 471.551 326.694C447.692 350.108 415.666 361.106 382.459 360.447C354.807 359.901 327.931 352.205 305.812 335.137C298.244 329.293 292.271 323.059 285.643 316.267C282.706 326.826 276.078 337.365 266.423 342.632C203.182 377.1 116.464 360.61 74.6014 300.581C58.9416 278.127 35.5772 259.741 21.7459 235.806C-17.8863 167.214 -3.69572 65.5036 67.192 22.6991C102.55 1.27985 145.059 -4.97858 185.095 5.34247C210.538 11.8024 224.448 19.7324 237.682 41.9733C264.93 14.0785 293.017 2.30679 331.729 0.340135C366.798 -1.5512 401.788 12.2506 426.72 36.7457C439.42 12.68 464.121 0.915763 490.873 1.09027C502.002 0.252258 527.31 9.15903 532.888 19.5334C541.751 36.0011 548.465 43.1095 561.983 57.0519C565.13 46.4986 570.268 30.6479 576.574 21.7468C595.908 -5.56676 638.551 -3.42871 663.325 14.9115C667.514 11.5708 670.234 9.52311 675.202 7.38257C679.072 5.71094 683.18 4.67457 687.369 4.31238C694.611 3.64824 703.996 3.88052 711.477 3.86545L789.773 3.78572C822.309 3.7807 837.535 1.61065 851.184 35.2918Z" fill="black"/> 3 + <path d="M248.008 241.819C199.438 175.623 215.062 46.8986 305.582 24.8613C352.394 13.4652 397.577 27.1413 424.865 67.9315C435.376 83.6421 439.75 98.1197 444.27 116.13C452.311 154.989 447.885 202.484 425.758 236.026C410.331 259.386 386.101 275.499 358.6 280.681C317.716 288.785 273.622 275.606 248.008 241.819Z" fill="#FE6446"/> 4 + <path d="M444.27 116C452.311 154.89 447.885 202.422 425.757 235.99C410.329 259.368 386.098 275.493 358.597 280.679C317.711 288.789 273.616 275.6 248 241.787C252.018 240.249 257.457 241.944 261.795 242.65C267.939 243.69 274.138 244.37 280.361 244.692C351.103 248.422 397.904 206.902 428.303 147.051C431.815 140.132 439.538 119.642 444.27 116Z" fill="#FF6B35"/> 5 + <path d="M333.207 92C357.907 93.0363 365.169 112.606 367.931 134.067C370.989 157.83 369.349 205.681 337.506 209C329.577 208.806 323.603 208.424 316.729 203.333C307.154 196.239 302.789 180.785 301.218 169.307C298.493 149.389 299.618 120.36 312.13 103.416C317.546 96.0807 324.501 93.0075 333.207 92Z" fill="black"/> 6 + <path d="M670.243 236.583L670.356 71.9859C670.363 67.6078 670.382 63.1196 670.407 58.7446C670.502 41.2932 672.25 25.3433 694.132 25.1885C730.378 24.9311 766.587 25.1004 802.833 25.0104C807.289 24.9991 811.827 24.916 816.252 25.4994C839.175 30.0386 837.799 76.9833 825.226 89.6827C818.984 95.9884 797.784 94.4957 789.744 94.524L746.214 94.6801L746.251 115.676C756.823 115.729 767.395 115.712 777.966 115.626C784.656 115.609 793.063 115.659 799.596 116.519C823.699 119.696 826.142 178.259 801.685 182.375C795.544 183.407 784.164 183.211 777.26 183.186L746.7 183.205L746.687 208.812C767.811 209.397 791.138 208.699 812.61 208.944C834.34 209.68 836.335 231.587 835.962 248.496C835.767 257.489 833.135 268.955 826.053 274.871C819.571 280.289 802.865 278.823 794.42 278.81L718.368 278.646C706.262 278.684 683.756 281.258 675.355 271.567C668.463 263.612 670.161 246.81 670.243 236.583Z" fill="#FE6446"/> 7 + <path d="M812.61 209C834.34 209.736 836.335 231.624 835.962 248.521C835.767 257.506 833.135 268.963 826.053 274.874C819.571 280.288 802.865 278.823 794.42 278.81L718.368 278.646C706.262 278.684 683.756 281.256 675.355 271.573C668.463 263.624 670.161 246.835 670.243 236.617C678.139 236.812 688.313 239.019 696.726 239.554C728.908 241.61 759.954 236.762 789.371 223.324C794.591 220.935 810.899 213.597 812.61 209Z" fill="#FF6B35"/> 8 + <path d="M846 236.551C846.391 221.828 851.361 206.682 863.298 197.284C880.35 183.852 895.449 201.45 911.506 206.493C915.764 207.829 919.682 209.184 924.142 209.688C932.835 211.705 959.531 208.112 946.171 194.561C942.297 190.634 930.605 185.768 925.434 183.676C904.407 175.173 882.492 166.532 866.781 149.432C840.331 120.625 842.787 70.2558 871.865 44.0833C899.336 19.5547 940.489 16.9497 974.801 25.2797C995.966 30.3107 1015.31 39.3725 1013.02 64.9159C1011.49 82.3323 998.933 109.511 977.094 102.971C961.969 98.442 946.101 88.1927 929.44 92.1182C925.912 92.949 923.525 94.6829 923.071 98.483C924.319 102.967 935.229 108.895 939.462 110.66C961.44 119.815 980.564 126.788 999.588 141.78C1031.78 168.971 1034.56 218.916 1007.86 250.884C991.909 269.976 965.944 280.042 941.547 281.712C916.753 283.105 878.479 279.853 859.014 262.866C850.485 255.416 847.165 247.481 846 236.551Z" fill="#FE6446"/> 9 + <path d="M999.588 142C1031.78 169.148 1034.56 219.015 1007.86 250.933C991.909 269.995 965.944 280.044 941.547 281.712C916.753 283.103 878.479 279.856 859.014 262.896C850.485 255.458 847.165 247.535 846 236.623C913.66 252.67 972.949 219.719 995.872 153.604C997.05 150.225 997.843 146.443 999.122 143.158L999.588 142Z" fill="#FF6B35"/> 10 + <path d="M555.303 159.129C556.832 148.192 563.237 128.092 566.502 117.076C573.466 93.4463 580.708 69.9014 588.232 46.4457C591.258 37.2511 594.385 30.5293 603.98 25.6924C623.943 14.4935 668.241 29.93 664.812 56.3428C663.113 69.436 652.543 94.1086 647.724 107.117L613.109 201.611C608.384 214.674 603.495 227.673 598.456 240.616C595.707 247.679 591.277 259.81 587.27 266.049C577.763 280.847 550.792 282.482 535.63 277.382C519.405 271.923 514.983 254.496 509.837 240.012C506.763 229.824 499.85 212.667 495.98 202.165L459.744 104.767C455.375 92.8331 450.462 80.9808 446.518 68.9624C443.635 60.2181 442.572 53.1071 446.908 44.4772C458.352 21.707 506.363 12.0828 519.363 37.2951C524.552 47.3582 529.232 66.4713 532.723 78.5683C540.619 105.319 548.143 132.174 555.303 159.129Z" fill="#FE6446"/> 11 + <path d="M510 240.217C523.458 235.58 533.376 234.492 547.522 227.233C585.953 207.499 611.201 167.949 631.785 131.374C636.111 123.691 639.868 112.64 647 108L612.607 202.012C607.913 215.007 603.056 227.94 598.049 240.817C595.317 247.844 590.916 259.913 586.934 266.12C577.489 280.843 550.691 282.47 535.626 277.395C519.506 271.964 515.113 254.626 510 240.217Z" fill="#FF6B35"/> 12 + <path d="M58.1592 248.089C49.8246 239.347 42.7202 229.51 37.0411 218.848C4.27592 157.249 22.0929 67.7222 86.9317 35.1588C118.762 19.5068 153.373 16.7459 187.287 27.3041C212.208 35.0624 225.684 49.9768 217.407 76.9327C214.424 86.647 207.503 99.3056 197.994 103.921C186.279 109.607 173.678 101.172 162.756 97.4856C80.0324 71.5959 75.4357 225.92 160.165 204.709C171.49 201.875 184.337 193.033 196.068 196.648C206.006 199.709 212.364 210.642 216.706 219.427C225.885 240.273 224.72 262.385 200.404 272.266C156.506 290.114 91.8953 284.188 58.1592 248.089Z" fill="#FE6446"/> 13 + <path d="M216.7 219C225.888 239.988 224.722 262.251 200.383 272.199C156.442 290.169 91.7689 284.203 58 247.857C64.2969 245.498 79.3874 250.837 87.1261 252.055C126.087 258.154 161.392 250.786 196.005 232.195C203.078 228.397 209.711 223.191 216.7 219Z" fill="#FF6B35"/> 14 + </svg>
static/images/lil_dude.png

This is a binary file and will not be displayed.

+2 -1
.claude/settings.json
··· 13 13 ] 14 14 }, 15 15 "enabledPlugins": { 16 - "pr-review-toolkit@claude-plugins-official": true 16 + "pr-review-toolkit@claude-plugins-official": true, 17 + "frontend-design@claude-plugins-official": true 17 18 } 18 19 }
+20 -13
go.mod
··· 7 7 require ( 8 8 github.com/bluesky-social/indigo v0.0.0-20251010013709-8f2296eee90f 9 9 github.com/go-chi/chi/v5 v5.2.1 10 - github.com/golang-jwt/jwt/v5 v5.3.0 11 - github.com/google/uuid v1.6.0 10 + github.com/go-chi/cors v1.2.2 12 11 github.com/gorilla/websocket v1.5.3 13 12 github.com/hashicorp/golang-lru/v2 v2.0.7 14 - github.com/lestrrat-go/jwx/v2 v2.0.12 15 13 github.com/lib/pq v1.10.9 16 14 github.com/pressly/goose/v3 v3.22.1 15 + github.com/rivo/uniseg v0.4.7 17 16 github.com/stretchr/testify v1.10.0 18 17 github.com/xeipuuv/gojsonschema v1.2.0 19 18 golang.org/x/net v0.46.0 ··· 24 23 github.com/beorn7/perks v1.0.1 // indirect 25 24 github.com/cespare/xxhash/v2 v2.2.0 // indirect 26 25 github.com/davecgh/go-spew v1.1.1 // indirect 27 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 26 + github.com/disintegration/imaging v1.6.2 // indirect 28 27 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 29 28 github.com/felixge/httpsnoop v1.0.4 // indirect 30 - github.com/go-chi/cors v1.2.2 // indirect 31 29 github.com/go-logr/logr v1.4.1 // indirect 32 30 github.com/go-logr/stdr v1.2.2 // indirect 33 - github.com/goccy/go-json v0.10.2 // indirect 34 31 github.com/gogo/protobuf v1.3.2 // indirect 32 + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect 35 33 github.com/google/go-querystring v1.1.0 // indirect 34 + github.com/google/uuid v1.6.0 // indirect 36 35 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 37 36 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 38 37 github.com/hashicorp/golang-lru v1.0.2 // indirect ··· 50 49 github.com/ipfs/go-metrics-interface v0.0.1 // indirect 51 50 github.com/jbenet/goprocess v0.1.4 // indirect 52 51 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 53 - github.com/lestrrat-go/blackmagic v1.0.1 // indirect 54 - github.com/lestrrat-go/httpcc v1.0.1 // indirect 55 - github.com/lestrrat-go/httprc v1.0.4 // indirect 56 - github.com/lestrrat-go/iter v1.0.2 // indirect 57 - github.com/lestrrat-go/option v1.0.1 // indirect 58 52 github.com/mattn/go-isatty v0.0.20 // indirect 59 53 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 60 54 github.com/mfridman/interpolate v0.0.2 // indirect ··· 72 66 github.com/prometheus/client_model v0.5.0 // indirect 73 67 github.com/prometheus/common v0.45.0 // indirect 74 68 github.com/prometheus/procfs v0.12.0 // indirect 75 - github.com/rivo/uniseg v0.4.7 // indirect 76 - github.com/segmentio/asm v1.2.0 // indirect 77 69 github.com/sethvargo/go-retry v0.3.0 // indirect 78 70 github.com/spaolacci/murmur3 v1.1.0 // indirect 71 + github.com/stretchr/objx v0.5.2 // indirect 79 72 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 80 73 github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect 81 74 github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + go.uber.org/multierr v1.11.0 // indirect 83 + go.uber.org/zap v1.26.0 // indirect 84 + golang.org/x/crypto v0.43.0 // indirect 85 + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect 86 + golang.org/x/sync v0.10.0 // indirect 87 + golang.org/x/sys v0.37.0 // indirect 88 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
+7 -56
go.sum
··· 10 10 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 11 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 12 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 - github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 14 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 15 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 13 + github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= 14 + github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 16 15 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 17 16 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 18 17 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= ··· 29 28 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 30 29 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 31 30 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 32 - github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 33 - github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 34 31 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 35 32 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 36 33 github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= ··· 100 97 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 101 98 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 102 99 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 103 - github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80= 104 - github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 105 - github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 106 - github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 107 - github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= 108 - github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 109 - github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 110 - github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 111 - github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA= 112 - github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ= 113 - github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 114 - github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 115 - github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 116 100 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 117 101 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 118 102 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= ··· 157 141 github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 158 142 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 159 143 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 160 - github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= 161 - github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 162 144 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 163 145 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 164 146 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 165 147 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 166 148 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 167 149 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 168 - github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 169 - github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 170 150 github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= 171 151 github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= 172 152 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= ··· 177 157 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 178 158 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 179 159 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 180 - github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 181 - github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 160 + github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 161 + github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 182 162 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 183 163 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 184 164 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 185 - github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 186 165 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 187 - github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 188 - github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 189 - github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 190 166 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 191 167 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 192 168 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= ··· 203 179 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 204 180 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 205 181 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 206 - github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 207 182 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 208 183 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 209 184 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 236 211 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 237 212 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 238 213 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 239 - golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 240 - golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 241 214 golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 242 215 golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 216 + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= 217 + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 243 218 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 244 - 219 + golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 245 220 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 246 221 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 247 222 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 248 - golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 249 - golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 250 223 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 251 224 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 252 225 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 253 226 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 254 227 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 255 - golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 256 228 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 257 - golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 258 - golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 259 - golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 260 229 golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 261 230 golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 262 231 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 263 232 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 264 233 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 265 234 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 266 - golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 267 - golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 268 235 golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 269 236 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 270 237 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= ··· 273 240 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 274 241 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 275 242 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 276 - golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 277 243 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 278 - golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 279 - golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 280 - golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 281 244 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 282 245 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 283 - golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 284 - golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 285 246 golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 286 247 golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 287 248 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 288 - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 289 - golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 290 - golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 291 - golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 292 249 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 293 250 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 294 - golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 295 - golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 296 - golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 297 - golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 298 251 golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 299 252 golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 300 253 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 307 260 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 308 261 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 309 262 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 310 - golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 311 - golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 312 263 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 313 264 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 314 265 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+28
internal/core/comments/comment_service_test.go
··· 201 201 return &users.ProfileStats{}, nil 202 202 } 203 203 204 + func (m *mockUserRepo) Delete(ctx context.Context, did string) error { 205 + if _, ok := m.users[did]; !ok { 206 + return users.ErrUserNotFound 207 + } 208 + delete(m.users, did) 209 + return nil 210 + } 211 + 212 + func (m *mockUserRepo) UpdateProfile(ctx context.Context, did string, input users.UpdateProfileInput) (*users.User, error) { 213 + user, exists := m.users[did] 214 + if !exists { 215 + return nil, users.ErrUserNotFound 216 + } 217 + if input.DisplayName != nil { 218 + user.DisplayName = *input.DisplayName 219 + } 220 + if input.Bio != nil { 221 + user.Bio = *input.Bio 222 + } 223 + if input.AvatarCID != nil { 224 + user.AvatarCID = *input.AvatarCID 225 + } 226 + if input.BannerCID != nil { 227 + user.BannerCID = *input.BannerCID 228 + } 229 + return user, nil 230 + } 231 + 204 232 // mockPostRepo is a mock implementation of the posts.Repository interface 205 233 type mockPostRepo struct { 206 234 posts map[string]*posts.Post
static/images/lil_dude_body.png

This is a binary file and will not be displayed.

static/images/lil_dude_claw.png

This is a binary file and will not be displayed.

+1 -1
PROJECT_STRUCTURE.md
··· 50 50 1. **Start with Lexicons**: Define data schemas first 51 51 2. **Implement Core Domain**: Create models and interfaces 52 52 3. **Build Services**: Implement business logic 53 - 5. **Wire XRPC**: Connect handlers last 53 + 4. **Wire XRPC**: Connect handlers last
+80
static/images/coves_logo_text.svg
··· 1 + <svg width="1310" height="406" viewBox="0 0 1310 406" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <mask id="path-1-outside-1_50_75" maskUnits="userSpaceOnUse" x="-0.00585938" y="-0.00390625" width="1311" height="406" fill="black"> 3 + <rect fill="white" x="-0.00585938" y="-0.00390625" width="1311" height="406"/> 4 + <path d="M320.132 201.184C321.683 186.461 325.516 172.069 331.494 158.526C335.776 148.794 341.667 139.226 348.379 131.077C392.112 77.9841 487.808 76.9853 530.626 131.984C541.251 145.632 546.222 157.166 552.272 173.071C559.743 198.749 561.501 219.036 558.799 245.647C548.182 320.908 494.724 365.408 418.12 354.309C389.235 350.403 363.206 334.846 346.082 311.258C336.515 298.327 331.977 289.015 326.797 273.922C319.18 247.016 317.556 228.701 320.132 201.184ZM441.692 261.373C459.846 252.65 455.889 185.406 437.476 185.255C419.614 193.476 424.145 240.241 433.35 254.842C435.801 258.733 437.272 260.042 441.692 261.373Z"/> 5 + <path d="M320.132 201.662C329.273 200.674 341.406 197.902 350.841 196.198C368.632 192.884 386.528 190.141 404.5 187.996C397.494 209.949 398.468 238.996 406.994 260.274C380.091 263.8 353.339 268.378 326.799 273.996C319.18 247.239 317.556 229.026 320.132 201.662Z"/> 6 + <path d="M469.994 179.65C497.382 176.837 524.826 174.618 552.308 172.996C559.744 198.883 561.494 219.333 558.805 246.161C533.354 246.094 502.416 249.757 477.029 251.996C481.694 225.984 479.912 203.991 469.994 179.65Z"/> 7 + <path d="M517.994 209.496C517.994 252.85 482.848 287.996 439.494 287.996C396.14 287.996 360.994 252.85 360.994 209.496C360.994 166.142 396.14 130.996 439.494 130.996C482.848 130.996 517.994 166.142 517.994 209.496Z"/> 8 + <path d="M517.994 209.496C517.994 166.142 482.848 130.996 439.494 130.996C396.14 130.996 360.994 166.142 360.994 209.496C360.994 252.85 396.14 287.996 439.494 287.996C482.848 287.996 517.994 252.85 517.994 209.496ZM530.994 209.496C530.994 260.03 490.028 300.996 439.494 300.996C388.96 300.996 347.994 260.03 347.994 209.496C347.994 158.962 388.96 117.996 439.494 117.996C490.028 117.996 530.994 158.962 530.994 209.496Z"/> 9 + <path d="M498.994 248.996C526.494 238.496 527.994 240.996 557.494 251.996C546.877 327.018 494.919 365.376 418.316 354.312C389.432 350.419 363.402 334.911 346.279 311.397C333.994 299.496 323.494 267.996 321.494 251.996C345.494 239.81 383.097 244.53 409.994 240.996C420.816 240.997 430.028 246.525 442.494 248.996C472.494 240.996 475.494 238.996 498.994 248.996Z"/> 10 + <path d="M399.258 231.707C396.133 233.134 392.723 235.274 391.587 236.559C389.172 239.698 378.233 239.841 375.392 236.844C367.863 229.424 349.963 228.425 341.155 235.132C338.03 237.7 333.91 239.27 329.649 239.698C321.835 240.411 318.994 242.409 318.994 246.975C318.994 249.401 320.131 250.97 322.688 251.826C330.359 254.823 338.314 252.825 347.264 245.976C352.378 241.838 360.333 242.552 368.289 247.546C373.829 251.113 376.386 251.826 383.773 251.826C389.882 251.826 393.575 251.113 395.848 249.258C406.219 241.695 411.617 241.41 421.845 247.831C427.954 251.684 430.511 252.397 437.898 252.397C448.268 252.54 450.825 250.828 448.837 245.12C447.7 241.695 446.422 240.982 438.608 239.555C433.778 238.699 428.664 236.987 427.244 235.703C423.408 232.278 414.884 228.996 409.628 228.996C407.071 228.996 402.383 230.28 399.258 231.707Z"/> 11 + <path d="M512.426 231.707C509.301 233.134 505.891 235.274 504.755 236.559C502.34 239.698 491.401 239.841 488.56 236.844C481.031 229.424 463.131 228.425 454.323 235.132C451.198 237.7 447.078 239.27 442.817 239.698C435.003 240.411 432.162 242.409 432.162 246.975C432.162 249.401 433.299 250.97 435.856 251.826C443.527 254.823 451.482 252.825 460.432 245.976C465.546 241.838 473.501 242.552 481.457 247.546C486.997 251.113 489.554 251.826 496.941 251.826C503.05 251.826 506.743 251.113 509.016 249.258C519.387 241.695 524.785 241.41 535.013 247.831C541.122 251.684 543.679 252.397 551.066 252.397C561.436 252.54 563.993 250.828 562.005 245.12C560.868 241.695 559.59 240.982 551.776 239.555C546.946 238.699 541.832 236.987 540.412 235.703C536.576 232.278 528.052 228.996 522.796 228.996C520.239 228.996 515.551 230.28 512.426 231.707Z"/> 12 + <path d="M460.753 260.501C456.713 262.346 452.305 265.113 450.836 266.774C447.714 270.832 433.573 271.017 429.901 267.143C420.167 257.55 397.028 256.258 385.643 264.929C381.602 268.25 376.277 270.279 370.767 270.832C360.667 271.755 356.994 274.338 356.994 280.241C356.994 283.377 358.463 285.407 361.769 286.513C371.686 290.388 381.97 287.805 393.539 278.95C400.15 273.6 410.434 274.522 420.718 280.979C427.88 285.591 431.186 286.513 440.736 286.513C448.632 286.513 453.407 285.591 456.345 283.193C469.751 273.415 476.73 273.046 489.952 281.348C497.849 286.329 501.154 287.251 510.704 287.251C524.11 287.436 527.415 285.222 524.844 277.843C523.375 273.415 521.722 272.493 511.622 270.648C505.378 269.541 498.767 267.327 496.93 265.667C491.972 261.239 480.953 256.996 474.159 256.996C470.853 256.996 464.793 258.657 460.753 260.501Z"/> 13 + <path d="M22.9566 250.635C10.3594 190.03 12.1659 125.15 51.0851 73.5085C96.5037 13.243 198.985 -4.01784 259.516 42.7293C275.211 55.2342 286.283 70.5282 288.652 90.809C291.686 115.283 274.191 135.564 249.469 136.717C204.345 138.821 216.713 91.7167 165.596 96.3897C150.139 97.6612 135.866 105.173 126.066 117.193C106.394 141.458 106.305 180.184 109.349 209.609C114.428 234.642 116.301 253.73 131.801 276.771C123.23 280.571 114.099 283.739 105.378 287.362C85.0213 295.814 67.2742 304.913 48.8178 316.996C36.7828 299.577 27.739 271.31 22.9566 250.635Z"/> 14 + <path d="M109.51 209.996C114.596 234.939 116.472 253.957 131.994 276.916C123.411 280.702 114.266 283.858 105.534 287.469C85.1476 295.89 67.375 304.957 48.8923 316.996C36.84 299.64 27.7834 271.475 22.9941 250.874C42.6972 235.928 80.6837 217.246 105.715 211.138C107.472 210.714 107.173 211.497 109.51 209.996Z"/> 15 + <path d="M109.51 209.996C114.596 234.939 116.472 253.957 131.994 276.916C123.411 280.702 114.266 283.858 105.534 287.469C85.1476 295.89 67.375 304.957 48.8923 316.996C36.84 299.64 27.7834 271.475 22.9941 250.874C42.6972 235.928 80.6837 217.246 105.715 211.138C107.472 210.714 107.173 211.497 109.51 209.996Z"/> 16 + <path d="M816.178 183.757C826.182 147.588 841.217 118.152 875.316 99.0176C926.881 70.0784 1007.15 82.8862 1036.26 138.022C1047.3 158.904 1051.35 179.225 1050.97 202.399C1049.74 232.205 1037.75 245.942 1006.9 243.317C968.524 240.05 929.968 237.418 891.581 234.291C892.539 247.122 893.718 252.063 898.955 263.839C895.206 265.093 870.174 258.478 864.082 257.077C847.419 253.198 830.624 249.88 813.74 247.137C810.403 224.314 812.2 206.019 816.178 183.757Z"/> 17 + <path d="M816.179 183.996C833.83 187.143 853.692 195.502 871.388 200.207C919.342 212.963 969.034 218.227 1018.27 210.199C1028.36 208.567 1041.03 205.111 1050.99 202.582C1049.76 232.299 1037.77 245.996 1006.92 243.379C968.54 240.122 929.98 237.497 891.589 234.38C892.547 247.172 893.726 252.098 898.964 263.839C895.214 265.089 870.18 258.495 864.088 257.098C847.423 253.23 830.626 249.922 813.74 247.187C810.403 224.432 812.2 206.192 816.179 183.996Z"/> 18 + <path d="M932.748 144.246C933.994 144.145 935.239 144.073 936.484 144.031C966.211 142.94 974.26 167.937 974.994 191.996C963.462 191.178 951.937 190.228 940.427 189.16C925.359 187.849 910.114 187.09 894.994 185.86C899.952 165.29 910.032 147.965 932.748 144.246Z"/> 19 + <path d="M1084.21 186.781C1083.91 179.865 1083.84 174.425 1084.53 167.552C1089.3 119.599 1129.08 91.6561 1175.6 91.0148C1213.3 90.4949 1246.99 100.795 1276.63 126.238C1302.22 148.198 1301.51 194.336 1261.09 197.458C1251.78 198.179 1240.24 193.253 1234.14 186.288C1230.05 181.708 1226.44 176.662 1222.62 171.376C1212.02 156.705 1184.3 149.617 1169.61 161.522C1162.7 167.125 1162.74 182.194 1169.91 187.856C1192.54 207.464 1221.84 217.441 1244.77 236.873C1273.97 258.432 1282.8 290.411 1274.49 324.996C1253.42 308.385 1234.98 297.148 1209.18 289.189C1201.42 286.788 1189.2 280.883 1181.33 280.942C1139.8 253.418 1093.94 242.322 1084.21 186.781Z"/> 20 + <path d="M1083.99 186.996C1092.98 190.282 1117.58 194.135 1128.67 196.892C1160.25 204.905 1191.13 215.521 1220.97 228.636C1225.13 230.496 1239.46 237.451 1243.05 238.164C1243.44 237.841 1244.07 237.385 1244.34 237.01C1273.5 258.536 1282.32 290.465 1274.02 324.996C1252.98 308.411 1234.56 297.192 1208.8 289.244C1201.05 286.848 1188.85 280.952 1180.98 281.01C1139.51 253.53 1093.72 242.45 1083.99 186.996Z"/> 21 + <path d="M131.972 276.481C143.384 291.69 156.742 302.131 174.684 304.393C205.909 308.349 227.403 294.825 244.046 269.486C249.743 260.813 268.339 255.763 279.297 257.256C289.731 258.595 299.158 264.185 305.367 272.732C313.338 283.579 315.197 301.495 310.302 313.991C289.136 367.99 225.109 388.922 172.003 388.996C124.165 389.062 81.4467 365.964 54.7831 326.258C52.8782 323.403 50.4904 320.032 48.9941 316.948C67.4494 304.793 85.1955 295.639 105.551 287.136C114.271 283.49 123.402 280.304 131.972 276.481Z"/> 22 + <path d="M1273.99 325.145C1260.62 367.51 1227.16 389.564 1182.77 389.988C1143.87 390.354 1099.25 378.57 1070.4 351.718C1051.64 334.251 1042.09 305.037 1062.22 284.512C1069.32 277.19 1076.68 275.003 1086.71 274.996C1109.67 275.135 1116.98 293.78 1130.39 308.416C1143.18 322.387 1173.7 330.162 1189.03 316.47C1192.61 313.266 1194.56 305.447 1194.11 300.751C1193.3 292.339 1186.99 286.516 1180.9 281.382C1188.77 281.323 1200.98 287.189 1208.73 289.574C1234.51 297.481 1252.93 308.643 1273.99 325.145Z"/> 23 + <path d="M813.994 246.996C830.791 249.742 847.5 253.064 864.078 256.946C870.138 258.349 895.041 264.97 898.771 263.715C914.894 288.377 941.797 292.067 967.938 281.519C976.835 277.932 983.819 272.669 993.435 270.787C1010.19 267.502 1025.34 278.22 1028.22 295.049C1035.15 330.156 994.204 345.753 967.337 349.886C916.228 357.747 860.069 344.357 831.546 297.618C821.601 281.313 817.006 265.804 813.994 246.996Z"/> 24 + <path d="M581.926 165.286L569.757 134.93C563.996 120.394 559.705 108.308 566.391 92.5046C578.002 65.0612 623.989 62.1057 639.595 86.9899C646.777 98.4495 650.519 115.389 654.909 128.484C658.827 140.188 661.766 152.594 665.943 164.44L680.284 212.177C682.391 219.207 684.659 228.12 686.994 234.812C686.582 235.172 686.169 235.54 685.757 235.9C664.072 236.797 630.584 235.033 610.047 236.996C607.925 230.554 603.396 220.097 600.764 213.478C594.327 197.476 588.048 181.41 581.926 165.286Z"/> 25 + <path d="M581.994 165.332C602.958 163.139 644.573 164.251 665.956 164.487L680.288 212.193C682.394 219.218 684.661 228.126 686.994 234.813C686.582 235.173 686.17 235.541 685.758 235.901C664.087 236.798 630.621 235.034 610.098 236.996C607.977 230.558 603.451 220.108 600.821 213.493C594.388 197.502 588.113 181.446 581.994 165.332Z"/> 26 + <path d="M710.057 165.015C716.317 145.973 729.153 99.3514 738.66 85.6755C742.179 80.6207 746.653 76.7058 752.201 74.0015C761.899 69.2659 774.08 68.7422 784.249 72.3629C793.954 75.8173 802.322 82.6424 806.657 92.1358C812.652 105.27 809.692 118.15 804.666 130.871C798.906 145.436 792.69 159.829 786.996 174.419C778.003 196.221 769.15 218.079 760.444 239.996C747.506 236.297 704.415 237.473 689.206 235.525C688.802 235.135 688.398 234.737 687.994 234.348C695.635 212.291 703.004 187.396 710.057 165.015Z"/> 27 + <path d="M687.994 234.346C695.635 212.284 703.004 187.383 710.057 164.996C714.781 166.428 731.892 167.321 737.755 167.967C754.264 169.784 770.61 171.303 786.994 174.402C778.001 196.21 769.148 218.074 760.442 239.996C747.504 236.296 704.415 237.473 689.206 235.523C688.802 235.133 688.398 234.736 687.994 234.346Z"/> 28 + <path d="M686.812 235.003L687.481 234.996C687.886 235.384 688.29 235.779 688.695 236.167C703.917 238.106 747.045 236.935 759.994 240.615C751.302 261.247 743.544 282.246 734.617 302.871C728.639 316.684 720.624 327.959 705.961 333.643C693.452 338.494 674.686 338.041 662.421 332.546C654.347 328.888 647.427 323.115 642.397 315.843C636.363 307.18 631.298 291.267 627.245 280.797L609.994 237.176C630.496 235.223 663.928 236.979 685.577 236.086C685.989 235.728 686.4 235.362 686.812 235.003Z"/> 29 + </mask> 30 + <path d="M320.132 201.184C321.683 186.461 325.516 172.069 331.494 158.526C335.776 148.794 341.667 139.226 348.379 131.077C392.112 77.9841 487.808 76.9853 530.626 131.984C541.251 145.632 546.222 157.166 552.272 173.071C559.743 198.749 561.501 219.036 558.799 245.647C548.182 320.908 494.724 365.408 418.12 354.309C389.235 350.403 363.206 334.846 346.082 311.258C336.515 298.327 331.977 289.015 326.797 273.922C319.18 247.016 317.556 228.701 320.132 201.184ZM441.692 261.373C459.846 252.65 455.889 185.406 437.476 185.255C419.614 193.476 424.145 240.241 433.35 254.842C435.801 258.733 437.272 260.042 441.692 261.373Z" fill="#F28C5B"/> 31 + <path d="M320.132 201.662C329.273 200.674 341.406 197.902 350.841 196.198C368.632 192.884 386.528 190.141 404.5 187.996C397.494 209.949 398.468 238.996 406.994 260.274C380.091 263.8 353.339 268.378 326.799 273.996C319.18 247.239 317.556 229.026 320.132 201.662Z" fill="#FFB36B"/> 32 + <path d="M469.994 179.65C497.382 176.837 524.826 174.618 552.308 172.996C559.744 198.883 561.494 219.333 558.805 246.161C533.354 246.094 502.416 249.757 477.029 251.996C481.694 225.984 479.912 203.991 469.994 179.65Z" fill="#FFB36B"/> 33 + <path d="M517.994 209.496C517.994 252.85 482.848 287.996 439.494 287.996C396.14 287.996 360.994 252.85 360.994 209.496C360.994 166.142 396.14 130.996 439.494 130.996C482.848 130.996 517.994 166.142 517.994 209.496Z" fill="#FA8E32"/> 34 + <path d="M517.994 209.496C517.994 166.142 482.848 130.996 439.494 130.996C396.14 130.996 360.994 166.142 360.994 209.496C360.994 252.85 396.14 287.996 439.494 287.996C482.848 287.996 517.994 252.85 517.994 209.496ZM530.994 209.496C530.994 260.03 490.028 300.996 439.494 300.996C388.96 300.996 347.994 260.03 347.994 209.496C347.994 158.962 388.96 117.996 439.494 117.996C490.028 117.996 530.994 158.962 530.994 209.496Z" fill="#1B2A2E"/> 35 + <path d="M498.994 248.996C526.494 238.496 527.994 240.996 557.494 251.996C546.877 327.018 494.919 365.376 418.316 354.312C389.432 350.419 363.402 334.911 346.279 311.397C333.994 299.496 323.494 267.996 321.494 251.996C345.494 239.81 383.097 244.53 409.994 240.996C420.816 240.997 430.028 246.525 442.494 248.996C472.494 240.996 475.494 238.996 498.994 248.996Z" fill="#5FB6B3"/> 36 + <path d="M399.258 231.707C396.133 233.134 392.723 235.274 391.587 236.559C389.172 239.698 378.233 239.841 375.392 236.844C367.863 229.424 349.963 228.425 341.155 235.132C338.03 237.7 333.91 239.27 329.649 239.698C321.835 240.411 318.994 242.409 318.994 246.975C318.994 249.401 320.131 250.97 322.688 251.826C330.359 254.823 338.314 252.825 347.264 245.976C352.378 241.838 360.333 242.552 368.289 247.546C373.829 251.113 376.386 251.826 383.773 251.826C389.882 251.826 393.575 251.113 395.848 249.258C406.219 241.695 411.617 241.41 421.845 247.831C427.954 251.684 430.511 252.397 437.898 252.397C448.268 252.54 450.825 250.828 448.837 245.12C447.7 241.695 446.422 240.982 438.608 239.555C433.778 238.699 428.664 236.987 427.244 235.703C423.408 232.278 414.884 228.996 409.628 228.996C407.071 228.996 402.383 230.28 399.258 231.707Z" fill="#1B2A2E"/> 37 + <path d="M512.426 231.707C509.301 233.134 505.891 235.274 504.755 236.559C502.34 239.698 491.401 239.841 488.56 236.844C481.031 229.424 463.131 228.425 454.323 235.132C451.198 237.7 447.078 239.27 442.817 239.698C435.003 240.411 432.162 242.409 432.162 246.975C432.162 249.401 433.299 250.97 435.856 251.826C443.527 254.823 451.482 252.825 460.432 245.976C465.546 241.838 473.501 242.552 481.457 247.546C486.997 251.113 489.554 251.826 496.941 251.826C503.05 251.826 506.743 251.113 509.016 249.258C519.387 241.695 524.785 241.41 535.013 247.831C541.122 251.684 543.679 252.397 551.066 252.397C561.436 252.54 563.993 250.828 562.005 245.12C560.868 241.695 559.59 240.982 551.776 239.555C546.946 238.699 541.832 236.987 540.412 235.703C536.576 232.278 528.052 228.996 522.796 228.996C520.239 228.996 515.551 230.28 512.426 231.707Z" fill="#1B2A2E"/> 38 + <path d="M460.753 260.501C456.713 262.346 452.305 265.113 450.836 266.774C447.714 270.832 433.573 271.017 429.901 267.143C420.167 257.55 397.028 256.258 385.643 264.929C381.602 268.25 376.277 270.279 370.767 270.832C360.667 271.755 356.994 274.338 356.994 280.241C356.994 283.377 358.463 285.407 361.769 286.513C371.686 290.388 381.97 287.805 393.539 278.95C400.15 273.6 410.434 274.522 420.718 280.979C427.88 285.591 431.186 286.513 440.736 286.513C448.632 286.513 453.407 285.591 456.345 283.193C469.751 273.415 476.73 273.046 489.952 281.348C497.849 286.329 501.154 287.251 510.704 287.251C524.11 287.436 527.415 285.222 524.844 277.843C523.375 273.415 521.722 272.493 511.622 270.648C505.378 269.541 498.767 267.327 496.93 265.667C491.972 261.239 480.953 256.996 474.159 256.996C470.853 256.996 464.793 258.657 460.753 260.501Z" fill="#1B2A2E"/> 39 + <path d="M22.9566 250.635C10.3594 190.03 12.1659 125.15 51.0851 73.5085C96.5037 13.243 198.985 -4.01784 259.516 42.7293C275.211 55.2342 286.283 70.5282 288.652 90.809C291.686 115.283 274.191 135.564 249.469 136.717C204.345 138.821 216.713 91.7167 165.596 96.3897C150.139 97.6612 135.866 105.173 126.066 117.193C106.394 141.458 106.305 180.184 109.349 209.609C114.428 234.642 116.301 253.73 131.801 276.771C123.23 280.571 114.099 283.739 105.378 287.362C85.0213 295.814 67.2742 304.913 48.8178 316.996C36.7828 299.577 27.739 271.31 22.9566 250.635Z" fill="#F08C59"/> 40 + <path d="M109.51 209.996C114.596 234.939 116.472 253.957 131.994 276.916C123.411 280.702 114.266 283.858 105.534 287.469C85.1476 295.89 67.375 304.957 48.8923 316.996C36.84 299.64 27.7834 271.475 22.9941 250.874C42.6972 235.928 80.6837 217.246 105.715 211.138C107.472 210.714 107.173 211.497 109.51 209.996Z" fill="#FFB468"/> 41 + <path d="M109.51 209.996C114.596 234.939 116.472 253.957 131.994 276.916C123.411 280.702 114.266 283.858 105.534 287.469C85.1476 295.89 67.375 304.957 48.8923 316.996C36.84 299.64 27.7834 271.475 22.9941 250.874C42.6972 235.928 80.6837 217.246 105.715 211.138C107.472 210.714 107.173 211.497 109.51 209.996Z" fill="#FFB468"/> 42 + <path d="M816.178 183.757C826.182 147.588 841.217 118.152 875.316 99.0176C926.881 70.0784 1007.15 82.8862 1036.26 138.022C1047.3 158.904 1051.35 179.225 1050.97 202.399C1049.74 232.205 1037.75 245.942 1006.9 243.317C968.524 240.05 929.968 237.418 891.581 234.291C892.539 247.122 893.718 252.063 898.955 263.839C895.206 265.093 870.174 258.478 864.082 257.077C847.419 253.198 830.624 249.88 813.74 247.137C810.403 224.314 812.2 206.019 816.178 183.757Z" fill="#F08C59"/> 43 + <path d="M816.179 183.996C833.83 187.143 853.692 195.502 871.388 200.207C919.342 212.963 969.034 218.227 1018.27 210.199C1028.36 208.567 1041.03 205.111 1050.99 202.582C1049.76 232.299 1037.77 245.996 1006.92 243.379C968.54 240.122 929.98 237.497 891.589 234.38C892.547 247.172 893.726 252.098 898.964 263.839C895.214 265.089 870.18 258.495 864.088 257.098C847.423 253.23 830.626 249.922 813.74 247.187C810.403 224.432 812.2 206.192 816.179 183.996Z" fill="#FFB468"/> 44 + <path d="M932.748 144.246C933.994 144.145 935.239 144.073 936.484 144.031C966.211 142.94 974.26 167.937 974.994 191.996C963.462 191.178 951.937 190.228 940.427 189.16C925.359 187.849 910.114 187.09 894.994 185.86C899.952 165.29 910.032 147.965 932.748 144.246Z" fill="#1B2A2E"/> 45 + <path d="M1084.21 186.781C1083.91 179.865 1083.84 174.425 1084.53 167.552C1089.3 119.599 1129.08 91.6561 1175.6 91.0148C1213.3 90.4949 1246.99 100.795 1276.63 126.238C1302.22 148.198 1301.51 194.336 1261.09 197.458C1251.78 198.179 1240.24 193.253 1234.14 186.288C1230.05 181.708 1226.44 176.662 1222.62 171.376C1212.02 156.705 1184.3 149.617 1169.61 161.522C1162.7 167.125 1162.74 182.194 1169.91 187.856C1192.54 207.464 1221.84 217.441 1244.77 236.873C1273.97 258.432 1282.8 290.411 1274.49 324.996C1253.42 308.385 1234.98 297.148 1209.18 289.189C1201.42 286.788 1189.2 280.883 1181.33 280.942C1139.8 253.418 1093.94 242.322 1084.21 186.781Z" fill="#F08C59"/> 46 + <path d="M1083.99 186.996C1092.98 190.282 1117.58 194.135 1128.67 196.892C1160.25 204.905 1191.13 215.521 1220.97 228.636C1225.13 230.496 1239.46 237.451 1243.05 238.164C1243.44 237.841 1244.07 237.385 1244.34 237.01C1273.5 258.536 1282.32 290.465 1274.02 324.996C1252.98 308.411 1234.56 297.192 1208.8 289.244C1201.05 286.848 1188.85 280.952 1180.98 281.01C1139.51 253.53 1093.72 242.45 1083.99 186.996Z" fill="#FFB468"/> 47 + <path d="M131.972 276.481C143.384 291.69 156.742 302.131 174.684 304.393C205.909 308.349 227.403 294.825 244.046 269.486C249.743 260.813 268.339 255.763 279.297 257.256C289.731 258.595 299.158 264.185 305.367 272.732C313.338 283.579 315.197 301.495 310.302 313.991C289.136 367.99 225.109 388.922 172.003 388.996C124.165 389.062 81.4467 365.964 54.7831 326.258C52.8782 323.403 50.4904 320.032 48.9941 316.948C67.4494 304.793 85.1955 295.639 105.551 287.136C114.271 283.49 123.402 280.304 131.972 276.481Z" fill="#63B5B1"/> 48 + <path d="M1273.99 325.145C1260.62 367.51 1227.16 389.564 1182.77 389.988C1143.87 390.354 1099.25 378.57 1070.4 351.718C1051.64 334.251 1042.09 305.037 1062.22 284.512C1069.32 277.19 1076.68 275.003 1086.71 274.996C1109.67 275.135 1116.98 293.78 1130.39 308.416C1143.18 322.387 1173.7 330.162 1189.03 316.47C1192.61 313.266 1194.56 305.447 1194.11 300.751C1193.3 292.339 1186.99 286.516 1180.9 281.382C1188.77 281.323 1200.98 287.189 1208.73 289.574C1234.51 297.481 1252.93 308.643 1273.99 325.145Z" fill="#63B5B1"/> 49 + <path d="M813.994 246.996C830.791 249.742 847.5 253.064 864.078 256.946C870.138 258.349 895.041 264.97 898.771 263.715C914.894 288.377 941.797 292.067 967.938 281.519C976.835 277.932 983.819 272.669 993.435 270.787C1010.19 267.502 1025.34 278.22 1028.22 295.049C1035.15 330.156 994.204 345.753 967.337 349.886C916.228 357.747 860.069 344.357 831.546 297.618C821.601 281.313 817.006 265.804 813.994 246.996Z" fill="#63B5B1"/> 50 + <path d="M581.926 165.286L569.757 134.93C563.996 120.394 559.705 108.308 566.391 92.5046C578.002 65.0612 623.989 62.1057 639.595 86.9899C646.777 98.4495 650.519 115.389 654.909 128.484C658.827 140.188 661.766 152.594 665.943 164.44L680.284 212.177C682.391 219.207 684.659 228.12 686.994 234.812C686.582 235.172 686.169 235.54 685.757 235.9C664.072 236.797 630.584 235.033 610.047 236.996C607.925 230.554 603.396 220.097 600.764 213.478C594.327 197.476 588.048 181.41 581.926 165.286Z" fill="#F08C59"/> 51 + <path d="M581.994 165.332C602.958 163.139 644.573 164.251 665.956 164.487L680.288 212.193C682.394 219.218 684.661 228.126 686.994 234.813C686.582 235.173 686.17 235.541 685.758 235.901C664.087 236.798 630.621 235.034 610.098 236.996C607.977 230.558 603.451 220.108 600.821 213.493C594.388 197.502 588.113 181.446 581.994 165.332Z" fill="#FFB468"/> 52 + <path d="M710.057 165.015C716.317 145.973 729.153 99.3514 738.66 85.6755C742.179 80.6207 746.653 76.7058 752.201 74.0015C761.899 69.2659 774.08 68.7422 784.249 72.3629C793.954 75.8173 802.322 82.6424 806.657 92.1358C812.652 105.27 809.692 118.15 804.666 130.871C798.906 145.436 792.69 159.829 786.996 174.419C778.003 196.221 769.15 218.079 760.444 239.996C747.506 236.297 704.415 237.473 689.206 235.525C688.802 235.135 688.398 234.737 687.994 234.348C695.635 212.291 703.004 187.396 710.057 165.015Z" fill="#F08C59"/> 53 + <path d="M687.994 234.346C695.635 212.284 703.004 187.383 710.057 164.996C714.781 166.428 731.892 167.321 737.755 167.967C754.264 169.784 770.61 171.303 786.994 174.402C778.001 196.21 769.148 218.074 760.442 239.996C747.504 236.296 704.415 237.473 689.206 235.523C688.802 235.133 688.398 234.736 687.994 234.346Z" fill="#FFB468"/> 54 + <path d="M686.812 235.003L687.481 234.996C687.886 235.384 688.29 235.779 688.695 236.167C703.917 238.106 747.045 236.935 759.994 240.615C751.302 261.247 743.544 282.246 734.617 302.871C728.639 316.684 720.624 327.959 705.961 333.643C693.452 338.494 674.686 338.041 662.421 332.546C654.347 328.888 647.427 323.115 642.397 315.843C636.363 307.18 631.298 291.267 627.245 280.797L609.994 237.176C630.496 235.223 663.928 236.979 685.577 236.086C685.989 235.728 686.4 235.362 686.812 235.003Z" fill="#63B5B1"/> 55 + <path d="M320.132 201.184C321.683 186.461 325.516 172.069 331.494 158.526C335.776 148.794 341.667 139.226 348.379 131.077C392.112 77.9841 487.808 76.9853 530.626 131.984C541.251 145.632 546.222 157.166 552.272 173.071C559.743 198.749 561.501 219.036 558.799 245.647C548.182 320.908 494.724 365.408 418.12 354.309C389.235 350.403 363.206 334.846 346.082 311.258C336.515 298.327 331.977 289.015 326.797 273.922C319.18 247.016 317.556 228.701 320.132 201.184ZM441.692 261.373C459.846 252.65 455.889 185.406 437.476 185.255C419.614 193.476 424.145 240.241 433.35 254.842C435.801 258.733 437.272 260.042 441.692 261.373Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 56 + <path d="M320.132 201.662C329.273 200.674 341.406 197.902 350.841 196.198C368.632 192.884 386.528 190.141 404.5 187.996C397.494 209.949 398.468 238.996 406.994 260.274C380.091 263.8 353.339 268.378 326.799 273.996C319.18 247.239 317.556 229.026 320.132 201.662Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 57 + <path d="M469.994 179.65C497.382 176.837 524.826 174.618 552.308 172.996C559.744 198.883 561.494 219.333 558.805 246.161C533.354 246.094 502.416 249.757 477.029 251.996C481.694 225.984 479.912 203.991 469.994 179.65Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 58 + <path d="M517.994 209.496C517.994 252.85 482.848 287.996 439.494 287.996C396.14 287.996 360.994 252.85 360.994 209.496C360.994 166.142 396.14 130.996 439.494 130.996C482.848 130.996 517.994 166.142 517.994 209.496Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 59 + <path d="M517.994 209.496C517.994 166.142 482.848 130.996 439.494 130.996C396.14 130.996 360.994 166.142 360.994 209.496C360.994 252.85 396.14 287.996 439.494 287.996C482.848 287.996 517.994 252.85 517.994 209.496ZM530.994 209.496C530.994 260.03 490.028 300.996 439.494 300.996C388.96 300.996 347.994 260.03 347.994 209.496C347.994 158.962 388.96 117.996 439.494 117.996C490.028 117.996 530.994 158.962 530.994 209.496Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 60 + <path d="M498.994 248.996C526.494 238.496 527.994 240.996 557.494 251.996C546.877 327.018 494.919 365.376 418.316 354.312C389.432 350.419 363.402 334.911 346.279 311.397C333.994 299.496 323.494 267.996 321.494 251.996C345.494 239.81 383.097 244.53 409.994 240.996C420.816 240.997 430.028 246.525 442.494 248.996C472.494 240.996 475.494 238.996 498.994 248.996Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 61 + <path d="M399.258 231.707C396.133 233.134 392.723 235.274 391.587 236.559C389.172 239.698 378.233 239.841 375.392 236.844C367.863 229.424 349.963 228.425 341.155 235.132C338.03 237.7 333.91 239.27 329.649 239.698C321.835 240.411 318.994 242.409 318.994 246.975C318.994 249.401 320.131 250.97 322.688 251.826C330.359 254.823 338.314 252.825 347.264 245.976C352.378 241.838 360.333 242.552 368.289 247.546C373.829 251.113 376.386 251.826 383.773 251.826C389.882 251.826 393.575 251.113 395.848 249.258C406.219 241.695 411.617 241.41 421.845 247.831C427.954 251.684 430.511 252.397 437.898 252.397C448.268 252.54 450.825 250.828 448.837 245.12C447.7 241.695 446.422 240.982 438.608 239.555C433.778 238.699 428.664 236.987 427.244 235.703C423.408 232.278 414.884 228.996 409.628 228.996C407.071 228.996 402.383 230.28 399.258 231.707Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 62 + <path d="M512.426 231.707C509.301 233.134 505.891 235.274 504.755 236.559C502.34 239.698 491.401 239.841 488.56 236.844C481.031 229.424 463.131 228.425 454.323 235.132C451.198 237.7 447.078 239.27 442.817 239.698C435.003 240.411 432.162 242.409 432.162 246.975C432.162 249.401 433.299 250.97 435.856 251.826C443.527 254.823 451.482 252.825 460.432 245.976C465.546 241.838 473.501 242.552 481.457 247.546C486.997 251.113 489.554 251.826 496.941 251.826C503.05 251.826 506.743 251.113 509.016 249.258C519.387 241.695 524.785 241.41 535.013 247.831C541.122 251.684 543.679 252.397 551.066 252.397C561.436 252.54 563.993 250.828 562.005 245.12C560.868 241.695 559.59 240.982 551.776 239.555C546.946 238.699 541.832 236.987 540.412 235.703C536.576 232.278 528.052 228.996 522.796 228.996C520.239 228.996 515.551 230.28 512.426 231.707Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 63 + <path d="M460.753 260.501C456.713 262.346 452.305 265.113 450.836 266.774C447.714 270.832 433.573 271.017 429.901 267.143C420.167 257.55 397.028 256.258 385.643 264.929C381.602 268.25 376.277 270.279 370.767 270.832C360.667 271.755 356.994 274.338 356.994 280.241C356.994 283.377 358.463 285.407 361.769 286.513C371.686 290.388 381.97 287.805 393.539 278.95C400.15 273.6 410.434 274.522 420.718 280.979C427.88 285.591 431.186 286.513 440.736 286.513C448.632 286.513 453.407 285.591 456.345 283.193C469.751 273.415 476.73 273.046 489.952 281.348C497.849 286.329 501.154 287.251 510.704 287.251C524.11 287.436 527.415 285.222 524.844 277.843C523.375 273.415 521.722 272.493 511.622 270.648C505.378 269.541 498.767 267.327 496.93 265.667C491.972 261.239 480.953 256.996 474.159 256.996C470.853 256.996 464.793 258.657 460.753 260.501Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 64 + <path d="M22.9566 250.635C10.3594 190.03 12.1659 125.15 51.0851 73.5085C96.5037 13.243 198.985 -4.01784 259.516 42.7293C275.211 55.2342 286.283 70.5282 288.652 90.809C291.686 115.283 274.191 135.564 249.469 136.717C204.345 138.821 216.713 91.7167 165.596 96.3897C150.139 97.6612 135.866 105.173 126.066 117.193C106.394 141.458 106.305 180.184 109.349 209.609C114.428 234.642 116.301 253.73 131.801 276.771C123.23 280.571 114.099 283.739 105.378 287.362C85.0213 295.814 67.2742 304.913 48.8178 316.996C36.7828 299.577 27.739 271.31 22.9566 250.635Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 65 + <path d="M109.51 209.996C114.596 234.939 116.472 253.957 131.994 276.916C123.411 280.702 114.266 283.858 105.534 287.469C85.1476 295.89 67.375 304.957 48.8923 316.996C36.84 299.64 27.7834 271.475 22.9941 250.874C42.6972 235.928 80.6837 217.246 105.715 211.138C107.472 210.714 107.173 211.497 109.51 209.996Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 66 + <path d="M109.51 209.996C114.596 234.939 116.472 253.957 131.994 276.916C123.411 280.702 114.266 283.858 105.534 287.469C85.1476 295.89 67.375 304.957 48.8923 316.996C36.84 299.64 27.7834 271.475 22.9941 250.874C42.6972 235.928 80.6837 217.246 105.715 211.138C107.472 210.714 107.173 211.497 109.51 209.996Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 67 + <path d="M816.178 183.757C826.182 147.588 841.217 118.152 875.316 99.0176C926.881 70.0784 1007.15 82.8862 1036.26 138.022C1047.3 158.904 1051.35 179.225 1050.97 202.399C1049.74 232.205 1037.75 245.942 1006.9 243.317C968.524 240.05 929.968 237.418 891.581 234.291C892.539 247.122 893.718 252.063 898.955 263.839C895.206 265.093 870.174 258.478 864.082 257.077C847.419 253.198 830.624 249.88 813.74 247.137C810.403 224.314 812.2 206.019 816.178 183.757Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 68 + <path d="M816.179 183.996C833.83 187.143 853.692 195.502 871.388 200.207C919.342 212.963 969.034 218.227 1018.27 210.199C1028.36 208.567 1041.03 205.111 1050.99 202.582C1049.76 232.299 1037.77 245.996 1006.92 243.379C968.54 240.122 929.98 237.497 891.589 234.38C892.547 247.172 893.726 252.098 898.964 263.839C895.214 265.089 870.18 258.495 864.088 257.098C847.423 253.23 830.626 249.922 813.74 247.187C810.403 224.432 812.2 206.192 816.179 183.996Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 69 + <path d="M932.748 144.246C933.994 144.145 935.239 144.073 936.484 144.031C966.211 142.94 974.26 167.937 974.994 191.996C963.462 191.178 951.937 190.228 940.427 189.16C925.359 187.849 910.114 187.09 894.994 185.86C899.952 165.29 910.032 147.965 932.748 144.246Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 70 + <path d="M1084.21 186.781C1083.91 179.865 1083.84 174.425 1084.53 167.552C1089.3 119.599 1129.08 91.6561 1175.6 91.0148C1213.3 90.4949 1246.99 100.795 1276.63 126.238C1302.22 148.198 1301.51 194.336 1261.09 197.458C1251.78 198.179 1240.24 193.253 1234.14 186.288C1230.05 181.708 1226.44 176.662 1222.62 171.376C1212.02 156.705 1184.3 149.617 1169.61 161.522C1162.7 167.125 1162.74 182.194 1169.91 187.856C1192.54 207.464 1221.84 217.441 1244.77 236.873C1273.97 258.432 1282.8 290.411 1274.49 324.996C1253.42 308.385 1234.98 297.148 1209.18 289.189C1201.42 286.788 1189.2 280.883 1181.33 280.942C1139.8 253.418 1093.94 242.322 1084.21 186.781Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 71 + <path d="M1083.99 186.996C1092.98 190.282 1117.58 194.135 1128.67 196.892C1160.25 204.905 1191.13 215.521 1220.97 228.636C1225.13 230.496 1239.46 237.451 1243.05 238.164C1243.44 237.841 1244.07 237.385 1244.34 237.01C1273.5 258.536 1282.32 290.465 1274.02 324.996C1252.98 308.411 1234.56 297.192 1208.8 289.244C1201.05 286.848 1188.85 280.952 1180.98 281.01C1139.51 253.53 1093.72 242.45 1083.99 186.996Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 72 + <path d="M131.972 276.481C143.384 291.69 156.742 302.131 174.684 304.393C205.909 308.349 227.403 294.825 244.046 269.486C249.743 260.813 268.339 255.763 279.297 257.256C289.731 258.595 299.158 264.185 305.367 272.732C313.338 283.579 315.197 301.495 310.302 313.991C289.136 367.99 225.109 388.922 172.003 388.996C124.165 389.062 81.4467 365.964 54.7831 326.258C52.8782 323.403 50.4904 320.032 48.9941 316.948C67.4494 304.793 85.1955 295.639 105.551 287.136C114.271 283.49 123.402 280.304 131.972 276.481Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 73 + <path d="M1273.99 325.145C1260.62 367.51 1227.16 389.564 1182.77 389.988C1143.87 390.354 1099.25 378.57 1070.4 351.718C1051.64 334.251 1042.09 305.037 1062.22 284.512C1069.32 277.19 1076.68 275.003 1086.71 274.996C1109.67 275.135 1116.98 293.78 1130.39 308.416C1143.18 322.387 1173.7 330.162 1189.03 316.47C1192.61 313.266 1194.56 305.447 1194.11 300.751C1193.3 292.339 1186.99 286.516 1180.9 281.382C1188.77 281.323 1200.98 287.189 1208.73 289.574C1234.51 297.481 1252.93 308.643 1273.99 325.145Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 74 + <path d="M813.994 246.996C830.791 249.742 847.5 253.064 864.078 256.946C870.138 258.349 895.041 264.97 898.771 263.715C914.894 288.377 941.797 292.067 967.938 281.519C976.835 277.932 983.819 272.669 993.435 270.787C1010.19 267.502 1025.34 278.22 1028.22 295.049C1035.15 330.156 994.204 345.753 967.337 349.886C916.228 357.747 860.069 344.357 831.546 297.618C821.601 281.313 817.006 265.804 813.994 246.996Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 75 + <path d="M581.926 165.286L569.757 134.93C563.996 120.394 559.705 108.308 566.391 92.5046C578.002 65.0612 623.989 62.1057 639.595 86.9899C646.777 98.4495 650.519 115.389 654.909 128.484C658.827 140.188 661.766 152.594 665.943 164.44L680.284 212.177C682.391 219.207 684.659 228.12 686.994 234.812C686.582 235.172 686.169 235.54 685.757 235.9C664.072 236.797 630.584 235.033 610.047 236.996C607.925 230.554 603.396 220.097 600.764 213.478C594.327 197.476 588.048 181.41 581.926 165.286Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 76 + <path d="M581.994 165.332C602.958 163.139 644.573 164.251 665.956 164.487L680.288 212.193C682.394 219.218 684.661 228.126 686.994 234.813C686.582 235.173 686.17 235.541 685.758 235.901C664.087 236.798 630.621 235.034 610.098 236.996C607.977 230.558 603.451 220.108 600.821 213.493C594.388 197.502 588.113 181.446 581.994 165.332Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 77 + <path d="M710.057 165.015C716.317 145.973 729.153 99.3514 738.66 85.6755C742.179 80.6207 746.653 76.7058 752.201 74.0015C761.899 69.2659 774.08 68.7422 784.249 72.3629C793.954 75.8173 802.322 82.6424 806.657 92.1358C812.652 105.27 809.692 118.15 804.666 130.871C798.906 145.436 792.69 159.829 786.996 174.419C778.003 196.221 769.15 218.079 760.444 239.996C747.506 236.297 704.415 237.473 689.206 235.525C688.802 235.135 688.398 234.737 687.994 234.348C695.635 212.291 703.004 187.396 710.057 165.015Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 78 + <path d="M687.994 234.346C695.635 212.284 703.004 187.383 710.057 164.996C714.781 166.428 731.892 167.321 737.755 167.967C754.264 169.784 770.61 171.303 786.994 174.402C778.001 196.21 769.148 218.074 760.442 239.996C747.504 236.296 704.415 237.473 689.206 235.523C688.802 235.133 688.398 234.736 687.994 234.346Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 79 + <path d="M686.812 235.003L687.481 234.996C687.886 235.384 688.29 235.779 688.695 236.167C703.917 238.106 747.045 236.935 759.994 240.615C751.302 261.247 743.544 282.246 734.617 302.871C728.639 316.684 720.624 327.959 705.961 333.643C693.452 338.494 674.686 338.041 662.421 332.546C654.347 328.888 647.427 323.115 642.397 315.843C636.363 307.18 631.298 291.267 627.245 280.797L609.994 237.176C630.496 235.223 663.928 236.979 685.577 236.086C685.989 235.728 686.4 235.362 686.812 235.003Z" stroke="#1B2A2E" stroke-width="32" mask="url(#path-1-outside-1_50_75)"/> 80 + </svg>
+90
static/images/lil_dude.svg
··· 1 + <svg width="689" height="638" viewBox="0 0 689 638" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M406.938 34.3899C416.833 34.6814 426.593 36.8124 435.708 40.6754C452.263 47.9869 465.227 61.5904 471.737 78.4804C478.437 95.6824 478.037 114.843 470.617 131.749C460.727 154.037 446.887 163.022 425.242 171.365C416.532 199.198 409.042 227.209 405.592 256.217C404.812 262.815 404.032 270.162 402.667 276.621C442.442 287.48 474.827 307.827 503.812 336.683C509.233 329.128 515.558 325.778 523.698 321.773C525.298 310.536 529.908 301.708 536.628 292.64C531.693 284.818 527.398 278.225 524.008 269.489C504.843 219.844 522.912 155.639 566.492 124.584C579.027 114.998 592.363 113.086 587.318 133.965C582.208 155.143 584.447 171.679 591.087 192.076C596.042 187.945 604.703 180.914 608.333 175.944C619.133 161.151 618.978 140.427 617.883 122.942C617.668 118.239 615.937 112.802 617.782 108.267C622.362 97.0024 636.997 108.286 642.602 112.701C691.382 151.111 700.827 223.318 673.287 277.065C658.957 305.023 643.673 319.083 614.208 329.073C605.498 353.658 592.277 367.278 568.352 373.228C567.662 373.398 561.092 378.548 559.587 379.573C574.942 394.223 551.272 416.023 543.652 429.288L543.307 429.898C552.087 426.973 563.342 422.728 572.562 422.098C576.702 421.818 578.283 422.868 581.148 425.513C587.538 426.378 596.138 426.978 601.958 429.593C605.323 431.108 609.287 437.213 611.662 440.328C632.507 467.643 644.517 501.283 648.862 535.168C648.967 539.663 647.313 544.018 646.698 548.413C646.348 550.953 646.157 553.593 645.927 556.148C644.277 574.293 639.657 592.408 631.152 608.583C628.747 613.158 622.928 623.158 616.693 618.718C615.618 617.953 614.477 616.643 614.292 615.308C612.197 606.918 612.353 598.128 611.458 589.568C610.373 579.978 608.973 570.383 607.768 560.813C607.248 560.523 606.742 560.218 606.247 559.898C588.857 548.463 579.587 504.668 575.612 484.703C563.722 490.573 556.952 493.553 544.417 498.318C556.227 508.308 560.307 506.833 563.727 524.018C568.232 546.648 567.662 567.118 563.557 589.678L558.422 591.473C548.122 607.963 538.398 619.313 521.133 629.203C514.603 632.713 506.052 637.683 498.347 636.303C495.472 635.788 493.573 631.283 494.693 628.533C497.293 622.148 500.643 616.078 503.698 609.878C508.158 600.618 512.402 591.253 516.422 581.793C512.237 568.573 515.662 557.023 518.177 543.493C489.647 540.088 474.717 534.503 449.652 521.018C438.597 524.208 430.473 527.088 418.943 529.843C399.758 533.603 380.222 535.338 360.672 535.008C350.202 534.928 339.407 534.103 329.002 534.163C319.197 534.218 309.28 534.748 299.404 534.813C280.713 535.038 262.03 533.943 243.495 531.533C249.876 554.403 253.227 584.903 224.019 555.788C215.07 546.868 198.789 541.583 186.633 538.323C187.124 540.393 187.668 542.458 188.264 544.503C194.566 565.653 202.489 574.983 222.754 583.613C228.404 586.023 242.398 590.253 244.089 596.188C242.843 609.938 205.882 621.108 193.916 623.528C159.446 630.503 129.488 622.143 100.535 603.188C102.718 609.348 111.667 626.988 109.696 633.258C106.817 642.413 92.614 634.043 87.4275 630.753C71.9165 620.918 62.1905 608.223 54.0855 592.038C44.3805 587.068 47.2265 566.403 48.014 557.188L41.0285 560.738C40.8045 563.318 40.2905 566.398 39.851 568.963C37.451 582.943 37.011 597.123 35.2925 611.163C34.8975 614.083 33.737 619.933 29.8845 620.043C20.014 620.313 13.893 601.408 11.223 594.493C5.17999 578.838 3.13749 562.753 1.79399 546.238C1.58249 543.643 0.347 541.108 0 538.533C3.05 501.033 17.7055 464.138 41.132 434.698C48.076 425.968 56.0255 426.923 66.2645 426.033C67.136 425.168 68.5575 423.743 69.6805 423.373C79.992 419.968 101.062 429.318 110.671 432.978C104.835 419.278 83.0795 399.853 89.281 383.838C92.6065 375.253 109.757 375.163 116.805 369.428C127.696 360.563 136.384 349.148 145.905 338.868C171.953 310.773 202.315 291.307 238.689 279.563C235.018 260.148 231.209 241.042 225.112 222.204C221.884 212.23 217.778 202.097 214.259 192.131C199.805 189.855 187.996 185.853 176.485 176.332C162.355 164.661 153.572 147.752 152.147 129.481C150.601 111.25 156.044 92.7139 168.037 78.7759C179.996 64.9194 196.961 56.3669 215.214 54.9919C233.403 53.6509 251.371 59.6459 265.111 71.6394C278.786 83.8304 287.125 100.904 288.335 119.183C289.661 142.233 280.944 157.979 266.172 174.468C267.539 182.909 268.568 191.132 270.163 199.581C274.633 222.494 280.597 245.092 288.016 267.227C306.479 263.865 334.437 264.418 353.102 266.032C356.472 249.588 360.787 233.728 363.847 216.983C367.292 198.156 369.262 181.746 371.037 162.791C367.977 160.785 365.667 159.313 362.852 156.912C348.972 145.044 340.373 128.153 338.943 109.949C335.933 67.8009 365.643 37.3889 406.938 34.3899Z" fill="black"/> 3 + <path d="M255.399 382.428C262.857 381.573 271.886 383.413 278.763 386.553C280.951 389.443 284.919 394.528 287.827 396.348C302.155 405.308 323.257 408.113 339.572 404.518C352.882 401.583 362.957 395.478 370.277 384.143C382.052 382.128 384.602 383.758 394.737 383.168C402.852 385.598 412.452 388.638 420.122 392.153C442.772 402.533 458.987 413.958 483.947 418.948C507.482 422.623 510.337 420.713 533.127 419.183C527.412 429.023 522.287 438.873 515.927 448.318C492.797 482.648 466.612 503.508 426.207 514.038C392.222 522.893 363.222 521.788 328.541 521.098C308.976 520.708 288.232 521.833 268.669 520.863C258.791 520.373 246.533 518.393 236.588 516.993C232.355 510.818 228.627 504.983 223.927 499.108C210.667 482.538 192.669 474.178 172.08 470.273C166.742 443.378 156.502 434.318 128.951 436.853C125.817 431.558 121.95 425.048 119.577 419.403C137.182 422.618 144.66 420.933 161.397 420.013C189.139 413.993 205.116 403.373 229.988 391.298C236.661 388.058 248.065 384.528 255.399 382.428Z" fill="#FBCA90"/> 4 + <path d="M483.947 418.948C507.482 422.623 510.337 420.713 533.127 419.183C527.412 429.023 522.287 438.873 515.927 448.318C492.797 482.648 466.612 503.508 426.207 514.038C392.222 522.893 363.222 521.788 328.541 521.098C308.976 520.708 288.232 521.833 268.669 520.863C258.791 520.373 246.533 518.393 236.588 516.993C232.355 510.818 228.627 504.983 223.927 499.108C210.667 482.538 192.669 474.178 172.08 470.273C166.742 443.378 156.502 434.318 128.951 436.853C125.817 431.558 121.95 425.048 119.577 419.403C137.182 422.618 144.66 420.933 161.397 420.013C162.497 423.773 166.507 428.713 168.918 431.758C181.021 447.053 195.089 457.863 212.544 466.528C235.812 477.883 261.489 483.418 287.368 482.658C298.925 482.433 310.653 481.263 322.201 481.418C337.422 481.588 352.857 483.093 368.062 482.438C407.567 480.738 447.987 465.643 473.237 434.043C476.797 429.588 481.412 424.073 483.947 418.948Z" fill="#DAA66F"/> 5 + <path d="M290.927 279.73C305.49 277.811 335.272 277.646 349.782 279.8C348.787 283.708 347.632 287.709 346.562 291.609C345.712 304.291 354.077 311.966 365.047 316.138C379.512 321.638 398.732 320.928 401.432 302.1L401.557 290.38C427.012 299.258 467.412 316.428 484.837 337.578L499.777 352.708C503.267 377.098 512.867 392.008 540.372 387.163C543.867 388.523 547.252 389.848 550.797 391.078C547.977 398.078 546.907 401.198 542.322 407.293C522.367 412.713 501.402 413.268 481.192 408.913C474.472 407.363 467.872 405.358 461.427 402.913C446.167 397.103 409.757 379.728 394.737 383.168C384.602 383.758 382.052 382.128 370.277 384.143C362.957 395.478 352.882 401.583 339.572 404.518C323.257 408.113 302.155 405.308 287.827 396.348C284.919 394.528 280.951 389.443 278.763 386.553C271.886 383.413 262.857 381.573 255.399 382.428C246.118 377.313 202.738 396.158 192.671 400.443C166.454 411.608 140.085 415.173 112.029 408.653C108.188 403.713 105.94 400.148 103.062 394.563C102.344 393.043 102.278 393.113 102.211 391.463C104.171 387.868 107.964 388.023 111.734 386.748C130.724 380.308 142.756 362.153 155.768 347.673C179.907 320.818 207.839 304.887 241.316 292.189C241.32 297.356 241.229 302.629 241.64 307.77C254 333.798 301.619 312.137 295.897 292.733C293.895 288.097 292.559 284.457 290.927 279.73Z" fill="#DF7E40"/> 6 + <path d="M103.062 394.563C102.344 393.043 102.278 393.113 102.211 391.463C104.171 387.868 107.964 388.023 111.734 386.748C130.724 380.308 142.756 362.153 155.768 347.673C179.907 320.818 207.839 304.887 241.316 292.189C241.32 297.356 241.229 302.629 241.64 307.77C236.466 310.59 230.917 312.955 225.698 315.518C203.634 326.358 183.835 340.168 167.311 358.408C156.897 369.903 146.949 382.573 131.988 387.888C121.223 392.058 112.704 391.838 103.062 394.563Z" fill="#DAA66F"/> 7 + <path d="M278.763 386.553C277.792 383.098 277.118 380.533 277.739 376.908C278.732 375.743 278.865 375.608 280.811 376.698C284.096 378.603 286.937 381.338 290.149 383.343C309.701 395.563 335.692 396.458 355.362 384.093C358.882 381.878 364.387 376.833 368.372 376.443C371.912 378.123 370.842 380.783 370.277 384.143C362.957 395.478 352.882 401.583 339.572 404.518C323.257 408.113 302.155 405.308 287.827 396.348C284.919 394.528 280.951 389.443 278.763 386.553Z" fill="black"/> 8 + <path d="M290.927 279.73C305.49 277.811 335.272 277.646 349.782 279.8C348.787 283.708 347.632 287.709 346.562 291.609C328.753 291.588 313.763 291.715 295.897 292.733C293.895 288.097 292.559 284.457 290.927 279.73Z" fill="#DAA66F"/> 9 + <path d="M401.557 290.38C427.012 299.258 467.412 316.428 484.837 337.578C480.157 338.238 469.447 329.303 464.942 326.898C446.767 317.203 421.602 305.086 401.432 302.1L401.557 290.38Z" fill="#DAA66F"/> 10 + <path d="M188.218 352.723C200.675 352.353 198.265 361.203 191.428 367.303C188.828 369.618 186.631 370.428 183.51 371.758C164.943 371.768 177.098 356.773 188.218 352.723Z" fill="#DAA66F"/> 11 + <path d="M242.995 334.978C247.068 334.298 250.928 337.023 251.647 341.093C252.366 345.158 249.676 349.043 245.617 349.803C241.501 350.573 237.549 347.833 236.82 343.713C236.091 339.588 238.866 335.663 242.995 334.978Z" fill="black"/> 12 + <path d="M401.092 334.993C405.017 334.418 408.712 337.008 409.512 340.893C410.312 344.783 407.942 348.618 404.107 349.643C401.422 350.358 398.557 349.528 396.672 347.488C394.787 345.448 394.187 342.528 395.112 339.908C396.037 337.288 398.342 335.393 401.092 334.993Z" fill="black"/> 13 + <path d="M209.537 338.898C213.48 337.943 217.453 340.353 218.435 344.288C219.417 348.223 217.038 352.213 213.11 353.223C209.141 354.243 205.102 351.838 204.11 347.858C203.119 343.883 205.554 339.863 209.537 338.898Z" fill="black"/> 14 + <path d="M316.08 306.207C326.399 307.582 328.821 314.238 318.954 319.758C306.624 320.228 305.312 309.618 316.08 306.207Z" fill="#DAA66F"/> 15 + <path d="M433.888 339.433C437.713 338.583 441.523 340.903 442.523 344.693C443.523 348.478 441.353 352.378 437.608 353.528C435.048 354.313 432.268 353.633 430.358 351.758C428.448 349.883 427.718 347.118 428.458 344.543C429.193 341.973 431.278 340.013 433.888 339.433Z" fill="black"/> 16 + <path d="M630.267 121.668L630.147 120.766L630.842 120.325C639.712 122.706 655.427 144.106 659.937 152.022C676.232 180.11 679.662 213.783 671.717 245.123C662.932 279.787 636.427 318.493 596.937 318.803C584.327 318.903 572.912 310.875 566.997 300.068C566.682 299.48 566.377 298.885 566.087 298.281L565.512 296.848C552.497 270.2 568.522 231.94 588.127 212.76C608.517 192.812 621.987 187.081 629.167 157.708C631.382 145.249 631.377 134.223 630.267 121.668Z" fill="#EC7558"/> 17 + <path d="M630.267 121.668L630.147 120.766L630.842 120.325C639.712 122.706 655.427 144.106 659.937 152.022C676.232 180.11 679.662 213.783 671.717 245.123C662.932 279.787 636.427 318.493 596.937 318.803C584.327 318.903 572.912 310.875 566.997 300.068C566.682 299.48 566.377 298.885 566.087 298.281C577.417 300.905 594.667 302.945 605.117 297.717C657.917 271.295 666.642 193.445 643.562 144.331C641.532 140.012 633.402 122.926 630.267 121.668Z" fill="#D25742"/> 18 + <path d="M629.167 157.708C630.637 159.133 630.722 159.46 630.747 161.489C631.052 183.609 622.067 198.628 607.702 214.398C595.362 227.948 582.707 242.648 574.702 259.181C571.807 265.327 570.867 270.573 569.342 276.939C568.317 281.243 567.407 293.901 565.512 296.848C552.497 270.2 568.522 231.94 588.127 212.76C608.517 192.812 621.987 187.081 629.167 157.708Z" fill="#FCA78D"/> 19 + <path d="M587.637 249.122C597.787 248.864 594.557 257.504 589.827 262.528C580.507 262.37 582.487 253.971 587.637 249.122Z" fill="#FCA78D"/> 20 + <path d="M103.195 502.338C123.881 495.133 142.727 503.038 158.016 517.268C174.306 532.428 176.731 550.338 185.25 569.643C195.315 584.778 207.622 590.113 223.788 597.263L225.347 597.863L225.561 598.533C217.103 604.418 197.959 610.218 187.743 611.748C159.83 616.003 131.298 610.493 108.467 593.398C85.0304 575.853 68.9984 539.043 88.6734 513.073C93.7114 507.863 96.4574 505.173 103.195 502.338Z" fill="#EC7558"/> 21 + <path d="M88.6734 513.073C89.2499 519.703 89.3974 527.758 90.3449 533.993C92.8219 550.293 104.732 568.108 117.475 578.388C142.11 598.268 172.487 603.813 203.33 600.333C206.991 599.918 221.312 597.698 223.548 597.743L223.788 597.263L225.347 597.863L225.561 598.533C217.103 604.418 197.959 610.218 187.743 611.748C159.83 616.003 131.298 610.493 108.467 593.398C85.0304 575.853 68.9984 539.043 88.6734 513.073Z" fill="#D25742"/> 22 + <path d="M103.195 502.338C123.881 495.133 142.727 503.038 158.016 517.268C174.306 532.428 176.731 550.338 185.25 569.643C178.476 569.478 165.297 542.483 160.837 536.588C157.568 532.273 154.479 527.163 150.074 523.223C140.683 514.878 129.258 508.393 116.999 505.458C112.954 504.488 106.79 504.288 103.195 502.338Z" fill="#FCA78D"/> 23 + <path d="M137.579 521.523C144.501 521.993 149.123 526.218 143.388 532.623C136.974 531.533 131.871 527.923 137.579 521.523Z" fill="#FCA78D"/> 24 + <path d="M353.837 90.4498C359.452 73.8303 367.342 62.2478 383.522 54.2008C396.532 47.8333 411.527 46.8383 425.272 51.4308C439.502 56.3343 451.182 66.7208 457.717 80.2798C460.507 86.1208 461.527 90.4803 462.742 96.7608C463.607 102.468 463.092 110.088 461.892 115.736C458.692 130.167 449.932 142.754 437.507 150.762C425.132 158.725 409.372 161.191 395.087 157.973C380.692 154.649 368.222 145.708 360.452 133.139C352.572 120.484 350.467 104.881 353.837 90.4498Z" fill="white"/> 25 + <path d="M353.837 90.4498C357.122 94.6063 359.612 101.952 361.922 106.942C368.787 121.772 385.107 131.843 400.887 134.148C415.372 136.22 430.087 132.457 441.797 123.687C449.007 118.236 453.357 111.985 458.017 104.323C459.322 102.175 461.262 98.6463 462.742 96.7608C463.607 102.468 463.092 110.088 461.892 115.736C458.692 130.167 449.932 142.754 437.507 150.762C425.132 158.725 409.372 161.191 395.087 157.973C380.692 154.649 368.222 145.708 360.452 133.139C352.572 120.484 350.467 104.881 353.837 90.4498Z" fill="#CBCBCB"/> 26 + <path d="M388.342 79.9167C392.292 79.6912 395.907 79.4488 399.797 80.3078C420.127 84.7558 421.472 113.43 404.662 123.189C397.832 127.153 390.652 126.481 383.437 124.46C381.412 123.545 380.067 122.888 378.277 121.528C373.637 118.021 370.642 112.759 369.997 106.978C369.232 99.7773 371.992 92.1377 376.547 86.5822C377.497 90.3352 378.822 95.9243 383.122 97.1873C386.867 98.2893 392.662 94.7797 393.347 90.9117C393.932 87.6387 390.147 83.3413 388.177 80.9138L388.342 79.9167Z" fill="black"/> 27 + <path d="M165.475 119.031C167.666 93.0158 187.94 72.1763 213.885 69.2718C239.83 66.3678 264.21 82.2083 272.099 107.095C278.778 128.169 272.113 151.191 255.208 165.436C238.303 179.682 214.484 182.348 194.848 172.193C175.212 162.038 163.621 141.06 165.475 119.031Z" fill="white"/> 28 + <path d="M272.099 107.095C278.778 128.169 272.113 151.191 255.208 165.436C238.303 179.682 214.484 182.348 194.848 172.193C175.212 162.038 163.621 141.06 165.475 119.031C168.482 122.38 170.249 127.31 172.898 131.043C186.146 149.712 209.444 157.783 231.448 151.535C234.924 150.548 243.589 147.883 244.95 144.338C244.448 143.319 244.692 143.603 243.915 142.819C245.99 141.578 248.211 139.977 250.226 138.596L251.649 139.189C258.416 137.498 264.252 126.015 266.841 119.896C268.386 116.244 269.901 110.122 272.099 107.095Z" fill="#CBCBCB"/> 29 + <path d="M224.677 100.037C232.673 97.4117 241.565 97.8652 248.379 103.208C253.056 106.921 256.069 112.338 256.753 118.271C257.727 126.269 254.976 132.495 250.226 138.596C248.211 139.977 245.99 141.578 243.915 142.819C239.157 144.811 233.928 145.395 228.848 144.503C215.224 142.1 208.111 128.444 210.842 115.628C211.381 113.099 211.845 110.62 214.109 109.105C217.095 110.191 219.322 118.813 224.839 116.879C237.837 112.324 233.6 105.027 224.677 100.037Z" fill="black"/> 30 + <path d="M571.682 136.256L572.662 135.567L573.267 135.956C573.597 139.022 572.262 151.077 572.017 155.099C570.882 173.83 575.437 185.601 581.817 202.617C577.077 207.596 572.917 212.055 568.702 217.56C559.412 230.646 553.222 243.369 550.662 259.395C549.962 263.781 549.947 267.79 549.417 271.816C549.682 278.786 550.497 284.363 551.737 291.199C529.307 262.437 524.557 230.091 533.547 194.954C538.467 175.711 553.957 146.542 571.682 136.256Z" fill="#EC7558"/> 31 + <path d="M551.737 291.199C529.307 262.437 524.557 230.091 533.547 194.954C538.467 175.711 553.957 146.542 571.682 136.256C569.317 145.164 565.942 154.032 563.577 163.197C545.887 188.281 539.297 224.112 543.587 254.389C544.147 258.352 547.032 268.9 549.417 271.816C549.682 278.786 550.497 284.363 551.737 291.199Z" fill="#FCA78D"/> 32 + <path d="M571.682 136.256L572.662 135.567L573.267 135.956C573.597 139.022 572.262 151.077 572.017 155.099C570.882 173.83 575.437 185.601 581.817 202.617C577.077 207.596 572.917 212.055 568.702 217.56C564.542 203.949 562.878 196.493 563.008 181.863C563.063 175.932 563.652 168.977 563.577 163.197C565.942 154.032 569.317 145.164 571.682 136.256Z" fill="#D25742"/> 33 + <path d="M125.905 488.768C143.79 479.983 163.771 477.843 182.788 484.403C202.503 491.308 218.678 505.743 227.769 524.553C230.265 529.753 232.697 536.558 234.16 542.178C234.865 544.418 235.272 547.163 235.702 549.503C231.529 546.628 227.522 543.123 223.057 540.198C207.421 529.938 196.352 527.793 178.497 525.123C175.75 520.383 172.071 515.743 168.622 511.478C158.348 500.768 151.894 496.873 138.223 491.728C134.165 490.743 129.967 489.663 125.905 488.768Z" fill="#EC7558"/> 34 + <path d="M125.905 488.768C143.79 479.983 163.771 477.843 182.788 484.403C202.503 491.308 218.678 505.743 227.769 524.553C230.265 529.753 232.697 536.558 234.16 542.178L233.248 542.328C230.914 540.458 227.385 536.478 225.206 534.158C221.174 524.323 205.62 512.248 197.21 505.928C181.679 494.263 157.47 488.293 138.223 491.728C134.165 490.743 129.967 489.663 125.905 488.768Z" fill="#FCA78D"/> 35 + <path d="M168.622 511.478L169.413 511.678C174.417 512.908 179.094 513.378 184.125 514.263C193.36 515.893 202.259 519.053 210.456 523.608C215.387 526.348 221.14 532.363 225.206 534.158C227.385 536.478 230.914 540.458 233.248 542.328L234.16 542.178C234.865 544.418 235.272 547.163 235.702 549.503C231.529 546.628 227.522 543.123 223.057 540.198C207.421 529.938 196.352 527.793 178.497 525.123C175.75 520.383 172.071 515.743 168.622 511.478Z" fill="#D25742"/> 36 + <path d="M382.522 183.94C384.327 183.074 386.942 184.987 388.607 185.954C394.787 189.142 400.647 189.292 404.772 191.163C395.532 221.024 387.832 272.307 387.837 303.509C380.297 305.63 375.922 305.824 368.432 303.116C363.867 300.9 361.917 299.3 358.982 295.333C369.652 258.855 377.522 221.616 382.522 183.94Z" fill="#EC7558"/> 37 + <path d="M382.522 183.94C384.327 183.074 386.942 184.987 388.607 185.954C386.387 198.175 384.322 210.424 382.427 222.7C380.907 232.459 371.897 298.778 368.432 303.116C363.867 300.9 361.917 299.3 358.982 295.333C369.652 258.855 377.522 221.616 382.522 183.94Z" fill="#FCA78D"/> 38 + <path d="M597.977 459.548C599.347 456.188 600.927 453.068 602.527 449.813C620.497 473.113 632.577 508.983 635.627 537.988L631.152 540.588C628.537 541.913 623.822 544.108 621.462 545.528C618.632 546.848 615.617 547.013 612.542 547.338C599.647 536.633 589.772 493.078 586.962 476.188L592.887 468.733C595.317 465.498 596.712 463.363 597.977 459.548Z" fill="#EC7558"/> 39 + <path d="M586.962 476.188L592.887 468.733C596.607 478.033 598.732 486.538 601.682 496.033C607.277 514.018 612.792 528.898 621.462 545.528C618.632 546.848 615.617 547.013 612.542 547.338C599.647 536.633 589.772 493.078 586.962 476.188Z" fill="#D25742"/> 40 + <path d="M597.977 459.548C599.347 456.188 600.927 453.068 602.527 449.813C620.497 473.113 632.577 508.983 635.627 537.988L631.152 540.588C628.967 534.053 627.872 525.003 625.997 518.393C621.132 501.223 610.352 471.583 597.977 459.548Z" fill="#FCA78D"/> 41 + <path d="M46.1465 449.888C47.436 452.763 48.6755 455.663 49.8655 458.578C51.7055 462.243 53.2465 465.323 55.383 468.808L61.4075 476.193C57.699 495.823 53.352 514.603 45.0825 532.918C40.496 543.078 38.763 550.938 26.31 545.458C23.143 543.888 20.741 542.493 17.702 540.663C16.4095 539.823 14.768 538.538 13.486 537.603C17.5475 504.138 26.2425 477.328 46.1465 449.888Z" fill="#EC7558"/> 42 + <path d="M55.383 468.808L61.4075 476.193C57.699 495.823 53.352 514.603 45.0825 532.918C40.496 543.078 38.763 550.938 26.31 545.458C27.9915 540.588 34.1014 531.733 36.5774 524.748C43.1459 506.218 49.1495 487.443 55.383 468.808Z" fill="#D25742"/> 43 + <path d="M46.1465 449.888C47.436 452.763 48.6755 455.663 49.8655 458.578C47.22 463.088 43.8825 467.528 41.417 472.118C32.374 488.948 25.4485 507.483 21.044 526.063C20.3975 528.788 18.779 538.953 17.702 540.663C16.4095 539.823 14.768 538.538 13.486 537.603C17.5475 504.138 26.2425 477.328 46.1465 449.888Z" fill="#FCA78D"/> 44 + <path d="M255.63 196.465C258.685 210.541 261.493 223.563 265.147 237.502C270.494 257.897 276.722 276.73 283.299 296.669C280.673 299.073 277.485 302.032 274.24 303.569C267.007 306.338 262.279 306.15 254.968 304.502C251.896 267.566 246.202 242.253 234.508 206.736C238.401 205.339 247.111 202.467 250.114 200.546C251.916 199.129 253.774 197.81 255.63 196.465Z" fill="#EC7558"/> 45 + <path d="M255.63 196.465C258.685 210.541 261.493 223.563 265.147 237.502C270.494 257.897 276.722 276.73 283.299 296.669C280.673 299.073 277.485 302.032 274.24 303.569C272.541 302.092 261.876 249.582 260.581 243.571C257.698 230.188 253.071 213.858 250.114 200.546C251.916 199.129 253.774 197.81 255.63 196.465Z" fill="#FCA78D"/> 46 + <path d="M545.867 303.084C551.982 307.991 557.002 310.809 563.562 314.983C565.202 316.028 568.147 319.658 570.197 321.133C579.172 327.583 588.657 329.653 599.357 330.273C597.672 334.233 596.132 337.728 593.562 341.198C580.692 358.588 549.767 373.023 537.492 346.588C537.097 345.793 536.762 344.963 536.487 344.118C533.012 333.528 537.257 317.638 542.107 307.999C543.267 306.411 544.562 304.524 545.867 303.084Z" fill="#D25742"/> 47 + <path d="M542.107 307.999L542.437 308.392C547.887 315.003 551.707 322.913 558.607 328.383C563.397 332.178 568.617 334.778 573.992 337.603C563.717 347.413 551.592 354.718 537.492 346.588C537.097 345.793 536.762 344.963 536.487 344.118C533.012 333.528 537.257 317.638 542.107 307.999Z" fill="#EC7558"/> 48 + <path d="M538.252 446.813C546.957 442.398 558.467 438.343 567.842 436.248C567.397 439.088 567.397 441.798 567.312 444.668C567.307 450.173 567.347 453.893 568.107 459.323C568.727 464.473 569.187 467.098 570.292 472.243C557.767 476.918 541.477 485.663 528.102 487.593C525.747 487.933 508.752 484.263 505.087 483.578C510.517 477.838 514.677 472.983 519.342 466.643C522.257 462.683 527.027 455.238 530.222 451.923L530.852 451.278C532.897 449.153 535.302 448.293 538.007 447.043L538.252 446.813Z" fill="#D25742"/> 49 + <path d="M538.252 446.813C546.957 442.398 558.467 438.343 567.842 436.248C567.397 439.088 567.397 441.798 567.312 444.668C567.307 450.173 567.347 453.893 568.107 459.323L567.967 460.718C565.287 464.478 534.342 474.198 528.092 476.598C532.187 467.948 537.327 456.513 538.007 447.043L538.252 446.813Z" fill="#EC7558"/> 50 + <path d="M538.252 446.813C546.957 442.398 558.467 438.343 567.842 436.248C567.397 439.088 567.397 441.798 567.312 444.668C558.337 444.793 552.437 446.073 543.842 448.658C542.587 449.038 539.452 450.188 538.502 449.708C538.412 448.753 538.302 447.768 538.252 446.813Z" fill="#FCA78D"/> 51 + <path d="M82.907 473.073C87.2555 461.633 100.359 450.873 112.933 452.163C120.9 452.978 125.246 456.363 130.245 462.078C132.571 464.653 133.629 466.838 135.31 469.863C135.281 472.118 135.732 471.383 134.615 472.658C121.201 477.283 115.223 481.203 103.97 489.818C101.06 492.048 93.736 493.183 90.7705 495.913C86.384 499.373 81.7335 504.998 77.977 509.303C77.3485 504.718 76.0979 497.153 77.6469 492.643C78.3649 485.468 79.8345 479.548 82.907 473.073Z" fill="#EC7558"/> 52 + <path d="M135.31 469.863C135.281 472.118 135.732 471.383 134.615 472.658C121.201 477.283 115.223 481.203 103.97 489.818C101.06 492.048 93.736 493.183 90.7705 495.913C86.384 499.373 81.7335 504.998 77.977 509.303C77.3485 504.718 76.0979 497.153 77.6469 492.643C79.1059 493.208 83.0855 488.693 84.3455 487.558C87.607 484.593 91.1055 481.903 94.805 479.503C108.878 470.453 119.242 469.623 135.31 469.863Z" fill="#D25742"/> 53 + <path d="M82.907 473.073C87.2555 461.633 100.359 450.873 112.933 452.163C120.9 452.978 125.246 456.363 130.245 462.078C123.46 462.993 115.782 456.168 104.2 460.283C96.7105 462.943 87.0495 473.193 82.907 473.073Z" fill="#FCA78D"/> 54 + <path d="M490.862 496.533C496.012 496.918 501.152 497.363 506.287 497.873C513.867 498.868 520.267 499.953 527.667 501.993C523.647 509.698 522.137 512.473 519.757 520.878C519.237 523.588 518.812 527.173 518.397 529.968C499.652 527.448 481.237 522.373 464.337 513.783C474.877 507.908 481.217 503.723 490.862 496.533Z" fill="#D25742"/> 55 + <path d="M506.287 497.873C513.867 498.868 520.267 499.953 527.667 501.993C523.647 509.698 522.137 512.473 519.757 520.878C509.532 518.613 502.397 517.153 492.792 512.708C495.562 509.753 503.507 498.993 506.287 497.873Z" fill="#EC7558"/> 56 + <path d="M540.027 538.163C543.567 535.708 547.923 532.853 551.148 530.108C553.618 548.918 553.673 559.673 551.828 578.503C545.463 578.613 541.017 578.428 534.688 577.463C531.953 576.673 529.507 575.388 527.062 573.968C527.647 565.033 529.702 551.528 531.597 542.788C534.257 541.023 537.177 539.623 540.027 538.163Z" fill="#EC7558"/> 57 + <path d="M540.027 538.163C537.952 546.693 535.342 568.693 534.688 577.463C531.953 576.673 529.507 575.388 527.062 573.968C527.647 565.033 529.702 551.528 531.597 542.788C534.257 541.023 537.177 539.623 540.027 538.163Z" fill="#D25742"/> 58 + <path d="M524.987 336.233L525.832 342.868C526.332 345.278 526.782 347.738 527.477 350.088C532.662 361.698 537.587 365.323 548.822 370.518C535.172 377.668 521.912 382.063 512.892 365.303C512.057 363.293 511.522 360.973 510.957 358.853C509.477 346.033 514.117 341.293 524.987 336.233Z" fill="#D25742"/> 59 + <path d="M524.987 336.233L525.832 342.868C526.332 345.278 526.782 347.738 527.477 350.088C526.952 361.673 525.057 365.318 512.892 365.303C512.057 363.293 511.522 360.973 510.957 358.853C509.477 346.033 514.117 341.293 524.987 336.233Z" fill="#EC7558"/> 60 + <path d="M524.987 336.233L525.832 342.868C516.527 348.173 515.482 355.238 510.957 358.853C509.477 346.033 514.117 341.293 524.987 336.233Z" fill="#FCA78D"/> 61 + <path d="M63.1015 543.668C63.705 539.218 64.87 532.123 67.78 528.553L68.5795 528.563C70.3855 532.328 68.879 543.683 69.458 548.413C70.145 554.028 71.899 559.428 72.94 564.858C74.287 569.323 77.087 574.453 79.317 578.528C72.021 579.988 68.2095 579.643 60.878 578.548C60.875 565.023 61.044 557.058 63.1015 543.668Z" fill="#EC7558"/> 62 + <path d="M63.1015 543.668C63.705 539.218 64.87 532.123 67.78 528.553L68.5795 528.563C70.3855 532.328 68.879 543.683 69.458 548.413C70.145 554.028 71.899 559.428 72.94 564.858C65.741 558.988 67.554 547.478 63.1015 543.668Z" fill="#D25742"/> 63 + <path d="M383.927 169.306C392.892 171.49 400.472 173.211 409.867 173.297C409.207 175.506 404.812 189.758 404.772 191.163C400.647 189.292 394.787 189.142 388.607 185.954C386.942 184.987 384.327 183.074 382.522 183.94C383.237 179.114 383.547 174.176 383.927 169.306Z" fill="#D25742"/> 64 + <path d="M129.305 447.048C137.482 445.878 145.265 445.083 152.292 450.078C154.382 451.758 155.976 453.168 157.36 455.518C159.5 459.733 160.807 464.318 161.207 469.028C155.553 468.868 152.214 468.843 146.665 469.973C144.881 465.228 143.442 461.563 140.684 457.263C138.804 454.778 137.236 453.313 135.014 451.138L129.305 447.048Z" fill="#D25742"/> 65 + <path d="M129.305 447.048C137.482 445.878 145.265 445.083 152.292 450.078C154.382 451.758 155.976 453.168 157.36 455.518C154.72 457.033 144.194 457.228 140.684 457.263C138.804 454.778 137.236 453.313 135.014 451.138L129.305 447.048Z" fill="#EC7558"/> 66 + <path d="M129.305 447.048C137.482 445.878 145.265 445.083 152.292 450.078C150.161 451.043 138.313 451.003 135.014 451.138L129.305 447.048Z" fill="#FCA78D"/> 67 + <path d="M228.779 191.537C238.128 189.715 244.519 187.479 253.121 183.867C254.1 187.87 254.549 192.118 255.63 196.465C253.774 197.81 251.916 199.129 250.114 200.546C247.111 202.467 238.401 205.339 234.508 206.736C232.684 201.699 230.677 196.56 228.779 191.537Z" fill="#D25742"/> 68 + <path d="M527.552 589.008L532.242 590.343L541.612 592.333C534.042 602.043 531.297 605.003 521.582 612.703C519.627 614.578 517.417 615.863 515.142 617.343C517.227 610.748 524.222 594.998 527.552 589.008Z" fill="#EC7558"/> 69 + <path d="M527.552 589.008L532.242 590.343C529.987 592.138 521.382 609.828 521.582 612.703C519.627 614.578 517.417 615.863 515.142 617.343C517.227 610.748 524.222 594.998 527.552 589.008Z" fill="#D25742"/> 70 + <path d="M80.606 591.863C81.8665 591.693 81.787 591.653 82.963 592.108C86.4145 595.743 90.2175 611.928 91.8115 617.693C89.7535 616.163 87.886 614.403 85.977 612.688C78.5545 605.248 75.3425 602.018 69.688 593.283L80.606 591.863Z" fill="url(#paint0_linear_12_761)"/> 71 + <path d="M625.938 558.418C628.697 557.458 630.458 556.788 633.148 555.568C631.518 565.678 629.138 580.943 624.703 590.043C624.353 586.598 624.032 583.023 623.587 579.598C622.447 572.933 621.608 566.223 621.062 559.488L625.938 558.418Z" fill="#EC7558"/> 72 + <path d="M621.062 559.488L625.938 558.418L625.888 559.118C625.433 565.788 627.462 575.483 623.662 579.443C623.637 579.493 623.612 579.548 623.587 579.598C622.447 572.933 621.608 566.223 621.062 559.488Z" fill="#D25742"/> 73 + <path d="M15.4434 555.588C18.0874 557.013 19.8183 557.708 22.5948 558.823C24.3158 559.318 25.5999 559.418 26.7989 560.518C27.1469 564.513 25.2134 575.628 24.5164 580.158C24.1304 582.998 23.9274 587.288 23.6929 590.248C19.8154 580.073 16.9349 566.418 15.4434 555.588Z" fill="#EC7558"/> 74 + <path d="M22.5948 558.823C24.3158 559.318 25.5999 559.418 26.7989 560.518C27.1469 564.513 25.2134 575.628 24.5164 580.158C20.9659 574.903 22.3613 565.498 22.5948 558.823Z" fill="#D25742"/> 75 + <path d="M80.1954 436.793C85.8729 438.053 92.2999 440.673 97.8129 442.743C94.7574 444.208 92.7519 445.373 89.8454 447.123C86.3229 449.618 84.3154 451.808 81.4064 454.963C81.2269 448.918 81.0179 442.773 80.1954 436.793Z" fill="#EC7558"/> 76 + <path d="M62.7345 439.883C64.3905 439.623 65.0345 439.338 66.493 440.128C68.912 443.283 67.6455 457.073 65.875 459.098L65.2039 458.493C61.7704 452.188 59.7124 447.143 57.0854 440.543L62.7345 439.883Z" fill="#FCA78D"/> 77 + <path d="M537.837 511.223C540.742 512.813 543.153 514.343 545.943 516.128C542.798 519.208 540.277 521.493 536.907 524.323L532.407 527.483C533.592 520.608 534.762 517.483 537.837 511.223Z" fill="#EC7558"/> 78 + <path d="M580.767 438.983L591.102 440.353L587.687 449.008C585.252 449.478 582.797 449.863 580.332 450.173C580.322 446.053 580.432 443.078 580.767 438.983Z" fill="#FCA78D"/> 79 + <path d="M580.332 450.173C582.797 449.863 585.252 449.478 587.687 449.008C585.752 453.788 584.162 456.743 581.722 461.248C580.957 457.773 580.367 453.728 580.332 450.173Z" fill="#D25742"/> 80 + <path d="M412.442 0.292289C429.932 -1.94271 450.812 8.87979 459.012 24.5403C462.002 30.2538 464.367 43.0648 453.577 36.7678C445.037 30.9748 436.292 26.1793 425.837 25.2133C415.502 24.2583 407.397 26.2433 400.842 16.5408C400.847 6.74379 402.117 2.30929 412.442 0.292289Z" fill="black"/> 81 + <path d="M413.347 6.67285C422.967 7.14585 433.182 9.88436 441.147 15.4429C443.652 17.1899 450.242 22.7168 450.757 25.6548C449.252 25.7103 447.792 24.8294 446.302 24.1714C440.062 21.2564 439.282 21.1389 432.407 19.0634C425.447 16.5084 415.252 18.7633 409.057 15.2413C404.982 12.9223 411.442 7.79085 413.347 6.67285Z" fill="#EC7558"/> 82 + <path d="M197.02 22.2794C205.885 21.8509 219.143 23.5194 217.439 37.2884C216.248 46.9104 195.42 46.4919 188.391 49.0604C182.824 51.0949 178.852 53.2069 173.402 57.0939C170.384 59.5219 164.819 64.7769 160.372 63.0674C158.319 62.2974 157.561 59.1034 157.919 57.1019C160.272 43.9374 172.288 32.4194 183.876 26.7324C188.359 24.5324 192.464 23.3444 197.02 22.2794Z" fill="black"/> 83 + <path d="M200.139 29.2489C204.609 28.8759 211.262 30.2984 209.651 36.3009C209.276 36.7954 208.565 37.8339 207.939 37.9684C199.539 39.7744 191.092 40.8314 183.012 43.9704C179.761 45.2334 177.013 46.5834 173.915 48.2249C170.735 50.3139 169.086 51.6794 166.18 54.1789C175.724 39.2179 182.96 33.4139 200.139 29.2489Z" fill="#EC7558"/> 84 + <defs> 85 + <linearGradient id="paint0_linear_12_761" x1="544.278" y1="228.288" x2="284.127" y2="411.499" gradientUnits="userSpaceOnUse"> 86 + <stop stop-color="#D55C47"/> 87 + <stop offset="1" stop-color="#F3876A"/> 88 + </linearGradient> 89 + </defs> 90 + </svg>
+1 -1
Caddyfile
··· 90 90 X-Content-Type-Options "nosniff" 91 91 X-Frame-Options "DENY" 92 92 Referrer-Policy "strict-origin-when-cross-origin" 93 - Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.bsky.network wss://*.bsky.network" 93 + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https://*.bsky.network wss://*.bsky.network" 94 94 # Remove Server header 95 95 -Server 96 96 }
+3
Dockerfile
··· 45 45 # Must maintain path structure as app looks for internal/db/migrations 46 46 COPY --from=builder /build/internal/db/migrations /app/internal/db/migrations 47 47 48 + # Copy static assets (images, etc. for the web interface) 49 + COPY --from=builder /build/static /app/static 50 + 48 51 # Set ownership 49 52 RUN chown -R coves:coves /app 50 53
static/logo.png

This is a binary file and will not be displayed.

static/favicon.ico

This is a binary file and will not be displayed.

+290
internal/web/templates/privacy.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Privacy Policy - Coves</title> 7 + <link rel="icon" type="image/png" href="/static/images/lil_dude.png"> 8 + <style> 9 + * { box-sizing: border-box; margin: 0; padding: 0; } 10 + body { 11 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 12 + background: #0B0F14; 13 + color: #e4e6e7; 14 + line-height: 1.7; 15 + padding: 24px; 16 + } 17 + .container { 18 + max-width: 800px; 19 + margin: 0 auto; 20 + } 21 + h1 { 22 + font-size: 2rem; 23 + margin-bottom: 0.5rem; 24 + color: #e4e6e7; 25 + } 26 + h2 { 27 + font-size: 1.4rem; 28 + margin-top: 2rem; 29 + margin-bottom: 1rem; 30 + color: #e4e6e7; 31 + border-bottom: 1px solid #2A2F36; 32 + padding-bottom: 0.5rem; 33 + } 34 + h3 { 35 + font-size: 1.1rem; 36 + margin-top: 1.5rem; 37 + margin-bottom: 0.5rem; 38 + color: #e4e6e7; 39 + } 40 + p { 41 + margin-bottom: 1rem; 42 + color: #B6C2D2; 43 + } 44 + ul { 45 + margin-bottom: 1rem; 46 + padding-left: 1.5rem; 47 + } 48 + li { 49 + margin-bottom: 0.5rem; 50 + color: #B6C2D2; 51 + } 52 + a { 53 + color: #7CB9E8; 54 + text-decoration: none; 55 + } 56 + a:hover { 57 + text-decoration: underline; 58 + } 59 + .effective-date { 60 + color: #5A6B7F; 61 + font-size: 0.9rem; 62 + margin-bottom: 2rem; 63 + } 64 + .highlight { 65 + background: rgba(124, 185, 232, 0.1); 66 + border-left: 3px solid #7CB9E8; 67 + padding: 1rem; 68 + margin: 1rem 0; 69 + border-radius: 0 8px 8px 0; 70 + } 71 + .highlight p { 72 + margin-bottom: 0; 73 + } 74 + table { 75 + width: 100%; 76 + border-collapse: collapse; 77 + margin: 1rem 0; 78 + } 79 + th, td { 80 + text-align: left; 81 + padding: 0.75rem; 82 + border-bottom: 1px solid #2A2F36; 83 + } 84 + th { 85 + color: #e4e6e7; 86 + font-weight: 600; 87 + } 88 + td { 89 + color: #B6C2D2; 90 + } 91 + .back-link { 92 + display: inline-block; 93 + margin-bottom: 1.5rem; 94 + color: #5A6B7F; 95 + font-size: 0.9rem; 96 + } 97 + .back-link:hover { 98 + color: #7CB9E8; 99 + } 100 + @media (max-width: 600px) { 101 + body { 102 + padding: 1rem; 103 + } 104 + h1 { 105 + font-size: 1.5rem; 106 + } 107 + } 108 + </style> 109 + </head> 110 + <body> 111 + <div class="container"> 112 + <a href="/" class="back-link">&larr; Back to Coves</a> 113 + 114 + <h1>Privacy Policy</h1> 115 + <p class="effective-date">Effective Date: January 16, 2026</p> 116 + 117 + <p>Coves Team ("we," "our," or "us") operates the Coves mobile application and website. This Privacy Policy explains how we collect, use, and protect your information when you use our service.</p> 118 + 119 + <div class="highlight"> 120 + <p><strong>Summary:</strong> Coves is built on the atProto protocol. Your data is stored on your Personal Data Server (PDS), which you control. We only index and cache publicly available data from the network to provide our service.</p> 121 + </div> 122 + 123 + <h2>1. Information We Collect</h2> 124 + 125 + <h3>1.1 Account Information</h3> 126 + <p>When you sign in to Coves, we receive the following from your atProto identity:</p> 127 + <ul> 128 + <li><strong>Decentralized Identifier (DID):</strong> Your unique identifier on the atProto network</li> 129 + <li><strong>Handle:</strong> Your username (e.g., yourname.bsky.social)</li> 130 + <li><strong>Profile Information:</strong> Display name, bio, and avatar that you've set on your PDS</li> 131 + </ul> 132 + 133 + <h3>1.2 Content You Create</h3> 134 + <p>When you use Coves, the content you create is written to your PDS:</p> 135 + <ul> 136 + <li>Posts and comments</li> 137 + <li>Votes (likes and downvotes)</li> 138 + <li>Community memberships</li> 139 + </ul> 140 + 141 + <h3>1.3 Information We Do NOT Collect</h3> 142 + <table> 143 + <tr> 144 + <th>Data Type</th> 145 + <th>Collected?</th> 146 + </tr> 147 + <tr> 148 + <td>Passwords</td> 149 + <td>No - OAuth only</td> 150 + </tr> 151 + <tr> 152 + <td>Device identifiers (IMEI, serial numbers)</td> 153 + <td>No</td> 154 + </tr> 155 + <tr> 156 + <td>Location data</td> 157 + <td>No</td> 158 + </tr> 159 + <tr> 160 + <td>Contacts</td> 161 + <td>No</td> 162 + </tr> 163 + <tr> 164 + <td>Photos/media from your device</td> 165 + <td>No</td> 166 + </tr> 167 + <tr> 168 + <td>Analytics or telemetry</td> 169 + <td>No</td> 170 + </tr> 171 + <tr> 172 + <td>Advertising identifiers</td> 173 + <td>No</td> 174 + </tr> 175 + <tr> 176 + <td>Crash reports</td> 177 + <td>No</td> 178 + </tr> 179 + </table> 180 + 181 + <h2>2. How We Use Your Information</h2> 182 + <p>We use the information we collect to:</p> 183 + <ul> 184 + <li>Authenticate you and maintain your session</li> 185 + <li>Display your profile and content within the app</li> 186 + <li>Show you posts, comments, and communities</li> 187 + <li>Process your votes and interactions</li> 188 + </ul> 189 + <p>We do not use your information for advertising, profiling, or selling to third parties.</p> 190 + 191 + <h2>3. The atProto Protocol and Federation</h2> 192 + 193 + <div class="highlight"> 194 + <p><strong>Important:</strong> Coves is built on the atProto (AT Protocol), a federated social networking protocol. This affects how your data works.</p> 195 + </div> 196 + 197 + <h3>3.1 Your Personal Data Server (PDS)</h3> 198 + <p>Your content (posts, comments, votes) is stored on your PDS, not on Coves servers. You may host your own PDS or use a hosted provider. You maintain control over your data at the PDS level.</p> 199 + 200 + <h3>3.2 What Coves Stores</h3> 201 + <p>Coves operates an AppView that indexes publicly available data from the atProto network firehose. This means:</p> 202 + <ul> 203 + <li>We cache and index your public content to provide fast access</li> 204 + <li>Your authentication tokens are stored encrypted on your device</li> 205 + <li>We do not store copies of your private data</li> 206 + </ul> 207 + 208 + <h3>3.3 Federation</h3> 209 + <p>Because atProto is federated, your public content may be visible on other applications and services that use the protocol. This is a feature of the protocol, not something Coves controls. When you post content, consider that it may be indexed and displayed by other atProto services.</p> 210 + 211 + <h2>4. Data Storage and Security</h2> 212 + 213 + <h3>4.1 Where Data is Stored</h3> 214 + <ul> 215 + <li><strong>Your device:</strong> Authentication tokens are stored in encrypted storage (iOS Keychain / Android EncryptedSharedPreferences)</li> 216 + <li><strong>Your PDS:</strong> Your content is stored on your Personal Data Server</li> 217 + <li><strong>Our servers:</strong> Located in Canada, used for indexing public atProto data</li> 218 + </ul> 219 + 220 + <h3>4.2 Security Measures</h3> 221 + <ul> 222 + <li>OAuth 2.0 with DPoP for authentication</li> 223 + <li>Encrypted token storage on device</li> 224 + <li>HTTPS for all network communications</li> 225 + <li>No plaintext storage of sensitive data</li> 226 + </ul> 227 + 228 + <h2>5. Third-Party Services</h2> 229 + <p>We use the following third-party services:</p> 230 + <ul> 231 + <li><strong>Cloudflare:</strong> For content delivery, DDoS protection, and DNS. Cloudflare may process your IP address and request metadata. See <a href="https://www.cloudflare.com/privacy/" target="_blank" rel="noopener">Cloudflare's Privacy Policy</a>.</li> 232 + </ul> 233 + <p>We do not use third-party analytics, advertising networks, or tracking services.</p> 234 + 235 + <h2>6. Data Retention and Deletion</h2> 236 + 237 + <h3>6.1 On Coves Servers</h3> 238 + <p>When you delete content or your account:</p> 239 + <ul> 240 + <li>Your data is removed from our index immediately</li> 241 + <li>We do not retain backups of deleted content</li> 242 + <li>Cached data is purged from our systems</li> 243 + </ul> 244 + 245 + <h3>6.2 On Your PDS</h3> 246 + <p>Content stored on your PDS is managed by you or your PDS provider. Deleting content from Coves removes it from our index, but the original data on your PDS must be managed separately according to your PDS provider's policies.</p> 247 + 248 + <h3>6.3 Federation Caveat</h3> 249 + <p>Due to the federated nature of atProto, content that was public may have been indexed or cached by other services before deletion. We cannot control data held by other parties on the network.</p> 250 + 251 + <h2>7. Your Rights</h2> 252 + <p>You have the right to:</p> 253 + <ul> 254 + <li><strong>Access:</strong> View all data associated with your account</li> 255 + <li><strong>Delete:</strong> Remove your content from our index by deleting it in the app or signing out</li> 256 + <li><strong>Control:</strong> Manage your data directly on your PDS</li> 257 + <li><strong>Portability:</strong> Your data lives on your PDS and can be moved to another atProto service</li> 258 + </ul> 259 + <p>To exercise these rights, contact us at <a href="mailto:support@coves.social">support@coves.social</a>.</p> 260 + 261 + <h2>8. Age Requirement</h2> 262 + <p>Coves is intended for users who are <strong>18 years of age or older</strong>. We do not knowingly collect information from anyone under 18. If you are under 18, please do not use this service.</p> 263 + <p>If we learn that we have collected personal information from a user under 18, we will take steps to delete that information promptly.</p> 264 + 265 + <h2>9. Changes to This Policy</h2> 266 + <p>We may update this Privacy Policy from time to time. We will notify you of significant changes by:</p> 267 + <ul> 268 + <li>Posting the new policy on this page</li> 269 + <li>Updating the "Effective Date" at the top</li> 270 + <li>Notifying you through the app when appropriate</li> 271 + </ul> 272 + <p>We encourage you to review this policy periodically.</p> 273 + 274 + <h2>10. Future Features</h2> 275 + <p>We may introduce analytics, crash reporting, or other features in the future to improve the service. If we do, we will:</p> 276 + <ul> 277 + <li>Update this Privacy Policy before implementing such features</li> 278 + <li>Use privacy-respecting options where possible</li> 279 + <li>Be transparent about what data is collected and why</li> 280 + </ul> 281 + 282 + <h2>11. Contact Us</h2> 283 + <p>If you have questions about this Privacy Policy or our practices, contact us at:</p> 284 + <p> 285 + <strong>Coves Team</strong><br> 286 + Email: <a href="mailto:support@coves.social">support@coves.social</a> 287 + </p> 288 + </div> 289 + </body> 290 + </html>
+37 -26
tests/integration/community_e2e_test.go
··· 70 70 71 71 72 72 73 - 74 - 75 - 76 - 77 - 78 - 73 + // Clean up test data from previous runs (order matters due to FK constraints) 74 + // Delete subscriptions first (references communities and users) 75 + if _, cleanErr := db.Exec("DELETE FROM community_subscriptions"); cleanErr != nil { 76 + t.Logf("Warning: Failed to clean up subscriptions: %v", cleanErr) 77 + } 78 + // Delete posts (references communities) 79 79 80 80 81 81 ··· 103 103 // Setup dependencies 104 104 communityRepo := postgres.NewCommunityRepository(db) 105 105 106 - // Get instance credentials 107 - instanceHandle := os.Getenv("PDS_INSTANCE_HANDLE") 108 - instancePassword := os.Getenv("PDS_INSTANCE_PASSWORD") 109 - if instanceHandle == "" { 110 - instanceHandle = "testuser123.local.coves.dev" 111 - } 112 - if instancePassword == "" { 113 - instancePassword = "test-password-123" 114 - } 106 + // Create a fresh test account on PDS (similar to user_journey_e2e_test pattern) 107 + // Use unique handle to avoid conflicts between test runs 108 + // Use full Unix seconds + nanoseconds remainder for better uniqueness across runs 109 + now := time.Now() 110 + uniqueID := fmt.Sprintf("%d%d", now.Unix()%100000, now.UnixNano()%10000) 111 + instanceHandle := fmt.Sprintf("ce%s.local.coves.dev", uniqueID) 112 + instanceEmail := fmt.Sprintf("comm%s@test.com", uniqueID) 113 + instancePassword := "test-password-community-123" 115 114 116 - t.Logf("๐Ÿ” Authenticating with PDS as: %s", instanceHandle) 115 + t.Logf("๐Ÿ” Creating test account on PDS: %s", instanceHandle) 117 116 118 - // Authenticate to get instance DID 119 - accessToken, instanceDID, err := authenticateWithPDS(pdsURL, instanceHandle, instancePassword) 117 + // Create account on PDS - this returns the access token and DID 118 + accessToken, instanceDID, err := createPDSAccount(pdsURL, instanceHandle, instanceEmail, instancePassword) 120 119 if err != nil { 121 - t.Fatalf("Failed to authenticate with PDS: %v", err) 120 + t.Fatalf("Failed to create account on PDS: %v", err) 122 121 } 123 122 124 - t.Logf("โœ… Authenticated - Instance DID: %s", instanceDID) 123 + t.Logf("โœ… Account created - Instance DID: %s", instanceDID) 125 124 126 125 // Initialize OAuth auth middleware for E2E testing 127 126 e2eAuth := NewE2EOAuthMiddleware() ··· 151 150 152 151 153 152 153 + provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 154 154 155 - 156 - 157 - 158 - 159 - 160 - 155 + // Create service with PDS factory for password-based auth in tests 156 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, instanceDID, instanceDomain, provisioner, CommunityPasswordAuthPDSClientFactory(), nil) 157 + if svc, ok := communityService.(interface{ SetPDSAccessToken(string) }); ok { 158 + svc.SetPDSAccessToken(accessToken) 159 + } 161 160 162 161 163 162 ··· 1761 1760 } 1762 1761 defer func() { _ = conn.Close() }() 1763 1762 1763 + // Track consecutive timeouts to detect stale connections 1764 + // gorilla/websocket panics after 1000 repeated reads on a failed connection 1765 + consecutiveTimeouts := 0 1766 + const maxConsecutiveTimeouts = 10 1767 + 1764 1768 // Read messages until we find our event or receive done signal 1765 1769 for { 1766 1770 select { ··· 1782 1786 return nil 1783 1787 } 1784 1788 if netErr, ok := err.(net.Error); ok && netErr.Timeout() { 1789 + consecutiveTimeouts++ 1790 + if consecutiveTimeouts >= maxConsecutiveTimeouts { 1791 + return fmt.Errorf("connection appears stale after %d consecutive timeouts", consecutiveTimeouts) 1792 + } 1785 1793 continue // Timeout is expected, keep listening 1786 1794 } 1787 1795 // For other errors, don't retry reading from a broken connection 1788 1796 return fmt.Errorf("failed to read Jetstream message: %w", err) 1789 1797 } 1790 1798 1799 + // Reset timeout counter on successful read 1800 + consecutiveTimeouts = 0 1801 + 1791 1802 // Check if this is the event we're looking for 1792 1803 if event.Did == targetDID && event.Kind == "commit" { 1793 1804 // Process the event through the consumer
+11
internal/atproto/lexicon/social/coves/community/create.json
··· 33 33 "accept": ["image/png", "image/jpeg", "image/webp"], 34 34 "maxSize": 2000000 35 35 }, 36 + "avatarMimeType": { 37 + "type": "string", 38 + "maxLength": 128, 39 + "description": "MIME type of avatar blob (image/png, image/jpeg, image/webp)" 40 + }, 41 + "bannerMimeType": { 42 + "type": "string", 43 + "maxLength": 128, 44 + "description": "MIME type of banner blob (image/png, image/jpeg, image/webp)" 45 + }, 36 46 "rules": { 37 47 "type": "array", 38 48 "maxLength": 10, ··· 102 112 }, 103 113 "handle": { 104 114 "type": "string", 115 + "maxLength": 512, 105 116 "description": "Scoped handle of the created community (~name@instance)" 106 117 } 107 118 }
+10
internal/atproto/lexicon/social/coves/community/update.json
··· 38 38 "accept": ["image/png", "image/jpeg", "image/webp"], 39 39 "maxSize": 2000000 40 40 }, 41 + "avatarMimeType": { 42 + "type": "string", 43 + "maxLength": 128, 44 + "description": "MIME type of avatar blob (image/png, image/jpeg, image/webp)" 45 + }, 46 + "bannerMimeType": { 47 + "type": "string", 48 + "maxLength": 128, 49 + "description": "MIME type of banner blob (image/png, image/jpeg, image/webp)" 50 + }, 41 51 "rules": { 42 52 "type": "array", 43 53 "maxLength": 10,
+12
internal/core/communities/pds_provisioning.go
··· 25 25 SigningKeyPEM string // PEM-encoded signing key (for atproto operations) 26 26 } 27 27 28 + // GetPDSURL implements blobs.BlobOwner interface. 29 + // Returns the community's PDS URL for blob uploads. 30 + func (c *CommunityPDSAccount) GetPDSURL() string { 31 + return c.PDSURL 32 + } 33 + 34 + // GetPDSAccessToken implements blobs.BlobOwner interface. 35 + // Returns the community's PDS access token for blob upload authentication. 36 + func (c *CommunityPDSAccount) GetPDSAccessToken() string { 37 + return c.AccessToken 38 + } 39 + 28 40 // PDSAccountProvisioner creates PDS accounts for communities with PDS-managed DIDs 29 41 type PDSAccountProvisioner struct { 30 42 instanceDomain string
+110 -9
internal/core/communities/service.go
··· 4 4 oauthclient "Coves/internal/atproto/oauth" 5 5 "Coves/internal/atproto/pds" 6 6 "Coves/internal/atproto/utils" 7 + "Coves/internal/core/blobs" 7 8 "bytes" 8 9 "context" 9 10 "encoding/json" ··· 39 40 // Interfaces and pointers first (better alignment) 40 41 repo Repository 41 42 provisioner *PDSAccountProvisioner 43 + blobService blobs.Service 42 44 43 45 // OAuth client for user PDS authentication (DPoP-based) 44 46 oauthClient *oauthclient.OAuthClient ··· 71 73 pdsURL, instanceDID, instanceDomain string, 72 74 provisioner *PDSAccountProvisioner, 73 75 oauthClient *oauthclient.OAuthClient, 76 + blobService blobs.Service, 74 77 ) Service { 75 78 // SECURITY: Basic validation that did:web domain matches configured instanceDomain 76 79 // This catches honest configuration mistakes but NOT malicious code modifications ··· 93 96 instanceDomain: instanceDomain, 94 97 provisioner: provisioner, 95 98 oauthClient: oauthClient, 99 + blobService: blobService, 96 100 refreshMutexes: make(map[string]*sync.Mutex), 97 101 } 98 102 } ··· 104 108 pdsURL, instanceDID, instanceDomain string, 105 109 provisioner *PDSAccountProvisioner, 106 110 factory PDSClientFactory, 111 + blobService blobs.Service, 107 112 ) Service { 108 113 return &communityService{ 109 114 repo: repo, ··· 112 117 instanceDomain: instanceDomain, 113 118 provisioner: provisioner, 114 119 pdsClientFactory: factory, 120 + blobService: blobService, 115 121 refreshMutexes: make(map[string]*sync.Mutex), 116 122 } 117 123 } ··· 219 225 profile["language"] = req.Language 220 226 } 221 227 222 - // TODO: Handle avatar and banner blobs 223 - // For now, we'll skip blob uploads. This would require: 224 - // 1. Upload blob to PDS via com.atproto.repo.uploadBlob 225 - // 2. Get blob ref (CID) 226 - // 3. Add to profile record 228 + // Track blob CIDs for storage in the community record 229 + var avatarCID, bannerCID string 230 + 231 + // Upload avatar if provided 232 + if len(req.AvatarBlob) > 0 { 233 + if req.AvatarMimeType == "" { 234 + return nil, fmt.Errorf("avatarMimeType is required when avatarBlob is provided") 235 + } 236 + if s.blobService == nil { 237 + return nil, fmt.Errorf("blob service not configured, cannot upload avatar") 238 + } 239 + avatarRef, err := s.blobService.UploadBlob(ctx, pdsAccount, req.AvatarBlob, req.AvatarMimeType) 240 + if err != nil { 241 + return nil, fmt.Errorf("failed to upload avatar: %w", err) 242 + } 243 + profile["avatar"] = map[string]interface{}{ 244 + "$type": avatarRef.Type, 245 + "ref": avatarRef.Ref, 246 + "mimeType": avatarRef.MimeType, 247 + "size": avatarRef.Size, 248 + } 249 + // Extract CID for database storage 250 + avatarCID = avatarRef.Ref["$link"] 251 + } 252 + 253 + // Upload banner if provided 254 + if len(req.BannerBlob) > 0 { 255 + if req.BannerMimeType == "" { 256 + return nil, fmt.Errorf("bannerMimeType is required when bannerBlob is provided") 257 + } 258 + if s.blobService == nil { 259 + return nil, fmt.Errorf("blob service not configured, cannot upload banner") 260 + } 261 + bannerRef, err := s.blobService.UploadBlob(ctx, pdsAccount, req.BannerBlob, req.BannerMimeType) 262 + if err != nil { 263 + return nil, fmt.Errorf("failed to upload banner: %w", err) 264 + } 265 + profile["banner"] = map[string]interface{}{ 266 + "$type": bannerRef.Type, 267 + "ref": bannerRef.Ref, 268 + "mimeType": bannerRef.MimeType, 269 + "size": bannerRef.Size, 270 + } 271 + // Extract CID for database storage 272 + bannerCID = bannerRef.Ref["$link"] 273 + } 227 274 228 275 // V2: Write to COMMUNITY's own repository (not instance repo!) 229 276 // Repository: at://COMMUNITY_DID/social.coves.community.profile/self ··· 257 304 PDSURL: pdsAccount.PDSURL, 258 305 Visibility: req.Visibility, 259 306 AllowExternalDiscovery: req.AllowExternalDiscovery, 307 + AvatarCID: avatarCID, 308 + BannerCID: bannerCID, 260 309 MemberCount: 0, 261 310 SubscriberCount: 0, 262 311 CreatedAt: time.Now(), ··· 363 412 return nil, err 364 413 } 365 414 415 + // Authorization: verify user is the creator BEFORE any expensive operations 416 + // This prevents unauthorized users from uploading orphaned blobs (DoS vector) 417 + // TODO(Communities-Auth): Add moderator check when moderation system is implemented 418 + if existing.CreatedByDID != req.UpdatedByDID { 419 + return nil, ErrUnauthorized 420 + } 421 + 366 422 // CRITICAL: Ensure fresh PDS access token before write operation 367 423 // Community PDS tokens expire every ~2 hours and must be refreshed 368 424 existing, err = s.EnsureFreshToken(ctx, existing) ··· 370 426 return nil, fmt.Errorf("failed to ensure fresh credentials: %w", err) 371 427 } 372 428 373 - // Authorization: verify user is the creator 374 - // TODO(Communities-Auth): Add moderator check when moderation system is implemented 375 - if existing.CreatedByDID != req.UpdatedByDID { 376 - return nil, ErrUnauthorized 429 + // Upload avatar if provided 430 + var avatarRef *blobs.BlobRef 431 + if len(req.AvatarBlob) > 0 { 432 + if req.AvatarMimeType == "" { 433 + return nil, fmt.Errorf("avatarMimeType is required when avatarBlob is provided") 434 + } 435 + if s.blobService == nil { 436 + return nil, fmt.Errorf("blob service not configured, cannot upload avatar") 437 + } 438 + ref, err := s.blobService.UploadBlob(ctx, existing, req.AvatarBlob, req.AvatarMimeType) 439 + if err != nil { 440 + return nil, fmt.Errorf("failed to upload avatar: %w", err) 441 + } 442 + avatarRef = ref 443 + } 444 + 445 + // Upload banner if provided 446 + var bannerRef *blobs.BlobRef 447 + if len(req.BannerBlob) > 0 { 448 + if req.BannerMimeType == "" { 449 + return nil, fmt.Errorf("bannerMimeType is required when bannerBlob is provided") 450 + } 451 + if s.blobService == nil { 452 + return nil, fmt.Errorf("blob service not configured, cannot upload banner") 453 + } 454 + ref, err := s.blobService.UploadBlob(ctx, existing, req.BannerBlob, req.BannerMimeType) 455 + if err != nil { 456 + return nil, fmt.Errorf("failed to upload banner: %w", err) 457 + } 458 + bannerRef = ref 377 459 } 378 460 379 461 // Build updated profile record (start with existing) ··· 429 511 profile["contentWarnings"] = existing.ContentWarnings 430 512 } 431 513 514 + // Add blob references if uploaded 515 + if avatarRef != nil { 516 + profile["avatar"] = map[string]interface{}{ 517 + "$type": avatarRef.Type, 518 + "ref": avatarRef.Ref, 519 + "mimeType": avatarRef.MimeType, 520 + "size": avatarRef.Size, 521 + } 522 + } 523 + 524 + if bannerRef != nil { 525 + profile["banner"] = map[string]interface{}{ 526 + "$type": bannerRef.Type, 527 + "ref": bannerRef.Ref, 528 + "mimeType": bannerRef.MimeType, 529 + "size": bannerRef.Size, 530 + } 531 + } 532 + 432 533 // V2: Community profiles always use "self" as rkey 433 534 // (No need to extract from URI - it's always "self" for V2 communities) 434 535
+1021
tests/integration/community_avatar_e2e_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/atproto/identity" 5 + "Coves/internal/atproto/jetstream" 6 + "Coves/internal/core/blobs" 7 + "Coves/internal/core/communities" 8 + "Coves/internal/db/postgres" 9 + "bytes" 10 + "context" 11 + "database/sql" 12 + "fmt" 13 + "image" 14 + "image/color" 15 + "image/png" 16 + "net/http" 17 + "os" 18 + "strings" 19 + "testing" 20 + "time" 21 + 22 + "github.com/gorilla/websocket" 23 + _ "github.com/lib/pq" 24 + "github.com/pressly/goose/v3" 25 + ) 26 + 27 + // createTestPNGImage creates a simple PNG image for testing 28 + // Panics on encoding error since this is a test helper and encoding should never fail 29 + // for simple in-memory images 30 + func createTestPNGImage(width, height int, c color.Color) []byte { 31 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 32 + for y := 0; y < height; y++ { 33 + for x := 0; x < width; x++ { 34 + img.Set(x, y, c) 35 + } 36 + } 37 + var buf bytes.Buffer 38 + if err := png.Encode(&buf, img); err != nil { 39 + panic(fmt.Sprintf("createTestPNGImage: failed to encode PNG: %v", err)) 40 + } 41 + return buf.Bytes() 42 + } 43 + 44 + // TestCommunityAvatarE2E_CreateWithAvatar tests creating a community with an avatar 45 + // Flow: CreateCommunity(avatar) โ†’ PDS uploadBlob + putRecord โ†’ Jetstream โ†’ Consumer โ†’ AppView 46 + func TestCommunityAvatarE2E_CreateWithAvatar(t *testing.T) { 47 + if testing.Short() { 48 + t.Skip("Skipping E2E test in short mode") 49 + } 50 + 51 + // Setup test database 52 + dbURL := os.Getenv("TEST_DATABASE_URL") 53 + if dbURL == "" { 54 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 55 + } 56 + 57 + db, err := sql.Open("postgres", dbURL) 58 + if err != nil { 59 + t.Fatalf("Failed to connect to test database: %v", err) 60 + } 61 + defer func() { _ = db.Close() }() 62 + 63 + // Run migrations 64 + if dialectErr := goose.SetDialect("postgres"); dialectErr != nil { 65 + t.Fatalf("Failed to set goose dialect: %v", dialectErr) 66 + } 67 + if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil { 68 + t.Fatalf("Failed to run migrations: %v", migrateErr) 69 + } 70 + 71 + // Check if PDS is running 72 + pdsURL := os.Getenv("PDS_URL") 73 + if pdsURL == "" { 74 + pdsURL = "http://localhost:3001" 75 + } 76 + 77 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 78 + if err != nil { 79 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 80 + } 81 + _ = healthResp.Body.Close() 82 + 83 + // Check if Jetstream is running 84 + pdsHostname := strings.TrimPrefix(pdsURL, "http://") 85 + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") 86 + pdsHostname = strings.Split(pdsHostname, ":")[0] 87 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.profile", pdsHostname) 88 + 89 + testConn, _, connErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 90 + if connErr != nil { 91 + t.Skipf("Jetstream not available at %s: %v. Run 'make dev-up' to start.", jetstreamURL, connErr) 92 + } 93 + _ = testConn.Close() 94 + t.Logf("โœ… Jetstream available at %s", jetstreamURL) 95 + 96 + ctx := context.Background() 97 + instanceDID := "did:web:coves.social" 98 + 99 + // Setup identity resolver with local PLC 100 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 101 + if plcURL == "" { 102 + plcURL = "http://localhost:3002" 103 + } 104 + identityConfig := identity.DefaultConfig() 105 + identityConfig.PLCURL = plcURL 106 + identityResolver := identity.NewResolver(db, identityConfig) 107 + 108 + // Setup services 109 + communityRepo := postgres.NewCommunityRepository(db) 110 + provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 111 + blobService := blobs.NewBlobService(pdsURL) 112 + 113 + communityService := communities.NewCommunityServiceWithPDSFactory( 114 + communityRepo, 115 + pdsURL, 116 + instanceDID, 117 + "coves.social", 118 + provisioner, 119 + nil, // No custom PDS factory, uses built-in 120 + blobService, 121 + ) 122 + 123 + consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver) 124 + 125 + t.Run("create community with avatar via real Jetstream", func(t *testing.T) { 126 + uniqueName := fmt.Sprintf("avt%d", time.Now().UnixNano()%1000000) 127 + creatorDID := "did:plc:avatar-create-test" 128 + 129 + // Create a test PNG image (100x100 red square) 130 + avatarData := createTestPNGImage(100, 100, color.RGBA{255, 0, 0, 255}) 131 + t.Logf("Created test avatar image: %d bytes", len(avatarData)) 132 + 133 + // Subscribe to Jetstream BEFORE creating the community 134 + // This ensures we catch the create event 135 + eventChan := make(chan *jetstream.JetstreamEvent, 10) 136 + done := make(chan bool) 137 + subscribeCtx, cancelSubscribe := context.WithTimeout(ctx, 30*time.Second) 138 + defer cancelSubscribe() 139 + 140 + // We don't know the DID yet, so we'll filter by collection and match after 141 + go func() { 142 + conn, _, dialErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 143 + if dialErr != nil { 144 + t.Logf("Failed to connect to Jetstream: %v", dialErr) 145 + return 146 + } 147 + defer func() { _ = conn.Close() }() 148 + 149 + for { 150 + select { 151 + case <-done: 152 + return 153 + case <-subscribeCtx.Done(): 154 + return 155 + default: 156 + if deadlineErr := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); deadlineErr != nil { 157 + return 158 + } 159 + 160 + var event jetstream.JetstreamEvent 161 + if readErr := conn.ReadJSON(&event); readErr != nil { 162 + continue // Timeout or error, keep trying 163 + } 164 + 165 + // Only process community profile create events 166 + if event.Kind == "commit" && event.Commit != nil && 167 + event.Commit.Collection == "social.coves.community.profile" && 168 + event.Commit.Operation == "create" { 169 + eventChan <- &event 170 + } 171 + } 172 + } 173 + }() 174 + time.Sleep(500 * time.Millisecond) // Give subscriber time to connect 175 + 176 + t.Logf("\n๐Ÿ“ Creating community with avatar on PDS...") 177 + community, createErr := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 178 + Name: uniqueName, 179 + DisplayName: "Community With Avatar", 180 + Description: "Testing avatar upload on create", 181 + Visibility: "public", 182 + CreatedByDID: creatorDID, 183 + HostedByDID: instanceDID, 184 + AllowExternalDiscovery: true, 185 + AvatarBlob: avatarData, 186 + AvatarMimeType: "image/png", 187 + }) 188 + if createErr != nil { 189 + close(done) 190 + t.Fatalf("Failed to create community with avatar: %v", createErr) 191 + } 192 + 193 + t.Logf("โœ… Community created on PDS:") 194 + t.Logf(" DID: %s", community.DID) 195 + t.Logf(" RecordCID: %s", community.RecordCID) 196 + t.Logf(" AvatarCID (from service): %s", community.AvatarCID) 197 + 198 + // Wait for REAL Jetstream event 199 + t.Logf("\nโณ Waiting for create event from Jetstream...") 200 + var realEvent *jetstream.JetstreamEvent 201 + timeout := time.After(15 * time.Second) 202 + 203 + eventLoop: 204 + for { 205 + select { 206 + case event := <-eventChan: 207 + // Match by DID (we now know it) 208 + if event.Did == community.DID { 209 + realEvent = event 210 + t.Logf("โœ… Received REAL create event from Jetstream!") 211 + t.Logf(" DID: %s", event.Did) 212 + t.Logf(" Operation: %s", event.Commit.Operation) 213 + t.Logf(" CID: %s", event.Commit.CID) 214 + 215 + // Log avatar info from real event 216 + if event.Commit.Record != nil { 217 + if avatar, hasAvatar := event.Commit.Record["avatar"]; hasAvatar { 218 + t.Logf(" Avatar in event: %v", avatar) 219 + } 220 + } 221 + break eventLoop 222 + } 223 + case <-timeout: 224 + close(done) 225 + t.Fatalf("Timeout waiting for Jetstream create event for DID %s", community.DID) 226 + } 227 + } 228 + close(done) 229 + 230 + // Process the REAL event through consumer 231 + // Note: The community already exists (service indexed it), so consumer will hit conflict 232 + // But this tests that the real event has correct avatar data 233 + t.Logf("\n๐Ÿ”„ Processing real Jetstream event through consumer...") 234 + if handleErr := consumer.HandleEvent(ctx, realEvent); handleErr != nil { 235 + t.Logf(" Note: Consumer conflict expected (already indexed): %v", handleErr) 236 + } 237 + 238 + // Verify avatar CID matches what's in the database 239 + final, err := communityRepo.GetByDID(ctx, community.DID) 240 + if err != nil { 241 + t.Fatalf("Failed to get final community: %v", err) 242 + } 243 + 244 + t.Logf("\nโœ… Community avatar verification:") 245 + t.Logf(" AvatarCID in DB: %s", final.AvatarCID) 246 + 247 + if final.AvatarCID == "" { 248 + t.Errorf("Expected AvatarCID to be set after create with avatar") 249 + } 250 + 251 + // Verify the avatar CID from the real Jetstream event matches what we stored 252 + if realEvent.Commit.Record != nil { 253 + if avatar, hasAvatar := realEvent.Commit.Record["avatar"].(map[string]interface{}); hasAvatar { 254 + if ref, hasRef := avatar["ref"].(map[string]interface{}); hasRef { 255 + if link, hasLink := ref["$link"].(string); hasLink { 256 + t.Logf(" AvatarCID from Jetstream: %s", link) 257 + if final.AvatarCID != link { 258 + t.Errorf("AvatarCID mismatch: DB has %s, Jetstream has %s", final.AvatarCID, link) 259 + } else { 260 + t.Logf(" โœ… AvatarCID matches between DB and Jetstream event!") 261 + } 262 + } 263 + } 264 + } 265 + } 266 + 267 + // Verify we can fetch the avatar from PDS 268 + pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.profile&rkey=self", 269 + pdsURL, community.DID)) 270 + if pdsErr != nil { 271 + t.Fatalf("Failed to fetch profile record from PDS: %v", pdsErr) 272 + } 273 + defer func() { _ = pdsResp.Body.Close() }() 274 + 275 + if pdsResp.StatusCode != http.StatusOK { 276 + t.Fatalf("Profile record not found on PDS: status %d", pdsResp.StatusCode) 277 + } 278 + t.Logf(" โœ… Profile record with avatar exists on PDS") 279 + 280 + t.Logf("\nโœ… TRUE E2E AVATAR CREATE FLOW COMPLETE:") 281 + t.Logf(" Service โ†’ PDS uploadBlob โ†’ PDS putRecord โ†’ Jetstream โ†’ Verified โœ“") 282 + }) 283 + } 284 + 285 + // TestCommunityAvatarE2E_UpdateWithAvatar tests updating a community's avatar 286 + // Flow: UpdateCommunity(avatar) โ†’ PDS uploadBlob + putRecord โ†’ Jetstream โ†’ Consumer โ†’ AppView 287 + func TestCommunityAvatarE2E_UpdateWithAvatar(t *testing.T) { 288 + if testing.Short() { 289 + t.Skip("Skipping E2E test in short mode") 290 + } 291 + 292 + // Setup test database 293 + dbURL := os.Getenv("TEST_DATABASE_URL") 294 + if dbURL == "" { 295 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 296 + } 297 + 298 + db, err := sql.Open("postgres", dbURL) 299 + if err != nil { 300 + t.Fatalf("Failed to connect to test database: %v", err) 301 + } 302 + defer func() { _ = db.Close() }() 303 + 304 + // Run migrations 305 + if dialectErr := goose.SetDialect("postgres"); dialectErr != nil { 306 + t.Fatalf("Failed to set goose dialect: %v", dialectErr) 307 + } 308 + if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil { 309 + t.Fatalf("Failed to run migrations: %v", migrateErr) 310 + } 311 + 312 + // Check if PDS is running 313 + pdsURL := os.Getenv("PDS_URL") 314 + if pdsURL == "" { 315 + pdsURL = "http://localhost:3001" 316 + } 317 + 318 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 319 + if err != nil { 320 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 321 + } 322 + _ = healthResp.Body.Close() 323 + 324 + // Check if Jetstream is running - REQUIRED for true E2E 325 + pdsHostname := strings.TrimPrefix(pdsURL, "http://") 326 + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") 327 + pdsHostname = strings.Split(pdsHostname, ":")[0] 328 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.profile", pdsHostname) 329 + 330 + testConn, _, connErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 331 + if connErr != nil { 332 + t.Skipf("Jetstream not available at %s: %v. Run 'make dev-up' to start.", jetstreamURL, connErr) 333 + } 334 + _ = testConn.Close() 335 + t.Logf("โœ… Jetstream available at %s", jetstreamURL) 336 + 337 + ctx := context.Background() 338 + instanceDID := "did:web:coves.social" 339 + 340 + // Setup identity resolver with local PLC 341 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 342 + if plcURL == "" { 343 + plcURL = "http://localhost:3002" 344 + } 345 + identityConfig := identity.DefaultConfig() 346 + identityConfig.PLCURL = plcURL 347 + identityResolver := identity.NewResolver(db, identityConfig) 348 + 349 + // Setup services 350 + communityRepo := postgres.NewCommunityRepository(db) 351 + provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 352 + blobService := blobs.NewBlobService(pdsURL) 353 + 354 + communityService := communities.NewCommunityServiceWithPDSFactory( 355 + communityRepo, 356 + pdsURL, 357 + instanceDID, 358 + "coves.social", 359 + provisioner, 360 + nil, 361 + blobService, 362 + ) 363 + 364 + consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver) 365 + 366 + // Helper to wait for Jetstream update event and process it 367 + waitForUpdateEvent := func(t *testing.T, communityDID string, timeout time.Duration) *jetstream.JetstreamEvent { 368 + eventChan := make(chan *jetstream.JetstreamEvent, 10) 369 + done := make(chan bool) 370 + subscribeCtx, cancelSubscribe := context.WithTimeout(ctx, timeout) 371 + defer cancelSubscribe() 372 + 373 + go func() { 374 + conn, _, dialErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 375 + if dialErr != nil { 376 + t.Logf("Failed to connect to Jetstream: %v", dialErr) 377 + return 378 + } 379 + defer func() { _ = conn.Close() }() 380 + 381 + for { 382 + select { 383 + case <-done: 384 + return 385 + case <-subscribeCtx.Done(): 386 + return 387 + default: 388 + if deadlineErr := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); deadlineErr != nil { 389 + return 390 + } 391 + 392 + var event jetstream.JetstreamEvent 393 + if readErr := conn.ReadJSON(&event); readErr != nil { 394 + continue 395 + } 396 + 397 + if event.Kind == "commit" && event.Commit != nil && 398 + event.Commit.Collection == "social.coves.community.profile" && 399 + event.Commit.Operation == "update" && 400 + event.Did == communityDID { 401 + eventChan <- &event 402 + } 403 + } 404 + } 405 + }() 406 + 407 + select { 408 + case event := <-eventChan: 409 + close(done) 410 + return event 411 + case <-time.After(timeout): 412 + close(done) 413 + return nil 414 + } 415 + } 416 + 417 + t.Run("add avatar to community without one", func(t *testing.T) { 418 + uniqueName := fmt.Sprintf("upav%d", time.Now().UnixNano()%1000000) 419 + creatorDID := "did:plc:avatar-update-test" 420 + 421 + // Create a community WITHOUT an avatar 422 + t.Logf("\n๐Ÿ“ Creating community without avatar...") 423 + community, createErr := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 424 + Name: uniqueName, 425 + DisplayName: "Community Without Avatar", 426 + Description: "Will add avatar via update", 427 + Visibility: "public", 428 + CreatedByDID: creatorDID, 429 + HostedByDID: instanceDID, 430 + AllowExternalDiscovery: true, 431 + }) 432 + if createErr != nil { 433 + t.Fatalf("Failed to create community: %v", createErr) 434 + } 435 + t.Logf("โœ… Community created: DID=%s", community.DID) 436 + 437 + // Verify no avatar initially 438 + initial, err := communityService.GetCommunity(ctx, community.DID) 439 + if err != nil { 440 + t.Fatalf("Community not indexed: %v", err) 441 + } 442 + if initial.AvatarCID != "" { 443 + t.Fatalf("Expected no initial avatar, got: %s", initial.AvatarCID) 444 + } 445 + t.Logf(" Initial AvatarCID: '' (confirmed empty)") 446 + 447 + // Create test avatar image (100x100 blue square) 448 + avatarData := createTestPNGImage(100, 100, color.RGBA{0, 0, 255, 255}) 449 + t.Logf("\n๐Ÿ“ Updating community with avatar (%d bytes)...", len(avatarData)) 450 + 451 + // Start listening for Jetstream event 452 + eventReceived := make(chan *jetstream.JetstreamEvent, 1) 453 + go func() { 454 + event := waitForUpdateEvent(t, community.DID, 15*time.Second) 455 + eventReceived <- event 456 + }() 457 + time.Sleep(500 * time.Millisecond) // Give subscriber time to connect 458 + 459 + // Perform the update with avatar 460 + newDisplayName := "Community With New Avatar" 461 + updated, updateErr := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 462 + CommunityDID: community.DID, 463 + UpdatedByDID: creatorDID, 464 + DisplayName: &newDisplayName, 465 + AvatarBlob: avatarData, 466 + AvatarMimeType: "image/png", 467 + }) 468 + if updateErr != nil { 469 + t.Fatalf("Failed to update community with avatar: %v", updateErr) 470 + } 471 + 472 + t.Logf("โœ… Community update written to PDS:") 473 + t.Logf(" New RecordCID: %s", updated.RecordCID) 474 + 475 + // Wait for REAL Jetstream event 476 + t.Logf("\nโณ Waiting for update event from Jetstream...") 477 + realEvent := <-eventReceived 478 + if realEvent == nil { 479 + t.Fatalf("Timeout waiting for Jetstream update event") 480 + } 481 + 482 + t.Logf("โœ… Received REAL update event from Jetstream!") 483 + t.Logf(" Operation: %s", realEvent.Commit.Operation) 484 + t.Logf(" CID: %s", realEvent.Commit.CID) 485 + 486 + // Extract avatar CID from real event 487 + var avatarCIDFromEvent string 488 + if realEvent.Commit.Record != nil { 489 + if avatar, hasAvatar := realEvent.Commit.Record["avatar"].(map[string]interface{}); hasAvatar { 490 + t.Logf(" Avatar in event: %v", avatar) 491 + if ref, hasRef := avatar["ref"].(map[string]interface{}); hasRef { 492 + if link, hasLink := ref["$link"].(string); hasLink { 493 + avatarCIDFromEvent = link 494 + t.Logf(" AvatarCID from Jetstream: %s", avatarCIDFromEvent) 495 + } 496 + } 497 + } 498 + } 499 + 500 + // Process the REAL event through consumer 501 + t.Logf("\n๐Ÿ”„ Processing real Jetstream event through consumer...") 502 + if handleErr := consumer.HandleEvent(ctx, realEvent); handleErr != nil { 503 + t.Logf(" Consumer error: %v", handleErr) 504 + } 505 + 506 + // Verify avatar CID is now set in DB 507 + final, err := communityRepo.GetByDID(ctx, community.DID) 508 + if err != nil { 509 + t.Fatalf("Failed to get final community: %v", err) 510 + } 511 + 512 + t.Logf("\nโœ… Community avatar update verified:") 513 + t.Logf(" DisplayName: %s", final.DisplayName) 514 + t.Logf(" AvatarCID in DB: %s", final.AvatarCID) 515 + 516 + if final.AvatarCID == "" { 517 + t.Errorf("Expected AvatarCID to be set after update") 518 + } 519 + 520 + // Verify DB matches Jetstream event 521 + if avatarCIDFromEvent != "" && final.AvatarCID != avatarCIDFromEvent { 522 + t.Errorf("AvatarCID mismatch: DB has %s, Jetstream has %s", final.AvatarCID, avatarCIDFromEvent) 523 + } else if avatarCIDFromEvent != "" { 524 + t.Logf(" โœ… AvatarCID matches between DB and Jetstream!") 525 + } 526 + 527 + t.Logf("\nโœ… TRUE E2E ADD AVATAR FLOW COMPLETE") 528 + }) 529 + 530 + t.Run("replace existing avatar with new one", func(t *testing.T) { 531 + uniqueName := fmt.Sprintf("rpav%d", time.Now().UnixNano()%1000000) 532 + creatorDID := "did:plc:avatar-replace-test" 533 + 534 + // Create a community WITH an initial avatar (red square) 535 + initialAvatarData := createTestPNGImage(100, 100, color.RGBA{255, 0, 0, 255}) 536 + t.Logf("\n๐Ÿ“ Creating community with initial avatar (red, %d bytes)...", len(initialAvatarData)) 537 + 538 + community, createErr := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 539 + Name: uniqueName, 540 + DisplayName: "Community With Initial Avatar", 541 + Description: "Will replace avatar", 542 + Visibility: "public", 543 + CreatedByDID: creatorDID, 544 + HostedByDID: instanceDID, 545 + AllowExternalDiscovery: true, 546 + AvatarBlob: initialAvatarData, 547 + AvatarMimeType: "image/png", 548 + }) 549 + if createErr != nil { 550 + t.Fatalf("Failed to create community with avatar: %v", createErr) 551 + } 552 + t.Logf("โœ… Community created: DID=%s", community.DID) 553 + 554 + // Verify initial avatar is set 555 + initial, err := communityService.GetCommunity(ctx, community.DID) 556 + if err != nil { 557 + t.Fatalf("Community not indexed: %v", err) 558 + } 559 + initialAvatarCID := initial.AvatarCID 560 + if initialAvatarCID == "" { 561 + t.Fatalf("Expected initial avatar to be set") 562 + } 563 + t.Logf(" Initial AvatarCID: %s", initialAvatarCID) 564 + 565 + // Create NEW avatar image (100x100 green square - different from initial red) 566 + newAvatarData := createTestPNGImage(100, 100, color.RGBA{0, 255, 0, 255}) 567 + t.Logf("\n๐Ÿ“ Replacing avatar with new one (green, %d bytes)...", len(newAvatarData)) 568 + 569 + // Start listening for Jetstream event 570 + eventReceived := make(chan *jetstream.JetstreamEvent, 1) 571 + go func() { 572 + event := waitForUpdateEvent(t, community.DID, 15*time.Second) 573 + eventReceived <- event 574 + }() 575 + time.Sleep(500 * time.Millisecond) 576 + 577 + // Perform the update with NEW avatar 578 + newDisplayName := "Community With Replaced Avatar" 579 + updated, updateErr := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 580 + CommunityDID: community.DID, 581 + UpdatedByDID: creatorDID, 582 + DisplayName: &newDisplayName, 583 + AvatarBlob: newAvatarData, 584 + AvatarMimeType: "image/png", 585 + }) 586 + if updateErr != nil { 587 + t.Fatalf("Failed to update community with new avatar: %v", updateErr) 588 + } 589 + 590 + t.Logf("โœ… Community update written to PDS:") 591 + t.Logf(" New RecordCID: %s", updated.RecordCID) 592 + 593 + // Wait for REAL Jetstream event 594 + t.Logf("\nโณ Waiting for update event from Jetstream...") 595 + realEvent := <-eventReceived 596 + if realEvent == nil { 597 + t.Fatalf("Timeout waiting for Jetstream update event") 598 + } 599 + 600 + t.Logf("โœ… Received REAL update event from Jetstream!") 601 + t.Logf(" Operation: %s", realEvent.Commit.Operation) 602 + 603 + // Extract new avatar CID from real event 604 + var newAvatarCIDFromEvent string 605 + if realEvent.Commit.Record != nil { 606 + if avatar, hasAvatar := realEvent.Commit.Record["avatar"].(map[string]interface{}); hasAvatar { 607 + if ref, hasRef := avatar["ref"].(map[string]interface{}); hasRef { 608 + if link, hasLink := ref["$link"].(string); hasLink { 609 + newAvatarCIDFromEvent = link 610 + t.Logf(" New AvatarCID from Jetstream: %s", newAvatarCIDFromEvent) 611 + } 612 + } 613 + } 614 + } 615 + 616 + // Process the REAL event through consumer 617 + t.Logf("\n๐Ÿ”„ Processing real Jetstream event through consumer...") 618 + if handleErr := consumer.HandleEvent(ctx, realEvent); handleErr != nil { 619 + t.Logf(" Consumer error: %v", handleErr) 620 + } 621 + 622 + // Verify avatar CID has CHANGED 623 + final, err := communityRepo.GetByDID(ctx, community.DID) 624 + if err != nil { 625 + t.Fatalf("Failed to get final community: %v", err) 626 + } 627 + 628 + t.Logf("\nโœ… Community avatar replacement verified:") 629 + t.Logf(" DisplayName: %s", final.DisplayName) 630 + t.Logf(" Old AvatarCID: %s", initialAvatarCID) 631 + t.Logf(" New AvatarCID: %s", final.AvatarCID) 632 + 633 + if final.AvatarCID == "" { 634 + t.Errorf("Expected AvatarCID to be set after replacement") 635 + } 636 + 637 + if final.AvatarCID == initialAvatarCID { 638 + t.Errorf("AvatarCID should have changed after replacement! Old: %s, New: %s", initialAvatarCID, final.AvatarCID) 639 + } else { 640 + t.Logf(" โœ… AvatarCID successfully changed!") 641 + } 642 + 643 + // Verify DB matches Jetstream event 644 + if newAvatarCIDFromEvent != "" && final.AvatarCID != newAvatarCIDFromEvent { 645 + t.Errorf("AvatarCID mismatch: DB has %s, Jetstream has %s", final.AvatarCID, newAvatarCIDFromEvent) 646 + } else if newAvatarCIDFromEvent != "" { 647 + t.Logf(" โœ… New AvatarCID matches between DB and Jetstream!") 648 + } 649 + 650 + t.Logf("\nโœ… TRUE E2E REPLACE AVATAR FLOW COMPLETE") 651 + }) 652 + } 653 + 654 + // TestCommunityAvatarE2E_UpdateWithBanner tests updating a community's banner 655 + // Flow: UpdateCommunity(banner) โ†’ PDS uploadBlob + putRecord โ†’ Jetstream โ†’ Consumer โ†’ AppView 656 + func TestCommunityAvatarE2E_UpdateWithBanner(t *testing.T) { 657 + if testing.Short() { 658 + t.Skip("Skipping E2E test in short mode") 659 + } 660 + 661 + // Setup test database 662 + dbURL := os.Getenv("TEST_DATABASE_URL") 663 + if dbURL == "" { 664 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 665 + } 666 + 667 + db, err := sql.Open("postgres", dbURL) 668 + if err != nil { 669 + t.Fatalf("Failed to connect to test database: %v", err) 670 + } 671 + defer func() { _ = db.Close() }() 672 + 673 + // Run migrations 674 + if dialectErr := goose.SetDialect("postgres"); dialectErr != nil { 675 + t.Fatalf("Failed to set goose dialect: %v", dialectErr) 676 + } 677 + if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil { 678 + t.Fatalf("Failed to run migrations: %v", migrateErr) 679 + } 680 + 681 + // Check if PDS is running 682 + pdsURL := os.Getenv("PDS_URL") 683 + if pdsURL == "" { 684 + pdsURL = "http://localhost:3001" 685 + } 686 + 687 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 688 + if err != nil { 689 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 690 + } 691 + _ = healthResp.Body.Close() 692 + 693 + // Check if Jetstream is running - REQUIRED for true E2E 694 + pdsHostname := strings.TrimPrefix(pdsURL, "http://") 695 + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") 696 + pdsHostname = strings.Split(pdsHostname, ":")[0] 697 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.profile", pdsHostname) 698 + 699 + testConn, _, connErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 700 + if connErr != nil { 701 + t.Skipf("Jetstream not available at %s: %v. Run 'make dev-up' to start.", jetstreamURL, connErr) 702 + } 703 + _ = testConn.Close() 704 + t.Logf("โœ… Jetstream available at %s", jetstreamURL) 705 + 706 + ctx := context.Background() 707 + instanceDID := "did:web:coves.social" 708 + 709 + // Setup identity resolver with local PLC 710 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 711 + if plcURL == "" { 712 + plcURL = "http://localhost:3002" 713 + } 714 + identityConfig := identity.DefaultConfig() 715 + identityConfig.PLCURL = plcURL 716 + identityResolver := identity.NewResolver(db, identityConfig) 717 + 718 + // Setup services 719 + communityRepo := postgres.NewCommunityRepository(db) 720 + provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 721 + blobService := blobs.NewBlobService(pdsURL) 722 + 723 + communityService := communities.NewCommunityServiceWithPDSFactory( 724 + communityRepo, 725 + pdsURL, 726 + instanceDID, 727 + "coves.social", 728 + provisioner, 729 + nil, 730 + blobService, 731 + ) 732 + 733 + consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver) 734 + 735 + // Helper to wait for Jetstream update event and process it 736 + waitForUpdateEvent := func(t *testing.T, communityDID string, timeout time.Duration) *jetstream.JetstreamEvent { 737 + eventChan := make(chan *jetstream.JetstreamEvent, 10) 738 + done := make(chan bool) 739 + subscribeCtx, cancelSubscribe := context.WithTimeout(ctx, timeout) 740 + defer cancelSubscribe() 741 + 742 + go func() { 743 + conn, _, dialErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 744 + if dialErr != nil { 745 + t.Logf("Failed to connect to Jetstream: %v", dialErr) 746 + return 747 + } 748 + defer func() { _ = conn.Close() }() 749 + 750 + for { 751 + select { 752 + case <-done: 753 + return 754 + case <-subscribeCtx.Done(): 755 + return 756 + default: 757 + if deadlineErr := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); deadlineErr != nil { 758 + return 759 + } 760 + 761 + var event jetstream.JetstreamEvent 762 + if readErr := conn.ReadJSON(&event); readErr != nil { 763 + continue 764 + } 765 + 766 + if event.Kind == "commit" && event.Commit != nil && 767 + event.Commit.Collection == "social.coves.community.profile" && 768 + event.Commit.Operation == "update" && 769 + event.Did == communityDID { 770 + eventChan <- &event 771 + } 772 + } 773 + } 774 + }() 775 + 776 + select { 777 + case event := <-eventChan: 778 + close(done) 779 + return event 780 + case <-time.After(timeout): 781 + close(done) 782 + return nil 783 + } 784 + } 785 + 786 + t.Run("add banner to community without one", func(t *testing.T) { 787 + uniqueName := fmt.Sprintf("ban%d", time.Now().UnixNano()%1000000) 788 + creatorDID := "did:plc:banner-add-test" 789 + 790 + // Create a community WITHOUT a banner 791 + t.Logf("\n๐Ÿ“ Creating community without banner...") 792 + community, createErr := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 793 + Name: uniqueName, 794 + DisplayName: "Community Without Banner", 795 + Description: "Will add banner via update", 796 + Visibility: "public", 797 + CreatedByDID: creatorDID, 798 + HostedByDID: instanceDID, 799 + AllowExternalDiscovery: true, 800 + }) 801 + if createErr != nil { 802 + t.Fatalf("Failed to create community: %v", createErr) 803 + } 804 + t.Logf("โœ… Community created: DID=%s", community.DID) 805 + 806 + // Verify no banner initially 807 + initial, err := communityService.GetCommunity(ctx, community.DID) 808 + if err != nil { 809 + t.Fatalf("Community not indexed: %v", err) 810 + } 811 + if initial.BannerCID != "" { 812 + t.Fatalf("Expected no initial banner, got: %s", initial.BannerCID) 813 + } 814 + t.Logf(" Initial BannerCID: '' (confirmed empty)") 815 + 816 + // Create test banner image (300x100 green rectangle) 817 + bannerData := createTestPNGImage(300, 100, color.RGBA{0, 255, 0, 255}) 818 + t.Logf("\n๐Ÿ“ Updating community with banner (%d bytes)...", len(bannerData)) 819 + 820 + // Start listening for Jetstream event 821 + eventReceived := make(chan *jetstream.JetstreamEvent, 1) 822 + go func() { 823 + event := waitForUpdateEvent(t, community.DID, 15*time.Second) 824 + eventReceived <- event 825 + }() 826 + time.Sleep(500 * time.Millisecond) // Give subscriber time to connect 827 + 828 + // Perform the update with banner 829 + newDisplayName := "Community With New Banner" 830 + updated, updateErr := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 831 + CommunityDID: community.DID, 832 + UpdatedByDID: creatorDID, 833 + DisplayName: &newDisplayName, 834 + BannerBlob: bannerData, 835 + BannerMimeType: "image/png", 836 + }) 837 + if updateErr != nil { 838 + t.Fatalf("Failed to update community with banner: %v", updateErr) 839 + } 840 + 841 + t.Logf("โœ… Community update written to PDS:") 842 + t.Logf(" New RecordCID: %s", updated.RecordCID) 843 + 844 + // Wait for REAL Jetstream event 845 + t.Logf("\nโณ Waiting for update event from Jetstream...") 846 + realEvent := <-eventReceived 847 + if realEvent == nil { 848 + t.Fatalf("Timeout waiting for Jetstream update event") 849 + } 850 + 851 + t.Logf("โœ… Received REAL update event from Jetstream!") 852 + t.Logf(" Operation: %s", realEvent.Commit.Operation) 853 + t.Logf(" CID: %s", realEvent.Commit.CID) 854 + 855 + // Extract banner CID from real event 856 + var bannerCIDFromEvent string 857 + if realEvent.Commit.Record != nil { 858 + if banner, hasBanner := realEvent.Commit.Record["banner"].(map[string]interface{}); hasBanner { 859 + t.Logf(" Banner in event: %v", banner) 860 + if ref, hasRef := banner["ref"].(map[string]interface{}); hasRef { 861 + if link, hasLink := ref["$link"].(string); hasLink { 862 + bannerCIDFromEvent = link 863 + t.Logf(" BannerCID from Jetstream: %s", bannerCIDFromEvent) 864 + } 865 + } 866 + } 867 + } 868 + 869 + // Process the REAL event through consumer 870 + t.Logf("\n๐Ÿ”„ Processing real Jetstream event through consumer...") 871 + if handleErr := consumer.HandleEvent(ctx, realEvent); handleErr != nil { 872 + t.Logf(" Consumer error: %v", handleErr) 873 + } 874 + 875 + // Verify banner CID is now set in DB 876 + final, err := communityRepo.GetByDID(ctx, community.DID) 877 + if err != nil { 878 + t.Fatalf("Failed to get final community: %v", err) 879 + } 880 + 881 + t.Logf("\nโœ… Community banner update verified:") 882 + t.Logf(" DisplayName: %s", final.DisplayName) 883 + t.Logf(" BannerCID in DB: %s", final.BannerCID) 884 + 885 + if final.BannerCID == "" { 886 + t.Errorf("Expected BannerCID to be set after update") 887 + } 888 + 889 + // Verify DB matches Jetstream event 890 + if bannerCIDFromEvent != "" && final.BannerCID != bannerCIDFromEvent { 891 + t.Errorf("BannerCID mismatch: DB has %s, Jetstream has %s", final.BannerCID, bannerCIDFromEvent) 892 + } else if bannerCIDFromEvent != "" { 893 + t.Logf(" โœ… BannerCID matches between DB and Jetstream!") 894 + } 895 + 896 + t.Logf("\nโœ… TRUE E2E ADD BANNER FLOW COMPLETE") 897 + }) 898 + 899 + t.Run("replace existing banner with new one", func(t *testing.T) { 900 + uniqueName := fmt.Sprintf("rpban%d", time.Now().UnixNano()%1000000) 901 + creatorDID := "did:plc:banner-replace-test" 902 + 903 + // Create a community WITH an initial banner (red rectangle) 904 + initialBannerData := createTestPNGImage(300, 100, color.RGBA{255, 0, 0, 255}) 905 + t.Logf("\n๐Ÿ“ Creating community with initial banner (red, %d bytes)...", len(initialBannerData)) 906 + 907 + community, createErr := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 908 + Name: uniqueName, 909 + DisplayName: "Community With Initial Banner", 910 + Description: "Will replace banner", 911 + Visibility: "public", 912 + CreatedByDID: creatorDID, 913 + HostedByDID: instanceDID, 914 + AllowExternalDiscovery: true, 915 + BannerBlob: initialBannerData, 916 + BannerMimeType: "image/png", 917 + }) 918 + if createErr != nil { 919 + t.Fatalf("Failed to create community with banner: %v", createErr) 920 + } 921 + t.Logf("โœ… Community created: DID=%s", community.DID) 922 + 923 + // Verify initial banner is set 924 + initial, err := communityService.GetCommunity(ctx, community.DID) 925 + if err != nil { 926 + t.Fatalf("Community not indexed: %v", err) 927 + } 928 + initialBannerCID := initial.BannerCID 929 + if initialBannerCID == "" { 930 + t.Fatalf("Expected initial banner to be set") 931 + } 932 + t.Logf(" Initial BannerCID: %s", initialBannerCID) 933 + 934 + // Create NEW banner image (300x100 blue rectangle - different from initial red) 935 + newBannerData := createTestPNGImage(300, 100, color.RGBA{0, 0, 255, 255}) 936 + t.Logf("\n๐Ÿ“ Replacing banner with new one (blue, %d bytes)...", len(newBannerData)) 937 + 938 + // Start listening for Jetstream event 939 + eventReceived := make(chan *jetstream.JetstreamEvent, 1) 940 + go func() { 941 + event := waitForUpdateEvent(t, community.DID, 15*time.Second) 942 + eventReceived <- event 943 + }() 944 + time.Sleep(500 * time.Millisecond) 945 + 946 + // Perform the update with NEW banner 947 + newDisplayName := "Community With Replaced Banner" 948 + updated, updateErr := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 949 + CommunityDID: community.DID, 950 + UpdatedByDID: creatorDID, 951 + DisplayName: &newDisplayName, 952 + BannerBlob: newBannerData, 953 + BannerMimeType: "image/png", 954 + }) 955 + if updateErr != nil { 956 + t.Fatalf("Failed to update community with new banner: %v", updateErr) 957 + } 958 + 959 + t.Logf("โœ… Community update written to PDS:") 960 + t.Logf(" New RecordCID: %s", updated.RecordCID) 961 + 962 + // Wait for REAL Jetstream event 963 + t.Logf("\nโณ Waiting for update event from Jetstream...") 964 + realEvent := <-eventReceived 965 + if realEvent == nil { 966 + t.Fatalf("Timeout waiting for Jetstream update event") 967 + } 968 + 969 + t.Logf("โœ… Received REAL update event from Jetstream!") 970 + t.Logf(" Operation: %s", realEvent.Commit.Operation) 971 + 972 + // Extract new banner CID from real event 973 + var newBannerCIDFromEvent string 974 + if realEvent.Commit.Record != nil { 975 + if banner, hasBanner := realEvent.Commit.Record["banner"].(map[string]interface{}); hasBanner { 976 + if ref, hasRef := banner["ref"].(map[string]interface{}); hasRef { 977 + if link, hasLink := ref["$link"].(string); hasLink { 978 + newBannerCIDFromEvent = link 979 + t.Logf(" New BannerCID from Jetstream: %s", newBannerCIDFromEvent) 980 + } 981 + } 982 + } 983 + } 984 + 985 + // Process the REAL event through consumer 986 + t.Logf("\n๐Ÿ”„ Processing real Jetstream event through consumer...") 987 + if handleErr := consumer.HandleEvent(ctx, realEvent); handleErr != nil { 988 + t.Logf(" Consumer error: %v", handleErr) 989 + } 990 + 991 + // Verify banner CID has CHANGED 992 + final, err := communityRepo.GetByDID(ctx, community.DID) 993 + if err != nil { 994 + t.Fatalf("Failed to get final community: %v", err) 995 + } 996 + 997 + t.Logf("\nโœ… Community banner replacement verified:") 998 + t.Logf(" DisplayName: %s", final.DisplayName) 999 + t.Logf(" Old BannerCID: %s", initialBannerCID) 1000 + t.Logf(" New BannerCID: %s", final.BannerCID) 1001 + 1002 + if final.BannerCID == "" { 1003 + t.Errorf("Expected BannerCID to be set after replacement") 1004 + } 1005 + 1006 + if final.BannerCID == initialBannerCID { 1007 + t.Errorf("BannerCID should have changed after replacement! Old: %s, New: %s", initialBannerCID, final.BannerCID) 1008 + } else { 1009 + t.Logf(" โœ… BannerCID successfully changed!") 1010 + } 1011 + 1012 + // Verify DB matches Jetstream event 1013 + if newBannerCIDFromEvent != "" && final.BannerCID != newBannerCIDFromEvent { 1014 + t.Errorf("BannerCID mismatch: DB has %s, Jetstream has %s", final.BannerCID, newBannerCIDFromEvent) 1015 + } else if newBannerCIDFromEvent != "" { 1016 + t.Logf(" โœ… New BannerCID matches between DB and Jetstream!") 1017 + } 1018 + 1019 + t.Logf("\nโœ… TRUE E2E REPLACE BANNER FLOW COMPLETE") 1020 + }) 1021 + }
+1 -1
tests/integration/user_journey_e2e_test.go
··· 130 130 } 131 131 132 132 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 133 - communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, instanceDID, instanceDomain, provisioner, CommunityPasswordAuthPDSClientFactory()) 133 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, instanceDID, instanceDomain, provisioner, CommunityPasswordAuthPDSClientFactory(), nil) 134 134 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) 135 135 timelineService := timelineCore.NewTimelineService(timelineRepo) 136 136
+55
tests/integration/comment_consumer_test.go
··· 1068 1068 postURI := createTestPost(t, db, testCommunity, testUser.DID, "OOO Test Post", 0, time.Now()) 1069 1069 1070 1070 t.Run("Child arrives before parent - count reconciled", func(t *testing.T) { 1071 + // Clean up comments to ensure isolation from other tests 1072 + _, cleanErr := db.ExecContext(ctx, "DELETE FROM comments") 1073 + if cleanErr != nil { 1074 + t.Fatalf("Failed to clean up comments: %v", cleanErr) 1075 + } 1076 + 1071 1077 // Scenario: User A creates comment C1 on post 1072 1078 // User B creates reply C2 to C1 1073 1079 // Jetstream delivers C2 before C1 (different repos) ··· 1162 1168 1163 1169 // THIS IS THE KEY TEST: Parent should have reply_count = 1 due to reconciliation 1164 1170 if parentComment.ReplyCount != 1 { 1171 + // Debug: Log the actual database state 1172 + var count int 1173 + if countErr := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM comments WHERE parent_uri = $1 AND deleted_at IS NULL", parentURI).Scan(&count); countErr != nil { 1174 + t.Logf("Debug: Failed to count children: %v", countErr) 1175 + } else { 1176 + t.Logf("Debug: Actual COUNT of children with parent_uri=%s: %d", parentURI, count) 1177 + } 1178 + 1179 + // Log all comments in the database 1180 + rows, _ := db.QueryContext(ctx, "SELECT uri, parent_uri FROM comments ORDER BY created_at") 1181 + if rows != nil { 1182 + defer rows.Close() 1183 + t.Logf("Debug: All comments in database:") 1184 + for rows.Next() { 1185 + var uri, puri string 1186 + rows.Scan(&uri, &puri) 1187 + t.Logf(" uri=%s parent_uri=%s", uri, puri) 1188 + } 1189 + } 1190 + 1165 1191 t.Errorf("Expected parent reply_count to be 1 (reconciled), got %d", parentComment.ReplyCount) 1166 1192 t.Logf("This indicates out-of-order reconciliation failed!") 1167 1193 } ··· 1177 1203 }) 1178 1204 1179 1205 t.Run("Multiple children arrive before parent", func(t *testing.T) { 1206 + // Clean up comments from previous subtest to ensure isolation 1207 + _, cleanErr := db.ExecContext(ctx, "DELETE FROM comments") 1208 + if cleanErr != nil { 1209 + t.Fatalf("Failed to clean up comments: %v", cleanErr) 1210 + } 1211 + 1180 1212 parentRkey := generateTID() 1181 1213 parentURI := fmt.Sprintf("at://%s/social.coves.community.comment/%s", testUser.DID, parentRkey) 1182 1214 1215 + t.Logf("Debug: postURI = %s", postURI) 1216 + t.Logf("Debug: parentURI = %s", parentURI) 1217 + 1183 1218 // Index 3 children before parent 1184 1219 for i := 1; i <= 3; i++ { 1185 1220 childRkey := generateTID() ··· 1256 1291 } 1257 1292 1258 1293 if parentComment.ReplyCount != 3 { 1294 + // Debug: Log the actual database state 1295 + var count int 1296 + if countErr := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM comments WHERE parent_uri = $1 AND deleted_at IS NULL", parentURI).Scan(&count); countErr != nil { 1297 + t.Logf("Debug: Failed to count children: %v", countErr) 1298 + } else { 1299 + t.Logf("Debug: Actual COUNT of children with parent_uri=%s: %d", parentURI, count) 1300 + } 1301 + 1302 + // Log all comments in the database 1303 + rows, _ := db.QueryContext(ctx, "SELECT uri, parent_uri FROM comments ORDER BY created_at") 1304 + if rows != nil { 1305 + defer rows.Close() 1306 + t.Logf("Debug: All comments in database:") 1307 + for rows.Next() { 1308 + var uri, puri string 1309 + rows.Scan(&uri, &puri) 1310 + t.Logf(" uri=%s parent_uri=%s", uri, puri) 1311 + } 1312 + } 1313 + 1259 1314 t.Errorf("Expected parent reply_count to be 3 (reconciled), got %d", parentComment.ReplyCount) 1260 1315 } 1261 1316 })
+18 -12
tests/integration/comment_e2e_test.go
··· 80 80 81 81 // Create test user on PDS 82 82 // Use shorter handle to avoid PDS length limits (max 20 chars for label) 83 - testUserHandle := fmt.Sprintf("cmt%d.local.coves.dev", time.Now().UnixNano()%1000000) 84 - testUserEmail := fmt.Sprintf("cmt%d@test.local", time.Now().UnixNano()%1000000) 83 + testID := uniqueTestID() 84 + testUserHandle := fmt.Sprintf("cmt%s.local.coves.dev", testID) 85 + testUserEmail := fmt.Sprintf("cmt%s@test.local", testID) 85 86 testUserPassword := "test-password-123" 86 87 87 88 t.Logf("Creating test user on PDS: %s", testUserHandle) ··· 294 295 postRepo := postgres.NewPostRepository(db) 295 296 296 297 // Create test user on PDS 297 - testUserHandle := fmt.Sprintf("cmtup%d.local.coves.dev", time.Now().UnixNano()%1000000) 298 - testUserEmail := fmt.Sprintf("cmtup%d@test.local", time.Now().UnixNano()%1000000) 298 + testID := uniqueTestID() 299 + testUserHandle := fmt.Sprintf("cmtup%s.local.coves.dev", testID) 300 + testUserEmail := fmt.Sprintf("cmtup%s@test.local", testID) 299 301 testUserPassword := "test-password-123" 300 302 301 303 pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword) ··· 522 524 commentRepo := postgres.NewCommentRepository(db) 523 525 postRepo := postgres.NewPostRepository(db) 524 526 525 - testUserHandle := fmt.Sprintf("cmtdl%d.local.coves.dev", time.Now().UnixNano()%1000000) 526 - testUserEmail := fmt.Sprintf("cmtdl%d@test.local", time.Now().UnixNano()%1000000) 527 + testID := uniqueTestID() 528 + testUserHandle := fmt.Sprintf("cmtdl%s.local.coves.dev", testID) 529 + testUserEmail := fmt.Sprintf("cmtdl%s@test.local", testID) 527 530 testUserPassword := "test-password-123" 528 531 529 532 pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword) ··· 888 891 postRepo := postgres.NewPostRepository(db) 889 892 890 893 // Create two test users on PDS 891 - userAHandle := fmt.Sprintf("usera%d.local.coves.dev", time.Now().UnixNano()%1000000) 892 - userAEmail := fmt.Sprintf("usera%d@test.local", time.Now().UnixNano()%1000000) 894 + userAID := uniqueTestID() 895 + userAHandle := fmt.Sprintf("usera%s.local.coves.dev", userAID) 896 + userAEmail := fmt.Sprintf("usera%s@test.local", userAID) 893 897 userAPassword := "test-password-123" 894 898 895 - userBHandle := fmt.Sprintf("userb%d.local.coves.dev", time.Now().UnixNano()%1000000) 896 - userBEmail := fmt.Sprintf("userb%d@test.local", time.Now().UnixNano()%1000000) 899 + userBID := uniqueTestID() 900 + userBHandle := fmt.Sprintf("userb%s.local.coves.dev", userBID) 901 + userBEmail := fmt.Sprintf("userb%s@test.local", userBID) 897 902 userBPassword := "test-password-123" 898 903 899 904 pdsAccessTokenA, userADID, err := createPDSAccount(pdsURL, userAHandle, userAEmail, userAPassword) ··· 1078 1083 postRepo := postgres.NewPostRepository(db) 1079 1084 1080 1085 // Create test user on PDS 1081 - testUserHandle := fmt.Sprintf("valtest%d.local.coves.dev", time.Now().UnixNano()%1000000) 1082 - testUserEmail := fmt.Sprintf("valtest%d@test.local", time.Now().UnixNano()%1000000) 1086 + testID := uniqueTestID() 1087 + testUserHandle := fmt.Sprintf("valtest%s.local.coves.dev", testID) 1088 + testUserEmail := fmt.Sprintf("valtest%s@test.local", testID) 1083 1089 testUserPassword := "test-password-123" 1084 1090 1085 1091 pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+58 -10
internal/core/votes/service_impl.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 6 7 "log/slog" 7 8 "strings" ··· 121 122 // handles orphaned votes correctly by only updating counts for non-deleted subjects. 122 123 // This avoids race conditions and eventual consistency issues. 123 124 124 - // Check for existing vote by querying PDS directly (source of truth) 125 - // This avoids eventual consistency issues with the AppView database 126 - existing, err := s.findExistingVote(ctx, pdsClient, req.Subject.URI) 125 + // Check for existing vote using cache with PDS fallback 126 + // First check populates cache from PDS, subsequent checks are O(1) lookups 127 + existing, err := s.findExistingVoteWithCache(ctx, pdsClient, session.AccountDID.String(), req.Subject.URI) 127 128 if err != nil { 128 129 s.logger.Error("failed to check existing vote on PDS", 129 130 "error", err, ··· 239 240 return fmt.Errorf("failed to create PDS client: %w", err) 240 241 } 241 242 242 - // Find existing vote by querying PDS directly (source of truth) 243 - // This avoids eventual consistency issues with the AppView database 244 - existing, err := s.findExistingVote(ctx, pdsClient, req.Subject.URI) 243 + // Find existing vote using cache with PDS fallback 244 + // First check populates cache from PDS, subsequent checks are O(1) lookups 245 + existing, err := s.findExistingVoteWithCache(ctx, pdsClient, session.AccountDID.String(), req.Subject.URI) 245 246 if err != nil { 246 247 s.logger.Error("failed to find vote on PDS", 247 248 "error", err, ··· 310 311 Direction string 311 312 } 312 313 313 - // findExistingVote queries the user's PDS directly to find an existing vote for a subject. 314 - // This avoids eventual consistency issues with the AppView database populated by Jetstream. 315 - // Paginates through all vote records to handle users with >100 votes. 314 + // findExistingVoteWithCache uses the vote cache for O(1) lookups when available. 315 + // Falls back to direct PDS pagination if cache is unavailable or cannot be populated. 316 + func (s *voteService) findExistingVoteWithCache(ctx context.Context, pdsClient pds.Client, userDID string, subjectURI string) (*existingVote, error) { 317 + if s.cache != nil { 318 + if !s.cache.IsCached(userDID) { 319 + // Populate cache first (fetches all votes via pagination, then cached for subsequent O(1) lookups) 320 + if err := s.cache.FetchAndCacheFromPDS(ctx, pdsClient); err != nil { 321 + // Auth errors won't succeed on fallback either - propagate immediately 322 + if errors.Is(err, ErrNotAuthorized) { 323 + return nil, err 324 + } 325 + // Log warning for other errors and fall back to direct PDS query 326 + s.logger.Warn("failed to populate vote cache, falling back to PDS pagination", 327 + "error", err, 328 + "user", userDID, 329 + "subject", subjectURI) 330 + } 331 + } 332 + 333 + if s.cache.IsCached(userDID) { 334 + cached := s.cache.GetVote(userDID, subjectURI) 335 + if cached == nil { 336 + s.logger.Debug("vote existence check via cache: not found", 337 + "user", userDID, 338 + "subject", subjectURI) 339 + return nil, nil // No vote exists 340 + } 341 + s.logger.Debug("vote existence check via cache: found", 342 + "user", userDID, 343 + "subject", subjectURI, 344 + "direction", cached.Direction) 345 + return &existingVote{ 346 + URI: cached.URI, 347 + RKey: cached.RKey, 348 + Direction: cached.Direction, 349 + // CID not cached - not needed for toggle/delete operations 350 + }, nil 351 + } 352 + } 353 + 354 + // Fallback: query PDS directly via pagination 355 + s.logger.Debug("vote existence check via PDS pagination (cache unavailable)", 356 + "user", userDID, 357 + "subject", subjectURI) 358 + return s.findExistingVoteFromPDS(ctx, pdsClient, subjectURI) 359 + } 360 + 361 + // findExistingVoteFromPDS queries the user's PDS directly to find an existing vote for a subject. 362 + // This is the slow fallback path that paginates through all vote records. 363 + // Prefer findExistingVoteWithCache for production use. 316 364 // Returns the vote record with rkey, or nil if no vote exists for the subject. 317 - func (s *voteService) findExistingVote(ctx context.Context, pdsClient pds.Client, subjectURI string) (*existingVote, error) { 365 + func (s *voteService) findExistingVoteFromPDS(ctx context.Context, pdsClient pds.Client, subjectURI string) (*existingVote, error) { 318 366 cursor := "" 319 367 const pageSize = 100 320 368
+474
internal/api/handlers/community/list_test.go
··· 1 + package community 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/communities" 6 + "context" 7 + "encoding/json" 8 + "net/http" 9 + "net/http/httptest" 10 + "testing" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + ) 16 + 17 + // listTestService implements communities.Service for list handler tests 18 + type listTestService struct { 19 + listFunc func(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) 20 + } 21 + 22 + func (m *listTestService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) { 23 + return nil, nil 24 + } 25 + 26 + func (m *listTestService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) { 27 + return nil, nil 28 + } 29 + 30 + func (m *listTestService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) { 31 + return nil, nil 32 + } 33 + 34 + func (m *listTestService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 35 + if m.listFunc != nil { 36 + return m.listFunc(ctx, req) 37 + } 38 + return []*communities.Community{}, nil 39 + } 40 + 41 + func (m *listTestService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) { 42 + return nil, 0, nil 43 + } 44 + 45 + func (m *listTestService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 46 + return nil, nil 47 + } 48 + 49 + func (m *listTestService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 50 + return nil 51 + } 52 + 53 + func (m *listTestService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) { 54 + return nil, nil 55 + } 56 + 57 + func (m *listTestService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) { 58 + return nil, nil 59 + } 60 + 61 + func (m *listTestService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 62 + return nil, nil 63 + } 64 + 65 + func (m *listTestService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 66 + return nil 67 + } 68 + 69 + func (m *listTestService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) { 70 + return nil, nil 71 + } 72 + 73 + func (m *listTestService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) { 74 + return false, nil 75 + } 76 + 77 + func (m *listTestService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) { 78 + return nil, nil 79 + } 80 + 81 + func (m *listTestService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) { 82 + return nil, nil 83 + } 84 + 85 + func (m *listTestService) ValidateHandle(handle string) error { 86 + return nil 87 + } 88 + 89 + func (m *listTestService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) { 90 + return identifier, nil 91 + } 92 + 93 + func (m *listTestService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) { 94 + return community, nil 95 + } 96 + 97 + func (m *listTestService) GetByDID(ctx context.Context, did string) (*communities.Community, error) { 98 + return nil, nil 99 + } 100 + 101 + // listTestRepo implements communities.Repository for list handler tests 102 + type listTestRepo struct{} 103 + 104 + func (r *listTestRepo) Create(ctx context.Context, community *communities.Community) (*communities.Community, error) { 105 + return nil, nil 106 + } 107 + func (r *listTestRepo) GetByDID(ctx context.Context, did string) (*communities.Community, error) { 108 + return nil, nil 109 + } 110 + func (r *listTestRepo) GetByHandle(ctx context.Context, handle string) (*communities.Community, error) { 111 + return nil, nil 112 + } 113 + func (r *listTestRepo) Update(ctx context.Context, community *communities.Community) (*communities.Community, error) { 114 + return nil, nil 115 + } 116 + func (r *listTestRepo) Delete(ctx context.Context, did string) error { return nil } 117 + func (r *listTestRepo) UpdateCredentials(ctx context.Context, did, accessToken, refreshToken string) error { 118 + return nil 119 + } 120 + func (r *listTestRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 121 + return nil, nil 122 + } 123 + func (r *listTestRepo) Search(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) { 124 + return nil, 0, nil 125 + } 126 + func (r *listTestRepo) Subscribe(ctx context.Context, subscription *communities.Subscription) (*communities.Subscription, error) { 127 + return nil, nil 128 + } 129 + func (r *listTestRepo) SubscribeWithCount(ctx context.Context, subscription *communities.Subscription) (*communities.Subscription, error) { 130 + return nil, nil 131 + } 132 + func (r *listTestRepo) Unsubscribe(ctx context.Context, userDID, communityDID string) error { return nil } 133 + func (r *listTestRepo) UnsubscribeWithCount(ctx context.Context, userDID, communityDID string) error { 134 + return nil 135 + } 136 + func (r *listTestRepo) GetSubscription(ctx context.Context, userDID, communityDID string) (*communities.Subscription, error) { 137 + return nil, nil 138 + } 139 + func (r *listTestRepo) GetSubscriptionByURI(ctx context.Context, recordURI string) (*communities.Subscription, error) { 140 + return nil, nil 141 + } 142 + func (r *listTestRepo) ListSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) { 143 + return nil, nil 144 + } 145 + func (r *listTestRepo) ListSubscribers(ctx context.Context, communityDID string, limit, offset int) ([]*communities.Subscription, error) { 146 + return nil, nil 147 + } 148 + func (r *listTestRepo) GetSubscribedCommunityDIDs(ctx context.Context, userDID string, communityDIDs []string) (map[string]bool, error) { 149 + return nil, nil 150 + } 151 + func (r *listTestRepo) BlockCommunity(ctx context.Context, block *communities.CommunityBlock) (*communities.CommunityBlock, error) { 152 + return nil, nil 153 + } 154 + func (r *listTestRepo) UnblockCommunity(ctx context.Context, userDID, communityDID string) error { 155 + return nil 156 + } 157 + func (r *listTestRepo) GetBlock(ctx context.Context, userDID, communityDID string) (*communities.CommunityBlock, error) { 158 + return nil, nil 159 + } 160 + func (r *listTestRepo) GetBlockByURI(ctx context.Context, recordURI string) (*communities.CommunityBlock, error) { 161 + return nil, nil 162 + } 163 + func (r *listTestRepo) ListBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) { 164 + return nil, nil 165 + } 166 + func (r *listTestRepo) IsBlocked(ctx context.Context, userDID, communityDID string) (bool, error) { 167 + return false, nil 168 + } 169 + func (r *listTestRepo) CreateMembership(ctx context.Context, membership *communities.Membership) (*communities.Membership, error) { 170 + return nil, nil 171 + } 172 + func (r *listTestRepo) GetMembership(ctx context.Context, userDID, communityDID string) (*communities.Membership, error) { 173 + return nil, nil 174 + } 175 + func (r *listTestRepo) UpdateMembership(ctx context.Context, membership *communities.Membership) (*communities.Membership, error) { 176 + return nil, nil 177 + } 178 + func (r *listTestRepo) ListMembers(ctx context.Context, communityDID string, limit, offset int) ([]*communities.Membership, error) { 179 + return nil, nil 180 + } 181 + func (r *listTestRepo) CreateModerationAction(ctx context.Context, action *communities.ModerationAction) (*communities.ModerationAction, error) { 182 + return nil, nil 183 + } 184 + func (r *listTestRepo) ListModerationActions(ctx context.Context, communityDID string, limit, offset int) ([]*communities.ModerationAction, error) { 185 + return nil, nil 186 + } 187 + func (r *listTestRepo) IncrementMemberCount(ctx context.Context, communityDID string) error { 188 + return nil 189 + } 190 + func (r *listTestRepo) DecrementMemberCount(ctx context.Context, communityDID string) error { 191 + return nil 192 + } 193 + func (r *listTestRepo) IncrementSubscriberCount(ctx context.Context, communityDID string) error { 194 + return nil 195 + } 196 + func (r *listTestRepo) DecrementSubscriberCount(ctx context.Context, communityDID string) error { 197 + return nil 198 + } 199 + func (r *listTestRepo) IncrementPostCount(ctx context.Context, communityDID string) error { 200 + return nil 201 + } 202 + 203 + // createListTestOAuthSession creates a mock OAuth session for testing 204 + func createListTestOAuthSession(did string) *oauth.ClientSessionData { 205 + parsedDID, _ := syntax.ParseDID(did) 206 + return &oauth.ClientSessionData{ 207 + AccountDID: parsedDID, 208 + SessionID: "test-session", 209 + HostURL: "http://localhost:3001", 210 + AccessToken: "test-access-token", 211 + } 212 + } 213 + 214 + func TestListHandler_SubscribedWithoutAuth_Returns401(t *testing.T) { 215 + mockService := &listTestService{} 216 + mockRepo := &listTestRepo{} 217 + handler := NewListHandler(mockService, mockRepo) 218 + 219 + // Request subscribed filter without authentication 220 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?subscribed=true", nil) 221 + 222 + w := httptest.NewRecorder() 223 + handler.HandleList(w, req) 224 + 225 + if w.Code != http.StatusUnauthorized { 226 + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) 227 + } 228 + 229 + // Verify JSON error response format 230 + var errResp struct { 231 + Error string `json:"error"` 232 + Message string `json:"message"` 233 + } 234 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 235 + t.Fatalf("Failed to decode error response: %v", err) 236 + } 237 + if errResp.Error != "AuthRequired" { 238 + t.Errorf("Expected error AuthRequired, got %s", errResp.Error) 239 + } 240 + if errResp.Message == "" { 241 + t.Error("Expected non-empty error message") 242 + } 243 + } 244 + 245 + func TestListHandler_SubscribedWithAuth_FiltersCorrectly(t *testing.T) { 246 + userDID := "did:plc:testuser123" 247 + 248 + // Create communities - some subscribed, some not 249 + allCommunities := []*communities.Community{ 250 + { 251 + DID: "did:plc:community1", 252 + Handle: "c-subscribed1.coves.social", 253 + Name: "subscribed1", 254 + CreatedAt: time.Now(), 255 + }, 256 + { 257 + DID: "did:plc:community2", 258 + Handle: "c-subscribed2.coves.social", 259 + Name: "subscribed2", 260 + CreatedAt: time.Now(), 261 + }, 262 + } 263 + 264 + var receivedRequest communities.ListCommunitiesRequest 265 + mockService := &listTestService{ 266 + listFunc: func(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 267 + receivedRequest = req 268 + // Service should receive the SubscriberDID and filter accordingly 269 + if req.SubscriberDID != "" { 270 + // Return only subscribed communities 271 + return allCommunities, nil 272 + } 273 + // Return all communities if no filter 274 + return allCommunities, nil 275 + }, 276 + } 277 + mockRepo := &listTestRepo{} 278 + handler := NewListHandler(mockService, mockRepo) 279 + 280 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?subscribed=true", nil) 281 + 282 + // Add authentication using the test helper 283 + ctx := middleware.SetTestUserDID(req.Context(), userDID) 284 + req = req.WithContext(ctx) 285 + 286 + w := httptest.NewRecorder() 287 + handler.HandleList(w, req) 288 + 289 + if w.Code != http.StatusOK { 290 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 291 + } 292 + 293 + // Verify that the service received the SubscriberDID 294 + if receivedRequest.SubscriberDID != userDID { 295 + t.Errorf("Expected SubscriberDID %q to be passed to service, got %q", userDID, receivedRequest.SubscriberDID) 296 + } 297 + 298 + // Verify response contains communities 299 + var resp struct { 300 + Communities []map[string]interface{} `json:"communities"` 301 + Cursor string `json:"cursor"` 302 + } 303 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 304 + t.Fatalf("Failed to decode response: %v", err) 305 + } 306 + if len(resp.Communities) != 2 { 307 + t.Errorf("Expected 2 communities in response, got %d", len(resp.Communities)) 308 + } 309 + } 310 + 311 + func TestListHandler_SubscribedFalse_NoFilter(t *testing.T) { 312 + var receivedRequest communities.ListCommunitiesRequest 313 + mockService := &listTestService{ 314 + listFunc: func(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 315 + receivedRequest = req 316 + return []*communities.Community{}, nil 317 + }, 318 + } 319 + mockRepo := &listTestRepo{} 320 + handler := NewListHandler(mockService, mockRepo) 321 + 322 + // Request with subscribed=false should not require auth and should not filter 323 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?subscribed=false", nil) 324 + 325 + w := httptest.NewRecorder() 326 + handler.HandleList(w, req) 327 + 328 + if w.Code != http.StatusOK { 329 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 330 + } 331 + 332 + // Verify that SubscriberDID is empty (no filter) 333 + if receivedRequest.SubscriberDID != "" { 334 + t.Errorf("Expected empty SubscriberDID, got %q", receivedRequest.SubscriberDID) 335 + } 336 + } 337 + 338 + func TestListHandler_InvalidLimit_Returns400(t *testing.T) { 339 + mockService := &listTestService{} 340 + mockRepo := &listTestRepo{} 341 + handler := NewListHandler(mockService, mockRepo) 342 + 343 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?limit=abc", nil) 344 + 345 + w := httptest.NewRecorder() 346 + handler.HandleList(w, req) 347 + 348 + if w.Code != http.StatusBadRequest { 349 + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 350 + } 351 + 352 + // Verify JSON error response format 353 + var errResp struct { 354 + Error string `json:"error"` 355 + Message string `json:"message"` 356 + } 357 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 358 + t.Fatalf("Failed to decode error response: %v", err) 359 + } 360 + if errResp.Error != "InvalidRequest" { 361 + t.Errorf("Expected error InvalidRequest, got %s", errResp.Error) 362 + } 363 + } 364 + 365 + func TestListHandler_InvalidCursor_Returns400(t *testing.T) { 366 + mockService := &listTestService{} 367 + mockRepo := &listTestRepo{} 368 + handler := NewListHandler(mockService, mockRepo) 369 + 370 + tests := []struct { 371 + name string 372 + cursor string 373 + }{ 374 + { 375 + name: "non-numeric cursor", 376 + cursor: "abc", 377 + }, 378 + { 379 + name: "negative cursor", 380 + cursor: "-5", 381 + }, 382 + } 383 + 384 + for _, tc := range tests { 385 + t.Run(tc.name, func(t *testing.T) { 386 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?cursor="+tc.cursor, nil) 387 + 388 + w := httptest.NewRecorder() 389 + handler.HandleList(w, req) 390 + 391 + if w.Code != http.StatusBadRequest { 392 + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 393 + } 394 + 395 + // Verify JSON error response format 396 + var errResp struct { 397 + Error string `json:"error"` 398 + Message string `json:"message"` 399 + } 400 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 401 + t.Fatalf("Failed to decode error response: %v", err) 402 + } 403 + if errResp.Error != "InvalidRequest" { 404 + t.Errorf("Expected error InvalidRequest, got %s", errResp.Error) 405 + } 406 + }) 407 + } 408 + } 409 + 410 + func TestListHandler_ValidLimitBoundaries(t *testing.T) { 411 + tests := []struct { 412 + name string 413 + limitParam string 414 + expectedLimit int 415 + }{ 416 + { 417 + name: "limit below minimum clamped to 1", 418 + limitParam: "0", 419 + expectedLimit: 1, 420 + }, 421 + { 422 + name: "limit above maximum clamped to 100", 423 + limitParam: "150", 424 + expectedLimit: 100, 425 + }, 426 + { 427 + name: "valid limit in range", 428 + limitParam: "25", 429 + expectedLimit: 25, 430 + }, 431 + } 432 + 433 + for _, tc := range tests { 434 + t.Run(tc.name, func(t *testing.T) { 435 + var receivedRequest communities.ListCommunitiesRequest 436 + mockService := &listTestService{ 437 + listFunc: func(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 438 + receivedRequest = req 439 + return []*communities.Community{}, nil 440 + }, 441 + } 442 + mockRepo := &listTestRepo{} 443 + handler := NewListHandler(mockService, mockRepo) 444 + 445 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?limit="+tc.limitParam, nil) 446 + 447 + w := httptest.NewRecorder() 448 + handler.HandleList(w, req) 449 + 450 + if w.Code != http.StatusOK { 451 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 452 + } 453 + 454 + if receivedRequest.Limit != tc.expectedLimit { 455 + t.Errorf("Expected limit %d, got %d", tc.expectedLimit, receivedRequest.Limit) 456 + } 457 + }) 458 + } 459 + } 460 + 461 + func TestListHandler_MethodNotAllowed(t *testing.T) { 462 + mockService := &listTestService{} 463 + mockRepo := &listTestRepo{} 464 + handler := NewListHandler(mockService, mockRepo) 465 + 466 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.list", nil) 467 + 468 + w := httptest.NewRecorder() 469 + handler.HandleList(w, req) 470 + 471 + if w.Code != http.StatusMethodNotAllowed { 472 + t.Errorf("Expected status 405, got %d", w.Code) 473 + } 474 + }
+4
internal/atproto/lexicon/social/coves/community/list.json
··· 38 38 "type": "string", 39 39 "format": "language", 40 40 "description": "Filter by language" 41 + }, 42 + "subscribed": { 43 + "type": "boolean", 44 + "description": "If true, only return communities the viewer is subscribed to. Requires authentication." 41 45 } 42 46 } 43 47 },
+177 -14
internal/db/postgres/community_repo.go
··· 350 350 args := []interface{}{} 351 351 argCount := 1 352 352 353 + // Build JOIN clause for subscribed filter 354 + joinClause := "" 355 + if req.SubscriberDID != "" { 356 + joinClause = fmt.Sprintf("INNER JOIN community_subscriptions cs ON c.did = cs.community_did AND cs.user_did = $%d", argCount) 357 + args = append(args, req.SubscriberDID) 358 + argCount++ 359 + } 360 + 353 361 if req.Visibility != "" { 354 - whereClauses = append(whereClauses, fmt.Sprintf("visibility = $%d", argCount)) 362 + whereClauses = append(whereClauses, fmt.Sprintf("c.visibility = $%d", argCount)) 355 363 args = append(args, req.Visibility) 356 364 argCount++ 357 365 } ··· 373 381 switch req.Sort { 374 382 case "popular": 375 383 // Most subscribers (default) 376 - sortColumn = "subscriber_count" 384 + sortColumn = "c.subscriber_count" 377 385 sortOrder = "DESC" 378 386 case "active": 379 387 // Most posts/activity 380 - sortColumn = "post_count" 388 + sortColumn = "c.post_count" 381 389 sortOrder = "DESC" 382 390 case "new": 383 391 // Recently created 384 - sortColumn = "created_at" 392 + sortColumn = "c.created_at" 385 393 sortOrder = "DESC" 386 394 case "alphabetical": 387 395 // Sorted by name A-Z 388 - sortColumn = "name" 396 + sortColumn = "c.name" 389 397 sortOrder = "ASC" 390 398 default: 391 399 // Fallback to popular if empty or invalid (should be validated in handler) 392 - sortColumn = "subscriber_count" 400 + sortColumn = "c.subscriber_count" 393 401 sortOrder = "DESC" 394 402 } 395 403 396 404 // Get communities with pagination 397 405 query := fmt.Sprintf(` 398 - SELECT id, did, handle, name, display_name, description, description_facets, 399 - avatar_cid, banner_cid, owner_did, created_by_did, hosted_by_did, 400 - visibility, allow_external_discovery, moderation_type, content_warnings, 401 - member_count, subscriber_count, post_count, 402 - federated_from, federated_id, created_at, updated_at, 403 - record_uri, record_cid 404 - FROM communities 406 + SELECT c.id, c.did, c.handle, c.name, c.display_name, c.description, c.description_facets, 407 + c.avatar_cid, c.banner_cid, c.owner_did, c.created_by_did, c.hosted_by_did, 408 + c.visibility, c.allow_external_discovery, c.moderation_type, c.content_warnings, 409 + c.member_count, c.subscriber_count, c.post_count, 410 + c.federated_from, c.federated_id, c.created_at, c.updated_at, 411 + c.record_uri, c.record_cid, c.pds_url 412 + FROM communities c 413 + %s 405 414 %s 406 415 ORDER BY %s %s 407 416 LIMIT $%d OFFSET $%d`, 408 - whereClause, sortColumn, sortOrder, argCount, argCount+1) 417 + joinClause, whereClause, sortColumn, sortOrder, argCount, argCount+1) 409 418 410 419 args = append(args, req.Limit, req.Offset) 411 420 421 + 422 + 423 + 424 + 425 + 426 + 427 + 428 + 429 + 430 + 431 + 432 + for rows.Next() { 433 + community := &communities.Community{} 434 + var displayName, description, avatarCID, bannerCID, moderationType sql.NullString 435 + var federatedFrom, federatedID, recordURI, recordCID, pdsURL sql.NullString 436 + var descFacets []byte 437 + var contentWarnings []string 438 + 439 + 440 + 441 + 442 + 443 + 444 + 445 + 446 + &community.MemberCount, &community.SubscriberCount, &community.PostCount, 447 + &federatedFrom, &federatedID, 448 + &community.CreatedAt, &community.UpdatedAt, 449 + &recordURI, &recordCID, &pdsURL, 450 + ) 451 + if scanErr != nil { 452 + return nil, fmt.Errorf("failed to scan community: %w", scanErr) 453 + 454 + 455 + 456 + 457 + 458 + 459 + 460 + 461 + 462 + 463 + community.FederatedID = federatedID.String 464 + community.RecordURI = recordURI.String 465 + community.RecordCID = recordCID.String 466 + community.PDSURL = pdsURL.String 467 + if descFacets != nil { 468 + community.DescriptionFacets = descFacets 469 + } 470 + 471 + 472 + 473 + 474 + 475 + 476 + 477 + 478 + 479 + 480 + 481 + 482 + 483 + 484 + 485 + 486 + 487 + 488 + 489 + 490 + 491 + 492 + 493 + 494 + 495 + 496 + 497 + 498 + 499 + 500 + 501 + 502 + 503 + 504 + 505 + 506 + 507 + 508 + 509 + 510 + 511 + visibility, allow_external_discovery, moderation_type, content_warnings, 512 + member_count, subscriber_count, post_count, 513 + federated_from, federated_id, created_at, updated_at, 514 + record_uri, record_cid, pds_url, 515 + similarity(name, $1) + similarity(COALESCE(description, ''), $1) as relevance 516 + FROM communities 517 + %s AND (similarity(name, $1) + similarity(COALESCE(description, ''), $1)) > 0.2 518 + 519 + 520 + 521 + 522 + 523 + 524 + 525 + 526 + 527 + 528 + 529 + 530 + 531 + 532 + 533 + 534 + 535 + for rows.Next() { 536 + community := &communities.Community{} 537 + var displayName, description, avatarCID, bannerCID, moderationType sql.NullString 538 + var federatedFrom, federatedID, recordURI, recordCID, pdsURL sql.NullString 539 + var descFacets []byte 540 + var contentWarnings []string 541 + var relevance float64 542 + 543 + 544 + 545 + 546 + 547 + 548 + 549 + 550 + &community.MemberCount, &community.SubscriberCount, &community.PostCount, 551 + &federatedFrom, &federatedID, 552 + &community.CreatedAt, &community.UpdatedAt, 553 + &recordURI, &recordCID, &pdsURL, 554 + &relevance, 555 + ) 556 + if scanErr != nil { 557 + 558 + 559 + 560 + 561 + 562 + 563 + 564 + 565 + 566 + 567 + 568 + community.FederatedID = federatedID.String 569 + community.RecordURI = recordURI.String 570 + community.RecordCID = recordCID.String 571 + community.PDSURL = pdsURL.String 572 + if descFacets != nil { 573 + community.DescriptionFacets = descFacets 574 + }
+416
internal/api/handlers/user/update_profile.go
··· 1 + package user 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "io" 10 + "log/slog" 11 + "net/http" 12 + "time" 13 + 14 + "Coves/internal/api/middleware" 15 + "Coves/internal/core/blobs" 16 + 17 + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 18 + ) 19 + 20 + const ( 21 + // MaxDisplayNameLength is the maximum allowed length for display names (per atProto lexicon) 22 + MaxDisplayNameLength = 64 23 + // MaxBioLength is the maximum allowed length for bio/description (per atProto lexicon) 24 + MaxBioLength = 256 25 + // MaxAvatarBlobSize is the maximum allowed avatar size in bytes (1MB per lexicon) 26 + MaxAvatarBlobSize = 1_000_000 27 + // MaxBannerBlobSize is the maximum allowed banner size in bytes (2MB per lexicon) 28 + MaxBannerBlobSize = 2_000_000 29 + // MaxRequestBodySize is the maximum request body size (10MB to accommodate base64 overhead) 30 + MaxRequestBodySize = 10_000_000 31 + ) 32 + 33 + // pdsError represents an error returned from the PDS with a specific status code 34 + type pdsError struct { 35 + StatusCode int 36 + } 37 + 38 + func (e *pdsError) Error() string { 39 + return fmt.Sprintf("PDS returned error %d", e.StatusCode) 40 + } 41 + 42 + // UpdateProfileRequest represents the request body for updating a user profile 43 + type UpdateProfileRequest struct { 44 + DisplayName *string `json:"displayName,omitempty"` 45 + Bio *string `json:"bio,omitempty"` 46 + AvatarBlob []byte `json:"avatarBlob,omitempty"` 47 + AvatarMimeType string `json:"avatarMimeType,omitempty"` 48 + BannerBlob []byte `json:"bannerBlob,omitempty"` 49 + BannerMimeType string `json:"bannerMimeType,omitempty"` 50 + } 51 + 52 + // UpdateProfileResponse represents the response from updating a profile 53 + type UpdateProfileResponse struct { 54 + URI string `json:"uri"` 55 + CID string `json:"cid"` 56 + } 57 + 58 + // userBlobOwner implements blobs.BlobOwner for users 59 + // This allows us to use the blob service to upload blobs on behalf of users 60 + type userBlobOwner struct { 61 + pdsURL string 62 + accessToken string 63 + } 64 + 65 + // GetPDSURL returns the PDS URL for this user 66 + func (u *userBlobOwner) GetPDSURL() string { 67 + return u.pdsURL 68 + } 69 + 70 + // GetPDSAccessToken returns the access token for authenticating with the PDS 71 + func (u *userBlobOwner) GetPDSAccessToken() string { 72 + return u.accessToken 73 + } 74 + 75 + // UpdateProfileHandler handles POST /xrpc/social.coves.actor.updateProfile 76 + // This endpoint allows authenticated users to update their profile on their PDS. 77 + // The handler: 78 + // 1. Validates the user is authenticated via OAuth 79 + // 2. Validates avatar/banner size and mime type constraints 80 + // 3. Uploads any provided blobs to the user's PDS 81 + // 4. Puts the profile record to the user's PDS via com.atproto.repo.putRecord 82 + type UpdateProfileHandler struct { 83 + blobService blobs.Service 84 + httpClient *http.Client // For making PDS calls 85 + } 86 + 87 + // NewUpdateProfileHandler creates a new update profile handler 88 + func NewUpdateProfileHandler(blobService blobs.Service, httpClient *http.Client) *UpdateProfileHandler { 89 + // Use default client if none provided 90 + if httpClient == nil { 91 + httpClient = &http.Client{ 92 + Timeout: 30 * time.Second, 93 + } 94 + } 95 + return &UpdateProfileHandler{ 96 + blobService: blobService, 97 + httpClient: httpClient, 98 + } 99 + } 100 + 101 + // ServeHTTP handles the update profile request 102 + func (h *UpdateProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 103 + ctx := r.Context() 104 + 105 + // Check HTTP method 106 + if r.Method != http.MethodPost { 107 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 108 + return 109 + } 110 + 111 + // 1. Get authenticated user from context 112 + userDID := middleware.GetUserDID(r) 113 + if userDID == "" { 114 + writeUpdateProfileError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 115 + return 116 + } 117 + 118 + // Get OAuth session for PDS URL and access token 119 + session := middleware.GetOAuthSession(r) 120 + if session == nil { 121 + writeUpdateProfileError(w, http.StatusUnauthorized, "MissingSession", "Missing PDS credentials") 122 + return 123 + } 124 + 125 + pdsURL := session.HostURL 126 + accessToken := session.AccessToken 127 + if pdsURL == "" || accessToken == "" { 128 + writeUpdateProfileError(w, http.StatusUnauthorized, "MissingCredentials", "Missing PDS credentials") 129 + return 130 + } 131 + 132 + // 2. Parse request 133 + r.Body = http.MaxBytesReader(w, r.Body, MaxRequestBodySize) 134 + var req UpdateProfileRequest 135 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 136 + writeUpdateProfileError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 137 + return 138 + } 139 + 140 + // Validate displayName length 141 + if req.DisplayName != nil && len(*req.DisplayName) > MaxDisplayNameLength { 142 + writeUpdateProfileError(w, http.StatusBadRequest, "DisplayNameTooLong", 143 + fmt.Sprintf("Display name exceeds %d character limit", MaxDisplayNameLength)) 144 + return 145 + } 146 + 147 + // Validate bio length 148 + if req.Bio != nil && len(*req.Bio) > MaxBioLength { 149 + writeUpdateProfileError(w, http.StatusBadRequest, "BioTooLong", 150 + fmt.Sprintf("Bio exceeds %d character limit", MaxBioLength)) 151 + return 152 + } 153 + 154 + // 3. Validate blob sizes and mime types 155 + if len(req.AvatarBlob) > 0 { 156 + // Validate mime type is provided when blob is provided 157 + if req.AvatarMimeType == "" { 158 + writeUpdateProfileError(w, http.StatusBadRequest, "InvalidRequest", "Avatar blob provided without mime type") 159 + return 160 + } 161 + // Validate size (1MB max for avatar per lexicon) 162 + if len(req.AvatarBlob) > MaxAvatarBlobSize { 163 + writeUpdateProfileError(w, http.StatusBadRequest, "AvatarTooLarge", "Avatar exceeds 1MB limit") 164 + return 165 + } 166 + if !isValidImageMimeType(req.AvatarMimeType) { 167 + writeUpdateProfileError(w, http.StatusBadRequest, "InvalidMimeType", "Invalid avatar mime type") 168 + return 169 + } 170 + } 171 + 172 + if len(req.BannerBlob) > 0 { 173 + // Validate mime type is provided when blob is provided 174 + if req.BannerMimeType == "" { 175 + writeUpdateProfileError(w, http.StatusBadRequest, "InvalidRequest", "Banner blob provided without mime type") 176 + return 177 + } 178 + // Validate size (2MB max for banner per lexicon) 179 + if len(req.BannerBlob) > MaxBannerBlobSize { 180 + writeUpdateProfileError(w, http.StatusBadRequest, "BannerTooLarge", "Banner exceeds 2MB limit") 181 + return 182 + } 183 + if !isValidImageMimeType(req.BannerMimeType) { 184 + writeUpdateProfileError(w, http.StatusBadRequest, "InvalidMimeType", "Invalid banner mime type") 185 + return 186 + } 187 + } 188 + 189 + // 4. Create blob owner for user (implements blobs.BlobOwner interface) 190 + owner := &userBlobOwner{pdsURL: pdsURL, accessToken: accessToken} 191 + 192 + // 5. Build profile record 193 + profile := map[string]interface{}{ 194 + "$type": "app.bsky.actor.profile", 195 + } 196 + 197 + // Add displayName if provided 198 + if req.DisplayName != nil { 199 + profile["displayName"] = *req.DisplayName 200 + } 201 + 202 + // Add bio (description) if provided 203 + if req.Bio != nil { 204 + profile["description"] = *req.Bio 205 + } 206 + 207 + // 6. Upload avatar blob if provided 208 + if len(req.AvatarBlob) > 0 { 209 + avatarRef, err := h.blobService.UploadBlob(ctx, owner, req.AvatarBlob, req.AvatarMimeType) 210 + if err != nil { 211 + slog.Error("failed to upload avatar blob", 212 + slog.String("did", userDID), 213 + slog.String("error", err.Error()), 214 + ) 215 + writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Failed to upload avatar") 216 + return 217 + } 218 + if avatarRef == nil || avatarRef.Ref == nil || avatarRef.Type == "" { 219 + slog.Error("invalid blob reference returned from avatar upload", slog.String("did", userDID)) 220 + writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Invalid avatar blob reference") 221 + return 222 + } 223 + profile["avatar"] = map[string]interface{}{ 224 + "$type": avatarRef.Type, 225 + "ref": avatarRef.Ref, 226 + "mimeType": avatarRef.MimeType, 227 + "size": avatarRef.Size, 228 + } 229 + } 230 + 231 + // 7. Upload banner blob if provided 232 + if len(req.BannerBlob) > 0 { 233 + bannerRef, err := h.blobService.UploadBlob(ctx, owner, req.BannerBlob, req.BannerMimeType) 234 + if err != nil { 235 + slog.Error("failed to upload banner blob", 236 + slog.String("did", userDID), 237 + slog.String("error", err.Error()), 238 + ) 239 + writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Failed to upload banner") 240 + return 241 + } 242 + if bannerRef == nil || bannerRef.Ref == nil || bannerRef.Type == "" { 243 + slog.Error("invalid blob reference returned from banner upload", slog.String("did", userDID)) 244 + writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Invalid banner blob reference") 245 + return 246 + } 247 + profile["banner"] = map[string]interface{}{ 248 + "$type": bannerRef.Type, 249 + "ref": bannerRef.Ref, 250 + "mimeType": bannerRef.MimeType, 251 + "size": bannerRef.Size, 252 + } 253 + } 254 + 255 + // 8. Put profile record to PDS using com.atproto.repo.putRecord 256 + uri, cid, err := h.putProfileRecord(ctx, session, userDID, profile) 257 + if err != nil { 258 + slog.Error("failed to put profile record to PDS", 259 + slog.String("did", userDID), 260 + slog.String("pds_url", pdsURL), 261 + slog.String("error", err.Error()), 262 + ) 263 + // Map PDS status codes to user-friendly messages 264 + var pdsErr *pdsError 265 + if errors.As(err, &pdsErr) { 266 + switch pdsErr.StatusCode { 267 + case http.StatusUnauthorized, http.StatusForbidden: 268 + writeUpdateProfileError(w, http.StatusUnauthorized, "AuthExpired", "Your session may have expired. Please re-authenticate.") 269 + return 270 + case http.StatusTooManyRequests: 271 + writeUpdateProfileError(w, http.StatusTooManyRequests, "RateLimited", "Too many requests. Please try again later.") 272 + return 273 + case http.StatusRequestEntityTooLarge: 274 + writeUpdateProfileError(w, http.StatusBadRequest, "PayloadTooLarge", "Profile data exceeds PDS limits.") 275 + return 276 + } 277 + } 278 + writeUpdateProfileError(w, http.StatusInternalServerError, "PDSError", "Failed to update profile") 279 + return 280 + } 281 + 282 + // 9. Return success response 283 + resp := UpdateProfileResponse{URI: uri, CID: cid} 284 + 285 + // Marshal to bytes first to catch encoding errors before writing headers 286 + responseBytes, err := json.Marshal(resp) 287 + if err != nil { 288 + slog.Error("failed to marshal update profile response", 289 + slog.String("did", userDID), 290 + slog.String("error", err.Error()), 291 + ) 292 + writeUpdateProfileError(w, http.StatusInternalServerError, "InternalError", "Failed to encode response") 293 + return 294 + } 295 + 296 + w.Header().Set("Content-Type", "application/json") 297 + w.WriteHeader(http.StatusOK) 298 + if _, writeErr := w.Write(responseBytes); writeErr != nil { 299 + slog.Warn("failed to write update profile response", 300 + slog.String("did", userDID), 301 + slog.String("error", writeErr.Error()), 302 + ) 303 + } 304 + } 305 + 306 + // putProfileRecord calls com.atproto.repo.putRecord on the user's PDS 307 + // This creates or updates the user's profile record at: 308 + // at://{did}/app.bsky.actor.profile/self 309 + func (h *UpdateProfileHandler) putProfileRecord(ctx context.Context, session *oauthlib.ClientSessionData, did string, profile map[string]interface{}) (string, string, error) { 310 + pdsURL := session.HostURL 311 + accessToken := session.AccessToken 312 + 313 + // Build the putRecord request body 314 + putRecordReq := map[string]interface{}{ 315 + "repo": did, 316 + "collection": "app.bsky.actor.profile", 317 + "rkey": "self", 318 + "record": profile, 319 + } 320 + 321 + reqBody, err := json.Marshal(putRecordReq) 322 + if err != nil { 323 + return "", "", fmt.Errorf("failed to marshal putRecord request: %w", err) 324 + } 325 + 326 + // Build the endpoint URL 327 + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", pdsURL) 328 + 329 + // Create the HTTP request 330 + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(reqBody)) 331 + if err != nil { 332 + return "", "", fmt.Errorf("failed to create PDS request: %w", err) 333 + } 334 + 335 + // Set headers 336 + req.Header.Set("Content-Type", "application/json") 337 + req.Header.Set("Authorization", "Bearer "+accessToken) 338 + 339 + // Execute the request 340 + resp, err := h.httpClient.Do(req) 341 + if err != nil { 342 + return "", "", fmt.Errorf("PDS request failed: %w", err) 343 + } 344 + defer func() { 345 + if closeErr := resp.Body.Close(); closeErr != nil { 346 + slog.Warn("failed to close PDS response body", slog.String("error", closeErr.Error())) 347 + } 348 + }() 349 + 350 + // Read response body 351 + body, err := io.ReadAll(resp.Body) 352 + if err != nil { 353 + return "", "", fmt.Errorf("failed to read PDS response: %w", err) 354 + } 355 + 356 + // Check for errors 357 + if resp.StatusCode != http.StatusOK { 358 + // Truncate error body for logging to prevent leaking sensitive data 359 + bodyPreview := string(body) 360 + if len(bodyPreview) > 200 { 361 + bodyPreview = bodyPreview[:200] + "... (truncated)" 362 + } 363 + slog.Error("PDS putRecord failed", 364 + slog.Int("status", resp.StatusCode), 365 + slog.String("body", bodyPreview), 366 + ) 367 + return "", "", &pdsError{StatusCode: resp.StatusCode} 368 + } 369 + 370 + // Parse the successful response 371 + var result struct { 372 + URI string `json:"uri"` 373 + CID string `json:"cid"` 374 + } 375 + if err := json.Unmarshal(body, &result); err != nil { 376 + return "", "", fmt.Errorf("failed to parse PDS response: %w", err) 377 + } 378 + 379 + if result.URI == "" || result.CID == "" { 380 + return "", "", fmt.Errorf("PDS response missing required fields (uri or cid)") 381 + } 382 + 383 + return result.URI, result.CID, nil 384 + } 385 + 386 + // isValidImageMimeType checks if the MIME type is allowed for profile images 387 + func isValidImageMimeType(mimeType string) bool { 388 + switch mimeType { 389 + case "image/png", "image/jpeg", "image/webp": 390 + return true 391 + default: 392 + return false 393 + } 394 + } 395 + 396 + // writeUpdateProfileError writes a JSON error response for update profile failures 397 + func writeUpdateProfileError(w http.ResponseWriter, statusCode int, errorType, message string) { 398 + responseBytes, err := json.Marshal(map[string]interface{}{ 399 + "error": errorType, 400 + "message": message, 401 + }) 402 + if err != nil { 403 + // Fallback to plain text if JSON encoding fails 404 + slog.Error("failed to marshal error response", slog.String("error", err.Error())) 405 + w.Header().Set("Content-Type", "text/plain") 406 + w.WriteHeader(statusCode) 407 + _, _ = w.Write([]byte(message)) 408 + return 409 + } 410 + 411 + w.Header().Set("Content-Type", "application/json") 412 + w.WriteHeader(statusCode) 413 + if _, writeErr := w.Write(responseBytes); writeErr != nil { 414 + slog.Warn("failed to write error response", slog.String("error", writeErr.Error())) 415 + } 416 + }
+1035
internal/api/handlers/user/update_profile_test.go
··· 1 + package user 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "net/http" 9 + "net/http/httptest" 10 + "strings" 11 + "testing" 12 + 13 + "Coves/internal/api/middleware" 14 + "Coves/internal/core/blobs" 15 + 16 + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 18 + "github.com/stretchr/testify/assert" 19 + "github.com/stretchr/testify/mock" 20 + ) 21 + 22 + // MockBlobService is a mock implementation of blobs.Service for testing 23 + type MockBlobService struct { 24 + mock.Mock 25 + } 26 + 27 + func (m *MockBlobService) UploadBlobFromURL(ctx context.Context, owner blobs.BlobOwner, imageURL string) (*blobs.BlobRef, error) { 28 + args := m.Called(ctx, owner, imageURL) 29 + if args.Get(0) == nil { 30 + return nil, args.Error(1) 31 + } 32 + return args.Get(0).(*blobs.BlobRef), args.Error(1) 33 + } 34 + 35 + func (m *MockBlobService) UploadBlob(ctx context.Context, owner blobs.BlobOwner, data []byte, mimeType string) (*blobs.BlobRef, error) { 36 + args := m.Called(ctx, owner, data, mimeType) 37 + if args.Get(0) == nil { 38 + return nil, args.Error(1) 39 + } 40 + return args.Get(0).(*blobs.BlobRef), args.Error(1) 41 + } 42 + 43 + // MockPDSClient is a mock HTTP client for PDS interactions 44 + type MockPDSClient struct { 45 + mock.Mock 46 + } 47 + 48 + // mockRoundTripper implements http.RoundTripper for testing 49 + type mockRoundTripper struct { 50 + mock.Mock 51 + } 52 + 53 + func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 54 + args := m.Called(req) 55 + if args.Get(0) == nil { 56 + return nil, args.Error(1) 57 + } 58 + return args.Get(0).(*http.Response), args.Error(1) 59 + } 60 + 61 + // createTestOAuthSession creates a test OAuth session for testing 62 + func createTestOAuthSession(did string) *oauthlib.ClientSessionData { 63 + parsedDID, _ := syntax.ParseDID(did) 64 + return &oauthlib.ClientSessionData{ 65 + AccountDID: parsedDID, 66 + SessionID: "test-session-id", 67 + HostURL: "https://test.pds.example", 68 + AccessToken: "test-access-token", 69 + } 70 + } 71 + 72 + // setTestOAuthSession sets both user DID and OAuth session in context 73 + func setTestOAuthSession(ctx context.Context, userDID string, session *oauthlib.ClientSessionData) context.Context { 74 + ctx = middleware.SetTestUserDID(ctx, userDID) 75 + ctx = context.WithValue(ctx, middleware.OAuthSessionKey, session) 76 + ctx = context.WithValue(ctx, middleware.UserAccessToken, session.AccessToken) 77 + return ctx 78 + } 79 + 80 + // TestUpdateProfileHandler_Unauthenticated tests that unauthenticated requests return 401 81 + func TestUpdateProfileHandler_Unauthenticated(t *testing.T) { 82 + mockBlobService := new(MockBlobService) 83 + handler := NewUpdateProfileHandler(mockBlobService, nil) 84 + 85 + reqBody := UpdateProfileRequest{ 86 + DisplayName: strPtr("Test User"), 87 + } 88 + body, _ := json.Marshal(reqBody) 89 + 90 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 91 + req.Header.Set("Content-Type", "application/json") 92 + // No auth context - simulates unauthenticated request 93 + 94 + w := httptest.NewRecorder() 95 + handler.ServeHTTP(w, req) 96 + 97 + assert.Equal(t, http.StatusUnauthorized, w.Code) 98 + assert.Contains(t, w.Body.String(), "AuthRequired") 99 + 100 + mockBlobService.AssertNotCalled(t, "UploadBlob", mock.Anything, mock.Anything, mock.Anything, mock.Anything) 101 + } 102 + 103 + // TestUpdateProfileHandler_MissingOAuthSession tests that missing OAuth session returns 401 104 + func TestUpdateProfileHandler_MissingOAuthSession(t *testing.T) { 105 + mockBlobService := new(MockBlobService) 106 + handler := NewUpdateProfileHandler(mockBlobService, nil) 107 + 108 + reqBody := UpdateProfileRequest{ 109 + DisplayName: strPtr("Test User"), 110 + } 111 + body, _ := json.Marshal(reqBody) 112 + 113 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 114 + req.Header.Set("Content-Type", "application/json") 115 + 116 + // Set user DID but no OAuth session 117 + ctx := middleware.SetTestUserDID(req.Context(), "did:plc:testuser123") 118 + req = req.WithContext(ctx) 119 + 120 + w := httptest.NewRecorder() 121 + handler.ServeHTTP(w, req) 122 + 123 + assert.Equal(t, http.StatusUnauthorized, w.Code) 124 + assert.Contains(t, w.Body.String(), "Missing PDS credentials") 125 + } 126 + 127 + // TestUpdateProfileHandler_InvalidRequestBody tests that invalid JSON returns 400 128 + func TestUpdateProfileHandler_InvalidRequestBody(t *testing.T) { 129 + mockBlobService := new(MockBlobService) 130 + handler := NewUpdateProfileHandler(mockBlobService, nil) 131 + 132 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", strings.NewReader("not valid json")) 133 + req.Header.Set("Content-Type", "application/json") 134 + 135 + testDID := "did:plc:testuser123" 136 + session := createTestOAuthSession(testDID) 137 + ctx := setTestOAuthSession(req.Context(), testDID, session) 138 + req = req.WithContext(ctx) 139 + 140 + w := httptest.NewRecorder() 141 + handler.ServeHTTP(w, req) 142 + 143 + assert.Equal(t, http.StatusBadRequest, w.Code) 144 + assert.Contains(t, w.Body.String(), "Invalid request body") 145 + } 146 + 147 + // TestUpdateProfileHandler_AvatarSizeExceedsLimit tests that avatar over 1MB is rejected 148 + func TestUpdateProfileHandler_AvatarSizeExceedsLimit(t *testing.T) { 149 + mockBlobService := new(MockBlobService) 150 + handler := NewUpdateProfileHandler(mockBlobService, nil) 151 + 152 + // Create avatar blob larger than 1MB (1,000,001 bytes) 153 + largeBlob := make([]byte, 1_000_001) 154 + 155 + reqBody := UpdateProfileRequest{ 156 + DisplayName: strPtr("Test User"), 157 + AvatarBlob: largeBlob, 158 + AvatarMimeType: "image/jpeg", 159 + } 160 + body, _ := json.Marshal(reqBody) 161 + 162 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 163 + req.Header.Set("Content-Type", "application/json") 164 + 165 + testDID := "did:plc:testuser123" 166 + session := createTestOAuthSession(testDID) 167 + ctx := setTestOAuthSession(req.Context(), testDID, session) 168 + req = req.WithContext(ctx) 169 + 170 + w := httptest.NewRecorder() 171 + handler.ServeHTTP(w, req) 172 + 173 + assert.Equal(t, http.StatusBadRequest, w.Code) 174 + assert.Contains(t, w.Body.String(), "Avatar exceeds 1MB limit") 175 + 176 + mockBlobService.AssertNotCalled(t, "UploadBlob", mock.Anything, mock.Anything, mock.Anything, mock.Anything) 177 + } 178 + 179 + // TestUpdateProfileHandler_BannerSizeExceedsLimit tests that banner over 2MB is rejected 180 + func TestUpdateProfileHandler_BannerSizeExceedsLimit(t *testing.T) { 181 + mockBlobService := new(MockBlobService) 182 + handler := NewUpdateProfileHandler(mockBlobService, nil) 183 + 184 + // Create banner blob larger than 2MB (2,000,001 bytes) 185 + largeBlob := make([]byte, 2_000_001) 186 + 187 + reqBody := UpdateProfileRequest{ 188 + DisplayName: strPtr("Test User"), 189 + BannerBlob: largeBlob, 190 + BannerMimeType: "image/jpeg", 191 + } 192 + body, _ := json.Marshal(reqBody) 193 + 194 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 195 + req.Header.Set("Content-Type", "application/json") 196 + 197 + testDID := "did:plc:testuser123" 198 + session := createTestOAuthSession(testDID) 199 + ctx := setTestOAuthSession(req.Context(), testDID, session) 200 + req = req.WithContext(ctx) 201 + 202 + w := httptest.NewRecorder() 203 + handler.ServeHTTP(w, req) 204 + 205 + assert.Equal(t, http.StatusBadRequest, w.Code) 206 + assert.Contains(t, w.Body.String(), "Banner exceeds 2MB limit") 207 + 208 + mockBlobService.AssertNotCalled(t, "UploadBlob", mock.Anything, mock.Anything, mock.Anything, mock.Anything) 209 + } 210 + 211 + // TestUpdateProfileHandler_InvalidAvatarMimeType tests that invalid avatar mime type is rejected 212 + func TestUpdateProfileHandler_InvalidAvatarMimeType(t *testing.T) { 213 + mockBlobService := new(MockBlobService) 214 + handler := NewUpdateProfileHandler(mockBlobService, nil) 215 + 216 + reqBody := UpdateProfileRequest{ 217 + DisplayName: strPtr("Test User"), 218 + AvatarBlob: []byte("fake image data"), 219 + AvatarMimeType: "image/gif", // Not allowed - only png/jpeg/webp 220 + } 221 + body, _ := json.Marshal(reqBody) 222 + 223 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 224 + req.Header.Set("Content-Type", "application/json") 225 + 226 + testDID := "did:plc:testuser123" 227 + session := createTestOAuthSession(testDID) 228 + ctx := setTestOAuthSession(req.Context(), testDID, session) 229 + req = req.WithContext(ctx) 230 + 231 + w := httptest.NewRecorder() 232 + handler.ServeHTTP(w, req) 233 + 234 + assert.Equal(t, http.StatusBadRequest, w.Code) 235 + assert.Contains(t, w.Body.String(), "Invalid avatar mime type") 236 + } 237 + 238 + // TestUpdateProfileHandler_InvalidBannerMimeType tests that invalid banner mime type is rejected 239 + func TestUpdateProfileHandler_InvalidBannerMimeType(t *testing.T) { 240 + mockBlobService := new(MockBlobService) 241 + handler := NewUpdateProfileHandler(mockBlobService, nil) 242 + 243 + reqBody := UpdateProfileRequest{ 244 + DisplayName: strPtr("Test User"), 245 + BannerBlob: []byte("fake image data"), 246 + BannerMimeType: "application/pdf", // Not allowed 247 + } 248 + body, _ := json.Marshal(reqBody) 249 + 250 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 251 + req.Header.Set("Content-Type", "application/json") 252 + 253 + testDID := "did:plc:testuser123" 254 + session := createTestOAuthSession(testDID) 255 + ctx := setTestOAuthSession(req.Context(), testDID, session) 256 + req = req.WithContext(ctx) 257 + 258 + w := httptest.NewRecorder() 259 + handler.ServeHTTP(w, req) 260 + 261 + assert.Equal(t, http.StatusBadRequest, w.Code) 262 + assert.Contains(t, w.Body.String(), "Invalid banner mime type") 263 + } 264 + 265 + // TestUpdateProfileHandler_ValidMimeTypes tests that all valid mime types are accepted 266 + func TestUpdateProfileHandler_ValidMimeTypes(t *testing.T) { 267 + validMimeTypes := []string{"image/png", "image/jpeg", "image/webp"} 268 + 269 + for _, mimeType := range validMimeTypes { 270 + t.Run(mimeType, func(t *testing.T) { 271 + mockBlobService := new(MockBlobService) 272 + 273 + // Set up mock PDS server for putRecord 274 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 275 + w.Header().Set("Content-Type", "application/json") 276 + json.NewEncoder(w).Encode(map[string]interface{}{ 277 + "uri": "at://did:plc:testuser123/app.bsky.actor.profile/self", 278 + "cid": "bafyreicid123", 279 + }) 280 + })) 281 + defer mockPDS.Close() 282 + 283 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 284 + 285 + avatarData := []byte("fake avatar image data") 286 + expectedBlobRef := &blobs.BlobRef{ 287 + Type: "blob", 288 + Ref: map[string]string{"$link": "bafyreiabc123"}, 289 + MimeType: mimeType, 290 + Size: len(avatarData), 291 + } 292 + 293 + mockBlobService.On("UploadBlob", mock.Anything, mock.Anything, avatarData, mimeType). 294 + Return(expectedBlobRef, nil) 295 + 296 + reqBody := UpdateProfileRequest{ 297 + DisplayName: strPtr("Test User"), 298 + AvatarBlob: avatarData, 299 + AvatarMimeType: mimeType, 300 + } 301 + body, _ := json.Marshal(reqBody) 302 + 303 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 304 + req.Header.Set("Content-Type", "application/json") 305 + 306 + testDID := "did:plc:testuser123" 307 + session := createTestOAuthSession(testDID) 308 + session.HostURL = mockPDS.URL // Point to mock PDS 309 + ctx := setTestOAuthSession(req.Context(), testDID, session) 310 + req = req.WithContext(ctx) 311 + 312 + w := httptest.NewRecorder() 313 + handler.ServeHTTP(w, req) 314 + 315 + // Should succeed or fail at PDS call, not at validation 316 + // We just verify the mime type validation passed 317 + assert.NotEqual(t, http.StatusBadRequest, w.Code) 318 + mockBlobService.AssertExpectations(t) 319 + }) 320 + } 321 + } 322 + 323 + // TestUpdateProfileHandler_AvatarBlobUploadFailure tests handling of blob upload failure 324 + func TestUpdateProfileHandler_AvatarBlobUploadFailure(t *testing.T) { 325 + mockBlobService := new(MockBlobService) 326 + handler := NewUpdateProfileHandler(mockBlobService, nil) 327 + 328 + avatarData := []byte("fake avatar image data") 329 + mockBlobService.On("UploadBlob", mock.Anything, mock.Anything, avatarData, "image/jpeg"). 330 + Return(nil, errors.New("PDS upload failed")) 331 + 332 + reqBody := UpdateProfileRequest{ 333 + DisplayName: strPtr("Test User"), 334 + AvatarBlob: avatarData, 335 + AvatarMimeType: "image/jpeg", 336 + } 337 + body, _ := json.Marshal(reqBody) 338 + 339 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 340 + req.Header.Set("Content-Type", "application/json") 341 + 342 + testDID := "did:plc:testuser123" 343 + session := createTestOAuthSession(testDID) 344 + ctx := setTestOAuthSession(req.Context(), testDID, session) 345 + req = req.WithContext(ctx) 346 + 347 + w := httptest.NewRecorder() 348 + handler.ServeHTTP(w, req) 349 + 350 + assert.Equal(t, http.StatusInternalServerError, w.Code) 351 + assert.Contains(t, w.Body.String(), "Failed to upload avatar") 352 + 353 + mockBlobService.AssertExpectations(t) 354 + } 355 + 356 + // TestUpdateProfileHandler_BannerBlobUploadFailure tests handling of banner blob upload failure 357 + func TestUpdateProfileHandler_BannerBlobUploadFailure(t *testing.T) { 358 + mockBlobService := new(MockBlobService) 359 + handler := NewUpdateProfileHandler(mockBlobService, nil) 360 + 361 + bannerData := []byte("fake banner image data") 362 + mockBlobService.On("UploadBlob", mock.Anything, mock.Anything, bannerData, "image/png"). 363 + Return(nil, errors.New("PDS upload failed")) 364 + 365 + reqBody := UpdateProfileRequest{ 366 + DisplayName: strPtr("Test User"), 367 + BannerBlob: bannerData, 368 + BannerMimeType: "image/png", 369 + } 370 + body, _ := json.Marshal(reqBody) 371 + 372 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 373 + req.Header.Set("Content-Type", "application/json") 374 + 375 + testDID := "did:plc:testuser123" 376 + session := createTestOAuthSession(testDID) 377 + ctx := setTestOAuthSession(req.Context(), testDID, session) 378 + req = req.WithContext(ctx) 379 + 380 + w := httptest.NewRecorder() 381 + handler.ServeHTTP(w, req) 382 + 383 + assert.Equal(t, http.StatusInternalServerError, w.Code) 384 + assert.Contains(t, w.Body.String(), "Failed to upload banner") 385 + 386 + mockBlobService.AssertExpectations(t) 387 + } 388 + 389 + // TestUpdateProfileHandler_PartialUpdateDisplayNameOnly tests updating only displayName (no blobs) 390 + func TestUpdateProfileHandler_PartialUpdateDisplayNameOnly(t *testing.T) { 391 + mockBlobService := new(MockBlobService) 392 + 393 + // Mock PDS server for putRecord 394 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 395 + // Verify it's the right endpoint 396 + assert.Equal(t, "/xrpc/com.atproto.repo.putRecord", r.URL.Path) 397 + 398 + // Parse request body 399 + var putReq map[string]interface{} 400 + json.NewDecoder(r.Body).Decode(&putReq) 401 + 402 + // Verify record structure 403 + record, ok := putReq["record"].(map[string]interface{}) 404 + assert.True(t, ok, "record should exist") 405 + assert.Equal(t, "app.bsky.actor.profile", record["$type"]) 406 + assert.Equal(t, "Updated Display Name", record["displayName"]) 407 + assert.Nil(t, record["avatar"], "avatar should not be set") 408 + assert.Nil(t, record["banner"], "banner should not be set") 409 + 410 + w.Header().Set("Content-Type", "application/json") 411 + json.NewEncoder(w).Encode(map[string]interface{}{ 412 + "uri": "at://did:plc:testuser123/app.bsky.actor.profile/self", 413 + "cid": "bafyreicid123", 414 + }) 415 + })) 416 + defer mockPDS.Close() 417 + 418 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 419 + 420 + reqBody := UpdateProfileRequest{ 421 + DisplayName: strPtr("Updated Display Name"), 422 + } 423 + body, _ := json.Marshal(reqBody) 424 + 425 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 426 + req.Header.Set("Content-Type", "application/json") 427 + 428 + testDID := "did:plc:testuser123" 429 + session := createTestOAuthSession(testDID) 430 + session.HostURL = mockPDS.URL 431 + ctx := setTestOAuthSession(req.Context(), testDID, session) 432 + req = req.WithContext(ctx) 433 + 434 + w := httptest.NewRecorder() 435 + handler.ServeHTTP(w, req) 436 + 437 + assert.Equal(t, http.StatusOK, w.Code) 438 + 439 + var response UpdateProfileResponse 440 + json.Unmarshal(w.Body.Bytes(), &response) 441 + assert.Contains(t, response.URI, "did:plc:testuser123") 442 + assert.NotEmpty(t, response.CID) 443 + 444 + // No blob uploads should have been called 445 + mockBlobService.AssertNotCalled(t, "UploadBlob", mock.Anything, mock.Anything, mock.Anything, mock.Anything) 446 + } 447 + 448 + // TestUpdateProfileHandler_PartialUpdateBioOnly tests updating only bio (description) 449 + func TestUpdateProfileHandler_PartialUpdateBioOnly(t *testing.T) { 450 + mockBlobService := new(MockBlobService) 451 + 452 + // Mock PDS server for putRecord 453 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 454 + var putReq map[string]interface{} 455 + json.NewDecoder(r.Body).Decode(&putReq) 456 + 457 + record := putReq["record"].(map[string]interface{}) 458 + assert.Equal(t, "This is my updated bio", record["description"]) 459 + assert.Nil(t, record["displayName"], "displayName should not be set if not provided") 460 + 461 + w.Header().Set("Content-Type", "application/json") 462 + json.NewEncoder(w).Encode(map[string]interface{}{ 463 + "uri": "at://did:plc:testuser123/app.bsky.actor.profile/self", 464 + "cid": "bafyreicid123", 465 + }) 466 + })) 467 + defer mockPDS.Close() 468 + 469 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 470 + 471 + reqBody := UpdateProfileRequest{ 472 + Bio: strPtr("This is my updated bio"), 473 + } 474 + body, _ := json.Marshal(reqBody) 475 + 476 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 477 + req.Header.Set("Content-Type", "application/json") 478 + 479 + testDID := "did:plc:testuser123" 480 + session := createTestOAuthSession(testDID) 481 + session.HostURL = mockPDS.URL 482 + ctx := setTestOAuthSession(req.Context(), testDID, session) 483 + req = req.WithContext(ctx) 484 + 485 + w := httptest.NewRecorder() 486 + handler.ServeHTTP(w, req) 487 + 488 + assert.Equal(t, http.StatusOK, w.Code) 489 + mockBlobService.AssertNotCalled(t, "UploadBlob", mock.Anything, mock.Anything, mock.Anything, mock.Anything) 490 + } 491 + 492 + // TestUpdateProfileHandler_FullUpdate tests updating displayName, bio, avatar, and banner 493 + func TestUpdateProfileHandler_FullUpdate(t *testing.T) { 494 + mockBlobService := new(MockBlobService) 495 + 496 + avatarData := []byte("avatar image data") 497 + bannerData := []byte("banner image data") 498 + 499 + avatarBlobRef := &blobs.BlobRef{ 500 + Type: "blob", 501 + Ref: map[string]string{"$link": "bafyreiavatarcid"}, 502 + MimeType: "image/jpeg", 503 + Size: len(avatarData), 504 + } 505 + bannerBlobRef := &blobs.BlobRef{ 506 + Type: "blob", 507 + Ref: map[string]string{"$link": "bafyreibannercid"}, 508 + MimeType: "image/png", 509 + Size: len(bannerData), 510 + } 511 + 512 + mockBlobService.On("UploadBlob", mock.Anything, mock.Anything, avatarData, "image/jpeg"). 513 + Return(avatarBlobRef, nil) 514 + mockBlobService.On("UploadBlob", mock.Anything, mock.Anything, bannerData, "image/png"). 515 + Return(bannerBlobRef, nil) 516 + 517 + // Mock PDS server for putRecord 518 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 519 + var putReq map[string]interface{} 520 + json.NewDecoder(r.Body).Decode(&putReq) 521 + 522 + record := putReq["record"].(map[string]interface{}) 523 + assert.Equal(t, "Full Update User", record["displayName"]) 524 + assert.Equal(t, "Updated bio with full profile", record["description"]) 525 + assert.NotNil(t, record["avatar"], "avatar should be set") 526 + assert.NotNil(t, record["banner"], "banner should be set") 527 + 528 + w.Header().Set("Content-Type", "application/json") 529 + json.NewEncoder(w).Encode(map[string]interface{}{ 530 + "uri": "at://did:plc:testuser123/app.bsky.actor.profile/self", 531 + "cid": "bafyreifullcid", 532 + }) 533 + })) 534 + defer mockPDS.Close() 535 + 536 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 537 + 538 + reqBody := UpdateProfileRequest{ 539 + DisplayName: strPtr("Full Update User"), 540 + Bio: strPtr("Updated bio with full profile"), 541 + AvatarBlob: avatarData, 542 + AvatarMimeType: "image/jpeg", 543 + BannerBlob: bannerData, 544 + BannerMimeType: "image/png", 545 + } 546 + body, _ := json.Marshal(reqBody) 547 + 548 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 549 + req.Header.Set("Content-Type", "application/json") 550 + 551 + testDID := "did:plc:testuser123" 552 + session := createTestOAuthSession(testDID) 553 + session.HostURL = mockPDS.URL 554 + ctx := setTestOAuthSession(req.Context(), testDID, session) 555 + req = req.WithContext(ctx) 556 + 557 + w := httptest.NewRecorder() 558 + handler.ServeHTTP(w, req) 559 + 560 + assert.Equal(t, http.StatusOK, w.Code) 561 + 562 + var response UpdateProfileResponse 563 + json.Unmarshal(w.Body.Bytes(), &response) 564 + assert.Contains(t, response.URI, "did:plc:testuser123") 565 + assert.Equal(t, "bafyreifullcid", response.CID) 566 + 567 + mockBlobService.AssertExpectations(t) 568 + } 569 + 570 + // TestUpdateProfileHandler_PDSPutRecordFailure tests handling of PDS putRecord failure 571 + func TestUpdateProfileHandler_PDSPutRecordFailure(t *testing.T) { 572 + mockBlobService := new(MockBlobService) 573 + 574 + // Mock PDS server that returns an error 575 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 576 + w.Header().Set("Content-Type", "application/json") 577 + w.WriteHeader(http.StatusInternalServerError) 578 + json.NewEncoder(w).Encode(map[string]interface{}{ 579 + "error": "InternalError", 580 + "message": "Failed to update record", 581 + }) 582 + })) 583 + defer mockPDS.Close() 584 + 585 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 586 + 587 + reqBody := UpdateProfileRequest{ 588 + DisplayName: strPtr("Test User"), 589 + } 590 + body, _ := json.Marshal(reqBody) 591 + 592 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 593 + req.Header.Set("Content-Type", "application/json") 594 + 595 + testDID := "did:plc:testuser123" 596 + session := createTestOAuthSession(testDID) 597 + session.HostURL = mockPDS.URL 598 + ctx := setTestOAuthSession(req.Context(), testDID, session) 599 + req = req.WithContext(ctx) 600 + 601 + w := httptest.NewRecorder() 602 + handler.ServeHTTP(w, req) 603 + 604 + assert.Equal(t, http.StatusInternalServerError, w.Code) 605 + assert.Contains(t, w.Body.String(), "Failed to update profile") 606 + } 607 + 608 + // TestUpdateProfileHandler_MethodNotAllowed tests that non-POST methods are rejected 609 + func TestUpdateProfileHandler_MethodNotAllowed(t *testing.T) { 610 + mockBlobService := new(MockBlobService) 611 + handler := NewUpdateProfileHandler(mockBlobService, nil) 612 + 613 + methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} 614 + 615 + for _, method := range methods { 616 + t.Run(method, func(t *testing.T) { 617 + req := httptest.NewRequest(method, "/xrpc/social.coves.actor.updateProfile", nil) 618 + 619 + testDID := "did:plc:testuser123" 620 + session := createTestOAuthSession(testDID) 621 + ctx := setTestOAuthSession(req.Context(), testDID, session) 622 + req = req.WithContext(ctx) 623 + 624 + w := httptest.NewRecorder() 625 + handler.ServeHTTP(w, req) 626 + 627 + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) 628 + }) 629 + } 630 + } 631 + 632 + // TestUpdateProfileHandler_AvatarBlobWithoutMimeType tests that providing blob without mime type fails 633 + func TestUpdateProfileHandler_AvatarBlobWithoutMimeType(t *testing.T) { 634 + mockBlobService := new(MockBlobService) 635 + handler := NewUpdateProfileHandler(mockBlobService, nil) 636 + 637 + reqBody := UpdateProfileRequest{ 638 + DisplayName: strPtr("Test User"), 639 + AvatarBlob: []byte("fake image data"), 640 + // Missing AvatarMimeType 641 + } 642 + body, _ := json.Marshal(reqBody) 643 + 644 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 645 + req.Header.Set("Content-Type", "application/json") 646 + 647 + testDID := "did:plc:testuser123" 648 + session := createTestOAuthSession(testDID) 649 + ctx := setTestOAuthSession(req.Context(), testDID, session) 650 + req = req.WithContext(ctx) 651 + 652 + w := httptest.NewRecorder() 653 + handler.ServeHTTP(w, req) 654 + 655 + assert.Equal(t, http.StatusBadRequest, w.Code) 656 + assert.Contains(t, w.Body.String(), "mime type") 657 + } 658 + 659 + // TestUpdateProfileHandler_BannerBlobWithoutMimeType tests that providing banner without mime type fails 660 + func TestUpdateProfileHandler_BannerBlobWithoutMimeType(t *testing.T) { 661 + mockBlobService := new(MockBlobService) 662 + handler := NewUpdateProfileHandler(mockBlobService, nil) 663 + 664 + reqBody := UpdateProfileRequest{ 665 + DisplayName: strPtr("Test User"), 666 + BannerBlob: []byte("fake image data"), 667 + // Missing BannerMimeType 668 + } 669 + body, _ := json.Marshal(reqBody) 670 + 671 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 672 + req.Header.Set("Content-Type", "application/json") 673 + 674 + testDID := "did:plc:testuser123" 675 + session := createTestOAuthSession(testDID) 676 + ctx := setTestOAuthSession(req.Context(), testDID, session) 677 + req = req.WithContext(ctx) 678 + 679 + w := httptest.NewRecorder() 680 + handler.ServeHTTP(w, req) 681 + 682 + assert.Equal(t, http.StatusBadRequest, w.Code) 683 + assert.Contains(t, w.Body.String(), "mime type") 684 + } 685 + 686 + // TestUpdateProfileHandler_UserBlobOwnerInterface tests that userBlobOwner correctly implements BlobOwner 687 + func TestUpdateProfileHandler_UserBlobOwnerInterface(t *testing.T) { 688 + owner := &userBlobOwner{ 689 + pdsURL: "https://test.pds.example", 690 + accessToken: "test-token-123", 691 + } 692 + 693 + // Verify interface compliance 694 + var _ blobs.BlobOwner = owner 695 + 696 + assert.Equal(t, "https://test.pds.example", owner.GetPDSURL()) 697 + assert.Equal(t, "test-token-123", owner.GetPDSAccessToken()) 698 + } 699 + 700 + // TestUpdateProfileHandler_EmptyRequest tests that empty request body is handled 701 + func TestUpdateProfileHandler_EmptyRequest(t *testing.T) { 702 + mockBlobService := new(MockBlobService) 703 + 704 + // Mock PDS server - even empty update should work 705 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 706 + w.Header().Set("Content-Type", "application/json") 707 + json.NewEncoder(w).Encode(map[string]interface{}{ 708 + "uri": "at://did:plc:testuser123/app.bsky.actor.profile/self", 709 + "cid": "bafyreicid123", 710 + }) 711 + })) 712 + defer mockPDS.Close() 713 + 714 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 715 + 716 + // Empty JSON object 717 + reqBody := UpdateProfileRequest{} 718 + body, _ := json.Marshal(reqBody) 719 + 720 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 721 + req.Header.Set("Content-Type", "application/json") 722 + 723 + testDID := "did:plc:testuser123" 724 + session := createTestOAuthSession(testDID) 725 + session.HostURL = mockPDS.URL 726 + ctx := setTestOAuthSession(req.Context(), testDID, session) 727 + req = req.WithContext(ctx) 728 + 729 + w := httptest.NewRecorder() 730 + handler.ServeHTTP(w, req) 731 + 732 + // Empty update is valid - just puts an empty profile record 733 + assert.Equal(t, http.StatusOK, w.Code) 734 + } 735 + 736 + // TestUpdateProfileHandler_PDSURLFromSession tests that PDS URL is correctly extracted from OAuth session 737 + func TestUpdateProfileHandler_PDSURLFromSession(t *testing.T) { 738 + mockBlobService := new(MockBlobService) 739 + 740 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 741 + // Verify request was received at the mock PDS 742 + assert.NotEmpty(t, r.URL.Path) 743 + w.Header().Set("Content-Type", "application/json") 744 + json.NewEncoder(w).Encode(map[string]interface{}{ 745 + "uri": "at://did:plc:testuser123/app.bsky.actor.profile/self", 746 + "cid": "bafyreicid123", 747 + }) 748 + })) 749 + defer mockPDS.Close() 750 + 751 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 752 + 753 + reqBody := UpdateProfileRequest{ 754 + DisplayName: strPtr("Test User"), 755 + } 756 + body, _ := json.Marshal(reqBody) 757 + 758 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 759 + req.Header.Set("Content-Type", "application/json") 760 + 761 + testDID := "did:plc:testuser123" 762 + session := createTestOAuthSession(testDID) 763 + // Use the mock server URL 764 + session.HostURL = mockPDS.URL 765 + ctx := setTestOAuthSession(req.Context(), testDID, session) 766 + req = req.WithContext(ctx) 767 + 768 + w := httptest.NewRecorder() 769 + handler.ServeHTTP(w, req) 770 + 771 + assert.Equal(t, http.StatusOK, w.Code) 772 + } 773 + 774 + // TestUpdateProfileHandler_AvatarExactly1MB tests boundary condition - avatar exactly 1MB should be accepted 775 + func TestUpdateProfileHandler_AvatarExactly1MB(t *testing.T) { 776 + mockBlobService := new(MockBlobService) 777 + 778 + // Create avatar blob exactly 1MB (1,000,000 bytes) 779 + avatarData := make([]byte, 1_000_000) 780 + 781 + expectedBlobRef := &blobs.BlobRef{ 782 + Type: "blob", 783 + Ref: map[string]string{"$link": "bafyreiabc123"}, 784 + MimeType: "image/jpeg", 785 + Size: len(avatarData), 786 + } 787 + mockBlobService.On("UploadBlob", mock.Anything, mock.Anything, avatarData, "image/jpeg"). 788 + Return(expectedBlobRef, nil) 789 + 790 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 791 + w.Header().Set("Content-Type", "application/json") 792 + json.NewEncoder(w).Encode(map[string]interface{}{ 793 + "uri": "at://did:plc:testuser123/app.bsky.actor.profile/self", 794 + "cid": "bafyreicid123", 795 + }) 796 + })) 797 + defer mockPDS.Close() 798 + 799 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 800 + 801 + reqBody := UpdateProfileRequest{ 802 + AvatarBlob: avatarData, 803 + AvatarMimeType: "image/jpeg", 804 + } 805 + body, _ := json.Marshal(reqBody) 806 + 807 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 808 + req.Header.Set("Content-Type", "application/json") 809 + 810 + testDID := "did:plc:testuser123" 811 + session := createTestOAuthSession(testDID) 812 + session.HostURL = mockPDS.URL 813 + ctx := setTestOAuthSession(req.Context(), testDID, session) 814 + req = req.WithContext(ctx) 815 + 816 + w := httptest.NewRecorder() 817 + handler.ServeHTTP(w, req) 818 + 819 + assert.Equal(t, http.StatusOK, w.Code) 820 + mockBlobService.AssertExpectations(t) 821 + } 822 + 823 + // TestUpdateProfileHandler_BannerExactly2MB tests boundary condition - banner exactly 2MB should be accepted 824 + func TestUpdateProfileHandler_BannerExactly2MB(t *testing.T) { 825 + mockBlobService := new(MockBlobService) 826 + 827 + // Create banner blob exactly 2MB (2,000,000 bytes) 828 + bannerData := make([]byte, 2_000_000) 829 + 830 + expectedBlobRef := &blobs.BlobRef{ 831 + Type: "blob", 832 + Ref: map[string]string{"$link": "bafyreiabc123"}, 833 + MimeType: "image/png", 834 + Size: len(bannerData), 835 + } 836 + mockBlobService.On("UploadBlob", mock.Anything, mock.Anything, bannerData, "image/png"). 837 + Return(expectedBlobRef, nil) 838 + 839 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 840 + w.Header().Set("Content-Type", "application/json") 841 + json.NewEncoder(w).Encode(map[string]interface{}{ 842 + "uri": "at://did:plc:testuser123/app.bsky.actor.profile/self", 843 + "cid": "bafyreicid123", 844 + }) 845 + })) 846 + defer mockPDS.Close() 847 + 848 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 849 + 850 + reqBody := UpdateProfileRequest{ 851 + BannerBlob: bannerData, 852 + BannerMimeType: "image/png", 853 + } 854 + body, _ := json.Marshal(reqBody) 855 + 856 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 857 + req.Header.Set("Content-Type", "application/json") 858 + 859 + testDID := "did:plc:testuser123" 860 + session := createTestOAuthSession(testDID) 861 + session.HostURL = mockPDS.URL 862 + ctx := setTestOAuthSession(req.Context(), testDID, session) 863 + req = req.WithContext(ctx) 864 + 865 + w := httptest.NewRecorder() 866 + handler.ServeHTTP(w, req) 867 + 868 + assert.Equal(t, http.StatusOK, w.Code) 869 + mockBlobService.AssertExpectations(t) 870 + } 871 + 872 + // TestUpdateProfileHandler_PDSNetworkError tests handling of network errors when calling PDS 873 + func TestUpdateProfileHandler_PDSNetworkError(t *testing.T) { 874 + mockBlobService := new(MockBlobService) 875 + 876 + // Create a handler with a client that will fail 877 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 878 + 879 + reqBody := UpdateProfileRequest{ 880 + DisplayName: strPtr("Test User"), 881 + } 882 + body, _ := json.Marshal(reqBody) 883 + 884 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 885 + req.Header.Set("Content-Type", "application/json") 886 + 887 + testDID := "did:plc:testuser123" 888 + session := createTestOAuthSession(testDID) 889 + // Use an invalid URL that will fail connection 890 + session.HostURL = "http://localhost:1" // Port 1 is typically refused 891 + ctx := setTestOAuthSession(req.Context(), testDID, session) 892 + req = req.WithContext(ctx) 893 + 894 + w := httptest.NewRecorder() 895 + handler.ServeHTTP(w, req) 896 + 897 + assert.Equal(t, http.StatusInternalServerError, w.Code) 898 + assert.Contains(t, w.Body.String(), "Failed to update profile") 899 + } 900 + 901 + // TestUpdateProfileHandler_ResponseFormat tests that response matches expected format 902 + func TestUpdateProfileHandler_ResponseFormat(t *testing.T) { 903 + mockBlobService := new(MockBlobService) 904 + 905 + expectedURI := "at://did:plc:testuser123/app.bsky.actor.profile/self" 906 + expectedCID := "bafyreicid456" 907 + 908 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 909 + w.Header().Set("Content-Type", "application/json") 910 + json.NewEncoder(w).Encode(map[string]interface{}{ 911 + "uri": expectedURI, 912 + "cid": expectedCID, 913 + }) 914 + })) 915 + defer mockPDS.Close() 916 + 917 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 918 + 919 + reqBody := UpdateProfileRequest{ 920 + DisplayName: strPtr("Test User"), 921 + } 922 + body, _ := json.Marshal(reqBody) 923 + 924 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 925 + req.Header.Set("Content-Type", "application/json") 926 + 927 + testDID := "did:plc:testuser123" 928 + session := createTestOAuthSession(testDID) 929 + session.HostURL = mockPDS.URL 930 + ctx := setTestOAuthSession(req.Context(), testDID, session) 931 + req = req.WithContext(ctx) 932 + 933 + w := httptest.NewRecorder() 934 + handler.ServeHTTP(w, req) 935 + 936 + assert.Equal(t, http.StatusOK, w.Code) 937 + 938 + var response UpdateProfileResponse 939 + err := json.Unmarshal(w.Body.Bytes(), &response) 940 + assert.NoError(t, err) 941 + assert.Equal(t, expectedURI, response.URI) 942 + assert.Equal(t, expectedCID, response.CID) 943 + } 944 + 945 + // Helper function to create string pointers 946 + func strPtr(s string) *string { 947 + return &s 948 + } 949 + 950 + // TestUserBlobOwner_ImplementsBlobOwnerInterface verifies interface compliance at compile time 951 + func TestUserBlobOwner_ImplementsBlobOwnerInterface(t *testing.T) { 952 + // This test ensures at compile time that userBlobOwner implements blobs.BlobOwner 953 + var owner blobs.BlobOwner = &userBlobOwner{ 954 + pdsURL: "https://test.example", 955 + accessToken: "token", 956 + } 957 + assert.NotNil(t, owner) 958 + } 959 + 960 + // TestUpdateProfileHandler_PDSReturnsEmptyURIOrCID tests handling when PDS returns 200 but with empty URI or CID 961 + func TestUpdateProfileHandler_PDSReturnsEmptyURIOrCID(t *testing.T) { 962 + testCases := []struct { 963 + name string 964 + response map[string]interface{} 965 + }{ 966 + { 967 + name: "empty URI", 968 + response: map[string]interface{}{ 969 + "uri": "", 970 + "cid": "bafyreicid123", 971 + }, 972 + }, 973 + { 974 + name: "empty CID", 975 + response: map[string]interface{}{ 976 + "uri": "at://did:plc:testuser123/app.bsky.actor.profile/self", 977 + "cid": "", 978 + }, 979 + }, 980 + { 981 + name: "missing URI", 982 + response: map[string]interface{}{ 983 + "cid": "bafyreicid123", 984 + }, 985 + }, 986 + { 987 + name: "missing CID", 988 + response: map[string]interface{}{ 989 + "uri": "at://did:plc:testuser123/app.bsky.actor.profile/self", 990 + }, 991 + }, 992 + { 993 + name: "both empty", 994 + response: map[string]interface{}{}, 995 + }, 996 + } 997 + 998 + for _, tc := range testCases { 999 + t.Run(tc.name, func(t *testing.T) { 1000 + mockBlobService := new(MockBlobService) 1001 + 1002 + // Mock PDS server that returns 200 but with empty/missing fields 1003 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1004 + w.Header().Set("Content-Type", "application/json") 1005 + w.WriteHeader(http.StatusOK) 1006 + json.NewEncoder(w).Encode(tc.response) 1007 + })) 1008 + defer mockPDS.Close() 1009 + 1010 + handler := NewUpdateProfileHandler(mockBlobService, http.DefaultClient) 1011 + 1012 + reqBody := UpdateProfileRequest{ 1013 + DisplayName: strPtr("Test User"), 1014 + } 1015 + body, _ := json.Marshal(reqBody) 1016 + 1017 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.updateProfile", bytes.NewReader(body)) 1018 + req.Header.Set("Content-Type", "application/json") 1019 + 1020 + testDID := "did:plc:testuser123" 1021 + session := createTestOAuthSession(testDID) 1022 + session.HostURL = mockPDS.URL 1023 + ctx := setTestOAuthSession(req.Context(), testDID, session) 1024 + req = req.WithContext(ctx) 1025 + 1026 + w := httptest.NewRecorder() 1027 + handler.ServeHTTP(w, req) 1028 + 1029 + // Should return an internal server error because URI/CID are required 1030 + assert.Equal(t, http.StatusInternalServerError, w.Code) 1031 + assert.Contains(t, w.Body.String(), "PDSError") 1032 + }) 1033 + } 1034 + } 1035 +
+816
internal/atproto/jetstream/user_consumer_test.go
··· 1 + package jetstream 2 + 3 + import ( 4 + "Coves/internal/atproto/identity" 5 + "Coves/internal/core/users" 6 + "context" 7 + "encoding/json" 8 + "errors" 9 + "testing" 10 + "time" 11 + ) 12 + 13 + // mockUserService is a test double for users.UserService 14 + type mockUserService struct { 15 + users map[string]*users.User 16 + updatedCalls []users.UpdateProfileInput 17 + updatedDIDs []string 18 + shouldFailGet bool 19 + getError error 20 + updateError error 21 + } 22 + 23 + func newMockUserService() *mockUserService { 24 + return &mockUserService{ 25 + users: make(map[string]*users.User), 26 + updatedCalls: []users.UpdateProfileInput{}, 27 + updatedDIDs: []string{}, 28 + } 29 + } 30 + 31 + func (m *mockUserService) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) { 32 + return nil, nil 33 + } 34 + 35 + func (m *mockUserService) GetUserByDID(ctx context.Context, did string) (*users.User, error) { 36 + if m.shouldFailGet { 37 + return nil, m.getError 38 + } 39 + user, exists := m.users[did] 40 + if !exists { 41 + return nil, users.ErrUserNotFound 42 + } 43 + return user, nil 44 + } 45 + 46 + func (m *mockUserService) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) { 47 + return nil, nil 48 + } 49 + 50 + func (m *mockUserService) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) { 51 + return nil, nil 52 + } 53 + 54 + func (m *mockUserService) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { 55 + return "", nil 56 + } 57 + 58 + func (m *mockUserService) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) { 59 + return nil, nil 60 + } 61 + 62 + func (m *mockUserService) IndexUser(ctx context.Context, did, handle, pdsURL string) error { 63 + return nil 64 + } 65 + 66 + func (m *mockUserService) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) { 67 + return nil, nil 68 + } 69 + 70 + func (m *mockUserService) UpdateProfile(ctx context.Context, did string, input users.UpdateProfileInput) (*users.User, error) { 71 + if m.updateError != nil { 72 + return nil, m.updateError 73 + } 74 + m.updatedCalls = append(m.updatedCalls, input) 75 + m.updatedDIDs = append(m.updatedDIDs, did) 76 + user := m.users[did] 77 + if user == nil { 78 + return nil, users.ErrUserNotFound 79 + } 80 + // Apply updates to mock user 81 + if input.DisplayName != nil { 82 + user.DisplayName = *input.DisplayName 83 + } 84 + if input.Bio != nil { 85 + user.Bio = *input.Bio 86 + } 87 + if input.AvatarCID != nil { 88 + user.AvatarCID = *input.AvatarCID 89 + } 90 + if input.BannerCID != nil { 91 + user.BannerCID = *input.BannerCID 92 + } 93 + return user, nil 94 + } 95 + 96 + func (m *mockUserService) DeleteAccount(ctx context.Context, did string) error { 97 + return nil 98 + } 99 + 100 + // mockIdentityResolverForUser is a test double for identity.Resolver 101 + type mockIdentityResolverForUser struct{} 102 + 103 + func (m *mockIdentityResolverForUser) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 104 + return nil, nil 105 + } 106 + 107 + func (m *mockIdentityResolverForUser) ResolveHandle(ctx context.Context, handle string) (string, string, error) { 108 + return "", "", nil 109 + } 110 + 111 + func (m *mockIdentityResolverForUser) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 112 + return nil, nil 113 + } 114 + 115 + func (m *mockIdentityResolverForUser) Purge(ctx context.Context, identifier string) error { 116 + return nil 117 + } 118 + 119 + func TestUserConsumer_HandleProfileCommit(t *testing.T) { 120 + t.Run("ignores commits for unknown collections", func(t *testing.T) { 121 + mockService := newMockUserService() 122 + mockResolver := &mockIdentityResolverForUser{} 123 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 124 + ctx := context.Background() 125 + 126 + // Event with a non-profile collection (e.g., social.coves.post) 127 + event := &JetstreamEvent{ 128 + Did: "did:plc:testuser123", 129 + TimeUS: time.Now().UnixMicro(), 130 + Kind: "commit", 131 + Commit: &CommitEvent{ 132 + Rev: "rev123", 133 + Operation: "create", 134 + Collection: "social.coves.post", // Not app.bsky.actor.profile 135 + RKey: "post123", 136 + CID: "bafy123", 137 + Record: map[string]interface{}{ 138 + "text": "Hello world", 139 + }, 140 + }, 141 + } 142 + 143 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 144 + if err != nil { 145 + t.Errorf("Expected no error for unknown collection, got: %v", err) 146 + } 147 + 148 + // Verify no UpdateProfile calls were made 149 + if len(mockService.updatedCalls) != 0 { 150 + t.Errorf("Expected 0 UpdateProfile calls, got %d", len(mockService.updatedCalls)) 151 + } 152 + }) 153 + 154 + t.Run("ignores commits for users not in database", func(t *testing.T) { 155 + mockService := newMockUserService() 156 + // Don't add any users - the user lookup will fail 157 + mockResolver := &mockIdentityResolverForUser{} 158 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 159 + ctx := context.Background() 160 + 161 + event := &JetstreamEvent{ 162 + Did: "did:plc:unknownuser", 163 + TimeUS: time.Now().UnixMicro(), 164 + Kind: "commit", 165 + Commit: &CommitEvent{ 166 + Rev: "rev123", 167 + Operation: "create", 168 + Collection: "app.bsky.actor.profile", 169 + RKey: "self", 170 + CID: "bafy123", 171 + Record: map[string]interface{}{ 172 + "displayName": "Unknown User", 173 + }, 174 + }, 175 + } 176 + 177 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 178 + // Should return nil (not an error) for users not in our database 179 + if err != nil { 180 + t.Errorf("Expected nil error for unknown user, got: %v", err) 181 + } 182 + 183 + // Verify no UpdateProfile calls were made 184 + if len(mockService.updatedCalls) != 0 { 185 + t.Errorf("Expected 0 UpdateProfile calls, got %d", len(mockService.updatedCalls)) 186 + } 187 + }) 188 + 189 + t.Run("extracts displayName from record", func(t *testing.T) { 190 + mockService := newMockUserService() 191 + mockService.users["did:plc:testuser"] = &users.User{ 192 + DID: "did:plc:testuser", 193 + Handle: "testuser.bsky.social", 194 + PDSURL: "https://bsky.social", 195 + } 196 + mockResolver := &mockIdentityResolverForUser{} 197 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 198 + ctx := context.Background() 199 + 200 + event := &JetstreamEvent{ 201 + Did: "did:plc:testuser", 202 + TimeUS: time.Now().UnixMicro(), 203 + Kind: "commit", 204 + Commit: &CommitEvent{ 205 + Rev: "rev123", 206 + Operation: "create", 207 + Collection: "app.bsky.actor.profile", 208 + RKey: "self", 209 + CID: "bafy123", 210 + Record: map[string]interface{}{ 211 + "displayName": "Test Display Name", 212 + }, 213 + }, 214 + } 215 + 216 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 217 + if err != nil { 218 + t.Fatalf("Expected no error, got: %v", err) 219 + } 220 + 221 + if len(mockService.updatedCalls) != 1 { 222 + t.Fatalf("Expected 1 UpdateProfile call, got %d", len(mockService.updatedCalls)) 223 + } 224 + 225 + call := mockService.updatedCalls[0] 226 + if call.DisplayName == nil || *call.DisplayName != "Test Display Name" { 227 + t.Errorf("Expected displayName 'Test Display Name', got %v", call.DisplayName) 228 + } 229 + }) 230 + 231 + t.Run("extracts description (bio) from record", func(t *testing.T) { 232 + mockService := newMockUserService() 233 + mockService.users["did:plc:testuser"] = &users.User{ 234 + DID: "did:plc:testuser", 235 + Handle: "testuser.bsky.social", 236 + PDSURL: "https://bsky.social", 237 + } 238 + mockResolver := &mockIdentityResolverForUser{} 239 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 240 + ctx := context.Background() 241 + 242 + event := &JetstreamEvent{ 243 + Did: "did:plc:testuser", 244 + TimeUS: time.Now().UnixMicro(), 245 + Kind: "commit", 246 + Commit: &CommitEvent{ 247 + Rev: "rev123", 248 + Operation: "create", 249 + Collection: "app.bsky.actor.profile", 250 + RKey: "self", 251 + CID: "bafy123", 252 + Record: map[string]interface{}{ 253 + "description": "This is my bio", 254 + }, 255 + }, 256 + } 257 + 258 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 259 + if err != nil { 260 + t.Fatalf("Expected no error, got: %v", err) 261 + } 262 + 263 + if len(mockService.updatedCalls) != 1 { 264 + t.Fatalf("Expected 1 UpdateProfile call, got %d", len(mockService.updatedCalls)) 265 + } 266 + 267 + call := mockService.updatedCalls[0] 268 + if call.Bio == nil || *call.Bio != "This is my bio" { 269 + t.Errorf("Expected bio 'This is my bio', got %v", call.Bio) 270 + } 271 + }) 272 + 273 + t.Run("extracts avatar CID from blob ref structure", func(t *testing.T) { 274 + mockService := newMockUserService() 275 + mockService.users["did:plc:testuser"] = &users.User{ 276 + DID: "did:plc:testuser", 277 + Handle: "testuser.bsky.social", 278 + PDSURL: "https://bsky.social", 279 + } 280 + mockResolver := &mockIdentityResolverForUser{} 281 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 282 + ctx := context.Background() 283 + 284 + event := &JetstreamEvent{ 285 + Did: "did:plc:testuser", 286 + TimeUS: time.Now().UnixMicro(), 287 + Kind: "commit", 288 + Commit: &CommitEvent{ 289 + Rev: "rev123", 290 + Operation: "create", 291 + Collection: "app.bsky.actor.profile", 292 + RKey: "self", 293 + CID: "bafy123", 294 + Record: map[string]interface{}{ 295 + "avatar": map[string]interface{}{ 296 + "$type": "blob", 297 + "ref": map[string]interface{}{"$link": "bafkavatar123"}, 298 + "mimeType": "image/jpeg", 299 + "size": float64(12345), 300 + }, 301 + }, 302 + }, 303 + } 304 + 305 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 306 + if err != nil { 307 + t.Fatalf("Expected no error, got: %v", err) 308 + } 309 + 310 + if len(mockService.updatedCalls) != 1 { 311 + t.Fatalf("Expected 1 UpdateProfile call, got %d", len(mockService.updatedCalls)) 312 + } 313 + 314 + call := mockService.updatedCalls[0] 315 + if call.AvatarCID == nil || *call.AvatarCID != "bafkavatar123" { 316 + t.Errorf("Expected avatar CID 'bafkavatar123', got %v", call.AvatarCID) 317 + } 318 + }) 319 + 320 + t.Run("extracts banner CID from blob ref structure", func(t *testing.T) { 321 + mockService := newMockUserService() 322 + mockService.users["did:plc:testuser"] = &users.User{ 323 + DID: "did:plc:testuser", 324 + Handle: "testuser.bsky.social", 325 + PDSURL: "https://bsky.social", 326 + } 327 + mockResolver := &mockIdentityResolverForUser{} 328 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 329 + ctx := context.Background() 330 + 331 + event := &JetstreamEvent{ 332 + Did: "did:plc:testuser", 333 + TimeUS: time.Now().UnixMicro(), 334 + Kind: "commit", 335 + Commit: &CommitEvent{ 336 + Rev: "rev123", 337 + Operation: "create", 338 + Collection: "app.bsky.actor.profile", 339 + RKey: "self", 340 + CID: "bafy123", 341 + Record: map[string]interface{}{ 342 + "banner": map[string]interface{}{ 343 + "$type": "blob", 344 + "ref": map[string]interface{}{"$link": "bafkbanner456"}, 345 + "mimeType": "image/png", 346 + "size": float64(54321), 347 + }, 348 + }, 349 + }, 350 + } 351 + 352 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 353 + if err != nil { 354 + t.Fatalf("Expected no error, got: %v", err) 355 + } 356 + 357 + if len(mockService.updatedCalls) != 1 { 358 + t.Fatalf("Expected 1 UpdateProfile call, got %d", len(mockService.updatedCalls)) 359 + } 360 + 361 + call := mockService.updatedCalls[0] 362 + if call.BannerCID == nil || *call.BannerCID != "bafkbanner456" { 363 + t.Errorf("Expected banner CID 'bafkbanner456', got %v", call.BannerCID) 364 + } 365 + }) 366 + 367 + t.Run("extracts all profile fields together", func(t *testing.T) { 368 + mockService := newMockUserService() 369 + mockService.users["did:plc:testuser"] = &users.User{ 370 + DID: "did:plc:testuser", 371 + Handle: "testuser.bsky.social", 372 + PDSURL: "https://bsky.social", 373 + } 374 + mockResolver := &mockIdentityResolverForUser{} 375 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 376 + ctx := context.Background() 377 + 378 + event := &JetstreamEvent{ 379 + Did: "did:plc:testuser", 380 + TimeUS: time.Now().UnixMicro(), 381 + Kind: "commit", 382 + Commit: &CommitEvent{ 383 + Rev: "rev123", 384 + Operation: "create", 385 + Collection: "app.bsky.actor.profile", 386 + RKey: "self", 387 + CID: "bafy123", 388 + Record: map[string]interface{}{ 389 + "displayName": "Full Profile User", 390 + "description": "A complete bio", 391 + "avatar": map[string]interface{}{ 392 + "$type": "blob", 393 + "ref": map[string]interface{}{"$link": "bafkfullav123"}, 394 + "mimeType": "image/jpeg", 395 + "size": float64(10000), 396 + }, 397 + "banner": map[string]interface{}{ 398 + "$type": "blob", 399 + "ref": map[string]interface{}{"$link": "bafkfullbn456"}, 400 + "mimeType": "image/png", 401 + "size": float64(20000), 402 + }, 403 + }, 404 + }, 405 + } 406 + 407 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 408 + if err != nil { 409 + t.Fatalf("Expected no error, got: %v", err) 410 + } 411 + 412 + if len(mockService.updatedCalls) != 1 { 413 + t.Fatalf("Expected 1 UpdateProfile call, got %d", len(mockService.updatedCalls)) 414 + } 415 + 416 + call := mockService.updatedCalls[0] 417 + if call.DisplayName == nil || *call.DisplayName != "Full Profile User" { 418 + t.Errorf("Expected displayName 'Full Profile User', got %v", call.DisplayName) 419 + } 420 + if call.Bio == nil || *call.Bio != "A complete bio" { 421 + t.Errorf("Expected bio 'A complete bio', got %v", call.Bio) 422 + } 423 + if call.AvatarCID == nil || *call.AvatarCID != "bafkfullav123" { 424 + t.Errorf("Expected avatar CID 'bafkfullav123', got %v", call.AvatarCID) 425 + } 426 + if call.BannerCID == nil || *call.BannerCID != "bafkfullbn456" { 427 + t.Errorf("Expected banner CID 'bafkfullbn456', got %v", call.BannerCID) 428 + } 429 + }) 430 + 431 + t.Run("handles delete operation by clearing profile fields", func(t *testing.T) { 432 + mockService := newMockUserService() 433 + mockService.users["did:plc:testuser"] = &users.User{ 434 + DID: "did:plc:testuser", 435 + Handle: "testuser.bsky.social", 436 + PDSURL: "https://bsky.social", 437 + DisplayName: "Existing Name", 438 + Bio: "Existing Bio", 439 + AvatarCID: "existingavatar", 440 + BannerCID: "existingbanner", 441 + } 442 + mockResolver := &mockIdentityResolverForUser{} 443 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 444 + ctx := context.Background() 445 + 446 + event := &JetstreamEvent{ 447 + Did: "did:plc:testuser", 448 + TimeUS: time.Now().UnixMicro(), 449 + Kind: "commit", 450 + Commit: &CommitEvent{ 451 + Rev: "rev123", 452 + Operation: "delete", 453 + Collection: "app.bsky.actor.profile", 454 + RKey: "self", 455 + }, 456 + } 457 + 458 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 459 + if err != nil { 460 + t.Fatalf("Expected no error, got: %v", err) 461 + } 462 + 463 + if len(mockService.updatedCalls) != 1 { 464 + t.Fatalf("Expected 1 UpdateProfile call, got %d", len(mockService.updatedCalls)) 465 + } 466 + 467 + call := mockService.updatedCalls[0] 468 + // Delete should pass empty strings to clear fields 469 + if call.DisplayName == nil || *call.DisplayName != "" { 470 + t.Errorf("Expected empty displayName for delete, got %v", call.DisplayName) 471 + } 472 + if call.Bio == nil || *call.Bio != "" { 473 + t.Errorf("Expected empty bio for delete, got %v", call.Bio) 474 + } 475 + if call.AvatarCID == nil || *call.AvatarCID != "" { 476 + t.Errorf("Expected empty avatar CID for delete, got %v", call.AvatarCID) 477 + } 478 + if call.BannerCID == nil || *call.BannerCID != "" { 479 + t.Errorf("Expected empty banner CID for delete, got %v", call.BannerCID) 480 + } 481 + }) 482 + 483 + t.Run("handles update operation same as create", func(t *testing.T) { 484 + mockService := newMockUserService() 485 + mockService.users["did:plc:testuser"] = &users.User{ 486 + DID: "did:plc:testuser", 487 + Handle: "testuser.bsky.social", 488 + PDSURL: "https://bsky.social", 489 + DisplayName: "Old Name", 490 + } 491 + mockResolver := &mockIdentityResolverForUser{} 492 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 493 + ctx := context.Background() 494 + 495 + event := &JetstreamEvent{ 496 + Did: "did:plc:testuser", 497 + TimeUS: time.Now().UnixMicro(), 498 + Kind: "commit", 499 + Commit: &CommitEvent{ 500 + Rev: "rev124", 501 + Operation: "update", // Update operation instead of create 502 + Collection: "app.bsky.actor.profile", 503 + RKey: "self", 504 + CID: "bafy456", 505 + Record: map[string]interface{}{ 506 + "displayName": "Updated Name", 507 + "description": "Updated bio", 508 + }, 509 + }, 510 + } 511 + 512 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 513 + if err != nil { 514 + t.Fatalf("Expected no error, got: %v", err) 515 + } 516 + 517 + if len(mockService.updatedCalls) != 1 { 518 + t.Fatalf("Expected 1 UpdateProfile call, got %d", len(mockService.updatedCalls)) 519 + } 520 + 521 + call := mockService.updatedCalls[0] 522 + if call.DisplayName == nil || *call.DisplayName != "Updated Name" { 523 + t.Errorf("Expected displayName 'Updated Name', got %v", call.DisplayName) 524 + } 525 + if call.Bio == nil || *call.Bio != "Updated bio" { 526 + t.Errorf("Expected bio 'Updated bio', got %v", call.Bio) 527 + } 528 + }) 529 + 530 + t.Run("propagates database errors from GetUserByDID", func(t *testing.T) { 531 + mockService := newMockUserService() 532 + mockService.shouldFailGet = true 533 + mockService.getError = errors.New("database connection error") 534 + mockResolver := &mockIdentityResolverForUser{} 535 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 536 + ctx := context.Background() 537 + 538 + event := &JetstreamEvent{ 539 + Did: "did:plc:testuser", 540 + TimeUS: time.Now().UnixMicro(), 541 + Kind: "commit", 542 + Commit: &CommitEvent{ 543 + Rev: "rev123", 544 + Operation: "create", 545 + Collection: "app.bsky.actor.profile", 546 + RKey: "self", 547 + CID: "bafy123", 548 + Record: map[string]interface{}{ 549 + "displayName": "Test User", 550 + }, 551 + }, 552 + } 553 + 554 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 555 + if err == nil { 556 + t.Fatal("Expected error for database failure, got nil") 557 + } 558 + if !errors.Is(err, mockService.getError) && err.Error() != "failed to check if user exists: database connection error" { 559 + t.Errorf("Expected wrapped database error, got: %v", err) 560 + } 561 + }) 562 + 563 + t.Run("handles nil commit gracefully", func(t *testing.T) { 564 + mockService := newMockUserService() 565 + mockResolver := &mockIdentityResolverForUser{} 566 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 567 + ctx := context.Background() 568 + 569 + event := &JetstreamEvent{ 570 + Did: "did:plc:testuser", 571 + TimeUS: time.Now().UnixMicro(), 572 + Kind: "commit", 573 + Commit: nil, // No commit data 574 + } 575 + 576 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 577 + if err != nil { 578 + t.Errorf("Expected no error for nil commit, got: %v", err) 579 + } 580 + }) 581 + 582 + t.Run("handles nil record in commit gracefully", func(t *testing.T) { 583 + mockService := newMockUserService() 584 + mockService.users["did:plc:testuser"] = &users.User{ 585 + DID: "did:plc:testuser", 586 + Handle: "testuser.bsky.social", 587 + PDSURL: "https://bsky.social", 588 + } 589 + mockResolver := &mockIdentityResolverForUser{} 590 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 591 + ctx := context.Background() 592 + 593 + event := &JetstreamEvent{ 594 + Did: "did:plc:testuser", 595 + TimeUS: time.Now().UnixMicro(), 596 + Kind: "commit", 597 + Commit: &CommitEvent{ 598 + Rev: "rev123", 599 + Operation: "create", 600 + Collection: "app.bsky.actor.profile", 601 + RKey: "self", 602 + CID: "bafy123", 603 + Record: nil, // No record data 604 + }, 605 + } 606 + 607 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 608 + if err != nil { 609 + t.Errorf("Expected no error for nil record, got: %v", err) 610 + } 611 + }) 612 + 613 + t.Run("handles invalid blob structure gracefully", func(t *testing.T) { 614 + mockService := newMockUserService() 615 + mockService.users["did:plc:testuser"] = &users.User{ 616 + DID: "did:plc:testuser", 617 + Handle: "testuser.bsky.social", 618 + PDSURL: "https://bsky.social", 619 + } 620 + mockResolver := &mockIdentityResolverForUser{} 621 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 622 + ctx := context.Background() 623 + 624 + event := &JetstreamEvent{ 625 + Did: "did:plc:testuser", 626 + TimeUS: time.Now().UnixMicro(), 627 + Kind: "commit", 628 + Commit: &CommitEvent{ 629 + Rev: "rev123", 630 + Operation: "create", 631 + Collection: "app.bsky.actor.profile", 632 + RKey: "self", 633 + CID: "bafy123", 634 + Record: map[string]interface{}{ 635 + "displayName": "Test User", 636 + "avatar": map[string]interface{}{ 637 + "$type": "not-a-blob", // Invalid type 638 + }, 639 + "banner": "not-a-map", // Invalid structure 640 + }, 641 + }, 642 + } 643 + 644 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 645 + if err != nil { 646 + t.Fatalf("Expected no error, got: %v", err) 647 + } 648 + 649 + if len(mockService.updatedCalls) != 1 { 650 + t.Fatalf("Expected 1 UpdateProfile call, got %d", len(mockService.updatedCalls)) 651 + } 652 + 653 + call := mockService.updatedCalls[0] 654 + // displayName should be extracted 655 + if call.DisplayName == nil || *call.DisplayName != "Test User" { 656 + t.Errorf("Expected displayName 'Test User', got %v", call.DisplayName) 657 + } 658 + // Avatar and banner should be nil (not extracted due to invalid structure) 659 + if call.AvatarCID != nil { 660 + t.Errorf("Expected nil avatar CID for invalid blob, got %v", call.AvatarCID) 661 + } 662 + if call.BannerCID != nil { 663 + t.Errorf("Expected nil banner CID for invalid structure, got %v", call.BannerCID) 664 + } 665 + }) 666 + } 667 + 668 + func TestUserConsumer_PropagatesUpdateProfileError(t *testing.T) { 669 + t.Run("propagates_database_errors_from_UpdateProfile", func(t *testing.T) { 670 + mockService := newMockUserService() 671 + mockService.users["did:plc:testuser"] = &users.User{ 672 + DID: "did:plc:testuser", 673 + Handle: "testuser.bsky.social", 674 + PDSURL: "https://bsky.social", 675 + } 676 + mockService.updateError = errors.New("database write error") 677 + mockResolver := &mockIdentityResolverForUser{} 678 + consumer := NewUserEventConsumer(mockService, mockResolver, "wss://jetstream.example.com", "") 679 + ctx := context.Background() 680 + 681 + event := &JetstreamEvent{ 682 + Did: "did:plc:testuser", 683 + TimeUS: time.Now().UnixMicro(), 684 + Kind: "commit", 685 + Commit: &CommitEvent{ 686 + Rev: "rev123", 687 + Operation: "create", 688 + Collection: "app.bsky.actor.profile", 689 + RKey: "self", 690 + CID: "bafy123", 691 + Record: map[string]interface{}{ 692 + "displayName": "Test User", 693 + }, 694 + }, 695 + } 696 + 697 + err := consumer.handleEvent(ctx, mustMarshalEvent(event)) 698 + if err == nil { 699 + t.Fatal("Expected error for UpdateProfile failure, got nil") 700 + } 701 + if !errors.Is(err, mockService.updateError) && err.Error() != "failed to update user profile: database write error" { 702 + t.Errorf("Expected wrapped database error, got: %v", err) 703 + } 704 + }) 705 + } 706 + 707 + func TestExtractBlobCID(t *testing.T) { 708 + t.Run("extracts CID from valid blob structure", func(t *testing.T) { 709 + blob := map[string]interface{}{ 710 + "$type": "blob", 711 + "ref": map[string]interface{}{"$link": "bafktest123"}, 712 + "mimeType": "image/jpeg", 713 + "size": float64(12345), 714 + } 715 + 716 + cid, ok := extractBlobCID(blob) 717 + if !ok { 718 + t.Fatal("Expected successful extraction") 719 + } 720 + if cid != "bafktest123" { 721 + t.Errorf("Expected CID 'bafktest123', got '%s'", cid) 722 + } 723 + }) 724 + 725 + t.Run("returns false for nil blob", func(t *testing.T) { 726 + cid, ok := extractBlobCID(nil) 727 + if ok { 728 + t.Error("Expected false for nil blob") 729 + } 730 + if cid != "" { 731 + t.Errorf("Expected empty CID for nil blob, got '%s'", cid) 732 + } 733 + }) 734 + 735 + t.Run("returns false for wrong $type", func(t *testing.T) { 736 + blob := map[string]interface{}{ 737 + "$type": "image", 738 + "ref": map[string]interface{}{"$link": "bafktest123"}, 739 + } 740 + 741 + cid, ok := extractBlobCID(blob) 742 + if ok { 743 + t.Error("Expected false for wrong $type") 744 + } 745 + if cid != "" { 746 + t.Errorf("Expected empty CID for wrong type, got '%s'", cid) 747 + } 748 + }) 749 + 750 + t.Run("returns false for missing $type", func(t *testing.T) { 751 + blob := map[string]interface{}{ 752 + "ref": map[string]interface{}{"$link": "bafktest123"}, 753 + } 754 + 755 + cid, ok := extractBlobCID(blob) 756 + if ok { 757 + t.Error("Expected false for missing $type") 758 + } 759 + if cid != "" { 760 + t.Errorf("Expected empty CID for missing type, got '%s'", cid) 761 + } 762 + }) 763 + 764 + t.Run("returns false for missing ref", func(t *testing.T) { 765 + blob := map[string]interface{}{ 766 + "$type": "blob", 767 + } 768 + 769 + cid, ok := extractBlobCID(blob) 770 + if ok { 771 + t.Error("Expected false for missing ref") 772 + } 773 + if cid != "" { 774 + t.Errorf("Expected empty CID for missing ref, got '%s'", cid) 775 + } 776 + }) 777 + 778 + t.Run("returns false for missing $link", func(t *testing.T) { 779 + blob := map[string]interface{}{ 780 + "$type": "blob", 781 + "ref": map[string]interface{}{}, 782 + } 783 + 784 + cid, ok := extractBlobCID(blob) 785 + if ok { 786 + t.Error("Expected false for missing $link") 787 + } 788 + if cid != "" { 789 + t.Errorf("Expected empty CID for missing link, got '%s'", cid) 790 + } 791 + }) 792 + 793 + t.Run("returns false for non-map ref", func(t *testing.T) { 794 + blob := map[string]interface{}{ 795 + "$type": "blob", 796 + "ref": "not-a-map", 797 + } 798 + 799 + cid, ok := extractBlobCID(blob) 800 + if ok { 801 + t.Error("Expected false for non-map ref") 802 + } 803 + if cid != "" { 804 + t.Errorf("Expected empty CID for non-map ref, got '%s'", cid) 805 + } 806 + }) 807 + } 808 + 809 + // mustMarshalEvent marshals an event to JSON bytes for testing 810 + func mustMarshalEvent(event *JetstreamEvent) []byte { 811 + data, err := json.Marshal(event) 812 + if err != nil { 813 + panic(err) 814 + } 815 + return data 816 + }
+11
internal/db/migrations/027_add_user_profile_fields.sql
··· 1 + -- +goose Up 2 + ALTER TABLE users ADD COLUMN display_name TEXT CHECK (display_name IS NULL OR length(display_name) <= 64); 3 + ALTER TABLE users ADD COLUMN bio TEXT CHECK (bio IS NULL OR length(bio) <= 256); 4 + ALTER TABLE users ADD COLUMN avatar_cid TEXT; 5 + ALTER TABLE users ADD COLUMN banner_cid TEXT; 6 + 7 + -- +goose Down 8 + ALTER TABLE users DROP COLUMN IF EXISTS banner_cid; 9 + ALTER TABLE users DROP COLUMN IF EXISTS avatar_cid; 10 + ALTER TABLE users DROP COLUMN IF EXISTS bio; 11 + ALTER TABLE users DROP COLUMN IF EXISTS display_name;
+104 -8
internal/db/postgres/user_repo.go
··· 48 48 // GetByDID retrieves a user by their DID 49 49 func (r *postgresUserRepo) GetByDID(ctx context.Context, did string) (*users.User, error) { 50 50 user := &users.User{} 51 - query := `SELECT did, handle, pds_url, created_at, updated_at FROM users WHERE did = $1` 51 + query := `SELECT did, handle, pds_url, created_at, updated_at, display_name, bio, avatar_cid, banner_cid FROM users WHERE did = $1` 52 52 53 + var displayName, bio, avatarCID, bannerCID sql.NullString 53 54 err := r.db.QueryRowContext(ctx, query, did). 54 - Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt) 55 + Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt, 56 + &displayName, &bio, &avatarCID, &bannerCID) 55 57 56 58 if err == sql.ErrNoRows { 57 59 return nil, users.ErrUserNotFound ··· 60 62 return nil, fmt.Errorf("failed to get user by DID: %w", err) 61 63 } 62 64 65 + user.DisplayName = displayName.String 66 + user.Bio = bio.String 67 + user.AvatarCID = avatarCID.String 68 + user.BannerCID = bannerCID.String 69 + 63 70 return user, nil 64 71 } 65 72 66 73 // GetByHandle retrieves a user by their handle 67 74 func (r *postgresUserRepo) GetByHandle(ctx context.Context, handle string) (*users.User, error) { 68 75 user := &users.User{} 69 - query := `SELECT did, handle, pds_url, created_at, updated_at FROM users WHERE handle = $1` 76 + query := `SELECT did, handle, pds_url, created_at, updated_at, display_name, bio, avatar_cid, banner_cid FROM users WHERE handle = $1` 70 77 78 + var displayName, bio, avatarCID, bannerCID sql.NullString 71 79 err := r.db.QueryRowContext(ctx, query, handle). 72 - Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt) 80 + Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt, 81 + &displayName, &bio, &avatarCID, &bannerCID) 73 82 74 83 if err == sql.ErrNoRows { 75 84 return nil, users.ErrUserNotFound ··· 78 87 return nil, fmt.Errorf("failed to get user by handle: %w", err) 79 88 } 80 89 90 + user.DisplayName = displayName.String 91 + user.Bio = bio.String 92 + user.AvatarCID = avatarCID.String 93 + user.BannerCID = bannerCID.String 94 + 81 95 return user, nil 82 96 } 83 97 ··· 88 102 UPDATE users 89 103 SET handle = $2, updated_at = NOW() 90 104 WHERE did = $1 91 - RETURNING did, handle, pds_url, created_at, updated_at` 105 + RETURNING did, handle, pds_url, created_at, updated_at, display_name, bio, avatar_cid, banner_cid` 92 106 107 + var displayName, bio, avatarCID, bannerCID sql.NullString 93 108 err := r.db.QueryRowContext(ctx, query, did, newHandle). 94 - Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt) 109 + Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt, 110 + &displayName, &bio, &avatarCID, &bannerCID) 95 111 96 112 if err == sql.ErrNoRows { 97 113 return nil, users.ErrUserNotFound ··· 104 120 return nil, fmt.Errorf("failed to update handle: %w", err) 105 121 } 106 122 123 + user.DisplayName = displayName.String 124 + user.Bio = bio.String 125 + user.AvatarCID = avatarCID.String 126 + user.BannerCID = bannerCID.String 127 + 107 128 return user, nil 108 129 } 109 130 ··· 132 153 133 154 // Build parameterized query with IN clause 134 155 // Use ANY($1) for PostgreSQL array support with pq.Array() for type conversion 135 - query := `SELECT did, handle, pds_url, created_at, updated_at FROM users WHERE did = ANY($1)` 156 + query := `SELECT did, handle, pds_url, created_at, updated_at, display_name, bio, avatar_cid, banner_cid FROM users WHERE did = ANY($1)` 136 157 137 158 rows, err := r.db.QueryContext(ctx, query, pq.Array(dids)) 138 159 if err != nil { ··· 148 169 result := make(map[string]*users.User, len(dids)) 149 170 for rows.Next() { 150 171 user := &users.User{} 151 - err := rows.Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt) 172 + var displayName, bio, avatarCID, bannerCID sql.NullString 173 + err := rows.Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt, 174 + &displayName, &bio, &avatarCID, &bannerCID) 152 175 if err != nil { 153 176 return nil, fmt.Errorf("failed to scan user row: %w", err) 154 177 } 178 + user.DisplayName = displayName.String 179 + user.Bio = bio.String 180 + user.AvatarCID = avatarCID.String 181 + user.BannerCID = bannerCID.String 155 182 result[user.DID] = user 156 183 } 157 184 ··· 283 310 284 311 return nil 285 312 } 313 + 314 + // UpdateProfile updates a user's profile fields (display name, bio, avatar, banner). 315 + // Nil values in the input mean "don't change this field" - only non-nil values are updated. 316 + // Empty string values will clear the field in the database. 317 + // Returns the updated user with all fields populated. 318 + // Returns ErrUserNotFound if the user does not exist. 319 + func (r *postgresUserRepo) UpdateProfile(ctx context.Context, did string, input users.UpdateProfileInput) (*users.User, error) { 320 + // Validate DID format 321 + if !strings.HasPrefix(did, "did:") { 322 + return nil, &users.InvalidDIDError{DID: did, Reason: "must start with 'did:'"} 323 + } 324 + 325 + // Build dynamic UPDATE query based on which fields are provided 326 + setClauses := []string{"updated_at = NOW()"} 327 + args := []interface{}{} 328 + argNum := 1 329 + 330 + if input.DisplayName != nil { 331 + setClauses = append(setClauses, fmt.Sprintf("display_name = $%d", argNum)) 332 + args = append(args, *input.DisplayName) 333 + argNum++ 334 + } 335 + if input.Bio != nil { 336 + setClauses = append(setClauses, fmt.Sprintf("bio = $%d", argNum)) 337 + args = append(args, *input.Bio) 338 + argNum++ 339 + } 340 + if input.AvatarCID != nil { 341 + setClauses = append(setClauses, fmt.Sprintf("avatar_cid = $%d", argNum)) 342 + args = append(args, *input.AvatarCID) 343 + argNum++ 344 + } 345 + if input.BannerCID != nil { 346 + setClauses = append(setClauses, fmt.Sprintf("banner_cid = $%d", argNum)) 347 + args = append(args, *input.BannerCID) 348 + argNum++ 349 + } 350 + 351 + // Add the DID as the final parameter for the WHERE clause 352 + args = append(args, did) 353 + 354 + query := fmt.Sprintf(` 355 + UPDATE users 356 + SET %s 357 + WHERE did = $%d 358 + RETURNING did, handle, pds_url, created_at, updated_at, display_name, bio, avatar_cid, banner_cid`, 359 + strings.Join(setClauses, ", "), argNum) 360 + 361 + user := &users.User{} 362 + var displayNameVal, bioVal, avatarCIDVal, bannerCIDVal sql.NullString 363 + 364 + err := r.db.QueryRowContext(ctx, query, args...). 365 + Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt, 366 + &displayNameVal, &bioVal, &avatarCIDVal, &bannerCIDVal) 367 + 368 + if err == sql.ErrNoRows { 369 + return nil, users.ErrUserNotFound 370 + } 371 + if err != nil { 372 + return nil, fmt.Errorf("failed to update profile: %w", err) 373 + } 374 + 375 + user.DisplayName = displayNameVal.String 376 + user.Bio = bioVal.String 377 + user.AvatarCID = avatarCIDVal.String 378 + user.BannerCID = bannerCIDVal.String 379 + 380 + return user, nil 381 + }
+1031
tests/integration/user_profile_avatar_e2e_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/api/handlers/user" 5 + "Coves/internal/api/routes" 6 + "Coves/internal/atproto/identity" 7 + "Coves/internal/atproto/jetstream" 8 + "Coves/internal/core/blobs" 9 + "Coves/internal/core/users" 10 + "Coves/internal/db/postgres" 11 + "bytes" 12 + "context" 13 + "database/sql" 14 + "encoding/json" 15 + "fmt" 16 + "image" 17 + "image/color" 18 + "image/png" 19 + "net" 20 + "net/http" 21 + "net/http/httptest" 22 + "net/url" 23 + "os" 24 + "strings" 25 + "testing" 26 + "time" 27 + 28 + "github.com/go-chi/chi/v5" 29 + "github.com/gorilla/websocket" 30 + _ "github.com/lib/pq" 31 + "github.com/pressly/goose/v3" 32 + "github.com/stretchr/testify/assert" 33 + "github.com/stretchr/testify/require" 34 + ) 35 + 36 + // createTestAvatarPNG creates a simple PNG image for avatar testing 37 + // Parameters: 38 + // - width, height: image dimensions in pixels 39 + // - c: fill color for the image 40 + // Returns the PNG encoded as bytes 41 + func createTestAvatarPNG(width, height int, c color.Color) []byte { 42 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 43 + for y := 0; y < height; y++ { 44 + for x := 0; x < width; x++ { 45 + img.Set(x, y, c) 46 + } 47 + } 48 + var buf bytes.Buffer 49 + if err := png.Encode(&buf, img); err != nil { 50 + panic(fmt.Sprintf("createTestAvatarPNG: failed to encode PNG: %v", err)) 51 + } 52 + return buf.Bytes() 53 + } 54 + 55 + // TestUserProfileAvatarE2E_UpdateWithAvatar tests the full flow of updating a user profile with an avatar: 56 + // 1. User updates profile via Coves API (POST /xrpc/social.coves.actor.updateProfile) 57 + // 2. Profile record is written to PDS (app.bsky.actor.profile) 58 + // 3. Jetstream consumer receives and processes the event 59 + // 4. GetProfile returns the correct avatar URL 60 + func TestUserProfileAvatarE2E_UpdateWithAvatar(t *testing.T) { 61 + if testing.Short() { 62 + t.Skip("Skipping E2E test in short mode") 63 + } 64 + 65 + // Setup test database 66 + dbURL := os.Getenv("TEST_DATABASE_URL") 67 + if dbURL == "" { 68 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 69 + } 70 + 71 + db, err := sql.Open("postgres", dbURL) 72 + require.NoError(t, err, "Failed to connect to test database") 73 + defer func() { _ = db.Close() }() 74 + 75 + // Run migrations 76 + require.NoError(t, goose.SetDialect("postgres")) 77 + require.NoError(t, goose.Up(db, "../../internal/db/migrations")) 78 + 79 + // Check if PDS is running 80 + pdsURL := os.Getenv("PDS_URL") 81 + if pdsURL == "" { 82 + pdsURL = "http://localhost:3001" 83 + } 84 + 85 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 86 + if err != nil { 87 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 88 + } 89 + _ = healthResp.Body.Close() 90 + 91 + // Check if Jetstream is running 92 + pdsHostname := strings.TrimPrefix(pdsURL, "http://") 93 + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") 94 + pdsHostname = strings.Split(pdsHostname, ":")[0] 95 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=app.bsky.actor.profile", pdsHostname) 96 + 97 + testConn, _, connErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 98 + if connErr != nil { 99 + t.Skipf("Jetstream not available at %s: %v. Run 'make dev-up' to start.", jetstreamURL, connErr) 100 + } 101 + _ = testConn.Close() 102 + t.Logf("Jetstream available at %s", jetstreamURL) 103 + 104 + ctx := context.Background() 105 + 106 + // Setup identity resolver 107 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 108 + if plcURL == "" { 109 + plcURL = "http://localhost:3002" 110 + } 111 + identityConfig := identity.DefaultConfig() 112 + identityConfig.PLCURL = plcURL 113 + identityResolver := identity.NewResolver(db, identityConfig) 114 + 115 + // Setup services 116 + userRepo := postgres.NewUserRepository(db) 117 + userService := users.NewUserService(userRepo, identityResolver, pdsURL) 118 + blobService := blobs.NewBlobService(pdsURL) 119 + 120 + // Setup user consumer for processing Jetstream events 121 + userConsumer := jetstream.NewUserEventConsumer(userService, identityResolver, jetstreamURL, "") 122 + 123 + // Setup HTTP server with all user routes 124 + e2eAuth := NewE2EOAuthMiddleware() 125 + r := chi.NewRouter() 126 + routes.RegisterUserRoutes(r, userService, e2eAuth.OAuthAuthMiddleware, blobService) 127 + httpServer := httptest.NewServer(r) 128 + defer httpServer.Close() 129 + 130 + // Cleanup old test data 131 + timestamp := time.Now().Unix() 132 + shortTS := timestamp % 10000 133 + _, _ = db.Exec("DELETE FROM users WHERE handle LIKE 'avatartest%.local.coves.dev'") 134 + 135 + t.Run("update profile with avatar via real PDS and Jetstream", func(t *testing.T) { 136 + // Create test user account on PDS 137 + userHandle := fmt.Sprintf("avatartest%d.local.coves.dev", shortTS) 138 + email := fmt.Sprintf("avatartest%d@test.com", shortTS) 139 + password := "test-password-avatar-123" 140 + 141 + t.Logf("\n Creating test user account on PDS: %s", userHandle) 142 + 143 + userToken, userDID, err := createPDSAccount(pdsURL, userHandle, email, password) 144 + require.NoError(t, err, "Failed to create test user account") 145 + require.NotEmpty(t, userToken, "User should receive access token") 146 + require.NotEmpty(t, userDID, "User should receive DID") 147 + 148 + t.Logf("User created: %s (%s)", userHandle, userDID) 149 + 150 + // Index user in AppView database 151 + _ = createTestUser(t, db, userHandle, userDID) 152 + 153 + // Register user with OAuth middleware using real PDS token 154 + userAPIToken := e2eAuth.AddUserWithPDSToken(userDID, userToken, pdsURL) 155 + 156 + // Verify user has no avatar initially 157 + initialProfile, err := userService.GetProfile(ctx, userDID) 158 + require.NoError(t, err) 159 + assert.Empty(t, initialProfile.Avatar, "Initial avatar should be empty") 160 + t.Logf("Initial profile verified - no avatar") 161 + 162 + // Create test avatar image (100x100 red square) 163 + avatarData := createTestAvatarPNG(100, 100, color.RGBA{255, 0, 0, 255}) 164 + t.Logf("\n Updating profile with avatar (%d bytes)...", len(avatarData)) 165 + 166 + // Subscribe to Jetstream BEFORE making the update 167 + eventChan := make(chan *jetstream.JetstreamEvent, 10) 168 + done := make(chan bool) 169 + subscribeCtx, cancelSubscribe := context.WithTimeout(ctx, 30*time.Second) 170 + defer cancelSubscribe() 171 + 172 + go func() { 173 + conn, _, dialErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 174 + if dialErr != nil { 175 + t.Logf("Failed to connect to Jetstream: %v", dialErr) 176 + return 177 + } 178 + defer func() { _ = conn.Close() }() 179 + 180 + for { 181 + select { 182 + case <-done: 183 + return 184 + case <-subscribeCtx.Done(): 185 + return 186 + default: 187 + if deadlineErr := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); deadlineErr != nil { 188 + return 189 + } 190 + 191 + var event jetstream.JetstreamEvent 192 + if readErr := conn.ReadJSON(&event); readErr != nil { 193 + var netErr net.Error 194 + if nErr, ok := readErr.(net.Error); ok && nErr.Timeout() { 195 + continue 196 + } 197 + // Check using errors.As as well 198 + if netErr != nil && netErr.Timeout() { 199 + continue 200 + } 201 + continue 202 + } 203 + 204 + // Only process profile update events for our user 205 + if event.Kind == "commit" && event.Commit != nil && 206 + event.Commit.Collection == "app.bsky.actor.profile" && 207 + event.Did == userDID { 208 + eventChan <- &event 209 + } 210 + } 211 + } 212 + }() 213 + time.Sleep(500 * time.Millisecond) // Give subscriber time to connect 214 + 215 + // Build update profile request 216 + displayName := "Avatar Test User" 217 + bio := "Testing avatar upload E2E" 218 + updateReq := user.UpdateProfileRequest{ 219 + DisplayName: &displayName, 220 + Bio: &bio, 221 + AvatarBlob: avatarData, 222 + AvatarMimeType: "image/png", 223 + } 224 + 225 + reqBody, _ := json.Marshal(updateReq) 226 + req, _ := http.NewRequest(http.MethodPost, 227 + httpServer.URL+"/xrpc/social.coves.actor.updateProfile", 228 + bytes.NewBuffer(reqBody)) 229 + req.Header.Set("Content-Type", "application/json") 230 + req.Header.Set("Authorization", "Bearer "+userAPIToken) 231 + 232 + resp, err := http.DefaultClient.Do(req) 233 + require.NoError(t, err) 234 + defer func() { _ = resp.Body.Close() }() 235 + 236 + require.Equal(t, http.StatusOK, resp.StatusCode, "Update profile should succeed") 237 + 238 + var updateResp user.UpdateProfileResponse 239 + require.NoError(t, json.NewDecoder(resp.Body).Decode(&updateResp)) 240 + 241 + t.Logf("Profile update written to PDS:") 242 + t.Logf(" URI: %s", updateResp.URI) 243 + t.Logf(" CID: %s", updateResp.CID) 244 + 245 + // Wait for REAL Jetstream event 246 + t.Logf("\n Waiting for profile update event from Jetstream...") 247 + var realEvent *jetstream.JetstreamEvent 248 + timeout := time.After(15 * time.Second) 249 + 250 + eventLoop: 251 + for { 252 + select { 253 + case event := <-eventChan: 254 + realEvent = event 255 + t.Logf("Received REAL profile update event from Jetstream!") 256 + t.Logf(" DID: %s", event.Did) 257 + t.Logf(" Operation: %s", event.Commit.Operation) 258 + t.Logf(" CID: %s", event.Commit.CID) 259 + 260 + // Log avatar info from real event 261 + if event.Commit.Record != nil { 262 + if avatar, hasAvatar := event.Commit.Record["avatar"]; hasAvatar { 263 + t.Logf(" Avatar in event: %v", avatar) 264 + } 265 + } 266 + break eventLoop 267 + case <-timeout: 268 + close(done) 269 + t.Fatalf("Timeout waiting for Jetstream profile update event for DID %s", userDID) 270 + } 271 + } 272 + close(done) 273 + 274 + // Process the REAL event through user consumer 275 + t.Logf("\n Processing real Jetstream event through user consumer...") 276 + if handleErr := userConsumer.HandleIdentityEventPublic(ctx, realEvent); handleErr != nil { 277 + // HandleIdentityEventPublic is for identity events, use commit handling instead 278 + t.Logf(" Note: Identity event handling result: %v", handleErr) 279 + } 280 + 281 + // For profile updates, we need to manually process the commit event 282 + // The consumer checks for app.bsky.actor.profile commit events 283 + if realEvent.Kind == "commit" && realEvent.Commit != nil { 284 + // Extract profile data from the event and update the user 285 + var displayNamePtr, bioPtr, avatarCIDPtr, bannerCIDPtr *string 286 + 287 + if dn, ok := realEvent.Commit.Record["displayName"].(string); ok { 288 + displayNamePtr = &dn 289 + } 290 + if desc, ok := realEvent.Commit.Record["description"].(string); ok { 291 + bioPtr = &desc 292 + } 293 + if avatarMap, ok := realEvent.Commit.Record["avatar"].(map[string]interface{}); ok { 294 + if ref, ok := avatarMap["ref"].(map[string]interface{}); ok { 295 + if link, ok := ref["$link"].(string); ok { 296 + avatarCIDPtr = &link 297 + t.Logf(" AvatarCID from Jetstream: %s", link) 298 + } 299 + } 300 + } 301 + 302 + _, updateErr := userService.UpdateProfile(ctx, userDID, users.UpdateProfileInput{ 303 + DisplayName: displayNamePtr, 304 + Bio: bioPtr, 305 + AvatarCID: avatarCIDPtr, 306 + BannerCID: bannerCIDPtr, 307 + }) 308 + if updateErr != nil { 309 + t.Logf(" Update profile from event error: %v", updateErr) 310 + } 311 + } 312 + 313 + // Verify profile now has avatar URL via GetProfile 314 + t.Logf("\n Verifying profile via GetProfile...") 315 + finalProfile, err := userService.GetProfile(ctx, userDID) 316 + require.NoError(t, err) 317 + 318 + t.Logf("Final profile verification:") 319 + t.Logf(" DisplayName: %s", finalProfile.DisplayName) 320 + t.Logf(" Bio: %s", finalProfile.Bio) 321 + t.Logf(" Avatar URL: %s", finalProfile.Avatar) 322 + 323 + assert.Equal(t, displayName, finalProfile.DisplayName, "DisplayName should match") 324 + assert.Equal(t, bio, finalProfile.Bio, "Bio should match") 325 + assert.NotEmpty(t, finalProfile.Avatar, "Avatar URL should be set") 326 + 327 + // Verify avatar URL format (should be PDS blob URL) 328 + if finalProfile.Avatar != "" { 329 + assert.Contains(t, finalProfile.Avatar, "/xrpc/com.atproto.sync.getBlob", 330 + "Avatar URL should be a PDS blob URL") 331 + // URL-decode the avatar URL before checking for DID (DIDs are URL-encoded in query params) 332 + decodedAvatarURL, _ := url.QueryUnescape(finalProfile.Avatar) 333 + assert.Contains(t, decodedAvatarURL, userDID, 334 + "Avatar URL should contain user DID") 335 + } 336 + 337 + // Optionally: Fetch avatar URL and verify blob is accessible 338 + if finalProfile.Avatar != "" { 339 + avatarResp, avatarErr := http.Get(finalProfile.Avatar) 340 + if avatarErr != nil { 341 + t.Logf(" Warning: Could not fetch avatar URL: %v", avatarErr) 342 + } else { 343 + defer func() { _ = avatarResp.Body.Close() }() 344 + t.Logf(" Avatar fetch status: %d", avatarResp.StatusCode) 345 + if avatarResp.StatusCode == http.StatusOK { 346 + t.Logf(" Avatar blob is accessible!") 347 + } 348 + } 349 + } 350 + 351 + t.Logf("\n TRUE E2E USER PROFILE AVATAR UPDATE COMPLETE") 352 + t.Logf(" API -> PDS uploadBlob -> PDS putRecord -> Jetstream -> AppView") 353 + }) 354 + } 355 + 356 + // TestUserProfileAvatarE2E_UpdateWithBanner tests the full flow of updating a user profile with a banner 357 + func TestUserProfileAvatarE2E_UpdateWithBanner(t *testing.T) { 358 + if testing.Short() { 359 + t.Skip("Skipping E2E test in short mode") 360 + } 361 + 362 + // Setup test database 363 + dbURL := os.Getenv("TEST_DATABASE_URL") 364 + if dbURL == "" { 365 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 366 + } 367 + 368 + db, err := sql.Open("postgres", dbURL) 369 + require.NoError(t, err, "Failed to connect to test database") 370 + defer func() { _ = db.Close() }() 371 + 372 + // Run migrations 373 + require.NoError(t, goose.SetDialect("postgres")) 374 + require.NoError(t, goose.Up(db, "../../internal/db/migrations")) 375 + 376 + // Check if PDS is running 377 + pdsURL := os.Getenv("PDS_URL") 378 + if pdsURL == "" { 379 + pdsURL = "http://localhost:3001" 380 + } 381 + 382 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 383 + if err != nil { 384 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 385 + } 386 + _ = healthResp.Body.Close() 387 + 388 + // Check if Jetstream is running 389 + pdsHostname := strings.TrimPrefix(pdsURL, "http://") 390 + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") 391 + pdsHostname = strings.Split(pdsHostname, ":")[0] 392 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=app.bsky.actor.profile", pdsHostname) 393 + 394 + testConn, _, connErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 395 + if connErr != nil { 396 + t.Skipf("Jetstream not available at %s: %v. Run 'make dev-up' to start.", jetstreamURL, connErr) 397 + } 398 + _ = testConn.Close() 399 + 400 + ctx := context.Background() 401 + 402 + // Setup identity resolver 403 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 404 + if plcURL == "" { 405 + plcURL = "http://localhost:3002" 406 + } 407 + identityConfig := identity.DefaultConfig() 408 + identityConfig.PLCURL = plcURL 409 + identityResolver := identity.NewResolver(db, identityConfig) 410 + 411 + // Setup services 412 + userRepo := postgres.NewUserRepository(db) 413 + userService := users.NewUserService(userRepo, identityResolver, pdsURL) 414 + blobService := blobs.NewBlobService(pdsURL) 415 + 416 + // Setup HTTP server 417 + e2eAuth := NewE2EOAuthMiddleware() 418 + r := chi.NewRouter() 419 + routes.RegisterUserRoutes(r, userService, e2eAuth.OAuthAuthMiddleware, blobService) 420 + httpServer := httptest.NewServer(r) 421 + defer httpServer.Close() 422 + 423 + timestamp := time.Now().Unix() 424 + shortTS := timestamp % 10000 425 + _, _ = db.Exec("DELETE FROM users WHERE handle LIKE 'bannertest%.local.coves.dev'") 426 + 427 + t.Run("update profile with banner via real PDS and Jetstream", func(t *testing.T) { 428 + // Create test user account on PDS 429 + userHandle := fmt.Sprintf("bannertest%d.local.coves.dev", shortTS) 430 + email := fmt.Sprintf("bannertest%d@test.com", shortTS) 431 + password := "test-password-banner-123" 432 + 433 + t.Logf("\n Creating test user account on PDS: %s", userHandle) 434 + 435 + userToken, userDID, err := createPDSAccount(pdsURL, userHandle, email, password) 436 + require.NoError(t, err, "Failed to create test user account") 437 + 438 + t.Logf("User created: %s (%s)", userHandle, userDID) 439 + 440 + // Index user in AppView database 441 + _ = createTestUser(t, db, userHandle, userDID) 442 + 443 + // Register user with OAuth middleware 444 + userAPIToken := e2eAuth.AddUserWithPDSToken(userDID, userToken, pdsURL) 445 + 446 + // Verify no banner initially 447 + initialProfile, err := userService.GetProfile(ctx, userDID) 448 + require.NoError(t, err) 449 + assert.Empty(t, initialProfile.Banner, "Initial banner should be empty") 450 + 451 + // Create test banner image (300x100 blue rectangle) 452 + bannerData := createTestAvatarPNG(300, 100, color.RGBA{0, 0, 255, 255}) 453 + t.Logf("\n Updating profile with banner (%d bytes)...", len(bannerData)) 454 + 455 + // Subscribe to Jetstream 456 + eventChan := make(chan *jetstream.JetstreamEvent, 10) 457 + done := make(chan bool) 458 + subscribeCtx, cancelSubscribe := context.WithTimeout(ctx, 30*time.Second) 459 + defer cancelSubscribe() 460 + 461 + go func() { 462 + conn, _, dialErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 463 + if dialErr != nil { 464 + return 465 + } 466 + defer func() { _ = conn.Close() }() 467 + 468 + for { 469 + select { 470 + case <-done: 471 + return 472 + case <-subscribeCtx.Done(): 473 + return 474 + default: 475 + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { 476 + return 477 + } 478 + 479 + var event jetstream.JetstreamEvent 480 + if err := conn.ReadJSON(&event); err != nil { 481 + continue 482 + } 483 + 484 + if event.Kind == "commit" && event.Commit != nil && 485 + event.Commit.Collection == "app.bsky.actor.profile" && 486 + event.Did == userDID { 487 + eventChan <- &event 488 + } 489 + } 490 + } 491 + }() 492 + time.Sleep(500 * time.Millisecond) 493 + 494 + // Build update profile request with banner 495 + displayName := "Banner Test User" 496 + updateReq := user.UpdateProfileRequest{ 497 + DisplayName: &displayName, 498 + BannerBlob: bannerData, 499 + BannerMimeType: "image/png", 500 + } 501 + 502 + reqBody, _ := json.Marshal(updateReq) 503 + req, _ := http.NewRequest(http.MethodPost, 504 + httpServer.URL+"/xrpc/social.coves.actor.updateProfile", 505 + bytes.NewBuffer(reqBody)) 506 + req.Header.Set("Content-Type", "application/json") 507 + req.Header.Set("Authorization", "Bearer "+userAPIToken) 508 + 509 + resp, err := http.DefaultClient.Do(req) 510 + require.NoError(t, err) 511 + defer func() { _ = resp.Body.Close() }() 512 + 513 + require.Equal(t, http.StatusOK, resp.StatusCode, "Update profile should succeed") 514 + 515 + var updateResp user.UpdateProfileResponse 516 + require.NoError(t, json.NewDecoder(resp.Body).Decode(&updateResp)) 517 + 518 + t.Logf("Profile update written to PDS: URI=%s, CID=%s", updateResp.URI, updateResp.CID) 519 + 520 + // Wait for Jetstream event 521 + t.Logf("\n Waiting for profile update event from Jetstream...") 522 + var realEvent *jetstream.JetstreamEvent 523 + timeout := time.After(15 * time.Second) 524 + 525 + eventLoop: 526 + for { 527 + select { 528 + case event := <-eventChan: 529 + realEvent = event 530 + t.Logf("Received REAL profile update event!") 531 + 532 + if event.Commit.Record != nil { 533 + if banner, hasBanner := event.Commit.Record["banner"]; hasBanner { 534 + t.Logf(" Banner in event: %v", banner) 535 + } 536 + } 537 + break eventLoop 538 + case <-timeout: 539 + close(done) 540 + t.Fatalf("Timeout waiting for Jetstream event") 541 + } 542 + } 543 + close(done) 544 + 545 + // Process the event and update user profile 546 + if realEvent.Kind == "commit" && realEvent.Commit != nil { 547 + var displayNamePtr, bioPtr, avatarCIDPtr, bannerCIDPtr *string 548 + 549 + if dn, ok := realEvent.Commit.Record["displayName"].(string); ok { 550 + displayNamePtr = &dn 551 + } 552 + if bannerMap, ok := realEvent.Commit.Record["banner"].(map[string]interface{}); ok { 553 + if ref, ok := bannerMap["ref"].(map[string]interface{}); ok { 554 + if link, ok := ref["$link"].(string); ok { 555 + bannerCIDPtr = &link 556 + t.Logf(" BannerCID from Jetstream: %s", link) 557 + } 558 + } 559 + } 560 + 561 + _, _ = userService.UpdateProfile(ctx, userDID, users.UpdateProfileInput{ 562 + DisplayName: displayNamePtr, 563 + Bio: bioPtr, 564 + AvatarCID: avatarCIDPtr, 565 + BannerCID: bannerCIDPtr, 566 + }) 567 + } 568 + 569 + // Verify profile now has banner URL 570 + finalProfile, err := userService.GetProfile(ctx, userDID) 571 + require.NoError(t, err) 572 + 573 + t.Logf("Final profile verification:") 574 + t.Logf(" DisplayName: %s", finalProfile.DisplayName) 575 + t.Logf(" Banner URL: %s", finalProfile.Banner) 576 + 577 + assert.Equal(t, displayName, finalProfile.DisplayName) 578 + assert.NotEmpty(t, finalProfile.Banner, "Banner URL should be set") 579 + 580 + if finalProfile.Banner != "" { 581 + assert.Contains(t, finalProfile.Banner, "/xrpc/com.atproto.sync.getBlob") 582 + // URL-decode the banner URL before checking for DID (DIDs are URL-encoded in query params) 583 + decodedBannerURL, _ := url.QueryUnescape(finalProfile.Banner) 584 + assert.Contains(t, decodedBannerURL, userDID) 585 + } 586 + 587 + t.Logf("\n TRUE E2E USER PROFILE BANNER UPDATE COMPLETE") 588 + }) 589 + } 590 + 591 + // TestUserProfileAvatarE2E_UpdateDisplayNameAndBio tests updating non-blob profile fields 592 + func TestUserProfileAvatarE2E_UpdateDisplayNameAndBio(t *testing.T) { 593 + if testing.Short() { 594 + t.Skip("Skipping E2E test in short mode") 595 + } 596 + 597 + // Setup test database 598 + dbURL := os.Getenv("TEST_DATABASE_URL") 599 + if dbURL == "" { 600 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 601 + } 602 + 603 + db, err := sql.Open("postgres", dbURL) 604 + require.NoError(t, err, "Failed to connect to test database") 605 + defer func() { _ = db.Close() }() 606 + 607 + // Run migrations 608 + require.NoError(t, goose.SetDialect("postgres")) 609 + require.NoError(t, goose.Up(db, "../../internal/db/migrations")) 610 + 611 + // Check if PDS is running 612 + pdsURL := os.Getenv("PDS_URL") 613 + if pdsURL == "" { 614 + pdsURL = "http://localhost:3001" 615 + } 616 + 617 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 618 + if err != nil { 619 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 620 + } 621 + _ = healthResp.Body.Close() 622 + 623 + // Check if Jetstream is running 624 + pdsHostname := strings.TrimPrefix(pdsURL, "http://") 625 + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") 626 + pdsHostname = strings.Split(pdsHostname, ":")[0] 627 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=app.bsky.actor.profile", pdsHostname) 628 + 629 + testConn, _, connErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 630 + if connErr != nil { 631 + t.Skipf("Jetstream not available at %s: %v. Run 'make dev-up' to start.", jetstreamURL, connErr) 632 + } 633 + _ = testConn.Close() 634 + 635 + ctx := context.Background() 636 + 637 + // Setup identity resolver 638 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 639 + if plcURL == "" { 640 + plcURL = "http://localhost:3002" 641 + } 642 + identityConfig := identity.DefaultConfig() 643 + identityConfig.PLCURL = plcURL 644 + identityResolver := identity.NewResolver(db, identityConfig) 645 + 646 + // Setup services 647 + userRepo := postgres.NewUserRepository(db) 648 + userService := users.NewUserService(userRepo, identityResolver, pdsURL) 649 + blobService := blobs.NewBlobService(pdsURL) 650 + 651 + // Setup HTTP server 652 + e2eAuth := NewE2EOAuthMiddleware() 653 + r := chi.NewRouter() 654 + routes.RegisterUserRoutes(r, userService, e2eAuth.OAuthAuthMiddleware, blobService) 655 + httpServer := httptest.NewServer(r) 656 + defer httpServer.Close() 657 + 658 + timestamp := time.Now().Unix() 659 + shortTS := timestamp % 10000 660 + 661 + t.Run("update display name and bio without blobs", func(t *testing.T) { 662 + // Create test user account on PDS 663 + userHandle := fmt.Sprintf("texttest%d.local.coves.dev", shortTS) 664 + email := fmt.Sprintf("texttest%d@test.com", shortTS) 665 + password := "test-password-text-123" 666 + 667 + userToken, userDID, err := createPDSAccount(pdsURL, userHandle, email, password) 668 + require.NoError(t, err) 669 + 670 + t.Logf("User created: %s (%s)", userHandle, userDID) 671 + 672 + // Index user in AppView 673 + _ = createTestUser(t, db, userHandle, userDID) 674 + userAPIToken := e2eAuth.AddUserWithPDSToken(userDID, userToken, pdsURL) 675 + 676 + // Subscribe to Jetstream 677 + eventChan := make(chan *jetstream.JetstreamEvent, 10) 678 + done := make(chan bool) 679 + subscribeCtx, cancelSubscribe := context.WithTimeout(ctx, 30*time.Second) 680 + defer cancelSubscribe() 681 + 682 + go func() { 683 + conn, _, dialErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 684 + if dialErr != nil { 685 + return 686 + } 687 + defer func() { _ = conn.Close() }() 688 + 689 + for { 690 + select { 691 + case <-done: 692 + return 693 + case <-subscribeCtx.Done(): 694 + return 695 + default: 696 + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { 697 + return 698 + } 699 + 700 + var event jetstream.JetstreamEvent 701 + if err := conn.ReadJSON(&event); err != nil { 702 + continue 703 + } 704 + 705 + if event.Kind == "commit" && event.Commit != nil && 706 + event.Commit.Collection == "app.bsky.actor.profile" && 707 + event.Did == userDID { 708 + eventChan <- &event 709 + } 710 + } 711 + } 712 + }() 713 + time.Sleep(500 * time.Millisecond) 714 + 715 + // Update with only text fields 716 + displayName := "Text Update Test User" 717 + bio := "This is my test bio for E2E testing" 718 + updateReq := user.UpdateProfileRequest{ 719 + DisplayName: &displayName, 720 + Bio: &bio, 721 + } 722 + 723 + reqBody, _ := json.Marshal(updateReq) 724 + req, _ := http.NewRequest(http.MethodPost, 725 + httpServer.URL+"/xrpc/social.coves.actor.updateProfile", 726 + bytes.NewBuffer(reqBody)) 727 + req.Header.Set("Content-Type", "application/json") 728 + req.Header.Set("Authorization", "Bearer "+userAPIToken) 729 + 730 + resp, err := http.DefaultClient.Do(req) 731 + require.NoError(t, err) 732 + defer func() { _ = resp.Body.Close() }() 733 + 734 + require.Equal(t, http.StatusOK, resp.StatusCode) 735 + 736 + // Wait for Jetstream event 737 + var realEvent *jetstream.JetstreamEvent 738 + timeout := time.After(15 * time.Second) 739 + 740 + eventLoop: 741 + for { 742 + select { 743 + case event := <-eventChan: 744 + realEvent = event 745 + t.Logf("Received profile update event!") 746 + break eventLoop 747 + case <-timeout: 748 + close(done) 749 + t.Fatalf("Timeout waiting for Jetstream event") 750 + } 751 + } 752 + close(done) 753 + 754 + // Process the event 755 + if realEvent.Kind == "commit" && realEvent.Commit != nil { 756 + var displayNamePtr, bioPtr *string 757 + 758 + if dn, ok := realEvent.Commit.Record["displayName"].(string); ok { 759 + displayNamePtr = &dn 760 + } 761 + if desc, ok := realEvent.Commit.Record["description"].(string); ok { 762 + bioPtr = &desc 763 + } 764 + 765 + _, _ = userService.UpdateProfile(ctx, userDID, users.UpdateProfileInput{ 766 + DisplayName: displayNamePtr, 767 + Bio: bioPtr, 768 + }) 769 + } 770 + 771 + // Verify profile 772 + finalProfile, err := userService.GetProfile(ctx, userDID) 773 + require.NoError(t, err) 774 + 775 + assert.Equal(t, displayName, finalProfile.DisplayName) 776 + assert.Equal(t, bio, finalProfile.Bio) 777 + 778 + t.Logf("Text-only profile update verified:") 779 + t.Logf(" DisplayName: %s", finalProfile.DisplayName) 780 + t.Logf(" Bio: %s", finalProfile.Bio) 781 + 782 + t.Logf("\n TRUE E2E TEXT-ONLY PROFILE UPDATE COMPLETE") 783 + }) 784 + } 785 + 786 + // TestUserProfileAvatarE2E_ReplaceAvatar tests replacing an existing avatar with a new one 787 + func TestUserProfileAvatarE2E_ReplaceAvatar(t *testing.T) { 788 + if testing.Short() { 789 + t.Skip("Skipping E2E test in short mode") 790 + } 791 + 792 + // Setup test database 793 + dbURL := os.Getenv("TEST_DATABASE_URL") 794 + if dbURL == "" { 795 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 796 + } 797 + 798 + db, err := sql.Open("postgres", dbURL) 799 + require.NoError(t, err, "Failed to connect to test database") 800 + defer func() { _ = db.Close() }() 801 + 802 + // Run migrations 803 + require.NoError(t, goose.SetDialect("postgres")) 804 + require.NoError(t, goose.Up(db, "../../internal/db/migrations")) 805 + 806 + // Check if PDS is running 807 + pdsURL := os.Getenv("PDS_URL") 808 + if pdsURL == "" { 809 + pdsURL = "http://localhost:3001" 810 + } 811 + 812 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 813 + if err != nil { 814 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 815 + } 816 + _ = healthResp.Body.Close() 817 + 818 + // Check if Jetstream is running 819 + pdsHostname := strings.TrimPrefix(pdsURL, "http://") 820 + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") 821 + pdsHostname = strings.Split(pdsHostname, ":")[0] 822 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=app.bsky.actor.profile", pdsHostname) 823 + 824 + testConn, _, connErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 825 + if connErr != nil { 826 + t.Skipf("Jetstream not available at %s: %v. Run 'make dev-up' to start.", jetstreamURL, connErr) 827 + } 828 + _ = testConn.Close() 829 + 830 + ctx := context.Background() 831 + 832 + // Setup identity resolver 833 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 834 + if plcURL == "" { 835 + plcURL = "http://localhost:3002" 836 + } 837 + identityConfig := identity.DefaultConfig() 838 + identityConfig.PLCURL = plcURL 839 + identityResolver := identity.NewResolver(db, identityConfig) 840 + 841 + // Setup services 842 + userRepo := postgres.NewUserRepository(db) 843 + userService := users.NewUserService(userRepo, identityResolver, pdsURL) 844 + blobService := blobs.NewBlobService(pdsURL) 845 + 846 + // Setup HTTP server 847 + e2eAuth := NewE2EOAuthMiddleware() 848 + r := chi.NewRouter() 849 + routes.RegisterUserRoutes(r, userService, e2eAuth.OAuthAuthMiddleware, blobService) 850 + httpServer := httptest.NewServer(r) 851 + defer httpServer.Close() 852 + 853 + timestamp := time.Now().Unix() 854 + shortTS := timestamp % 10000 855 + 856 + // Helper to wait for Jetstream event and extract avatar CID 857 + waitForProfileEvent := func(t *testing.T, userDID string, timeout time.Duration) (string, *jetstream.JetstreamEvent) { 858 + eventChan := make(chan *jetstream.JetstreamEvent, 10) 859 + done := make(chan bool) 860 + subscribeCtx, cancelSubscribe := context.WithTimeout(ctx, timeout) 861 + defer cancelSubscribe() 862 + 863 + go func() { 864 + conn, _, dialErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 865 + if dialErr != nil { 866 + return 867 + } 868 + defer func() { _ = conn.Close() }() 869 + 870 + for { 871 + select { 872 + case <-done: 873 + return 874 + case <-subscribeCtx.Done(): 875 + return 876 + default: 877 + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { 878 + return 879 + } 880 + 881 + var event jetstream.JetstreamEvent 882 + if err := conn.ReadJSON(&event); err != nil { 883 + continue 884 + } 885 + 886 + if event.Kind == "commit" && event.Commit != nil && 887 + event.Commit.Collection == "app.bsky.actor.profile" && 888 + event.Did == userDID { 889 + eventChan <- &event 890 + } 891 + } 892 + } 893 + }() 894 + 895 + select { 896 + case event := <-eventChan: 897 + close(done) 898 + var avatarCID string 899 + if event.Commit.Record != nil { 900 + if avatarMap, ok := event.Commit.Record["avatar"].(map[string]interface{}); ok { 901 + if ref, ok := avatarMap["ref"].(map[string]interface{}); ok { 902 + if link, ok := ref["$link"].(string); ok { 903 + avatarCID = link 904 + } 905 + } 906 + } 907 + } 908 + return avatarCID, event 909 + case <-time.After(timeout): 910 + close(done) 911 + return "", nil 912 + } 913 + } 914 + 915 + t.Run("replace existing avatar with new one", func(t *testing.T) { 916 + // Create test user account on PDS 917 + userHandle := fmt.Sprintf("replaceav%d.local.coves.dev", shortTS) 918 + email := fmt.Sprintf("replaceav%d@test.com", shortTS) 919 + password := "test-password-replace-123" 920 + 921 + userToken, userDID, err := createPDSAccount(pdsURL, userHandle, email, password) 922 + require.NoError(t, err) 923 + 924 + t.Logf("User created: %s (%s)", userHandle, userDID) 925 + 926 + // Index user in AppView 927 + _ = createTestUser(t, db, userHandle, userDID) 928 + userAPIToken := e2eAuth.AddUserWithPDSToken(userDID, userToken, pdsURL) 929 + 930 + // STEP 1: Create initial avatar (red square) 931 + t.Logf("\n Step 1: Setting initial avatar (red)...") 932 + 933 + initialAvatarData := createTestAvatarPNG(100, 100, color.RGBA{255, 0, 0, 255}) 934 + displayName := "Replace Avatar Test" 935 + updateReq := user.UpdateProfileRequest{ 936 + DisplayName: &displayName, 937 + AvatarBlob: initialAvatarData, 938 + AvatarMimeType: "image/png", 939 + } 940 + 941 + // Start listening before update 942 + go func() { 943 + time.Sleep(500 * time.Millisecond) 944 + }() 945 + 946 + reqBody, _ := json.Marshal(updateReq) 947 + req, _ := http.NewRequest(http.MethodPost, 948 + httpServer.URL+"/xrpc/social.coves.actor.updateProfile", 949 + bytes.NewBuffer(reqBody)) 950 + req.Header.Set("Content-Type", "application/json") 951 + req.Header.Set("Authorization", "Bearer "+userAPIToken) 952 + 953 + resp, err := http.DefaultClient.Do(req) 954 + require.NoError(t, err) 955 + _ = resp.Body.Close() 956 + require.Equal(t, http.StatusOK, resp.StatusCode) 957 + 958 + // Wait for initial avatar event 959 + initialAvatarCID, initialEvent := waitForProfileEvent(t, userDID, 15*time.Second) 960 + require.NotNil(t, initialEvent, "Should receive initial avatar event") 961 + require.NotEmpty(t, initialAvatarCID, "Initial avatar CID should not be empty") 962 + 963 + t.Logf(" Initial AvatarCID: %s", initialAvatarCID) 964 + 965 + // Update local user profile 966 + _, _ = userService.UpdateProfile(ctx, userDID, users.UpdateProfileInput{ 967 + DisplayName: &displayName, 968 + AvatarCID: &initialAvatarCID, 969 + }) 970 + 971 + // Verify initial avatar 972 + profileAfterInitial, err := userService.GetProfile(ctx, userDID) 973 + require.NoError(t, err) 974 + assert.NotEmpty(t, profileAfterInitial.Avatar) 975 + 976 + // Small delay between updates 977 + time.Sleep(1 * time.Second) 978 + 979 + // STEP 2: Replace with new avatar (green square) 980 + t.Logf("\n Step 2: Replacing avatar with new one (green)...") 981 + 982 + newAvatarData := createTestAvatarPNG(100, 100, color.RGBA{0, 255, 0, 255}) 983 + updateReq2 := user.UpdateProfileRequest{ 984 + AvatarBlob: newAvatarData, 985 + AvatarMimeType: "image/png", 986 + } 987 + 988 + reqBody2, _ := json.Marshal(updateReq2) 989 + req2, _ := http.NewRequest(http.MethodPost, 990 + httpServer.URL+"/xrpc/social.coves.actor.updateProfile", 991 + bytes.NewBuffer(reqBody2)) 992 + req2.Header.Set("Content-Type", "application/json") 993 + req2.Header.Set("Authorization", "Bearer "+userAPIToken) 994 + 995 + resp2, err := http.DefaultClient.Do(req2) 996 + require.NoError(t, err) 997 + _ = resp2.Body.Close() 998 + require.Equal(t, http.StatusOK, resp2.StatusCode) 999 + 1000 + // Wait for replacement avatar event 1001 + newAvatarCID, newEvent := waitForProfileEvent(t, userDID, 15*time.Second) 1002 + require.NotNil(t, newEvent, "Should receive replacement avatar event") 1003 + require.NotEmpty(t, newAvatarCID, "New avatar CID should not be empty") 1004 + 1005 + t.Logf(" New AvatarCID: %s", newAvatarCID) 1006 + 1007 + // Verify CIDs are different 1008 + assert.NotEqual(t, initialAvatarCID, newAvatarCID, 1009 + "New avatar CID should be different from initial") 1010 + 1011 + // Update local user profile with new avatar 1012 + _, _ = userService.UpdateProfile(ctx, userDID, users.UpdateProfileInput{ 1013 + AvatarCID: &newAvatarCID, 1014 + }) 1015 + 1016 + // Verify final profile 1017 + finalProfile, err := userService.GetProfile(ctx, userDID) 1018 + require.NoError(t, err) 1019 + 1020 + assert.NotEmpty(t, finalProfile.Avatar, "Final avatar URL should be set") 1021 + assert.Contains(t, finalProfile.Avatar, newAvatarCID, 1022 + "Avatar URL should contain new CID") 1023 + 1024 + t.Logf("\n Avatar replacement verified:") 1025 + t.Logf(" Old CID: %s", initialAvatarCID) 1026 + t.Logf(" New CID: %s", newAvatarCID) 1027 + t.Logf(" CIDs different: %v", initialAvatarCID != newAvatarCID) 1028 + 1029 + t.Logf("\n TRUE E2E AVATAR REPLACEMENT COMPLETE") 1030 + }) 1031 + }
+100
.claude/commands/merge-to-main.md
··· 1 + # Merge to Main 2 + 3 + Review the current code changes and create a comprehensive commit message, then merge to main. 4 + 5 + ## Workflow 6 + 7 + ### Step 1: Analyze Current State 8 + 9 + First, determine the current git state: 10 + 1. Check if on a feature branch or main 11 + 2. Identify all changes: 12 + - `git status` - Check for staged/unstaged changes 13 + - `git log main..HEAD --oneline` - If on a branch, see commits ahead of main 14 + - `git diff main...HEAD --stat` - Summary of all changes vs main 15 + 16 + ### Step 2: Review the Code Changes 17 + 18 + Thoroughly review all changes to understand what was done: 19 + 1. Read through the diff: `git diff main...HEAD` (or `git diff` if uncommitted changes exist) 20 + 2. Look at changed files and understand the context 21 + 3. Identify: 22 + - New features added 23 + - Bugs fixed 24 + - Refactoring done 25 + - Tests added/modified 26 + - Configuration changes 27 + 28 + ### Step 3: Generate Comprehensive Commit Message 29 + 30 + Create a detailed commit message following this format: 31 + 32 + ``` 33 + <type>(<scope>): <short summary> 34 + 35 + <detailed description of what changed and why> 36 + 37 + Changes: 38 + - <specific change 1> 39 + - <specific change 2> 40 + - ... 41 + 42 + <optional: Breaking changes, migration notes, etc.> 43 + ``` 44 + 45 + **Types**: feat, fix, refactor, test, docs, chore, perf, style 46 + **Scope**: The area of the codebase (e.g., user-profile, auth, api) 47 + 48 + The summary should be: 49 + - Under 72 characters 50 + - In imperative mood ("add" not "added") 51 + - Descriptive of the overall change 52 + 53 + The description should: 54 + - Explain the "why" behind the changes 55 + - Reference any related issues (bd issue IDs if applicable) 56 + - List all significant changes made 57 + 58 + ### Step 4: Present to User 59 + 60 + Show the user: 61 + 1. Summary of all changes (files changed, insertions, deletions) 62 + 2. The proposed commit message 63 + 3. Ask for confirmation or modifications 64 + 65 + ### Step 5: Execute Merge 66 + 67 + Based on the current state, offer appropriate options: 68 + 69 + **If on a feature branch with commits:** 70 + 1. Option A: Squash merge to main (recommended for cleaner history) 71 + ``` 72 + git checkout main 73 + git merge --squash <branch> 74 + git commit -m "<comprehensive message>" 75 + ``` 76 + 2. Option B: Regular merge to main 77 + ``` 78 + git checkout main 79 + git merge <branch> 80 + ``` 81 + 82 + **If on a feature branch with uncommitted changes:** 83 + 1. First commit the changes with the comprehensive message 84 + 2. Then offer merge options as above 85 + 86 + **If on main with uncommitted changes:** 87 + 1. Commit directly with the comprehensive message 88 + 89 + ### Step 6: Cleanup (Optional) 90 + 91 + After successful merge, offer to: 92 + - Delete the feature branch locally: `git branch -d <branch>` 93 + - Delete the feature branch remotely: `git push origin --delete <branch>` 94 + 95 + ## Important Notes 96 + 97 + - Always show the user what will happen before executing 98 + - Never force push or use destructive operations without explicit confirmation 99 + - If there are merge conflicts, stop and help the user resolve them 100 + - Preserve the Co-Authored-By trailer as required by the repo guidelines
+8 -4
internal/api/handlers/community/get.go
··· 1 1 package community 2 2 3 3 import ( 4 - "Coves/internal/core/communities" 5 4 "encoding/json" 5 + "log" 6 6 "net/http" 7 + 8 + "Coves/internal/core/communities" 7 9 ) 8 10 9 11 // GetHandler handles community retrieval ··· 40 42 return 41 43 } 42 44 45 + // Convert to detailed view for API response 46 + view := community.ToCommunityViewDetailed() 47 + 43 48 // Return community data 44 49 w.Header().Set("Content-Type", "application/json") 45 50 w.WriteHeader(http.StatusOK) 46 - if err := json.NewEncoder(w).Encode(community); err != nil { 51 + if err := json.NewEncoder(w).Encode(view); err != nil { 47 52 // Log encoding errors but don't return error response (headers already sent) 48 - // This follows Go's standard practice for HTTP handlers 49 - _ = err 53 + log.Printf("Failed to encode community get response: %v", err) 50 54 } 51 55 }
+32 -7
internal/api/handlers/community/search.go
··· 1 1 package community 2 2 3 3 import ( 4 - "Coves/internal/core/communities" 5 4 "encoding/json" 5 + "log" 6 6 "net/http" 7 7 "strconv" 8 + 9 + "Coves/internal/core/communities" 8 10 ) 9 11 10 12 // SearchHandler handles community search ··· 36 38 return 37 39 } 38 40 41 + // Parse limit (1-100, default 50) 39 42 limit := 50 40 43 if limitStr := query.Get("limit"); limitStr != "" { 41 - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { 44 + l, err := strconv.Atoi(limitStr) 45 + if err != nil { 46 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid limit parameter: must be an integer") 47 + return 48 + } 49 + if l < 1 { 50 + limit = 1 51 + } else if l > 100 { 52 + limit = 100 53 + } else { 42 54 limit = l 43 55 } 44 56 } 45 57 58 + // Parse cursor (offset-based for now) 46 59 offset := 0 47 60 if cursorStr := query.Get("cursor"); cursorStr != "" { 48 - if o, err := strconv.Atoi(cursorStr); err == nil && o >= 0 { 49 - offset = o 61 + o, err := strconv.Atoi(cursorStr) 62 + if err != nil { 63 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid cursor parameter: must be an integer") 64 + return 50 65 } 66 + if o < 0 { 67 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid cursor parameter: must be non-negative") 68 + return 69 + } 70 + offset = o 51 71 } 52 72 53 73 req := communities.SearchCommunitiesRequest{ ··· 64 84 return 65 85 } 66 86 87 + // Convert to view structs for API response 88 + views := make([]*communities.CommunityView, len(results)) 89 + for i, c := range results { 90 + views[i] = c.ToCommunityView() 91 + } 92 + 67 93 // Build response 68 94 response := map[string]interface{}{ 69 - "communities": results, 95 + "communities": views, 70 96 "cursor": offset + len(results), 71 97 "total": total, 72 98 } ··· 75 101 w.WriteHeader(http.StatusOK) 76 102 if err := json.NewEncoder(w).Encode(response); err != nil { 77 103 // Log encoding errors but don't return error response (headers already sent) 78 - // This follows Go's standard practice for HTTP handlers 79 - _ = err 104 + log.Printf("Failed to encode community search response: %v", err) 80 105 } 81 106 }
+72
internal/core/blobs/types.go
··· 1 1 package blobs 2 2 3 + import ( 4 + "log/slog" 5 + "net/url" 6 + "strings" 7 + ) 8 + 3 9 // BlobRef represents a blob reference for atproto records 4 10 type BlobRef struct { 5 11 Type string `json:"$type"` ··· 7 13 MimeType string `json:"mimeType"` 8 14 Size int `json:"size"` 9 15 } 16 + 17 + // HydrateBlobURL converts a blob CID to a full PDS blob URL. 18 + // Returns empty string if any required parameter is empty. 19 + // Format: {pdsURL}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid} 20 + func HydrateBlobURL(pdsURL, did, cid string) string { 21 + if pdsURL == "" || did == "" || cid == "" { 22 + return "" 23 + } 24 + return strings.TrimSuffix(pdsURL, "/") + "/xrpc/com.atproto.sync.getBlob?did=" + 25 + url.QueryEscape(did) + "&cid=" + url.QueryEscape(cid) 26 + } 27 + 28 + // HydrateImageProxyURL generates a URL for the image proxy with the specified preset. 29 + // Format: {proxyBaseURL}/img/{preset}/plain/{did}/{cid} 30 + // If proxyBaseURL is empty, generates a relative URL: /img/{preset}/plain/{did}/{cid} 31 + // Returns empty string if preset, did, or cid are empty. 32 + // DID and CID are URL-escaped for safety in path segments. 33 + func HydrateImageProxyURL(proxyBaseURL, preset, did, cid string) string { 34 + if preset == "" || did == "" || cid == "" { 35 + return "" 36 + } 37 + return strings.TrimSuffix(proxyBaseURL, "/") + "/img/" + preset + "/plain/" + 38 + url.PathEscape(did) + "/" + url.PathEscape(cid) 39 + } 40 + 41 + // ImageURLConfig holds configuration for image URL generation. 42 + type ImageURLConfig struct { 43 + ProxyEnabled bool // Whether the image proxy is enabled 44 + ProxyBaseURL string // Base URL for the image proxy (e.g., "https://coves.social") 45 + CDNURL string // Optional CDN override URL 46 + } 47 + 48 + // HydrateImageURL generates the appropriate image URL based on config. 49 + // If proxy is disabled, returns direct PDS URL via HydrateBlobURL. 50 + // If CDN URL is set and proxy is enabled, uses CDN instead of ProxyBaseURL. 51 + // Returns empty string if the generated URL would be invalid. 52 + func HydrateImageURL(config ImageURLConfig, pdsURL, did, cid, preset string) string { 53 + if !config.ProxyEnabled { 54 + return HydrateBlobURL(pdsURL, did, cid) 55 + } 56 + 57 + // Determine which base URL to use 58 + baseURL := config.ProxyBaseURL 59 + if config.CDNURL != "" { 60 + baseURL = config.CDNURL 61 + } 62 + 63 + // Generate proxy URL 64 + proxyURL := HydrateImageProxyURL(baseURL, preset, did, cid) 65 + 66 + // If proxy URL generation failed (e.g., empty preset or base URL), fall back to direct URL 67 + // Log this as it indicates a configuration problem when proxy is enabled 68 + if proxyURL == "" { 69 + slog.Warn("[IMAGE-PROXY] proxy URL generation failed, falling back to direct PDS URL", 70 + "proxy_enabled", config.ProxyEnabled, 71 + "proxy_base_url", config.ProxyBaseURL, 72 + "cdn_url", config.CDNURL, 73 + "preset", preset, 74 + "did", did, 75 + "cid", cid, 76 + ) 77 + return HydrateBlobURL(pdsURL, did, cid) 78 + } 79 + 80 + return proxyURL 81 + }
+274
internal/core/blobs/types_test.go
··· 1 + package blobs 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestHydrateBlobURL(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + pdsURL string 12 + did string 13 + cid string 14 + expected string 15 + }{ 16 + { 17 + name: "valid inputs", 18 + pdsURL: "https://pds.example.com", 19 + did: "did:plc:abc123", 20 + cid: "bafyreiabc123", 21 + expected: "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc123&cid=bafyreiabc123", 22 + }, 23 + { 24 + name: "trailing slash on PDS URL removed", 25 + pdsURL: "https://pds.example.com/", 26 + did: "did:plc:abc123", 27 + cid: "bafyreiabc123", 28 + expected: "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc123&cid=bafyreiabc123", 29 + }, 30 + { 31 + name: "empty pdsURL returns empty", 32 + pdsURL: "", 33 + did: "did:plc:abc123", 34 + cid: "bafyreiabc123", 35 + expected: "", 36 + }, 37 + { 38 + name: "empty did returns empty", 39 + pdsURL: "https://pds.example.com", 40 + did: "", 41 + cid: "bafyreiabc123", 42 + expected: "", 43 + }, 44 + { 45 + name: "empty cid returns empty", 46 + pdsURL: "https://pds.example.com", 47 + did: "did:plc:abc123", 48 + cid: "", 49 + expected: "", 50 + }, 51 + { 52 + name: "all empty returns empty", 53 + pdsURL: "", 54 + did: "", 55 + cid: "", 56 + expected: "", 57 + }, 58 + { 59 + name: "special characters in DID are URL encoded", 60 + pdsURL: "https://pds.example.com", 61 + did: "did:web:example.com:user", 62 + cid: "bafyreiabc123", 63 + expected: "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aweb%3Aexample.com%3Auser&cid=bafyreiabc123", 64 + }, 65 + } 66 + 67 + for _, tt := range tests { 68 + t.Run(tt.name, func(t *testing.T) { 69 + result := HydrateBlobURL(tt.pdsURL, tt.did, tt.cid) 70 + if result != tt.expected { 71 + t.Errorf("HydrateBlobURL(%q, %q, %q) = %q, want %q", 72 + tt.pdsURL, tt.did, tt.cid, result, tt.expected) 73 + } 74 + }) 75 + } 76 + } 77 + 78 + func TestHydrateImageProxyURL(t *testing.T) { 79 + tests := []struct { 80 + name string 81 + proxyBaseURL string 82 + preset string 83 + did string 84 + cid string 85 + expected string 86 + }{ 87 + { 88 + name: "generates correct format", 89 + proxyBaseURL: "https://coves.social", 90 + preset: "avatar", 91 + did: "did:plc:abc123", 92 + cid: "bafyreiabc123", 93 + expected: "https://coves.social/img/avatar/plain/did:plc:abc123/bafyreiabc123", 94 + }, 95 + { 96 + name: "trailing slash on proxy URL removed", 97 + proxyBaseURL: "https://coves.social/", 98 + preset: "thumb", 99 + did: "did:plc:abc123", 100 + cid: "bafyreiabc123", 101 + expected: "https://coves.social/img/thumb/plain/did:plc:abc123/bafyreiabc123", 102 + }, 103 + { 104 + name: "empty proxyBaseURL generates relative URL", 105 + proxyBaseURL: "", 106 + preset: "avatar", 107 + did: "did:plc:abc123", 108 + cid: "bafyreiabc123", 109 + expected: "/img/avatar/plain/did:plc:abc123/bafyreiabc123", 110 + }, 111 + { 112 + name: "empty preset returns empty", 113 + proxyBaseURL: "https://coves.social", 114 + preset: "", 115 + did: "did:plc:abc123", 116 + cid: "bafyreiabc123", 117 + expected: "", 118 + }, 119 + { 120 + name: "empty did returns empty", 121 + proxyBaseURL: "https://coves.social", 122 + preset: "avatar", 123 + did: "", 124 + cid: "bafyreiabc123", 125 + expected: "", 126 + }, 127 + { 128 + name: "empty cid returns empty", 129 + proxyBaseURL: "https://coves.social", 130 + preset: "avatar", 131 + did: "did:plc:abc123", 132 + cid: "", 133 + expected: "", 134 + }, 135 + { 136 + name: "all empty returns empty", 137 + proxyBaseURL: "", 138 + preset: "", 139 + did: "", 140 + cid: "", 141 + expected: "", 142 + }, 143 + { 144 + name: "DID with colons preserved in path", 145 + proxyBaseURL: "https://coves.social", 146 + preset: "avatar", 147 + did: "did:web:example.com:user", 148 + cid: "bafyreiabc123", 149 + // Colons are allowed in path segments per RFC 3986 150 + expected: "https://coves.social/img/avatar/plain/did:web:example.com:user/bafyreiabc123", 151 + }, 152 + { 153 + name: "forward slashes escaped in CID", 154 + proxyBaseURL: "https://coves.social", 155 + preset: "avatar", 156 + did: "did:plc:abc123", 157 + cid: "bafyrei+special/chars", 158 + // Forward slashes must be escaped; plus signs allowed per RFC 3986 159 + expected: "https://coves.social/img/avatar/plain/did:plc:abc123/bafyrei+special%2Fchars", 160 + }, 161 + } 162 + 163 + for _, tt := range tests { 164 + t.Run(tt.name, func(t *testing.T) { 165 + result := HydrateImageProxyURL(tt.proxyBaseURL, tt.preset, tt.did, tt.cid) 166 + if result != tt.expected { 167 + t.Errorf("HydrateImageProxyURL(%q, %q, %q, %q) = %q, want %q", 168 + tt.proxyBaseURL, tt.preset, tt.did, tt.cid, result, tt.expected) 169 + } 170 + }) 171 + } 172 + } 173 + 174 + func TestHydrateImageURL_ProxyDisabled(t *testing.T) { 175 + config := ImageURLConfig{ 176 + ProxyEnabled: false, 177 + ProxyBaseURL: "https://coves.social", 178 + } 179 + pdsURL := "https://pds.example.com" 180 + did := "did:plc:abc123" 181 + cid := "bafyreiabc123" 182 + preset := "avatar" 183 + 184 + result := HydrateImageURL(config, pdsURL, did, cid, preset) 185 + 186 + // Should return direct PDS URL when proxy is disabled 187 + expected := HydrateBlobURL(pdsURL, did, cid) 188 + if result != expected { 189 + t.Errorf("HydrateImageURL with proxy disabled = %q, want %q", result, expected) 190 + } 191 + } 192 + 193 + func TestHydrateImageURL_ProxyEnabled(t *testing.T) { 194 + config := ImageURLConfig{ 195 + ProxyEnabled: true, 196 + ProxyBaseURL: "https://coves.social", 197 + } 198 + pdsURL := "https://pds.example.com" 199 + did := "did:plc:abc123" 200 + cid := "bafyreiabc123" 201 + preset := "avatar" 202 + 203 + result := HydrateImageURL(config, pdsURL, did, cid, preset) 204 + 205 + // Should return proxy URL when proxy is enabled 206 + expected := HydrateImageProxyURL(config.ProxyBaseURL, preset, did, cid) 207 + if result != expected { 208 + t.Errorf("HydrateImageURL with proxy enabled = %q, want %q", result, expected) 209 + } 210 + } 211 + 212 + func TestHydrateImageURL_CDNOverride(t *testing.T) { 213 + config := ImageURLConfig{ 214 + ProxyEnabled: true, 215 + ProxyBaseURL: "https://coves.social", 216 + CDNURL: "https://cdn.coves.social", 217 + } 218 + pdsURL := "https://pds.example.com" 219 + did := "did:plc:abc123" 220 + cid := "bafyreiabc123" 221 + preset := "avatar" 222 + 223 + result := HydrateImageURL(config, pdsURL, did, cid, preset) 224 + 225 + // Should use CDN URL instead of proxy base URL 226 + expected := HydrateImageProxyURL(config.CDNURL, preset, did, cid) 227 + if result != expected { 228 + t.Errorf("HydrateImageURL with CDN URL = %q, want %q", result, expected) 229 + } 230 + 231 + // Verify CDN URL is actually in the result 232 + if !strings.HasPrefix(result, "https://cdn.coves.social/") { 233 + t.Errorf("Expected CDN URL prefix, got %q", result) 234 + } 235 + } 236 + 237 + func TestHydrateImageURL_EmptyPresetUsesDirectURL(t *testing.T) { 238 + config := ImageURLConfig{ 239 + ProxyEnabled: true, 240 + ProxyBaseURL: "https://coves.social", 241 + } 242 + pdsURL := "https://pds.example.com" 243 + did := "did:plc:abc123" 244 + cid := "bafyreiabc123" 245 + preset := "" // empty preset 246 + 247 + result := HydrateImageURL(config, pdsURL, did, cid, preset) 248 + 249 + // With empty preset, proxy URL will return empty, so fall back to direct URL 250 + // This tests the behavior when preset is not specified 251 + expected := HydrateBlobURL(pdsURL, did, cid) 252 + if result != expected { 253 + t.Errorf("HydrateImageURL with empty preset = %q, want %q", result, expected) 254 + } 255 + } 256 + 257 + func TestImageURLConfig(t *testing.T) { 258 + // Test that ImageURLConfig holds correct fields 259 + config := ImageURLConfig{ 260 + ProxyEnabled: true, 261 + ProxyBaseURL: "https://coves.social", 262 + CDNURL: "https://cdn.coves.social", 263 + } 264 + 265 + if !config.ProxyEnabled { 266 + t.Error("ProxyEnabled should be true") 267 + } 268 + if config.ProxyBaseURL != "https://coves.social" { 269 + t.Errorf("ProxyBaseURL = %q, want %q", config.ProxyBaseURL, "https://coves.social") 270 + } 271 + if config.CDNURL != "https://cdn.coves.social" { 272 + t.Errorf("CDNURL = %q, want %q", config.CDNURL, "https://cdn.coves.social") 273 + } 274 + }
+134 -2
internal/core/communities/community.go
··· 1 1 2 2 3 3 4 - 4 + "fmt" 5 5 "log" 6 6 "strings" 7 + "sync" 7 8 "time" 9 + 10 + "Coves/internal/core/blobs" 8 11 ) 9 12 10 - // Community represents a Coves community indexed from the firehose 13 + // imageProxyConfigOnce ensures thread-safe initialization of the image proxy config. 14 + var imageProxyConfigOnce sync.Once 15 + 16 + // imageProxyConfig holds the immutable configuration after initialization. 17 + // Access only through GetImageProxyConfig(). 18 + var imageProxyConfig = blobs.ImageURLConfig{ 19 + ProxyEnabled: false, // Default to disabled until configured 20 + } 21 + 22 + // imageProxyConfigInitialized tracks whether SetImageProxyConfig has been called. 23 + var imageProxyConfigInitialized bool 11 24 25 + // SetImageProxyConfig initializes the image proxy configuration. 26 + // This should be called once during server startup. Subsequent calls are no-ops 27 + // and will log a warning. This design ensures thread-safety and prevents 28 + // accidental config changes during runtime. 29 + func SetImageProxyConfig(config blobs.ImageURLConfig) { 30 + imageProxyConfigOnce.Do(func() { 31 + imageProxyConfig = config 32 + imageProxyConfigInitialized = true 33 + }) 34 + // Log warning if called multiple times (indicates a programming error) 35 + if imageProxyConfigInitialized && config != imageProxyConfig { 36 + log.Printf("WARN: SetImageProxyConfig called multiple times with different config (ignored)") 37 + } 38 + } 39 + 40 + // GetImageProxyConfig returns the current image proxy configuration. 41 + // Thread-safe for concurrent access. 42 + func GetImageProxyConfig() blobs.ImageURLConfig { 43 + return imageProxyConfig 44 + } 45 + 46 + // ResetImageProxyConfigForTesting resets the config state for testing purposes. 47 + // This should ONLY be used in tests, never in production code. 48 + func ResetImageProxyConfigForTesting() { 49 + imageProxyConfigOnce = sync.Once{} 50 + imageProxyConfig = blobs.ImageURLConfig{ProxyEnabled: false} 51 + imageProxyConfigInitialized = false 52 + } 12 53 54 + // Community represents a Coves community indexed from the firehose 55 + // Communities are federated, instance-scoped forums built on atProto 56 + type Community struct { 13 57 14 58 15 59 ··· 58 102 Member *bool `json:"member,omitempty"` 59 103 } 60 104 105 + // CommunityView is the API view for community lists 106 + // Based on social.coves.community.defs#communityView lexicon 107 + type CommunityView struct { 108 + DID string `json:"did"` 109 + Handle string `json:"handle,omitempty"` 110 + Name string `json:"name"` 111 + DisplayName string `json:"displayName,omitempty"` 112 + DisplayHandle string `json:"displayHandle,omitempty"` 113 + Avatar string `json:"avatar,omitempty"` // URL, not CID 114 + Visibility string `json:"visibility,omitempty"` 115 + SubscriberCount int `json:"subscriberCount"` 116 + MemberCount int `json:"memberCount"` 117 + PostCount int `json:"postCount"` 118 + Viewer *CommunityViewerState `json:"viewer,omitempty"` 119 + } 120 + 121 + // CommunityViewDetailed is the full API view for single community lookups 122 + // Based on social.coves.community.defs#communityViewDetailed lexicon 123 + type CommunityViewDetailed struct { 124 + DID string `json:"did"` 125 + Handle string `json:"handle,omitempty"` 126 + Name string `json:"name"` 127 + DisplayName string `json:"displayName,omitempty"` 128 + DisplayHandle string `json:"displayHandle,omitempty"` 129 + Description string `json:"description,omitempty"` 130 + Avatar string `json:"avatar,omitempty"` // URL 131 + Banner string `json:"banner,omitempty"` // URL 132 + CreatedByDID string `json:"createdBy,omitempty"` 133 + HostedByDID string `json:"hostedBy,omitempty"` 134 + Visibility string `json:"visibility,omitempty"` 135 + ModerationType string `json:"moderationType,omitempty"` 136 + ContentWarnings []string `json:"contentWarnings,omitempty"` 137 + CreatedAt time.Time `json:"createdAt"` 138 + AllowExternalDiscovery bool `json:"allowExternalDiscovery"` 139 + SubscriberCount int `json:"subscriberCount"` 140 + MemberCount int `json:"memberCount"` 141 + PostCount int `json:"postCount"` 142 + Viewer *CommunityViewerState `json:"viewer,omitempty"` 143 + } 144 + 61 145 // Subscription represents a lightweight feed follow (user subscribes to see posts) 62 146 type Subscription struct { 63 147 SubscribedAt time.Time `json:"subscribedAt" db:"subscribed_at"` ··· 204 288 func (c *Community) GetPDSAccessToken() string { 205 289 return c.PDSAccessToken 206 290 } 291 + 292 + // ToCommunityView converts a Community to a CommunityView for API responses 293 + // Uses avatar_small preset (24px) for list views 294 + func (c *Community) ToCommunityView() *CommunityView { 295 + view := &CommunityView{ 296 + DID: c.DID, 297 + Handle: c.Handle, 298 + Name: c.Name, 299 + DisplayName: c.DisplayName, 300 + DisplayHandle: c.GetDisplayHandle(), 301 + Avatar: blobs.HydrateImageURL(GetImageProxyConfig(), c.PDSURL, c.DID, c.AvatarCID, "avatar_small"), 302 + Visibility: c.Visibility, 303 + SubscriberCount: c.SubscriberCount, 304 + MemberCount: c.MemberCount, 305 + PostCount: c.PostCount, 306 + Viewer: c.Viewer, 307 + } 308 + 309 + return view 310 + } 311 + 312 + // ToCommunityViewDetailed converts a Community to a CommunityViewDetailed for API responses 313 + // Uses avatar preset (80px) for detail views and banner preset for banners 314 + func (c *Community) ToCommunityViewDetailed() *CommunityViewDetailed { 315 + view := &CommunityViewDetailed{ 316 + DID: c.DID, 317 + Handle: c.Handle, 318 + Name: c.Name, 319 + DisplayName: c.DisplayName, 320 + DisplayHandle: c.GetDisplayHandle(), 321 + Description: c.Description, 322 + Avatar: blobs.HydrateImageURL(GetImageProxyConfig(), c.PDSURL, c.DID, c.AvatarCID, "avatar"), 323 + Banner: blobs.HydrateImageURL(GetImageProxyConfig(), c.PDSURL, c.DID, c.BannerCID, "banner"), 324 + CreatedByDID: c.CreatedByDID, 325 + HostedByDID: c.HostedByDID, 326 + Visibility: c.Visibility, 327 + ModerationType: c.ModerationType, 328 + ContentWarnings: c.ContentWarnings, 329 + CreatedAt: c.CreatedAt, 330 + AllowExternalDiscovery: c.AllowExternalDiscovery, 331 + SubscriberCount: c.SubscriberCount, 332 + MemberCount: c.MemberCount, 333 + PostCount: c.PostCount, 334 + Viewer: c.Viewer, 335 + } 336 + 337 + return view 338 + }
+14
.env.prod.example
··· 169 169 # MUST be false in production to prevent domain spoofing 170 170 SKIP_DID_WEB_VERIFICATION=false 171 171 172 + # ============================================================================= 173 + # Image Proxy Configuration 174 + # ============================================================================= 175 + # On-the-fly image resizing with disk caching 176 + # Enabled by default - gracefully falls back to direct PDS URLs on failure 177 + IMAGE_PROXY_ENABLED=true 178 + IMAGE_PROXY_BASE_URL=https://coves.social 179 + IMAGE_PROXY_CACHE_PATH=/var/cache/coves/images 180 + IMAGE_PROXY_CACHE_MAX_GB=10 181 + # Optional: CDN URL for edge caching (recommended for production) 182 + # IMAGE_PROXY_CDN_URL=https://cdn.coves.social 183 + IMAGE_PROXY_FETCH_TIMEOUT_SECONDS=30 184 + IMAGE_PROXY_MAX_SOURCE_SIZE_MB=10 185 + 172 186 # ============================================================================= 173 187 # Optional: Versioning 174 188 # =============================================================================
+3
.gitignore
··· 38 38 /local_dev_data/ 39 39 /test_db_data/ 40 40 41 + # Image proxy cache 42 + /cache/ 43 + 41 44 # Logs 42 45 *.log 43 46
+60
cmd/server/main.go
··· 24 24 "Coves/internal/atproto/jetstream" 25 25 "Coves/internal/atproto/oauth" 26 26 27 + imageproxyhandlers "Coves/internal/api/handlers/imageproxy" 28 + "Coves/internal/core/imageproxy" 29 + 27 30 indigoauth "github.com/bluesky-social/indigo/atproto/auth" 28 31 indigoidentity "github.com/bluesky-social/indigo/atproto/identity" 29 32 "Coves/internal/core/aggregators" ··· 578 581 discoverService := discover.NewDiscoverService(discoverRepo) 579 582 log.Println("โœ… Discover service initialized") 580 583 584 + // Initialize image proxy (optional service for resizing/caching images) 585 + imageProxyConfig := imageproxy.ConfigFromEnv() 586 + var imageProxyCacheCleanupCancel context.CancelFunc = func() {} // No-op default 587 + if imageProxyConfig.Enabled { 588 + // Validate configuration at startup - fail fast if misconfigured 589 + if err := imageProxyConfig.Validate(); err != nil { 590 + log.Fatalf("Image proxy configuration error: %v", err) 591 + } 592 + 593 + imageProxyCache, err := imageproxy.NewDiskCache( 594 + imageProxyConfig.CachePath, 595 + imageProxyConfig.CacheMaxGB, 596 + imageProxyConfig.CacheTTLDays, 597 + ) 598 + if err != nil { 599 + log.Fatalf("Failed to create image proxy cache: %v", err) 600 + } 601 + 602 + // Start background cache cleanup job 603 + imageProxyCacheCleanupCancel = imageProxyCache.StartCleanupJob(imageProxyConfig.CleanupInterval) 604 + 605 + imageProxyProcessor := imageproxy.NewProcessor() 606 + imageProxyFetcher := imageproxy.NewPDSFetcher(imageProxyConfig.FetchTimeout, imageProxyConfig.MaxSourceSizeMB) 607 + imageProxyService, err := imageproxy.NewService( 608 + imageProxyCache, 609 + imageProxyProcessor, 610 + imageProxyFetcher, 611 + imageProxyConfig, 612 + ) 613 + if err != nil { 614 + log.Fatalf("Failed to create image proxy service: %v", err) 615 + } 616 + imageProxyHandler := imageproxyhandlers.NewHandler(imageProxyService, identityResolver) 617 + routes.RegisterImageProxyRoutes(r, imageProxyHandler) 618 + log.Println("โœ… Image proxy enabled at /img/{preset}/plain/{did}/{cid}") 619 + slog.Info("[IMAGE-PROXY] service started", 620 + "base_url", imageProxyConfig.BaseURL, 621 + "cdn_url", imageProxyConfig.CDNURL, 622 + "cache_path", imageProxyConfig.CachePath, 623 + "cache_max_gb", imageProxyConfig.CacheMaxGB, 624 + "cache_ttl_days", imageProxyConfig.CacheTTLDays, 625 + "cleanup_interval", imageProxyConfig.CleanupInterval, 626 + "fetch_timeout_seconds", int(imageProxyConfig.FetchTimeout.Seconds()), 627 + "max_source_size_mb", imageProxyConfig.MaxSourceSizeMB, 628 + ) 629 + } 630 + 631 + // Initialize image proxy config for URL generation in communities package 632 + // This is called once at startup and is thread-safe for concurrent access 633 + communities.SetImageProxyConfig(blobs.ImageURLConfig{ 634 + ProxyEnabled: imageProxyConfig.Enabled, 635 + ProxyBaseURL: imageProxyConfig.BaseURL, 636 + CDNURL: imageProxyConfig.CDNURL, 637 + }) 638 + log.Printf("Image proxy URL generation config set (enabled: %v)", imageProxyConfig.Enabled) 639 + 581 640 // Start Jetstream consumer for posts 582 641 // This consumer indexes posts created in community repositories via the firehose 583 642 // Currently handles only CREATE operations - UPDATE/DELETE deferred until those features exist ··· 829 888 // Stop background jobs 830 889 cleanupCancel() 831 890 tokenRefreshCancel() 891 + imageProxyCacheCleanupCancel() 832 892 833 893 if err := server.Shutdown(ctx); err != nil { 834 894 log.Fatalf("Server shutdown error: %v", err)
+189
internal/api/handlers/imageproxy/handler.go
··· 1 + // Package imageproxy provides HTTP handlers for the image proxy service. 2 + // It handles requests for proxied and transformed images from AT Protocol PDSes. 3 + package imageproxy 4 + 5 + import ( 6 + "context" 7 + "errors" 8 + "fmt" 9 + "log/slog" 10 + "net/http" 11 + 12 + "github.com/go-chi/chi/v5" 13 + 14 + "Coves/internal/atproto/identity" 15 + "Coves/internal/core/imageproxy" 16 + ) 17 + 18 + // Service defines the interface for the image proxy service. 19 + // This interface is implemented by the imageproxy package's service layer. 20 + type Service interface { 21 + // GetImage retrieves and processes an image from a PDS. 22 + // preset: the image transformation preset (e.g., "avatar", "banner") 23 + // did: the DID of the user who owns the blob 24 + // cid: the content identifier of the blob 25 + // pdsURL: the URL of the user's PDS 26 + GetImage(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) 27 + } 28 + 29 + // Handler handles HTTP requests for the image proxy. 30 + type Handler struct { 31 + service Service 32 + identityResolver identity.Resolver 33 + } 34 + 35 + // NewHandler creates a new image proxy handler. 36 + func NewHandler(service Service, resolver identity.Resolver) *Handler { 37 + return &Handler{ 38 + service: service, 39 + identityResolver: resolver, 40 + } 41 + } 42 + 43 + // HandleImage handles GET /img/{preset}/plain/{did}/{cid} 44 + // It fetches the image from the user's PDS, transforms it according to the preset, 45 + // and returns the result with appropriate caching headers. 46 + func (h *Handler) HandleImage(w http.ResponseWriter, r *http.Request) { 47 + // Parse URL parameters 48 + preset := chi.URLParam(r, "preset") 49 + did := chi.URLParam(r, "did") 50 + cid := chi.URLParam(r, "cid") 51 + 52 + // Validate required parameters 53 + if preset == "" || did == "" || cid == "" { 54 + writeErrorResponse(w, http.StatusBadRequest, "missing required parameters") 55 + return 56 + } 57 + 58 + // Validate preset exists before proceeding 59 + if _, err := imageproxy.GetPreset(preset); err != nil { 60 + if errors.Is(err, imageproxy.ErrInvalidPreset) { 61 + writeErrorResponse(w, http.StatusBadRequest, "invalid preset: "+preset) 62 + return 63 + } 64 + writeErrorResponse(w, http.StatusBadRequest, "invalid preset") 65 + return 66 + } 67 + 68 + // Validate DID format (must be did:plc: or did:web:) 69 + if err := imageproxy.ValidateDID(did); err != nil { 70 + writeErrorResponse(w, http.StatusBadRequest, "invalid DID format") 71 + return 72 + } 73 + 74 + // Validate CID format (must be valid base32/base58 CID) 75 + if err := imageproxy.ValidateCID(cid); err != nil { 76 + writeErrorResponse(w, http.StatusBadRequest, "invalid CID format") 77 + return 78 + } 79 + 80 + // Generate ETag for caching 81 + etag := fmt.Sprintf(`"%s-%s"`, preset, cid) 82 + 83 + // Check If-None-Match header for 304 response 84 + if r.Header.Get("If-None-Match") == etag { 85 + w.WriteHeader(http.StatusNotModified) 86 + return 87 + } 88 + 89 + // Resolve DID to get PDS URL 90 + didDoc, err := h.identityResolver.ResolveDID(r.Context(), did) 91 + if err != nil { 92 + slog.Warn("[IMAGE-PROXY] failed to resolve DID", 93 + "did", did, 94 + "error", err, 95 + ) 96 + writeErrorResponse(w, http.StatusBadGateway, "failed to resolve DID") 97 + return 98 + } 99 + 100 + // Extract PDS URL from DID document 101 + pdsURL := getPDSEndpoint(didDoc) 102 + if pdsURL == "" { 103 + slog.Warn("[IMAGE-PROXY] no PDS endpoint found in DID document", 104 + "did", did, 105 + ) 106 + writeErrorResponse(w, http.StatusBadGateway, "no PDS endpoint found") 107 + return 108 + } 109 + 110 + // Fetch and process the image 111 + imageData, err := h.service.GetImage(r.Context(), preset, did, cid, pdsURL) 112 + if err != nil { 113 + handleServiceError(w, err) 114 + return 115 + } 116 + 117 + // Set response headers 118 + w.Header().Set("Content-Type", "image/jpeg") 119 + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 120 + w.Header().Set("ETag", etag) 121 + 122 + // Write image data 123 + w.WriteHeader(http.StatusOK) 124 + if _, err := w.Write(imageData); err != nil { 125 + slog.Warn("[IMAGE-PROXY] failed to write image response", 126 + "preset", preset, 127 + "did", did, 128 + "cid", cid, 129 + "error", err, 130 + ) 131 + } 132 + } 133 + 134 + // getPDSEndpoint extracts the PDS service endpoint from a DID document. 135 + func getPDSEndpoint(doc *identity.DIDDocument) string { 136 + if doc == nil { 137 + return "" 138 + } 139 + for _, service := range doc.Service { 140 + if service.Type == "AtprotoPersonalDataServer" { 141 + return service.ServiceEndpoint 142 + } 143 + } 144 + return "" 145 + } 146 + 147 + // handleServiceError converts service errors to appropriate HTTP responses. 148 + func handleServiceError(w http.ResponseWriter, err error) { 149 + switch { 150 + case errors.Is(err, imageproxy.ErrPDSNotFound): 151 + writeErrorResponse(w, http.StatusNotFound, "blob not found") 152 + case errors.Is(err, imageproxy.ErrPDSTimeout): 153 + writeErrorResponse(w, http.StatusGatewayTimeout, "request timed out") 154 + case errors.Is(err, imageproxy.ErrPDSFetchFailed): 155 + writeErrorResponse(w, http.StatusBadGateway, "failed to fetch blob from PDS") 156 + case errors.Is(err, imageproxy.ErrInvalidPreset): 157 + writeErrorResponse(w, http.StatusBadRequest, "invalid preset") 158 + case errors.Is(err, imageproxy.ErrInvalidDID): 159 + writeErrorResponse(w, http.StatusBadRequest, "invalid DID format") 160 + case errors.Is(err, imageproxy.ErrInvalidCID): 161 + writeErrorResponse(w, http.StatusBadRequest, "invalid CID format") 162 + case errors.Is(err, imageproxy.ErrUnsupportedFormat): 163 + writeErrorResponse(w, http.StatusBadRequest, "unsupported image format") 164 + case errors.Is(err, imageproxy.ErrImageTooLarge): 165 + writeErrorResponse(w, http.StatusBadRequest, "image too large") 166 + case errors.Is(err, imageproxy.ErrProcessingFailed): 167 + writeErrorResponse(w, http.StatusInternalServerError, "image processing failed") 168 + default: 169 + slog.Error("[IMAGE-PROXY] unhandled service error", 170 + "error", err, 171 + ) 172 + writeErrorResponse(w, http.StatusInternalServerError, "internal server error") 173 + } 174 + } 175 + 176 + // writeErrorResponse writes a plain text error response. 177 + // For the image proxy, we use simple text responses rather than JSON 178 + // since the expected response is binary image data. 179 + func writeErrorResponse(w http.ResponseWriter, status int, message string) { 180 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 181 + w.WriteHeader(status) 182 + if _, err := w.Write([]byte(message)); err != nil { 183 + slog.Warn("[IMAGE-PROXY] failed to write error response", 184 + "status", status, 185 + "message", message, 186 + "error", err, 187 + ) 188 + } 189 + }
+663
internal/api/handlers/imageproxy/handler_test.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + 10 + "github.com/go-chi/chi/v5" 11 + 12 + "Coves/internal/atproto/identity" 13 + "Coves/internal/core/imageproxy" 14 + ) 15 + 16 + // Valid test constants that pass validation 17 + const ( 18 + // validTestDID is a valid did:plc identifier (24 lowercase base32 chars after did:plc:) 19 + validTestDID = "did:plc:z72i7hdynmk6r22z27h6tvur" 20 + // validTestCID is a valid CIDv1 base32 identifier 21 + validTestCID = "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi" 22 + ) 23 + 24 + // mockService implements imageproxy.Service for testing 25 + type mockService struct { 26 + getImageFunc func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) 27 + } 28 + 29 + func (m *mockService) GetImage(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 30 + if m.getImageFunc != nil { 31 + return m.getImageFunc(ctx, preset, did, cid, pdsURL) 32 + } 33 + return nil, errors.New("not implemented") 34 + } 35 + 36 + // mockIdentityResolver implements identity.Resolver for testing 37 + type mockIdentityResolver struct { 38 + resolveFunc func(ctx context.Context, identifier string) (*identity.Identity, error) 39 + resolveDIDFunc func(ctx context.Context, did string) (*identity.DIDDocument, error) 40 + } 41 + 42 + func (m *mockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 43 + if m.resolveFunc != nil { 44 + return m.resolveFunc(ctx, identifier) 45 + } 46 + return nil, errors.New("not implemented") 47 + } 48 + 49 + func (m *mockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) { 50 + return "", "", errors.New("not implemented") 51 + } 52 + 53 + func (m *mockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 54 + if m.resolveDIDFunc != nil { 55 + return m.resolveDIDFunc(ctx, did) 56 + } 57 + return nil, errors.New("not implemented") 58 + } 59 + 60 + func (m *mockIdentityResolver) Purge(ctx context.Context, identifier string) error { 61 + return nil 62 + } 63 + 64 + // createTestRequest creates an HTTP request with chi URL params 65 + func createTestRequest(method, path string, params map[string]string) *http.Request { 66 + req := httptest.NewRequest(method, path, nil) 67 + rctx := chi.NewRouteContext() 68 + for k, v := range params { 69 + rctx.URLParams.Add(k, v) 70 + } 71 + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) 72 + } 73 + 74 + func TestHandler_HandleImage_Success(t *testing.T) { 75 + expectedImage := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes 76 + testPDSURL := "https://pds.example.com" 77 + testPreset := "avatar" 78 + 79 + mockSvc := &mockService{ 80 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 81 + if preset != testPreset { 82 + t.Errorf("Expected preset %q, got %q", testPreset, preset) 83 + } 84 + if did != validTestDID { 85 + t.Errorf("Expected DID %q, got %q", validTestDID, did) 86 + } 87 + if cid != validTestCID { 88 + t.Errorf("Expected CID %q, got %q", validTestCID, cid) 89 + } 90 + if pdsURL != testPDSURL { 91 + t.Errorf("Expected PDS URL %q, got %q", testPDSURL, pdsURL) 92 + } 93 + return expectedImage, nil 94 + }, 95 + } 96 + 97 + mockResolver := &mockIdentityResolver{ 98 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 99 + return &identity.DIDDocument{ 100 + DID: did, 101 + Service: []identity.Service{ 102 + { 103 + ID: "#atproto_pds", 104 + Type: "AtprotoPersonalDataServer", 105 + ServiceEndpoint: testPDSURL, 106 + }, 107 + }, 108 + }, nil 109 + }, 110 + } 111 + 112 + handler := NewHandler(mockSvc, mockResolver) 113 + 114 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 115 + "preset": testPreset, 116 + "did": validTestDID, 117 + "cid": validTestCID, 118 + }) 119 + 120 + w := httptest.NewRecorder() 121 + handler.HandleImage(w, req) 122 + 123 + if w.Code != http.StatusOK { 124 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 125 + } 126 + 127 + // Verify Content-Type 128 + contentType := w.Header().Get("Content-Type") 129 + if contentType != "image/jpeg" { 130 + t.Errorf("Expected Content-Type image/jpeg, got %s", contentType) 131 + } 132 + 133 + // Verify Cache-Control 134 + cacheControl := w.Header().Get("Cache-Control") 135 + expectedCacheControl := "public, max-age=31536000, immutable" 136 + if cacheControl != expectedCacheControl { 137 + t.Errorf("Expected Cache-Control %q, got %q", expectedCacheControl, cacheControl) 138 + } 139 + 140 + // Verify ETag format 141 + etag := w.Header().Get("ETag") 142 + expectedETag := `"avatar-` + validTestCID + `"` 143 + if etag != expectedETag { 144 + t.Errorf("Expected ETag %q, got %q", expectedETag, etag) 145 + } 146 + 147 + // Verify body 148 + if w.Body.Len() != len(expectedImage) { 149 + t.Errorf("Expected body length %d, got %d", len(expectedImage), w.Body.Len()) 150 + } 151 + } 152 + 153 + func TestHandler_HandleImage_ETagMatch_Returns304(t *testing.T) { 154 + testPreset := "avatar" 155 + 156 + mockSvc := &mockService{ 157 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 158 + t.Error("Service should not be called when ETag matches") 159 + return nil, nil 160 + }, 161 + } 162 + 163 + mockResolver := &mockIdentityResolver{ 164 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 165 + t.Error("Resolver should not be called when ETag matches") 166 + return nil, nil 167 + }, 168 + } 169 + 170 + handler := NewHandler(mockSvc, mockResolver) 171 + 172 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 173 + "preset": testPreset, 174 + "did": validTestDID, 175 + "cid": validTestCID, 176 + }) 177 + // Set If-None-Match header with matching ETag 178 + req.Header.Set("If-None-Match", `"avatar-`+validTestCID+`"`) 179 + 180 + w := httptest.NewRecorder() 181 + handler.HandleImage(w, req) 182 + 183 + if w.Code != http.StatusNotModified { 184 + t.Errorf("Expected status 304, got %d. Body: %s", w.Code, w.Body.String()) 185 + } 186 + 187 + // Verify no body in 304 response 188 + if w.Body.Len() != 0 { 189 + t.Errorf("Expected empty body for 304 response, got %d bytes", w.Body.Len()) 190 + } 191 + } 192 + 193 + func TestHandler_HandleImage_ETagMismatch_ReturnsImage(t *testing.T) { 194 + expectedImage := []byte{0xFF, 0xD8, 0xFF, 0xE0} 195 + testPreset := "avatar" 196 + testPDSURL := "https://pds.example.com" 197 + 198 + serviceCalled := false 199 + mockSvc := &mockService{ 200 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 201 + serviceCalled = true 202 + return expectedImage, nil 203 + }, 204 + } 205 + 206 + mockResolver := &mockIdentityResolver{ 207 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 208 + return &identity.DIDDocument{ 209 + DID: did, 210 + Service: []identity.Service{ 211 + { 212 + ID: "#atproto_pds", 213 + Type: "AtprotoPersonalDataServer", 214 + ServiceEndpoint: testPDSURL, 215 + }, 216 + }, 217 + }, nil 218 + }, 219 + } 220 + 221 + handler := NewHandler(mockSvc, mockResolver) 222 + 223 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 224 + "preset": testPreset, 225 + "did": validTestDID, 226 + "cid": validTestCID, 227 + }) 228 + // Set If-None-Match header with different ETag 229 + req.Header.Set("If-None-Match", `"other-preset-somecid"`) 230 + 231 + w := httptest.NewRecorder() 232 + handler.HandleImage(w, req) 233 + 234 + if w.Code != http.StatusOK { 235 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 236 + } 237 + 238 + if !serviceCalled { 239 + t.Error("Service should have been called when ETag doesn't match") 240 + } 241 + } 242 + 243 + func TestHandler_HandleImage_InvalidPreset_Returns400(t *testing.T) { 244 + mockSvc := &mockService{} 245 + mockResolver := &mockIdentityResolver{} 246 + 247 + handler := NewHandler(mockSvc, mockResolver) 248 + 249 + req := createTestRequest(http.MethodGet, "/img/invalid_preset/plain/did:plc:test/somecid", map[string]string{ 250 + "preset": "invalid_preset", 251 + "did": "did:plc:test", 252 + "cid": "somecid", 253 + }) 254 + 255 + w := httptest.NewRecorder() 256 + handler.HandleImage(w, req) 257 + 258 + if w.Code != http.StatusBadRequest { 259 + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 260 + } 261 + 262 + // Verify error response contains error info 263 + body := w.Body.String() 264 + if body == "" { 265 + t.Error("Expected error message in response body") 266 + } 267 + } 268 + 269 + func TestHandler_HandleImage_DIDResolutionFailed_Returns502(t *testing.T) { 270 + mockSvc := &mockService{} 271 + mockResolver := &mockIdentityResolver{ 272 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 273 + return nil, errors.New("failed to resolve DID") 274 + }, 275 + } 276 + 277 + handler := NewHandler(mockSvc, mockResolver) 278 + 279 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 280 + "preset": "avatar", 281 + "did": validTestDID, 282 + "cid": validTestCID, 283 + }) 284 + 285 + w := httptest.NewRecorder() 286 + handler.HandleImage(w, req) 287 + 288 + if w.Code != http.StatusBadGateway { 289 + t.Errorf("Expected status 502, got %d. Body: %s", w.Code, w.Body.String()) 290 + } 291 + } 292 + 293 + func TestHandler_HandleImage_BlobNotFound_Returns404(t *testing.T) { 294 + testPDSURL := "https://pds.example.com" 295 + 296 + mockSvc := &mockService{ 297 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 298 + return nil, imageproxy.ErrPDSNotFound 299 + }, 300 + } 301 + 302 + mockResolver := &mockIdentityResolver{ 303 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 304 + return &identity.DIDDocument{ 305 + DID: did, 306 + Service: []identity.Service{ 307 + { 308 + ID: "#atproto_pds", 309 + Type: "AtprotoPersonalDataServer", 310 + ServiceEndpoint: testPDSURL, 311 + }, 312 + }, 313 + }, nil 314 + }, 315 + } 316 + 317 + handler := NewHandler(mockSvc, mockResolver) 318 + 319 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 320 + "preset": "avatar", 321 + "did": validTestDID, 322 + "cid": validTestCID, 323 + }) 324 + 325 + w := httptest.NewRecorder() 326 + handler.HandleImage(w, req) 327 + 328 + if w.Code != http.StatusNotFound { 329 + t.Errorf("Expected status 404, got %d. Body: %s", w.Code, w.Body.String()) 330 + } 331 + } 332 + 333 + func TestHandler_HandleImage_Timeout_Returns504(t *testing.T) { 334 + testPDSURL := "https://pds.example.com" 335 + 336 + mockSvc := &mockService{ 337 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 338 + return nil, imageproxy.ErrPDSTimeout 339 + }, 340 + } 341 + 342 + mockResolver := &mockIdentityResolver{ 343 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 344 + return &identity.DIDDocument{ 345 + DID: did, 346 + Service: []identity.Service{ 347 + { 348 + ID: "#atproto_pds", 349 + Type: "AtprotoPersonalDataServer", 350 + ServiceEndpoint: testPDSURL, 351 + }, 352 + }, 353 + }, nil 354 + }, 355 + } 356 + 357 + handler := NewHandler(mockSvc, mockResolver) 358 + 359 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 360 + "preset": "avatar", 361 + "did": validTestDID, 362 + "cid": validTestCID, 363 + }) 364 + 365 + w := httptest.NewRecorder() 366 + handler.HandleImage(w, req) 367 + 368 + if w.Code != http.StatusGatewayTimeout { 369 + t.Errorf("Expected status 504, got %d. Body: %s", w.Code, w.Body.String()) 370 + } 371 + } 372 + 373 + func TestHandler_HandleImage_InternalError_Returns500(t *testing.T) { 374 + testPDSURL := "https://pds.example.com" 375 + 376 + mockSvc := &mockService{ 377 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 378 + return nil, errors.New("unexpected internal error") 379 + }, 380 + } 381 + 382 + mockResolver := &mockIdentityResolver{ 383 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 384 + return &identity.DIDDocument{ 385 + DID: did, 386 + Service: []identity.Service{ 387 + { 388 + ID: "#atproto_pds", 389 + Type: "AtprotoPersonalDataServer", 390 + ServiceEndpoint: testPDSURL, 391 + }, 392 + }, 393 + }, nil 394 + }, 395 + } 396 + 397 + handler := NewHandler(mockSvc, mockResolver) 398 + 399 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 400 + "preset": "avatar", 401 + "did": validTestDID, 402 + "cid": validTestCID, 403 + }) 404 + 405 + w := httptest.NewRecorder() 406 + handler.HandleImage(w, req) 407 + 408 + if w.Code != http.StatusInternalServerError { 409 + t.Errorf("Expected status 500, got %d. Body: %s", w.Code, w.Body.String()) 410 + } 411 + } 412 + 413 + func TestHandler_HandleImage_PDSFetchFailed_Returns502(t *testing.T) { 414 + testPDSURL := "https://pds.example.com" 415 + 416 + mockSvc := &mockService{ 417 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 418 + return nil, imageproxy.ErrPDSFetchFailed 419 + }, 420 + } 421 + 422 + mockResolver := &mockIdentityResolver{ 423 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 424 + return &identity.DIDDocument{ 425 + DID: did, 426 + Service: []identity.Service{ 427 + { 428 + ID: "#atproto_pds", 429 + Type: "AtprotoPersonalDataServer", 430 + ServiceEndpoint: testPDSURL, 431 + }, 432 + }, 433 + }, nil 434 + }, 435 + } 436 + 437 + handler := NewHandler(mockSvc, mockResolver) 438 + 439 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 440 + "preset": "avatar", 441 + "did": validTestDID, 442 + "cid": validTestCID, 443 + }) 444 + 445 + w := httptest.NewRecorder() 446 + handler.HandleImage(w, req) 447 + 448 + if w.Code != http.StatusBadGateway { 449 + t.Errorf("Expected status 502, got %d. Body: %s", w.Code, w.Body.String()) 450 + } 451 + } 452 + 453 + func TestHandler_HandleImage_MissingParams(t *testing.T) { 454 + mockSvc := &mockService{} 455 + mockResolver := &mockIdentityResolver{} 456 + 457 + handler := NewHandler(mockSvc, mockResolver) 458 + 459 + tests := []struct { 460 + name string 461 + params map[string]string 462 + }{ 463 + { 464 + name: "missing preset", 465 + params: map[string]string{"did": "did:plc:test", "cid": "somecid"}, 466 + }, 467 + { 468 + name: "missing did", 469 + params: map[string]string{"preset": "avatar", "cid": "somecid"}, 470 + }, 471 + { 472 + name: "missing cid", 473 + params: map[string]string{"preset": "avatar", "did": "did:plc:test"}, 474 + }, 475 + { 476 + name: "empty preset", 477 + params: map[string]string{"preset": "", "did": "did:plc:test", "cid": "somecid"}, 478 + }, 479 + { 480 + name: "empty did", 481 + params: map[string]string{"preset": "avatar", "did": "", "cid": "somecid"}, 482 + }, 483 + { 484 + name: "empty cid", 485 + params: map[string]string{"preset": "avatar", "did": "did:plc:test", "cid": ""}, 486 + }, 487 + } 488 + 489 + for _, tc := range tests { 490 + t.Run(tc.name, func(t *testing.T) { 491 + req := createTestRequest(http.MethodGet, "/img/test/plain/did:plc:test/cid", tc.params) 492 + 493 + w := httptest.NewRecorder() 494 + handler.HandleImage(w, req) 495 + 496 + if w.Code != http.StatusBadRequest { 497 + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 498 + } 499 + }) 500 + } 501 + } 502 + 503 + func TestHandler_HandleImage_AllPresets(t *testing.T) { 504 + expectedImage := []byte{0xFF, 0xD8, 0xFF, 0xE0} 505 + testPDSURL := "https://pds.example.com" 506 + 507 + // Test all valid presets 508 + validPresets := []string{"avatar", "avatar_small", "banner", "content_preview", "content_full", "embed_thumbnail"} 509 + 510 + for _, preset := range validPresets { 511 + t.Run(preset, func(t *testing.T) { 512 + mockSvc := &mockService{ 513 + getImageFunc: func(ctx context.Context, p, did, cid, pdsURL string) ([]byte, error) { 514 + if p != preset { 515 + t.Errorf("Expected preset %q, got %q", preset, p) 516 + } 517 + return expectedImage, nil 518 + }, 519 + } 520 + 521 + mockResolver := &mockIdentityResolver{ 522 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 523 + return &identity.DIDDocument{ 524 + DID: did, 525 + Service: []identity.Service{ 526 + { 527 + ID: "#atproto_pds", 528 + Type: "AtprotoPersonalDataServer", 529 + ServiceEndpoint: testPDSURL, 530 + }, 531 + }, 532 + }, nil 533 + }, 534 + } 535 + 536 + handler := NewHandler(mockSvc, mockResolver) 537 + 538 + req := createTestRequest(http.MethodGet, "/img/"+preset+"/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 539 + "preset": preset, 540 + "did": validTestDID, 541 + "cid": validTestCID, 542 + }) 543 + 544 + w := httptest.NewRecorder() 545 + handler.HandleImage(w, req) 546 + 547 + if w.Code != http.StatusOK { 548 + t.Errorf("Expected status 200 for preset %q, got %d. Body: %s", preset, w.Code, w.Body.String()) 549 + } 550 + 551 + // Verify ETag matches preset 552 + etag := w.Header().Get("ETag") 553 + expectedETag := `"` + preset + `-` + validTestCID + `"` 554 + if etag != expectedETag { 555 + t.Errorf("Expected ETag %q, got %q", expectedETag, etag) 556 + } 557 + }) 558 + } 559 + } 560 + 561 + func TestHandler_HandleImage_NoPDSEndpoint_Returns502(t *testing.T) { 562 + mockSvc := &mockService{} 563 + mockResolver := &mockIdentityResolver{ 564 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 565 + // Return document without PDS service 566 + return &identity.DIDDocument{ 567 + DID: did, 568 + Service: []identity.Service{}, 569 + }, nil 570 + }, 571 + } 572 + 573 + handler := NewHandler(mockSvc, mockResolver) 574 + 575 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 576 + "preset": "avatar", 577 + "did": validTestDID, 578 + "cid": validTestCID, 579 + }) 580 + 581 + w := httptest.NewRecorder() 582 + handler.HandleImage(w, req) 583 + 584 + if w.Code != http.StatusBadGateway { 585 + t.Errorf("Expected status 502, got %d. Body: %s", w.Code, w.Body.String()) 586 + } 587 + } 588 + 589 + // TestHandler_HandleImage_InvalidDID tests that invalid DIDs are rejected 590 + // Note: We use Indigo's syntax.ParseDID for validation consistency with the codebase. 591 + // Some DIDs that look "wrong" (like did:plc:abc) are actually valid per Indigo's parser. 592 + func TestHandler_HandleImage_InvalidDID(t *testing.T) { 593 + mockSvc := &mockService{} 594 + mockResolver := &mockIdentityResolver{} 595 + 596 + handler := NewHandler(mockSvc, mockResolver) 597 + 598 + // These DIDs are invalid per Indigo's syntax.ParseDID (or fail our security checks) 599 + // Note: null bytes can't be tested at HTTP layer - Go's HTTP library rejects them first 600 + invalidDIDs := []struct { 601 + name string 602 + did string 603 + }{ 604 + {"missing method", "did:abc123"}, 605 + {"path traversal", "did:plc:../../../etc/passwd"}, 606 + {"not a DID", "notadid"}, 607 + {"forward slash", "did:plc:abc/def"}, 608 + {"backslash", "did:plc:abc\\def"}, 609 + {"empty string", ""}, 610 + } 611 + 612 + for _, tc := range invalidDIDs { 613 + t.Run(tc.name, func(t *testing.T) { 614 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+tc.did+"/"+validTestCID, map[string]string{ 615 + "preset": "avatar", 616 + "did": tc.did, 617 + "cid": validTestCID, 618 + }) 619 + 620 + w := httptest.NewRecorder() 621 + handler.HandleImage(w, req) 622 + 623 + if w.Code != http.StatusBadRequest { 624 + t.Errorf("Expected status 400 for invalid DID %q, got %d. Body: %s", tc.did, w.Code, w.Body.String()) 625 + } 626 + }) 627 + } 628 + } 629 + 630 + // TestHandler_HandleImage_InvalidCID tests that invalid CIDs are rejected 631 + func TestHandler_HandleImage_InvalidCID(t *testing.T) { 632 + mockSvc := &mockService{} 633 + mockResolver := &mockIdentityResolver{} 634 + 635 + handler := NewHandler(mockSvc, mockResolver) 636 + 637 + invalidCIDs := []struct { 638 + name string 639 + cid string 640 + }{ 641 + {"too short", "bafyabc"}, 642 + {"path traversal", "../../../etc/passwd"}, 643 + {"contains slash", "bafy/path/to/file"}, 644 + {"random string", "this_is_not_a_cid"}, 645 + } 646 + 647 + for _, tc := range invalidCIDs { 648 + t.Run(tc.name, func(t *testing.T) { 649 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+tc.cid, map[string]string{ 650 + "preset": "avatar", 651 + "did": validTestDID, 652 + "cid": tc.cid, 653 + }) 654 + 655 + w := httptest.NewRecorder() 656 + handler.HandleImage(w, req) 657 + 658 + if w.Code != http.StatusBadRequest { 659 + t.Errorf("Expected status 400 for invalid CID %q, got %d. Body: %s", tc.cid, w.Code, w.Body.String()) 660 + } 661 + }) 662 + } 663 + }
+22
internal/api/routes/imageproxy.go
··· 1 + package routes 2 + 3 + import ( 4 + "github.com/go-chi/chi/v5" 5 + 6 + imageproxyhandlers "Coves/internal/api/handlers/imageproxy" 7 + ) 8 + 9 + // RegisterImageProxyRoutes registers image proxy endpoints on the router. 10 + // The image proxy serves transformed images from AT Protocol PDSes. 11 + // 12 + // Route: GET /img/{preset}/plain/{did}/{cid} 13 + // 14 + // Parameters: 15 + // - preset: Image transformation preset (e.g., "avatar", "banner", "content_preview") 16 + // - did: DID of the user who owns the blob 17 + // - cid: Content identifier of the blob 18 + // 19 + // The endpoint supports ETag-based caching with If-None-Match headers. 20 + func RegisterImageProxyRoutes(r chi.Router, handler *imageproxyhandlers.Handler) { 21 + r.Get("/img/{preset}/plain/{did}/{cid}", handler.HandleImage) 22 + }
+539
internal/core/imageproxy/cache.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "io/fs" 7 + "log/slog" 8 + "os" 9 + "path/filepath" 10 + "sort" 11 + "strings" 12 + "time" 13 + ) 14 + 15 + var ( 16 + // ErrEmptyParameter is returned when a required parameter is empty 17 + ErrEmptyParameter = errors.New("required parameter is empty") 18 + // ErrInvalidCacheBasePath is returned when the cache base path is empty 19 + ErrInvalidCacheBasePath = errors.New("cache base path cannot be empty") 20 + // ErrInvalidCacheMaxSize is returned when maxSizeGB is not positive 21 + ErrInvalidCacheMaxSize = errors.New("cache max size must be positive") 22 + ) 23 + 24 + // Cache defines the interface for image proxy caching 25 + type Cache interface { 26 + // Get retrieves cached image data for the given preset, DID, and CID. 27 + // Returns the data, whether it was found, and any error. 28 + Get(preset, did, cid string) ([]byte, bool, error) 29 + 30 + // Set stores image data in the cache for the given preset, DID, and CID. 31 + Set(preset, did, cid string, data []byte) error 32 + 33 + // Delete removes cached image data for the given preset, DID, and CID. 34 + Delete(preset, did, cid string) error 35 + 36 + // Cleanup runs both LRU eviction and TTL cleanup. 37 + // Returns the number of entries removed and any error. 38 + Cleanup() (int, error) 39 + } 40 + 41 + // DiskCache implements Cache using the filesystem for storage. 42 + // Cache key format: {basePath}/{preset}/{did_safe}/{cid} 43 + // where did_safe has colons replaced with underscores for filesystem safety. 44 + type DiskCache struct { 45 + basePath string 46 + maxSizeGB int 47 + ttlDays int 48 + } 49 + 50 + // NewDiskCache creates a new DiskCache with the specified base path, maximum size, and TTL. 51 + // Returns an error if basePath is empty or maxSizeGB is not positive. 52 + // ttlDays of 0 disables TTL-based cleanup (only LRU eviction applies). 53 + func NewDiskCache(basePath string, maxSizeGB int, ttlDays int) (*DiskCache, error) { 54 + if basePath == "" { 55 + return nil, ErrInvalidCacheBasePath 56 + } 57 + if maxSizeGB <= 0 { 58 + return nil, ErrInvalidCacheMaxSize 59 + } 60 + if ttlDays < 0 { 61 + return nil, errors.New("ttlDays cannot be negative") 62 + } 63 + return &DiskCache{ 64 + basePath: basePath, 65 + maxSizeGB: maxSizeGB, 66 + ttlDays: ttlDays, 67 + }, nil 68 + } 69 + 70 + // makeDIDSafe converts a DID to a filesystem-safe directory name. 71 + // It sanitizes the input to prevent path traversal attacks by: 72 + // - Replacing colons with underscores 73 + // - Removing path separators (/ and \) 74 + // - Removing path traversal sequences (..) 75 + // - Removing null bytes 76 + func makeDIDSafe(did string) string { 77 + // Replace colons with underscores 78 + s := strings.ReplaceAll(did, ":", "_") 79 + 80 + // Remove path separators to prevent directory escape 81 + s = strings.ReplaceAll(s, "/", "") 82 + s = strings.ReplaceAll(s, "\\", "") 83 + 84 + // Remove path traversal sequences 85 + s = strings.ReplaceAll(s, "..", "") 86 + 87 + // Remove null bytes (could be used to terminate strings early) 88 + s = strings.ReplaceAll(s, "\x00", "") 89 + 90 + return s 91 + } 92 + 93 + // makeCIDSafe sanitizes a CID for use in filesystem paths. 94 + // It removes characters that could be used for path traversal attacks. 95 + func makeCIDSafe(cid string) string { 96 + // Remove path separators to prevent directory escape 97 + s := strings.ReplaceAll(cid, "/", "") 98 + s = strings.ReplaceAll(s, "\\", "") 99 + 100 + // Remove path traversal sequences 101 + s = strings.ReplaceAll(s, "..", "") 102 + 103 + // Remove null bytes 104 + s = strings.ReplaceAll(s, "\x00", "") 105 + 106 + return s 107 + } 108 + 109 + // makePresetSafe sanitizes a preset name for use in filesystem paths. 110 + func makePresetSafe(preset string) string { 111 + // Remove path separators 112 + s := strings.ReplaceAll(preset, "/", "") 113 + s = strings.ReplaceAll(s, "\\", "") 114 + 115 + // Remove path traversal sequences 116 + s = strings.ReplaceAll(s, "..", "") 117 + 118 + // Remove null bytes 119 + s = strings.ReplaceAll(s, "\x00", "") 120 + 121 + return s 122 + } 123 + 124 + // cachePath constructs the full filesystem path for a cached item. 125 + // All components are sanitized to prevent path traversal attacks. 126 + func (c *DiskCache) cachePath(preset, did, cid string) string { 127 + presetSafe := makePresetSafe(preset) 128 + didSafe := makeDIDSafe(did) 129 + cidSafe := makeCIDSafe(cid) 130 + return filepath.Join(c.basePath, presetSafe, didSafe, cidSafe) 131 + } 132 + 133 + // validateParams checks that all required parameters are non-empty. 134 + func validateParams(preset, did, cid string) error { 135 + if preset == "" || did == "" || cid == "" { 136 + return ErrEmptyParameter 137 + } 138 + return nil 139 + } 140 + 141 + // Get retrieves cached image data for the given preset, DID, and CID. 142 + // Returns the data, whether it was found, and any error. 143 + // If the item is not in cache, returns (nil, false, nil). 144 + // Updates the file's modification time on access for LRU tracking. 145 + func (c *DiskCache) Get(preset, did, cid string) ([]byte, bool, error) { 146 + if err := validateParams(preset, did, cid); err != nil { 147 + return nil, false, err 148 + } 149 + 150 + path := c.cachePath(preset, did, cid) 151 + 152 + data, err := os.ReadFile(path) 153 + if err != nil { 154 + if os.IsNotExist(err) { 155 + return nil, false, nil 156 + } 157 + return nil, false, err 158 + } 159 + 160 + // Update mtime for LRU tracking 161 + // Log errors as warnings since failed mtime updates degrade LRU accuracy 162 + now := time.Now() 163 + if chtimesErr := os.Chtimes(path, now, now); chtimesErr != nil { 164 + slog.Warn("[IMAGE-PROXY] failed to update mtime for LRU tracking", 165 + "path", path, 166 + "error", chtimesErr, 167 + ) 168 + } 169 + 170 + return data, true, nil 171 + } 172 + 173 + // Set stores image data in the cache for the given preset, DID, and CID. 174 + // Creates necessary directories if they don't exist. 175 + func (c *DiskCache) Set(preset, did, cid string, data []byte) error { 176 + if err := validateParams(preset, did, cid); err != nil { 177 + return err 178 + } 179 + 180 + path := c.cachePath(preset, did, cid) 181 + dir := filepath.Dir(path) 182 + 183 + // Create directory structure if it doesn't exist 184 + if err := os.MkdirAll(dir, 0755); err != nil { 185 + return err 186 + } 187 + 188 + // Write the file atomically by writing to a temp file first 189 + // then renaming (to avoid partial writes on crash) 190 + tmpPath := path + ".tmp" 191 + if err := os.WriteFile(tmpPath, data, 0644); err != nil { 192 + return err 193 + } 194 + 195 + return os.Rename(tmpPath, path) 196 + } 197 + 198 + // Delete removes cached image data for the given preset, DID, and CID. 199 + // Returns nil if the item doesn't exist (idempotent delete). 200 + func (c *DiskCache) Delete(preset, did, cid string) error { 201 + if err := validateParams(preset, did, cid); err != nil { 202 + return err 203 + } 204 + 205 + path := c.cachePath(preset, did, cid) 206 + 207 + err := os.Remove(path) 208 + if err != nil && !os.IsNotExist(err) { 209 + return err 210 + } 211 + 212 + return nil 213 + } 214 + 215 + // cacheEntry represents a cached file with its metadata. 216 + type cacheEntry struct { 217 + path string 218 + size int64 219 + modTime time.Time 220 + } 221 + 222 + // scanCache walks the cache directory and returns all cache entries. 223 + func (c *DiskCache) scanCache() ([]cacheEntry, int64, error) { 224 + var entries []cacheEntry 225 + var totalSize int64 226 + 227 + err := filepath.WalkDir(c.basePath, func(path string, d fs.DirEntry, err error) error { 228 + if err != nil { 229 + return err 230 + } 231 + if d.IsDir() { 232 + return nil 233 + } 234 + 235 + info, err := d.Info() 236 + if err != nil { 237 + slog.Warn("[IMAGE-PROXY] failed to stat file during cache scan, cache size may be inaccurate", 238 + "path", path, 239 + "error", err, 240 + ) 241 + return nil // Skip files we can't stat 242 + } 243 + 244 + entries = append(entries, cacheEntry{ 245 + path: path, 246 + size: info.Size(), 247 + modTime: info.ModTime(), 248 + }) 249 + totalSize += info.Size() 250 + 251 + return nil 252 + }) 253 + 254 + if err != nil && !os.IsNotExist(err) { 255 + return nil, 0, err 256 + } 257 + 258 + return entries, totalSize, nil 259 + } 260 + 261 + // GetCacheSize returns the current cache size in bytes. 262 + func (c *DiskCache) GetCacheSize() (int64, error) { 263 + _, totalSize, err := c.scanCache() 264 + return totalSize, err 265 + } 266 + 267 + // EvictLRU removes the least recently used entries until the cache is under the size limit. 268 + // Returns the number of entries removed. 269 + func (c *DiskCache) EvictLRU() (int, error) { 270 + entries, totalSize, err := c.scanCache() 271 + if err != nil { 272 + return 0, err 273 + } 274 + 275 + maxSizeBytes := int64(c.maxSizeGB) * 1024 * 1024 * 1024 276 + if totalSize <= maxSizeBytes { 277 + return 0, nil // Under limit, nothing to do 278 + } 279 + 280 + // Sort by modification time (oldest first for LRU) 281 + sort.Slice(entries, func(i, j int) bool { 282 + return entries[i].modTime.Before(entries[j].modTime) 283 + }) 284 + 285 + removed := 0 286 + for _, entry := range entries { 287 + if totalSize <= maxSizeBytes { 288 + break 289 + } 290 + 291 + if err := os.Remove(entry.path); err != nil { 292 + if !os.IsNotExist(err) { 293 + slog.Warn("[IMAGE-PROXY] failed to remove cache entry during LRU eviction", 294 + "path", entry.path, 295 + "error", err, 296 + ) 297 + } 298 + continue 299 + } 300 + 301 + totalSize -= entry.size 302 + removed++ 303 + 304 + slog.Debug("[IMAGE-PROXY] evicted cache entry (LRU)", 305 + "path", entry.path, 306 + "size_bytes", entry.size, 307 + ) 308 + } 309 + 310 + if removed > 0 { 311 + slog.Info("[IMAGE-PROXY] LRU eviction completed", 312 + "entries_removed", removed, 313 + "new_size_bytes", totalSize, 314 + "max_size_bytes", maxSizeBytes, 315 + ) 316 + } 317 + 318 + return removed, nil 319 + } 320 + 321 + // CleanExpired removes cache entries older than the configured TTL. 322 + // Returns the number of entries removed. 323 + // If TTL is 0 (disabled), returns 0 without scanning. 324 + func (c *DiskCache) CleanExpired() (int, error) { 325 + if c.ttlDays <= 0 { 326 + return 0, nil // TTL disabled 327 + } 328 + 329 + entries, _, err := c.scanCache() 330 + if err != nil { 331 + return 0, err 332 + } 333 + 334 + cutoff := time.Now().AddDate(0, 0, -c.ttlDays) 335 + removed := 0 336 + 337 + for _, entry := range entries { 338 + if entry.modTime.After(cutoff) { 339 + continue // Not expired 340 + } 341 + 342 + if err := os.Remove(entry.path); err != nil { 343 + if !os.IsNotExist(err) { 344 + slog.Warn("[IMAGE-PROXY] failed to remove expired cache entry", 345 + "path", entry.path, 346 + "mod_time", entry.modTime, 347 + "error", err, 348 + ) 349 + } 350 + continue 351 + } 352 + 353 + removed++ 354 + 355 + slog.Debug("[IMAGE-PROXY] removed expired cache entry", 356 + "path", entry.path, 357 + "mod_time", entry.modTime, 358 + "ttl_days", c.ttlDays, 359 + ) 360 + } 361 + 362 + if removed > 0 { 363 + slog.Info("[IMAGE-PROXY] TTL cleanup completed", 364 + "entries_removed", removed, 365 + "ttl_days", c.ttlDays, 366 + ) 367 + } 368 + 369 + return removed, nil 370 + } 371 + 372 + // Cleanup runs both TTL cleanup and LRU eviction. 373 + // TTL cleanup runs first (removes definitely stale entries), 374 + // then LRU eviction runs if still over size limit. 375 + // Returns the total number of entries removed. 376 + func (c *DiskCache) Cleanup() (int, error) { 377 + totalRemoved := 0 378 + 379 + // First, remove expired entries (definitely stale) 380 + ttlRemoved, err := c.CleanExpired() 381 + if err != nil { 382 + return 0, err 383 + } 384 + totalRemoved += ttlRemoved 385 + 386 + // Then, run LRU eviction if still over limit 387 + lruRemoved, err := c.EvictLRU() 388 + if err != nil { 389 + return totalRemoved, err 390 + } 391 + totalRemoved += lruRemoved 392 + 393 + return totalRemoved, nil 394 + } 395 + 396 + // cleanEmptyDirs removes empty directories under the cache base path. 397 + // This is useful after eviction/cleanup to remove orphaned preset/DID directories. 398 + func (c *DiskCache) cleanEmptyDirs() error { 399 + // Walk in reverse depth order to clean leaf directories first 400 + var dirs []string 401 + 402 + var walkErrors []error 403 + err := filepath.WalkDir(c.basePath, func(path string, d fs.DirEntry, err error) error { 404 + if err != nil { 405 + // Log WalkDir errors but continue scanning to clean as much as possible 406 + slog.Warn("[IMAGE-PROXY] error during empty dir cleanup scan", 407 + "path", path, 408 + "error", err, 409 + ) 410 + walkErrors = append(walkErrors, err) 411 + return nil // Continue scanning despite errors 412 + } 413 + if d.IsDir() && path != c.basePath { 414 + dirs = append(dirs, path) 415 + } 416 + return nil 417 + }) 418 + 419 + if err != nil { 420 + return err 421 + } 422 + 423 + if len(walkErrors) > 0 { 424 + slog.Warn("[IMAGE-PROXY] encountered errors during empty dir cleanup scan", 425 + "error_count", len(walkErrors), 426 + ) 427 + } 428 + 429 + // Sort by length descending (deepest paths first) 430 + sort.Slice(dirs, func(i, j int) bool { 431 + return len(dirs[i]) > len(dirs[j]) 432 + }) 433 + 434 + var removeErrors int 435 + for _, dir := range dirs { 436 + entries, err := os.ReadDir(dir) 437 + if err != nil { 438 + slog.Warn("[IMAGE-PROXY] failed to read directory during cleanup", 439 + "path", dir, 440 + "error", err, 441 + ) 442 + continue 443 + } 444 + if len(entries) == 0 { 445 + if removeErr := os.Remove(dir); removeErr != nil { 446 + slog.Warn("[IMAGE-PROXY] failed to remove empty directory", 447 + "path", dir, 448 + "error", removeErr, 449 + ) 450 + removeErrors++ 451 + } 452 + } 453 + } 454 + 455 + if removeErrors > 0 { 456 + slog.Warn("[IMAGE-PROXY] some empty directories could not be removed", 457 + "failed_count", removeErrors, 458 + ) 459 + } 460 + 461 + return nil 462 + } 463 + 464 + // StartCleanupJob starts a background goroutine that periodically runs cache cleanup. 465 + // Returns a cancel function that should be called during graceful shutdown. 466 + // If interval is 0 or negative, no cleanup job is started and the cancel function is a no-op. 467 + func (c *DiskCache) StartCleanupJob(interval time.Duration) context.CancelFunc { 468 + if interval <= 0 { 469 + slog.Info("[IMAGE-PROXY] cache cleanup job disabled (interval=0)") 470 + return func() {} // No-op cancel 471 + } 472 + 473 + ctx, cancel := context.WithCancel(context.Background()) 474 + 475 + go func() { 476 + defer func() { 477 + if r := recover(); r != nil { 478 + slog.Error("[IMAGE-PROXY] CRITICAL: cache cleanup job panicked", 479 + "panic", r, 480 + ) 481 + } 482 + }() 483 + 484 + ticker := time.NewTicker(interval) 485 + defer ticker.Stop() 486 + 487 + slog.Info("[IMAGE-PROXY] cache cleanup job started", 488 + "interval", interval, 489 + "ttl_days", c.ttlDays, 490 + "max_size_gb", c.maxSizeGB, 491 + ) 492 + 493 + cycleCount := 0 494 + for { 495 + select { 496 + case <-ctx.Done(): 497 + slog.Info("[IMAGE-PROXY] cache cleanup job stopped") 498 + return 499 + case <-ticker.C: 500 + cycleCount++ 501 + 502 + removed, err := c.Cleanup() 503 + if err != nil { 504 + slog.Error("[IMAGE-PROXY] cache cleanup error", 505 + "error", err, 506 + "cycle", cycleCount, 507 + ) 508 + continue 509 + } 510 + 511 + // Also clean up empty directories after removing files 512 + if removed > 0 { 513 + if err := c.cleanEmptyDirs(); err != nil { 514 + slog.Warn("[IMAGE-PROXY] failed to clean empty directories", 515 + "error", err, 516 + ) 517 + } 518 + } 519 + 520 + // Log activity or heartbeat every 6 cycles (6 hours if interval is 1h) 521 + if removed > 0 { 522 + slog.Info("[IMAGE-PROXY] cache cleanup completed", 523 + "entries_removed", removed, 524 + "cycle", cycleCount, 525 + ) 526 + } else if cycleCount%6 == 0 { 527 + // Get cache size for heartbeat log 528 + size, _ := c.GetCacheSize() 529 + slog.Debug("[IMAGE-PROXY] cache cleanup heartbeat", 530 + "cycle", cycleCount, 531 + "cache_size_bytes", size, 532 + ) 533 + } 534 + } 535 + } 536 + }() 537 + 538 + return cancel 539 + }
+610
internal/core/imageproxy/cache_test.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "errors" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + "time" 9 + ) 10 + 11 + // mustNewDiskCache is a test helper that creates a DiskCache or fails the test 12 + // Uses 0 for TTL (disabled) by default for backward compatibility 13 + func mustNewDiskCache(t *testing.T, basePath string, maxSizeGB int) *DiskCache { 14 + t.Helper() 15 + cache, err := NewDiskCache(basePath, maxSizeGB, 0) 16 + if err != nil { 17 + t.Fatalf("NewDiskCache failed: %v", err) 18 + } 19 + return cache 20 + } 21 + 22 + func TestDiskCache_SetAndGet(t *testing.T) { 23 + // Create a temporary directory for the cache 24 + tmpDir := t.TempDir() 25 + 26 + cache := mustNewDiskCache(t, tmpDir, 1) 27 + 28 + testData := []byte("test image data") 29 + preset := "thumb" 30 + did := "did:plc:abc123" 31 + cid := "bafyreiabc123" 32 + 33 + // Set the data 34 + err := cache.Set(preset, did, cid, testData) 35 + if err != nil { 36 + t.Fatalf("Set failed: %v", err) 37 + } 38 + 39 + // Get the data back 40 + data, found, err := cache.Get(preset, did, cid) 41 + if err != nil { 42 + t.Fatalf("Get failed: %v", err) 43 + } 44 + if !found { 45 + t.Fatal("Expected data to be found in cache") 46 + } 47 + if string(data) != string(testData) { 48 + t.Errorf("Get returned %q, want %q", string(data), string(testData)) 49 + } 50 + } 51 + 52 + func TestDiskCache_GetMissingKey(t *testing.T) { 53 + tmpDir := t.TempDir() 54 + cache := mustNewDiskCache(t, tmpDir, 1) 55 + 56 + data, found, err := cache.Get("thumb", "did:plc:notexist", "bafynotexist") 57 + if err != nil { 58 + t.Fatalf("Get should not error for missing key: %v", err) 59 + } 60 + if found { 61 + t.Error("Expected found to be false for missing key") 62 + } 63 + if data != nil { 64 + t.Error("Expected data to be nil for missing key") 65 + } 66 + } 67 + 68 + func TestDiskCache_Delete(t *testing.T) { 69 + tmpDir := t.TempDir() 70 + cache := mustNewDiskCache(t, tmpDir, 1) 71 + 72 + testData := []byte("data to delete") 73 + preset := "medium" 74 + did := "did:plc:todelete" 75 + cid := "bafyreitodelete" 76 + 77 + // Set data 78 + err := cache.Set(preset, did, cid, testData) 79 + if err != nil { 80 + t.Fatalf("Set failed: %v", err) 81 + } 82 + 83 + // Verify it exists 84 + _, found, _ := cache.Get(preset, did, cid) 85 + if !found { 86 + t.Fatal("Expected data to exist before delete") 87 + } 88 + 89 + // Delete 90 + err = cache.Delete(preset, did, cid) 91 + if err != nil { 92 + t.Fatalf("Delete failed: %v", err) 93 + } 94 + 95 + // Verify it's gone 96 + _, found, _ = cache.Get(preset, did, cid) 97 + if found { 98 + t.Error("Expected data to be gone after delete") 99 + } 100 + } 101 + 102 + func TestDiskCache_DeleteNonExistent(t *testing.T) { 103 + tmpDir := t.TempDir() 104 + cache := mustNewDiskCache(t, tmpDir, 1) 105 + 106 + // Deleting a non-existent key should not error 107 + err := cache.Delete("thumb", "did:plc:notexist", "bafynotexist") 108 + if err != nil { 109 + t.Errorf("Delete of non-existent key should not error: %v", err) 110 + } 111 + } 112 + 113 + func TestDiskCache_PathConstruction(t *testing.T) { 114 + tmpDir := t.TempDir() 115 + cache := mustNewDiskCache(t, tmpDir, 1) 116 + 117 + testData := []byte("path test data") 118 + preset := "thumb" 119 + did := "did:plc:abc123" 120 + cid := "bafyreiabc123" 121 + 122 + err := cache.Set(preset, did, cid, testData) 123 + if err != nil { 124 + t.Fatalf("Set failed: %v", err) 125 + } 126 + 127 + // Verify the path structure: {basePath}/{preset}/{did_safe}/{cid} 128 + // did_safe should have colons replaced with underscores 129 + expectedPath := filepath.Join(tmpDir, preset, "did_plc_abc123", cid) 130 + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { 131 + t.Errorf("Expected cache file at %s to exist", expectedPath) 132 + } 133 + } 134 + 135 + func TestDiskCache_HandlesSpecialCharactersInDID(t *testing.T) { 136 + tmpDir := t.TempDir() 137 + cache := mustNewDiskCache(t, tmpDir, 1) 138 + 139 + tests := []struct { 140 + name string 141 + did string 142 + wantDir string 143 + }{ 144 + { 145 + name: "plc DID with colons", 146 + did: "did:plc:abc123", 147 + wantDir: "did_plc_abc123", 148 + }, 149 + { 150 + name: "web DID with multiple colons", 151 + did: "did:web:example.com:user", 152 + wantDir: "did_web_example.com_user", 153 + }, 154 + { 155 + name: "DID with many segments", 156 + did: "did:plc:a:b:c:d", 157 + wantDir: "did_plc_a_b_c_d", 158 + }, 159 + } 160 + 161 + for _, tt := range tests { 162 + t.Run(tt.name, func(t *testing.T) { 163 + testData := []byte("test data for " + tt.name) 164 + preset := "thumb" 165 + cid := "bafytest123" 166 + 167 + err := cache.Set(preset, tt.did, cid, testData) 168 + if err != nil { 169 + t.Fatalf("Set failed: %v", err) 170 + } 171 + 172 + expectedPath := filepath.Join(tmpDir, preset, tt.wantDir, cid) 173 + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { 174 + t.Errorf("Expected cache file at %s to exist for DID %s", expectedPath, tt.did) 175 + } 176 + 177 + // Also verify we can read it back 178 + data, found, err := cache.Get(preset, tt.did, cid) 179 + if err != nil { 180 + t.Fatalf("Get failed: %v", err) 181 + } 182 + if !found { 183 + t.Error("Expected to find cached data") 184 + } 185 + if string(data) != string(testData) { 186 + t.Errorf("Get returned %q, want %q", string(data), string(testData)) 187 + } 188 + }) 189 + } 190 + } 191 + 192 + func TestDiskCache_DifferentPresetsAreSeparate(t *testing.T) { 193 + tmpDir := t.TempDir() 194 + cache := mustNewDiskCache(t, tmpDir, 1) 195 + 196 + did := "did:plc:same" 197 + cid := "bafysame" 198 + thumbData := []byte("thumbnail data") 199 + fullData := []byte("full size data") 200 + 201 + // Set different data for different presets 202 + err := cache.Set("thumb", did, cid, thumbData) 203 + if err != nil { 204 + t.Fatalf("Set thumb failed: %v", err) 205 + } 206 + 207 + err = cache.Set("full", did, cid, fullData) 208 + if err != nil { 209 + t.Fatalf("Set full failed: %v", err) 210 + } 211 + 212 + // Verify they're separate 213 + data, found, _ := cache.Get("thumb", did, cid) 214 + if !found { 215 + t.Fatal("Expected thumb data to be found") 216 + } 217 + if string(data) != string(thumbData) { 218 + t.Errorf("thumb preset returned wrong data: got %q, want %q", string(data), string(thumbData)) 219 + } 220 + 221 + data, found, _ = cache.Get("full", did, cid) 222 + if !found { 223 + t.Fatal("Expected full data to be found") 224 + } 225 + if string(data) != string(fullData) { 226 + t.Errorf("full preset returned wrong data: got %q, want %q", string(data), string(fullData)) 227 + } 228 + } 229 + 230 + func TestDiskCache_EmptyParametersHandled(t *testing.T) { 231 + tmpDir := t.TempDir() 232 + cache := mustNewDiskCache(t, tmpDir, 1) 233 + 234 + // Empty preset 235 + err := cache.Set("", "did:plc:abc", "bafytest", []byte("data")) 236 + if err == nil { 237 + t.Error("Expected error when preset is empty") 238 + } 239 + 240 + // Empty DID 241 + err = cache.Set("thumb", "", "bafytest", []byte("data")) 242 + if err == nil { 243 + t.Error("Expected error when DID is empty") 244 + } 245 + 246 + // Empty CID 247 + err = cache.Set("thumb", "did:plc:abc", "", []byte("data")) 248 + if err == nil { 249 + t.Error("Expected error when CID is empty") 250 + } 251 + } 252 + 253 + func TestNewDiskCache(t *testing.T) { 254 + cache, err := NewDiskCache("/some/path", 5, 30) 255 + if err != nil { 256 + t.Fatalf("NewDiskCache failed: %v", err) 257 + } 258 + 259 + if cache == nil { 260 + t.Fatal("NewDiskCache returned nil") 261 + } 262 + if cache.basePath != "/some/path" { 263 + t.Errorf("basePath = %q, want %q", cache.basePath, "/some/path") 264 + } 265 + if cache.maxSizeGB != 5 { 266 + t.Errorf("maxSizeGB = %d, want %d", cache.maxSizeGB, 5) 267 + } 268 + if cache.ttlDays != 30 { 269 + t.Errorf("ttlDays = %d, want %d", cache.ttlDays, 30) 270 + } 271 + } 272 + 273 + func TestNewDiskCache_Errors(t *testing.T) { 274 + t.Run("empty base path", func(t *testing.T) { 275 + _, err := NewDiskCache("", 5, 0) 276 + if !errors.Is(err, ErrInvalidCacheBasePath) { 277 + t.Errorf("expected ErrInvalidCacheBasePath, got: %v", err) 278 + } 279 + }) 280 + 281 + t.Run("zero max size", func(t *testing.T) { 282 + _, err := NewDiskCache("/some/path", 0, 0) 283 + if !errors.Is(err, ErrInvalidCacheMaxSize) { 284 + t.Errorf("expected ErrInvalidCacheMaxSize, got: %v", err) 285 + } 286 + }) 287 + 288 + t.Run("negative max size", func(t *testing.T) { 289 + _, err := NewDiskCache("/some/path", -1, 0) 290 + if !errors.Is(err, ErrInvalidCacheMaxSize) { 291 + t.Errorf("expected ErrInvalidCacheMaxSize, got: %v", err) 292 + } 293 + }) 294 + 295 + t.Run("negative TTL", func(t *testing.T) { 296 + _, err := NewDiskCache("/some/path", 5, -1) 297 + if err == nil { 298 + t.Error("expected error for negative TTL") 299 + } 300 + }) 301 + } 302 + 303 + func TestCache_InterfaceImplementation(t *testing.T) { 304 + // Compile-time check that DiskCache implements Cache 305 + var _ Cache = (*DiskCache)(nil) 306 + } 307 + 308 + func TestDiskCache_GetCacheSize(t *testing.T) { 309 + tmpDir := t.TempDir() 310 + cache := mustNewDiskCache(t, tmpDir, 1) 311 + 312 + // Empty cache should be 0 313 + size, err := cache.GetCacheSize() 314 + if err != nil { 315 + t.Fatalf("GetCacheSize failed: %v", err) 316 + } 317 + if size != 0 { 318 + t.Errorf("Expected 0 for empty cache, got %d", size) 319 + } 320 + 321 + // Add some data 322 + data := make([]byte, 1000) // 1KB 323 + if err := cache.Set("avatar", "did:plc:test1", "cid1", data); err != nil { 324 + t.Fatalf("Set failed: %v", err) 325 + } 326 + if err := cache.Set("avatar", "did:plc:test2", "cid2", data); err != nil { 327 + t.Fatalf("Set failed: %v", err) 328 + } 329 + 330 + size, err = cache.GetCacheSize() 331 + if err != nil { 332 + t.Fatalf("GetCacheSize failed: %v", err) 333 + } 334 + if size != 2000 { 335 + t.Errorf("Expected 2000 bytes, got %d", size) 336 + } 337 + } 338 + 339 + func TestDiskCache_EvictLRU(t *testing.T) { 340 + tmpDir := t.TempDir() 341 + // Use a very small max size (1 byte) so any data triggers eviction 342 + cache, err := NewDiskCache(tmpDir, 1, 0) // 1GB but we'll add more than that won't fit 343 + if err != nil { 344 + t.Fatalf("NewDiskCache failed: %v", err) 345 + } 346 + 347 + // Add some files with different modification times 348 + data := make([]byte, 100) 349 + 350 + // Create old file 351 + if err := cache.Set("avatar", "did:plc:old", "cid_old", data); err != nil { 352 + t.Fatalf("Set failed: %v", err) 353 + } 354 + oldPath := cache.cachePath("avatar", "did:plc:old", "cid_old") 355 + oldTime := time.Now().Add(-24 * time.Hour) 356 + if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil { 357 + t.Fatalf("Chtimes failed: %v", err) 358 + } 359 + 360 + // Create new file 361 + if err := cache.Set("avatar", "did:plc:new", "cid_new", data); err != nil { 362 + t.Fatalf("Set failed: %v", err) 363 + } 364 + 365 + // Cache is under 1GB so eviction shouldn't remove anything 366 + removed, err := cache.EvictLRU() 367 + if err != nil { 368 + t.Fatalf("EvictLRU failed: %v", err) 369 + } 370 + if removed != 0 { 371 + t.Errorf("Expected 0 entries removed (under limit), got %d", removed) 372 + } 373 + 374 + // Both files should still exist 375 + if _, found, _ := cache.Get("avatar", "did:plc:old", "cid_old"); !found { 376 + t.Error("Old entry should still exist") 377 + } 378 + if _, found, _ := cache.Get("avatar", "did:plc:new", "cid_new"); !found { 379 + t.Error("New entry should still exist") 380 + } 381 + } 382 + 383 + func TestDiskCache_CleanExpired(t *testing.T) { 384 + tmpDir := t.TempDir() 385 + // TTL of 1 day 386 + cache, err := NewDiskCache(tmpDir, 1, 1) 387 + if err != nil { 388 + t.Fatalf("NewDiskCache failed: %v", err) 389 + } 390 + 391 + data := make([]byte, 100) 392 + 393 + // Create fresh file 394 + if err := cache.Set("avatar", "did:plc:fresh", "cid_fresh", data); err != nil { 395 + t.Fatalf("Set failed: %v", err) 396 + } 397 + 398 + // Create expired file (manually set old mtime) 399 + if err := cache.Set("avatar", "did:plc:expired", "cid_expired", data); err != nil { 400 + t.Fatalf("Set failed: %v", err) 401 + } 402 + expiredPath := cache.cachePath("avatar", "did:plc:expired", "cid_expired") 403 + oldTime := time.Now().Add(-48 * time.Hour) // 2 days old, TTL is 1 day 404 + if err := os.Chtimes(expiredPath, oldTime, oldTime); err != nil { 405 + t.Fatalf("Chtimes failed: %v", err) 406 + } 407 + 408 + // Clean expired entries 409 + removed, err := cache.CleanExpired() 410 + if err != nil { 411 + t.Fatalf("CleanExpired failed: %v", err) 412 + } 413 + if removed != 1 { 414 + t.Errorf("Expected 1 expired entry removed, got %d", removed) 415 + } 416 + 417 + // Fresh file should still exist 418 + if _, found, _ := cache.Get("avatar", "did:plc:fresh", "cid_fresh"); !found { 419 + t.Error("Fresh entry should still exist") 420 + } 421 + 422 + // Expired file should be gone 423 + if _, found, _ := cache.Get("avatar", "did:plc:expired", "cid_expired"); found { 424 + t.Error("Expired entry should be removed") 425 + } 426 + } 427 + 428 + func TestDiskCache_CleanExpired_TTLDisabled(t *testing.T) { 429 + tmpDir := t.TempDir() 430 + // TTL of 0 = disabled 431 + cache, err := NewDiskCache(tmpDir, 1, 0) 432 + if err != nil { 433 + t.Fatalf("NewDiskCache failed: %v", err) 434 + } 435 + 436 + data := make([]byte, 100) 437 + 438 + // Create a file with old mtime 439 + if err := cache.Set("avatar", "did:plc:old", "cid_old", data); err != nil { 440 + t.Fatalf("Set failed: %v", err) 441 + } 442 + path := cache.cachePath("avatar", "did:plc:old", "cid_old") 443 + oldTime := time.Now().Add(-365 * 24 * time.Hour) // 1 year old 444 + if err := os.Chtimes(path, oldTime, oldTime); err != nil { 445 + t.Fatalf("Chtimes failed: %v", err) 446 + } 447 + 448 + // Clean expired should do nothing when TTL is disabled 449 + removed, err := cache.CleanExpired() 450 + if err != nil { 451 + t.Fatalf("CleanExpired failed: %v", err) 452 + } 453 + if removed != 0 { 454 + t.Errorf("Expected 0 removed with TTL disabled, got %d", removed) 455 + } 456 + 457 + // File should still exist 458 + if _, found, _ := cache.Get("avatar", "did:plc:old", "cid_old"); !found { 459 + t.Error("Entry should still exist when TTL is disabled") 460 + } 461 + } 462 + 463 + func TestDiskCache_Cleanup(t *testing.T) { 464 + tmpDir := t.TempDir() 465 + // TTL of 1 day 466 + cache, err := NewDiskCache(tmpDir, 1, 1) 467 + if err != nil { 468 + t.Fatalf("NewDiskCache failed: %v", err) 469 + } 470 + 471 + data := make([]byte, 100) 472 + 473 + // Create fresh file 474 + if err := cache.Set("avatar", "did:plc:fresh", "cid_fresh", data); err != nil { 475 + t.Fatalf("Set failed: %v", err) 476 + } 477 + 478 + // Create expired file 479 + if err := cache.Set("avatar", "did:plc:expired", "cid_expired", data); err != nil { 480 + t.Fatalf("Set failed: %v", err) 481 + } 482 + expiredPath := cache.cachePath("avatar", "did:plc:expired", "cid_expired") 483 + oldTime := time.Now().Add(-48 * time.Hour) 484 + if err := os.Chtimes(expiredPath, oldTime, oldTime); err != nil { 485 + t.Fatalf("Chtimes failed: %v", err) 486 + } 487 + 488 + // Cleanup should remove expired entry 489 + removed, err := cache.Cleanup() 490 + if err != nil { 491 + t.Fatalf("Cleanup failed: %v", err) 492 + } 493 + if removed != 1 { 494 + t.Errorf("Expected 1 entry removed, got %d", removed) 495 + } 496 + 497 + // Fresh file should still exist 498 + if _, found, _ := cache.Get("avatar", "did:plc:fresh", "cid_fresh"); !found { 499 + t.Error("Fresh entry should still exist") 500 + } 501 + } 502 + 503 + func TestDiskCache_GetUpdatesMtime(t *testing.T) { 504 + tmpDir := t.TempDir() 505 + cache := mustNewDiskCache(t, tmpDir, 1) 506 + 507 + data := []byte("test data") 508 + if err := cache.Set("avatar", "did:plc:test", "cid1", data); err != nil { 509 + t.Fatalf("Set failed: %v", err) 510 + } 511 + 512 + path := cache.cachePath("avatar", "did:plc:test", "cid1") 513 + 514 + // Set an old mtime 515 + oldTime := time.Now().Add(-24 * time.Hour) 516 + if err := os.Chtimes(path, oldTime, oldTime); err != nil { 517 + t.Fatalf("Chtimes failed: %v", err) 518 + } 519 + 520 + // Get the file - this should update mtime 521 + _, found, err := cache.Get("avatar", "did:plc:test", "cid1") 522 + if err != nil { 523 + t.Fatalf("Get failed: %v", err) 524 + } 525 + if !found { 526 + t.Fatal("Expected to find entry") 527 + } 528 + 529 + // Check that mtime was updated 530 + info, err := os.Stat(path) 531 + if err != nil { 532 + t.Fatalf("Stat failed: %v", err) 533 + } 534 + 535 + // Mtime should be recent (within last minute) 536 + if time.Since(info.ModTime()) > time.Minute { 537 + t.Errorf("Expected mtime to be updated to now, but it's %v old", time.Since(info.ModTime())) 538 + } 539 + } 540 + 541 + func TestDiskCache_StartCleanupJob(t *testing.T) { 542 + tmpDir := t.TempDir() 543 + // Create cache with 1 day TTL 544 + cache, err := NewDiskCache(tmpDir, 1, 1) 545 + if err != nil { 546 + t.Fatalf("NewDiskCache failed: %v", err) 547 + } 548 + 549 + data := make([]byte, 100) 550 + 551 + // Create an expired file 552 + if err := cache.Set("avatar", "did:plc:expired", "cid_expired", data); err != nil { 553 + t.Fatalf("Set failed: %v", err) 554 + } 555 + expiredPath := cache.cachePath("avatar", "did:plc:expired", "cid_expired") 556 + oldTime := time.Now().Add(-48 * time.Hour) 557 + if err := os.Chtimes(expiredPath, oldTime, oldTime); err != nil { 558 + t.Fatalf("Chtimes failed: %v", err) 559 + } 560 + 561 + // Start cleanup job with very short interval 562 + cancel := cache.StartCleanupJob(50 * time.Millisecond) 563 + defer cancel() 564 + 565 + // Wait for at least one cleanup cycle 566 + time.Sleep(100 * time.Millisecond) 567 + 568 + // Expired file should be gone 569 + if _, found, _ := cache.Get("avatar", "did:plc:expired", "cid_expired"); found { 570 + t.Error("Expired entry should have been cleaned up by background job") 571 + } 572 + } 573 + 574 + func TestDiskCache_StartCleanupJob_ZeroInterval(t *testing.T) { 575 + tmpDir := t.TempDir() 576 + cache := mustNewDiskCache(t, tmpDir, 1) 577 + 578 + // Starting with 0 interval should return a no-op cancel 579 + cancel := cache.StartCleanupJob(0) 580 + defer cancel() 581 + 582 + // Should not panic when called 583 + cancel() 584 + cancel() // Multiple calls should be safe 585 + } 586 + 587 + func TestDiskCache_StartCleanupJob_GracefulShutdown(t *testing.T) { 588 + tmpDir := t.TempDir() 589 + cache := mustNewDiskCache(t, tmpDir, 1) 590 + 591 + // Start cleanup job 592 + cancel := cache.StartCleanupJob(10 * time.Millisecond) 593 + 594 + // Let it run briefly 595 + time.Sleep(30 * time.Millisecond) 596 + 597 + // Cancel should not hang or panic 598 + done := make(chan struct{}) 599 + go func() { 600 + cancel() 601 + close(done) 602 + }() 603 + 604 + select { 605 + case <-done: 606 + // Good, cancel returned 607 + case <-time.After(1 * time.Second): 608 + t.Error("Cancel took too long, may be stuck") 609 + } 610 + }
+232
internal/core/imageproxy/config.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "log/slog" 7 + "os" 8 + "strconv" 9 + "time" 10 + ) 11 + 12 + // Config validation errors 13 + var ( 14 + // ErrInvalidCacheMaxGB is returned when CacheMaxGB is not positive 15 + ErrInvalidCacheMaxGB = errors.New("CacheMaxGB must be positive") 16 + // ErrInvalidFetchTimeout is returned when FetchTimeout is not positive 17 + ErrInvalidFetchTimeout = errors.New("FetchTimeout must be positive") 18 + // ErrInvalidMaxSourceSize is returned when MaxSourceSizeMB is not positive 19 + ErrInvalidMaxSourceSize = errors.New("MaxSourceSizeMB must be positive") 20 + // ErrMissingCachePath is returned when CachePath is empty while Enabled is true 21 + ErrMissingCachePath = errors.New("CachePath is required when proxy is enabled") 22 + // ErrInvalidCacheTTL is returned when CacheTTLDays is negative 23 + ErrInvalidCacheTTL = errors.New("CacheTTLDays cannot be negative") 24 + ) 25 + 26 + // Config holds the configuration for the image proxy service. 27 + type Config struct { 28 + // Enabled determines whether the image proxy service is active. 29 + Enabled bool 30 + 31 + // BaseURL is the origin/domain for the image proxy service (e.g., "https://coves.social"). 32 + // Empty string generates relative URLs (e.g., "/img/avatar/plain/did/cid"). 33 + // The /img path prefix is added automatically by the URL generation function. 34 + BaseURL string 35 + 36 + // CachePath is the filesystem path where cached images are stored. 37 + CachePath string 38 + 39 + // CacheMaxGB is the maximum cache size in gigabytes. 40 + CacheMaxGB int 41 + 42 + // CacheTTLDays is the maximum age in days for cached entries. 43 + // Entries older than this are eligible for cleanup regardless of cache size. 44 + // Set to 0 to disable TTL-based cleanup (only LRU eviction applies). 45 + CacheTTLDays int 46 + 47 + // CleanupInterval is how often to run cache cleanup (TTL + LRU eviction). 48 + // Set to 0 to disable background cleanup. 49 + CleanupInterval time.Duration 50 + 51 + // CDNURL is the optional CDN URL prefix for serving cached images. 52 + CDNURL string 53 + 54 + // FetchTimeout is the maximum time allowed for fetching images from PDS. 55 + FetchTimeout time.Duration 56 + 57 + // MaxSourceSizeMB is the maximum allowed size for source images in megabytes. 58 + MaxSourceSizeMB int 59 + } 60 + 61 + // NewConfig creates a new Config with the provided values and validates it. 62 + // This is the recommended way to create a Config, as it ensures all invariants are satisfied. 63 + // Use DefaultConfig() or ConfigFromEnv() for convenient config creation with sensible defaults. 64 + func NewConfig( 65 + enabled bool, 66 + baseURL string, 67 + cachePath string, 68 + cacheMaxGB int, 69 + cacheTTLDays int, 70 + cleanupInterval time.Duration, 71 + cdnURL string, 72 + fetchTimeout time.Duration, 73 + maxSourceSizeMB int, 74 + ) (Config, error) { 75 + cfg := Config{ 76 + Enabled: enabled, 77 + BaseURL: baseURL, 78 + CachePath: cachePath, 79 + CacheMaxGB: cacheMaxGB, 80 + CacheTTLDays: cacheTTLDays, 81 + CleanupInterval: cleanupInterval, 82 + CDNURL: cdnURL, 83 + FetchTimeout: fetchTimeout, 84 + MaxSourceSizeMB: maxSourceSizeMB, 85 + } 86 + 87 + if err := cfg.Validate(); err != nil { 88 + return Config{}, err 89 + } 90 + 91 + return cfg, nil 92 + } 93 + 94 + // Validate checks the configuration for invalid values. 95 + // Returns nil if the configuration is valid, or an error describing the problem. 96 + // When Enabled is false, only numeric constraints are validated (for safety). 97 + // When Enabled is true, all required fields must be set. 98 + func (c Config) Validate() error { 99 + // Always validate numeric constraints regardless of enabled state 100 + if c.CacheMaxGB <= 0 { 101 + return fmt.Errorf("%w: got %d", ErrInvalidCacheMaxGB, c.CacheMaxGB) 102 + } 103 + if c.FetchTimeout <= 0 { 104 + return fmt.Errorf("%w: got %v", ErrInvalidFetchTimeout, c.FetchTimeout) 105 + } 106 + if c.MaxSourceSizeMB <= 0 { 107 + return fmt.Errorf("%w: got %d", ErrInvalidMaxSourceSize, c.MaxSourceSizeMB) 108 + } 109 + if c.CacheTTLDays < 0 { 110 + return fmt.Errorf("%w: got %d", ErrInvalidCacheTTL, c.CacheTTLDays) 111 + } 112 + 113 + // When enabled, validate required fields 114 + if c.Enabled { 115 + if c.CachePath == "" { 116 + return ErrMissingCachePath 117 + } 118 + // BaseURL can be empty for relative URLs 119 + } 120 + 121 + return nil 122 + } 123 + 124 + // DefaultConfig returns a Config with sensible default values. 125 + func DefaultConfig() Config { 126 + return Config{ 127 + Enabled: true, 128 + BaseURL: "", 129 + CachePath: "/var/cache/coves/images", 130 + CacheMaxGB: 10, 131 + CacheTTLDays: 30, 132 + CleanupInterval: 1 * time.Hour, 133 + CDNURL: "", 134 + FetchTimeout: 30 * time.Second, 135 + MaxSourceSizeMB: 10, 136 + } 137 + } 138 + 139 + // ConfigFromEnv creates a Config from environment variables. 140 + // Uses defaults for any missing environment variables. 141 + // 142 + // Environment variables: 143 + // - IMAGE_PROXY_ENABLED: "true"/"1" to enable, "false"/"0" to disable (default: true) 144 + // - IMAGE_PROXY_BASE_URL: origin URL for image proxy (default: "" for relative URLs) 145 + // - IMAGE_PROXY_CACHE_PATH: filesystem cache path (default: "/var/cache/coves/images") 146 + // - IMAGE_PROXY_CACHE_MAX_GB: max cache size in GB (default: 10) 147 + // - IMAGE_PROXY_CACHE_TTL_DAYS: max age for cache entries in days, 0 to disable (default: 30) 148 + // - IMAGE_PROXY_CLEANUP_INTERVAL_MINUTES: cleanup job interval in minutes, 0 to disable (default: 60) 149 + // - IMAGE_PROXY_CDN_URL: optional CDN URL prefix (default: "") 150 + // - IMAGE_PROXY_FETCH_TIMEOUT_SECONDS: PDS fetch timeout in seconds (default: 30) 151 + // - IMAGE_PROXY_MAX_SOURCE_SIZE_MB: max source image size in MB (default: 10) 152 + func ConfigFromEnv() Config { 153 + cfg := DefaultConfig() 154 + 155 + if v := os.Getenv("IMAGE_PROXY_ENABLED"); v != "" { 156 + cfg.Enabled = v == "true" || v == "1" 157 + } 158 + 159 + if v := os.Getenv("IMAGE_PROXY_BASE_URL"); v != "" { 160 + cfg.BaseURL = v 161 + } 162 + 163 + if v := os.Getenv("IMAGE_PROXY_CACHE_PATH"); v != "" { 164 + cfg.CachePath = v 165 + } 166 + 167 + if v := os.Getenv("IMAGE_PROXY_CACHE_MAX_GB"); v != "" { 168 + if n, err := strconv.Atoi(v); err == nil && n > 0 { 169 + cfg.CacheMaxGB = n 170 + } else { 171 + slog.Warn("[IMAGE-PROXY] invalid IMAGE_PROXY_CACHE_MAX_GB value, using default", 172 + "value", v, 173 + "default", cfg.CacheMaxGB, 174 + "error", err, 175 + ) 176 + } 177 + } 178 + 179 + if v := os.Getenv("IMAGE_PROXY_CACHE_TTL_DAYS"); v != "" { 180 + if n, err := strconv.Atoi(v); err == nil && n >= 0 { 181 + cfg.CacheTTLDays = n 182 + } else { 183 + slog.Warn("[IMAGE-PROXY] invalid IMAGE_PROXY_CACHE_TTL_DAYS value, using default", 184 + "value", v, 185 + "default", cfg.CacheTTLDays, 186 + "error", err, 187 + ) 188 + } 189 + } 190 + 191 + if v := os.Getenv("IMAGE_PROXY_CLEANUP_INTERVAL_MINUTES"); v != "" { 192 + if n, err := strconv.Atoi(v); err == nil && n >= 0 { 193 + cfg.CleanupInterval = time.Duration(n) * time.Minute 194 + } else { 195 + slog.Warn("[IMAGE-PROXY] invalid IMAGE_PROXY_CLEANUP_INTERVAL_MINUTES value, using default", 196 + "value", v, 197 + "default_minutes", int(cfg.CleanupInterval.Minutes()), 198 + "error", err, 199 + ) 200 + } 201 + } 202 + 203 + if v := os.Getenv("IMAGE_PROXY_CDN_URL"); v != "" { 204 + cfg.CDNURL = v 205 + } 206 + 207 + if v := os.Getenv("IMAGE_PROXY_FETCH_TIMEOUT_SECONDS"); v != "" { 208 + if n, err := strconv.Atoi(v); err == nil && n > 0 { 209 + cfg.FetchTimeout = time.Duration(n) * time.Second 210 + } else { 211 + slog.Warn("[IMAGE-PROXY] invalid IMAGE_PROXY_FETCH_TIMEOUT_SECONDS value, using default", 212 + "value", v, 213 + "default_seconds", int(cfg.FetchTimeout.Seconds()), 214 + "error", err, 215 + ) 216 + } 217 + } 218 + 219 + if v := os.Getenv("IMAGE_PROXY_MAX_SOURCE_SIZE_MB"); v != "" { 220 + if n, err := strconv.Atoi(v); err == nil && n > 0 { 221 + cfg.MaxSourceSizeMB = n 222 + } else { 223 + slog.Warn("[IMAGE-PROXY] invalid IMAGE_PROXY_MAX_SOURCE_SIZE_MB value, using default", 224 + "value", v, 225 + "default", cfg.MaxSourceSizeMB, 226 + "error", err, 227 + ) 228 + } 229 + } 230 + 231 + return cfg 232 + }
+245
internal/core/imageproxy/config_test.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "errors" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + func TestConfig_Validate(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + config Config 13 + wantErr error 14 + }{ 15 + { 16 + name: "valid default config", 17 + config: DefaultConfig(), 18 + wantErr: nil, 19 + }, 20 + { 21 + name: "valid enabled config", 22 + config: Config{ 23 + Enabled: true, 24 + BaseURL: "/img", 25 + CachePath: "/var/cache/images", 26 + CacheMaxGB: 10, 27 + FetchTimeout: 30 * time.Second, 28 + MaxSourceSizeMB: 10, 29 + }, 30 + wantErr: nil, 31 + }, 32 + { 33 + name: "invalid CacheMaxGB zero", 34 + config: Config{ 35 + Enabled: false, 36 + BaseURL: "/img", 37 + CachePath: "/var/cache/images", 38 + CacheMaxGB: 0, 39 + FetchTimeout: 30 * time.Second, 40 + MaxSourceSizeMB: 10, 41 + }, 42 + wantErr: ErrInvalidCacheMaxGB, 43 + }, 44 + { 45 + name: "invalid CacheMaxGB negative", 46 + config: Config{ 47 + Enabled: false, 48 + BaseURL: "/img", 49 + CachePath: "/var/cache/images", 50 + CacheMaxGB: -5, 51 + FetchTimeout: 30 * time.Second, 52 + MaxSourceSizeMB: 10, 53 + }, 54 + wantErr: ErrInvalidCacheMaxGB, 55 + }, 56 + { 57 + name: "invalid FetchTimeout zero", 58 + config: Config{ 59 + Enabled: false, 60 + BaseURL: "/img", 61 + CachePath: "/var/cache/images", 62 + CacheMaxGB: 10, 63 + FetchTimeout: 0, 64 + MaxSourceSizeMB: 10, 65 + }, 66 + wantErr: ErrInvalidFetchTimeout, 67 + }, 68 + { 69 + name: "invalid FetchTimeout negative", 70 + config: Config{ 71 + Enabled: false, 72 + BaseURL: "/img", 73 + CachePath: "/var/cache/images", 74 + CacheMaxGB: 10, 75 + FetchTimeout: -1 * time.Second, 76 + MaxSourceSizeMB: 10, 77 + }, 78 + wantErr: ErrInvalidFetchTimeout, 79 + }, 80 + { 81 + name: "invalid MaxSourceSizeMB zero", 82 + config: Config{ 83 + Enabled: false, 84 + BaseURL: "/img", 85 + CachePath: "/var/cache/images", 86 + CacheMaxGB: 10, 87 + FetchTimeout: 30 * time.Second, 88 + MaxSourceSizeMB: 0, 89 + }, 90 + wantErr: ErrInvalidMaxSourceSize, 91 + }, 92 + { 93 + name: "invalid MaxSourceSizeMB negative", 94 + config: Config{ 95 + Enabled: false, 96 + BaseURL: "/img", 97 + CachePath: "/var/cache/images", 98 + CacheMaxGB: 10, 99 + FetchTimeout: 30 * time.Second, 100 + MaxSourceSizeMB: -5, 101 + }, 102 + wantErr: ErrInvalidMaxSourceSize, 103 + }, 104 + { 105 + name: "enabled but missing CachePath", 106 + config: Config{ 107 + Enabled: true, 108 + BaseURL: "/img", 109 + CachePath: "", 110 + CacheMaxGB: 10, 111 + FetchTimeout: 30 * time.Second, 112 + MaxSourceSizeMB: 10, 113 + }, 114 + wantErr: ErrMissingCachePath, 115 + }, 116 + { 117 + name: "enabled allows empty BaseURL for relative URLs", 118 + config: Config{ 119 + Enabled: true, 120 + BaseURL: "", 121 + CachePath: "/var/cache/images", 122 + CacheMaxGB: 10, 123 + FetchTimeout: 30 * time.Second, 124 + MaxSourceSizeMB: 10, 125 + }, 126 + wantErr: nil, 127 + }, 128 + { 129 + name: "disabled allows empty CachePath", 130 + config: Config{ 131 + Enabled: false, 132 + BaseURL: "/img", 133 + CachePath: "", 134 + CacheMaxGB: 10, 135 + FetchTimeout: 30 * time.Second, 136 + MaxSourceSizeMB: 10, 137 + }, 138 + wantErr: nil, 139 + }, 140 + { 141 + name: "disabled allows empty BaseURL", 142 + config: Config{ 143 + Enabled: false, 144 + BaseURL: "", 145 + CachePath: "/var/cache/images", 146 + CacheMaxGB: 10, 147 + FetchTimeout: 30 * time.Second, 148 + MaxSourceSizeMB: 10, 149 + }, 150 + wantErr: nil, 151 + }, 152 + { 153 + name: "valid TTL zero (disabled)", 154 + config: Config{ 155 + Enabled: true, 156 + BaseURL: "", 157 + CachePath: "/var/cache/images", 158 + CacheMaxGB: 10, 159 + CacheTTLDays: 0, 160 + FetchTimeout: 30 * time.Second, 161 + MaxSourceSizeMB: 10, 162 + }, 163 + wantErr: nil, 164 + }, 165 + { 166 + name: "valid TTL positive", 167 + config: Config{ 168 + Enabled: true, 169 + BaseURL: "", 170 + CachePath: "/var/cache/images", 171 + CacheMaxGB: 10, 172 + CacheTTLDays: 30, 173 + FetchTimeout: 30 * time.Second, 174 + MaxSourceSizeMB: 10, 175 + }, 176 + wantErr: nil, 177 + }, 178 + { 179 + name: "invalid TTL negative", 180 + config: Config{ 181 + Enabled: true, 182 + BaseURL: "", 183 + CachePath: "/var/cache/images", 184 + CacheMaxGB: 10, 185 + CacheTTLDays: -1, 186 + FetchTimeout: 30 * time.Second, 187 + MaxSourceSizeMB: 10, 188 + }, 189 + wantErr: ErrInvalidCacheTTL, 190 + }, 191 + } 192 + 193 + for _, tt := range tests { 194 + t.Run(tt.name, func(t *testing.T) { 195 + err := tt.config.Validate() 196 + if tt.wantErr == nil { 197 + if err != nil { 198 + t.Errorf("expected no error, got: %v", err) 199 + } 200 + } else { 201 + if !errors.Is(err, tt.wantErr) { 202 + t.Errorf("expected %v, got: %v", tt.wantErr, err) 203 + } 204 + } 205 + }) 206 + } 207 + } 208 + 209 + func TestDefaultConfig(t *testing.T) { 210 + cfg := DefaultConfig() 211 + 212 + // Verify default values 213 + if !cfg.Enabled { 214 + t.Error("expected Enabled to be true by default") 215 + } 216 + if cfg.BaseURL != "" { 217 + t.Errorf("expected empty BaseURL for relative URLs, got %q", cfg.BaseURL) 218 + } 219 + if cfg.CachePath != "/var/cache/coves/images" { 220 + t.Errorf("expected CachePath '/var/cache/coves/images', got %q", cfg.CachePath) 221 + } 222 + if cfg.CacheMaxGB != 10 { 223 + t.Errorf("expected CacheMaxGB 10, got %d", cfg.CacheMaxGB) 224 + } 225 + if cfg.CacheTTLDays != 30 { 226 + t.Errorf("expected CacheTTLDays 30, got %d", cfg.CacheTTLDays) 227 + } 228 + if cfg.CleanupInterval != 1*time.Hour { 229 + t.Errorf("expected CleanupInterval 1h, got %v", cfg.CleanupInterval) 230 + } 231 + if cfg.CDNURL != "" { 232 + t.Errorf("expected empty CDNURL, got %q", cfg.CDNURL) 233 + } 234 + if cfg.FetchTimeout != 30*time.Second { 235 + t.Errorf("expected FetchTimeout 30s, got %v", cfg.FetchTimeout) 236 + } 237 + if cfg.MaxSourceSizeMB != 10 { 238 + t.Errorf("expected MaxSourceSizeMB 10, got %d", cfg.MaxSourceSizeMB) 239 + } 240 + 241 + // Default config should be valid 242 + if err := cfg.Validate(); err != nil { 243 + t.Errorf("default config should be valid, got error: %v", err) 244 + } 245 + }
+35
internal/core/imageproxy/errors.go
··· 1 + package imageproxy 2 + 3 + import "errors" 4 + 5 + var ( 6 + // ErrInvalidPreset is returned when a preset name is not found in the preset registry. 7 + ErrInvalidPreset = errors.New("invalid image preset") 8 + 9 + // ErrInvalidDID is returned when a DID string does not match expected atproto DID format. 10 + ErrInvalidDID = errors.New("invalid DID format") 11 + 12 + // ErrInvalidCID is returned when a CID string is not a valid content identifier. 13 + ErrInvalidCID = errors.New("invalid CID format") 14 + 15 + // ErrPDSFetchFailed is returned when fetching a blob from a PDS fails for any reason. 16 + ErrPDSFetchFailed = errors.New("failed to fetch blob from PDS") 17 + 18 + // ErrPDSNotFound is returned when the requested blob does not exist on the PDS. 19 + ErrPDSNotFound = errors.New("blob not found on PDS") 20 + 21 + // ErrPDSTimeout is returned when a PDS request exceeds the configured timeout. 22 + ErrPDSTimeout = errors.New("PDS request timed out") 23 + 24 + // ErrUnsupportedFormat is returned when the source image format cannot be processed. 25 + ErrUnsupportedFormat = errors.New("unsupported image format") 26 + 27 + // ErrImageTooLarge is returned when the source image exceeds the maximum allowed size. 28 + ErrImageTooLarge = errors.New("source image exceeds size limit") 29 + 30 + // ErrProcessingFailed is returned when image processing fails for any reason. 31 + ErrProcessingFailed = errors.New("image processing failed") 32 + 33 + // ErrNilDependency is returned when a required dependency is nil. 34 + ErrNilDependency = errors.New("required dependency is nil") 35 + )
+158
internal/core/imageproxy/fetcher.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + "time" 12 + ) 13 + 14 + // Fetcher defines the interface for fetching blobs from a PDS. 15 + type Fetcher interface { 16 + // Fetch retrieves a blob from the specified PDS. 17 + // Returns the blob bytes or an error if the fetch fails. 18 + Fetch(ctx context.Context, pdsURL, did, cid string) ([]byte, error) 19 + } 20 + 21 + // PDSFetcher implements the Fetcher interface for fetching blobs from atproto PDS servers. 22 + type PDSFetcher struct { 23 + client *http.Client 24 + timeout time.Duration 25 + maxSizeBytes int64 26 + } 27 + 28 + // DefaultMaxSourceSizeMB is the default maximum source image size if not configured. 29 + const DefaultMaxSourceSizeMB = 10 30 + 31 + // NewPDSFetcher creates a new PDSFetcher with the specified timeout. 32 + // maxSizeMB specifies the maximum allowed image size in megabytes (0 uses default of 10MB). 33 + func NewPDSFetcher(timeout time.Duration, maxSizeMB int) *PDSFetcher { 34 + if maxSizeMB <= 0 { 35 + maxSizeMB = DefaultMaxSourceSizeMB 36 + } 37 + return &PDSFetcher{ 38 + client: &http.Client{ 39 + Timeout: timeout, 40 + }, 41 + timeout: timeout, 42 + maxSizeBytes: int64(maxSizeMB) * 1024 * 1024, 43 + } 44 + } 45 + 46 + // Fetch retrieves a blob from the specified PDS using the com.atproto.sync.getBlob endpoint. 47 + // Returns: 48 + // - ErrPDSNotFound if the blob does not exist (404 response) 49 + // - ErrPDSTimeout if the request times out or context is cancelled 50 + // - ErrPDSFetchFailed for any other error 51 + func (f *PDSFetcher) Fetch(ctx context.Context, pdsURL, did, cid string) ([]byte, error) { 52 + // Construct the request URL 53 + endpoint, err := url.Parse(pdsURL) 54 + if err != nil { 55 + return nil, fmt.Errorf("%w: invalid PDS URL: %v", ErrPDSFetchFailed, err) 56 + } 57 + endpoint.Path = "/xrpc/com.atproto.sync.getBlob" 58 + 59 + query := url.Values{} 60 + query.Set("did", did) 61 + query.Set("cid", cid) 62 + endpoint.RawQuery = query.Encode() 63 + 64 + // Create the request with context 65 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) 66 + if err != nil { 67 + return nil, fmt.Errorf("%w: failed to create request: %v", ErrPDSFetchFailed, err) 68 + } 69 + 70 + // Set User-Agent header for identification 71 + req.Header.Set("User-Agent", "Coves-ImageProxy/1.0") 72 + 73 + // Execute the request 74 + resp, err := f.client.Do(req) 75 + if err != nil { 76 + // Check if the error is due to context cancellation or timeout 77 + if ctx.Err() != nil { 78 + return nil, fmt.Errorf("%w: %v", ErrPDSTimeout, ctx.Err()) 79 + } 80 + // Check if it's a timeout error from the http client 81 + if isTimeoutError(err) { 82 + return nil, fmt.Errorf("%w: request timed out", ErrPDSTimeout) 83 + } 84 + return nil, fmt.Errorf("%w: %v", ErrPDSFetchFailed, err) 85 + } 86 + defer resp.Body.Close() 87 + 88 + // Handle response status codes 89 + switch resp.StatusCode { 90 + case http.StatusOK: 91 + // Check Content-Length header if available 92 + if resp.ContentLength > 0 && resp.ContentLength > f.maxSizeBytes { 93 + return nil, fmt.Errorf("%w: content length %d exceeds maximum %d bytes", 94 + ErrImageTooLarge, resp.ContentLength, f.maxSizeBytes) 95 + } 96 + 97 + // Use a limited reader to prevent memory exhaustion even if Content-Length is missing or wrong. 98 + // We read maxSizeBytes + 1 to detect if the response exceeds the limit. 99 + limitedReader := io.LimitReader(resp.Body, f.maxSizeBytes+1) 100 + data, err := io.ReadAll(limitedReader) 101 + if err != nil { 102 + return nil, fmt.Errorf("%w: failed to read response body: %v", ErrPDSFetchFailed, err) 103 + } 104 + 105 + // Check if we hit the limit (meaning there was more data) 106 + if int64(len(data)) > f.maxSizeBytes { 107 + return nil, fmt.Errorf("%w: response body exceeds maximum %d bytes", 108 + ErrImageTooLarge, f.maxSizeBytes) 109 + } 110 + 111 + return data, nil 112 + 113 + case http.StatusNotFound: 114 + return nil, ErrPDSNotFound 115 + 116 + case http.StatusBadRequest: 117 + // AT Protocol PDS may return 400 with "Blob not found" for missing blobs 118 + // We need to check the error message to distinguish from actual bad requests 119 + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024)) 120 + if readErr == nil && isBlobNotFoundError(body) { 121 + return nil, ErrPDSNotFound 122 + } 123 + return nil, fmt.Errorf("%w: bad request (status 400)", ErrPDSFetchFailed) 124 + 125 + default: 126 + return nil, fmt.Errorf("%w: unexpected status code %d", ErrPDSFetchFailed, resp.StatusCode) 127 + } 128 + } 129 + 130 + // pdsErrorResponse represents the error response structure from AT Protocol PDS 131 + type pdsErrorResponse struct { 132 + Error string `json:"error"` 133 + Message string `json:"message"` 134 + } 135 + 136 + // isBlobNotFoundError checks if the error response indicates a blob was not found. 137 + // AT Protocol PDS returns 400 with {"error":"InvalidRequest","message":"Blob not found"} 138 + // for missing blobs instead of a proper 404. 139 + func isBlobNotFoundError(body []byte) bool { 140 + var errResp pdsErrorResponse 141 + if err := json.Unmarshal(body, &errResp); err != nil { 142 + return false 143 + } 144 + // Check for "Blob not found" message (case-insensitive) 145 + return strings.Contains(strings.ToLower(errResp.Message), "blob not found") 146 + } 147 + 148 + // isTimeoutError checks if the error is a timeout-related error. 149 + func isTimeoutError(err error) bool { 150 + if err == nil { 151 + return false 152 + } 153 + // Check for timeout interface 154 + if te, ok := err.(interface{ Timeout() bool }); ok { 155 + return te.Timeout() 156 + } 157 + return false 158 + }
+231
internal/core/imageproxy/fetcher_test.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + "time" 10 + ) 11 + 12 + func TestPDSFetcher_Fetch_Success(t *testing.T) { 13 + // Setup test server that returns blob data 14 + expectedData := []byte("test image data") 15 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 + // Verify the request path and query parameters 17 + if r.URL.Path != "/xrpc/com.atproto.sync.getBlob" { 18 + t.Errorf("unexpected path: %s", r.URL.Path) 19 + } 20 + if r.URL.Query().Get("did") != "did:plc:test123" { 21 + t.Errorf("unexpected did: %s", r.URL.Query().Get("did")) 22 + } 23 + if r.URL.Query().Get("cid") != "bafyreicid123" { 24 + t.Errorf("unexpected cid: %s", r.URL.Query().Get("cid")) 25 + } 26 + w.WriteHeader(http.StatusOK) 27 + w.Write(expectedData) 28 + })) 29 + defer server.Close() 30 + 31 + fetcher := NewPDSFetcher(5 * time.Second, 10) 32 + ctx := context.Background() 33 + 34 + data, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 35 + if err != nil { 36 + t.Fatalf("expected no error, got: %v", err) 37 + } 38 + if string(data) != string(expectedData) { 39 + t.Errorf("expected data %q, got %q", expectedData, data) 40 + } 41 + } 42 + 43 + func TestPDSFetcher_Fetch_NotFound(t *testing.T) { 44 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 45 + w.WriteHeader(http.StatusNotFound) 46 + })) 47 + defer server.Close() 48 + 49 + fetcher := NewPDSFetcher(5 * time.Second, 10) 50 + ctx := context.Background() 51 + 52 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 53 + if !errors.Is(err, ErrPDSNotFound) { 54 + t.Errorf("expected ErrPDSNotFound, got: %v", err) 55 + } 56 + } 57 + 58 + func TestPDSFetcher_Fetch_Timeout(t *testing.T) { 59 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 60 + // Sleep longer than the timeout 61 + time.Sleep(200 * time.Millisecond) 62 + w.WriteHeader(http.StatusOK) 63 + })) 64 + defer server.Close() 65 + 66 + // Use a very short timeout 67 + fetcher := NewPDSFetcher(50 * time.Millisecond, 10) 68 + ctx := context.Background() 69 + 70 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 71 + if !errors.Is(err, ErrPDSTimeout) { 72 + t.Errorf("expected ErrPDSTimeout, got: %v", err) 73 + } 74 + } 75 + 76 + func TestPDSFetcher_Fetch_NetworkError(t *testing.T) { 77 + fetcher := NewPDSFetcher(5 * time.Second, 10) 78 + ctx := context.Background() 79 + 80 + // Use an invalid URL that will cause a network error 81 + _, err := fetcher.Fetch(ctx, "http://localhost:99999", "did:plc:test123", "bafyreicid123") 82 + if !errors.Is(err, ErrPDSFetchFailed) { 83 + t.Errorf("expected ErrPDSFetchFailed, got: %v", err) 84 + } 85 + } 86 + 87 + func TestPDSFetcher_Fetch_ContextCancellation(t *testing.T) { 88 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 89 + // Sleep to allow context cancellation 90 + time.Sleep(100 * time.Millisecond) 91 + w.WriteHeader(http.StatusOK) 92 + })) 93 + defer server.Close() 94 + 95 + fetcher := NewPDSFetcher(5 * time.Second, 10) 96 + ctx, cancel := context.WithCancel(context.Background()) 97 + 98 + // Cancel the context immediately 99 + cancel() 100 + 101 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 102 + if err == nil { 103 + t.Error("expected error due to context cancellation") 104 + } 105 + // Context cancellation should return ErrPDSTimeout 106 + if !errors.Is(err, ErrPDSTimeout) { 107 + t.Errorf("expected ErrPDSTimeout for context cancellation, got: %v", err) 108 + } 109 + } 110 + 111 + func TestPDSFetcher_Fetch_ServerError(t *testing.T) { 112 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 113 + w.WriteHeader(http.StatusInternalServerError) 114 + })) 115 + defer server.Close() 116 + 117 + fetcher := NewPDSFetcher(5 * time.Second, 10) 118 + ctx := context.Background() 119 + 120 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 121 + if !errors.Is(err, ErrPDSFetchFailed) { 122 + t.Errorf("expected ErrPDSFetchFailed, got: %v", err) 123 + } 124 + } 125 + 126 + func TestPDSFetcher_Fetch_URLConstruction(t *testing.T) { 127 + var capturedURL string 128 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 129 + capturedURL = r.URL.String() 130 + w.WriteHeader(http.StatusOK) 131 + w.Write([]byte("data")) 132 + })) 133 + defer server.Close() 134 + 135 + fetcher := NewPDSFetcher(5 * time.Second, 10) 136 + ctx := context.Background() 137 + 138 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:abc123", "bafyreicid456") 139 + if err != nil { 140 + t.Fatalf("unexpected error: %v", err) 141 + } 142 + 143 + expectedPath := "/xrpc/com.atproto.sync.getBlob?cid=bafyreicid456&did=did%3Aplc%3Aabc123" 144 + if capturedURL != expectedPath { 145 + t.Errorf("expected URL %q, got %q", expectedPath, capturedURL) 146 + } 147 + } 148 + 149 + func TestPDSFetcher_Fetch_ImageTooLarge_ContentLength(t *testing.T) { 150 + // Server returns Content-Length header indicating size exceeds limit 151 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 152 + // Set Content-Length larger than the max (1MB) 153 + w.Header().Set("Content-Length", "2097152") // 2MB 154 + w.WriteHeader(http.StatusOK) 155 + // Don't actually write 2MB of data 156 + })) 157 + defer server.Close() 158 + 159 + // Use 1MB max size 160 + fetcher := NewPDSFetcher(5*time.Second, 1) 161 + ctx := context.Background() 162 + 163 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 164 + if !errors.Is(err, ErrImageTooLarge) { 165 + t.Errorf("expected ErrImageTooLarge, got: %v", err) 166 + } 167 + } 168 + 169 + func TestPDSFetcher_Fetch_ImageTooLarge_StreamingBody(t *testing.T) { 170 + // Server doesn't send Content-Length but streams more data than allowed 171 + largeData := make([]byte, 2*1024*1024) // 2MB of zeros 172 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 173 + w.WriteHeader(http.StatusOK) 174 + w.Write(largeData) 175 + })) 176 + defer server.Close() 177 + 178 + // Use 1MB max size 179 + fetcher := NewPDSFetcher(5*time.Second, 1) 180 + ctx := context.Background() 181 + 182 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 183 + if !errors.Is(err, ErrImageTooLarge) { 184 + t.Errorf("expected ErrImageTooLarge, got: %v", err) 185 + } 186 + } 187 + 188 + func TestPDSFetcher_Fetch_SizeWithinLimit(t *testing.T) { 189 + // Server returns data within the limit 190 + testData := make([]byte, 512*1024) // 512KB 191 + for i := range testData { 192 + testData[i] = byte(i % 256) 193 + } 194 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 195 + w.WriteHeader(http.StatusOK) 196 + w.Write(testData) 197 + })) 198 + defer server.Close() 199 + 200 + // Use 1MB max size 201 + fetcher := NewPDSFetcher(5*time.Second, 1) 202 + ctx := context.Background() 203 + 204 + data, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 205 + if err != nil { 206 + t.Fatalf("expected no error, got: %v", err) 207 + } 208 + if len(data) != len(testData) { 209 + t.Errorf("expected %d bytes, got %d", len(testData), len(data)) 210 + } 211 + } 212 + 213 + func TestPDSFetcher_Fetch_DefaultMaxSize(t *testing.T) { 214 + // Test that 0 for maxSizeMB uses the default 215 + fetcher := NewPDSFetcher(5*time.Second, 0) 216 + expectedDefault := int64(DefaultMaxSourceSizeMB) * 1024 * 1024 217 + 218 + if fetcher.maxSizeBytes != expectedDefault { 219 + t.Errorf("expected default maxSizeBytes %d, got %d", expectedDefault, fetcher.maxSizeBytes) 220 + } 221 + } 222 + 223 + func TestPDSFetcher_Fetch_NegativeMaxSize(t *testing.T) { 224 + // Test that negative maxSizeMB uses the default 225 + fetcher := NewPDSFetcher(5*time.Second, -5) 226 + expectedDefault := int64(DefaultMaxSourceSizeMB) * 1024 * 1024 227 + 228 + if fetcher.maxSizeBytes != expectedDefault { 229 + t.Errorf("expected default maxSizeBytes %d, got %d", expectedDefault, fetcher.maxSizeBytes) 230 + } 231 + }
+117
internal/core/imageproxy/presets.go
··· 1 + package imageproxy 2 + 3 + // FitMode defines how an image should be fitted to the target dimensions. 4 + type FitMode string 5 + 6 + const ( 7 + // FitCover scales the image to cover the target dimensions, cropping if necessary. 8 + FitCover FitMode = "cover" 9 + // FitContain scales the image to fit within the target dimensions, preserving aspect ratio. 10 + FitContain FitMode = "contain" 11 + ) 12 + 13 + // String returns the string representation of the FitMode. 14 + func (f FitMode) String() string { 15 + return string(f) 16 + } 17 + 18 + // Preset defines the configuration for an image transformation preset. 19 + type Preset struct { 20 + Name string 21 + Width int 22 + Height int 23 + Fit FitMode 24 + Quality int 25 + } 26 + 27 + // Validate checks that the preset has valid configuration values. 28 + // Returns nil if valid, or an error describing what is wrong. 29 + func (p Preset) Validate() error { 30 + if p.Name == "" { 31 + return ErrInvalidPreset 32 + } 33 + if p.Width <= 0 { 34 + return ErrInvalidPreset 35 + } 36 + // Height can be 0 for FitContain (proportional scaling) 37 + if p.Fit == FitCover && p.Height <= 0 { 38 + return ErrInvalidPreset 39 + } 40 + // Quality must be in JPEG range (1-100) 41 + if p.Quality < 1 || p.Quality > 100 { 42 + return ErrInvalidPreset 43 + } 44 + // Validate FitMode is a known value 45 + if p.Fit != FitCover && p.Fit != FitContain { 46 + return ErrInvalidPreset 47 + } 48 + return nil 49 + } 50 + 51 + // presets is the registry of all available image presets. 52 + var presets = map[string]Preset{ 53 + "avatar": { 54 + Name: "avatar", 55 + Width: 1000, 56 + Height: 1000, 57 + Fit: FitCover, 58 + Quality: 85, 59 + }, 60 + "avatar_small": { 61 + Name: "avatar_small", 62 + Width: 360, 63 + Height: 360, 64 + Fit: FitCover, 65 + Quality: 80, 66 + }, 67 + "banner": { 68 + Name: "banner", 69 + Width: 640, 70 + Height: 300, 71 + Fit: FitCover, 72 + Quality: 85, 73 + }, 74 + "content_preview": { 75 + Name: "content_preview", 76 + Width: 800, 77 + Height: 0, 78 + Fit: FitContain, 79 + Quality: 80, 80 + }, 81 + "content_full": { 82 + Name: "content_full", 83 + Width: 1600, 84 + Height: 0, 85 + Fit: FitContain, 86 + Quality: 90, 87 + }, 88 + "embed_thumbnail": { 89 + Name: "embed_thumbnail", 90 + Width: 720, 91 + Height: 360, 92 + Fit: FitCover, 93 + Quality: 80, 94 + }, 95 + } 96 + 97 + // GetPreset returns the preset configuration for the given name. 98 + // Returns ErrInvalidPreset if the preset name is not found. 99 + func GetPreset(name string) (Preset, error) { 100 + if name == "" { 101 + return Preset{}, ErrInvalidPreset 102 + } 103 + preset, exists := presets[name] 104 + if !exists { 105 + return Preset{}, ErrInvalidPreset 106 + } 107 + return preset, nil 108 + } 109 + 110 + // ListPresets returns all available presets. 111 + func ListPresets() []Preset { 112 + result := make([]Preset, 0, len(presets)) 113 + for _, p := range presets { 114 + result = append(result, p) 115 + } 116 + return result 117 + }
+271
internal/core/imageproxy/presets_test.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + "github.com/stretchr/testify/require" 8 + ) 9 + 10 + func TestGetPreset(t *testing.T) { 11 + tests := []struct { 12 + name string 13 + presetName string 14 + wantWidth int 15 + wantHeight int 16 + wantFit FitMode 17 + wantQuality int 18 + wantErr error 19 + }{ 20 + { 21 + name: "avatar preset returns correct dimensions", 22 + presetName: "avatar", 23 + wantWidth: 1000, 24 + wantHeight: 1000, 25 + wantFit: FitCover, 26 + wantQuality: 85, 27 + wantErr: nil, 28 + }, 29 + { 30 + name: "avatar_small preset returns correct dimensions", 31 + presetName: "avatar_small", 32 + wantWidth: 360, 33 + wantHeight: 360, 34 + wantFit: FitCover, 35 + wantQuality: 80, 36 + wantErr: nil, 37 + }, 38 + { 39 + name: "banner preset returns correct dimensions", 40 + presetName: "banner", 41 + wantWidth: 640, 42 + wantHeight: 300, 43 + wantFit: FitCover, 44 + wantQuality: 85, 45 + wantErr: nil, 46 + }, 47 + { 48 + name: "content_preview preset returns correct dimensions", 49 + presetName: "content_preview", 50 + wantWidth: 800, 51 + wantHeight: 0, 52 + wantFit: FitContain, 53 + wantQuality: 80, 54 + wantErr: nil, 55 + }, 56 + { 57 + name: "content_full preset returns correct dimensions", 58 + presetName: "content_full", 59 + wantWidth: 1600, 60 + wantHeight: 0, 61 + wantFit: FitContain, 62 + wantQuality: 90, 63 + wantErr: nil, 64 + }, 65 + { 66 + name: "embed_thumbnail preset returns correct dimensions", 67 + presetName: "embed_thumbnail", 68 + wantWidth: 720, 69 + wantHeight: 360, 70 + wantFit: FitCover, 71 + wantQuality: 80, 72 + wantErr: nil, 73 + }, 74 + { 75 + name: "invalid preset returns error", 76 + presetName: "invalid", 77 + wantErr: ErrInvalidPreset, 78 + }, 79 + { 80 + name: "empty preset name returns error", 81 + presetName: "", 82 + wantErr: ErrInvalidPreset, 83 + }, 84 + { 85 + name: "case sensitive - AVATAR should not match", 86 + presetName: "AVATAR", 87 + wantErr: ErrInvalidPreset, 88 + }, 89 + } 90 + 91 + for _, tt := range tests { 92 + t.Run(tt.name, func(t *testing.T) { 93 + preset, err := GetPreset(tt.presetName) 94 + 95 + if tt.wantErr != nil { 96 + require.Error(t, err) 97 + assert.ErrorIs(t, err, tt.wantErr) 98 + return 99 + } 100 + 101 + require.NoError(t, err) 102 + assert.Equal(t, tt.presetName, preset.Name) 103 + assert.Equal(t, tt.wantWidth, preset.Width) 104 + assert.Equal(t, tt.wantHeight, preset.Height) 105 + assert.Equal(t, tt.wantFit, preset.Fit) 106 + assert.Equal(t, tt.wantQuality, preset.Quality) 107 + }) 108 + } 109 + } 110 + 111 + func TestAllPresetsHaveValidDimensions(t *testing.T) { 112 + presetNames := []string{ 113 + "avatar", 114 + "avatar_small", 115 + "banner", 116 + "content_preview", 117 + "content_full", 118 + "embed_thumbnail", 119 + } 120 + 121 + for _, name := range presetNames { 122 + t.Run(name, func(t *testing.T) { 123 + preset, err := GetPreset(name) 124 + require.NoError(t, err) 125 + 126 + // Width must always be positive 127 + assert.Greater(t, preset.Width, 0, "preset %s must have positive width", name) 128 + 129 + // Height can be 0 for contain fit (proportional scaling) 130 + if preset.Fit == FitCover { 131 + assert.Greater(t, preset.Height, 0, "cover fit preset %s must have positive height", name) 132 + } 133 + 134 + // Quality must be between 1 and 100 135 + assert.GreaterOrEqual(t, preset.Quality, 1, "preset %s quality must be >= 1", name) 136 + assert.LessOrEqual(t, preset.Quality, 100, "preset %s quality must be <= 100", name) 137 + 138 + // Name must match 139 + assert.Equal(t, name, preset.Name) 140 + }) 141 + } 142 + } 143 + 144 + func TestFitModeString(t *testing.T) { 145 + tests := []struct { 146 + mode FitMode 147 + want string 148 + }{ 149 + {FitCover, "cover"}, 150 + {FitContain, "contain"}, 151 + } 152 + 153 + for _, tt := range tests { 154 + t.Run(tt.want, func(t *testing.T) { 155 + assert.Equal(t, tt.want, tt.mode.String()) 156 + }) 157 + } 158 + } 159 + 160 + func TestListPresets(t *testing.T) { 161 + presets := ListPresets() 162 + 163 + // Should have all 6 presets 164 + assert.Len(t, presets, 6) 165 + 166 + // Verify all expected presets are present 167 + expectedNames := map[string]bool{ 168 + "avatar": false, 169 + "avatar_small": false, 170 + "banner": false, 171 + "content_preview": false, 172 + "content_full": false, 173 + "embed_thumbnail": false, 174 + } 175 + 176 + for _, p := range presets { 177 + if _, exists := expectedNames[p.Name]; exists { 178 + expectedNames[p.Name] = true 179 + } 180 + } 181 + 182 + for name, found := range expectedNames { 183 + assert.True(t, found, "expected preset %s to be in list", name) 184 + } 185 + } 186 + 187 + func TestPreset_Validate(t *testing.T) { 188 + tests := []struct { 189 + name string 190 + preset Preset 191 + wantErr bool 192 + }{ 193 + { 194 + name: "valid avatar preset", 195 + preset: Preset{Name: "avatar", Width: 1000, Height: 1000, Fit: FitCover, Quality: 85}, 196 + wantErr: false, 197 + }, 198 + { 199 + name: "valid contain preset with zero height", 200 + preset: Preset{Name: "content", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, 201 + wantErr: false, 202 + }, 203 + { 204 + name: "invalid empty name", 205 + preset: Preset{Name: "", Width: 160, Height: 160, Fit: FitCover, Quality: 85}, 206 + wantErr: true, 207 + }, 208 + { 209 + name: "invalid zero width", 210 + preset: Preset{Name: "test", Width: 0, Height: 160, Fit: FitCover, Quality: 85}, 211 + wantErr: true, 212 + }, 213 + { 214 + name: "invalid negative width", 215 + preset: Preset{Name: "test", Width: -100, Height: 160, Fit: FitCover, Quality: 85}, 216 + wantErr: true, 217 + }, 218 + { 219 + name: "invalid cover fit with zero height", 220 + preset: Preset{Name: "test", Width: 160, Height: 0, Fit: FitCover, Quality: 85}, 221 + wantErr: true, 222 + }, 223 + { 224 + name: "invalid quality zero", 225 + preset: Preset{Name: "test", Width: 160, Height: 160, Fit: FitCover, Quality: 0}, 226 + wantErr: true, 227 + }, 228 + { 229 + name: "invalid quality over 100", 230 + preset: Preset{Name: "test", Width: 160, Height: 160, Fit: FitCover, Quality: 101}, 231 + wantErr: true, 232 + }, 233 + { 234 + name: "valid quality at boundary 1", 235 + preset: Preset{Name: "test", Width: 160, Height: 160, Fit: FitCover, Quality: 1}, 236 + wantErr: false, 237 + }, 238 + { 239 + name: "valid quality at boundary 100", 240 + preset: Preset{Name: "test", Width: 160, Height: 160, Fit: FitCover, Quality: 100}, 241 + wantErr: false, 242 + }, 243 + { 244 + name: "invalid unknown fit mode", 245 + preset: Preset{Name: "test", Width: 160, Height: 160, Fit: FitMode("unknown"), Quality: 85}, 246 + wantErr: true, 247 + }, 248 + } 249 + 250 + for _, tt := range tests { 251 + t.Run(tt.name, func(t *testing.T) { 252 + err := tt.preset.Validate() 253 + if tt.wantErr { 254 + assert.Error(t, err) 255 + assert.ErrorIs(t, err, ErrInvalidPreset) 256 + } else { 257 + assert.NoError(t, err) 258 + } 259 + }) 260 + } 261 + } 262 + 263 + func TestAllPresetsValidate(t *testing.T) { 264 + // All built-in presets should pass validation 265 + for _, preset := range ListPresets() { 266 + t.Run(preset.Name, func(t *testing.T) { 267 + err := preset.Validate() 268 + assert.NoError(t, err, "built-in preset %s should be valid", preset.Name) 269 + }) 270 + } 271 + }
+117
internal/core/imageproxy/processor.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "image" 7 + "image/jpeg" 8 + _ "image/png" // Register PNG decoder 9 + 10 + "github.com/disintegration/imaging" 11 + _ "golang.org/x/image/webp" // Register WebP decoder 12 + ) 13 + 14 + // Processor defines the interface for image processing operations. 15 + type Processor interface { 16 + // Process transforms image data according to the preset configuration. 17 + // Returns the processed image as JPEG bytes, or an error if processing fails. 18 + Process(data []byte, preset Preset) ([]byte, error) 19 + } 20 + 21 + // ImageProcessor implements the Processor interface using the imaging library. 22 + type ImageProcessor struct{} 23 + 24 + // NewProcessor creates a new ImageProcessor instance. 25 + func NewProcessor() Processor { 26 + return &ImageProcessor{} 27 + } 28 + 29 + // Process transforms the input image data according to the preset configuration. 30 + // It handles both cover fit (crops to exact dimensions) and contain fit (preserves 31 + // aspect ratio within bounds). Output is always JPEG format. 32 + func (p *ImageProcessor) Process(data []byte, preset Preset) ([]byte, error) { 33 + // Check for empty or nil data 34 + if len(data) == 0 { 35 + return nil, fmt.Errorf("%w: empty image data", ErrUnsupportedFormat) 36 + } 37 + 38 + // Decode the source image 39 + img, format, err := image.Decode(bytes.NewReader(data)) 40 + if err != nil { 41 + // Determine if this is a format issue or a corruption issue 42 + if isUnsupportedFormatError(err) { 43 + return nil, fmt.Errorf("%w: %v", ErrUnsupportedFormat, err) 44 + } 45 + return nil, fmt.Errorf("%w: failed to decode image: %v", ErrProcessingFailed, err) 46 + } 47 + 48 + // Validate that we decoded a supported format 49 + if format != "jpeg" && format != "png" && format != "webp" { 50 + return nil, fmt.Errorf("%w: format %s", ErrUnsupportedFormat, format) 51 + } 52 + 53 + // Process the image based on fit mode 54 + var processed image.Image 55 + switch preset.Fit { 56 + case FitCover: 57 + processed = processCover(img, preset.Width, preset.Height) 58 + case FitContain: 59 + processed = processContain(img, preset.Width, preset.Height) 60 + default: 61 + return nil, fmt.Errorf("%w: unknown fit mode", ErrProcessingFailed) 62 + } 63 + 64 + // Encode as JPEG 65 + var buf bytes.Buffer 66 + if err := jpeg.Encode(&buf, processed, &jpeg.Options{Quality: preset.Quality}); err != nil { 67 + return nil, fmt.Errorf("%w: failed to encode JPEG: %v", ErrProcessingFailed, err) 68 + } 69 + 70 + return buf.Bytes(), nil 71 + } 72 + 73 + // processCover scales and crops the image to exactly fill the target dimensions. 74 + // The image is scaled to cover the entire target area, then cropped to exact size. 75 + func processCover(img image.Image, width, height int) image.Image { 76 + // Use imaging.Fill which scales to cover and crops to exact dimensions 77 + return imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos) 78 + } 79 + 80 + // processContain scales the image to fit within the target width while preserving 81 + // aspect ratio. If the source image is smaller than the target, it is not upscaled. 82 + // Height of 0 means scale proportionally based on width only. 83 + func processContain(img image.Image, maxWidth, maxHeight int) image.Image { 84 + bounds := img.Bounds() 85 + srcWidth := bounds.Dx() 86 + srcHeight := bounds.Dy() 87 + 88 + // Don't upscale images smaller than target 89 + if srcWidth <= maxWidth { 90 + return img 91 + } 92 + 93 + // Calculate new dimensions preserving aspect ratio 94 + newWidth := maxWidth 95 + newHeight := int(float64(srcHeight) * (float64(maxWidth) / float64(srcWidth))) 96 + 97 + // If maxHeight is specified and calculated height exceeds it, 98 + // scale based on height instead 99 + if maxHeight > 0 && newHeight > maxHeight { 100 + newHeight = maxHeight 101 + newWidth = int(float64(srcWidth) * (float64(maxHeight) / float64(srcHeight))) 102 + } 103 + 104 + return imaging.Resize(img, newWidth, newHeight, imaging.Lanczos) 105 + } 106 + 107 + // isUnsupportedFormatError checks if the error indicates an unsupported image format. 108 + func isUnsupportedFormatError(err error) bool { 109 + if err == nil { 110 + return false 111 + } 112 + errStr := err.Error() 113 + return errStr == "image: unknown format" || 114 + errStr == "invalid JPEG format: missing SOI marker" || 115 + errStr == "invalid JPEG format: short segment" || 116 + bytes.Contains([]byte(errStr), []byte("unknown format")) 117 + }
+299
internal/core/imageproxy/processor_test.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "bytes" 5 + "image" 6 + "image/color" 7 + "image/jpeg" 8 + "image/png" 9 + "testing" 10 + 11 + "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/require" 13 + ) 14 + 15 + // createTestJPEG creates a test JPEG image with the specified dimensions. 16 + func createTestJPEG(t *testing.T, width, height int) []byte { 17 + t.Helper() 18 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 19 + // Fill with a solid color 20 + for y := 0; y < height; y++ { 21 + for x := 0; x < width; x++ { 22 + img.Set(x, y, color.RGBA{R: 255, G: 128, B: 64, A: 255}) 23 + } 24 + } 25 + var buf bytes.Buffer 26 + err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}) 27 + require.NoError(t, err) 28 + return buf.Bytes() 29 + } 30 + 31 + // createTestPNG creates a test PNG image with the specified dimensions. 32 + func createTestPNG(t *testing.T, width, height int) []byte { 33 + t.Helper() 34 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 35 + // Fill with a solid color 36 + for y := 0; y < height; y++ { 37 + for x := 0; x < width; x++ { 38 + img.Set(x, y, color.RGBA{R: 64, G: 128, B: 255, A: 255}) 39 + } 40 + } 41 + var buf bytes.Buffer 42 + err := png.Encode(&buf, img) 43 + require.NoError(t, err) 44 + return buf.Bytes() 45 + } 46 + 47 + func TestProcessor_Process_CoverFit(t *testing.T) { 48 + proc := NewProcessor() 49 + 50 + tests := []struct { 51 + name string 52 + srcWidth int 53 + srcHeight int 54 + preset Preset 55 + wantWidth int 56 + wantHeight int 57 + description string 58 + }{ 59 + { 60 + name: "landscape image to square avatar", 61 + srcWidth: 800, 62 + srcHeight: 600, 63 + preset: Preset{Name: "avatar", Width: 1000, Height: 1000, Fit: FitCover, Quality: 85}, 64 + wantWidth: 1000, 65 + wantHeight: 1000, 66 + description: "landscape cropped to square", 67 + }, 68 + { 69 + name: "portrait image to square avatar", 70 + srcWidth: 600, 71 + srcHeight: 800, 72 + preset: Preset{Name: "avatar", Width: 1000, Height: 1000, Fit: FitCover, Quality: 85}, 73 + wantWidth: 1000, 74 + wantHeight: 1000, 75 + description: "portrait cropped to square", 76 + }, 77 + { 78 + name: "square image to smaller square", 79 + srcWidth: 500, 80 + srcHeight: 500, 81 + preset: Preset{Name: "avatar_small", Width: 360, Height: 360, Fit: FitCover, Quality: 80}, 82 + wantWidth: 360, 83 + wantHeight: 360, 84 + description: "square scaled down", 85 + }, 86 + { 87 + name: "landscape to banner dimensions", 88 + srcWidth: 1920, 89 + srcHeight: 1080, 90 + preset: Preset{Name: "banner", Width: 640, Height: 300, Fit: FitCover, Quality: 85}, 91 + wantWidth: 640, 92 + wantHeight: 300, 93 + description: "banner crop", 94 + }, 95 + { 96 + name: "embed thumbnail dimensions", 97 + srcWidth: 1600, 98 + srcHeight: 900, 99 + preset: Preset{Name: "embed_thumbnail", Width: 720, Height: 360, Fit: FitCover, Quality: 80}, 100 + wantWidth: 720, 101 + wantHeight: 360, 102 + description: "embed thumbnail crop", 103 + }, 104 + } 105 + 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + srcData := createTestJPEG(t, tt.srcWidth, tt.srcHeight) 109 + 110 + result, err := proc.Process(srcData, tt.preset) 111 + require.NoError(t, err) 112 + require.NotNil(t, result) 113 + 114 + // Decode the result to verify dimensions 115 + img, _, err := image.Decode(bytes.NewReader(result)) 116 + require.NoError(t, err) 117 + 118 + bounds := img.Bounds() 119 + assert.Equal(t, tt.wantWidth, bounds.Dx(), "width mismatch for %s", tt.description) 120 + assert.Equal(t, tt.wantHeight, bounds.Dy(), "height mismatch for %s", tt.description) 121 + }) 122 + } 123 + } 124 + 125 + func TestProcessor_Process_ContainFit(t *testing.T) { 126 + proc := NewProcessor() 127 + 128 + tests := []struct { 129 + name string 130 + srcWidth int 131 + srcHeight int 132 + preset Preset 133 + wantMaxWidth int 134 + wantMaxHeight int 135 + description string 136 + }{ 137 + { 138 + name: "landscape image scaled to content_preview width", 139 + srcWidth: 1600, 140 + srcHeight: 900, 141 + preset: Preset{Name: "content_preview", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, 142 + wantMaxWidth: 800, 143 + wantMaxHeight: 450, // 800 * (900/1600) = 450 (aspect ratio preserved) 144 + description: "landscape scaled proportionally", 145 + }, 146 + { 147 + name: "portrait image scaled to content_preview width", 148 + srcWidth: 900, 149 + srcHeight: 1600, 150 + preset: Preset{Name: "content_preview", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, 151 + wantMaxWidth: 800, 152 + wantMaxHeight: 1422, // 800 * (1600/900) ~= 1422 153 + description: "portrait scaled proportionally", 154 + }, 155 + { 156 + name: "wide panorama to content_full", 157 + srcWidth: 3200, 158 + srcHeight: 800, 159 + preset: Preset{Name: "content_full", Width: 1600, Height: 0, Fit: FitContain, Quality: 90}, 160 + wantMaxWidth: 1600, 161 + wantMaxHeight: 400, // 1600 * (800/3200) = 400 162 + description: "panorama scaled proportionally", 163 + }, 164 + { 165 + name: "image smaller than target width stays same size", 166 + srcWidth: 400, 167 + srcHeight: 300, 168 + preset: Preset{Name: "content_preview", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, 169 + wantMaxWidth: 400, // Don't upscale 170 + wantMaxHeight: 300, 171 + description: "small image not upscaled", 172 + }, 173 + } 174 + 175 + for _, tt := range tests { 176 + t.Run(tt.name, func(t *testing.T) { 177 + srcData := createTestJPEG(t, tt.srcWidth, tt.srcHeight) 178 + 179 + result, err := proc.Process(srcData, tt.preset) 180 + require.NoError(t, err) 181 + require.NotNil(t, result) 182 + 183 + // Decode the result to verify dimensions 184 + img, _, err := image.Decode(bytes.NewReader(result)) 185 + require.NoError(t, err) 186 + 187 + bounds := img.Bounds() 188 + // For contain fit, verify width doesn't exceed max and aspect ratio is preserved 189 + assert.LessOrEqual(t, bounds.Dx(), tt.wantMaxWidth, "width should not exceed max for %s", tt.description) 190 + assert.Equal(t, tt.wantMaxWidth, bounds.Dx(), "width mismatch for %s", tt.description) 191 + assert.Equal(t, tt.wantMaxHeight, bounds.Dy(), "height mismatch for %s", tt.description) 192 + }) 193 + } 194 + } 195 + 196 + func TestProcessor_Process_InvalidImageData(t *testing.T) { 197 + proc := NewProcessor() 198 + 199 + tests := []struct { 200 + name string 201 + data []byte 202 + wantErr error 203 + }{ 204 + { 205 + name: "empty data", 206 + data: []byte{}, 207 + wantErr: ErrUnsupportedFormat, 208 + }, 209 + { 210 + name: "nil data", 211 + data: nil, 212 + wantErr: ErrUnsupportedFormat, 213 + }, 214 + { 215 + name: "random garbage data", 216 + data: []byte("not an image at all"), 217 + wantErr: ErrUnsupportedFormat, 218 + }, 219 + { 220 + name: "truncated JPEG header", 221 + data: []byte{0xFF, 0xD8, 0xFF, 0xE0}, // Partial JPEG magic 222 + wantErr: ErrProcessingFailed, 223 + }, 224 + } 225 + 226 + preset, _ := GetPreset("avatar") 227 + 228 + for _, tt := range tests { 229 + t.Run(tt.name, func(t *testing.T) { 230 + result, err := proc.Process(tt.data, preset) 231 + require.Error(t, err) 232 + assert.ErrorIs(t, err, tt.wantErr) 233 + assert.Nil(t, result) 234 + }) 235 + } 236 + } 237 + 238 + func TestProcessor_Process_SupportsJPEG(t *testing.T) { 239 + proc := NewProcessor() 240 + srcData := createTestJPEG(t, 500, 500) 241 + preset, _ := GetPreset("avatar") 242 + 243 + result, err := proc.Process(srcData, preset) 244 + require.NoError(t, err) 245 + require.NotNil(t, result) 246 + 247 + // Verify output is valid JPEG 248 + img, format, err := image.Decode(bytes.NewReader(result)) 249 + require.NoError(t, err) 250 + assert.Equal(t, "jpeg", format) 251 + assert.Equal(t, 1000, img.Bounds().Dx()) 252 + assert.Equal(t, 1000, img.Bounds().Dy()) 253 + } 254 + 255 + func TestProcessor_Process_SupportsPNG(t *testing.T) { 256 + proc := NewProcessor() 257 + srcData := createTestPNG(t, 500, 500) 258 + preset, _ := GetPreset("avatar") 259 + 260 + result, err := proc.Process(srcData, preset) 261 + require.NoError(t, err) 262 + require.NotNil(t, result) 263 + 264 + // Verify output is valid JPEG (always output JPEG) 265 + img, format, err := image.Decode(bytes.NewReader(result)) 266 + require.NoError(t, err) 267 + assert.Equal(t, "jpeg", format) 268 + assert.Equal(t, 1000, img.Bounds().Dx()) 269 + assert.Equal(t, 1000, img.Bounds().Dy()) 270 + } 271 + 272 + func TestProcessor_Process_AlwaysOutputsJPEG(t *testing.T) { 273 + proc := NewProcessor() 274 + preset, _ := GetPreset("avatar") 275 + 276 + // Test with PNG input 277 + pngData := createTestPNG(t, 300, 300) 278 + result, err := proc.Process(pngData, preset) 279 + require.NoError(t, err) 280 + 281 + // Verify output is JPEG even when input is PNG 282 + _, format, err := image.Decode(bytes.NewReader(result)) 283 + require.NoError(t, err) 284 + assert.Equal(t, "jpeg", format, "output should always be JPEG") 285 + } 286 + 287 + func TestProcessor_Interface(t *testing.T) { 288 + // Compile-time check that ImageProcessor implements Processor 289 + var _ Processor = (*ImageProcessor)(nil) 290 + } 291 + 292 + func TestNewProcessor(t *testing.T) { 293 + proc := NewProcessor() 294 + require.NotNil(t, proc) 295 + 296 + // Verify it's an *ImageProcessor 297 + _, ok := proc.(*ImageProcessor) 298 + assert.True(t, ok, "NewProcessor should return *ImageProcessor") 299 + }
+141
internal/core/imageproxy/service.go
··· 1 + // Package imageproxy provides image proxy functionality for AT Protocol applications. 2 + // It handles fetching, caching, and transforming images from Personal Data Servers (PDS). 3 + // 4 + // The package implements a multi-tier architecture: 5 + // - Service: Orchestrates caching, fetching, and processing 6 + // - Cache: Disk-based LRU cache with TTL-based expiration 7 + // - Fetcher: Retrieves blobs from AT Protocol PDSes 8 + // - Processor: Transforms images according to preset configurations 9 + // 10 + // Presets define image transformation parameters (dimensions, fit mode, quality) 11 + // for common use cases like avatars, banners, and feed thumbnails. 12 + package imageproxy 13 + 14 + import ( 15 + "context" 16 + "fmt" 17 + "log/slog" 18 + "sync/atomic" 19 + ) 20 + 21 + // cacheWriteErrors tracks the number of async cache write failures. 22 + // This provides observability for cache write issues until proper metrics are implemented. 23 + var cacheWriteErrors atomic.Int64 24 + 25 + // CacheWriteErrorCount returns the total number of async cache write errors. 26 + // This is useful for monitoring and alerting on cache health. 27 + func CacheWriteErrorCount() int64 { 28 + return cacheWriteErrors.Load() 29 + } 30 + 31 + // Service defines the interface for the image proxy service. 32 + type Service interface { 33 + // GetImage retrieves an image for the given preset, DID, and CID. 34 + // It checks the cache first, then fetches from the PDS if not cached, 35 + // processes the image according to the preset, and stores in cache. 36 + GetImage(ctx context.Context, preset, did, cid string, pdsURL string) ([]byte, error) 37 + } 38 + 39 + // ImageProxyService implements the Service interface and orchestrates 40 + // caching, fetching, and processing of images. 41 + type ImageProxyService struct { 42 + cache Cache 43 + processor Processor 44 + fetcher Fetcher 45 + config Config 46 + } 47 + 48 + // NewService creates a new ImageProxyService with the provided dependencies. 49 + // Returns an error if any required dependency is nil. 50 + func NewService(cache Cache, processor Processor, fetcher Fetcher, config Config) (*ImageProxyService, error) { 51 + if cache == nil { 52 + return nil, fmt.Errorf("%w: cache", ErrNilDependency) 53 + } 54 + if processor == nil { 55 + return nil, fmt.Errorf("%w: processor", ErrNilDependency) 56 + } 57 + if fetcher == nil { 58 + return nil, fmt.Errorf("%w: fetcher", ErrNilDependency) 59 + } 60 + 61 + return &ImageProxyService{ 62 + cache: cache, 63 + processor: processor, 64 + fetcher: fetcher, 65 + config: config, 66 + }, nil 67 + } 68 + 69 + // GetImage retrieves an image for the given preset, DID, and CID. 70 + // The service flow is: 71 + // 1. Validate preset exists 72 + // 2. Check cache for (preset, did, cid) - return if hit 73 + // 3. Fetch blob from PDS using pdsURL 74 + // 4. Process image with preset 75 + // 5. Store in cache (async, don't block response) 76 + // 6. Return processed image 77 + func (s *ImageProxyService) GetImage(ctx context.Context, presetName, did, cid string, pdsURL string) ([]byte, error) { 78 + // Step 1: Validate preset exists 79 + preset, err := GetPreset(presetName) 80 + if err != nil { 81 + return nil, err 82 + } 83 + 84 + // Step 2: Check cache for (preset, did, cid) 85 + cachedData, found, err := s.cache.Get(presetName, did, cid) 86 + if err != nil { 87 + // Log cache read error but continue - cache miss is acceptable 88 + slog.Warn("[IMAGE-PROXY] cache read error, falling back to fetch", 89 + "preset", presetName, 90 + "did", did, 91 + "cid", cid, 92 + "error", err, 93 + ) 94 + } 95 + if found { 96 + slog.Debug("[IMAGE-PROXY] cache hit", 97 + "preset", presetName, 98 + "did", did, 99 + "cid", cid, 100 + ) 101 + return cachedData, nil 102 + } 103 + 104 + // Step 3: Fetch blob from PDS 105 + rawData, err := s.fetcher.Fetch(ctx, pdsURL, did, cid) 106 + if err != nil { 107 + return nil, err 108 + } 109 + 110 + // Step 4: Process image with preset 111 + processedData, err := s.processor.Process(rawData, preset) 112 + if err != nil { 113 + return nil, err 114 + } 115 + 116 + // Step 5: Store in cache (async, don't block response) 117 + go func() { 118 + // Use a background context since the original request context may be cancelled 119 + if cacheErr := s.cache.Set(presetName, did, cid, processedData); cacheErr != nil { 120 + // Increment error counter for monitoring 121 + cacheWriteErrors.Add(1) 122 + slog.Error("[IMAGE-PROXY] async cache write failed", 123 + "preset", presetName, 124 + "did", did, 125 + "cid", cid, 126 + "error", cacheErr, 127 + "total_cache_write_errors", cacheWriteErrors.Load(), 128 + ) 129 + } else { 130 + slog.Debug("[IMAGE-PROXY] cached processed image", 131 + "preset", presetName, 132 + "did", did, 133 + "cid", cid, 134 + "size_bytes", len(processedData), 135 + ) 136 + } 137 + }() 138 + 139 + // Step 6: Return processed image 140 + return processedData, nil 141 + }
+401
internal/core/imageproxy/service_test.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "sync" 7 + "testing" 8 + "time" 9 + ) 10 + 11 + // MockCache implements Cache for testing 12 + type MockCache struct { 13 + mu sync.Mutex 14 + data map[string][]byte 15 + getCalls int 16 + setCalls int 17 + setData map[string][]byte // Track what was set 18 + } 19 + 20 + func NewMockCache() *MockCache { 21 + return &MockCache{ 22 + data: make(map[string][]byte), 23 + setData: make(map[string][]byte), 24 + } 25 + } 26 + 27 + func (m *MockCache) cacheKey(preset, did, cid string) string { 28 + return preset + ":" + did + ":" + cid 29 + } 30 + 31 + func (m *MockCache) Get(preset, did, cid string) ([]byte, bool, error) { 32 + m.mu.Lock() 33 + defer m.mu.Unlock() 34 + m.getCalls++ 35 + key := m.cacheKey(preset, did, cid) 36 + data, found := m.data[key] 37 + return data, found, nil 38 + } 39 + 40 + func (m *MockCache) Set(preset, did, cid string, data []byte) error { 41 + m.mu.Lock() 42 + defer m.mu.Unlock() 43 + m.setCalls++ 44 + key := m.cacheKey(preset, did, cid) 45 + m.data[key] = data 46 + m.setData[key] = data 47 + return nil 48 + } 49 + 50 + func (m *MockCache) Delete(preset, did, cid string) error { 51 + m.mu.Lock() 52 + defer m.mu.Unlock() 53 + key := m.cacheKey(preset, did, cid) 54 + delete(m.data, key) 55 + return nil 56 + } 57 + 58 + func (m *MockCache) Cleanup() (int, error) { 59 + // Mock implementation - no-op for tests 60 + return 0, nil 61 + } 62 + 63 + func (m *MockCache) SetCacheData(preset, did, cid string, data []byte) { 64 + m.mu.Lock() 65 + defer m.mu.Unlock() 66 + key := m.cacheKey(preset, did, cid) 67 + m.data[key] = data 68 + } 69 + 70 + func (m *MockCache) GetCalls() int { 71 + m.mu.Lock() 72 + defer m.mu.Unlock() 73 + return m.getCalls 74 + } 75 + 76 + func (m *MockCache) SetCalls() int { 77 + m.mu.Lock() 78 + defer m.mu.Unlock() 79 + return m.setCalls 80 + } 81 + 82 + func (m *MockCache) GetSetData(preset, did, cid string) ([]byte, bool) { 83 + m.mu.Lock() 84 + defer m.mu.Unlock() 85 + key := m.cacheKey(preset, did, cid) 86 + data, found := m.setData[key] 87 + return data, found 88 + } 89 + 90 + // MockProcessor implements Processor for testing 91 + type MockProcessor struct { 92 + returnData []byte 93 + returnErr error 94 + calls int 95 + mu sync.Mutex 96 + } 97 + 98 + func NewMockProcessor(returnData []byte, returnErr error) *MockProcessor { 99 + return &MockProcessor{ 100 + returnData: returnData, 101 + returnErr: returnErr, 102 + } 103 + } 104 + 105 + func (m *MockProcessor) Process(data []byte, preset Preset) ([]byte, error) { 106 + m.mu.Lock() 107 + m.calls++ 108 + m.mu.Unlock() 109 + if m.returnErr != nil { 110 + return nil, m.returnErr 111 + } 112 + return m.returnData, nil 113 + } 114 + 115 + func (m *MockProcessor) Calls() int { 116 + m.mu.Lock() 117 + defer m.mu.Unlock() 118 + return m.calls 119 + } 120 + 121 + // MockFetcher implements Fetcher for testing 122 + type MockFetcher struct { 123 + returnData []byte 124 + returnErr error 125 + calls int 126 + mu sync.Mutex 127 + } 128 + 129 + func NewMockFetcher(returnData []byte, returnErr error) *MockFetcher { 130 + return &MockFetcher{ 131 + returnData: returnData, 132 + returnErr: returnErr, 133 + } 134 + } 135 + 136 + func (m *MockFetcher) Fetch(ctx context.Context, pdsURL, did, cid string) ([]byte, error) { 137 + m.mu.Lock() 138 + m.calls++ 139 + m.mu.Unlock() 140 + if m.returnErr != nil { 141 + return nil, m.returnErr 142 + } 143 + return m.returnData, nil 144 + } 145 + 146 + func (m *MockFetcher) Calls() int { 147 + m.mu.Lock() 148 + defer m.mu.Unlock() 149 + return m.calls 150 + } 151 + 152 + // mustNewService is a test helper that creates a service or fails the test 153 + func mustNewService(t *testing.T, cache Cache, processor Processor, fetcher Fetcher, config Config) *ImageProxyService { 154 + t.Helper() 155 + service, err := NewService(cache, processor, fetcher, config) 156 + if err != nil { 157 + t.Fatalf("NewService failed: %v", err) 158 + } 159 + return service 160 + } 161 + 162 + func TestImageProxyService_GetImage_CacheHit(t *testing.T) { 163 + cache := NewMockCache() 164 + processor := NewMockProcessor(nil, nil) 165 + fetcher := NewMockFetcher(nil, nil) 166 + config := DefaultConfig() 167 + 168 + // Pre-populate the cache 169 + cachedData := []byte("cached image data") 170 + cache.SetCacheData("avatar", "did:plc:test123", "bafyreicid123", cachedData) 171 + 172 + service := mustNewService(t, cache, processor, fetcher, config) 173 + ctx := context.Background() 174 + 175 + data, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 176 + if err != nil { 177 + t.Fatalf("expected no error, got: %v", err) 178 + } 179 + if string(data) != string(cachedData) { 180 + t.Errorf("expected cached data %q, got %q", cachedData, data) 181 + } 182 + 183 + // Verify fetcher was not called 184 + if fetcher.Calls() != 0 { 185 + t.Errorf("expected fetcher to not be called on cache hit, got %d calls", fetcher.Calls()) 186 + } 187 + 188 + // Verify processor was not called 189 + if processor.Calls() != 0 { 190 + t.Errorf("expected processor to not be called on cache hit, got %d calls", processor.Calls()) 191 + } 192 + } 193 + 194 + func TestImageProxyService_GetImage_CacheMiss(t *testing.T) { 195 + cache := NewMockCache() 196 + rawImageData := []byte("raw image from PDS") 197 + processedData := []byte("processed image") 198 + processor := NewMockProcessor(processedData, nil) 199 + fetcher := NewMockFetcher(rawImageData, nil) 200 + config := DefaultConfig() 201 + 202 + service := mustNewService(t, cache, processor, fetcher, config) 203 + ctx := context.Background() 204 + 205 + data, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 206 + if err != nil { 207 + t.Fatalf("expected no error, got: %v", err) 208 + } 209 + if string(data) != string(processedData) { 210 + t.Errorf("expected processed data %q, got %q", processedData, data) 211 + } 212 + 213 + // Verify fetcher was called 214 + if fetcher.Calls() != 1 { 215 + t.Errorf("expected fetcher to be called once, got %d calls", fetcher.Calls()) 216 + } 217 + 218 + // Verify processor was called 219 + if processor.Calls() != 1 { 220 + t.Errorf("expected processor to be called once, got %d calls", processor.Calls()) 221 + } 222 + 223 + // Wait a bit for async cache write 224 + time.Sleep(50 * time.Millisecond) 225 + 226 + // Verify cache was written 227 + if cache.SetCalls() < 1 { 228 + t.Errorf("expected cache to be written, got %d set calls", cache.SetCalls()) 229 + } 230 + 231 + // Verify the correct data was cached 232 + setData, found := cache.GetSetData("avatar", "did:plc:test123", "bafyreicid123") 233 + if !found { 234 + t.Error("expected data to be set in cache") 235 + } 236 + if string(setData) != string(processedData) { 237 + t.Errorf("expected cached data %q, got %q", processedData, setData) 238 + } 239 + } 240 + 241 + func TestImageProxyService_GetImage_InvalidPreset(t *testing.T) { 242 + cache := NewMockCache() 243 + processor := NewMockProcessor(nil, nil) 244 + fetcher := NewMockFetcher(nil, nil) 245 + config := DefaultConfig() 246 + 247 + service := mustNewService(t, cache, processor, fetcher, config) 248 + ctx := context.Background() 249 + 250 + _, err := service.GetImage(ctx, "invalid_preset", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 251 + if !errors.Is(err, ErrInvalidPreset) { 252 + t.Errorf("expected ErrInvalidPreset, got: %v", err) 253 + } 254 + } 255 + 256 + func TestImageProxyService_GetImage_PDSFetchError(t *testing.T) { 257 + cache := NewMockCache() 258 + processor := NewMockProcessor(nil, nil) 259 + fetcher := NewMockFetcher(nil, ErrPDSNotFound) 260 + config := DefaultConfig() 261 + 262 + service := mustNewService(t, cache, processor, fetcher, config) 263 + ctx := context.Background() 264 + 265 + _, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 266 + if !errors.Is(err, ErrPDSNotFound) { 267 + t.Errorf("expected ErrPDSNotFound, got: %v", err) 268 + } 269 + } 270 + 271 + func TestImageProxyService_GetImage_ProcessingError(t *testing.T) { 272 + cache := NewMockCache() 273 + processor := NewMockProcessor(nil, ErrProcessingFailed) 274 + fetcher := NewMockFetcher([]byte("raw data"), nil) 275 + config := DefaultConfig() 276 + 277 + service := mustNewService(t, cache, processor, fetcher, config) 278 + ctx := context.Background() 279 + 280 + _, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 281 + if !errors.Is(err, ErrProcessingFailed) { 282 + t.Errorf("expected ErrProcessingFailed, got: %v", err) 283 + } 284 + } 285 + 286 + func TestImageProxyService_GetImage_CacheWriteIsAsync(t *testing.T) { 287 + cache := NewMockCache() 288 + rawImageData := []byte("raw image from PDS") 289 + processedData := []byte("processed image") 290 + processor := NewMockProcessor(processedData, nil) 291 + fetcher := NewMockFetcher(rawImageData, nil) 292 + config := DefaultConfig() 293 + 294 + service := mustNewService(t, cache, processor, fetcher, config) 295 + ctx := context.Background() 296 + 297 + // Call GetImage 298 + startTime := time.Now() 299 + data, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 300 + elapsed := time.Since(startTime) 301 + 302 + if err != nil { 303 + t.Fatalf("expected no error, got: %v", err) 304 + } 305 + if string(data) != string(processedData) { 306 + t.Errorf("expected processed data %q, got %q", processedData, data) 307 + } 308 + 309 + // The response should come back quickly, not blocked by cache write 310 + // (This is a soft assertion - just ensures we're not blocking) 311 + if elapsed > 100*time.Millisecond { 312 + t.Logf("warning: GetImage took %v, expected faster response", elapsed) 313 + } 314 + 315 + // Wait for async cache write to complete 316 + time.Sleep(100 * time.Millisecond) 317 + 318 + // Now verify cache was written 319 + if cache.SetCalls() < 1 { 320 + t.Errorf("expected cache to be written asynchronously, got %d set calls", cache.SetCalls()) 321 + } 322 + } 323 + 324 + func TestImageProxyService_GetImage_EmptyPreset(t *testing.T) { 325 + cache := NewMockCache() 326 + processor := NewMockProcessor(nil, nil) 327 + fetcher := NewMockFetcher(nil, nil) 328 + config := DefaultConfig() 329 + 330 + service := mustNewService(t, cache, processor, fetcher, config) 331 + ctx := context.Background() 332 + 333 + _, err := service.GetImage(ctx, "", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 334 + if !errors.Is(err, ErrInvalidPreset) { 335 + t.Errorf("expected ErrInvalidPreset for empty preset, got: %v", err) 336 + } 337 + } 338 + 339 + func TestImageProxyService_GetImage_AllPresets(t *testing.T) { 340 + // Test that all predefined presets work 341 + presets := []string{"avatar", "avatar_small", "banner", "content_preview", "content_full", "embed_thumbnail"} 342 + 343 + for _, presetName := range presets { 344 + t.Run(presetName, func(t *testing.T) { 345 + cache := NewMockCache() 346 + processedData := []byte("processed image") 347 + processor := NewMockProcessor(processedData, nil) 348 + fetcher := NewMockFetcher([]byte("raw data"), nil) 349 + config := DefaultConfig() 350 + 351 + service := mustNewService(t, cache, processor, fetcher, config) 352 + ctx := context.Background() 353 + 354 + data, err := service.GetImage(ctx, presetName, "did:plc:test123", "bafyreicid123", "https://pds.example.com") 355 + if err != nil { 356 + t.Errorf("expected no error for preset %s, got: %v", presetName, err) 357 + } 358 + if string(data) != string(processedData) { 359 + t.Errorf("expected processed data for preset %s", presetName) 360 + } 361 + }) 362 + } 363 + } 364 + 365 + func TestNewService_NilDependencies(t *testing.T) { 366 + config := DefaultConfig() 367 + cache := NewMockCache() 368 + processor := NewMockProcessor(nil, nil) 369 + fetcher := NewMockFetcher(nil, nil) 370 + 371 + t.Run("nil cache", func(t *testing.T) { 372 + _, err := NewService(nil, processor, fetcher, config) 373 + if !errors.Is(err, ErrNilDependency) { 374 + t.Errorf("expected ErrNilDependency, got: %v", err) 375 + } 376 + }) 377 + 378 + t.Run("nil processor", func(t *testing.T) { 379 + _, err := NewService(cache, nil, fetcher, config) 380 + if !errors.Is(err, ErrNilDependency) { 381 + t.Errorf("expected ErrNilDependency, got: %v", err) 382 + } 383 + }) 384 + 385 + t.Run("nil fetcher", func(t *testing.T) { 386 + _, err := NewService(cache, processor, nil, config) 387 + if !errors.Is(err, ErrNilDependency) { 388 + t.Errorf("expected ErrNilDependency, got: %v", err) 389 + } 390 + }) 391 + 392 + t.Run("all valid", func(t *testing.T) { 393 + service, err := NewService(cache, processor, fetcher, config) 394 + if err != nil { 395 + t.Errorf("expected no error with valid dependencies, got: %v", err) 396 + } 397 + if service == nil { 398 + t.Error("expected non-nil service") 399 + } 400 + }) 401 + }
+87
internal/core/imageproxy/validation.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + // ValidateDID validates that a DID string matches expected atproto DID formats. 10 + // It uses the Indigo library's syntax.ParseDID for consistent validation across the codebase. 11 + // Returns ErrInvalidDID if the DID is invalid. 12 + func ValidateDID(did string) error { 13 + // Check for path traversal attempts before parsing 14 + if strings.Contains(did, "..") || strings.Contains(did, "/") || strings.Contains(did, "\\") || strings.Contains(did, "\x00") { 15 + return ErrInvalidDID 16 + } 17 + 18 + // Use Indigo's DID parser for consistent validation with the rest of the codebase 19 + _, err := syntax.ParseDID(did) 20 + if err != nil { 21 + return ErrInvalidDID 22 + } 23 + 24 + return nil 25 + } 26 + 27 + // ValidateCID validates that a CID string is a valid content identifier. 28 + // It uses the Indigo library's syntax.ParseCID for consistent validation across the codebase. 29 + // Returns ErrInvalidCID if the CID is invalid. 30 + func ValidateCID(cid string) error { 31 + // Check for path traversal attempts before parsing 32 + if strings.Contains(cid, "..") || strings.Contains(cid, "/") || strings.Contains(cid, "\\") || strings.Contains(cid, "\x00") { 33 + return ErrInvalidCID 34 + } 35 + 36 + // Use Indigo's CID parser for consistent validation with the rest of the codebase 37 + _, err := syntax.ParseCID(cid) 38 + if err != nil { 39 + return ErrInvalidCID 40 + } 41 + 42 + return nil 43 + } 44 + 45 + // SanitizePathComponent ensures a string is safe to use as a filesystem path component. 46 + // It removes or replaces characters that could be used for path traversal attacks. 47 + // This is used as an additional safety layer beyond DID/CID validation. 48 + func SanitizePathComponent(s string) string { 49 + // Replace any path separators 50 + s = strings.ReplaceAll(s, "/", "_") 51 + s = strings.ReplaceAll(s, "\\", "_") 52 + 53 + // Remove any path traversal sequences 54 + s = strings.ReplaceAll(s, "..", "") 55 + 56 + // Replace colons for filesystem compatibility (Windows and general safety) 57 + s = strings.ReplaceAll(s, ":", "_") 58 + 59 + // Remove null bytes 60 + s = strings.ReplaceAll(s, "\x00", "") 61 + 62 + return s 63 + } 64 + 65 + // ValidatePreset validates that a preset name is safe and exists. 66 + // This combines format validation with registry lookup. 67 + func ValidatePreset(preset string) error { 68 + // Check for empty preset 69 + if preset == "" { 70 + return ErrInvalidPreset 71 + } 72 + 73 + // Check for path separators (dangerous characters) 74 + // Note: We use ContainsAny for individual chars and Contains for substrings 75 + if strings.ContainsAny(preset, "/\\") { 76 + return ErrInvalidPreset 77 + } 78 + 79 + // Check for path traversal sequences (must check ".." as a substring, not individual dots) 80 + if strings.Contains(preset, "..") { 81 + return ErrInvalidPreset 82 + } 83 + 84 + // Verify preset exists in registry 85 + _, err := GetPreset(preset) 86 + return err 87 + }
+342
internal/core/imageproxy/validation_test.go
··· 1 + package imageproxy 2 + 3 + import ( 4 + "errors" 5 + "testing" 6 + ) 7 + 8 + func TestValidateDID(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + did string 12 + wantErr error 13 + }{ 14 + // Valid DIDs - uses Indigo's syntax.ParseDID for consistency with codebase 15 + { 16 + name: "valid did:plc", 17 + did: "did:plc:z72i7hdynmk6r22z27h6tvur", 18 + wantErr: nil, 19 + }, 20 + { 21 + name: "valid did:web simple", 22 + did: "did:web:example.com", 23 + wantErr: nil, 24 + }, 25 + { 26 + name: "valid did:web with subdomain", 27 + did: "did:web:bsky.social", 28 + wantErr: nil, 29 + }, 30 + { 31 + name: "valid did:web with path", 32 + did: "did:web:example.com:user:alice", 33 + wantErr: nil, 34 + }, 35 + // did:key is valid per Indigo library (used in other atproto contexts) 36 + { 37 + name: "valid did:key", 38 + did: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 39 + wantErr: nil, 40 + }, 41 + // Invalid DIDs 42 + { 43 + name: "empty string", 44 + did: "", 45 + wantErr: ErrInvalidDID, 46 + }, 47 + { 48 + name: "missing did: prefix", 49 + did: "plc:z72i7hdynmk6r22z27h6tvur", 50 + wantErr: ErrInvalidDID, 51 + }, 52 + { 53 + name: "path traversal attempt in did", 54 + did: "did:plc:../../../etc/passwd", 55 + wantErr: ErrInvalidDID, 56 + }, 57 + { 58 + name: "null byte injection", 59 + did: "did:plc:abc\x00def", 60 + wantErr: ErrInvalidDID, 61 + }, 62 + { 63 + name: "forward slash injection", 64 + did: "did:plc:abc/def", 65 + wantErr: ErrInvalidDID, 66 + }, 67 + { 68 + name: "backslash injection", 69 + did: "did:plc:abc\\def", 70 + wantErr: ErrInvalidDID, 71 + }, 72 + { 73 + name: "just did prefix", 74 + did: "did:", 75 + wantErr: ErrInvalidDID, 76 + }, 77 + { 78 + name: "random gibberish", 79 + did: "not-a-did-at-all", 80 + wantErr: ErrInvalidDID, 81 + }, 82 + } 83 + 84 + for _, tt := range tests { 85 + t.Run(tt.name, func(t *testing.T) { 86 + err := ValidateDID(tt.did) 87 + if !errors.Is(err, tt.wantErr) { 88 + t.Errorf("ValidateDID(%q) = %v, want %v", tt.did, err, tt.wantErr) 89 + } 90 + }) 91 + } 92 + } 93 + 94 + func TestValidateCID(t *testing.T) { 95 + tests := []struct { 96 + name string 97 + cid string 98 + wantErr error 99 + }{ 100 + // Valid CIDs 101 + { 102 + name: "valid CIDv1 base32 bafy", 103 + cid: "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi", 104 + wantErr: nil, 105 + }, 106 + { 107 + name: "valid CIDv1 base32 bafk", 108 + cid: "bafkreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi", 109 + wantErr: nil, 110 + }, 111 + { 112 + name: "valid CIDv0", 113 + cid: "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", 114 + wantErr: nil, 115 + }, 116 + // Invalid CIDs 117 + { 118 + name: "empty string", 119 + cid: "", 120 + wantErr: ErrInvalidCID, 121 + }, 122 + { 123 + name: "too short", 124 + cid: "bafyabc", 125 + wantErr: ErrInvalidCID, 126 + }, 127 + { 128 + name: "path traversal attempt", 129 + cid: "../../../etc/passwd", 130 + wantErr: ErrInvalidCID, 131 + }, 132 + { 133 + name: "contains slash", 134 + cid: "bafyrei/abc/def", 135 + wantErr: ErrInvalidCID, 136 + }, 137 + { 138 + name: "contains backslash", 139 + cid: "bafyrei\\abc", 140 + wantErr: ErrInvalidCID, 141 + }, 142 + { 143 + name: "contains double dot", 144 + cid: "bafyrei..abc", 145 + wantErr: ErrInvalidCID, 146 + }, 147 + { 148 + name: "invalid base32 chars", 149 + cid: "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi!@#", 150 + wantErr: ErrInvalidCID, 151 + }, 152 + { 153 + name: "random string not matching any CID pattern", 154 + cid: "this_is_not_a_valid_cid_at_all_12345", 155 + wantErr: ErrInvalidCID, 156 + }, 157 + { 158 + name: "too long", 159 + cid: "bafyrei" + string(make([]byte, 200)), 160 + wantErr: ErrInvalidCID, 161 + }, 162 + } 163 + 164 + for _, tt := range tests { 165 + t.Run(tt.name, func(t *testing.T) { 166 + err := ValidateCID(tt.cid) 167 + if !errors.Is(err, tt.wantErr) { 168 + t.Errorf("ValidateCID(%q) = %v, want %v", tt.cid, err, tt.wantErr) 169 + } 170 + }) 171 + } 172 + } 173 + 174 + func TestSanitizePathComponent(t *testing.T) { 175 + tests := []struct { 176 + name string 177 + input string 178 + want string 179 + }{ 180 + { 181 + name: "clean string unchanged", 182 + input: "abc123", 183 + want: "abc123", 184 + }, 185 + { 186 + name: "forward slashes removed", 187 + input: "path/to/file", 188 + want: "path_to_file", 189 + }, 190 + { 191 + name: "backslashes removed", 192 + input: "path\\to\\file", 193 + want: "path_to_file", 194 + }, 195 + { 196 + name: "path traversal removed", 197 + input: "../../../etc/passwd", 198 + want: "___etc_passwd", 199 + }, 200 + { 201 + name: "colons replaced", 202 + input: "did:plc:abc123", 203 + want: "did_plc_abc123", 204 + }, 205 + { 206 + name: "null bytes removed", 207 + input: "abc\x00def", 208 + want: "abcdef", 209 + }, 210 + { 211 + name: "multiple dangerous chars", 212 + input: "../path:to\\file\x00.txt", 213 + want: "_path_to_file.txt", 214 + }, 215 + } 216 + 217 + for _, tt := range tests { 218 + t.Run(tt.name, func(t *testing.T) { 219 + got := SanitizePathComponent(tt.input) 220 + if got != tt.want { 221 + t.Errorf("SanitizePathComponent(%q) = %q, want %q", tt.input, got, tt.want) 222 + } 223 + }) 224 + } 225 + } 226 + 227 + func TestMakeDIDSafe_PathTraversal(t *testing.T) { 228 + tests := []struct { 229 + name string 230 + did string 231 + check func(result string) bool 232 + }{ 233 + { 234 + name: "normal did:plc is safe", 235 + did: "did:plc:abc123", 236 + check: func(r string) bool { 237 + return r == "did_plc_abc123" 238 + }, 239 + }, 240 + { 241 + name: "path traversal sequences removed", 242 + did: "did:plc:../../../etc/passwd", 243 + check: func(r string) bool { 244 + // Should not contain .. or / 245 + return !contains(r, "..") && !contains(r, "/") && !contains(r, "\\") 246 + }, 247 + }, 248 + { 249 + name: "forward slashes removed", 250 + did: "did:plc:abc/def", 251 + check: func(r string) bool { 252 + return !contains(r, "/") 253 + }, 254 + }, 255 + { 256 + name: "backslashes removed", 257 + did: "did:plc:abc\\def", 258 + check: func(r string) bool { 259 + return !contains(r, "\\") 260 + }, 261 + }, 262 + { 263 + name: "null bytes removed", 264 + did: "did:plc:abc\x00def", 265 + check: func(r string) bool { 266 + return !contains(r, "\x00") 267 + }, 268 + }, 269 + } 270 + 271 + for _, tt := range tests { 272 + t.Run(tt.name, func(t *testing.T) { 273 + result := makeDIDSafe(tt.did) 274 + if !tt.check(result) { 275 + t.Errorf("makeDIDSafe(%q) = %q, failed safety check", tt.did, result) 276 + } 277 + }) 278 + } 279 + } 280 + 281 + func TestMakeCIDSafe_PathTraversal(t *testing.T) { 282 + tests := []struct { 283 + name string 284 + cid string 285 + check func(result string) bool 286 + }{ 287 + { 288 + name: "normal CID unchanged", 289 + cid: "bafyreiabc123", 290 + check: func(r string) bool { 291 + return r == "bafyreiabc123" 292 + }, 293 + }, 294 + { 295 + name: "path traversal removed", 296 + cid: "../../../etc/passwd", 297 + check: func(r string) bool { 298 + return !contains(r, "..") && !contains(r, "/") 299 + }, 300 + }, 301 + { 302 + name: "forward slashes removed", 303 + cid: "abc/def/ghi", 304 + check: func(r string) bool { 305 + return !contains(r, "/") 306 + }, 307 + }, 308 + { 309 + name: "backslashes removed", 310 + cid: "abc\\def\\ghi", 311 + check: func(r string) bool { 312 + return !contains(r, "\\") 313 + }, 314 + }, 315 + { 316 + name: "null bytes removed", 317 + cid: "abc\x00def", 318 + check: func(r string) bool { 319 + return !contains(r, "\x00") 320 + }, 321 + }, 322 + } 323 + 324 + for _, tt := range tests { 325 + t.Run(tt.name, func(t *testing.T) { 326 + result := makeCIDSafe(tt.cid) 327 + if !tt.check(result) { 328 + t.Errorf("makeCIDSafe(%q) = %q, failed safety check", tt.cid, result) 329 + } 330 + }) 331 + } 332 + } 333 + 334 + // helper function for checking string containment 335 + func contains(s, substr string) bool { 336 + for i := 0; i <= len(s)-len(substr); i++ { 337 + if s[i:i+len(substr)] == substr { 338 + return true 339 + } 340 + } 341 + return false 342 + }
+30 -15
internal/db/postgres/feed_repo_base.go
··· 8 8 "encoding/hex" 9 9 "encoding/json" 10 10 "fmt" 11 + "log/slog" 11 12 "strings" 12 13 "time" 13 14 14 15 "Coves/internal/core/blobs" 16 + "Coves/internal/core/communities" 15 17 "Coves/internal/core/posts" 16 18 ) 17 19 ··· 347 349 if communityHandle.Valid { 348 350 communityRef.Handle = communityHandle.String 349 351 } 350 - // Hydrate avatar CID to URL (instead of returning raw CID) 351 - if avatarURL := blobs.HydrateBlobURL(communityPDSURL.String, communityRef.DID, communityAvatar.String); avatarURL != "" { 352 + // Hydrate avatar CID to URL using image proxy config (avatar_small preset for feed lists) 353 + if avatarURL := blobs.HydrateImageURL(communities.GetImageProxyConfig(), communityPDSURL.String, communityRef.DID, communityAvatar.String, "avatar_small"); avatarURL != "" { 352 354 communityRef.Avatar = &avatarURL 353 355 } 354 356 if communityPDSURL.Valid { ··· 361 363 postView.Text = nullStringPtr(content) 362 364 363 365 // Parse facets JSON 366 + // Log errors but continue - a single malformed post shouldn't break the entire feed 364 367 if facets.Valid { 365 368 var facetArray []interface{} 366 - if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil { 369 + if err := json.Unmarshal([]byte(facets.String), &facetArray); err != nil { 370 + slog.Warn("[FEED] failed to parse facets JSON", 371 + "post_uri", postView.URI, 372 + "error", err, 373 + ) 374 + } else { 367 375 postView.TextFacets = facetArray 368 376 } 369 377 } 370 378 371 379 // Parse embed JSON 380 + // Log errors but continue - a single malformed post shouldn't break the entire feed 372 381 if embed.Valid { 373 382 var embedData interface{} 374 - if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil { 383 + if err := json.Unmarshal([]byte(embed.String), &embedData); err != nil { 384 + slog.Warn("[FEED] failed to parse embed JSON", 385 + "post_uri", postView.URI, 386 + "error", err, 387 + ) 388 + } else { 375 389 postView.Embed = embedData 376 390 } 377 391 } ··· 399 413 if content.Valid { 400 414 record["content"] = content.String 401 415 } 402 - if facets.Valid { 403 - var facetArray []interface{} 404 - if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil { 405 - record["facets"] = facetArray 406 - } 416 + // Reuse already-parsed facets and embed from PostView (parsed above with logging) 417 + // This avoids double parsing and ensures consistent error handling 418 + if postView.TextFacets != nil { 419 + record["facets"] = postView.TextFacets 407 420 } 408 - if embed.Valid { 409 - var embedData interface{} 410 - if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil { 411 - record["embed"] = embedData 412 - } 421 + if postView.Embed != nil { 422 + record["embed"] = postView.Embed 413 423 } 414 424 if labelsJSON.Valid { 415 425 // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 416 426 // Deserialize and include in record 417 427 var selfLabels posts.SelfLabels 418 - if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil { 428 + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err != nil { 429 + slog.Warn("[FEED] failed to parse labels JSON", 430 + "post_uri", postView.URI, 431 + "error", err, 432 + ) 433 + } else { 419 434 record["labels"] = selfLabels 420 435 } 421 436 }
+943
tests/integration/image_proxy_e2e_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "image" 9 + "image/color" 10 + "image/png" 11 + "net/http" 12 + "net/http/httptest" 13 + "os" 14 + "strings" 15 + "testing" 16 + "time" 17 + 18 + "github.com/disintegration/imaging" 19 + "github.com/go-chi/chi/v5" 20 + "github.com/stretchr/testify/assert" 21 + "github.com/stretchr/testify/require" 22 + 23 + "Coves/internal/api/handlers/imageproxy" 24 + "Coves/internal/api/routes" 25 + "Coves/internal/atproto/identity" 26 + "Coves/internal/core/blobs" 27 + "Coves/internal/core/communities" 28 + imageproxycore "Coves/internal/core/imageproxy" 29 + "Coves/internal/db/postgres" 30 + ) 31 + 32 + // TestImageProxy_E2E tests the complete image proxy flow including: 33 + // - Creating a community with an avatar 34 + // - Fetching the avatar via the image proxy 35 + // - Verifying response headers, status codes, and image dimensions 36 + // - Testing ETag-based caching (304 responses) 37 + // - Error handling for invalid presets and missing blobs 38 + func TestImageProxy_E2E(t *testing.T) { 39 + if testing.Short() { 40 + t.Skip("Skipping E2E integration test in short mode") 41 + } 42 + 43 + // Check if PDS is running 44 + pdsURL := getTestPDSURL() 45 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 46 + if err != nil { 47 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 48 + } 49 + _ = healthResp.Body.Close() 50 + 51 + // Setup test database 52 + db := setupTestDB(t) 53 + defer func() { _ = db.Close() }() 54 + 55 + ctx := context.Background() 56 + 57 + // Setup repositories and services 58 + communityRepo := postgres.NewCommunityRepository(db) 59 + 60 + // Setup identity resolver with local PLC 61 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 62 + if plcURL == "" { 63 + plcURL = "http://localhost:3002" 64 + } 65 + identityConfig := identity.DefaultConfig() 66 + identityConfig.PLCURL = plcURL 67 + identityResolver := identity.NewResolver(db, identityConfig) 68 + 69 + // Create a real community WITH an avatar using the community service 70 + // This ensures the blob is referenced by the community profile record 71 + // (blobs must be referenced to be stored by PDS) 72 + instanceDID := "did:web:coves.social" 73 + provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 74 + blobService := blobs.NewBlobService(pdsURL) 75 + 76 + communityService := communities.NewCommunityServiceWithPDSFactory( 77 + communityRepo, 78 + pdsURL, 79 + instanceDID, 80 + "coves.social", 81 + provisioner, 82 + nil, // No custom PDS factory 83 + blobService, 84 + ) 85 + 86 + // Create avatar image data 87 + avatarData := createTestImageForProxy(t, 200, 200, color.RGBA{R: 100, G: 150, B: 200, A: 255}) 88 + 89 + uniqueID := time.Now().UnixNano() % 100000000 // Keep shorter for handle limit 90 + communityName := fmt.Sprintf("ip%d", uniqueID) 91 + creatorDID := fmt.Sprintf("did:plc:c%d", uniqueID) 92 + 93 + t.Logf("Creating community with avatar: %s", communityName) 94 + community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 95 + Name: communityName, 96 + DisplayName: "Image Proxy Test Community", 97 + Description: "Testing image proxy with avatar", 98 + Visibility: "public", 99 + CreatedByDID: creatorDID, 100 + HostedByDID: instanceDID, 101 + AllowExternalDiscovery: true, 102 + AvatarBlob: avatarData, 103 + AvatarMimeType: "image/png", 104 + }) 105 + require.NoError(t, err, "Failed to create community with avatar") 106 + 107 + // Get the avatar CID from the created community 108 + avatarCID := community.AvatarCID 109 + require.NotEmpty(t, avatarCID, "Avatar CID should not be empty") 110 + t.Logf("Created community: DID=%s, AvatarCID=%s", community.DID, avatarCID) 111 + 112 + // Verify blob exists on PDS before starting proxy tests 113 + directBlobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pdsURL, community.DID, avatarCID) 114 + t.Logf("Verifying blob exists at: %s", directBlobURL) 115 + verifyResp, verifyErr := http.Get(directBlobURL) 116 + if verifyErr != nil { 117 + t.Logf("Warning: Failed to verify blob: %v", verifyErr) 118 + } else { 119 + t.Logf("Direct blob fetch status: %d", verifyResp.StatusCode) 120 + if verifyResp.StatusCode != http.StatusOK { 121 + var errBuf bytes.Buffer 122 + _, _ = errBuf.ReadFrom(verifyResp.Body) 123 + t.Logf("Direct blob fetch error: %s", errBuf.String()) 124 + } 125 + _ = verifyResp.Body.Close() 126 + } 127 + 128 + // Create the test server with image proxy routes 129 + testServer := createImageProxyTestServer(t, pdsURL, identityResolver) 130 + defer testServer.Close() 131 + 132 + t.Run("fetch avatar via proxy returns valid JPEG", func(t *testing.T) { 133 + // Build request URL 134 + proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 135 + t.Logf("Requesting: %s", proxyURL) 136 + 137 + resp, err := http.Get(proxyURL) 138 + require.NoError(t, err, "Request should succeed") 139 + defer func() { _ = resp.Body.Close() }() 140 + 141 + // Log error details if not 200 142 + if resp.StatusCode != http.StatusOK { 143 + var errBuf bytes.Buffer 144 + _, _ = errBuf.ReadFrom(resp.Body) 145 + t.Logf("Error response (status %d): %s", resp.StatusCode, errBuf.String()) 146 + } 147 + 148 + // Verify status code 149 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 OK") 150 + 151 + // Verify Content-Type header 152 + contentType := resp.Header.Get("Content-Type") 153 + assert.Equal(t, "image/jpeg", contentType, "Content-Type should be image/jpeg") 154 + 155 + // Read the response body 156 + var buf bytes.Buffer 157 + _, err = buf.ReadFrom(resp.Body) 158 + require.NoError(t, err, "Should read response body") 159 + 160 + // Verify it's valid image data 161 + img, err := imaging.Decode(&buf) 162 + require.NoError(t, err, "Response should be valid image data") 163 + 164 + // Verify dimensions match avatar_small preset (360x360) 165 + bounds := img.Bounds() 166 + assert.Equal(t, 360, bounds.Dx(), "Width should be 360 (avatar_small preset)") 167 + assert.Equal(t, 360, bounds.Dy(), "Height should be 360 (avatar_small preset)") 168 + 169 + t.Logf("Successfully fetched and verified avatar: %dx%d", bounds.Dx(), bounds.Dy()) 170 + }) 171 + 172 + t.Run("returns correct cache headers", func(t *testing.T) { 173 + proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 174 + 175 + resp, err := http.Get(proxyURL) 176 + require.NoError(t, err, "Request should succeed") 177 + defer func() { _ = resp.Body.Close() }() 178 + 179 + // Verify Cache-Control header 180 + cacheControl := resp.Header.Get("Cache-Control") 181 + expectedCacheControl := "public, max-age=31536000, immutable" 182 + assert.Equal(t, expectedCacheControl, cacheControl, "Cache-Control header should be correct") 183 + 184 + // Verify ETag header is present and matches expected format 185 + etag := resp.Header.Get("ETag") 186 + expectedETag := fmt.Sprintf(`"avatar_small-%s"`, avatarCID) 187 + assert.Equal(t, expectedETag, etag, "ETag should match preset-cid format") 188 + 189 + t.Logf("Cache headers verified: Cache-Control=%s, ETag=%s", cacheControl, etag) 190 + }) 191 + 192 + t.Run("ETag returns 304 on match", func(t *testing.T) { 193 + proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 194 + 195 + // First, get the ETag 196 + resp, err := http.Get(proxyURL) 197 + require.NoError(t, err, "Initial request should succeed") 198 + etag := resp.Header.Get("ETag") 199 + _ = resp.Body.Close() 200 + require.NotEmpty(t, etag, "ETag should be present") 201 + 202 + // Now make a conditional request with If-None-Match 203 + req, err := http.NewRequest(http.MethodGet, proxyURL, nil) 204 + require.NoError(t, err, "Should create request") 205 + req.Header.Set("If-None-Match", etag) 206 + 207 + resp, err = http.DefaultClient.Do(req) 208 + require.NoError(t, err, "Conditional request should succeed") 209 + defer func() { _ = resp.Body.Close() }() 210 + 211 + // Verify 304 Not Modified 212 + assert.Equal(t, http.StatusNotModified, resp.StatusCode, "Should return 304 Not Modified") 213 + 214 + // Verify no body in 304 response 215 + var buf bytes.Buffer 216 + _, _ = buf.ReadFrom(resp.Body) 217 + assert.Equal(t, 0, buf.Len(), "304 response should have empty body") 218 + 219 + t.Log("ETag conditional request correctly returned 304 Not Modified") 220 + }) 221 + 222 + t.Run("ETag mismatch returns full image", func(t *testing.T) { 223 + proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 224 + 225 + // Make request with non-matching ETag 226 + req, err := http.NewRequest(http.MethodGet, proxyURL, nil) 227 + require.NoError(t, err, "Should create request") 228 + req.Header.Set("If-None-Match", `"wrong-etag-value"`) 229 + 230 + resp, err := http.DefaultClient.Do(req) 231 + require.NoError(t, err, "Request should succeed") 232 + defer func() { _ = resp.Body.Close() }() 233 + 234 + // Should return 200 with full image 235 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 OK when ETag doesn't match") 236 + 237 + // Verify body is present 238 + var buf bytes.Buffer 239 + _, _ = buf.ReadFrom(resp.Body) 240 + assert.Greater(t, buf.Len(), 0, "Response should have body") 241 + 242 + t.Log("Non-matching ETag correctly returned full image") 243 + }) 244 + 245 + t.Run("invalid preset returns 400", func(t *testing.T) { 246 + proxyURL := fmt.Sprintf("%s/img/not_a_valid_preset/plain/%s/%s", testServer.URL, community.DID, avatarCID) 247 + 248 + resp, err := http.Get(proxyURL) 249 + require.NoError(t, err, "Request should succeed") 250 + defer func() { _ = resp.Body.Close() }() 251 + 252 + // Verify 400 Bad Request 253 + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "Should return 400 for invalid preset") 254 + 255 + // Verify error message 256 + var buf bytes.Buffer 257 + _, _ = buf.ReadFrom(resp.Body) 258 + body := buf.String() 259 + assert.Contains(t, body, "invalid preset", "Error message should mention invalid preset") 260 + 261 + t.Logf("Invalid preset correctly returned 400: %s", body) 262 + }) 263 + 264 + t.Run("non-existent CID returns 404", func(t *testing.T) { 265 + // Use a valid CIDv1 (raw codec, sha256) that doesn't exist on the PDS 266 + // This is a properly formatted CID that will pass validation but won't exist 267 + fakeCID := "bafkreiemeosfdll427qzow5tipvctigjebyvi6ketznqrau2ydhzyggt7i" 268 + proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, fakeCID) 269 + 270 + resp, err := http.Get(proxyURL) 271 + require.NoError(t, err, "Request should succeed") 272 + defer func() { _ = resp.Body.Close() }() 273 + 274 + // Verify 404 Not Found (blob not found on PDS) 275 + assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Should return 404 for non-existent CID") 276 + 277 + t.Log("Non-existent CID correctly returned 404") 278 + }) 279 + 280 + t.Run("all valid presets work correctly", func(t *testing.T) { 281 + // Test a subset of presets with fixed dimensions (cover fit) 282 + presetTests := []struct { 283 + preset string 284 + expectWidth int 285 + expectHeight int 286 + }{ 287 + {"avatar", 1000, 1000}, 288 + {"avatar_small", 360, 360}, 289 + // banner has 640x300 but input is 200x200, so it will be scaled+cropped 290 + {"banner", 640, 300}, 291 + // embed_thumbnail is 720x360, will also be scaled+cropped 292 + {"embed_thumbnail", 720, 360}, 293 + } 294 + 295 + for _, tc := range presetTests { 296 + t.Run(tc.preset, func(t *testing.T) { 297 + proxyURL := fmt.Sprintf("%s/img/%s/plain/%s/%s", testServer.URL, tc.preset, community.DID, avatarCID) 298 + 299 + resp, err := http.Get(proxyURL) 300 + require.NoError(t, err, "Request should succeed for preset %s", tc.preset) 301 + defer func() { _ = resp.Body.Close() }() 302 + 303 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 for valid preset %s", tc.preset) 304 + 305 + // Verify ETag format 306 + etag := resp.Header.Get("ETag") 307 + expectedETag := fmt.Sprintf(`"%s-%s"`, tc.preset, avatarCID) 308 + assert.Equal(t, expectedETag, etag, "ETag should match for preset %s", tc.preset) 309 + 310 + // Verify image dimensions 311 + var buf bytes.Buffer 312 + _, _ = buf.ReadFrom(resp.Body) 313 + img, err := imaging.Decode(&buf) 314 + require.NoError(t, err, "Should decode image for preset %s", tc.preset) 315 + 316 + bounds := img.Bounds() 317 + assert.Equal(t, tc.expectWidth, bounds.Dx(), "Width should match for preset %s", tc.preset) 318 + assert.Equal(t, tc.expectHeight, bounds.Dy(), "Height should match for preset %s", tc.preset) 319 + 320 + t.Logf("Preset %s: verified %dx%d", tc.preset, bounds.Dx(), bounds.Dy()) 321 + }) 322 + } 323 + }) 324 + 325 + t.Run("missing parameters return 400", func(t *testing.T) { 326 + testCases := []struct { 327 + name string 328 + url string 329 + }{ 330 + {"missing CID", fmt.Sprintf("%s/img/avatar/plain/%s/", testServer.URL, community.DID)}, 331 + {"missing DID", fmt.Sprintf("%s/img/avatar/plain//%s", testServer.URL, avatarCID)}, 332 + } 333 + 334 + for _, tc := range testCases { 335 + t.Run(tc.name, func(t *testing.T) { 336 + resp, err := http.Get(tc.url) 337 + require.NoError(t, err, "Request should succeed") 338 + defer func() { _ = resp.Body.Close() }() 339 + 340 + // Should return 400 or 404 (depends on routing) 341 + assert.True(t, resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusNotFound, 342 + "Should return 400 or 404 for %s, got %d", tc.name, resp.StatusCode) 343 + }) 344 + } 345 + }) 346 + 347 + t.Run("content_preview preset preserves aspect ratio", func(t *testing.T) { 348 + // content_preview uses FitContain which preserves aspect ratio 349 + // Input is 200x200, max width is 800, so output should be 200x200 (no upscaling) 350 + proxyURL := fmt.Sprintf("%s/img/content_preview/plain/%s/%s", testServer.URL, community.DID, avatarCID) 351 + 352 + resp, err := http.Get(proxyURL) 353 + require.NoError(t, err, "Request should succeed") 354 + defer func() { _ = resp.Body.Close() }() 355 + 356 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 357 + 358 + var buf bytes.Buffer 359 + _, _ = buf.ReadFrom(resp.Body) 360 + img, err := imaging.Decode(&buf) 361 + require.NoError(t, err, "Should decode image") 362 + 363 + bounds := img.Bounds() 364 + // content_preview with FitContain doesn't upscale, so 200x200 stays 200x200 365 + assert.Equal(t, 200, bounds.Dx(), "Width should be preserved (no upscaling)") 366 + assert.Equal(t, 200, bounds.Dy(), "Height should be preserved (no upscaling)") 367 + 368 + t.Logf("content_preview preserved aspect ratio: %dx%d", bounds.Dx(), bounds.Dy()) 369 + }) 370 + } 371 + 372 + // TestImageProxy_CacheHit tests that cache hits are faster than cache misses 373 + func TestImageProxy_CacheHit(t *testing.T) { 374 + if testing.Short() { 375 + t.Skip("Skipping cache test in short mode") 376 + } 377 + 378 + // Check if PDS is running 379 + pdsURL := getTestPDSURL() 380 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 381 + if err != nil { 382 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 383 + } 384 + _ = healthResp.Body.Close() 385 + 386 + // Setup test database 387 + db := setupTestDB(t) 388 + defer func() { _ = db.Close() }() 389 + 390 + ctx := context.Background() 391 + 392 + // Setup repositories and services 393 + communityRepo := postgres.NewCommunityRepository(db) 394 + 395 + // Setup identity resolver 396 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 397 + if plcURL == "" { 398 + plcURL = "http://localhost:3002" 399 + } 400 + identityConfig := identity.DefaultConfig() 401 + identityConfig.PLCURL = plcURL 402 + identityResolver := identity.NewResolver(db, identityConfig) 403 + 404 + // Create a real community with avatar using the community service 405 + instanceDID := "did:web:coves.social" 406 + provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 407 + blobService := blobs.NewBlobService(pdsURL) 408 + 409 + communityService := communities.NewCommunityServiceWithPDSFactory( 410 + communityRepo, 411 + pdsURL, 412 + instanceDID, 413 + "coves.social", 414 + provisioner, 415 + nil, 416 + blobService, 417 + ) 418 + 419 + avatarData := createTestImageForProxy(t, 150, 150, color.RGBA{R: 50, G: 100, B: 150, A: 255}) 420 + uniqueID := time.Now().UnixNano() % 100000000 // Keep shorter for handle limit 421 + communityName := fmt.Sprintf("ic%d", uniqueID) 422 + creatorDID := fmt.Sprintf("did:plc:cc%d", uniqueID) 423 + 424 + community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 425 + Name: communityName, 426 + DisplayName: "Image Cache Test Community", 427 + Description: "Testing image proxy caching", 428 + Visibility: "public", 429 + CreatedByDID: creatorDID, 430 + HostedByDID: instanceDID, 431 + AllowExternalDiscovery: true, 432 + AvatarBlob: avatarData, 433 + AvatarMimeType: "image/png", 434 + }) 435 + require.NoError(t, err, "Failed to create community with avatar") 436 + 437 + avatarCID := community.AvatarCID 438 + require.NotEmpty(t, avatarCID, "Avatar CID should not be empty") 439 + t.Logf("Created community: DID=%s, AvatarCID=%s", community.DID, avatarCID) 440 + 441 + // Create temp directory for cache 442 + cacheDir := t.TempDir() 443 + 444 + // Create test server with caching enabled 445 + testServer := createImageProxyTestServerWithCache(t, pdsURL, identityResolver, cacheDir) 446 + defer testServer.Close() 447 + 448 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, community.DID, avatarCID) 449 + 450 + // First request (cache miss) 451 + startFirst := time.Now() 452 + resp, err := http.Get(proxyURL) 453 + require.NoError(t, err, "First request should succeed") 454 + _ = resp.Body.Close() 455 + firstDuration := time.Since(startFirst) 456 + 457 + // Second request (should hit cache) 458 + startSecond := time.Now() 459 + resp, err = http.Get(proxyURL) 460 + require.NoError(t, err, "Second request should succeed") 461 + _ = resp.Body.Close() 462 + secondDuration := time.Since(startSecond) 463 + 464 + t.Logf("First request (cache miss): %v", firstDuration) 465 + t.Logf("Second request (should hit cache): %v", secondDuration) 466 + 467 + // Note: Cache hit should generally be faster, but timing can be flaky in tests 468 + // So we just verify both requests succeed 469 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Cached request should return 200") 470 + } 471 + 472 + // createTestImageForProxy creates a test PNG image with specified dimensions and color 473 + func createTestImageForProxy(t *testing.T, width, height int, fillColor color.Color) []byte { 474 + t.Helper() 475 + 476 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 477 + for y := 0; y < height; y++ { 478 + for x := 0; x < width; x++ { 479 + img.Set(x, y, fillColor) 480 + } 481 + } 482 + 483 + var buf bytes.Buffer 484 + err := png.Encode(&buf, img) 485 + require.NoError(t, err, "PNG encoding should succeed") 486 + 487 + return buf.Bytes() 488 + } 489 + 490 + // createImageProxyTestServer creates an httptest server with image proxy routes configured 491 + func createImageProxyTestServer(t *testing.T, pdsURL string, identityResolver identity.Resolver) *httptest.Server { 492 + t.Helper() 493 + 494 + // Create temp directory for cache 495 + cacheDir := t.TempDir() 496 + return createImageProxyTestServerWithCache(t, pdsURL, identityResolver, cacheDir) 497 + } 498 + 499 + // createImageProxyTestServerWithCache creates an httptest server with image proxy routes and specified cache directory 500 + func createImageProxyTestServerWithCache(t *testing.T, pdsURL string, identityResolver identity.Resolver, cacheDir string) *httptest.Server { 501 + t.Helper() 502 + 503 + // Create imageproxy service components 504 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 505 + require.NoError(t, err, "Failed to create disk cache") // 1GB max 506 + processor := imageproxycore.NewProcessor() 507 + fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 508 + config := imageproxycore.Config{ 509 + Enabled: true, 510 + CachePath: cacheDir, 511 + CacheMaxGB: 1, 512 + FetchTimeout: 30 * time.Second, 513 + MaxSourceSizeMB: 10, 514 + } 515 + 516 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 517 + require.NoError(t, err, "Failed to create imageproxy service") 518 + 519 + // Create handler 520 + handler := imageproxy.NewHandler(service, identityResolver) 521 + 522 + // Create router and register routes 523 + r := chi.NewRouter() 524 + routes.RegisterImageProxyRoutes(r, handler) 525 + 526 + return httptest.NewServer(r) 527 + } 528 + 529 + // TestImageProxy_MockPDS tests the image proxy with a mock PDS server 530 + // This allows testing image proxy behavior without a real PDS 531 + func TestImageProxy_MockPDS(t *testing.T) { 532 + // Create test image 533 + testImage := createTestImageForProxy(t, 100, 100, color.RGBA{R: 255, G: 128, B: 64, A: 255}) 534 + testCID := "bafybeimockimagetest123" 535 + testDID := "did:plc:mocktest123" 536 + 537 + // Create mock PDS server that returns the test image 538 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 539 + // Verify it's a getBlob request 540 + if !strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.sync.getBlob") { 541 + w.WriteHeader(http.StatusNotFound) 542 + return 543 + } 544 + 545 + // Check query parameters 546 + did := r.URL.Query().Get("did") 547 + cid := r.URL.Query().Get("cid") 548 + 549 + if did == testDID && cid == testCID { 550 + w.Header().Set("Content-Type", "image/png") 551 + w.WriteHeader(http.StatusOK) 552 + _, _ = w.Write(testImage) 553 + return 554 + } 555 + 556 + // Return 404 for unknown blobs 557 + w.WriteHeader(http.StatusNotFound) 558 + })) 559 + defer mockPDS.Close() 560 + 561 + // Create mock identity resolver that returns the mock PDS URL 562 + mockResolver := &mockIdentityResolverForImageProxy{ 563 + pdsURL: mockPDS.URL, 564 + } 565 + 566 + // Create test server 567 + cacheDir := t.TempDir() 568 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 569 + require.NoError(t, err, "Failed to create disk cache") 570 + processor := imageproxycore.NewProcessor() 571 + fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 572 + config := imageproxycore.Config{ 573 + Enabled: true, 574 + CachePath: cacheDir, 575 + CacheMaxGB: 1, 576 + FetchTimeout: 30 * time.Second, 577 + MaxSourceSizeMB: 10, 578 + } 579 + 580 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 581 + require.NoError(t, err, "Failed to create imageproxy service") 582 + handler := imageproxy.NewHandler(service, mockResolver) 583 + 584 + r := chi.NewRouter() 585 + routes.RegisterImageProxyRoutes(r, handler) 586 + testServer := httptest.NewServer(r) 587 + defer testServer.Close() 588 + 589 + t.Run("mock PDS returns valid image", func(t *testing.T) { 590 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, testDID, testCID) 591 + 592 + resp, err := http.Get(proxyURL) 593 + require.NoError(t, err, "Request should succeed") 594 + defer func() { _ = resp.Body.Close() }() 595 + 596 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 597 + assert.Equal(t, "image/jpeg", resp.Header.Get("Content-Type"), "Content-Type should be JPEG") 598 + 599 + // Verify processed dimensions (avatar is 1000x1000 per presets.go) 600 + var buf bytes.Buffer 601 + _, _ = buf.ReadFrom(resp.Body) 602 + img, err := imaging.Decode(&buf) 603 + require.NoError(t, err, "Should decode image") 604 + 605 + bounds := img.Bounds() 606 + assert.Equal(t, 1000, bounds.Dx(), "Width should be 1000 (avatar preset)") 607 + assert.Equal(t, 1000, bounds.Dy(), "Height should be 1000 (avatar preset)") 608 + }) 609 + 610 + t.Run("mock PDS 404 returns proxy 404", func(t *testing.T) { 611 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, testDID, "nonexistentcid") 612 + 613 + resp, err := http.Get(proxyURL) 614 + require.NoError(t, err, "Request should succeed") 615 + defer func() { _ = resp.Body.Close() }() 616 + 617 + assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Should return 404") 618 + }) 619 + } 620 + 621 + // mockIdentityResolverForImageProxy is a mock identity resolver for testing 622 + type mockIdentityResolverForImageProxy struct { 623 + pdsURL string 624 + } 625 + 626 + func (m *mockIdentityResolverForImageProxy) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 627 + return nil, fmt.Errorf("not implemented") 628 + } 629 + 630 + func (m *mockIdentityResolverForImageProxy) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) { 631 + return "", "", fmt.Errorf("not implemented") 632 + } 633 + 634 + func (m *mockIdentityResolverForImageProxy) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 635 + return &identity.DIDDocument{ 636 + DID: did, 637 + Service: []identity.Service{ 638 + { 639 + ID: "#atproto_pds", 640 + Type: "AtprotoPersonalDataServer", 641 + ServiceEndpoint: m.pdsURL, 642 + }, 643 + }, 644 + }, nil 645 + } 646 + 647 + func (m *mockIdentityResolverForImageProxy) Purge(ctx context.Context, identifier string) error { 648 + return nil 649 + } 650 + 651 + // TestImageProxy_ErrorHandling tests various error conditions 652 + func TestImageProxy_ErrorHandling(t *testing.T) { 653 + // Create mock identity resolver 654 + mockResolver := &mockIdentityResolverForImageProxy{ 655 + pdsURL: "http://localhost:9999", // Non-existent server 656 + } 657 + 658 + // Create test server 659 + cacheDir := t.TempDir() 660 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 661 + require.NoError(t, err, "Failed to create disk cache") 662 + processor := imageproxycore.NewProcessor() 663 + fetcher := imageproxycore.NewPDSFetcher(1 * time.Second, 10) // Short timeout 664 + config := imageproxycore.Config{ 665 + Enabled: true, 666 + CachePath: cacheDir, 667 + CacheMaxGB: 1, 668 + FetchTimeout: 1 * time.Second, 669 + MaxSourceSizeMB: 10, 670 + } 671 + 672 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 673 + require.NoError(t, err, "Failed to create imageproxy service") 674 + handler := imageproxy.NewHandler(service, mockResolver) 675 + 676 + r := chi.NewRouter() 677 + routes.RegisterImageProxyRoutes(r, handler) 678 + testServer := httptest.NewServer(r) 679 + defer testServer.Close() 680 + 681 + t.Run("connection refused returns 502", func(t *testing.T) { 682 + // Use a valid CID format - this will pass validation but fail at the PDS fetch stage 683 + validCID := "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi" 684 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/did:plc:test/%s", testServer.URL, validCID) 685 + 686 + resp, err := http.Get(proxyURL) 687 + require.NoError(t, err, "Request should succeed") 688 + defer func() { _ = resp.Body.Close() }() 689 + 690 + // Should return 502 Bad Gateway when PDS fetch fails 691 + assert.Equal(t, http.StatusBadGateway, resp.StatusCode, "Should return 502 when PDS is unreachable") 692 + }) 693 + 694 + t.Run("invalid DID resolution returns 502", func(t *testing.T) { 695 + // Create resolver that returns error 696 + errorResolver := &errorMockResolver{} 697 + 698 + errorHandler := imageproxy.NewHandler(service, errorResolver) 699 + errorRouter := chi.NewRouter() 700 + routes.RegisterImageProxyRoutes(errorRouter, errorHandler) 701 + errorServer := httptest.NewServer(errorRouter) 702 + defer errorServer.Close() 703 + 704 + // Use a valid CID format - this will pass validation but fail at DID resolution 705 + validCID := "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi" 706 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/did:plc:test/%s", errorServer.URL, validCID) 707 + 708 + resp, err := http.Get(proxyURL) 709 + require.NoError(t, err, "Request should succeed") 710 + defer func() { _ = resp.Body.Close() }() 711 + 712 + assert.Equal(t, http.StatusBadGateway, resp.StatusCode, "Should return 502 when DID resolution fails") 713 + }) 714 + } 715 + 716 + // errorMockResolver is a mock resolver that always returns an error 717 + type errorMockResolver struct{} 718 + 719 + func (m *errorMockResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 720 + return nil, fmt.Errorf("resolution failed") 721 + } 722 + 723 + func (m *errorMockResolver) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) { 724 + return "", "", fmt.Errorf("resolution failed") 725 + } 726 + 727 + func (m *errorMockResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 728 + return nil, fmt.Errorf("resolution failed") 729 + } 730 + 731 + func (m *errorMockResolver) Purge(ctx context.Context, identifier string) error { 732 + return nil 733 + } 734 + 735 + // TestImageProxy_UnsupportedFormat tests behavior with unsupported image formats 736 + func TestImageProxy_UnsupportedFormat(t *testing.T) { 737 + // Create mock PDS that returns invalid image data 738 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 739 + if strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.sync.getBlob") { 740 + cid := r.URL.Query().Get("cid") 741 + 742 + if cid == "textdata" { 743 + // Return text data instead of image 744 + w.Header().Set("Content-Type", "text/plain") 745 + w.WriteHeader(http.StatusOK) 746 + _, _ = w.Write([]byte("this is not an image")) 747 + return 748 + } 749 + 750 + if cid == "corruptedimage" { 751 + // Return corrupted image data 752 + w.Header().Set("Content-Type", "image/png") 753 + w.WriteHeader(http.StatusOK) 754 + _, _ = w.Write([]byte{0x89, 0x50, 0x4E, 0x47, 0x00, 0x00}) // Incomplete PNG header 755 + return 756 + } 757 + 758 + if cid == "emptybody" { 759 + // Return empty body 760 + w.WriteHeader(http.StatusOK) 761 + return 762 + } 763 + } 764 + w.WriteHeader(http.StatusNotFound) 765 + })) 766 + defer mockPDS.Close() 767 + 768 + mockResolver := &mockIdentityResolverForImageProxy{ 769 + pdsURL: mockPDS.URL, 770 + } 771 + 772 + cacheDir := t.TempDir() 773 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 774 + require.NoError(t, err, "Failed to create disk cache") 775 + processor := imageproxycore.NewProcessor() 776 + fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 777 + config := imageproxycore.DefaultConfig() 778 + 779 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 780 + require.NoError(t, err, "Failed to create imageproxy service") 781 + handler := imageproxy.NewHandler(service, mockResolver) 782 + 783 + r := chi.NewRouter() 784 + routes.RegisterImageProxyRoutes(r, handler) 785 + testServer := httptest.NewServer(r) 786 + defer testServer.Close() 787 + 788 + testCases := []struct { 789 + name string 790 + cid string 791 + expectedStatus int 792 + }{ 793 + {"text data", "textdata", http.StatusBadRequest}, 794 + {"corrupted image", "corruptedimage", http.StatusInternalServerError}, 795 + {"empty body", "emptybody", http.StatusBadRequest}, 796 + } 797 + 798 + for _, tc := range testCases { 799 + t.Run(tc.name, func(t *testing.T) { 800 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/did:plc:test/%s", testServer.URL, tc.cid) 801 + 802 + resp, err := http.Get(proxyURL) 803 + require.NoError(t, err, "Request should succeed") 804 + defer func() { _ = resp.Body.Close() }() 805 + 806 + // Should return error status for invalid image data 807 + assert.True(t, resp.StatusCode >= 400, "Should return error status for %s", tc.name) 808 + t.Logf("%s returned status %d", tc.name, resp.StatusCode) 809 + }) 810 + } 811 + } 812 + 813 + // TestImageProxy_LargeImage tests behavior with large images 814 + func TestImageProxy_LargeImage(t *testing.T) { 815 + // Create a large test image (1000x1000) 816 + largeImage := createTestImageForProxy(t, 1000, 1000, color.RGBA{R: 200, G: 100, B: 50, A: 255}) 817 + testCID := "bafylargeimagecid" 818 + testDID := "did:plc:largetest" 819 + 820 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 821 + if strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.sync.getBlob") { 822 + did := r.URL.Query().Get("did") 823 + cid := r.URL.Query().Get("cid") 824 + 825 + if did == testDID && cid == testCID { 826 + w.Header().Set("Content-Type", "image/png") 827 + w.WriteHeader(http.StatusOK) 828 + _, _ = w.Write(largeImage) 829 + return 830 + } 831 + } 832 + w.WriteHeader(http.StatusNotFound) 833 + })) 834 + defer mockPDS.Close() 835 + 836 + mockResolver := &mockIdentityResolverForImageProxy{ 837 + pdsURL: mockPDS.URL, 838 + } 839 + 840 + cacheDir := t.TempDir() 841 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 842 + require.NoError(t, err, "Failed to create disk cache") 843 + processor := imageproxycore.NewProcessor() 844 + fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 845 + config := imageproxycore.DefaultConfig() 846 + 847 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 848 + require.NoError(t, err, "Failed to create imageproxy service") 849 + handler := imageproxy.NewHandler(service, mockResolver) 850 + 851 + r := chi.NewRouter() 852 + routes.RegisterImageProxyRoutes(r, handler) 853 + testServer := httptest.NewServer(r) 854 + defer testServer.Close() 855 + 856 + t.Run("large image resized correctly", func(t *testing.T) { 857 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, testDID, testCID) 858 + 859 + resp, err := http.Get(proxyURL) 860 + require.NoError(t, err, "Request should succeed") 861 + defer func() { _ = resp.Body.Close() }() 862 + 863 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 864 + 865 + var buf bytes.Buffer 866 + _, _ = buf.ReadFrom(resp.Body) 867 + img, err := imaging.Decode(&buf) 868 + require.NoError(t, err, "Should decode image") 869 + 870 + bounds := img.Bounds() 871 + assert.Equal(t, 1000, bounds.Dx(), "Width should be 1000 (avatar preset)") 872 + assert.Equal(t, 1000, bounds.Dy(), "Height should be 1000 (avatar preset)") 873 + 874 + t.Logf("Large image correctly resized to %dx%d", bounds.Dx(), bounds.Dy()) 875 + }) 876 + 877 + t.Run("content_preview limits width for large image", func(t *testing.T) { 878 + proxyURL := fmt.Sprintf("%s/img/content_preview/plain/%s/%s", testServer.URL, testDID, testCID) 879 + 880 + resp, err := http.Get(proxyURL) 881 + require.NoError(t, err, "Request should succeed") 882 + defer func() { _ = resp.Body.Close() }() 883 + 884 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 885 + 886 + var buf bytes.Buffer 887 + _, _ = buf.ReadFrom(resp.Body) 888 + img, err := imaging.Decode(&buf) 889 + require.NoError(t, err, "Should decode image") 890 + 891 + bounds := img.Bounds() 892 + // content_preview max width is 800, preserves aspect ratio 893 + assert.Equal(t, 800, bounds.Dx(), "Width should be limited to 800") 894 + assert.Equal(t, 800, bounds.Dy(), "Height should be 800 (1:1 aspect ratio)") 895 + 896 + t.Logf("Large image correctly scaled to %dx%d for content_preview", bounds.Dx(), bounds.Dy()) 897 + }) 898 + } 899 + 900 + // TestImageProxy_ResponseJSON verifies no JSON is returned (should be plain text or image) 901 + func TestImageProxy_ResponseJSON(t *testing.T) { 902 + mockResolver := &mockIdentityResolverForImageProxy{ 903 + pdsURL: "http://localhost:9999", 904 + } 905 + 906 + cacheDir := t.TempDir() 907 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 908 + require.NoError(t, err, "Failed to create disk cache") 909 + processor := imageproxycore.NewProcessor() 910 + fetcher := imageproxycore.NewPDSFetcher(1 * time.Second, 10) 911 + config := imageproxycore.DefaultConfig() 912 + 913 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 914 + require.NoError(t, err, "Failed to create imageproxy service") 915 + handler := imageproxy.NewHandler(service, mockResolver) 916 + 917 + r := chi.NewRouter() 918 + routes.RegisterImageProxyRoutes(r, handler) 919 + testServer := httptest.NewServer(r) 920 + defer testServer.Close() 921 + 922 + t.Run("error responses are plain text not JSON", func(t *testing.T) { 923 + proxyURL := fmt.Sprintf("%s/img/invalid_preset/plain/did:plc:test/cid", testServer.URL) 924 + 925 + resp, err := http.Get(proxyURL) 926 + require.NoError(t, err, "Request should succeed") 927 + defer func() { _ = resp.Body.Close() }() 928 + 929 + contentType := resp.Header.Get("Content-Type") 930 + assert.Contains(t, contentType, "text/plain", "Error responses should be text/plain") 931 + 932 + // Verify body is not valid JSON 933 + var buf bytes.Buffer 934 + _, _ = buf.ReadFrom(resp.Body) 935 + body := buf.Bytes() 936 + 937 + var jsonCheck map[string]interface{} 938 + jsonErr := json.Unmarshal(body, &jsonCheck) 939 + assert.Error(t, jsonErr, "Error response should not be valid JSON") 940 + 941 + t.Logf("Error response correctly returned as plain text: %s", string(body)) 942 + }) 943 + }