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
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("/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");
|
|
}
|