use super::*;
use crate::oauth2::provider::ProviderConfig;
use crate::oauth2::{
FedCMCallbackRequest, OAuth2Account, OAuth2Error, prepare_fedcm_nonce,
prepare_oauth2_auth_request_inner,
};
use crate::test_utils::init_test_environment;
use crate::userdb::User;
use chrono::Utc;
use serial_test::serial;
async fn get_authorized_core_with_ctx(
ctx: &ProviderConfig,
auth_response: &AuthResponse,
cookies: &headers::Cookie,
headers: &HeaderMap,
) -> Result<(HeaderMap, String), CoordinationError> {
authorized_core(ctx, HttpMethod::Get, auth_response, cookies, headers).await
}
async fn post_authorized_core_with_ctx(
ctx: &ProviderConfig,
auth_response: &AuthResponse,
cookies: &headers::Cookie,
headers: &HeaderMap,
) -> Result<(HeaderMap, String), CoordinationError> {
authorized_core(ctx, HttpMethod::Post, auth_response, cookies, headers).await
}
async fn fedcm_authorized_core_with_ctx(
ctx: &ProviderConfig,
request: &FedCMCallbackRequest,
headers: &HeaderMap,
) -> Result<(HeaderMap, String), CoordinationError> {
let idinfo = validate_fedcm_token(ctx, &request.credential, &request.nonce_id).await?;
let oauth2_account = oauth2_account_from_idinfo(&idinfo, ctx)?;
let mode = match &request.mode {
Some(mode_str) => {
let parsed: OAuth2Mode = mode_str.parse().map_err(|_| {
CoordinationError::InvalidState(format!("Invalid FedCM mode: {mode_str}"))
})?;
Some(parsed)
}
None => None,
};
if matches!(mode, Some(OAuth2Mode::AddToUser)) {
return Err(CoordinationError::InvalidState(
"FedCM does not support add_to_user mode".to_string(),
));
}
let login_context = LoginContext::from_headers(headers);
let result = process_authenticated_oauth2_user(
oauth2_account,
mode,
AuthMethod::FedCM,
login_context,
None,
None,
None,
)
.await?;
Ok(result)
}
use axum::routing::{get, post};
use headers::HeaderMapExt;
use serde_json::json;
use std::collections::HashMap;
use std::sync::{Arc, Mutex as StdMutex};
#[tokio::test]
#[serial]
async fn test_get_oauth2_field_mappings_defaults() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let (account_field, label_field) = get_oauth2_field_mappings();
assert_eq!(
account_field, "email",
"Default account field should be 'email'"
);
assert_eq!(label_field, "name", "Default label field should be 'name'");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_account_and_label_from_oauth2_account() -> Result<(), Box<dyn std::error::Error>>
{
init_test_environment().await;
let oauth2_account = OAuth2Account {
sequence_number: None,
id: "test_id".to_string(),
user_id: "test_user".to_string(),
provider: "google".to_string(),
provider_user_id: "google_123".to_string(),
name: "John Doe".to_string(),
email: "john.doe@example.com".to_string(),
picture: Some("https://example.com/picture.jpg".to_string()),
metadata: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
let (account, label) = get_account_and_label_from_oauth2_account(&oauth2_account);
assert_eq!(
account, "john.doe@example.com",
"Account should be mapped to email"
);
assert_eq!(label, "John Doe", "Label should be mapped to name");
Ok(())
}
async fn create_test_user_in_db(user_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let user = User {
id: user_id.to_string(),
account: "test_account".to_string(),
label: "Test User".to_string(),
is_admin: false,
sequence_number: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
UserStore::upsert_user(user).await?;
Ok(())
}
async fn create_test_oauth2_account_in_db(
user_id: &str,
provider: &str,
provider_user_id: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let timestamp = Utc::now().timestamp_nanos_opt().unwrap_or(0);
let unique_provider_user_id = format!("{provider_user_id}-{timestamp}");
let account_id = format!("test-id-{timestamp}");
let oauth2_account = OAuth2Account {
sequence_number: None,
id: account_id.clone(),
user_id: user_id.to_string(),
provider: provider.to_string(),
provider_user_id: unique_provider_user_id.clone(),
name: "Test User".to_string(),
email: "test@example.com".to_string(),
picture: Some("https://example.com/picture.jpg".to_string()),
metadata: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
OAuth2Store::upsert_oauth2_account(oauth2_account).await?;
Ok(unique_provider_user_id)
}
#[tokio::test]
#[serial]
async fn test_list_accounts_core() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let timestamp = chrono::Utc::now().timestamp_millis();
let user_id = format!("test_user_list_accounts_{timestamp}");
let provider1 = "google";
let provider2 = "github";
let provider_user_id1 = "google_user_123";
let provider_user_id2 = "github_user_456";
create_test_user_in_db(&user_id).await?;
let _unique_provider_user_id1 =
create_test_oauth2_account_in_db(&user_id, provider1, provider_user_id1).await?;
let _unique_provider_user_id2 =
create_test_oauth2_account_in_db(&user_id, provider2, provider_user_id2).await?;
let accounts = list_accounts_core(UserId::new(user_id.clone()).expect("Valid user ID")).await?;
assert_eq!(
accounts.len(),
2,
"Expected 2 OAuth2 accounts, got: {}",
accounts.len()
);
for account in &accounts {
assert_eq!(
account.user_id, user_id,
"Account should belong to the test user"
);
}
Ok(())
}
#[tokio::test]
#[serial]
async fn test_delete_oauth2_account_core_success() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let user_id = "test_user_delete_success";
let provider = "google";
let provider_user_id = "google_user_delete_123";
create_test_user_in_db(user_id).await?;
let unique_provider_user_id =
create_test_oauth2_account_in_db(user_id, provider, provider_user_id).await?;
let result = delete_oauth2_account_core(
UserId::new(user_id.to_string()).expect("Valid user ID"),
Provider::new(provider.to_string()).expect("Valid provider"),
ProviderUserId::new(unique_provider_user_id.clone()).expect("Valid user ID"),
)
.await;
assert!(
result.is_ok(),
"Failed to delete OAuth2 account: {result:?}"
);
let accounts = OAuth2Store::get_oauth2_accounts_by(AccountSearchField::ProviderUserId(
crate::oauth2::ProviderUserId::new(unique_provider_user_id).expect("Valid user ID"),
))
.await?;
assert!(accounts.is_empty(), "OAuth2 account was not deleted");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_delete_oauth2_account_core_unauthorized() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let timestamp = chrono::Utc::now().timestamp_millis();
let user_id = format!("test_user_delete_owner_{timestamp}");
let other_user_id = format!("test_user_delete_unauthorized_{timestamp}");
let provider = "google";
let provider_user_id = format!("google_user_delete_456_{timestamp}");
create_test_user_in_db(&user_id).await?;
create_test_user_in_db(&other_user_id).await?;
let unique_provider_user_id =
create_test_oauth2_account_in_db(&user_id, provider, &provider_user_id).await?;
let result = delete_oauth2_account_core(
UserId::new(other_user_id).expect("Valid user ID"),
Provider::new(provider.to_string()).expect("Valid provider"),
ProviderUserId::new(unique_provider_user_id).expect("Valid user ID"),
)
.await;
assert!(
matches!(result, Err(CoordinationError::Unauthorized)),
"Expected Unauthorized error, got: {result:?}"
);
Ok(())
}
const MOCK_PORT: u16 = 19876;
const MOCK_BASE_URL: &str = "http://127.0.0.1:19876";
const JWT_SECRET: &[u8] = b"test_secret";
struct StoredAuthRequest {
nonce: Option<String>,
code_challenge: Option<String>,
}
#[derive(Clone)]
struct MockServerState {
auth_codes: Arc<StdMutex<HashMap<String, StoredAuthRequest>>>,
user_email: Arc<StdMutex<String>>,
user_sub: Arc<StdMutex<String>>,
user_name: Arc<StdMutex<String>>,
}
impl Default for MockServerState {
fn default() -> Self {
Self {
auth_codes: Arc::new(StdMutex::new(HashMap::new())),
user_email: Arc::new(StdMutex::new("first-user@example.com".to_string())),
user_sub: Arc::new(StdMutex::new("first-user-test-google-id".to_string())),
user_name: Arc::new(StdMutex::new("First User".to_string())),
}
}
}
struct MockServerHandle {
state: MockServerState,
}
impl MockServerHandle {
fn configure_user(&self, email: &str, sub: &str, name: &str) {
*self.state.user_email.lock().unwrap() = email.to_string();
*self.state.user_sub.lock().unwrap() = sub.to_string();
*self.state.user_name.lock().unwrap() = name.to_string();
}
fn reset_to_first_user(&self) {
self.configure_user(
"first-user@example.com",
"first-user-test-google-id",
"First User",
);
}
fn configure_user_guarded(&self, email: &str, sub: &str, name: &str) -> MockUserGuard<'_> {
self.configure_user(email, sub, name);
MockUserGuard { handle: self }
}
}
struct MockUserGuard<'a> {
handle: &'a MockServerHandle,
}
impl Drop for MockUserGuard<'_> {
fn drop(&mut self) {
self.handle.reset_to_first_user();
}
}
static MOCK_SERVER: LazyLock<MockServerHandle> = LazyLock::new(|| {
let state = MockServerState::default();
let state_for_server = state.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime for core mock OAuth2 server");
rt.block_on(async {
let app = axum::Router::new()
.route("/oauth2/auth", get(mock_auth_handler))
.route("/oauth2/token", post(mock_token_handler))
.route("/oauth2/v3/certs", get(mock_jwks_handler))
.route("/oauth2/userinfo", get(mock_userinfo_handler))
.with_state(state_for_server);
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{MOCK_PORT}"))
.await
.expect("Failed to bind core mock OAuth2 server");
axum::serve(listener, app)
.await
.expect("Core mock OAuth2 server failed");
});
});
for _ in 0..50 {
if std::net::TcpStream::connect(format!("127.0.0.1:{MOCK_PORT}")).is_ok() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
std::net::TcpStream::connect(format!("127.0.0.1:{MOCK_PORT}"))
.expect("Core mock OAuth2 server failed to start within timeout");
MockServerHandle { state }
});
fn ensure_mock_server() -> &'static MockServerHandle {
&MOCK_SERVER
}
async fn mock_auth_handler(
axum::extract::Query(params): axum::extract::Query<HashMap<String, String>>,
axum::extract::State(state): axum::extract::State<MockServerState>,
) -> axum::response::Redirect {
let auth_code = uuid::Uuid::new_v4().to_string();
let redirect_uri = params.get("redirect_uri").cloned().unwrap_or_default();
state.auth_codes.lock().unwrap().insert(
auth_code.clone(),
StoredAuthRequest {
nonce: params.get("nonce").cloned(),
code_challenge: params.get("code_challenge").cloned(),
},
);
let state_param = params.get("state").cloned().unwrap_or_default();
axum::response::Redirect::to(&format!(
"{redirect_uri}?code={auth_code}&state={state_param}"
))
}
async fn mock_token_handler(
axum::extract::State(state): axum::extract::State<MockServerState>,
axum::extract::Form(params): axum::extract::Form<HashMap<String, String>>,
) -> Result<axum::Json<serde_json::Value>, axum::http::StatusCode> {
let code = params
.get("code")
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
let code_verifier = params.get("code_verifier");
let auth_req = state
.auth_codes
.lock()
.unwrap()
.remove(code)
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
if let Some(challenge) = &auth_req.code_challenge {
let verifier = code_verifier.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
use base64::Engine as _;
use sha2::{Digest, Sha256};
let computed = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(verifier.as_bytes()));
if computed != *challenge {
return Err(axum::http::StatusCode::BAD_REQUEST);
}
}
let email = state.user_email.lock().unwrap().clone();
let sub = state.user_sub.lock().unwrap().clone();
let name = state.user_name.lock().unwrap().clone();
let id_token = create_mock_jwt(&email, &sub, &name, auth_req.nonce.as_deref());
Ok(axum::Json(json!({
"access_token": "mock_access_token",
"id_token": id_token,
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid email profile"
})))
}
async fn mock_jwks_handler() -> axum::Json<serde_json::Value> {
use base64::Engine as _;
axum::Json(json!({
"keys": [{
"kty": "oct",
"kid": "mock_key_id",
"use": "sig",
"alg": "HS256",
"k": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(JWT_SECRET)
}]
}))
}
async fn mock_userinfo_handler(
axum::extract::State(state): axum::extract::State<MockServerState>,
) -> axum::Json<serde_json::Value> {
let email = state.user_email.lock().unwrap().clone();
let sub = state.user_sub.lock().unwrap().clone();
let name = state.user_name.lock().unwrap().clone();
let parts: Vec<&str> = name.splitn(2, ' ').collect();
let given_name = parts.first().copied().unwrap_or(&name);
let family_name = parts.get(1).copied().unwrap_or("");
axum::Json(json!({
"sub": sub,
"email": email,
"name": name,
"given_name": given_name,
"family_name": family_name,
"picture": "https://example.com/photo.jpg",
"email_verified": true
}))
}
fn create_mock_jwt(email: &str, sub: &str, name: &str, nonce: Option<&str>) -> String {
use jsonwebtoken::{EncodingKey, Header, encode};
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let parts: Vec<&str> = name.splitn(2, ' ').collect();
let given_name = parts.first().copied().unwrap_or(name);
let family_name = parts.get(1).copied().unwrap_or("");
let mut claims = json!({
"iss": MOCK_BASE_URL,
"sub": sub,
"aud": "test-client-id.apps.googleusercontent.com",
"azp": "test-client-id.apps.googleusercontent.com",
"exp": now + 3600,
"iat": now,
"email": email,
"name": name,
"given_name": given_name,
"family_name": family_name,
"email_verified": true
});
if let Some(nonce_value) = nonce {
claims["nonce"] = json!(nonce_value);
}
let mut header = Header::new(jsonwebtoken::Algorithm::HS256);
header.kid = Some("mock_key_id".to_string());
encode(&header, &claims, &EncodingKey::from_secret(JWT_SECRET))
.expect("Failed to create mock JWT")
}
fn set_mock_env_vars() {
use std::io::Write;
let env_content = format!(
"OAUTH2_AUTH_URL='{MOCK_BASE_URL}/oauth2/auth'\n\
OAUTH2_TOKEN_URL='{MOCK_BASE_URL}/oauth2/token'\n\
OAUTH2_JWKS_URL='{MOCK_BASE_URL}/oauth2/v3/certs'\n\
OAUTH2_USERINFO_URL='{MOCK_BASE_URL}/oauth2/userinfo'\n\
OAUTH2_EXPECTED_ISSUER='{MOCK_BASE_URL}'\n"
);
let temp_path = "/tmp/oauth2_passkey_core_mock_env";
let mut file =
std::fs::File::create(temp_path).expect("Failed to create temp env file for mock");
file.write_all(env_content.as_bytes())
.expect("Failed to write temp env file for mock");
dotenvy::from_filename_override(temp_path).expect("Failed to load mock env vars");
}
async fn drive_oauth2_flow(
mode: &str,
extra_request_headers: Option<&http::HeaderMap>,
) -> Result<
(
crate::oauth2::AuthResponse,
headers::Cookie,
http::HeaderMap,
),
Box<dyn std::error::Error>,
> {
let mut request_headers = http::HeaderMap::new();
request_headers.insert(http::header::USER_AGENT, "TestBrowser/1.0".parse().unwrap());
if let Some(extra) = extra_request_headers {
for (key, value) in extra {
request_headers.insert(key.clone(), value.clone());
}
}
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let (auth_url, response_headers) =
prepare_oauth2_auth_request_inner(&ctx, request_headers, Some(mode)).await?;
let set_cookie = response_headers
.get(http::header::SET_COOKIE)
.expect("prepare_oauth2_auth_request should set CSRF cookie")
.to_str()?;
let csrf_cookie_pair = set_cookie
.split(';')
.next()
.expect("Set-Cookie should have name=value");
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let mock_response = client.get(&auth_url).send().await?;
let location = mock_response
.headers()
.get(http::header::LOCATION)
.expect("Mock auth should return redirect with Location header")
.to_str()?;
let parsed = url::Url::parse(location)?;
let query_params: HashMap<String, String> = parsed.query_pairs().into_owned().collect();
let code = query_params
.get("code")
.expect("Redirect should have code param")
.clone();
let state = query_params
.get("state")
.expect("Redirect should have state param")
.clone();
let auth_response: crate::oauth2::AuthResponse = serde_json::from_value(json!({
"code": code,
"state": state,
"_id_token": null,
}))?;
let mut cookie_header_map = http::HeaderMap::new();
cookie_header_map.insert(http::header::COOKIE, csrf_cookie_pair.parse()?);
let cookie: headers::Cookie = cookie_header_map
.typed_get()
.expect("Should parse Cookie header");
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, "TestBrowser/1.0".parse().unwrap());
headers.insert(
http::header::REFERER,
format!("{MOCK_BASE_URL}/oauth2/auth").parse().unwrap(),
);
Ok((auth_response, cookie, headers))
}
#[tokio::test]
#[serial]
async fn test_post_authorized_core_wrong_response_mode() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let auth_response: crate::oauth2::AuthResponse = serde_json::from_value(json!({
"code": "dummy_code",
"state": "dummy_state",
"_id_token": null,
}))?;
let mut cookie_hmap = http::HeaderMap::new();
cookie_hmap.insert(http::header::COOKIE, "dummy=value".parse().unwrap());
let cookie: headers::Cookie = cookie_hmap.typed_get().expect("Should parse Cookie header");
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result =
post_authorized_core_with_ctx(&ctx, &auth_response, &cookie, &http::HeaderMap::new()).await;
assert!(
matches!(result, Err(CoordinationError::InvalidResponseMode(_))),
"Expected InvalidResponseMode error for POST with query mode, got: {result:?}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_authorized_core_login_existing_user() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
mock.reset_to_first_user();
let (auth_response, cookie, headers) = drive_oauth2_flow("login", None).await?;
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = get_authorized_core_with_ctx(&ctx, &auth_response, &cookie, &headers).await;
assert!(
result.is_ok(),
"Login with existing account should succeed: {result:?}"
);
let (response_headers, message) = result.unwrap();
assert!(
response_headers.get(http::header::SET_COOKIE).is_some(),
"Response should include Set-Cookie header for session"
);
assert!(
message.contains("Signing in"),
"Message should indicate sign-in: {message}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_authorized_core_login_nonexistent_account()
-> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
let _guard = mock.configure_user_guarded(
"nonexistent@example.com",
"nonexistent-user-id",
"Nonexistent User",
);
let (auth_response, cookie, headers) = drive_oauth2_flow("login", None).await?;
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = get_authorized_core_with_ctx(&ctx, &auth_response, &cookie, &headers).await;
assert!(
matches!(result, Err(CoordinationError::Conflict(_))),
"Login with nonexistent account should return Conflict error, got: {result:?}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_authorized_core_create_new_user() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
let timestamp = chrono::Utc::now().timestamp_millis();
let new_email = format!("new-user-{timestamp}@example.com");
let new_sub = format!("new-user-{timestamp}");
let new_name = format!("New User {timestamp}");
let _guard = mock.configure_user_guarded(&new_email, &new_sub, &new_name);
let (auth_response, cookie, headers) = drive_oauth2_flow("create_user", None).await?;
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = get_authorized_core_with_ctx(&ctx, &auth_response, &cookie, &headers).await;
assert!(
result.is_ok(),
"Creating new user should succeed: {result:?}"
);
let (response_headers, message) = result.unwrap();
assert!(
response_headers.get(http::header::SET_COOKIE).is_some(),
"Response should include Set-Cookie header for session"
);
assert!(
message.contains("Created new user"),
"Message should indicate user creation: {message}"
);
let expected_provider_user_id = format!("google_{new_sub}");
let provider = crate::oauth2::Provider::new("google".to_string()).unwrap();
let provider_user_id = crate::oauth2::ProviderUserId::new(expected_provider_user_id).unwrap();
let account =
crate::oauth2::OAuth2Store::get_oauth2_account_by_provider(provider, provider_user_id)
.await?;
assert!(
account.is_some(),
"OAuth2 account should exist in database after creation"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_authorized_core_create_user_or_login() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
let timestamp = chrono::Utc::now().timestamp_millis();
let new_email = format!("dual-mode-{timestamp}@example.com");
let new_sub = format!("dual-mode-{timestamp}");
let new_name = format!("Dual Mode User {timestamp}");
let _guard = mock.configure_user_guarded(&new_email, &new_sub, &new_name);
let (auth_response, cookie, headers) = drive_oauth2_flow("create_user_or_login", None).await?;
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = get_authorized_core_with_ctx(&ctx, &auth_response, &cookie, &headers).await;
assert!(
result.is_ok(),
"create_user_or_login with new identity should succeed: {result:?}"
);
let (_headers, message) = result.unwrap();
assert!(
message.contains("Created new user"),
"Should create new user: {message}"
);
let (auth_response, cookie, headers) = drive_oauth2_flow("create_user_or_login", None).await?;
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = get_authorized_core_with_ctx(&ctx, &auth_response, &cookie, &headers).await;
assert!(
result.is_ok(),
"create_user_or_login with existing identity should succeed: {result:?}"
);
let (_headers, message) = result.unwrap();
assert!(
message.contains("Signing in"),
"Should sign in with existing account: {message}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_authorized_core_add_to_user() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
let first_user_id = UserId::new("first-user".to_string()).expect("Valid user ID");
let session_headers = crate::session::new_session_header(first_user_id).await?;
let session_set_cookie = session_headers
.get(http::header::SET_COOKIE)
.expect("new_session_header should set session cookie")
.to_str()?;
let mut session_request_headers = http::HeaderMap::new();
let session_cookie_pair = session_set_cookie
.split(';')
.next()
.expect("Set-Cookie should have name=value");
session_request_headers.insert(http::header::COOKIE, session_cookie_pair.parse()?);
let timestamp = chrono::Utc::now().timestamp_millis();
let link_email = format!("linked-{timestamp}@example.com");
let link_sub = format!("linked-{timestamp}");
let link_name = format!("Linked User {timestamp}");
let _guard = mock.configure_user_guarded(&link_email, &link_sub, &link_name);
let (auth_response, cookie, headers) =
drive_oauth2_flow("add_to_user", Some(&session_request_headers)).await?;
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = get_authorized_core_with_ctx(&ctx, &auth_response, &cookie, &headers).await;
assert!(result.is_ok(), "add_to_user should succeed: {result:?}");
let (_response_headers, message) = result.unwrap();
assert!(
message.contains("linked"),
"Message should indicate account linking: {message}"
);
let expected_provider_user_id = format!("google_{link_sub}");
let provider = crate::oauth2::Provider::new("google".to_string()).unwrap();
let provider_user_id = crate::oauth2::ProviderUserId::new(expected_provider_user_id).unwrap();
let account =
crate::oauth2::OAuth2Store::get_oauth2_account_by_provider(provider, provider_user_id)
.await?;
assert!(account.is_some(), "Linked OAuth2 account should exist");
assert_eq!(
account.unwrap().user_id,
"first-user",
"Linked account should belong to the first user"
);
Ok(())
}
async fn drive_fedcm_flow(
mode: Option<&str>,
) -> Result<(http::HeaderMap, String), Box<dyn std::error::Error>> {
let nonce_response = prepare_fedcm_nonce().await?;
let mock = ensure_mock_server();
let email = mock.state.user_email.lock().unwrap().clone();
let sub = mock.state.user_sub.lock().unwrap().clone();
let name = mock.state.user_name.lock().unwrap().clone();
let jwt = create_mock_jwt(&email, &sub, &name, Some(&nonce_response.nonce));
let request = FedCMCallbackRequest {
credential: jwt,
nonce_id: nonce_response.nonce_id,
mode: mode.map(|s| s.to_string()),
};
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, "TestBrowser/1.0".parse().unwrap());
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = fedcm_authorized_core_with_ctx(&ctx, &request, &headers).await?;
Ok(result)
}
#[tokio::test]
#[serial]
async fn test_fedcm_login_existing_user() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
mock.reset_to_first_user();
let (response_headers, message) = drive_fedcm_flow(Some("login")).await?;
assert!(
response_headers.get(http::header::SET_COOKIE).is_some(),
"Response should include Set-Cookie header for session"
);
assert!(
message.contains("Signing in"),
"Message should indicate sign-in: {message}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_fedcm_login_nonexistent_account() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
let _guard = mock.configure_user_guarded(
"fedcm-nonexistent@example.com",
"fedcm-nonexistent-id",
"FedCM Nonexistent User",
);
let nonce_response = prepare_fedcm_nonce().await?;
let jwt = create_mock_jwt(
"fedcm-nonexistent@example.com",
"fedcm-nonexistent-id",
"FedCM Nonexistent User",
Some(&nonce_response.nonce),
);
let request = FedCMCallbackRequest {
credential: jwt,
nonce_id: nonce_response.nonce_id,
mode: Some("login".to_string()),
};
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, "TestBrowser/1.0".parse().unwrap());
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = fedcm_authorized_core_with_ctx(&ctx, &request, &headers).await;
assert!(
matches!(result, Err(CoordinationError::Conflict(_))),
"FedCM login with nonexistent account should return Conflict error, got: {result:?}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_fedcm_create_new_user() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
let timestamp = Utc::now().timestamp_millis();
let new_email = format!("fedcm-new-{timestamp}@example.com");
let new_sub = format!("fedcm-new-{timestamp}");
let new_name = format!("FedCM New User {timestamp}");
let _guard = mock.configure_user_guarded(&new_email, &new_sub, &new_name);
let (response_headers, message) = drive_fedcm_flow(Some("create_user")).await?;
assert!(
response_headers.get(http::header::SET_COOKIE).is_some(),
"Response should include Set-Cookie header for session"
);
assert!(
message.contains("Created new user"),
"Message should indicate user creation: {message}"
);
let expected_provider_user_id = format!("google_{new_sub}");
let provider = crate::oauth2::Provider::new("google".to_string()).unwrap();
let provider_user_id = crate::oauth2::ProviderUserId::new(expected_provider_user_id).unwrap();
let account =
crate::oauth2::OAuth2Store::get_oauth2_account_by_provider(provider, provider_user_id)
.await?;
assert!(
account.is_some(),
"OAuth2 account should exist in database after FedCM creation"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_fedcm_create_user_or_login() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
let timestamp = Utc::now().timestamp_millis();
let new_email = format!("fedcm-dual-{timestamp}@example.com");
let new_sub = format!("fedcm-dual-{timestamp}");
let new_name = format!("FedCM Dual Mode {timestamp}");
let _guard = mock.configure_user_guarded(&new_email, &new_sub, &new_name);
let (_headers, message) = drive_fedcm_flow(Some("create_user_or_login")).await?;
assert!(
message.contains("Created new user"),
"Should create new user: {message}"
);
let (_headers, message) = drive_fedcm_flow(Some("create_user_or_login")).await?;
assert!(
message.contains("Signing in"),
"Should sign in with existing account: {message}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_fedcm_reject_add_to_user() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
mock.reset_to_first_user();
let nonce_response = prepare_fedcm_nonce().await?;
let jwt = create_mock_jwt(
"first-user@example.com",
"first-user-test-google-id",
"First User",
Some(&nonce_response.nonce),
);
let request = FedCMCallbackRequest {
credential: jwt,
nonce_id: nonce_response.nonce_id,
mode: Some("add_to_user".to_string()),
};
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, "TestBrowser/1.0".parse().unwrap());
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = fedcm_authorized_core_with_ctx(&ctx, &request, &headers).await;
assert!(
matches!(result, Err(CoordinationError::InvalidState(_))),
"FedCM should reject add_to_user mode, got: {result:?}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_fedcm_create_existing_user_conflict() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
mock.reset_to_first_user();
let nonce_response = prepare_fedcm_nonce().await?;
let jwt = create_mock_jwt(
"first-user@example.com",
"first-user-test-google-id",
"First User",
Some(&nonce_response.nonce),
);
let request = FedCMCallbackRequest {
credential: jwt,
nonce_id: nonce_response.nonce_id,
mode: Some("create_user".to_string()),
};
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, "TestBrowser/1.0".parse().unwrap());
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = fedcm_authorized_core_with_ctx(&ctx, &request, &headers).await;
assert!(
matches!(result, Err(CoordinationError::Conflict(_))),
"FedCM create_user with existing account should return Conflict, got: {result:?}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_fedcm_nonce_replay() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
mock.reset_to_first_user();
let nonce_response = prepare_fedcm_nonce().await?;
let jwt = create_mock_jwt(
"first-user@example.com",
"first-user-test-google-id",
"First User",
Some(&nonce_response.nonce),
);
let request = FedCMCallbackRequest {
credential: jwt.clone(),
nonce_id: nonce_response.nonce_id.clone(),
mode: Some("login".to_string()),
};
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, "TestBrowser/1.0".parse().unwrap());
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let first_result = fedcm_authorized_core_with_ctx(&ctx, &request, &headers).await;
assert!(
first_result.is_ok(),
"First FedCM call should succeed: {first_result:?}"
);
let replay_request = FedCMCallbackRequest {
credential: jwt,
nonce_id: nonce_response.nonce_id,
mode: Some("login".to_string()),
};
let replay_result = fedcm_authorized_core_with_ctx(&ctx, &replay_request, &headers).await;
assert!(
matches!(
replay_result,
Err(CoordinationError::OAuth2Error(
OAuth2Error::SecurityTokenNotFound(_)
))
),
"Replayed nonce should return SecurityTokenNotFound, got: {replay_result:?}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_fedcm_nonce_mismatch() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
mock.reset_to_first_user();
let nonce_response = prepare_fedcm_nonce().await?;
let jwt = create_mock_jwt(
"first-user@example.com",
"first-user-test-google-id",
"First User",
Some("wrong-nonce-value"), );
let request = FedCMCallbackRequest {
credential: jwt,
nonce_id: nonce_response.nonce_id,
mode: Some("login".to_string()),
};
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, "TestBrowser/1.0".parse().unwrap());
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = fedcm_authorized_core_with_ctx(&ctx, &request, &headers).await;
assert!(
matches!(
result,
Err(CoordinationError::OAuth2Error(OAuth2Error::NonceMismatch))
),
"Mismatched nonce should return NonceMismatch, got: {result:?}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_fedcm_mode_none() -> Result<(), Box<dyn std::error::Error>> {
init_test_environment().await;
let mock = ensure_mock_server();
set_mock_env_vars();
mock.reset_to_first_user();
let nonce_response = prepare_fedcm_nonce().await?;
let jwt = create_mock_jwt(
"first-user@example.com",
"first-user-test-google-id",
"First User",
Some(&nonce_response.nonce),
);
let request = FedCMCallbackRequest {
credential: jwt,
nonce_id: nonce_response.nonce_id,
mode: None,
};
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, "TestBrowser/1.0".parse().unwrap());
let ctx = ProviderConfig::for_mock_server(MOCK_BASE_URL);
let result = fedcm_authorized_core_with_ctx(&ctx, &request, &headers).await;
assert!(
matches!(result, Err(CoordinationError::InvalidState(_))),
"FedCM with mode=None should return InvalidState, got: {result:?}"
);
Ok(())
}