use crate::adapters::{MemoryDatabaseAdapter, SessionOps, VerificationOps};
use crate::plugins::{
AccountManagementPlugin, ApiKeyPlugin, EmailPasswordPlugin, EmailVerificationPlugin,
PasswordManagementPlugin, SessionManagementPlugin,
};
use crate::{AuthBuilder, AuthConfig, AuthRequest, BetterAuth, CreateVerification, HttpMethod};
fn test_config() -> AuthConfig {
AuthConfig::new("test-secret-key-that-is-at-least-32-characters-long")
.base_url("http://localhost:3000")
.password_min_length(8)
}
async fn create_test_auth() -> BetterAuth<MemoryDatabaseAdapter> {
AuthBuilder::new(test_config())
.database(MemoryDatabaseAdapter::new())
.plugin(EmailPasswordPlugin::new().enable_signup(true))
.plugin(SessionManagementPlugin::new())
.plugin(PasswordManagementPlugin::new().require_current_password(true))
.plugin(AccountManagementPlugin::new())
.plugin(EmailVerificationPlugin::new())
.plugin(ApiKeyPlugin::builder().build())
.build()
.await
.expect("Failed to create test auth instance")
}
fn post_json(path: &str, body: serde_json::Value) -> AuthRequest {
let mut req = AuthRequest::new(HttpMethod::Post, path);
req.body = Some(body.to_string().into_bytes());
req.headers
.insert("content-type".to_string(), "application/json".to_string());
req.headers
.insert("origin".to_string(), "http://localhost:3000".to_string());
req
}
fn get_with_auth(path: &str, token: &str) -> AuthRequest {
let mut req = AuthRequest::new(HttpMethod::Get, path);
req.headers
.insert("authorization".to_string(), format!("Bearer {}", token));
req.headers
.insert("origin".to_string(), "http://localhost:3000".to_string());
req
}
fn post_json_with_auth(path: &str, body: serde_json::Value, token: &str) -> AuthRequest {
let mut req = post_json(path, body);
req.headers
.insert("authorization".to_string(), format!("Bearer {}", token));
req
}
async fn signup_user(
auth: &BetterAuth<MemoryDatabaseAdapter>,
email: &str,
password: &str,
name: &str,
) -> (String, serde_json::Value) {
let req = post_json(
"/sign-up/email",
serde_json::json!({
"name": name,
"email": email,
"password": password,
}),
);
let resp = auth.handle_request(req).await.expect("signup failed");
assert_eq!(resp.status, 200, "signup status should be 200");
let json: serde_json::Value =
serde_json::from_slice(&resp.body).expect("signup response is not valid JSON");
let token = json["token"]
.as_str()
.expect("signup response missing token")
.to_string();
(token, json)
}
async fn signin_user(
auth: &BetterAuth<MemoryDatabaseAdapter>,
email: &str,
password: &str,
) -> (String, serde_json::Value) {
let req = post_json(
"/sign-in/email",
serde_json::json!({
"email": email,
"password": password,
}),
);
let resp = auth.handle_request(req).await.expect("signin failed");
assert_eq!(resp.status, 200, "signin status should be 200");
let json: serde_json::Value =
serde_json::from_slice(&resp.body).expect("signin response is not valid JSON");
let token = json["token"]
.as_str()
.expect("signin response missing token")
.to_string();
(token, json)
}
#[tokio::test]
async fn test_signup_response_shape() {
let auth = create_test_auth().await;
let (_token, json) = signup_user(&auth, "shape@example.com", "password123", "Shape User").await;
assert!(
json["token"].is_string() || json["token"].is_null(),
"token must be string or null, got: {:?}",
json["token"]
);
assert!(json["user"].is_object(), "user must be an object");
assert!(json["user"]["id"].is_string(), "user.id must be a string");
assert!(
json["user"]["email"].is_string(),
"user.email must be a string"
);
assert!(
json["user"]["name"].is_string(),
"user.name must be a string"
);
assert!(
json["user"]["emailVerified"].is_boolean(),
"user.emailVerified must be a boolean"
);
assert!(
json["user"]["createdAt"].is_string(),
"user.createdAt must be a string"
);
assert!(
json["user"]["updatedAt"].is_string(),
"user.updatedAt must be a string"
);
}
#[tokio::test]
async fn test_signin_response_shape() {
let auth = create_test_auth().await;
signup_user(&auth, "si@example.com", "password123", "SI User").await;
let (_token, json) = signin_user(&auth, "si@example.com", "password123").await;
assert!(
json["redirect"].is_boolean(),
"redirect must be a boolean, got: {:?}",
json["redirect"]
);
assert!(
json["token"].is_string(),
"token must be a string, got: {:?}",
json["token"]
);
assert!(
json["url"].is_null() || json["url"].is_string(),
"url must be null or string, got: {:?}",
json["url"]
);
assert!(json["user"].is_object(), "user must be an object");
assert!(json["user"]["id"].is_string(), "user.id must be a string");
assert!(
json["user"]["email"].is_string(),
"user.email must be a string"
);
}
#[tokio::test]
async fn test_get_session_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "gs@example.com", "password123", "GS User").await;
let req = get_with_auth("/get-session", &token);
let resp = auth
.handle_request(req)
.await
.expect("get-session request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(json["session"].is_object(), "session must be an object");
assert!(
json["session"]["id"].is_string(),
"session.id must be a string"
);
assert!(
json["session"]["token"].is_string(),
"session.token must be a string"
);
assert!(
json["session"]["userId"].is_string() || json["session"]["user_id"].is_string(),
"session must have userId or user_id"
);
assert!(json["user"].is_object(), "user must be an object");
assert!(json["user"]["id"].is_string(), "user.id must be a string");
assert!(
json["user"]["email"].is_string(),
"user.email must be a string"
);
}
#[tokio::test]
async fn test_sign_out_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "so@example.com", "password123", "SO User").await;
let req = post_json_with_auth("/sign-out", serde_json::json!({}), &token);
let resp = auth
.handle_request(req)
.await
.expect("sign-out request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["success"].is_boolean(),
"success must be a boolean, got: {:?}",
json
);
}
#[tokio::test]
async fn test_list_sessions_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "ls@example.com", "password123", "LS User").await;
let req = get_with_auth("/list-sessions", &token);
let resp = auth
.handle_request(req)
.await
.expect("list-sessions request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(json.is_array(), "list-sessions response must be an array");
let arr = json.as_array().unwrap();
assert!(!arr.is_empty(), "should have at least one session");
let session = &arr[0];
assert!(session["id"].is_string(), "session.id must be a string");
assert!(
session["token"].is_string(),
"session.token must be a string"
);
}
#[tokio::test]
async fn test_forget_password_response_shape() {
let auth = create_test_auth().await;
signup_user(&auth, "fp@example.com", "password123", "FP User").await;
let req = post_json(
"/forget-password",
serde_json::json!({
"email": "fp@example.com",
}),
);
let resp = auth
.handle_request(req)
.await
.expect("forget-password request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["status"].is_boolean(),
"status must be a boolean, got: {:?}",
json
);
}
#[tokio::test]
async fn test_reset_password_response_shape() {
let auth = create_test_auth().await;
let (token, signup_json) = signup_user(&auth, "rp@example.com", "password123", "RP User").await;
let _user_id = signup_json["user"]["id"].as_str().unwrap();
let reset_token = format!("reset_{}", uuid::Uuid::new_v4());
let create_verification = CreateVerification {
identifier: "rp@example.com".to_string(),
value: reset_token.clone(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(24),
};
auth.database()
.create_verification(create_verification)
.await
.expect("failed to create verification");
let req = post_json(
"/reset-password",
serde_json::json!({
"newPassword": "NewPassword123",
"token": reset_token,
}),
);
let resp = auth
.handle_request(req)
.await
.expect("reset-password request failed");
if resp.status == 200 {
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["status"].is_boolean(),
"status must be a boolean, got: {:?}",
json
);
}
let _ = token;
}
#[tokio::test]
async fn test_change_password_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "cp@example.com", "password123", "CP User").await;
let req = post_json_with_auth(
"/change-password",
serde_json::json!({
"currentPassword": "password123",
"newPassword": "newpassword456",
"revokeOtherSessions": "false",
}),
&token,
);
let resp = auth
.handle_request(req)
.await
.expect("change-password request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["token"].is_null() || json["token"].is_string(),
"token must be null or string, got: {:?}",
json["token"]
);
assert!(
json["user"].is_object(),
"user must be an object, got: {:?}",
json["user"]
);
assert!(json["user"]["id"].is_string(), "user.id must be a string");
assert!(
json["user"]["email"].is_string(),
"user.email must be a string"
);
}
#[tokio::test]
async fn test_update_user_response_shape_actual() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "uu@example.com", "password123", "UU User").await;
let req = post_json_with_auth(
"/update-user",
serde_json::json!({
"name": "Updated Name",
}),
&token,
);
let resp = auth
.handle_request(req)
.await
.expect("update-user request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["status"].is_boolean(),
"status must be a boolean, got: {:?}",
json
);
assert_eq!(json["status"], true);
}
#[tokio::test]
async fn test_delete_user_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "du@example.com", "password123", "DU User").await;
let req = post_json_with_auth("/delete-user", serde_json::json!({}), &token);
let resp = auth
.handle_request(req)
.await
.expect("delete-user request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["success"].is_boolean(),
"success must be a boolean, got: {:?}",
json
);
assert!(
json["message"].is_string(),
"message must be a string, got: {:?}",
json
);
}
#[tokio::test]
async fn test_change_email_response_shape_actual() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "ce@example.com", "password123", "CE User").await;
let req = post_json_with_auth(
"/change-email",
serde_json::json!({
"newEmail": "ce_new@example.com",
}),
&token,
);
let resp = auth
.handle_request(req)
.await
.expect("change-email request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["status"].is_boolean(),
"status must be a boolean, got: {:?}",
json
);
assert_eq!(json["status"], true);
assert!(
json["message"].is_string(),
"message must be a string, got: {:?}",
json
);
}
#[tokio::test]
async fn test_send_verification_email_response_shape() {
let auth = create_test_auth().await;
signup_user(&auth, "sv@example.com", "password123", "SV User").await;
let req = post_json(
"/send-verification-email",
serde_json::json!({
"email": "sv@example.com",
}),
);
let resp = auth
.handle_request(req)
.await
.expect("send-verification-email request failed");
if resp.status == 200 {
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["status"].is_boolean(),
"status must be a boolean, got: {:?}",
json
);
}
}
#[tokio::test]
async fn test_list_accounts_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "la@example.com", "password123", "LA User").await;
let req = get_with_auth("/list-accounts", &token);
let resp = auth
.handle_request(req)
.await
.expect("list-accounts request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(json.is_array(), "list-accounts response must be an array");
if let Some(arr) = json.as_array() {
for account in arr {
assert!(
account["id"].is_string(),
"account.id must be a string, got: {:?}",
account
);
assert!(
account["provider"].is_string(),
"account must have provider field, got: {:?}",
account
);
assert!(
account["scopes"].is_array(),
"account must have scopes array, got: {:?}",
account
);
}
}
}
#[tokio::test]
async fn test_api_key_create_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "ak_create@example.com", "password123", "AK User").await;
let req = post_json_with_auth(
"/api-key/create",
serde_json::json!({
"name": "shape-test-key",
"prefix": "sk_"
}),
&token,
);
let resp = auth
.handle_request(req)
.await
.expect("api-key/create request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["key"].is_string(),
"key must be a string, got: {:?}",
json["key"]
);
assert!(
json["key"].as_str().unwrap().starts_with("sk_"),
"key should start with prefix"
);
assert!(json["id"].is_string(), "id must be a string");
assert!(
json["userId"].is_string(),
"userId must be a string, got: {:?}",
json["userId"]
);
assert_eq!(json["name"], "shape-test-key");
assert!(json["enabled"].is_boolean(), "enabled must be a boolean");
assert!(
json["rateLimitEnabled"].is_boolean(),
"rateLimitEnabled must be a boolean"
);
assert!(json["createdAt"].is_string(), "createdAt must be a string");
assert!(json["updatedAt"].is_string(), "updatedAt must be a string");
assert!(
json.get("keyHash").is_none(),
"keyHash must not be in response"
);
assert!(
json.get("key_hash").is_none(),
"key_hash must not be in response"
);
}
#[tokio::test]
async fn test_api_key_get_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "ak_get@example.com", "password123", "AK Get User").await;
let create_req = post_json_with_auth(
"/api-key/create",
serde_json::json!({"name": "get-shape-key"}),
&token,
);
let create_resp = auth.handle_request(create_req).await.unwrap();
let create_json: serde_json::Value = serde_json::from_slice(&create_resp.body).unwrap();
let key_id = create_json["id"].as_str().unwrap();
let mut req = AuthRequest::new(HttpMethod::Get, "/api-key/get");
req.headers
.insert("authorization".to_string(), format!("Bearer {}", token));
req.headers
.insert("origin".to_string(), "http://localhost:3000".to_string());
req.query.insert("id".to_string(), key_id.to_string());
let resp = auth.handle_request(req).await.expect("api-key/get failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(json["id"].is_string(), "id must be a string");
assert_eq!(json["name"], "get-shape-key");
assert!(json["userId"].is_string(), "userId must be a string");
assert!(json["enabled"].is_boolean(), "enabled must be a boolean");
assert!(json["createdAt"].is_string(), "createdAt must be a string");
assert!(json["updatedAt"].is_string(), "updatedAt must be a string");
assert!(
json.get("key").is_none(),
"key must not be returned on get endpoint"
);
}
#[tokio::test]
async fn test_api_key_list_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "ak_list@example.com", "password123", "AK List User").await;
for name in &["list-key-1", "list-key-2"] {
let req = post_json_with_auth("/api-key/create", serde_json::json!({"name": name}), &token);
auth.handle_request(req).await.unwrap();
}
let req = get_with_auth("/api-key/list", &token);
let resp = auth.handle_request(req).await.expect("api-key/list failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(json.is_array(), "list response must be an array");
let arr = json.as_array().unwrap();
assert_eq!(arr.len(), 2, "should have 2 keys");
for item in arr {
assert!(item["id"].is_string(), "item.id must be a string");
assert!(item["userId"].is_string(), "item.userId must be a string");
assert!(
item["enabled"].is_boolean(),
"item.enabled must be a boolean"
);
assert!(
item.get("key").is_none(),
"key must not appear in list items"
);
}
}
#[tokio::test]
async fn test_api_key_update_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "ak_upd@example.com", "password123", "AK Upd User").await;
let create_req = post_json_with_auth(
"/api-key/create",
serde_json::json!({"name": "original"}),
&token,
);
let create_resp = auth.handle_request(create_req).await.unwrap();
let create_json: serde_json::Value = serde_json::from_slice(&create_resp.body).unwrap();
let key_id = create_json["id"].as_str().unwrap();
let req = post_json_with_auth(
"/api-key/update",
serde_json::json!({
"id": key_id,
"name": "updated",
"enabled": false
}),
&token,
);
let resp = auth
.handle_request(req)
.await
.expect("api-key/update failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(json["id"], key_id);
assert_eq!(json["name"], "updated");
assert_eq!(json["enabled"], false);
assert!(json["updatedAt"].is_string(), "updatedAt must be a string");
}
#[tokio::test]
async fn test_api_key_delete_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "ak_del@example.com", "password123", "AK Del User").await;
let create_req = post_json_with_auth(
"/api-key/create",
serde_json::json!({"name": "to-delete"}),
&token,
);
let create_resp = auth.handle_request(create_req).await.unwrap();
let create_json: serde_json::Value = serde_json::from_slice(&create_resp.body).unwrap();
let key_id = create_json["id"].as_str().unwrap();
let req = post_json_with_auth("/api-key/delete", serde_json::json!({"id": key_id}), &token);
let resp = auth
.handle_request(req)
.await
.expect("api-key/delete failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["status"].is_boolean(),
"status must be a boolean, got: {:?}",
json
);
assert_eq!(json["status"], true);
}
#[tokio::test]
async fn test_unlink_account_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "ua@example.com", "password123", "UA User").await;
let req = post_json_with_auth(
"/unlink-account",
serde_json::json!({
"providerId": "nonexistent-provider",
}),
&token,
);
let resp = auth
.handle_request(req)
.await
.expect("unlink-account request failed");
if resp.status == 200 {
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["status"].is_boolean(),
"status must be a boolean, got: {:?}",
json
);
} else {
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["message"].is_string(),
"error response must have message string, got: {:?}",
json
);
}
}
#[tokio::test]
async fn test_enable_session_for_api_keys_injects_session() {
let auth = AuthBuilder::new(test_config())
.database(MemoryDatabaseAdapter::new())
.plugin(EmailPasswordPlugin::new().enable_signup(true))
.plugin(SessionManagementPlugin::new())
.plugin(
ApiKeyPlugin::builder()
.enable_session_for_api_keys(true)
.build(),
)
.build()
.await
.expect("Failed to create auth with session-for-api-keys");
let (token, _) = signup_user(
&auth,
"apikey_session@example.com",
"password123",
"AK Session",
)
.await;
let create_req = post_json_with_auth(
"/api-key/create",
serde_json::json!({"name": "session-emulation-key"}),
&token,
);
let create_resp = auth.handle_request(create_req).await.unwrap();
assert_eq!(create_resp.status, 200);
let create_json: serde_json::Value = serde_json::from_slice(&create_resp.body).unwrap();
let raw_key = create_json["key"]
.as_str()
.expect("create response must contain raw key")
.to_string();
let mut req = AuthRequest::new(HttpMethod::Post, "/update-user");
req.body = Some(
serde_json::json!({"name": "Updated Via API Key"})
.to_string()
.into_bytes(),
);
req.headers
.insert("content-type".to_string(), "application/json".to_string());
req.headers
.insert("origin".to_string(), "http://localhost:3000".to_string());
req.headers.insert("x-api-key".to_string(), raw_key.clone());
let resp = auth
.handle_request(req)
.await
.expect("update-user via api-key failed");
assert_eq!(
resp.status, 200,
"update-user via API key should succeed (session injected by before_request hook)"
);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(
json["status"], true,
"update-user should return status: true"
);
let mut get_session_req = AuthRequest::new(HttpMethod::Get, "/get-session");
get_session_req
.headers
.insert("origin".to_string(), "http://localhost:3000".to_string());
get_session_req
.headers
.insert("x-api-key".to_string(), raw_key);
let get_session_resp = auth
.handle_request(get_session_req)
.await
.expect("get-session via api-key failed");
assert_eq!(get_session_resp.status, 200);
let gs_json: serde_json::Value = serde_json::from_slice(&get_session_resp.body).unwrap();
assert!(
gs_json["user"].is_object(),
"get-session via API key must return user object"
);
assert_eq!(
gs_json["user"]["email"], "apikey_session@example.com",
"get-session must return the correct user"
);
}
#[tokio::test]
async fn test_disabled_path_blocks_api_key_get_session_short_circuit() {
let auth = AuthBuilder::new(test_config().disabled_path("/get-session"))
.database(MemoryDatabaseAdapter::new())
.plugin(EmailPasswordPlugin::new().enable_signup(true))
.plugin(SessionManagementPlugin::new())
.plugin(
ApiKeyPlugin::builder()
.enable_session_for_api_keys(true)
.build(),
)
.build()
.await
.expect("Failed to create auth with disabled get-session");
let (token, _) = signup_user(
&auth,
"disabled_get_session@example.com",
"password123",
"DG",
)
.await;
let create_req = post_json_with_auth(
"/api-key/create",
serde_json::json!({"name": "disabled-get-session-key"}),
&token,
);
let create_resp = auth.handle_request(create_req).await.unwrap();
assert_eq!(create_resp.status, 200);
let create_json: serde_json::Value = serde_json::from_slice(&create_resp.body).unwrap();
let raw_key = create_json["key"]
.as_str()
.expect("create response must contain raw key");
let mut get_session_req = AuthRequest::new(HttpMethod::Get, "/get-session");
get_session_req
.headers
.insert("origin".to_string(), "http://localhost:3000".to_string());
get_session_req
.headers
.insert("x-api-key".to_string(), raw_key.to_string());
let resp = auth.handle_request(get_session_req).await.unwrap();
assert_eq!(resp.status, 404, "disabled path must return 404");
}
#[tokio::test]
async fn test_handle_request_ignores_caller_supplied_virtual_user_id() {
let auth = create_test_auth().await;
let (_token, signup_json) = signup_user(
&auth,
"forged_virtual_user@example.com",
"password123",
"FVU",
)
.await;
let user_id = signup_json["user"]["id"]
.as_str()
.expect("signup response must contain user id")
.to_string();
let mut req = post_json("/update-user", serde_json::json!({ "name": "should-fail" }));
req.set_virtual_user_id(user_id);
let resp = auth.handle_request(req).await.unwrap();
assert_eq!(
resp.status, 401,
"request without real session token must remain unauthorized"
);
}
#[tokio::test]
async fn test_api_key_without_session_emulation_does_not_inject() {
let auth = create_test_auth().await;
let (token, _) = signup_user(
&auth,
"apikey_nosess@example.com",
"password123",
"AK NoSess",
)
.await;
let create_req = post_json_with_auth(
"/api-key/create",
serde_json::json!({"name": "no-session-key"}),
&token,
);
let create_resp = auth.handle_request(create_req).await.unwrap();
let create_json: serde_json::Value = serde_json::from_slice(&create_resp.body).unwrap();
let raw_key = create_json["key"].as_str().unwrap();
let mut req = AuthRequest::new(HttpMethod::Post, "/update-user");
req.body = Some(
serde_json::json!({"name": "Should Fail"})
.to_string()
.into_bytes(),
);
req.headers
.insert("content-type".to_string(), "application/json".to_string());
req.headers
.insert("origin".to_string(), "http://localhost:3000".to_string());
req.headers
.insert("x-api-key".to_string(), raw_key.to_string());
let resp = auth
.handle_request(req)
.await
.expect("request should not panic");
assert_eq!(
resp.status, 401,
"without enableSessionForAPIKeys, api key should NOT inject a session"
);
}
#[tokio::test]
async fn test_revoke_session_response_shape() {
let auth = create_test_auth().await;
let (token, _) = signup_user(&auth, "rs@example.com", "password123", "RS User").await;
let (token2, _) = signin_user(&auth, "rs@example.com", "password123").await;
let req = post_json_with_auth(
"/revoke-session",
serde_json::json!({
"token": token,
}),
&token2,
);
let resp = auth
.handle_request(req)
.await
.expect("revoke-session request failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["status"].is_boolean(),
"status must be a boolean, got: {:?}",
json
);
}
async fn create_auth_with_api_key_session() -> BetterAuth<MemoryDatabaseAdapter> {
AuthBuilder::new(test_config())
.database(MemoryDatabaseAdapter::new())
.plugin(EmailPasswordPlugin::new().enable_signup(true))
.plugin(SessionManagementPlugin::new())
.plugin(
ApiKeyPlugin::builder()
.enable_session_for_api_keys(true)
.build(),
)
.build()
.await
.expect("Failed to create auth with session-for-api-keys")
}
async fn setup_user_with_api_key(
auth: &BetterAuth<MemoryDatabaseAdapter>,
email: &str,
key_body: serde_json::Value,
) -> (String, String, String) {
let (token, signup_json) = signup_user(auth, email, "password123", "E2E User").await;
let user_id = signup_json["user"]["id"]
.as_str()
.expect("user id")
.to_string();
let create_req = post_json_with_auth("/api-key/create", key_body, &token);
let create_resp = auth.handle_request(create_req).await.unwrap();
assert_eq!(create_resp.status, 200, "api-key/create should succeed");
let create_json: serde_json::Value = serde_json::from_slice(&create_resp.body).unwrap();
let raw_key = create_json["key"].as_str().expect("raw key").to_string();
(token, raw_key, user_id)
}
fn api_key_request(method: HttpMethod, path: &str, raw_key: &str) -> AuthRequest {
let mut req = AuthRequest::new(method, path);
req.headers
.insert("x-api-key".to_string(), raw_key.to_string());
req.headers
.insert("origin".to_string(), "http://localhost:3000".to_string());
req
}
#[tokio::test]
async fn e2e_virtual_session_creates_no_db_session() {
let auth = create_auth_with_api_key_session().await;
let (_token, raw_key, user_id) = setup_user_with_api_key(
&auth,
"e2e_nosess@example.com",
serde_json::json!({"name": "no-session-key"}),
)
.await;
let sessions_before = auth
.database()
.get_user_sessions(&user_id)
.await
.unwrap()
.len();
let mut req = api_key_request(HttpMethod::Post, "/update-user", &raw_key);
req.body = Some(
serde_json::json!({"name": "Updated"})
.to_string()
.into_bytes(),
);
req.headers
.insert("content-type".to_string(), "application/json".to_string());
let resp = auth
.handle_request(req)
.await
.expect("request should not panic");
assert_eq!(resp.status, 200, "update-user via API key should succeed");
let sessions_after = auth
.database()
.get_user_sessions(&user_id)
.await
.unwrap()
.len();
assert_eq!(
sessions_before, sessions_after,
"No new database sessions should be created by API-key auth"
);
}
#[tokio::test]
async fn e2e_virtual_session_get_session_returns_synthetic() {
let auth = create_auth_with_api_key_session().await;
let (_token, raw_key, user_id) = setup_user_with_api_key(
&auth,
"e2e_getsess@example.com",
serde_json::json!({"name": "get-session-key"}),
)
.await;
let req = api_key_request(HttpMethod::Get, "/get-session", &raw_key);
let resp = auth.handle_request(req).await.expect("get-session failed");
assert_eq!(resp.status, 200);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(json["user"].is_object(), "must contain user object");
assert_eq!(json["user"]["id"], user_id, "user id must match");
assert_eq!(
json["user"]["email"], "e2e_getsess@example.com",
"user email must match"
);
assert!(json["session"].is_object(), "must contain session object");
assert!(
json["session"]["id"].is_string(),
"session.id must be present"
);
assert_eq!(
json["session"]["userId"], user_id,
"session.userId must match"
);
}
#[tokio::test]
async fn e2e_rate_limiting_third_request_fails() {
let auth = create_test_auth().await;
let (_token, raw_key, _user_id) = setup_user_with_api_key(
&auth,
"e2e_ratelimit@example.com",
serde_json::json!({
"name": "rate-limited-key",
"rateLimitEnabled": true,
"rateLimitMax": 2,
"rateLimitTimeWindow": 60000
}),
)
.await;
let resp1 = auth
.handle_request(post_json(
"/api-key/verify",
serde_json::json!({"key": raw_key}),
))
.await
.unwrap();
assert_eq!(resp1.status, 200);
let j1: serde_json::Value = serde_json::from_slice(&resp1.body).unwrap();
assert_eq!(j1["valid"], true, "1st verify should be valid");
let resp2 = auth
.handle_request(post_json(
"/api-key/verify",
serde_json::json!({"key": raw_key}),
))
.await
.unwrap();
assert_eq!(resp2.status, 200);
let j2: serde_json::Value = serde_json::from_slice(&resp2.body).unwrap();
assert_eq!(j2["valid"], true, "2nd verify should be valid");
let resp3 = auth
.handle_request(post_json(
"/api-key/verify",
serde_json::json!({"key": raw_key}),
))
.await
.unwrap();
assert_eq!(resp3.status, 200);
let j3: serde_json::Value = serde_json::from_slice(&resp3.body).unwrap();
assert_eq!(
j3["valid"], false,
"3rd verify should be invalid (rate-limited)"
);
assert_eq!(
j3["error"]["code"], "RATE_LIMITED",
"error code must be RATE_LIMITED"
);
}
#[tokio::test]
async fn e2e_remaining_consumption_third_fails() {
let auth = create_test_auth().await;
let (_token, raw_key, _user_id) = setup_user_with_api_key(
&auth,
"e2e_remaining@example.com",
serde_json::json!({
"name": "remaining-key",
"remaining": 2
}),
)
.await;
let resp1 = auth
.handle_request(post_json(
"/api-key/verify",
serde_json::json!({"key": raw_key}),
))
.await
.unwrap();
assert_eq!(resp1.status, 200);
let j1: serde_json::Value = serde_json::from_slice(&resp1.body).unwrap();
assert_eq!(j1["valid"], true, "1st verify should be valid");
let resp2 = auth
.handle_request(post_json(
"/api-key/verify",
serde_json::json!({"key": raw_key}),
))
.await
.unwrap();
assert_eq!(resp2.status, 200);
let j2: serde_json::Value = serde_json::from_slice(&resp2.body).unwrap();
assert_eq!(j2["valid"], true, "2nd verify should be valid");
let resp3 = auth
.handle_request(post_json(
"/api-key/verify",
serde_json::json!({"key": raw_key}),
))
.await
.unwrap();
assert_eq!(resp3.status, 200);
let j3: serde_json::Value = serde_json::from_slice(&resp3.body).unwrap();
assert_eq!(
j3["valid"], false,
"3rd verify should be invalid (usage exceeded)"
);
assert_eq!(
j3["error"]["code"], "USAGE_EXCEEDED",
"error code must be USAGE_EXCEEDED"
);
}
#[tokio::test]
async fn e2e_refill_resets_after_interval() {
let auth = create_test_auth().await;
let (_token, raw_key, _user_id) = setup_user_with_api_key(
&auth,
"e2e_refill@example.com",
serde_json::json!({
"name": "refill-key",
"remaining": 1,
"refillInterval": 1000,
"refillAmount": 5
}),
)
.await;
let resp1 = auth
.handle_request(post_json(
"/api-key/verify",
serde_json::json!({"key": raw_key}),
))
.await
.unwrap();
assert_eq!(resp1.status, 200);
let j1: serde_json::Value = serde_json::from_slice(&resp1.body).unwrap();
assert_eq!(j1["valid"], true, "1st verify should be valid");
let resp2 = auth
.handle_request(post_json(
"/api-key/verify",
serde_json::json!({"key": raw_key}),
))
.await
.unwrap();
assert_eq!(resp2.status, 200);
let j2: serde_json::Value = serde_json::from_slice(&resp2.body).unwrap();
assert_eq!(
j2["valid"], false,
"2nd verify should be invalid (remaining exhausted)"
);
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let resp3 = auth
.handle_request(post_json(
"/api-key/verify",
serde_json::json!({"key": raw_key}),
))
.await
.unwrap();
assert_eq!(resp3.status, 200);
let j3: serde_json::Value = serde_json::from_slice(&resp3.body).unwrap();
assert_eq!(j3["valid"], true, "3rd verify after refill should be valid");
}
#[tokio::test]
async fn e2e_permissions_validation() {
let auth = create_test_auth().await;
let (_token, raw_key, _user_id) = setup_user_with_api_key(
&auth,
"e2e_perms@example.com",
serde_json::json!({
"name": "perm-key",
"permissions": {"admin": ["read"]}
}),
)
.await;
let req_ok = post_json(
"/api-key/verify",
serde_json::json!({
"key": raw_key,
"permissions": {"admin": ["read"]}
}),
);
let resp_ok = auth.handle_request(req_ok).await.unwrap();
assert_eq!(resp_ok.status, 200);
let j_ok: serde_json::Value = serde_json::from_slice(&resp_ok.body).unwrap();
assert_eq!(
j_ok["valid"], true,
"verify with correct permissions should be valid"
);
let req_fail = post_json(
"/api-key/verify",
serde_json::json!({
"key": raw_key,
"permissions": {"admin": ["write"]}
}),
);
let resp_fail = auth.handle_request(req_fail).await.unwrap();
assert_eq!(resp_fail.status, 200);
let j_fail: serde_json::Value = serde_json::from_slice(&resp_fail.body).unwrap();
assert_eq!(
j_fail["valid"], false,
"verify with wrong permissions should be invalid"
);
assert_eq!(
j_fail["error"]["code"], "KEY_NOT_FOUND",
"permission failure returns KEY_NOT_FOUND (matching TS behaviour)"
);
}
#[tokio::test]
async fn e2e_concurrent_rate_limiting_sequential() {
let auth = create_test_auth().await;
let (_token, raw_key, _user_id) = setup_user_with_api_key(
&auth,
"e2e_concurrent_rl@example.com",
serde_json::json!({
"name": "concurrent-rl-key",
"rateLimitEnabled": true,
"rateLimitMax": 2,
"rateLimitTimeWindow": 60000
}),
)
.await;
let mut successes = 0u32;
let mut rate_limited = 0u32;
for _ in 0..5 {
let req = post_json("/api-key/verify", serde_json::json!({"key": raw_key}));
let resp = auth.handle_request(req).await.unwrap();
assert_eq!(resp.status, 200);
let j: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
if j["valid"] == true {
successes += 1;
} else {
rate_limited += 1;
}
}
assert_eq!(successes, 2, "exactly 2 requests should succeed");
assert_eq!(rate_limited, 3, "exactly 3 requests should be rate-limited");
}
#[tokio::test]
async fn e2e_delete_expired_api_keys() {
let auth = create_test_auth().await;
let (token, _raw_key, _user_id) = setup_user_with_api_key(
&auth,
"e2e_expire@example.com",
serde_json::json!({
"name": "expire-key",
"expiresIn": 1
}),
)
.await;
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let req = post_json_with_auth(
"/api-key/delete-all-expired-api-keys",
serde_json::json!({}),
&token,
);
let resp = auth.handle_request(req).await.unwrap();
assert_eq!(
resp.status, 200,
"delete-all-expired should succeed with auth"
);
let json: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
json["deleted"].is_number(),
"response should contain 'deleted' count, got: {:?}",
json
);
let deleted = json["deleted"].as_u64().unwrap();
assert!(deleted >= 1, "at least 1 expired key should be deleted");
let list_req = get_with_auth("/api-key/list", &token);
let list_resp = auth.handle_request(list_req).await.unwrap();
assert_eq!(list_resp.status, 200);
let keys: Vec<serde_json::Value> = serde_json::from_slice(&list_resp.body).unwrap();
assert_eq!(keys.len(), 0, "expired key should have been deleted");
}
#[tokio::test]
async fn e2e_delete_expired_unauthenticated_returns_401() {
let auth = create_test_auth().await;
let req = post_json(
"/api-key/delete-all-expired-api-keys",
serde_json::json!({}),
);
let resp = auth.handle_request(req).await.unwrap();
assert_eq!(
resp.status, 401,
"delete-all-expired without auth should return 401"
);
}
#[tokio::test]
async fn e2e_virtual_user_id_not_settable_externally() {
let req = AuthRequest::new(HttpMethod::Get, "/test");
assert!(
req.virtual_user_id().is_none(),
"AuthRequest::new must initialise virtual_user_id to None"
);
let req2 = AuthRequest::from_parts(
HttpMethod::Post,
"/test".to_string(),
std::collections::HashMap::new(),
None,
std::collections::HashMap::new(),
);
assert!(
req2.virtual_user_id().is_none(),
"AuthRequest::from_parts must initialise virtual_user_id to None"
);
}