// +build integration package main import ( "bytes" "database/sql" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "time" _ "github.com/lib/pq" ) // Test database configuration - use test schema to isolate from dev/prod var testDB *sql.DB var testServer *httptest.Server func TestMain(m *testing.M) { // Setup if err := setupTestEnvironment(); err != nil { fmt.Printf("Failed to setup test environment: %v\n", err) os.Exit(1) } // Run tests code := m.Run() // Cleanup cleanupTestEnvironment() os.Exit(code) } func setupTestEnvironment() error { // Set required environment variables os.Setenv("JWT_SECRET", "test-jwt-secret-minimum-64-characters-long-for-security-purposes-in-testing") os.Setenv("DB_SSL_MODE", "disable") os.Setenv("CORS_ALLOW_ORIGIN", "*") // Use env vars or defaults (for local testing with podman-compose) dbUser := os.Getenv("TEST_DB_USER") dbPassword := os.Getenv("TEST_DB_PASSWORD") dbName := os.Getenv("TEST_DB_NAME") dbHost := os.Getenv("TEST_DB_HOST") if dbUser == "" { dbUser = "user" } if dbPassword == "" { dbPassword = "password" } if dbName == "" { dbName = "coppertone_db" } if dbHost == "" { dbHost = "localhost" } os.Setenv("DB_USER", dbUser) os.Setenv("DB_PASSWORD", dbPassword) os.Setenv("DB_NAME", dbName) os.Setenv("DB_HOST", dbHost) // First connect WITHOUT schema to create the testing schema os.Setenv("DB_SCHEMA", "") // Create testing schema before initializing with it connStr := fmt.Sprintf("user=%s password=%s dbname=%s host=%s sslmode=disable", dbUser, dbPassword, dbName, dbHost) tempDB, err := sql.Open("postgres", connStr) if err != nil { return fmt.Errorf("failed to connect to database: %v", err) } defer tempDB.Close() // Create testing schema if not exists _, err = tempDB.Exec(`CREATE SCHEMA IF NOT EXISTS testing`) if err != nil { return fmt.Errorf("failed to create testing schema: %v", err) } // Now set the schema and initialize os.Setenv("DB_SCHEMA", "testing") // Load config loadConfig() // Initialize database with testing schema testDB = initDB() if testDB == nil { return fmt.Errorf("failed to initialize database") } // Set the global db db = testDB // Clean up any existing test data cleanupTestData() // Create test tables if err := createTestTables(); err != nil { return fmt.Errorf("failed to create test tables: %v", err) } return nil } func cleanupTestEnvironment() { if testDB != nil { cleanupTestData() testDB.Close() } } func cleanupTestData() { if testDB == nil { return } // Clean tables in reverse order of dependencies testDB.Exec("DELETE FROM superuser_transfers") testDB.Exec("DELETE FROM csrf_tokens") testDB.Exec("DELETE FROM refresh_tokens") testDB.Exec("DELETE FROM user_roles") testDB.Exec("DELETE FROM identities") testDB.Exec("DELETE FROM users") // Reset rate limiters for tests if loginLimiter != nil { loginLimiter = &rateLimiter{attempts: make(map[string]*attemptInfo)} } if registerLimiter != nil { registerLimiter = &rateLimiter{attempts: make(map[string]*attemptInfo)} } } func createTestTables() error { tables := []string{ `CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(254) UNIQUE, is_initial_superuser BOOLEAN DEFAULT FALSE, is_protected BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW() )`, `CREATE TABLE IF NOT EXISTS identities ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, type VARCHAR(50) NOT NULL, identifier VARCHAR(255) NOT NULL, credential TEXT, is_primary_login BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(type, identifier) )`, `CREATE TABLE IF NOT EXISTS user_roles ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, role VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(user_id, role) )`, `CREATE TABLE IF NOT EXISTS refresh_tokens ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL, expires_at TIMESTAMP NOT NULL, client_ip VARCHAR(45), created_at TIMESTAMP DEFAULT NOW(), revoked_at TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS csrf_tokens ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL, expires_at TIMESTAMP NOT NULL, client_ip VARCHAR(45), created_at TIMESTAMP DEFAULT NOW() )`, `CREATE TABLE IF NOT EXISTS superuser_transfers ( id SERIAL PRIMARY KEY, from_user_id INTEGER REFERENCES users(id), to_user_id INTEGER REFERENCES users(id), reason TEXT, created_at TIMESTAMP DEFAULT NOW() )`, } for _, table := range tables { if _, err := testDB.Exec(table); err != nil { return fmt.Errorf("failed to create table: %v\nSQL: %s", err, table) } } return nil } // Helper function to make HTTP requests func makeRequest(method, path string, body interface{}, headers map[string]string) (*http.Response, []byte, error) { var reqBody io.Reader if body != nil { jsonBody, err := json.Marshal(body) if err != nil { return nil, nil, err } reqBody = bytes.NewReader(jsonBody) } req := httptest.NewRequest(method, path, reqBody) req.Header.Set("Content-Type", "application/json") for k, v := range headers { req.Header.Set(k, v) } rr := httptest.NewRecorder() // Route to appropriate handler handler := getHandler(path) if handler == nil { return nil, nil, fmt.Errorf("no handler for path: %s", path) } handler(rr, req) respBody, _ := io.ReadAll(rr.Body) return rr.Result(), respBody, nil } func getHandler(path string) http.HandlerFunc { switch { case path == "/register-email-password": return handleRegisterEmailPassword case path == "/register-blockchain": return handleRegisterBlockchain case path == "/login-email-password": return handleLoginEmailPassword case path == "/login-blockchain": return handleLoginBlockchain case path == "/auth/refresh": return handleRefreshToken case path == "/auth/logout": return handleLogout case path == "/auth/logout-all": return authenticate(requireCSRF(handleLogoutAll)) case path == "/profile": return authenticate(handleProfile) case path == "/identities": return authenticate(handleGetIdentities) case path == "/link-identity": return authenticate(requireCSRF(requireRole(handleLinkIdentity, "CLIENT", "STAFF", "ADMIN"))) case path == "/unlink-identity": return authenticate(requireCSRF(requireRole(handleUnlinkIdentity, "CLIENT", "STAFF", "ADMIN"))) case path == "/admin/users": return authenticate(requireRole(handleGetAllUsers, "ADMIN", "SUPERUSER")) case path == "/admin/users/promote-role": return authenticate(requireCSRF(requireRole(handlePromoteUserRole, "ADMIN", "SUPERUSER"))) case path == "/admin/users/demote-role": return authenticate(requireCSRF(requireRole(handleDemoteUserRole, "ADMIN", "SUPERUSER"))) case path == "/superuser/promote": return authenticate(requireCSRF(requireRole(handlePromoteSuperuser, "SUPERUSER"))) case path == "/superuser/demote": return authenticate(requireCSRF(requireRole(handleDemoteSuperuser, "SUPERUSER"))) case path == "/superuser/transfer": return authenticate(requireCSRF(requireRole(handleTransferInitialSuperuser, "SUPERUSER"))) case path == "/healthz": return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintln(w, "ok") } default: return nil } } // ==================== REGISTRATION TESTS ==================== func TestRegisterEmailPassword_Success(t *testing.T) { cleanupTestData() payload := map[string]string{ "email": "test@example.com", "password": "TestPassword123", "name": "Test User", } resp, body, err := makeRequest("POST", "/register-email-password", payload, nil) if err != nil { t.Fatalf("Request failed: %v", err) } if resp.StatusCode != http.StatusCreated { t.Errorf("Expected status 201, got %d: %s", resp.StatusCode, string(body)) } var result map[string]interface{} json.Unmarshal(body, &result) if result["userId"] == nil { t.Error("Expected userId in response") } // Verify user was created in database var count int testDB.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", "test@example.com").Scan(&count) if count != 1 { t.Errorf("Expected 1 user, found %d", count) } } func TestRegisterEmailPassword_FirstUserIsSuperuser(t *testing.T) { cleanupTestData() payload := map[string]string{ "email": "admin@example.com", "password": "AdminPassword123", "name": "Admin User", } resp, _, err := makeRequest("POST", "/register-email-password", payload, nil) if err != nil { t.Fatalf("Request failed: %v", err) } if resp.StatusCode != http.StatusCreated { t.Errorf("Expected status 201, got %d", resp.StatusCode) } // Verify first user got SUPERUSER role var role string err = testDB.QueryRow(` SELECT r.role FROM user_roles r JOIN users u ON u.id = r.user_id WHERE u.email = $1 `, "admin@example.com").Scan(&role) if err != nil { t.Fatalf("Failed to query role: %v", err) } if role != "SUPERUSER" { t.Errorf("Expected first user to have SUPERUSER role, got %s", role) } // Verify is_initial_superuser flag var isInitialSU bool testDB.QueryRow("SELECT is_initial_superuser FROM users WHERE email = $1", "admin@example.com").Scan(&isInitialSU) if !isInitialSU { t.Error("Expected first user to have is_initial_superuser = true") } } func TestRegisterEmailPassword_DuplicateEmail(t *testing.T) { cleanupTestData() payload := map[string]string{ "email": "duplicate@example.com", "password": "TestPassword123", "name": "Test User", } // First registration makeRequest("POST", "/register-email-password", payload, nil) // Second registration with same email resp, body, _ := makeRequest("POST", "/register-email-password", payload, nil) if resp.StatusCode != http.StatusConflict { t.Errorf("Expected status 409 Conflict, got %d: %s", resp.StatusCode, string(body)) } } func TestRegisterEmailPassword_InvalidEmail(t *testing.T) { cleanupTestData() testCases := []struct { email string desc string }{ {"", "empty email"}, {"notanemail", "invalid format"}, {"test@", "missing domain"}, {"@example.com", "missing local part"}, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { payload := map[string]string{ "email": tc.email, "password": "TestPassword123", "name": "Test User", } resp, _, _ := makeRequest("POST", "/register-email-password", payload, nil) if resp.StatusCode != http.StatusBadRequest { t.Errorf("Expected 400 for %s, got %d", tc.desc, resp.StatusCode) } }) } } func TestRegisterEmailPassword_WeakPassword(t *testing.T) { cleanupTestData() testCases := []struct { password string desc string }{ {"short", "too short"}, {"alllowercase123", "no uppercase"}, {"ALLUPPERCASE123", "no lowercase"}, {"NoNumbersHere", "no numbers"}, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { payload := map[string]string{ "email": "test@example.com", "password": tc.password, "name": "Test User", } resp, _, _ := makeRequest("POST", "/register-email-password", payload, nil) if resp.StatusCode != http.StatusBadRequest { t.Errorf("Expected 400 for %s, got %d", tc.desc, resp.StatusCode) } }) } } // ==================== LOGIN TESTS ==================== func TestLoginEmailPassword_Success(t *testing.T) { cleanupTestData() // First register a user regPayload := map[string]string{ "email": "login@example.com", "password": "LoginPassword123", "name": "Login User", } makeRequest("POST", "/register-email-password", regPayload, nil) // Now login loginPayload := map[string]string{ "email": "login@example.com", "password": "LoginPassword123", } resp, body, err := makeRequest("POST", "/login-email-password", loginPayload, nil) if err != nil { t.Fatalf("Request failed: %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Expected status 200, got %d: %s", resp.StatusCode, string(body)) } var result AuthTokenResponse if err := json.Unmarshal(body, &result); err != nil { t.Fatalf("Failed to parse response: %v", err) } if result.AccessToken == "" { t.Error("Expected accessToken in response") } if result.RefreshToken == "" { t.Error("Expected refreshToken in response") } if result.CsrfToken == "" { t.Error("Expected csrfToken in response") } if result.TokenType != "Bearer" { t.Errorf("Expected tokenType 'Bearer', got %s", result.TokenType) } } func TestLoginEmailPassword_InvalidCredentials(t *testing.T) { cleanupTestData() // Register a user regPayload := map[string]string{ "email": "test@example.com", "password": "CorrectPassword123", "name": "Test User", } makeRequest("POST", "/register-email-password", regPayload, nil) testCases := []struct { email string password string desc string }{ {"test@example.com", "WrongPassword123", "wrong password"}, {"nonexistent@example.com", "CorrectPassword123", "non-existent user"}, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { payload := map[string]string{ "email": tc.email, "password": tc.password, } resp, body, _ := makeRequest("POST", "/login-email-password", payload, nil) if resp.StatusCode != http.StatusUnauthorized { t.Errorf("Expected 401 for %s, got %d: %s", tc.desc, resp.StatusCode, string(body)) } }) } } // ==================== TOKEN REFRESH TESTS ==================== func TestRefreshToken_Success(t *testing.T) { cleanupTestData() // Register and login regPayload := map[string]string{ "email": "refresh@example.com", "password": "RefreshPassword123", "name": "Refresh User", } makeRequest("POST", "/register-email-password", regPayload, nil) loginPayload := map[string]string{ "email": "refresh@example.com", "password": "RefreshPassword123", } _, loginBody, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var loginResult AuthTokenResponse json.Unmarshal(loginBody, &loginResult) // Wait a moment to ensure different token time.Sleep(100 * time.Millisecond) // Refresh token refreshPayload := map[string]string{ "refreshToken": loginResult.RefreshToken, } resp, body, err := makeRequest("POST", "/auth/refresh", refreshPayload, nil) if err != nil { t.Fatalf("Request failed: %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Expected status 200, got %d: %s", resp.StatusCode, string(body)) } var refreshResult AuthTokenResponse json.Unmarshal(body, &refreshResult) if refreshResult.AccessToken == "" { t.Error("Expected new accessToken") } if refreshResult.RefreshToken == "" { t.Error("Expected new refreshToken") } } func TestRefreshToken_InvalidToken(t *testing.T) { cleanupTestData() payload := map[string]string{ "refreshToken": "invalid-token-12345", } resp, _, _ := makeRequest("POST", "/auth/refresh", payload, nil) if resp.StatusCode != http.StatusUnauthorized { t.Errorf("Expected 401, got %d", resp.StatusCode) } } // ==================== LOGOUT TESTS ==================== func TestLogout_Success(t *testing.T) { cleanupTestData() // Register and login regPayload := map[string]string{ "email": "logout@example.com", "password": "LogoutPassword123", "name": "Logout User", } makeRequest("POST", "/register-email-password", regPayload, nil) loginPayload := map[string]string{ "email": "logout@example.com", "password": "LogoutPassword123", } _, loginBody, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var loginResult AuthTokenResponse json.Unmarshal(loginBody, &loginResult) // Logout logoutPayload := map[string]string{ "refreshToken": loginResult.RefreshToken, } resp, body, _ := makeRequest("POST", "/auth/logout", logoutPayload, nil) if resp.StatusCode != http.StatusOK { t.Errorf("Expected 200, got %d: %s", resp.StatusCode, string(body)) } // Verify refresh token is revoked (can't use it again) resp2, _, _ := makeRequest("POST", "/auth/refresh", logoutPayload, nil) if resp2.StatusCode != http.StatusUnauthorized { t.Error("Expected refresh to fail after logout") } } // ==================== PROFILE TESTS ==================== func TestProfile_Success(t *testing.T) { cleanupTestData() // Register and login regPayload := map[string]string{ "email": "profile@example.com", "password": "ProfilePassword123", "name": "Profile User", } regResp, _, _ := makeRequest("POST", "/register-email-password", regPayload, nil) if regResp.StatusCode != http.StatusCreated { t.Fatalf("Registration failed with status %d", regResp.StatusCode) } loginPayload := map[string]string{ "email": "profile@example.com", "password": "ProfilePassword123", } loginResp, loginBody, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) if loginResp.StatusCode != http.StatusOK { t.Fatalf("Login failed with status %d: %s", loginResp.StatusCode, string(loginBody)) } var loginResult AuthTokenResponse if err := json.Unmarshal(loginBody, &loginResult); err != nil { t.Fatalf("Failed to parse login response: %v", err) } if loginResult.AccessToken == "" { t.Fatal("Access token is empty") } // Get profile headers := map[string]string{ "Authorization": "Bearer " + loginResult.AccessToken, } resp, body, _ := makeRequest("GET", "/profile", nil, headers) if resp.StatusCode != http.StatusOK { t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) } var profile User if err := json.Unmarshal(body, &profile); err != nil { t.Fatalf("Failed to parse profile: %v", err) } if profile.Name != "Profile User" { t.Errorf("Expected name 'Profile User', got '%s'", profile.Name) } if profile.Email == nil { t.Error("Expected email to be set") } else if *profile.Email != "profile@example.com" { t.Errorf("Expected email 'profile@example.com', got '%s'", *profile.Email) } } func TestProfile_Unauthorized(t *testing.T) { cleanupTestData() // Try without token resp, _, _ := makeRequest("GET", "/profile", nil, nil) if resp.StatusCode != http.StatusUnauthorized { t.Errorf("Expected 401 without token, got %d", resp.StatusCode) } // Try with invalid token headers := map[string]string{ "Authorization": "Bearer invalid-token", } resp2, _, _ := makeRequest("GET", "/profile", nil, headers) if resp2.StatusCode != http.StatusUnauthorized { t.Errorf("Expected 401 with invalid token, got %d", resp2.StatusCode) } } // ==================== RATE LIMITING TESTS ==================== func TestRateLimit_Login(t *testing.T) { cleanupTestData() // Reset rate limiter loginLimiter = &rateLimiter{attempts: make(map[string]*attemptInfo)} // Register a user regPayload := map[string]string{ "email": "ratelimit@example.com", "password": "RateLimitPassword123", "name": "Rate Limit User", } makeRequest("POST", "/register-email-password", regPayload, nil) // Make many failed login attempts wrongPayload := map[string]string{ "email": "ratelimit@example.com", "password": "WrongPassword123", } var lastStatus int for i := 0; i < 10; i++ { resp, _, _ := makeRequest("POST", "/login-email-password", wrongPayload, nil) lastStatus = resp.StatusCode if resp.StatusCode == http.StatusTooManyRequests { // Rate limited - expected return } } t.Errorf("Expected rate limiting after multiple failed attempts, last status: %d", lastStatus) } // ==================== SECOND USER IS CLIENT TESTS ==================== func TestSecondUserIsClient(t *testing.T) { cleanupTestData() // First user (will be SUPERUSER) firstPayload := map[string]string{ "email": "first@example.com", "password": "FirstPassword123", "name": "First User", } makeRequest("POST", "/register-email-password", firstPayload, nil) // Second user (should be CLIENT) secondPayload := map[string]string{ "email": "second@example.com", "password": "SecondPassword123", "name": "Second User", } makeRequest("POST", "/register-email-password", secondPayload, nil) // Check second user's role var role string err := testDB.QueryRow(` SELECT r.role FROM user_roles r JOIN users u ON u.id = r.user_id WHERE u.email = $1 `, "second@example.com").Scan(&role) if err != nil { t.Fatalf("Failed to query role: %v", err) } if role != "CLIENT" { t.Errorf("Expected second user to have CLIENT role, got %s", role) } } // ==================== HEALTHCHECK TEST ==================== func TestHealthcheck(t *testing.T) { resp, body, err := makeRequest("GET", "/healthz", nil, nil) if err != nil { t.Fatalf("Request failed: %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Expected 200, got %d", resp.StatusCode) } if !strings.Contains(string(body), "ok") { t.Errorf("Expected 'ok' in response, got: %s", string(body)) } } // ==================== BLOCKCHAIN REGISTRATION TESTS ==================== func TestRegisterBlockchain_Success(t *testing.T) { cleanupTestData() // Use a valid Ethereum address format payload := map[string]string{ "blockchainAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f4aB23", "name": "Blockchain User", } resp, body, err := makeRequest("POST", "/register-blockchain", payload, nil) if err != nil { t.Fatalf("Request failed: %v", err) } if resp.StatusCode != http.StatusCreated { t.Errorf("Expected status 201, got %d: %s", resp.StatusCode, string(body)) } var result map[string]interface{} json.Unmarshal(body, &result) if result["userId"] == nil { t.Error("Expected userId in response") } // Verify identity was created var count int testDB.QueryRow("SELECT COUNT(*) FROM identities WHERE type = 'blockchain' AND identifier = $1", strings.ToLower("0x742d35Cc6634C0532925a3b844Bc9e7595f4aB23")).Scan(&count) if count != 1 { t.Errorf("Expected 1 blockchain identity, found %d", count) } } func TestRegisterBlockchain_FirstUserIsSuperuser(t *testing.T) { cleanupTestData() payload := map[string]string{ "blockchainAddress": "0x1234567890123456789012345678901234567890", "name": "First Blockchain User", } resp, _, err := makeRequest("POST", "/register-blockchain", payload, nil) if err != nil { t.Fatalf("Request failed: %v", err) } if resp.StatusCode != http.StatusCreated { t.Errorf("Expected status 201, got %d", resp.StatusCode) } // Verify first user got SUPERUSER role var role string err = testDB.QueryRow(` SELECT r.role FROM user_roles r JOIN identities i ON i.user_id = r.user_id WHERE i.identifier = $1 `, strings.ToLower("0x1234567890123456789012345678901234567890")).Scan(&role) if err != nil { t.Fatalf("Failed to query role: %v", err) } if role != "SUPERUSER" { t.Errorf("Expected first blockchain user to have SUPERUSER role, got %s", role) } } func TestRegisterBlockchain_DuplicateAddress(t *testing.T) { cleanupTestData() payload := map[string]string{ "blockchainAddress": "0xDuplicateAddress12345678901234567890Dup", "name": "First User", } // First registration makeRequest("POST", "/register-blockchain", payload, nil) // Second registration with same address resp, body, _ := makeRequest("POST", "/register-blockchain", payload, nil) if resp.StatusCode != http.StatusConflict { t.Errorf("Expected status 409 Conflict, got %d: %s", resp.StatusCode, string(body)) } } func TestRegisterBlockchain_InvalidAddress(t *testing.T) { cleanupTestData() testCases := []struct { address string desc string }{ {"", "empty address"}, {"short", "too short"}, {"0x", "just prefix"}, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { payload := map[string]string{ "blockchainAddress": tc.address, "name": "Test User", } resp, _, _ := makeRequest("POST", "/register-blockchain", payload, nil) if resp.StatusCode != http.StatusBadRequest { t.Errorf("Expected 400 for %s, got %d", tc.desc, resp.StatusCode) } }) } } func TestRegisterBlockchain_MissingName(t *testing.T) { cleanupTestData() payload := map[string]string{ "blockchainAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f4aB23", } resp, _, _ := makeRequest("POST", "/register-blockchain", payload, nil) if resp.StatusCode != http.StatusBadRequest { t.Errorf("Expected 400 for missing name, got %d", resp.StatusCode) } } // ==================== BLOCKCHAIN LOGIN TESTS ==================== func TestLoginBlockchain_Success(t *testing.T) { cleanupTestData() // Register blockchain user regPayload := map[string]string{ "blockchainAddress": "0xLoginTest12345678901234567890123456789012", "name": "Blockchain Login User", } makeRequest("POST", "/register-blockchain", regPayload, nil) // Login - for test purposes, we skip signature verification or use mock loginPayload := map[string]string{ "blockchainAddress": "0xLoginTest12345678901234567890123456789012", "signature": "test-signature", "message": "test-message", } resp, body, err := makeRequest("POST", "/login-blockchain", loginPayload, nil) if err != nil { t.Fatalf("Request failed: %v", err) } // Check response - blockchain login may require valid signature // This tests the endpoint exists and processes requests if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusUnauthorized { t.Errorf("Expected 200 or 401, got %d: %s", resp.StatusCode, string(body)) } } func TestLoginBlockchain_NonExistentAddress(t *testing.T) { cleanupTestData() payload := map[string]string{ "blockchainAddress": "0xNonExistent123456789012345678901234567890", "signature": "test-signature", "message": "test-message", } resp, _, _ := makeRequest("POST", "/login-blockchain", payload, nil) if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusNotFound { t.Errorf("Expected 401 or 404 for non-existent address, got %d", resp.StatusCode) } } func TestLoginBlockchain_MissingFields(t *testing.T) { cleanupTestData() testCases := []struct { payload map[string]string desc string }{ {map[string]string{"signature": "sig", "message": "msg"}, "missing address"}, {map[string]string{"blockchainAddress": "0x123"}, "missing signature and message"}, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { resp, _, _ := makeRequest("POST", "/login-blockchain", tc.payload, nil) if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized { t.Errorf("Expected 400 or 401 for %s, got %d", tc.desc, resp.StatusCode) } }) } } // ==================== IDENTITY MANAGEMENT TESTS ==================== func TestGetIdentities_Success(t *testing.T) { cleanupTestData() // Register and login regPayload := map[string]string{ "email": "identities@example.com", "password": "IdentitiesPassword123", "name": "Identity User", } makeRequest("POST", "/register-email-password", regPayload, nil) loginPayload := map[string]string{ "email": "identities@example.com", "password": "IdentitiesPassword123", } _, loginBody, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var loginResult AuthTokenResponse json.Unmarshal(loginBody, &loginResult) // Get identities headers := map[string]string{ "Authorization": "Bearer " + loginResult.AccessToken, } resp, body, _ := makeRequest("GET", "/identities", nil, headers) if resp.StatusCode != http.StatusOK { t.Errorf("Expected 200, got %d: %s", resp.StatusCode, string(body)) } var identities []Identity if err := json.Unmarshal(body, &identities); err != nil { t.Fatalf("Failed to parse identities: %v", err) } // Should have at least one email identity hasEmailIdentity := false for _, id := range identities { if id.Type == "email" { hasEmailIdentity = true break } } if !hasEmailIdentity { t.Error("Expected email identity in response") } } func TestGetIdentities_Unauthorized(t *testing.T) { cleanupTestData() resp, _, _ := makeRequest("GET", "/identities", nil, nil) if resp.StatusCode != http.StatusUnauthorized { t.Errorf("Expected 401, got %d", resp.StatusCode) } } func TestLinkIdentity_Success(t *testing.T) { cleanupTestData() // Register with email regPayload := map[string]string{ "email": "link@example.com", "password": "LinkPassword123", "name": "Link User", } makeRequest("POST", "/register-email-password", regPayload, nil) loginPayload := map[string]string{ "email": "link@example.com", "password": "LinkPassword123", } _, loginBody, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var loginResult AuthTokenResponse json.Unmarshal(loginBody, &loginResult) // Link blockchain identity linkPayload := map[string]string{ "type": "blockchain", "identifier": "0xLinkedAddress123456789012345678901234567890", } headers := map[string]string{ "Authorization": "Bearer " + loginResult.AccessToken, "X-CSRF-Token": loginResult.CsrfToken, } resp, body, _ := makeRequest("POST", "/link-identity", linkPayload, headers) // Check response - may succeed or fail depending on CSRF validation if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusForbidden { t.Errorf("Expected 200, 201, or 403, got %d: %s", resp.StatusCode, string(body)) } } func TestLinkIdentity_DuplicateIdentity(t *testing.T) { cleanupTestData() // Create first user with email reg1 := map[string]string{ "email": "user1@example.com", "password": "User1Password123", "name": "User 1", } makeRequest("POST", "/register-email-password", reg1, nil) // Create second user with blockchain reg2 := map[string]string{ "blockchainAddress": "0xExistingAddress12345678901234567890123456", "name": "User 2", } makeRequest("POST", "/register-blockchain", reg2, nil) // Login as user1 login1 := map[string]string{ "email": "user1@example.com", "password": "User1Password123", } _, loginBody, _ := makeRequest("POST", "/login-email-password", login1, nil) var loginResult AuthTokenResponse json.Unmarshal(loginBody, &loginResult) // Try to link the existing blockchain address to user1 linkPayload := map[string]string{ "type": "blockchain", "identifier": "0xExistingAddress12345678901234567890123456", } headers := map[string]string{ "Authorization": "Bearer " + loginResult.AccessToken, "X-CSRF-Token": loginResult.CsrfToken, } resp, _, _ := makeRequest("POST", "/link-identity", linkPayload, headers) // Should fail as identity is already linked to another user if resp.StatusCode != http.StatusConflict && resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusForbidden { t.Errorf("Expected 409, 400, or 403 for duplicate identity, got %d", resp.StatusCode) } } func TestUnlinkIdentity_Success(t *testing.T) { cleanupTestData() // Register with email regPayload := map[string]string{ "email": "unlink@example.com", "password": "UnlinkPassword123", "name": "Unlink User", } makeRequest("POST", "/register-email-password", regPayload, nil) // Login loginPayload := map[string]string{ "email": "unlink@example.com", "password": "UnlinkPassword123", } _, loginBody, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var loginResult AuthTokenResponse json.Unmarshal(loginBody, &loginResult) // First link a blockchain identity linkPayload := map[string]string{ "type": "blockchain", "identifier": "0xToUnlink123456789012345678901234567890123", } headers := map[string]string{ "Authorization": "Bearer " + loginResult.AccessToken, "X-CSRF-Token": loginResult.CsrfToken, } makeRequest("POST", "/link-identity", linkPayload, headers) // Now unlink it unlinkPayload := map[string]string{ "type": "blockchain", "identifier": "0xToUnlink123456789012345678901234567890123", } resp, body, _ := makeRequest("POST", "/unlink-identity", unlinkPayload, headers) // Should succeed or fail gracefully if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusBadRequest { t.Errorf("Expected 200, 400, or 403, got %d: %s", resp.StatusCode, string(body)) } } func TestUnlinkIdentity_CannotUnlinkLastIdentity(t *testing.T) { cleanupTestData() // Register with email only regPayload := map[string]string{ "email": "lastidentity@example.com", "password": "LastIdentityPassword123", "name": "Last Identity User", } makeRequest("POST", "/register-email-password", regPayload, nil) // Login loginPayload := map[string]string{ "email": "lastidentity@example.com", "password": "LastIdentityPassword123", } _, loginBody, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var loginResult AuthTokenResponse json.Unmarshal(loginBody, &loginResult) // Try to unlink the only identity unlinkPayload := map[string]string{ "type": "email", "identifier": "lastidentity@example.com", } headers := map[string]string{ "Authorization": "Bearer " + loginResult.AccessToken, "X-CSRF-Token": loginResult.CsrfToken, } resp, _, _ := makeRequest("POST", "/unlink-identity", unlinkPayload, headers) // Should fail - can't unlink last identity if resp.StatusCode == http.StatusOK { t.Error("Should not be able to unlink last identity") } } // ==================== LOGOUT ALL TESTS ==================== func TestLogoutAll_Success(t *testing.T) { cleanupTestData() // Register regPayload := map[string]string{ "email": "logoutall@example.com", "password": "LogoutAllPassword123", "name": "Logout All User", } makeRequest("POST", "/register-email-password", regPayload, nil) // Login multiple times to create multiple sessions loginPayload := map[string]string{ "email": "logoutall@example.com", "password": "LogoutAllPassword123", } // First session _, loginBody1, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var session1 AuthTokenResponse json.Unmarshal(loginBody1, &session1) // Second session _, loginBody2, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var session2 AuthTokenResponse json.Unmarshal(loginBody2, &session2) // Logout all using first session headers := map[string]string{ "Authorization": "Bearer " + session1.AccessToken, "X-CSRF-Token": session1.CsrfToken, } resp, body, _ := makeRequest("POST", "/auth/logout-all", nil, headers) // Check result if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden { t.Errorf("Expected 200 or 403, got %d: %s", resp.StatusCode, string(body)) } // If successful, both refresh tokens should be revoked if resp.StatusCode == http.StatusOK { // Try to refresh with second session refreshPayload := map[string]string{ "refreshToken": session2.RefreshToken, } resp2, _, _ := makeRequest("POST", "/auth/refresh", refreshPayload, nil) if resp2.StatusCode != http.StatusUnauthorized { t.Error("Second session should be revoked after logout-all") } } } // ==================== ADMIN ENDPOINT TESTS ==================== func getAdminLogin(t *testing.T) AuthTokenResponse { t.Helper() cleanupTestData() // First user is SUPERUSER (which has admin privileges) regPayload := map[string]string{ "email": "admin@example.com", "password": "AdminPassword123", "name": "Admin User", } makeRequest("POST", "/register-email-password", regPayload, nil) loginPayload := map[string]string{ "email": "admin@example.com", "password": "AdminPassword123", } _, loginBody, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var result AuthTokenResponse json.Unmarshal(loginBody, &result) return result } func getClientLogin(t *testing.T) AuthTokenResponse { t.Helper() // Second user is CLIENT regPayload := map[string]string{ "email": "client@example.com", "password": "ClientPassword123", "name": "Client User", } makeRequest("POST", "/register-email-password", regPayload, nil) loginPayload := map[string]string{ "email": "client@example.com", "password": "ClientPassword123", } _, loginBody, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var result AuthTokenResponse json.Unmarshal(loginBody, &result) return result } func TestAdminGetUsers_Success(t *testing.T) { adminToken := getAdminLogin(t) headers := map[string]string{ "Authorization": "Bearer " + adminToken.AccessToken, } resp, body, _ := makeRequest("GET", "/admin/users", nil, headers) if resp.StatusCode != http.StatusOK { t.Errorf("Expected 200, got %d: %s", resp.StatusCode, string(body)) } } func TestAdminGetUsers_Unauthorized(t *testing.T) { getAdminLogin(t) clientToken := getClientLogin(t) // Client should not have admin access headers := map[string]string{ "Authorization": "Bearer " + clientToken.AccessToken, } resp, _, _ := makeRequest("GET", "/admin/users", nil, headers) if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusUnauthorized { t.Errorf("Expected 403 or 401, got %d", resp.StatusCode) } } func TestAdminPromoteRole_Success(t *testing.T) { adminToken := getAdminLogin(t) getClientLogin(t) // Get client user ID var clientID int testDB.QueryRow("SELECT id FROM users WHERE email = $1", "client@example.com").Scan(&clientID) // Promote client to STAFF promotePayload := map[string]interface{}{ "userId": clientID, "role": "STAFF", } headers := map[string]string{ "Authorization": "Bearer " + adminToken.AccessToken, "X-CSRF-Token": adminToken.CsrfToken, } resp, body, _ := makeRequest("POST", "/admin/users/promote-role", promotePayload, headers) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden { t.Errorf("Expected 200 or 403, got %d: %s", resp.StatusCode, string(body)) } // Verify if promotion succeeded if resp.StatusCode == http.StatusOK { var role string err := testDB.QueryRow("SELECT role FROM user_roles WHERE user_id = $1 AND role = 'STAFF'", clientID).Scan(&role) if err != nil { t.Error("Client should have STAFF role after promotion") } } } func TestAdminDemoteRole_Success(t *testing.T) { adminToken := getAdminLogin(t) getClientLogin(t) // First promote to STAFF var clientID int testDB.QueryRow("SELECT id FROM users WHERE email = $1", "client@example.com").Scan(&clientID) // Add STAFF role directly testDB.Exec("INSERT INTO user_roles (user_id, role) VALUES ($1, 'STAFF') ON CONFLICT DO NOTHING", clientID) // Now demote from STAFF demotePayload := map[string]interface{}{ "userId": clientID, "role": "STAFF", } headers := map[string]string{ "Authorization": "Bearer " + adminToken.AccessToken, "X-CSRF-Token": adminToken.CsrfToken, } resp, body, _ := makeRequest("POST", "/admin/users/demote-role", demotePayload, headers) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden { t.Errorf("Expected 200 or 403, got %d: %s", resp.StatusCode, string(body)) } } func TestAdminPromoteRole_ClientCannotPromote(t *testing.T) { getAdminLogin(t) clientToken := getClientLogin(t) promotePayload := map[string]interface{}{ "userId": 1, "role": "STAFF", } headers := map[string]string{ "Authorization": "Bearer " + clientToken.AccessToken, "X-CSRF-Token": clientToken.CsrfToken, } resp, _, _ := makeRequest("POST", "/admin/users/promote-role", promotePayload, headers) if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusUnauthorized { t.Errorf("Expected 403 or 401, got %d", resp.StatusCode) } } // ==================== SUPERUSER ENDPOINT TESTS ==================== func TestSuperuserPromote_Success(t *testing.T) { adminToken := getAdminLogin(t) getClientLogin(t) var clientID int testDB.QueryRow("SELECT id FROM users WHERE email = $1", "client@example.com").Scan(&clientID) // Promote client to ADMIN (superuser can promote to any role including ADMIN) promotePayload := map[string]interface{}{ "userId": clientID, "role": "ADMIN", } headers := map[string]string{ "Authorization": "Bearer " + adminToken.AccessToken, "X-CSRF-Token": adminToken.CsrfToken, } resp, body, _ := makeRequest("POST", "/superuser/promote", promotePayload, headers) // Superuser endpoint may succeed or require CSRF if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden { t.Errorf("Expected 200 or 403, got %d: %s", resp.StatusCode, string(body)) } } func TestSuperuserDemote_Success(t *testing.T) { adminToken := getAdminLogin(t) getClientLogin(t) var clientID int testDB.QueryRow("SELECT id FROM users WHERE email = $1", "client@example.com").Scan(&clientID) // Add ADMIN role to client testDB.Exec("INSERT INTO user_roles (user_id, role) VALUES ($1, 'ADMIN') ON CONFLICT DO NOTHING", clientID) // Demote from ADMIN demotePayload := map[string]interface{}{ "userId": clientID, "role": "ADMIN", } headers := map[string]string{ "Authorization": "Bearer " + adminToken.AccessToken, "X-CSRF-Token": adminToken.CsrfToken, } resp, body, _ := makeRequest("POST", "/superuser/demote", demotePayload, headers) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden { t.Errorf("Expected 200 or 403, got %d: %s", resp.StatusCode, string(body)) } } func TestSuperuserTransfer_Success(t *testing.T) { adminToken := getAdminLogin(t) getClientLogin(t) var clientID int testDB.QueryRow("SELECT id FROM users WHERE email = $1", "client@example.com").Scan(&clientID) // Transfer superuser to client transferPayload := map[string]interface{}{ "toUserId": clientID, "reason": "Testing transfer", } headers := map[string]string{ "Authorization": "Bearer " + adminToken.AccessToken, "X-CSRF-Token": adminToken.CsrfToken, } resp, body, _ := makeRequest("POST", "/superuser/transfer", transferPayload, headers) // Transfer may succeed or be blocked by CSRF if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden { t.Errorf("Expected 200 or 403, got %d: %s", resp.StatusCode, string(body)) } } func TestSuperuserTransfer_ClientCannotTransfer(t *testing.T) { getAdminLogin(t) clientToken := getClientLogin(t) var adminID int testDB.QueryRow("SELECT id FROM users WHERE email = $1", "admin@example.com").Scan(&adminID) transferPayload := map[string]interface{}{ "toUserId": adminID, "reason": "Malicious transfer attempt", } headers := map[string]string{ "Authorization": "Bearer " + clientToken.AccessToken, "X-CSRF-Token": clientToken.CsrfToken, } resp, _, _ := makeRequest("POST", "/superuser/transfer", transferPayload, headers) if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusUnauthorized { t.Errorf("Expected 403 or 401, got %d", resp.StatusCode) } } // ==================== VALIDATION TESTS ==================== func TestRegistration_EmptyPayload(t *testing.T) { cleanupTestData() resp, _, _ := makeRequest("POST", "/register-email-password", map[string]string{}, nil) if resp.StatusCode != http.StatusBadRequest { t.Errorf("Expected 400 for empty payload, got %d", resp.StatusCode) } } func TestLogin_EmptyPayload(t *testing.T) { cleanupTestData() resp, _, _ := makeRequest("POST", "/login-email-password", map[string]string{}, nil) if resp.StatusCode != http.StatusBadRequest { t.Errorf("Expected 400 for empty login payload, got %d", resp.StatusCode) } } func TestRegister_LongName(t *testing.T) { cleanupTestData() // Name that's too long (> 100 chars based on schema) longName := strings.Repeat("a", 150) payload := map[string]string{ "email": "longname@example.com", "password": "LongNamePassword123", "name": longName, } resp, _, _ := makeRequest("POST", "/register-email-password", payload, nil) // Should either truncate or reject if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusCreated { t.Errorf("Expected 400 or 201 for long name, got %d", resp.StatusCode) } } func TestRegister_SQLInjection(t *testing.T) { cleanupTestData() // Attempt SQL injection in email payload := map[string]string{ "email": "test@example.com'; DROP TABLE users;--", "password": "SQLInjectionTest123", "name": "SQL Injection", } resp, _, _ := makeRequest("POST", "/register-email-password", payload, nil) // Should fail validation (invalid email format) if resp.StatusCode != http.StatusBadRequest { t.Errorf("Expected 400 for SQL injection attempt, got %d", resp.StatusCode) } // Verify users table still exists var count int err := testDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) if err != nil { t.Error("Users table should still exist after SQL injection attempt") } } func TestRegister_XSSAttempt(t *testing.T) { cleanupTestData() // Attempt XSS in name field payload := map[string]string{ "email": "xss@example.com", "password": "XSSTestPassword123", "name": "", } resp, _, _ := makeRequest("POST", "/register-email-password", payload, nil) // Should either sanitize or accept (XSS prevention is on output, not input) if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusCreated { t.Errorf("Expected 400 or 201 for XSS attempt, got %d", resp.StatusCode) } } // ==================== TOKEN EXPIRY TESTS ==================== func TestAccessToken_Expiry(t *testing.T) { cleanupTestData() // This test would require waiting for token expiry or mocking time // For now, verify token contains proper claims regPayload := map[string]string{ "email": "expiry@example.com", "password": "ExpiryPassword123", "name": "Expiry User", } makeRequest("POST", "/register-email-password", regPayload, nil) loginPayload := map[string]string{ "email": "expiry@example.com", "password": "ExpiryPassword123", } _, loginBody, _ := makeRequest("POST", "/login-email-password", loginPayload, nil) var result AuthTokenResponse json.Unmarshal(loginBody, &result) // Verify we got tokens if result.AccessToken == "" { t.Error("Should receive access token") } if result.RefreshToken == "" { t.Error("Should receive refresh token") } if result.ExpiresIn <= 0 { t.Error("ExpiresIn should be positive") } } // ==================== CONCURRENT REQUEST TESTS ==================== func TestConcurrentRegistrations(t *testing.T) { cleanupTestData() // Test that concurrent registrations with same email properly handle conflicts done := make(chan bool, 10) successCount := 0 for i := 0; i < 10; i++ { go func(idx int) { payload := map[string]string{ "email": "concurrent@example.com", "password": "ConcurrentPassword123", "name": fmt.Sprintf("Concurrent User %d", idx), } resp, _, _ := makeRequest("POST", "/register-email-password", payload, nil) if resp.StatusCode == http.StatusCreated { successCount++ } done <- true }(i) } // Wait for all goroutines for i := 0; i < 10; i++ { <-done } // Only one should succeed if successCount > 1 { t.Errorf("Expected at most 1 successful registration, got %d", successCount) } // Verify only one user exists var count int testDB.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", "concurrent@example.com").Scan(&count) if count != 1 { t.Errorf("Expected exactly 1 user, found %d", count) } } // ==================== HTTP METHOD TESTS ==================== func TestEndpoints_WrongMethod(t *testing.T) { cleanupTestData() // POST endpoints should reject GET testCases := []struct { path string method string }{ {"/register-email-password", "GET"}, {"/login-email-password", "GET"}, {"/auth/refresh", "GET"}, {"/auth/logout", "GET"}, } for _, tc := range testCases { t.Run(tc.path+"_"+tc.method, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, nil) rr := httptest.NewRecorder() handler := getHandler(tc.path) if handler != nil { handler(rr, req) if rr.Code == http.StatusOK || rr.Code == http.StatusCreated { // Handler may not check method, that's implementation-specific // Just verify endpoint exists } } }) } } // ==================== CONTENT-TYPE TESTS ==================== func TestRegister_WrongContentType(t *testing.T) { cleanupTestData() req := httptest.NewRequest("POST", "/register-email-password", strings.NewReader("email=test@example.com&password=Test123&name=Test")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() handleRegisterEmailPassword(rr, req) // Should reject non-JSON content type if rr.Code == http.StatusCreated { // Some implementations may accept form data, just verify it's handled t.Log("Handler accepts form-urlencoded data") } }