diff --git a/.claude/commands/create-pr.md b/.claude/commands/create-pr.md --- a/.claude/commands/create-pr.md +++ b/.claude/commands/create-pr.md @@ -4,7 +4,6 @@ Create a new branch, commit changes, and submit a pull request. ## Behavior - Creates a new branch based on current changes -- Formats modified files using Biome - Analyzes changes and automatically splits into logical commits when appropriate - Each commit focuses on a single logical change or feature - Creates descriptive commit messages for each logical unit diff --git a/.env.dev b/.env.dev --- a/.env.dev +++ b/.env.dev @@ -57,6 +57,22 @@ POSTGRES_TEST_PASSWORD=test_password POSTGRES_TEST_PORT=5434 # ============================================================================= +# Jetstream Configuration (Read-Forward User Indexing) +# ============================================================================= +# Jetstream WebSocket URL for real-time atProto events +# +# Production: Use Bluesky's public Jetstream (indexes entire network) +# JETSTREAM_URL=wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.actor.profile +# +# Local E2E Testing: Use local Jetstream (indexes only local PDS) +# 1. Start local Jetstream: docker-compose --profile jetstream up pds jetstream +# 2. Use this URL: +JETSTREAM_URL=ws://localhost:6008/subscribe + +# Optional: Filter events to specific PDS +# JETSTREAM_PDS_FILTER=http://localhost:3001 + +# ============================================================================= # Development Settings # ============================================================================= # Environment diff --git a/Makefile b/Makefile --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev-up dev-down dev-logs dev-status dev-reset dev-db-up dev-db-down dev-db-reset test clean +.PHONY: help dev-up dev-down dev-logs dev-status dev-reset test e2e-test clean # Default target - show help .DEFAULT_GOAL := help @@ -26,17 +26,22 @@ @echo "" ##@ Local Development (All-in-One) -dev-up: ## Start PDS + PostgreSQL for local development +dev-up: ## Start PDS + PostgreSQL + Jetstream for local development @echo "$(GREEN)Starting Coves development stack...$(RESET)" - @docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d postgres pds + @docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile jetstream up -d postgres pds jetstream @echo "" @echo "$(GREEN)✓ Development stack started!$(RESET)" @echo "" @echo "Services available at:" @echo " - PostgreSQL: localhost:5433" @echo " - PDS (XRPC): http://localhost:3001" - @echo " - PDS Firehose (WS): ws://localhost:3001/xrpc/com.atproto.sync.subscribeRepos" - @echo " - AppView (API): http://localhost:8081 (when uncommented)" + @echo " - PDS Firehose: ws://localhost:3001/xrpc/com.atproto.sync.subscribeRepos" + @echo " - Jetstream: ws://localhost:6008/subscribe $(CYAN)(Read-Forward)$(RESET)" + @echo " - Jetstream Metrics: http://localhost:6009/metrics" + @echo "" + @echo "$(CYAN)Next steps:$(RESET)" + @echo " 1. Run: make run (starts AppView)" + @echo " 2. AppView will auto-index users from Jetstream" @echo "" @echo "Run 'make dev-logs' to view logs" @@ -90,16 +95,30 @@ @echo "$(GREEN)✓ Database reset complete$(RESET)" ##@ Testing -test: ## Run all tests with test database +test: ## Run fast unit/integration tests (skips slow E2E tests) @echo "$(GREEN)Starting test database...$(RESET)" @docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile test up -d postgres-test @echo "Waiting for test database to be ready..." @sleep 3 @echo "$(GREEN)Running migrations on test database...$(RESET)" @goose -dir internal/db/migrations postgres "postgresql://$(POSTGRES_TEST_USER):$(POSTGRES_TEST_PASSWORD)@localhost:$(POSTGRES_TEST_PORT)/$(POSTGRES_TEST_DB)?sslmode=disable" up || true - @echo "$(GREEN)Running tests...$(RESET)" - @go test ./... -v + @echo "$(GREEN)Running fast tests (use 'make e2e-test' for E2E tests)...$(RESET)" + @go test ./... -short -v @echo "$(GREEN)✓ Tests complete$(RESET)" + +e2e-test: ## Run automated E2E tests (requires: make dev-up + make run in another terminal) + @echo "$(CYAN)========================================$(RESET)" + @echo "$(CYAN) E2E Test: Full User Signup Flow $(RESET)" + @echo "$(CYAN)========================================$(RESET)" + @echo "" + @echo "$(CYAN)Prerequisites:$(RESET)" + @echo " 1. Run 'make dev-up' (if not already running)" + @echo " 2. Run 'make run' in another terminal (AppView must be running)" + @echo "" + @echo "$(GREEN)Running E2E tests...$(RESET)" + @go test ./tests/e2e -run TestE2E_UserSignup -v + @echo "" + @echo "$(GREEN)✓ E2E tests complete!$(RESET)" test-db-reset: ## Reset test database @echo "$(GREEN)Resetting test database...$(RESET)" diff --git a/cmd/server/main.go b/cmd/server/main.go --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "database/sql" "fmt" "log" @@ -17,6 +18,7 @@ "Coves/internal/api/middleware" "Coves/internal/api/routes" "Coves/internal/core/users" postgresRepo "Coves/internal/db/postgres" + "Coves/internal/jetstream" ) func main() { @@ -27,10 +29,10 @@ // Use dev database from .env.dev dbURL = "postgres://dev_user:dev_password@localhost:5433/coves_dev?sslmode=disable" } - // PDS URL configuration - pdsURL := os.Getenv("PDS_URL") - if pdsURL == "" { - pdsURL = "http://localhost:3001" // Local dev PDS + // Default PDS URL for this Coves instance (supports self-hosting) + defaultPDS := os.Getenv("PDS_URL") + if defaultPDS == "" { + defaultPDS = "http://localhost:3001" // Local dev PDS } db, err := sql.Open("postgres", dbURL) @@ -68,10 +70,28 @@ r.Use(rateLimiter.Middleware) // Initialize repositories and services userRepo := postgresRepo.NewUserRepository(db) - userService := users.NewUserService(userRepo, pdsURL) + userService := users.NewUserService(userRepo, defaultPDS) - // Mount XRPC routes - r.Mount("/xrpc/social.coves.actor", routes.UserRoutes(userService)) + // Start Jetstream consumer for read-forward user indexing + jetstreamURL := os.Getenv("JETSTREAM_URL") + if jetstreamURL == "" { + jetstreamURL = "wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.actor.profile" + } + + pdsFilter := os.Getenv("JETSTREAM_PDS_FILTER") // Optional: filter to specific PDS + + userConsumer := jetstream.NewUserEventConsumer(userService, jetstreamURL, pdsFilter) + ctx := context.Background() + go func() { + if err := userConsumer.Start(ctx); err != nil { + log.Printf("Jetstream consumer stopped: %v", err) + } + }() + + log.Printf("Started Jetstream consumer: %s", jetstreamURL) + + // Register XRPC routes + routes.RegisterUserRoutes(r, userService) r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -84,6 +104,6 @@ port = "8081" // Match .env.dev default } fmt.Printf("Coves AppView starting on port %s\n", port) - fmt.Printf("PDS URL: %s\n", pdsURL) + fmt.Printf("Default PDS: %s\n", defaultPDS) log.Fatal(http.ListenAndServe(":"+port, r)) } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -102,6 +102,62 @@ interval: 10s timeout: 5s retries: 5 + # Jetstream - Consumes PDS firehose and serves JSON WebSocket + # This is the RECOMMENDED approach for local E2E testing + # Jetstream converts raw atProto CBOR firehose to clean JSON events + # + # Flow: PDS firehose → Jetstream (CBOR→JSON) → Your AppView (JSON) + # + # Usage: + # docker-compose --profile jetstream up pds jetstream + # Your AppView connects to: ws://localhost:6008/subscribe + # + # Why use Jetstream instead of direct PDS firehose? + # - PDS emits raw CBOR (binary) - hard to parse + # - Jetstream converts to clean JSON - easy to consume + # - Same format as production Bluesky Jetstream + jetstream: + image: ghcr.io/bluesky-social/jetstream:sha-306e463693365e21a5ffd3ec051a5a7920000214 + container_name: coves-dev-jetstream + ports: + - "6008:6008" # Jetstream WebSocket endpoint + - "6009:6009" # Metrics endpoint + environment: + # Point Jetstream at local PDS firehose + JETSTREAM_WS_URL: ws://pds:3000/xrpc/com.atproto.sync.subscribeRepos + + # Server configuration + JETSTREAM_LISTEN_ADDR: ":6008" + JETSTREAM_METRICS_LISTEN_ADDR: ":6009" + + # Data storage + JETSTREAM_DATA_DIR: /data + JETSTREAM_EVENT_TTL: 24h + + # Set long liveness TTL for local dev (PDS may be quiet for long periods) + JETSTREAM_LIVENESS_TTL: 24h + + # Performance tuning + JETSTREAM_WORKER_COUNT: 10 + JETSTREAM_MAX_QUEUE_SIZE: 1000 + + # Development settings + LOG_LEVEL: ${LOG_LEVEL:-debug} + volumes: + - jetstream-data:/data + networks: + - coves-dev + depends_on: + pds: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:6009/metrics"] + interval: 10s + timeout: 5s + retries: 5 + profiles: + - jetstream + # Indigo Relay (BigSky) - OPTIONAL for local dev # WARNING: BigSky is designed to crawl the entire atProto network! # For local dev, consider using direct PDS firehose instead (see AppView config below) @@ -196,3 +252,5 @@ postgres-test-data: name: coves-test-postgres-data pds-data: name: coves-dev-pds-data + jetstream-data: + name: coves-dev-jetstream-data diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md new file mode 100644 --- /dev/null +++ b/docs/E2E_TESTING.md @@ -0,0 +1,174 @@ +# End-to-End Testing Guide + +## Overview + +Coves supports full E2E testing with a local atProto stack: + +``` +Third-party Client → Coves XRPC → PDS → Jetstream → Coves AppView → PostgreSQL +``` + +**Why Jetstream?** +- PDS emits raw CBOR-encoded firehose (binary, hard to parse) +- Jetstream converts CBOR → clean JSON (same format as production) +- Tests exactly match production behavior + +--- + +## Quick Start + +### 1. Start Development Stack + +```bash +make dev-up +``` + +This starts: +- **PostgreSQL** (port 5433) - Coves database +- **PDS** (port 3001) - Local atProto server +- **Jetstream** (port 6008) - CBOR → JSON converter (always runs for read-forward) + +> **Note:** Jetstream is now part of `dev-up` since read-forward architecture requires it + +### 2. Start AppView + +```bash +# In another terminal +make run # Starts AppView (auto-runs migrations) +``` + +AppView will connect to `ws://localhost:6008/subscribe` (configured in `.env.dev`) + +### 3. Run Automated E2E Tests + +```bash +make e2e-test +``` + +This runs the full test suite: +- Creates accounts via XRPC endpoint +- Verifies PDS account creation +- Validates Jetstream indexing +- Confirms database storage + +### 4. Manual Testing (Optional) + +#### Create User via Coves XRPC + +```bash +curl -X POST http://localhost:8081/xrpc/social.coves.actor.signup \ + -H "Content-Type: application/json" \ + -d '{ + "handle": "alice.local.coves.dev", + "email": "alice@test.com", + "password": "test1234" + }' +``` + +**Response:** +```json +{ + "did": "did:plc:xyz123...", + "handle": "alice.local.coves.dev", + "accessJwt": "eyJ...", + "refreshJwt": "eyJ..." +} +``` + +**What happens:** +1. Coves XRPC handler receives signup request +2. Calls PDS `com.atproto.server.createAccount` +3. PDS creates account → emits to firehose +4. Jetstream converts event → JSON +5. AppView receives JSON → indexes user +6. User appears in PostgreSQL `users` table + +### 5. Verify Indexing + +Check AppView logs: +``` +2025/01/15 12:00:00 Identity event: did:plc:xyz123 → alice.local.coves.dev +2025/01/15 12:00:00 Indexed new user: alice.local.coves.dev (did:plc:xyz123) +``` + +Query via API: +```bash +curl "http://localhost:8081/xrpc/social.coves.actor.getProfile?actor=alice.local.coves.dev" +``` + +Expected response: +```json +{ + "did": "did:plc:xyz123...", + "profile": { + "handle": "alice.local.coves.dev", + "createdAt": "2025-01-15T12:00:00Z" + } +} +``` + +--- + +## Workflow Summary + +### Daily Development +```bash +make dev-up # Start PDS + PostgreSQL + Jetstream (once) +make run # Start AppView (in another terminal) +make test # Run fast tests during development +``` + +### Before Commits/PRs +```bash +make e2e-test # Run full E2E test suite +``` + +### Reset Everything +```bash +make dev-reset # Nuclear option - deletes all data +make dev-up # Start fresh +``` + +--- + +## Testing Scenarios + +### Automated E2E Test Suite + +```bash +make e2e-test +``` + +This tests: +- ✅ Single account creation via XRPC +- ✅ Idempotent duplicate event handling +- ✅ Multiple concurrent user indexing + +### Manual: User Registration via XRPC + +```bash +curl -X POST http://localhost:8081/xrpc/social.coves.actor.signup \ + -H "Content-Type: application/json" \ + -d '{"handle":"bob.local.coves.dev","email":"bob@test.com","password":"pass1234"}' +``` + +### Manual: Federated User (Direct PDS) + +```bash +# Simulates a user on another PDS +curl -X POST http://localhost:3001/xrpc/com.atproto.server.createAccount \ + -H "Content-Type: application/json" \ + -d '{"handle":"charlie.local.coves.dev","email":"charlie@test.com","password":"pass1234"}' + +# Coves AppView will still index via Jetstream (read-forward) +``` + +--- + +## Next Steps + +1. ✅ E2E testing infrastructure complete +2. ✅ Automated E2E test suite implemented +3. ✅ XRPC signup endpoint for third-party clients +4. 🔨 TODO: Add handle update support +5. 🔨 TODO: Add CI/CD E2E tests diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ github.com/gocql/gocql v1.7.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.5 // indirect diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= diff --git a/internal/api/routes/user.go b/internal/api/routes/user.go --- a/internal/api/routes/user.go +++ b/internal/api/routes/user.go @@ -2,6 +2,7 @@ package routes import ( "encoding/json" + "errors" "net/http" "time" @@ -21,16 +22,16 @@ userService: userService, } } -// UserRoutes returns user-related XRPC routes +// RegisterUserRoutes registers user-related XRPC endpoints on the router // Implements social.coves.actor.* lexicon endpoints -func UserRoutes(service users.UserService) chi.Router { +func RegisterUserRoutes(r chi.Router, service users.UserService) { h := NewUserHandler(service) - r := chi.NewRouter() // social.coves.actor.getProfile - query endpoint - r.Get("/profile", h.GetProfile) + r.Get("/xrpc/social.coves.actor.getProfile", h.GetProfile) - return r + // social.coves.actor.signup - procedure endpoint + r.Post("/xrpc/social.coves.actor.signup", h.Signup) } // GetProfile handles social.coves.actor.getProfile @@ -73,4 +74,102 @@ w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(response) +} + +// Signup handles social.coves.actor.signup +// Procedure endpoint that registers a new account on the Coves instance +func (h *UserHandler) Signup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Parse request body + var req users.RegisterAccountRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + // Call service to register account + resp, err := h.userService.RegisterAccount(ctx, req) + if err != nil { + // Map service errors to lexicon error types with proper HTTP status codes + respondWithLexiconError(w, err) + return + } + + // Return response matching lexicon output schema + response := map[string]interface{}{ + "did": resp.DID, + "handle": resp.Handle, + "accessJwt": resp.AccessJwt, + "refreshJwt": resp.RefreshJwt, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// respondWithLexiconError maps domain errors to lexicon error types and HTTP status codes +// Error names match the lexicon definition in social.coves.actor.signup +func respondWithLexiconError(w http.ResponseWriter, err error) { + var ( + statusCode int + errorName string + message string + ) + + // Map domain errors to lexicon error types + var invalidHandleErr *users.InvalidHandleError + var handleNotAvailableErr *users.HandleNotAvailableError + var invalidInviteCodeErr *users.InvalidInviteCodeError + var invalidEmailErr *users.InvalidEmailError + var weakPasswordErr *users.WeakPasswordError + var pdsErr *users.PDSError + + switch { + case errors.As(err, &invalidHandleErr): + statusCode = http.StatusBadRequest + errorName = "InvalidHandle" + message = invalidHandleErr.Error() + + case errors.As(err, &handleNotAvailableErr): + statusCode = http.StatusBadRequest + errorName = "HandleNotAvailable" + message = handleNotAvailableErr.Error() + + case errors.As(err, &invalidInviteCodeErr): + statusCode = http.StatusBadRequest + errorName = "InvalidInviteCode" + message = invalidInviteCodeErr.Error() + + case errors.As(err, &invalidEmailErr): + statusCode = http.StatusBadRequest + errorName = "InvalidEmail" + message = invalidEmailErr.Error() + + case errors.As(err, &weakPasswordErr): + statusCode = http.StatusBadRequest + errorName = "WeakPassword" + message = weakPasswordErr.Error() + + case errors.As(err, &pdsErr): + // PDS errors get mapped based on status code + statusCode = pdsErr.StatusCode + errorName = "PDSError" + message = pdsErr.Message + + default: + // Generic error handling (avoid leaking internal details) + statusCode = http.StatusInternalServerError + errorName = "InternalServerError" + message = "An error occurred while processing your request" + } + + // XRPC error response format + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": errorName, + "message": message, + }) } \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/actor/signup.json b/internal/atproto/lexicon/social/coves/actor/signup.json new file mode 100644 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/actor/signup.json @@ -0,0 +1,85 @@ +{ + "lexicon": 1, + "id": "social.coves.actor.signup", + "defs": { + "main": { + "type": "procedure", + "description": "Register a new account on the Coves instance. Creates account on PDS and indexes in AppView.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["handle", "email", "password"], + "properties": { + "handle": { + "type": "string", + "format": "handle", + "description": "Requested handle for the account (e.g., alice.coves.dev)" + }, + "email": { + "type": "string", + "description": "Email address for account recovery and notifications" + }, + "password": { + "type": "string", + "description": "Account password (must meet strength requirements)" + }, + "inviteCode": { + "type": "string", + "description": "Invite code (required if instance has invite-only registration)" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["did", "handle", "accessJwt", "refreshJwt"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "The DID of the newly created account" + }, + "handle": { + "type": "string", + "format": "handle", + "description": "The handle of the newly created account" + }, + "accessJwt": { + "type": "string", + "description": "Access token for authenticated requests" + }, + "refreshJwt": { + "type": "string", + "description": "Refresh token for obtaining new access tokens" + } + } + } + }, + "errors": [ + { + "name": "InvalidHandle", + "description": "Handle does not meet format requirements" + }, + { + "name": "HandleNotAvailable", + "description": "The requested handle is already taken" + }, + { + "name": "InvalidInviteCode", + "description": "The provided invite code is invalid or expired" + }, + { + "name": "InvalidEmail", + "description": "The email address is invalid" + }, + { + "name": "WeakPassword", + "description": "Password does not meet strength requirements" + } + ] + } + } +} diff --git a/internal/core/users/errors.go b/internal/core/users/errors.go new file mode 100644 --- /dev/null +++ b/internal/core/users/errors.go @@ -0,0 +1,57 @@ +package users + +import "fmt" + +// Domain errors for user service operations +// These map to lexicon error types defined in social.coves.actor.signup + +type InvalidHandleError struct { + Handle string + Reason string +} + +func (e *InvalidHandleError) Error() string { + return fmt.Sprintf("invalid handle %q: %s", e.Handle, e.Reason) +} + +type HandleNotAvailableError struct { + Handle string +} + +func (e *HandleNotAvailableError) Error() string { + return fmt.Sprintf("handle %q is not available", e.Handle) +} + +type InvalidInviteCodeError struct { + Code string +} + +func (e *InvalidInviteCodeError) Error() string { + return "invalid or expired invite code" +} + +type InvalidEmailError struct { + Email string +} + +func (e *InvalidEmailError) Error() string { + return fmt.Sprintf("invalid email address: %q", e.Email) +} + +type WeakPasswordError struct { + Reason string +} + +func (e *WeakPasswordError) Error() string { + return fmt.Sprintf("password does not meet strength requirements: %s", e.Reason) +} + +// PDSError wraps errors from the PDS that we couldn't map to domain errors +type PDSError struct { + StatusCode int + Message string +} + +func (e *PDSError) Error() string { + return fmt.Sprintf("PDS error (%d): %s", e.StatusCode, e.Message) +} diff --git a/internal/core/users/interfaces.go b/internal/core/users/interfaces.go --- a/internal/core/users/interfaces.go +++ b/internal/core/users/interfaces.go @@ -15,4 +15,5 @@ CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) GetUserByDID(ctx context.Context, did string) (*User, error) GetUserByHandle(ctx context.Context, handle string) (*User, error) ResolveHandleToDID(ctx context.Context, handle string) (string, error) + RegisterAccount(ctx context.Context, req RegisterAccountRequest) (*RegisterAccountResponse, error) } \ No newline at end of file diff --git a/internal/core/users/service.go b/internal/core/users/service.go --- a/internal/core/users/service.go +++ b/internal/core/users/service.go @@ -1,30 +1,58 @@ package users import ( + "bytes" "context" + "encoding/json" "fmt" + "io" + "net/http" "regexp" "strings" + "time" ) -// atProto handle validation regex -// Handles must: start/end with alphanumeric, contain only alphanumeric + hyphens, no consecutive hyphens -var handleRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$`) +// atProto handle validation regex (per official atProto spec: https://atproto.com/specs/handle) +// - Must have at least one dot (domain-like structure) +// - Each segment max 63 chars, total max 253 chars +// - Segments: alphanumeric start/end, hyphens allowed in middle +// - TLD (final segment) must start with letter (not digit) +// - Case-insensitive, normalized to lowercase +var handleRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) + +// Disallowed TLDs per atProto spec +var disallowedTLDs = map[string]bool{ + ".alt": true, + ".arpa": true, + ".example": true, + ".internal": true, + ".invalid": true, + ".local": true, + ".localhost": true, + ".onion": true, + // .test is allowed for development +} + +const ( + minPasswordLength = 8 // Reasonable minimum, though PDS may enforce stricter rules + maxHandleLength = 253 +) type userService struct { - userRepo UserRepository - pdsURL string // TODO: Support federated PDS - different users may have different PDS hosts + userRepo UserRepository + defaultPDS string // Default PDS URL for this Coves instance (used when creating new local users via registration API) } // NewUserService creates a new user service -func NewUserService(userRepo UserRepository, pdsURL string) UserService { +func NewUserService(userRepo UserRepository, defaultPDS string) UserService { return &userService{ - userRepo: userRepo, - pdsURL: pdsURL, + userRepo: userRepo, + defaultPDS: defaultPDS, } } // CreateUser creates a new user in the AppView database +// This method is idempotent: if a user with the same DID already exists, it returns the existing user func (s *userService) CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) { if err := s.validateCreateRequest(req); err != nil { return nil, err @@ -33,14 +61,30 @@ // Normalize handle req.Handle = strings.TrimSpace(strings.ToLower(req.Handle)) req.DID = strings.TrimSpace(req.DID) + req.PDSURL = strings.TrimSpace(req.PDSURL) user := &User{ DID: req.DID, Handle: req.Handle, + PDSURL: req.PDSURL, } - // Repository will handle duplicate constraint errors - return s.userRepo.Create(ctx, user) + // Try to create the user + createdUser, err := s.userRepo.Create(ctx, user) + if err != nil { + // If user with this DID already exists, fetch and return it (idempotent behavior) + if strings.Contains(err.Error(), "user with DID already exists") { + existingUser, getErr := s.userRepo.GetByDID(ctx, req.DID) + if getErr != nil { + return nil, fmt.Errorf("user exists but failed to fetch: %w", getErr) + } + return existingUser, nil + } + // For other errors (validation, handle conflict, etc.), return the error + return nil, err + } + + return createdUser, nil } // GetUserByDID retrieves a user by their DID @@ -81,6 +125,67 @@ return user.DID, nil } +// RegisterAccount creates a new account on the PDS via XRPC +// This is what a UI signup button would call - it handles the PDS account creation +func (s *userService) RegisterAccount(ctx context.Context, req RegisterAccountRequest) (*RegisterAccountResponse, error) { + if err := s.validateRegisterRequest(req); err != nil { + return nil, err + } + + // Call PDS com.atproto.server.createAccount XRPC endpoint + pdsURL := strings.TrimSuffix(s.defaultPDS, "/") + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.server.createAccount", pdsURL) + + payload := map[string]string{ + "handle": req.Handle, + "email": req.Email, + "password": req.Password, + } + if req.InviteCode != "" { + payload["inviteCode"] = req.InviteCode + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + // Set timeout to prevent hanging on slow/unavailable PDS + client := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to call PDS: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body)) + } + + var pdsResp RegisterAccountResponse + if err := json.Unmarshal(body, &pdsResp); err != nil { + return nil, fmt.Errorf("failed to parse PDS response: %w", err) + } + + // Set the PDS URL in the response (PDS doesn't return this) + pdsResp.PDSURL = s.defaultPDS + + return &pdsResp, nil +} + func (s *userService) validateCreateRequest(req CreateUserRequest) error { if strings.TrimSpace(req.DID) == "" { return fmt.Errorf("DID is required") @@ -88,6 +193,10 @@ } if strings.TrimSpace(req.Handle) == "" { return fmt.Errorf("handle is required") + } + + if strings.TrimSpace(req.PDSURL) == "" { + return fmt.Errorf("PDS URL is required") } // DID format validation @@ -95,22 +204,69 @@ if !strings.HasPrefix(req.DID, "did:") { return fmt.Errorf("invalid DID format: must start with 'did:'") } - // atProto handle validation - handle := strings.TrimSpace(strings.ToLower(req.Handle)) + // Validate handle format + if err := validateHandle(req.Handle); err != nil { + return err + } - // Length validation (1-253 characters per atProto spec) - if len(handle) < 1 || len(handle) > 253 { - return fmt.Errorf("handle must be between 1 and 253 characters") + return nil +} + +func (s *userService) validateRegisterRequest(req RegisterAccountRequest) error { + if strings.TrimSpace(req.Handle) == "" { + return fmt.Errorf("handle is required") } - // Regex validation: alphanumeric + hyphens + dots, no consecutive hyphens + if strings.TrimSpace(req.Email) == "" { + return &InvalidEmailError{Email: req.Email} + } + + // Basic email validation + if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { + return &InvalidEmailError{Email: req.Email} + } + + // Password validation + if strings.TrimSpace(req.Password) == "" { + return &WeakPasswordError{Reason: "password is required"} + } + + if len(req.Password) < minPasswordLength { + return &WeakPasswordError{Reason: fmt.Sprintf("password must be at least %d characters", minPasswordLength)} + } + + // Validate handle format + if err := validateHandle(req.Handle); err != nil { + return err + } + + return nil +} + +// validateHandle validates handle per atProto spec: https://atproto.com/specs/handle +func validateHandle(handle string) error { + // Normalize to lowercase (handles are case-insensitive) + handle = strings.TrimSpace(strings.ToLower(handle)) + + if handle == "" { + return &InvalidHandleError{Handle: handle, Reason: "handle cannot be empty"} + } + + // Check length + if len(handle) > maxHandleLength { + return &InvalidHandleError{Handle: handle, Reason: fmt.Sprintf("handle exceeds maximum length of %d characters", maxHandleLength)} + } + + // Check regex pattern if !handleRegex.MatchString(handle) { - return fmt.Errorf("invalid handle format: must contain only alphanumeric characters, hyphens, and dots; must start and end with alphanumeric; no consecutive hyphens") + return &InvalidHandleError{Handle: handle, Reason: "handle must be domain-like (e.g., user.bsky.social), with segments of alphanumeric/hyphens separated by dots"} } - // Check for consecutive hyphens (not allowed in atProto) - if strings.Contains(handle, "--") { - return fmt.Errorf("invalid handle format: consecutive hyphens not allowed") + // Check for disallowed TLDs + for tld := range disallowedTLDs { + if strings.HasSuffix(handle, tld) { + return &InvalidHandleError{Handle: handle, Reason: fmt.Sprintf("TLD %s is not allowed", tld)} + } } return nil diff --git a/internal/core/users/user.go b/internal/core/users/user.go --- a/internal/core/users/user.go +++ b/internal/core/users/user.go @@ -10,6 +10,7 @@ // This table only tracks metadata for efficient AppView queries type User struct { DID string `json:"did" db:"did"` // atProto DID (e.g., did:plc:xyz123) Handle string `json:"handle" db:"handle"` // Human-readable handle (e.g., alice.coves.dev) + PDSURL string `json:"pdsUrl" db:"pds_url"` // User's PDS host URL (supports federation) CreatedAt time.Time `json:"createdAt" db:"created_at"` UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` } @@ -18,4 +19,22 @@ // CreateUserRequest represents the input for creating a new user type CreateUserRequest struct { DID string `json:"did"` Handle string `json:"handle"` + PDSURL string `json:"pdsUrl"` // User's PDS host URL +} + +// RegisterAccountRequest represents the input for registering a new account on the PDS +type RegisterAccountRequest struct { + Handle string `json:"handle"` + Email string `json:"email"` + Password string `json:"password"` + InviteCode string `json:"inviteCode,omitempty"` +} + +// RegisterAccountResponse represents the response from PDS account creation +type RegisterAccountResponse struct { + DID string `json:"did"` + Handle string `json:"handle"` + AccessJwt string `json:"accessJwt"` + RefreshJwt string `json:"refreshJwt"` + PDSURL string `json:"pdsUrl"` } diff --git a/internal/db/migrations/001_create_users_table.sql b/internal/db/migrations/001_create_users_table.sql --- a/internal/db/migrations/001_create_users_table.sql +++ b/internal/db/migrations/001_create_users_table.sql @@ -3,6 +3,7 @@ -- Create main users table for Coves (all users are atProto users) CREATE TABLE users ( did TEXT PRIMARY KEY, handle TEXT UNIQUE NOT NULL, + pds_url TEXT NOT NULL CHECK (pds_url <> ''), -- User's PDS host (supports federation) created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -12,4 +13,6 @@ CREATE INDEX idx_users_handle ON users(handle); CREATE INDEX idx_users_created_at ON users(created_at); -- +goose Down -DROP TABLE users; +DROP INDEX IF EXISTS idx_users_created_at; +DROP INDEX IF EXISTS idx_users_handle; +DROP TABLE IF EXISTS users; diff --git a/internal/db/postgres/user_repo.go b/internal/db/postgres/user_repo.go --- a/internal/db/postgres/user_repo.go +++ b/internal/db/postgres/user_repo.go @@ -21,12 +21,12 @@ // Create inserts a new user into the users table func (r *postgresUserRepo) Create(ctx context.Context, user *users.User) (*users.User, error) { query := ` - INSERT INTO users (did, handle) - VALUES ($1, $2) - RETURNING did, handle, created_at, updated_at` + INSERT INTO users (did, handle, pds_url) + VALUES ($1, $2, $3) + RETURNING did, handle, pds_url, created_at, updated_at` - err := r.db.QueryRowContext(ctx, query, user.DID, user.Handle). - Scan(&user.DID, &user.Handle, &user.CreatedAt, &user.UpdatedAt) + err := r.db.QueryRowContext(ctx, query, user.DID, user.Handle, user.PDSURL). + Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt) if err != nil { // Check for unique constraint violations @@ -47,10 +47,10 @@ // GetByDID retrieves a user by their DID func (r *postgresUserRepo) GetByDID(ctx context.Context, did string) (*users.User, error) { user := &users.User{} - query := `SELECT did, handle, created_at, updated_at FROM users WHERE did = $1` + query := `SELECT did, handle, pds_url, created_at, updated_at FROM users WHERE did = $1` err := r.db.QueryRowContext(ctx, query, did). - Scan(&user.DID, &user.Handle, &user.CreatedAt, &user.UpdatedAt) + Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") @@ -65,10 +65,10 @@ // GetByHandle retrieves a user by their handle func (r *postgresUserRepo) GetByHandle(ctx context.Context, handle string) (*users.User, error) { user := &users.User{} - query := `SELECT did, handle, created_at, updated_at FROM users WHERE handle = $1` + query := `SELECT did, handle, pds_url, created_at, updated_at FROM users WHERE handle = $1` err := r.db.QueryRowContext(ctx, query, handle). - Scan(&user.DID, &user.Handle, &user.CreatedAt, &user.UpdatedAt) + Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") diff --git a/internal/jetstream/user_consumer.go b/internal/jetstream/user_consumer.go new file mode 100644 --- /dev/null +++ b/internal/jetstream/user_consumer.go @@ -0,0 +1,254 @@ +package jetstream + +import ( + "context" + "encoding/json" + "fmt" + "log" + "time" + + "Coves/internal/core/users" + "github.com/gorilla/websocket" +) + +// JetstreamEvent represents an event from the Jetstream firehose +// Jetstream documentation: https://docs.bsky.app/docs/advanced-guides/jetstream +type JetstreamEvent struct { + Did string `json:"did"` + TimeUS int64 `json:"time_us"` + Kind string `json:"kind"` // "account", "commit", "identity" + Account *AccountEvent `json:"account,omitempty"` + Identity *IdentityEvent `json:"identity,omitempty"` +} + +type AccountEvent struct { + Active bool `json:"active"` + Did string `json:"did"` + Seq int64 `json:"seq"` + Time string `json:"time"` +} + +type IdentityEvent struct { + Did string `json:"did"` + Handle string `json:"handle"` + Seq int64 `json:"seq"` + Time string `json:"time"` +} + +// UserEventConsumer consumes user-related events from Jetstream +type UserEventConsumer struct { + userService users.UserService + wsURL string + pdsFilter string // Optional: only index users from specific PDS +} + +// NewUserEventConsumer creates a new Jetstream consumer for user events +func NewUserEventConsumer(userService users.UserService, wsURL string, pdsFilter string) *UserEventConsumer { + return &UserEventConsumer{ + userService: userService, + wsURL: wsURL, + pdsFilter: pdsFilter, + } +} + +// Start begins consuming events from Jetstream +// Runs indefinitely, reconnecting on errors +func (c *UserEventConsumer) Start(ctx context.Context) error { + log.Printf("Starting Jetstream user consumer: %s", c.wsURL) + + for { + select { + case <-ctx.Done(): + log.Println("Jetstream consumer shutting down") + return ctx.Err() + default: + if err := c.connect(ctx); err != nil { + log.Printf("Jetstream connection error: %v. Retrying in 5s...", err) + time.Sleep(5 * time.Second) + continue + } + } + } +} + +// connect establishes WebSocket connection and processes events +func (c *UserEventConsumer) connect(ctx context.Context) error { + conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL, nil) + if err != nil { + return fmt.Errorf("failed to connect to Jetstream: %w", err) + } + defer conn.Close() + + log.Println("Connected to Jetstream") + + // Set read deadline to detect connection issues + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + + // Set pong handler to keep connection alive + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + + // Start ping ticker + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + done := make(chan struct{}) + + // Goroutine to send pings + // TODO: Fix race condition - multiple goroutines can call close(done) concurrently + // Use sync.Once to ensure close(done) is called exactly once + // See PR review issue #4 + go func() { + for { + select { + case <-ticker.C: + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + log.Printf("Ping error: %v", err) + close(done) + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + // Read messages + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-done: + return fmt.Errorf("connection closed") + default: + _, message, err := conn.ReadMessage() + if err != nil { + close(done) + return fmt.Errorf("read error: %w", err) + } + + // Reset read deadline on successful read + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + + if err := c.handleEvent(ctx, message); err != nil { + log.Printf("Error handling event: %v", err) + // Continue processing other events + } + } + } +} + +// handleEvent processes a single Jetstream event +func (c *UserEventConsumer) handleEvent(ctx context.Context, data []byte) error { + var event JetstreamEvent + if err := json.Unmarshal(data, &event); err != nil { + return fmt.Errorf("failed to parse event: %w", err) + } + + // We're interested in identity events (handle updates) and account events (new users) + switch event.Kind { + case "identity": + return c.handleIdentityEvent(ctx, &event) + case "account": + return c.handleAccountEvent(ctx, &event) + default: + // Ignore other event types (commits, etc.) + return nil + } +} + +// HandleIdentityEventPublic is a public wrapper for testing +func (c *UserEventConsumer) HandleIdentityEventPublic(ctx context.Context, event *JetstreamEvent) error { + return c.handleIdentityEvent(ctx, event) +} + +// handleIdentityEvent processes identity events (handle changes) +func (c *UserEventConsumer) handleIdentityEvent(ctx context.Context, event *JetstreamEvent) error { + if event.Identity == nil { + return fmt.Errorf("identity event missing identity data") + } + + did := event.Identity.Did + handle := event.Identity.Handle + + if did == "" || handle == "" { + return fmt.Errorf("identity event missing did or handle") + } + + log.Printf("Identity event: %s → %s", did, handle) + + // For now, we'll create/update user on identity events + // In a full implementation, you'd want to: + // 1. Check if user exists + // 2. Update handle if changed + // 3. Resolve PDS URL from DID document + + // Simplified: just try to create user (will be idempotent) + // We need PDS URL - for now use a placeholder + // TODO: Implement DID→PDS resolution via PLC directory (https://plc.directory/{did}) + // For production federation support, resolve PDS endpoint from DID document + // For local dev, this works fine since we filter to our own PDS + // See PR review issue #2 + pdsURL := "https://bsky.social" // Default Bluesky PDS + + _, err := c.userService.CreateUser(ctx, users.CreateUserRequest{ + DID: did, + Handle: handle, + PDSURL: pdsURL, + }) + + if err != nil { + // Check if it's a duplicate error (expected for idempotency) + if isDuplicateError(err) { + log.Printf("User already indexed: %s (%s)", handle, did) + return nil + } + return fmt.Errorf("failed to create user: %w", err) + } + + log.Printf("Indexed new user: %s (%s)", handle, did) + return nil +} + +// handleAccountEvent processes account events (account creation/updates) +func (c *UserEventConsumer) handleAccountEvent(ctx context.Context, event *JetstreamEvent) error { + if event.Account == nil { + return fmt.Errorf("account event missing account data") + } + + did := event.Account.Did + if did == "" { + return fmt.Errorf("account event missing did") + } + + // Account events don't include handle, so we can't index yet + // We'll wait for the corresponding identity event + log.Printf("Account event for %s (waiting for identity event)", did) + return nil +} + +// isDuplicateError checks if error is due to duplicate DID/handle +func isDuplicateError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return contains(errStr, "already exists") || contains(errStr, "already taken") || contains(errStr, "duplicate") +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && anySubstring(s, substr)) +} + +func anySubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/tests/e2e/user_signup_test.go b/tests/e2e/user_signup_test.go new file mode 100644 --- /dev/null +++ b/tests/e2e/user_signup_test.go @@ -0,0 +1,410 @@ +package e2e + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "os" + "testing" + "time" + + "Coves/internal/core/users" + "Coves/internal/db/postgres" + "Coves/internal/jetstream" + _ "github.com/lib/pq" + "github.com/pressly/goose/v3" +) + +// TestE2E_UserSignup tests the full user signup flow: +// Third-party client → social.coves.actor.signup XRPC → PDS account creation → Jetstream → AppView indexing +// +// This tests the same code path that a third-party client or UI would use. +// +// Prerequisites: +// - AppView running on localhost:8081 +// - PDS running on localhost:3001 +// - Jetstream running on localhost:6008 (consuming from PDS) +// - Test database on localhost:5434 +// +// Run with: +// make e2e-up # Start infrastructure +// go run ./cmd/server & # Start AppView +// go test ./tests/integration -run TestE2E_UserSignup -v +func TestE2E_UserSignup(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + // Check if AppView is available + if !isAppViewAvailable(t) { + t.Skip("AppView not available at localhost:8081 - run 'go run ./cmd/server' first") + } + + // Check if PDS is available + if !isPDSAvailable(t) { + t.Skip("PDS not available at localhost:3001 - run 'make e2e-up' first") + } + + // Check if Jetstream is available + if !isJetstreamAvailable(t) { + t.Skip("Jetstream not available at localhost:6008 - run 'make e2e-up' first") + } + + db := setupTestDB(t) + defer db.Close() + + // Set up services + userRepo := postgres.NewUserRepository(db) + userService := users.NewUserService(userRepo, "http://localhost:3001") + + // Start Jetstream consumer + consumer := jetstream.NewUserEventConsumer( + userService, + "ws://localhost:6008/subscribe", + "", // No PDS filter + ) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Start consumer in background + consumerDone := make(chan error, 1) + go func() { + consumerDone <- consumer.Start(ctx) + }() + + // Give Jetstream consumer a moment to connect (no need to wait long) + t.Log("Waiting for Jetstream consumer to connect...") + time.Sleep(500 * time.Millisecond) + + // Test 1: Create account on PDS + t.Run("Create account on PDS and verify indexing", func(t *testing.T) { + handle := fmt.Sprintf("alice-%d.local.coves.dev", time.Now().Unix()) + email := fmt.Sprintf("alice-%d@test.com", time.Now().Unix()) + + t.Logf("Creating account: %s", handle) + + // Create account via UserService (what UI would call) + did, err := createPDSAccount(t, userService, handle, email, "test1234") + if err != nil { + t.Fatalf("Failed to create PDS account: %v", err) + } + + t.Logf("Account created with DID: %s", did) + + // Wait for Jetstream to process and AppView to index (with retry) + t.Log("Waiting for Jetstream → AppView indexing...") + var user *users.User + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + user, err = userService.GetUserByDID(ctx, did) + if err == nil { + break // Successfully indexed! + } + time.Sleep(500 * time.Millisecond) + } + if err != nil { + t.Fatalf("User not indexed in AppView after 10s: %v", err) + } + + if user.Handle != handle { + t.Errorf("Expected handle %s, got %s", handle, user.Handle) + } + + if user.DID != did { + t.Errorf("Expected DID %s, got %s", did, user.DID) + } + + t.Logf("✅ User successfully indexed: %s → %s", handle, did) + }) + + // Test 2: Idempotency + t.Run("Idempotent indexing on duplicate events", func(t *testing.T) { + handle := fmt.Sprintf("bob-%d.local.coves.dev", time.Now().Unix()) + email := fmt.Sprintf("bob-%d@test.com", time.Now().Unix()) + + // Create account via UserService + did, err := createPDSAccount(t, userService, handle, email, "test1234") + if err != nil { + t.Fatalf("Failed to create PDS account: %v", err) + } + + // Wait for indexing (with retry) + var user1 *users.User + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + user1, err = userService.GetUserByDID(ctx, did) + if err == nil { + break + } + time.Sleep(500 * time.Millisecond) + } + if err != nil { + t.Fatalf("User not indexed after 10s: %v", err) + } + + // Manually trigger duplicate indexing (simulates Jetstream replay) + _, err = userService.CreateUser(ctx, users.CreateUserRequest{ + DID: did, + Handle: handle, + PDSURL: "http://localhost:3001", + }) + if err != nil { + t.Fatalf("Idempotent CreateUser should not error: %v", err) + } + + // Verify still only one user + user2, err := userService.GetUserByDID(ctx, did) + if err != nil { + t.Fatalf("Failed to get user after duplicate: %v", err) + } + + if user1.CreatedAt != user2.CreatedAt { + t.Errorf("Duplicate indexing created new user (timestamps differ)") + } + + t.Logf("✅ Idempotency verified: duplicate events handled gracefully") + }) + + // Test 3: Multiple users + t.Run("Index multiple users concurrently", func(t *testing.T) { + const numUsers = 3 + dids := make([]string, numUsers) + + for i := 0; i < numUsers; i++ { + handle := fmt.Sprintf("user%d-%d.local.coves.dev", i, time.Now().Unix()) + email := fmt.Sprintf("user%d-%d@test.com", i, time.Now().Unix()) + + did, err := createPDSAccount(t, userService, handle, email, "test1234") + if err != nil { + t.Fatalf("Failed to create account %d: %v", i, err) + } + dids[i] = did + t.Logf("Created user %d: %s", i, did) + + // Small delay between creations + time.Sleep(500 * time.Millisecond) + } + + // Verify all indexed (with retry for each user) + t.Log("Waiting for all users to be indexed...") + for i, did := range dids { + var user *users.User + var err error + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + user, err = userService.GetUserByDID(ctx, did) + if err == nil { + break + } + time.Sleep(500 * time.Millisecond) + } + if err != nil { + t.Errorf("User %d not indexed after 15s: %v", i, err) + continue + } + t.Logf("✅ User %d indexed: %s", i, user.Handle) + } + }) + + // Clean shutdown + cancel() + select { + case err := <-consumerDone: + if err != nil && err != context.Canceled { + t.Logf("Consumer stopped with error: %v", err) + } + case <-time.After(5 * time.Second): + t.Log("Consumer shutdown timeout") + } +} + +// generateInviteCode generates a single-use invite code via PDS admin API +func generateInviteCode(t *testing.T) (string, error) { + payload := map[string]int{ + "useCount": 1, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest( + "POST", + "http://localhost:3001/xrpc/com.atproto.server.createInviteCode", + bytes.NewBuffer(jsonData), + ) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + // PDS admin authentication + req.SetBasicAuth("admin", "admin") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to create invite code: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var errorResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&errorResp) + return "", fmt.Errorf("PDS admin API returned status %d: %v", resp.StatusCode, errorResp) + } + + var result struct { + Code string `json:"code"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + t.Logf("Generated invite code: %s", result.Code) + return result.Code, nil +} + +// createPDSAccount creates an account via the coves.user.signup XRPC endpoint +// This is the same code path that a third-party client or UI would use +func createPDSAccount(t *testing.T, userService users.UserService, handle, email, password string) (string, error) { + // Generate fresh invite code for each account + inviteCode, err := generateInviteCode(t) + if err != nil { + return "", fmt.Errorf("failed to generate invite code: %w", err) + } + + // Call our XRPC endpoint (what a third-party client would call) + payload := map[string]string{ + "handle": handle, + "email": email, + "password": password, + "inviteCode": inviteCode, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := http.Post( + "http://localhost:8081/xrpc/social.coves.actor.signup", + "application/json", + bytes.NewBuffer(jsonData), + ) + if err != nil { + return "", fmt.Errorf("failed to call signup endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var errorResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&errorResp) + return "", fmt.Errorf("signup endpoint returned status %d: %v", resp.StatusCode, errorResp) + } + + var result struct { + DID string `json:"did"` + Handle string `json:"handle"` + AccessJwt string `json:"accessJwt"` + RefreshJwt string `json:"refreshJwt"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + t.Logf("Account created via XRPC endpoint: %s → %s", result.Handle, result.DID) + + return result.DID, nil +} + +// isPDSAvailable checks if PDS is running +func isPDSAvailable(t *testing.T) bool { + resp, err := http.Get("http://localhost:3001/xrpc/_health") + if err != nil { + t.Logf("PDS not available: %v", err) + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// isJetstreamAvailable checks if Jetstream is running +func isJetstreamAvailable(t *testing.T) bool { + // Use 127.0.0.1 instead of localhost to force IPv4 + resp, err := http.Get("http://127.0.0.1:6009/metrics") + if err != nil { + t.Logf("Jetstream not available: %v", err) + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// isAppViewAvailable checks if AppView is running +func isAppViewAvailable(t *testing.T) bool { + resp, err := http.Get("http://localhost:8081/health") + if err != nil { + t.Logf("AppView not available: %v", err) + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// setupTestDB connects to test database and runs migrations +func setupTestDB(t *testing.T) *sql.DB { + // Build connection string from environment variables (set by .env.dev) + testUser := os.Getenv("POSTGRES_TEST_USER") + testPassword := os.Getenv("POSTGRES_TEST_PASSWORD") + testPort := os.Getenv("POSTGRES_TEST_PORT") + testDB := os.Getenv("POSTGRES_TEST_DB") + + // Fallback to defaults if not set + if testUser == "" { + testUser = "test_user" + } + if testPassword == "" { + testPassword = "test_password" + } + if testPort == "" { + testPort = "5434" + } + if testDB == "" { + testDB = "coves_test" + } + + dbURL := fmt.Sprintf("postgres://%s:%s@localhost:%s/%s?sslmode=disable", + testUser, testPassword, testPort, testDB) + + db, err := sql.Open("postgres", dbURL) + if err != nil { + t.Fatalf("Failed to connect to test database: %v", err) + } + + if err := db.Ping(); err != nil { + t.Fatalf("Failed to ping test database: %v", err) + } + + if err := goose.SetDialect("postgres"); err != nil { + t.Fatalf("Failed to set goose dialect: %v", err) + } + + if err := goose.Up(db, "../../internal/db/migrations"); err != nil { + t.Fatalf("Failed to run migrations: %v", err) + } + + // Clean up any existing test data + _, err = db.Exec("DELETE FROM users WHERE handle LIKE '%.test' OR handle LIKE '%.local.coves.dev'") + if err != nil { + t.Logf("Warning: Failed to clean up test data: %v", err) + } + + return db +} diff --git a/tests/integration/integration_test.go b/tests/integration/user_test.go rename from tests/integration/integration_test.go rename to tests/integration/user_test.go --- a/tests/integration/integration_test.go +++ b/tests/integration/user_test.go @@ -85,6 +85,7 @@ t.Run("Create User", func(t *testing.T) { req := users.CreateUserRequest{ DID: "did:plc:test123456", Handle: "alice.test", + PDSURL: "http://localhost:3001", } user, err := userService.CreateUser(ctx, req) @@ -155,6 +156,7 @@ ctx := context.Background() _, err := userService.CreateUser(ctx, users.CreateUserRequest{ DID: "did:plc:endpoint123", Handle: "bob.test", + PDSURL: "http://localhost:3001", }) if err != nil { t.Fatalf("Failed to create test user: %v", err) @@ -162,11 +164,11 @@ } // Set up HTTP router r := chi.NewRouter() - r.Mount("/xrpc/social.coves.actor", routes.UserRoutes(userService)) + routes.RegisterUserRoutes(r, userService) // Test 1: Get profile by DID t.Run("Get Profile By DID", func(t *testing.T) { - req := httptest.NewRequest("GET", "/xrpc/social.coves.actor/profile?actor=did:plc:endpoint123", nil) + req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=did:plc:endpoint123", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -187,7 +189,7 @@ }) // Test 2: Get profile by handle t.Run("Get Profile By Handle", func(t *testing.T) { - req := httptest.NewRequest("GET", "/xrpc/social.coves.actor/profile?actor=bob.test", nil) + req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=bob.test", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -209,7 +211,7 @@ }) // Test 3: Missing actor parameter t.Run("Missing Actor Parameter", func(t *testing.T) { - req := httptest.NewRequest("GET", "/xrpc/social.coves.actor/profile", nil) + req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -220,7 +222,7 @@ }) // Test 4: User not found t.Run("User Not Found", func(t *testing.T) { - req := httptest.NewRequest("GET", "/xrpc/social.coves.actor/profile?actor=nonexistent.test", nil) + req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=nonexistent.test", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -243,24 +245,28 @@ // Create first user _, err := userService.CreateUser(ctx, users.CreateUserRequest{ DID: "did:plc:duplicate123", Handle: "duplicate.test", + PDSURL: "http://localhost:3001", }) if err != nil { t.Fatalf("Failed to create first user: %v", err) } - // Test duplicate DID - t.Run("Duplicate DID", func(t *testing.T) { - _, err := userService.CreateUser(ctx, users.CreateUserRequest{ + // Test duplicate DID - now idempotent, returns existing user + t.Run("Duplicate DID - Idempotent", func(t *testing.T) { + user, err := userService.CreateUser(ctx, users.CreateUserRequest{ DID: "did:plc:duplicate123", - Handle: "different.test", + Handle: "different.test", // Different handle, same DID + PDSURL: "http://localhost:3001", }) - if err == nil { - t.Error("Expected error for duplicate DID, got nil") + // Should return existing user, not error + if err != nil { + t.Fatalf("Expected idempotent behavior, got error: %v", err) } - if !strings.Contains(err.Error(), "DID already exists") { - t.Errorf("Expected 'DID already exists' error, got: %v", err) + // Should return the original user (with original handle) + if user.Handle != "duplicate.test" { + t.Errorf("Expected original handle 'duplicate.test', got: %s", user.Handle) } }) @@ -269,6 +275,7 @@ t.Run("Duplicate Handle", func(t *testing.T) { _, err := userService.CreateUser(ctx, users.CreateUserRequest{ DID: "did:plc:different456", Handle: "duplicate.test", + PDSURL: "http://localhost:3001", }) if err == nil { @@ -294,6 +301,7 @@ testCases := []struct { name string did string handle string + pdsURL string shouldError bool errorMsg string }{ @@ -301,60 +309,76 @@ { name: "Valid handle with hyphen", did: "did:plc:valid1", handle: "alice-bob.test", + pdsURL: "http://localhost:3001", shouldError: false, }, { name: "Valid handle with dots", did: "did:plc:valid2", handle: "alice.bob.test", + pdsURL: "http://localhost:3001", shouldError: false, }, { - name: "Invalid: consecutive hyphens", - did: "did:plc:invalid1", + name: "Invalid: no dot (not domain-like)", + did: "did:plc:invalid8", + handle: "alice", + pdsURL: "http://localhost:3001", + shouldError: true, + errorMsg: "invalid handle", + }, + { + name: "Valid: consecutive hyphens (allowed per atProto spec)", + did: "did:plc:valid3", handle: "alice--bob.test", - shouldError: true, - errorMsg: "consecutive hyphens", + pdsURL: "http://localhost:3001", + shouldError: false, }, { name: "Invalid: starts with hyphen", did: "did:plc:invalid2", handle: "-alice.test", + pdsURL: "http://localhost:3001", shouldError: true, - errorMsg: "invalid handle format", + errorMsg: "invalid handle", }, { name: "Invalid: ends with hyphen", did: "did:plc:invalid3", handle: "alice-.test", + pdsURL: "http://localhost:3001", shouldError: true, - errorMsg: "invalid handle format", + errorMsg: "invalid handle", }, { name: "Invalid: special characters", did: "did:plc:invalid4", handle: "alice!bob.test", + pdsURL: "http://localhost:3001", shouldError: true, - errorMsg: "invalid handle format", + errorMsg: "invalid handle", }, { name: "Invalid: spaces", did: "did:plc:invalid5", handle: "alice bob.test", + pdsURL: "http://localhost:3001", shouldError: true, - errorMsg: "invalid handle format", + errorMsg: "invalid handle", }, { name: "Invalid: too long", did: "did:plc:invalid6", handle: strings.Repeat("a", 254) + ".test", + pdsURL: "http://localhost:3001", shouldError: true, - errorMsg: "must be between 1 and 253 characters", + errorMsg: "invalid handle", }, { name: "Invalid: missing DID prefix", did: "plc:invalid7", handle: "valid.test", + pdsURL: "http://localhost:3001", shouldError: true, errorMsg: "must start with 'did:'", }, @@ -365,6 +389,7 @@ t.Run(tc.name, func(t *testing.T) { _, err := userService.CreateUser(ctx, users.CreateUserRequest{ DID: tc.did, Handle: tc.handle, + PDSURL: tc.pdsURL, }) if tc.shouldError { diff --git a/tests/integration/jetstream_consumer_test.go b/tests/integration/jetstream_consumer_test.go new file mode 100644 --- /dev/null +++ b/tests/integration/jetstream_consumer_test.go @@ -0,0 +1,271 @@ +package integration + +import ( + "context" + "testing" + "time" + + "Coves/internal/core/users" + "Coves/internal/db/postgres" + "Coves/internal/jetstream" +) + +func TestUserIndexingFromJetstream(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Wire up dependencies + userRepo := postgres.NewUserRepository(db) + userService := users.NewUserService(userRepo, "http://localhost:3001") + + ctx := context.Background() + + t.Run("Index new user from identity event", func(t *testing.T) { + // Simulate an identity event from Jetstream + event := jetstream.JetstreamEvent{ + Did: "did:plc:jetstream123", + Kind: "identity", + Identity: &jetstream.IdentityEvent{ + Did: "did:plc:jetstream123", + Handle: "alice.jetstream.test", + Seq: 12345, + Time: time.Now().Format(time.RFC3339), + }, + } + + consumer := jetstream.NewUserEventConsumer(userService, "", "") + + // Handle the event + err := consumer.HandleIdentityEventPublic(ctx, &event) + if err != nil { + t.Fatalf("failed to handle identity event: %v", err) + } + + // Verify user was indexed + user, err := userService.GetUserByDID(ctx, "did:plc:jetstream123") + if err != nil { + t.Fatalf("failed to get indexed user: %v", err) + } + + if user.DID != "did:plc:jetstream123" { + t.Errorf("expected DID did:plc:jetstream123, got %s", user.DID) + } + + if user.Handle != "alice.jetstream.test" { + t.Errorf("expected handle alice.jetstream.test, got %s", user.Handle) + } + }) + + t.Run("Idempotent indexing - duplicate event", func(t *testing.T) { + // Create a user first + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ + DID: "did:plc:duplicate123", + Handle: "duplicate.test", + PDSURL: "https://bsky.social", + }) + if err != nil { + t.Fatalf("failed to create initial user: %v", err) + } + + // Simulate duplicate identity event + event := jetstream.JetstreamEvent{ + Did: "did:plc:duplicate123", + Kind: "identity", + Identity: &jetstream.IdentityEvent{ + Did: "did:plc:duplicate123", + Handle: "duplicate.test", + Seq: 12346, + Time: time.Now().Format(time.RFC3339), + }, + } + + consumer := jetstream.NewUserEventConsumer(userService, "", "") + + // Handle duplicate event - should not error + err = consumer.HandleIdentityEventPublic(ctx, &event) + if err != nil { + t.Fatalf("duplicate event should be handled gracefully: %v", err) + } + + // Verify still only one user + user, err := userService.GetUserByDID(ctx, "did:plc:duplicate123") + if err != nil { + t.Fatalf("failed to get user: %v", err) + } + + if user.Handle != "duplicate.test" { + t.Errorf("expected handle duplicate.test, got %s", user.Handle) + } + }) + + t.Run("Index multiple users", func(t *testing.T) { + consumer := jetstream.NewUserEventConsumer(userService, "", "") + + users := []struct { + did string + handle string + }{ + {"did:plc:multi1", "user1.test"}, + {"did:plc:multi2", "user2.test"}, + {"did:plc:multi3", "user3.test"}, + } + + for _, u := range users { + event := jetstream.JetstreamEvent{ + Did: u.did, + Kind: "identity", + Identity: &jetstream.IdentityEvent{ + Did: u.did, + Handle: u.handle, + Seq: 12345, + Time: time.Now().Format(time.RFC3339), + }, + } + + err := consumer.HandleIdentityEventPublic(ctx, &event) + if err != nil { + t.Fatalf("failed to index user %s: %v", u.handle, err) + } + } + + // Verify all users indexed + for _, u := range users { + user, err := userService.GetUserByDID(ctx, u.did) + if err != nil { + t.Fatalf("user %s not found: %v", u.did, err) + } + + if user.Handle != u.handle { + t.Errorf("expected handle %s, got %s", u.handle, user.Handle) + } + } + }) + + t.Run("Skip invalid events", func(t *testing.T) { + consumer := jetstream.NewUserEventConsumer(userService, "", "") + + // Missing DID + invalidEvent1 := jetstream.JetstreamEvent{ + Did: "", + Kind: "identity", + Identity: &jetstream.IdentityEvent{ + Did: "", + Handle: "invalid.test", + Seq: 12345, + Time: time.Now().Format(time.RFC3339), + }, + } + + err := consumer.HandleIdentityEventPublic(ctx, &invalidEvent1) + if err == nil { + t.Error("expected error for missing DID, got nil") + } + + // Missing handle + invalidEvent2 := jetstream.JetstreamEvent{ + Did: "did:plc:invalid", + Kind: "identity", + Identity: &jetstream.IdentityEvent{ + Did: "did:plc:invalid", + Handle: "", + Seq: 12345, + Time: time.Now().Format(time.RFC3339), + }, + } + + err = consumer.HandleIdentityEventPublic(ctx, &invalidEvent2) + if err == nil { + t.Error("expected error for missing handle, got nil") + } + + // Missing identity data + invalidEvent3 := jetstream.JetstreamEvent{ + Did: "did:plc:invalid2", + Kind: "identity", + Identity: nil, + } + + err = consumer.HandleIdentityEventPublic(ctx, &invalidEvent3) + if err == nil { + t.Error("expected error for nil identity data, got nil") + } + }) +} + +func TestUserServiceIdempotency(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + userRepo := postgres.NewUserRepository(db) + userService := users.NewUserService(userRepo, "http://localhost:3001") + ctx := context.Background() + + t.Run("CreateUser is idempotent for duplicate DID", func(t *testing.T) { + req := users.CreateUserRequest{ + DID: "did:plc:idempotent123", + Handle: "idempotent.test", + PDSURL: "https://bsky.social", + } + + // First creation + user1, err := userService.CreateUser(ctx, req) + if err != nil { + t.Fatalf("first creation failed: %v", err) + } + + // Second creation with same DID - should return existing user, not error + user2, err := userService.CreateUser(ctx, req) + if err != nil { + t.Fatalf("second creation should be idempotent: %v", err) + } + + if user1.DID != user2.DID { + t.Errorf("expected same DID, got %s and %s", user1.DID, user2.DID) + } + + if user1.CreatedAt != user2.CreatedAt { + t.Errorf("expected same user (same created_at), got different timestamps") + } + }) + + t.Run("CreateUser fails for duplicate handle with different DID", func(t *testing.T) { + // Create first user + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ + DID: "did:plc:handleconflict1", + Handle: "conflicting.handle", + PDSURL: "https://bsky.social", + }) + if err != nil { + t.Fatalf("first creation failed: %v", err) + } + + // Try to create different user with same handle + _, err = userService.CreateUser(ctx, users.CreateUserRequest{ + DID: "did:plc:handleconflict2", + Handle: "conflicting.handle", // Same handle, different DID + PDSURL: "https://bsky.social", + }) + + if err == nil { + t.Fatal("expected error for duplicate handle, got nil") + } + + if !contains(err.Error(), "handle already taken") { + t.Errorf("expected 'handle already taken' error, got: %v", err) + } + }) +} + +// Helper function +func contains(s, substr string) bool { + return len(s) >= len(substr) && anySubstring(s, substr) +} + +func anySubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}