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