1719 lines
48 KiB
Go
1719 lines
48 KiB
Go
// +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": "<script>alert('xss')</script>",
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|