You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

538 lines
16 KiB
Rust

//! 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_with_session("/api/v1/auth/me", "invalid-session-token").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");
}