//! Integration tests for the authentication flow. //! //! Tests register, login, magic link verify, /me, and logout endpoints. //! Turnstile and email services are bypassed using test keys. //! //! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run. mod common; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; fn require_test_db() -> bool { std::env::var("TEST_DATABASE_URL").is_ok() } // ── Register ───────────────────────────────────────────────────────────── #[tokio::test] async fn register_with_valid_email_returns_200() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (status, body) = app.register_user_via_api("newuser@example.com").await; assert_eq!(status, StatusCode::OK, "Register should return 200"); assert!( body["message"].as_str().unwrap_or("").len() > 0, "Response should contain a message" ); } #[tokio::test] async fn register_with_invalid_email_returns_400() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let body = serde_json::json!({ "email": "not-an-email", "turnstile_token": "test-token" }); let (status, resp) = app.post("/api/v1/auth/register", &body).await; assert_eq!( status, StatusCode::BAD_REQUEST, "Register with invalid email should return 400" ); assert_eq!(resp["error"], "bad_request"); } #[tokio::test] async fn register_with_missing_turnstile_token_returns_422() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; // Omit turnstile_token entirely — Axum should fail to deserialize let body = serde_json::json!({ "email": "user@example.com" }); let (status, _) = app.post("/api/v1/auth/register", &body).await; // Axum returns 422 for JSON deserialization errors assert!( status == StatusCode::UNPROCESSABLE_ENTITY || status == StatusCode::BAD_REQUEST, "Register with missing turnstile_token should return 422 or 400, got {}", status ); } #[tokio::test] async fn register_duplicate_email_returns_same_response() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; // Register the user for the first time let (status1, body1) = app.register_user_via_api("dup@example.com").await; assert_eq!(status1, StatusCode::OK); // Register the same email again — should return the same response (anti-enumeration) let (status2, body2) = app.register_user_via_api("dup@example.com").await; assert_eq!(status2, StatusCode::OK); // Both responses should have the same message assert_eq!( body1["message"], body2["message"], "Duplicate registration should return the same message (anti-enumeration)" ); } // ── Login ──────────────────────────────────────────────────────────────── #[tokio::test] async fn login_existing_user_returns_200() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; // First, create a user app.create_test_user("login-existing@example.com").await; let body = serde_json::json!({ "email": "login-existing@example.com", "turnstile_token": "test-token" }); let (status, resp) = app.post("/api/v1/auth/login", &body).await; assert_eq!(status, StatusCode::OK, "Login should return 200"); assert!( resp["message"].as_str().unwrap_or("").len() > 0, "Response should contain a message" ); } #[tokio::test] async fn login_nonexistent_user_returns_200_anti_enumeration() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let body = serde_json::json!({ "email": "nonexistent@example.com", "turnstile_token": "test-token" }); let (status, resp) = app.post("/api/v1/auth/login", &body).await; assert_eq!( status, StatusCode::OK, "Login with non-existing email should also return 200 (anti-enumeration)" ); assert!( resp["message"].as_str().unwrap_or("").len() > 0, "Response should contain a message" ); } #[tokio::test] async fn login_invalid_email_returns_400() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let body = serde_json::json!({ "email": "no-at-sign", "turnstile_token": "test-token" }); let (status, resp) = app.post("/api/v1/auth/login", &body).await; assert_eq!( status, StatusCode::BAD_REQUEST, "Login with invalid email should return 400" ); assert_eq!(resp["error"], "bad_request"); } // ── Verify (magic link) ───────────────────────────────────────────────── #[tokio::test] async fn verify_valid_token_sets_session_cookie_and_redirects() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; // Create a user and a magic link token app.create_test_user("verify-ok@example.com").await; let raw_token = app .create_magic_link_for_email("verify-ok@example.com") .await .expect("Should create a magic link token"); // Call verify let uri = format!("/api/v1/auth/verify?token={}", raw_token); let req = Request::builder() .method(Method::GET) .uri(&uri) .body(Body::empty()) .unwrap(); let response = app.raw_request(req).await; let status = response.status(); // Should redirect (3xx) assert!( status.is_redirection(), "Verify with valid token should redirect, got {}", status ); // Should have a Set-Cookie header with the session cookie let set_cookie = response .headers() .get("set-cookie") .map(|v| v.to_str().unwrap_or("")) .unwrap_or(""); assert!( set_cookie.contains("ai_synth_session="), "Response should set the session cookie, got: {}", set_cookie ); assert!( set_cookie.contains("HttpOnly"), "Session cookie should be HttpOnly" ); } #[tokio::test] async fn verify_invalid_token_redirects_with_error() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; // Call verify with a bogus token let req = Request::builder() .method(Method::GET) .uri("/api/v1/auth/verify?token=totally-invalid-token") .body(Body::empty()) .unwrap(); let response = app.raw_request(req).await; let status = response.status(); // Should redirect to error page assert!( status.is_redirection(), "Verify with invalid token should redirect, got {}", status ); // The redirect location should contain error=invalid_token let location = response .headers() .get("location") .map(|v| v.to_str().unwrap_or("")) .unwrap_or(""); assert!( location.contains("error=invalid_token"), "Redirect location should contain error=invalid_token, got: {}", location ); } #[tokio::test] async fn verify_already_used_token_redirects_with_error() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; // Create user and magic link app.create_test_user("verify-reuse@example.com").await; let raw_token = app .create_magic_link_for_email("verify-reuse@example.com") .await .expect("Should create token"); // First verification — should succeed let uri = format!("/api/v1/auth/verify?token={}", raw_token); let req1 = Request::builder() .method(Method::GET) .uri(&uri) .body(Body::empty()) .unwrap(); let response1 = app.raw_request(req1).await; assert!( response1.status().is_redirection(), "First verify should redirect (success)" ); // The first verification should set a session cookie (no error in location) let location1 = response1 .headers() .get("location") .map(|v| v.to_str().unwrap_or("")) .unwrap_or(""); assert!( !location1.contains("error="), "First verify should not redirect with error" ); // Second verification with the same token — should fail (single-use) let req2 = Request::builder() .method(Method::GET) .uri(&uri) .body(Body::empty()) .unwrap(); let response2 = app.raw_request(req2).await; assert!( response2.status().is_redirection(), "Second verify should also redirect" ); let location2 = response2 .headers() .get("location") .map(|v| v.to_str().unwrap_or("")) .unwrap_or(""); assert!( location2.contains("error=invalid_token"), "Second verify (reused token) should redirect with error=invalid_token, got: {}", location2 ); } // ── GET /me ────────────────────────────────────────────────────────────── #[tokio::test] async fn me_with_valid_session_returns_user_data() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_id, session_token) = app.create_authenticated_user("me-ok@example.com").await; let (status, body) = app .get_with_session("/api/v1/auth/me", &session_token) .await; assert_eq!(status, StatusCode::OK, "GET /me with session should return 200"); assert_eq!( body["email"], "me-ok@example.com", "Response should contain the user's email" ); assert_eq!( body["id"], user_id.to_string(), "Response should contain the user's UUID" ); assert_eq!(body["role"], "user", "Default role should be 'user'"); } #[tokio::test] async fn me_without_session_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (status, body) = app.get("/api/v1/auth/me").await; assert_eq!( status, StatusCode::UNAUTHORIZED, "GET /me without session should return 401" ); assert_eq!(body["error"], "unauthorized"); } // ── Logout ─────────────────────────────────────────────────────────────── #[tokio::test] async fn logout_invalidates_session() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session_token) = app .create_authenticated_user("logout-test@example.com") .await; // Verify session works before logout let (status, _) = app .get_with_session("/api/v1/auth/me", &session_token) .await; assert_eq!( status, StatusCode::OK, "Session should be valid before logout" ); // Logout let (logout_status, logout_body) = app .post_with_session( "/api/v1/auth/logout", &serde_json::json!({}), &session_token, ) .await; assert_eq!(logout_status, StatusCode::OK, "Logout should return 200"); assert_eq!(logout_body["message"], "Logged out"); // After logout, /me should return 401 let (status_after, body_after) = app .get_with_session("/api/v1/auth/me", &session_token) .await; assert_eq!( status_after, StatusCode::UNAUTHORIZED, "GET /me after logout should return 401" ); assert_eq!(body_after["error"], "unauthorized"); } #[tokio::test] async fn logout_clears_cookie() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session_token) = app .create_authenticated_user("logout-cookie@example.com") .await; // Logout via raw request to inspect response headers let req = Request::builder() .method(Method::POST) .uri("/api/v1/auth/logout") .header("Content-Type", "application/json") .header("X-Requested-With", "XMLHttpRequest") .header( "Cookie", format!("ai_synth_session={}", session_token), ) .body(Body::empty()) .unwrap(); let response = app.raw_request(req).await; assert_eq!(response.status(), StatusCode::OK); let set_cookie = response .headers() .get("set-cookie") .map(|v| v.to_str().unwrap_or("")) .unwrap_or(""); assert!( set_cookie.contains("Max-Age=0"), "Logout should clear the cookie with Max-Age=0, got: {}", set_cookie ); } // ── Edge cases ─────────────────────────────────────────────────────────── #[tokio::test] async fn me_with_invalid_session_token_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (status, body) = app .get_with_session("/api/v1/auth/me", "completely-bogus-session-token") .await; assert_eq!( status, StatusCode::UNAUTHORIZED, "GET /me with invalid session should return 401" ); assert_eq!(body["error"], "unauthorized"); } #[tokio::test] async fn register_then_verify_full_flow() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; // Step 1: Register let (reg_status, _) = app.register_user_via_api("fullflow@example.com").await; assert_eq!(reg_status, StatusCode::OK); // Step 2: Create a magic link (simulate what registration did internally) let raw_token = app .create_magic_link_for_email("fullflow@example.com") .await .expect("Should create magic link"); // Step 3: Verify let uri = format!("/api/v1/auth/verify?token={}", raw_token); let req = Request::builder() .method(Method::GET) .uri(&uri) .body(Body::empty()) .unwrap(); let response = app.raw_request(req).await; assert!(response.status().is_redirection()); // Extract session cookie from Set-Cookie header let set_cookie = response .headers() .get("set-cookie") .map(|v| v.to_str().unwrap_or("")) .unwrap_or(""); let session_token = set_cookie .split(';') .next() .unwrap_or("") .strip_prefix("ai_synth_session=") .expect("Should have session cookie"); // Step 4: Use the session to call /me let (me_status, me_body) = app .get_with_session("/api/v1/auth/me", session_token) .await; assert_eq!(me_status, StatusCode::OK); assert_eq!(me_body["email"], "fullflow@example.com"); }