A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "Coves/internal/atproto/auth" 5 "Coves/internal/core/users" 6 "bytes" 7 "context" 8 "database/sql" 9 "encoding/base64" 10 "encoding/json" 11 "fmt" 12 "io" 13 "net/http" 14 "os" 15 "strings" 16 "testing" 17 "time" 18 19 "github.com/golang-jwt/jwt/v5" 20) 21 22// getTestPDSURL returns the PDS URL for testing from env var or default 23func getTestPDSURL() string { 24 pdsURL := os.Getenv("PDS_URL") 25 if pdsURL == "" { 26 pdsURL = "http://localhost:3001" 27 } 28 return pdsURL 29} 30 31// getTestInstanceDID returns the instance DID for testing from env var or default 32func getTestInstanceDID() string { 33 instanceDID := os.Getenv("INSTANCE_DID") 34 if instanceDID == "" { 35 instanceDID = "did:web:test.coves.social" 36 } 37 return instanceDID 38} 39 40// createTestUser creates a test user in the database for use in integration tests 41// Returns the created user or fails the test 42func createTestUser(t *testing.T, db *sql.DB, handle, did string) *users.User { 43 t.Helper() 44 45 ctx := context.Background() 46 47 // Create user directly in DB for speed 48 query := ` 49 INSERT INTO users (did, handle, pds_url, created_at, updated_at) 50 VALUES ($1, $2, $3, NOW(), NOW()) 51 RETURNING did, handle, pds_url, created_at, updated_at 52 ` 53 54 user := &users.User{} 55 err := db.QueryRowContext(ctx, query, did, handle, getTestPDSURL()).Scan( 56 &user.DID, 57 &user.Handle, 58 &user.PDSURL, 59 &user.CreatedAt, 60 &user.UpdatedAt, 61 ) 62 if err != nil { 63 t.Fatalf("Failed to create test user: %v", err) 64 } 65 66 return user 67} 68 69// contains checks if string s contains substring substr 70// Helper for error message assertions 71func contains(s, substr string) bool { 72 return strings.Contains(s, substr) 73} 74 75// authenticateWithPDS authenticates with PDS to get access token and DID 76// Used for setting up test environments that need PDS credentials 77func authenticateWithPDS(pdsURL, handle, password string) (string, string, error) { 78 // Call com.atproto.server.createSession 79 sessionReq := map[string]string{ 80 "identifier": handle, 81 "password": password, 82 } 83 84 reqBody, marshalErr := json.Marshal(sessionReq) 85 if marshalErr != nil { 86 return "", "", fmt.Errorf("failed to marshal session request: %w", marshalErr) 87 } 88 resp, err := http.Post( 89 pdsURL+"/xrpc/com.atproto.server.createSession", 90 "application/json", 91 bytes.NewBuffer(reqBody), 92 ) 93 if err != nil { 94 return "", "", fmt.Errorf("failed to create session: %w", err) 95 } 96 defer func() { _ = resp.Body.Close() }() 97 98 if resp.StatusCode != http.StatusOK { 99 body, readErr := io.ReadAll(resp.Body) 100 if readErr != nil { 101 return "", "", fmt.Errorf("PDS auth failed (status %d, failed to read body: %w)", resp.StatusCode, readErr) 102 } 103 return "", "", fmt.Errorf("PDS auth failed (status %d): %s", resp.StatusCode, string(body)) 104 } 105 106 var sessionResp struct { 107 AccessJwt string `json:"accessJwt"` 108 DID string `json:"did"` 109 } 110 111 if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil { 112 return "", "", fmt.Errorf("failed to decode session response: %w", err) 113 } 114 115 return sessionResp.AccessJwt, sessionResp.DID, nil 116} 117 118// createSimpleTestJWT creates a minimal JWT for testing (Phase 1 - no signature) 119// In production, this would be a real OAuth token from PDS with proper signatures 120func createSimpleTestJWT(userDID string) string { 121 // Create minimal JWT claims using RegisteredClaims 122 // Use userDID as issuer since we don't have a proper PDS DID for testing 123 claims := auth.Claims{ 124 RegisteredClaims: jwt.RegisteredClaims{ 125 Subject: userDID, 126 Issuer: userDID, // Use DID as issuer for testing (valid per atProto) 127 Audience: jwt.ClaimStrings{getTestInstanceDID()}, 128 IssuedAt: jwt.NewNumericDate(time.Now()), 129 ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), 130 }, 131 Scope: "com.atproto.access", 132 } 133 134 // For Phase 1 testing, we create an unsigned JWT 135 // The middleware is configured with skipVerify=true for testing 136 header := map[string]interface{}{ 137 "alg": "none", 138 "typ": "JWT", 139 } 140 141 headerJSON, _ := json.Marshal(header) 142 claimsJSON, _ := json.Marshal(claims) 143 144 // Base64url encode (without padding) 145 headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) 146 claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) 147 148 // For "alg: none", signature is empty 149 return headerB64 + "." + claimsB64 + "." 150} 151 152// generateTID generates a simple timestamp-based identifier for testing 153// In production, PDS generates proper TIDs 154func generateTID() string { 155 return fmt.Sprintf("3k%d", time.Now().UnixNano()/1000) 156} 157 158// createPDSAccount creates a new account on PDS and returns access token + DID 159// This is used for E2E tests that need real PDS accounts 160func createPDSAccount(pdsURL, handle, email, password string) (accessToken, did string, err error) { 161 // Call com.atproto.server.createAccount 162 reqBody := map[string]string{ 163 "handle": handle, 164 "email": email, 165 "password": password, 166 } 167 168 reqJSON, marshalErr := json.Marshal(reqBody) 169 if marshalErr != nil { 170 return "", "", fmt.Errorf("failed to marshal account request: %w", marshalErr) 171 } 172 173 resp, httpErr := http.Post( 174 pdsURL+"/xrpc/com.atproto.server.createAccount", 175 "application/json", 176 bytes.NewBuffer(reqJSON), 177 ) 178 if httpErr != nil { 179 return "", "", fmt.Errorf("failed to create account: %w", httpErr) 180 } 181 defer func() { _ = resp.Body.Close() }() 182 183 if resp.StatusCode != http.StatusOK { 184 body, readErr := io.ReadAll(resp.Body) 185 if readErr != nil { 186 return "", "", fmt.Errorf("account creation failed (status %d, failed to read body: %w)", resp.StatusCode, readErr) 187 } 188 return "", "", fmt.Errorf("account creation failed (status %d): %s", resp.StatusCode, string(body)) 189 } 190 191 var accountResp struct { 192 AccessJwt string `json:"accessJwt"` 193 DID string `json:"did"` 194 } 195 196 if decodeErr := json.NewDecoder(resp.Body).Decode(&accountResp); decodeErr != nil { 197 return "", "", fmt.Errorf("failed to decode account response: %w", decodeErr) 198 } 199 200 return accountResp.AccessJwt, accountResp.DID, nil 201} 202 203// writePDSRecord writes a record to PDS via com.atproto.repo.createRecord 204// Returns the AT-URI and CID of the created record 205func writePDSRecord(pdsURL, accessToken, repo, collection, rkey string, record interface{}) (uri, cid string, err error) { 206 reqBody := map[string]interface{}{ 207 "repo": repo, 208 "collection": collection, 209 "record": record, 210 } 211 212 // If rkey is provided, include it 213 if rkey != "" { 214 reqBody["rkey"] = rkey 215 } 216 217 reqJSON, marshalErr := json.Marshal(reqBody) 218 if marshalErr != nil { 219 return "", "", fmt.Errorf("failed to marshal record request: %w", marshalErr) 220 } 221 222 req, reqErr := http.NewRequest("POST", pdsURL+"/xrpc/com.atproto.repo.createRecord", bytes.NewBuffer(reqJSON)) 223 if reqErr != nil { 224 return "", "", fmt.Errorf("failed to create request: %w", reqErr) 225 } 226 227 req.Header.Set("Content-Type", "application/json") 228 req.Header.Set("Authorization", "Bearer "+accessToken) 229 230 resp, httpErr := http.DefaultClient.Do(req) 231 if httpErr != nil { 232 return "", "", fmt.Errorf("failed to write record: %w", httpErr) 233 } 234 defer func() { _ = resp.Body.Close() }() 235 236 if resp.StatusCode != http.StatusOK { 237 body, readErr := io.ReadAll(resp.Body) 238 if readErr != nil { 239 return "", "", fmt.Errorf("record creation failed (status %d, failed to read body: %w)", resp.StatusCode, readErr) 240 } 241 return "", "", fmt.Errorf("record creation failed (status %d): %s", resp.StatusCode, string(body)) 242 } 243 244 var recordResp struct { 245 URI string `json:"uri"` 246 CID string `json:"cid"` 247 } 248 249 if decodeErr := json.NewDecoder(resp.Body).Decode(&recordResp); decodeErr != nil { 250 return "", "", fmt.Errorf("failed to decode record response: %w", decodeErr) 251 } 252 253 return recordResp.URI, recordResp.CID, nil 254} 255 256// createFeedTestCommunity creates a test community for feed tests 257// Returns the community DID or an error 258func createFeedTestCommunity(db *sql.DB, ctx context.Context, name, ownerHandle string) (string, error) { 259 // Get configuration from env vars 260 pdsURL := getTestPDSURL() 261 instanceDID := getTestInstanceDID() 262 263 // Create owner user first (directly insert to avoid service dependencies) 264 ownerDID := fmt.Sprintf("did:plc:%s", ownerHandle) 265 _, err := db.ExecContext(ctx, ` 266 INSERT INTO users (did, handle, pds_url, created_at) 267 VALUES ($1, $2, $3, NOW()) 268 ON CONFLICT (did) DO NOTHING 269 `, ownerDID, ownerHandle, pdsURL) 270 if err != nil { 271 return "", err 272 } 273 274 // Create community 275 communityDID := fmt.Sprintf("did:plc:community-%s", name) 276 _, err = db.ExecContext(ctx, ` 277 INSERT INTO communities (did, name, owner_did, created_by_did, hosted_by_did, handle, pds_url, created_at) 278 VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) 279 ON CONFLICT (did) DO NOTHING 280 `, communityDID, name, ownerDID, ownerDID, instanceDID, fmt.Sprintf("%s.coves.social", name), pdsURL) 281 282 return communityDID, err 283} 284 285// createTestPost creates a test post and returns its URI 286func createTestPost(t *testing.T, db *sql.DB, communityDID, authorDID, title string, score int, createdAt time.Time) string { 287 t.Helper() 288 289 ctx := context.Background() 290 291 // Create author user if not exists (directly insert to avoid service dependencies) 292 _, _ = db.ExecContext(ctx, ` 293 INSERT INTO users (did, handle, pds_url, created_at) 294 VALUES ($1, $2, $3, NOW()) 295 ON CONFLICT (did) DO NOTHING 296 `, authorDID, fmt.Sprintf("%s.bsky.social", authorDID), getTestPDSURL()) 297 298 // Generate URI 299 rkey := fmt.Sprintf("post-%d", time.Now().UnixNano()) 300 uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", communityDID, rkey) 301 302 // Insert post 303 _, err := db.ExecContext(ctx, ` 304 INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at, score, upvote_count) 305 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 306 `, uri, "bafytest", rkey, authorDID, communityDID, title, createdAt, score, score) 307 if err != nil { 308 t.Fatalf("Failed to create test post: %v", err) 309 } 310 311 return uri 312}