use aurabase::types::*;
use aurabase::{create_client, AuraClient};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn fake_user() -> serde_json::Value {
serde_json::json!({
"id": "user-1",
"email": "test@example.com",
"phone": null,
"role": "user",
"email_verified": true,
"is_banned": false,
"is_anonymous": false,
"mfa_enabled": false,
"avatar_url": null,
"user_metadata": {},
"app_metadata": {},
"created_at": "2026-01-01T00:00:00Z",
"last_sign_in_at": "2026-01-01T00:00:00Z"
})
}
fn fake_token_pair() -> serde_json::Value {
serde_json::json!({
"access_token": "access-tok",
"refresh_token": "refresh-tok",
"token_type": "bearer",
"expires_in": 900,
"user": fake_user()
})
}
fn test_client(base_url: &str) -> AuraClient {
create_client(base_url, "aura_anon_test", None)
}
#[tokio::test]
async fn sign_up_saves_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/register"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_token_pair()
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.sign_up(SignUpRequest {
email: "test@example.com".into(),
password: "secret123".into(),
captcha_token: None,
})
.await
.unwrap();
assert!(res.data.is_some());
assert_eq!(client.auth_store().token().unwrap(), "access-tok");
assert_eq!(client.auth_store().refresh_token().unwrap(), "refresh-tok");
}
#[tokio::test]
async fn sign_up_error_returns_aura_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/register"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": { "code": "email_exists", "message": "Email already registered" }
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.sign_up(SignUpRequest {
email: "dup@example.com".into(),
password: "secret123".into(),
captcha_token: None,
})
.await
.unwrap();
assert!(res.data.is_none());
assert!(res.error.is_some());
assert_eq!(res.error.unwrap().code, "email_exists");
}
#[tokio::test]
async fn sign_in_with_password_saves_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/login"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_token_pair()
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.sign_in_with_password(SignInRequest {
email: "test@example.com".into(),
password: "secret123".into(),
captcha_token: None,
})
.await
.unwrap();
assert!(res.data.is_some());
assert_eq!(client.auth_store().token().unwrap(), "access-tok");
}
#[tokio::test]
async fn sign_in_with_password_mfa_challenge() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/login"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {
"mfa_required": true,
"mfa_token": "mfa-tok-123",
"mfa_setup_required": false
}
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.sign_in_with_password(SignInRequest {
email: "test@example.com".into(),
password: "secret123".into(),
captcha_token: None,
})
.await
.unwrap();
assert!(res.data.is_some());
if let Some(LoginResponse::MfaChallenge(mfa)) = res.data {
assert!(mfa.mfa_required);
assert_eq!(mfa.mfa_token, "mfa-tok-123");
} else {
panic!("Expected MFA challenge response");
}
assert!(client.auth_store().token().is_none());
}
#[tokio::test]
async fn sign_out_clears_store() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/register"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_token_pair()
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/auth/logout"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let client = test_client(&server.uri());
client
.auth()
.sign_up(SignUpRequest {
email: "t@t.com".into(),
password: "secret123".into(),
captcha_token: None,
})
.await
.unwrap();
assert!(client.auth_store().token().is_some());
client.auth().sign_out().await.unwrap();
assert!(client.auth_store().token().is_none());
assert!(client.auth_store().user().is_none());
}
#[tokio::test]
async fn refresh_session_updates_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/refresh"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {
"access_token": "new-access",
"refresh_token": "new-refresh",
"token_type": "bearer",
"expires_in": 900,
"user": fake_user()
}
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client.auth().refresh_session().await.unwrap();
assert!(res.data.is_some());
assert_eq!(client.auth_store().token().unwrap(), "new-access");
assert_eq!(client.auth_store().refresh_token().unwrap(), "new-refresh");
}
#[tokio::test]
async fn get_user_returns_user() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/auth/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_user()
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client.auth().get_user().await.unwrap();
assert!(res.data.is_some());
assert_eq!(
res.data.unwrap().email,
Some("test@example.com".to_string())
);
}
#[tokio::test]
async fn sign_in_with_oauth_returns_url() {
let client = create_client("http://localhost:8080", "aura_anon_test", None);
let url = client
.auth()
.sign_in_with_oauth("google", None)
.expect("aucun query_params : aucun refus possible");
assert!(url.contains("/v1/auth/oauth/google/start"));
assert!(!url.contains("project_id"), "{url}");
}
#[tokio::test]
async fn sign_in_with_oauth_includes_options() {
let client = create_client("http://localhost:8080", "aura_anon_test", None);
let url = client
.auth()
.sign_in_with_oauth(
"github",
Some(OAuthSignInOptions {
redirect_to: Some("http://localhost:3000/callback".into()),
scopes: Some("user:email".into()),
query_params: None,
}),
)
.expect("options légitimes : aucun refus");
assert!(url.contains("redirect_to="));
assert!(url.contains("scopes=user%3Aemail"));
}
#[tokio::test]
async fn sign_in_anonymously_saves_session() {
let server = MockServer::start().await;
let mut anon_user = fake_user();
anon_user["is_anonymous"] = serde_json::json!(true);
Mock::given(method("POST"))
.and(path("/v1/auth/anonymous"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {
"access_token": "anon-tok",
"refresh_token": "anon-refresh",
"token_type": "bearer",
"expires_in": 900,
"user": anon_user
}
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client.auth().sign_in_anonymously().await.unwrap();
assert!(res.data.is_some());
assert_eq!(client.auth_store().token().unwrap(), "anon-tok");
assert!(client.auth_store().user().unwrap().is_anonymous);
}
#[tokio::test]
async fn sign_in_with_magic_link() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/magic-link"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": { "message": "Magic link sent" }
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.sign_in_with_magic_link(MagicLinkRequest {
email: "test@example.com".into(),
redirect_url: None,
})
.await
.unwrap();
assert!(res.data.is_some());
}
#[tokio::test]
async fn sign_in_with_email_otp() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/email-otp/send"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": { "message": "OTP sent" }
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.sign_in_with_email_otp("test@example.com")
.await
.unwrap();
assert!(res.data.is_some());
}
#[tokio::test]
async fn verify_otp_email_saves_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/email-otp/verify"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_token_pair()
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.verify_otp(VerifyOtpParams::Email {
email: "test@example.com",
token: "123456",
})
.await
.unwrap();
assert!(res.data.is_some());
assert_eq!(client.auth_store().token().unwrap(), "access-tok");
}
#[tokio::test]
async fn sign_in_with_sms_otp() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/sms/send"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": { "message": "SMS sent" }
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.sign_in_with_sms_otp("+33612345678")
.await
.unwrap();
assert!(res.data.is_some());
}
#[tokio::test]
async fn verify_otp_sms_saves_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/sms/verify"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_token_pair()
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.verify_otp(VerifyOtpParams::Sms {
phone: "+33612345678",
token: "123456",
})
.await
.unwrap();
assert!(res.data.is_some());
assert_eq!(client.auth_store().token().unwrap(), "access-tok");
}
#[tokio::test]
async fn verify_otp_signup_saves_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/verify-email"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_token_pair()
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.verify_otp(VerifyOtpParams::Signup {
token: "verify-tok-123",
})
.await
.unwrap();
assert!(res.data.is_some());
assert_eq!(client.auth_store().token().unwrap(), "access-tok");
}
#[tokio::test]
async fn verify_otp_email_change_conserve_la_session_et_met_a_jour_l_utilisateur() {
let server = MockServer::start().await;
let mut utilisateur = fake_user();
utilisateur["email"] = serde_json::json!("nouvelle@example.com");
Mock::given(method("POST"))
.and(path("/v1/auth/user/confirm-email-change"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": utilisateur })),
)
.mount(&server)
.await;
let client = test_client(&server.uri());
let user: aurabase::types::User = serde_json::from_value(fake_user()).unwrap();
client
.auth_store()
.save("access-tok".to_string(), "refresh-tok".to_string(), user);
let res = client
.auth()
.verify_otp(VerifyOtpParams::EmailChange { token: "chg-tok" })
.await
.unwrap();
assert!(res.data.is_some());
assert_eq!(
client.auth_store().token().as_deref(),
Some("access-tok"),
"la session ne doit pas bouger : ce flux ne délivre pas de jetons"
);
assert_eq!(
client.auth_store().user().unwrap().email.as_deref(),
Some("nouvelle@example.com"),
"l'utilisateur stocké doit refléter la nouvelle adresse"
);
}
#[tokio::test]
async fn mfa_enroll() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/mfa/setup"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {
"otpauth_url": "otpauth://totp/Aurabase:test@example.com?secret=ABC&issuer=Aurabase",
"secret": "ABC"
}
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client.auth().mfa_enroll().await.unwrap();
assert!(res.data.is_some());
assert_eq!(res.data.unwrap().secret, "ABC");
}
#[tokio::test]
async fn mfa_verify_saves_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/mfa/verify-login"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_token_pair()
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.mfa_verify(MfaVerifyRequest {
mfa_token: "mfa-tok".into(),
code: "123456".into(),
})
.await
.unwrap();
assert!(res.data.is_some());
assert_eq!(client.auth_store().token().unwrap(), "access-tok");
}
#[tokio::test]
async fn list_identities() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/auth/user/identities"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{
"id": "id-1",
"provider": "github",
"provider_id": "gh-123",
"identity_data": {},
"auto_linked": false,
"created_at": "2026-01-01T00:00:00Z"
}]
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client.auth().list_identities().await.unwrap();
assert!(res.data.is_some());
assert_eq!(res.data.unwrap().len(), 1);
}
#[tokio::test]
async fn link_identity() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/user/identities/link/github"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": { "url": "https://github.com/login/oauth/authorize?..." }
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client.auth().link_identity("github").await.unwrap();
assert!(res.data.is_some());
}
#[tokio::test]
async fn unlink_identity() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v1/auth/user/identities/identity-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client.auth().unlink_identity("identity-1").await.unwrap();
assert!(res.error.is_none());
}
fn jeton_expire() -> String {
use base64::Engine;
let entete = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256"}"#);
let charge = serde_json::json!({ "exp": chrono::Utc::now().timestamp() - 60, "sub": "user-1" });
let charge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(charge.to_string());
format!("{entete}.{charge}.sig")
}
fn client_avec_session_expiree(base_url: &str) -> AuraClient {
let client = create_client(base_url, "aura_anon_test", None);
let user: aurabase::types::User = serde_json::from_value(fake_user()).unwrap();
client
.auth_store()
.save(jeton_expire(), "refresh-tok".to_string(), user);
client
}
#[tokio::test]
async fn le_rafraichissement_proactif_envoie_la_cle_api() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/refresh"))
.and(wiremock::matchers::header("apikey", "aura_anon_test"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_token_pair()
})))
.mount(&server)
.await;
let client = client_avec_session_expiree(&server.uri());
let _ = client.auth().get_user().await;
assert_eq!(
client.auth_store().token().as_deref(),
Some("access-tok"),
"sans en-tête `apikey`, le mock ne répond pas et le rafraîchissement échoue — c'est \
exactement le 401 « Clé API requise » que rendait le plan données"
);
}
#[tokio::test]
async fn un_401_de_cle_api_ne_deconnecte_pas() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/refresh"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": { "code": "UNAUTHORIZED", "message": "Clé API requise" }
})))
.mount(&server)
.await;
let client = client_avec_session_expiree(&server.uri());
let _ = client.auth().get_user().await;
assert!(
client.auth_store().refresh_token().is_some(),
"un 401 « UNAUTHORIZED » est indiscernable d'un refus de clé par la passerelle : \
détruire la session sur ce signal déconnecte tout un parc à la moindre rotation de clé"
);
}
#[tokio::test]
async fn un_500_ne_deconnecte_pas() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/refresh"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let client = client_avec_session_expiree(&server.uri());
let _ = client.auth().get_user().await;
assert!(
client.auth_store().refresh_token().is_some(),
"une panne serveur n'est pas un verdict sur le refresh token"
);
}
#[tokio::test]
async fn un_refresh_token_definitivement_rejete_efface_bien_la_session() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/refresh"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": { "code": "REFRESH_TOKEN_REJECTED", "message": "Refresh token rejeté" }
})))
.mount(&server)
.await;
let client = client_avec_session_expiree(&server.uri());
let _ = client.auth().get_user().await;
assert!(
client.auth_store().refresh_token().is_none(),
"le discriminant contractuel d'aura-auth condamne le jeton : la session DOIT partir, \
sinon le correctif ne fait que désactiver le nettoyage"
);
}
#[tokio::test]
async fn un_403_efface_aussi_la_session() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/refresh"))
.respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
"error": { "code": "FORBIDDEN", "message": "Refresh token invalide pour ce projet" }
})))
.mount(&server)
.await;
let client = client_avec_session_expiree(&server.uri());
let _ = client.auth().get_user().await;
assert!(
client.auth_store().refresh_token().is_none(),
"la clé partant toujours en en-tête sur ce chemin, le seul 403 possible vient \
d'aura-auth et condamne le jeton"
);
}
#[tokio::test]
async fn verify_otp_magiclink_consomme_le_jeton_et_ouvre_la_session() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/magic-link/verify"))
.and(wiremock::matchers::body_json(
serde_json::json!({ "token": "ML" }),
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_token_pair()
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.verify_otp(VerifyOtpParams::MagicLink { token: "ML" })
.await
.unwrap();
assert!(res.data.is_some());
assert_eq!(client.auth_store().token().unwrap(), "access-tok");
}
#[tokio::test]
async fn verify_otp_recovery_consomme_le_jeton_et_pose_le_mot_de_passe() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/reset-password"))
.and(wiremock::matchers::body_json(serde_json::json!({
"token": "RCV",
"password": "nouveau-secret"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": fake_token_pair()
})))
.mount(&server)
.await;
let client = test_client(&server.uri());
let res = client
.auth()
.verify_otp(VerifyOtpParams::Recovery {
token: "RCV",
password: "nouveau-secret",
})
.await
.unwrap();
assert!(res.data.is_some());
assert_eq!(client.auth_store().token().unwrap(), "access-tok");
}
#[test]
fn la_surface_de_verification_couvre_exactement_les_six_flux_servis() {
let toutes = [
VerifyOtpParams::Signup { token: "t" },
VerifyOtpParams::MagicLink { token: "t" },
VerifyOtpParams::Email {
email: "a@b.c",
token: "t",
},
VerifyOtpParams::Sms {
phone: "+1",
token: "t",
},
VerifyOtpParams::Recovery {
token: "t",
password: "p",
},
VerifyOtpParams::EmailChange { token: "t" },
];
assert_eq!(toutes.len(), 6);
for p in toutes {
let chemin = match p {
VerifyOtpParams::Signup { .. } => "/verify-email",
VerifyOtpParams::MagicLink { .. } => "/magic-link/verify",
VerifyOtpParams::Email { .. } => "/email-otp/verify",
VerifyOtpParams::Sms { .. } => "/sms/verify",
VerifyOtpParams::Recovery { .. } => "/reset-password",
VerifyOtpParams::EmailChange { .. } => "/user/confirm-email-change",
};
assert!(chemin.starts_with('/'));
}
}