use std::time::Duration;
use codlet_core::CodePolicy;
use codlet_core::audit::{CollectingAuditSink, NoopAuditSink};
use codlet_core::auth::{
CodeAuth, FormTokenManager, IssuedSession, NoRateLimit, RedeemError, SessionManager,
};
use codlet_core::clock::FixedClock;
use codlet_core::cookie::CookiePolicy;
use codlet_core::error::PublicRedemptionError;
use codlet_core::hashing::{SecretHasher, StaticKeyProvider};
use codlet_core::mem::{MemCodeStore, MemFormTokenStore, MemSessionStore};
use codlet_core::rng::SystemRandom;
use codlet_core::secret::{CodeId, SessionId, SubjectId};
use codlet_core::state::SessionValidationOutcome;
use codlet_core::store::token::TokenSubject;
const NOW: u64 = 1_700_000_000;
fn hasher() -> SecretHasher<StaticKeyProvider> {
SecretHasher::new(StaticKeyProvider::single("v1", b"test-key-32bytes".to_vec()).unwrap())
}
fn policy() -> CodePolicy {
CodePolicy::default_human(Duration::from_secs(3600)).unwrap()
}
fn cookie() -> CookiePolicy {
CookiePolicy::production_strict("sid", Duration::from_secs(30 * 86_400))
}
fn code_auth()
-> CodeAuth<MemCodeStore, NoRateLimit, StaticKeyProvider, FixedClock, CollectingAuditSink> {
CodeAuth::without_rate_limit(
MemCodeStore::new(),
hasher(),
FixedClock::at(NOW),
CollectingAuditSink::new(),
policy(),
)
}
fn session_mgr() -> SessionManager<MemSessionStore, StaticKeyProvider, FixedClock, NoopAuditSink> {
SessionManager::new(
MemSessionStore::new(),
hasher(),
FixedClock::at(NOW),
NoopAuditSink,
cookie(),
)
}
#[tokio::test]
async fn two_step_issue_find_claim_session() {
let ca = code_auth();
let sm = session_mgr();
let mut rng = SystemRandom::new();
let mut sess_rng = SystemRandom::new();
let (code_id, plain) = ca
.issue_code(
&mut rng,
CodeId::new("code-1".into()),
None,
None,
Some("grant-A".into()),
)
.await
.unwrap();
let found = ca.find(plain.expose(), None).await.unwrap();
assert_eq!(found.id, code_id);
assert_eq!(found.grant.as_deref(), Some("grant-A"));
let subject = SubjectId::new("user-1".into());
let success = ca.claim(&found, subject.clone(), None).await.unwrap();
assert_eq!(success.subject.as_str(), "user-1");
assert_eq!(success.grant.as_deref(), Some("grant-A"));
let issued = sm
.issue(&success, SessionId::new("sess-1".into()), &mut sess_rng)
.await
.unwrap();
assert!(!issued.set_cookie.is_empty());
assert!(issued.set_cookie.contains("sid="));
assert!(issued.set_cookie.contains("HttpOnly"));
assert!(issued.set_cookie.contains("Secure"));
let cookie_val = issued
.set_cookie
.split(';')
.next()
.unwrap()
.trim_start_matches("sid=");
let outcome = sm.validate(cookie_val).await.unwrap();
assert!(outcome.is_authenticated());
assert_eq!(outcome.subject().unwrap().as_str(), "user-1");
}
#[tokio::test]
async fn callback_flow_issues_session_only_on_won() {
let ca = code_auth();
let sm = session_mgr();
let mut rng = SystemRandom::new();
let mut sess_rng = SystemRandom::new();
let (_, plain) = ca
.issue_code(&mut rng, CodeId::new("c2".into()), None, None, None)
.await
.unwrap();
let success = ca
.redeem_with_callback(plain.expose(), None, |_record| async {
Ok::<_, std::convert::Infallible>(SubjectId::new("user-2".into()))
})
.await
.unwrap();
assert_eq!(success.subject.as_str(), "user-2");
let issued = sm
.issue(&success, SessionId::new("s2".into()), &mut sess_rng)
.await
.unwrap();
assert!(issued.set_cookie.contains("sid="));
}
#[tokio::test]
async fn lost_claim_cannot_issue_session() {
let ca = code_auth();
let mut rng = SystemRandom::new();
let (_, plain) = ca
.issue_code(&mut rng, CodeId::new("c3".into()), None, None, None)
.await
.unwrap();
let found = ca.find(plain.expose(), None).await.unwrap();
let subj = SubjectId::new("winner".into());
let won = ca.claim(&found, subj, None).await.unwrap();
assert_eq!(won.subject.as_str(), "winner");
let lost = ca.claim(&found, SubjectId::new("loser".into()), None).await;
assert!(matches!(lost, Err(RedeemError::ClaimLost { .. })));
let public = lost.unwrap_err();
assert_eq!(*public.public(), PublicRedemptionError::InvalidOrExpired);
}
#[tokio::test]
async fn callback_error_leaves_claim_consumed_no_session() {
let ca = code_auth();
let mut rng = SystemRandom::new();
let (_, plain) = ca
.issue_code(&mut rng, CodeId::new("c4".into()), None, None, None)
.await
.unwrap();
let result = ca
.redeem_with_callback(plain.expose(), None, |_| async {
Err::<SubjectId, _>("host db unavailable")
})
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(*err.public(), PublicRedemptionError::TemporarilyUnavailable);
let retry = ca
.redeem_with_callback(plain.expose(), None, |_| async {
Ok::<_, std::convert::Infallible>(SubjectId::new("late".into()))
})
.await;
assert!(
retry.is_err(),
"code must not be redeemable after consumed-by-callback-error"
);
}
#[tokio::test]
async fn invalid_input_returns_generic_public_error() {
let ca = code_auth();
let err = ca.find("not-a-valid-code!!!", None).await.unwrap_err();
assert_eq!(*err.public(), PublicRedemptionError::InvalidOrExpired);
}
#[tokio::test]
async fn missing_code_returns_generic_public_error() {
let ca = code_auth();
let err = ca.find("ABCD2345", None).await.unwrap_err();
assert_eq!(*err.public(), PublicRedemptionError::InvalidOrExpired);
}
#[tokio::test]
async fn validate_expired_session_returns_unauthenticated() {
use codlet_core::hashing::SecretDomain;
use codlet_core::store::session::{SessionRecord, SessionStore};
let store = MemSessionStore::new();
let h = hasher();
let (lk, kv) = h
.lookup_key(SecretDomain::Session, "cookie-secret-xyz")
.unwrap();
store
.insert_session(SessionRecord {
id: SessionId::new("s-old".into()),
lookup_key: lk,
key_version: kv,
subject: SubjectId::new("user-3".into()),
created_at: NOW - 100,
expires_at: NOW - 1, })
.await
.unwrap();
let sm = SessionManager::new(
store,
hasher(),
FixedClock::at(NOW),
NoopAuditSink,
cookie(),
);
let outcome = sm.validate("cookie-secret-xyz").await.unwrap();
assert_eq!(outcome, SessionValidationOutcome::Unauthenticated);
}
#[tokio::test]
async fn revoke_session_and_clear_cookie() {
let ca = code_auth();
let sm = session_mgr();
let mut rng = SystemRandom::new();
let mut sess_rng = SystemRandom::new();
let (_, plain) = ca
.issue_code(&mut rng, CodeId::new("c5".into()), None, None, None)
.await
.unwrap();
let found = ca.find(plain.expose(), None).await.unwrap();
let success = ca
.claim(&found, SubjectId::new("user-4".into()), None)
.await
.unwrap();
let IssuedSession {
session_id,
set_cookie,
} = sm
.issue(&success, SessionId::new("sess-4".into()), &mut sess_rng)
.await
.unwrap();
let cookie_val: String = set_cookie
.split(';')
.next()
.unwrap()
.trim_start_matches("sid=")
.to_string();
assert!(sm.validate(&cookie_val).await.unwrap().is_authenticated());
let clear_cookie = sm.revoke(&session_id).await.unwrap();
assert!(
clear_cookie.contains("Max-Age=0"),
"clear cookie must use Max-Age=0"
);
assert!(
clear_cookie.contains("sid="),
"clear cookie must have correct name"
);
assert_eq!(
sm.validate(&cookie_val).await.unwrap(),
SessionValidationOutcome::Unauthenticated
);
}
#[tokio::test]
async fn form_token_issue_and_consume() {
let ft = FormTokenManager::new(
MemFormTokenStore::new(),
hasher(),
FixedClock::at(NOW),
NoopAuditSink,
Duration::from_secs(3600),
);
let mut rng = SystemRandom::new();
let secret = ft
.issue(
&mut rng,
TokenSubject::Authenticated(SubjectId::new("u1".into())),
"logout",
None,
)
.await
.unwrap();
let subj = TokenSubject::Authenticated(SubjectId::new("u1".into()));
let r1 = ft
.consume(secret.expose(), &subj, "logout", None)
.await
.unwrap();
assert!(r1.is_none(), "first consume must return Ok(None)");
let r2 = ft
.consume(secret.expose(), &subj, "logout", None)
.await
.unwrap();
assert!(r2.is_none(), "replay with no result_ref returns None");
}
#[tokio::test]
async fn form_token_wrong_subject_is_invalid() {
let ft = FormTokenManager::new(
MemFormTokenStore::new(),
hasher(),
FixedClock::at(NOW),
NoopAuditSink,
Duration::from_secs(3600),
);
let mut rng = SystemRandom::new();
let secret = ft
.issue(
&mut rng,
TokenSubject::Authenticated(SubjectId::new("alice".into())),
"save",
None,
)
.await
.unwrap();
let bob = TokenSubject::Authenticated(SubjectId::new("bob".into()));
let err = ft
.consume(secret.expose(), &bob, "save", None)
.await
.unwrap_err();
assert!(matches!(err, codlet_core::FormTokenError::Invalid { .. }));
}
#[tokio::test]
async fn audit_events_emitted_through_complete_flow() {
let _code_sink = CollectingAuditSink::new();
let _sess_sink = CollectingAuditSink::new();
let _ca = CodeAuth::without_rate_limit(
MemCodeStore::new(),
hasher(),
FixedClock::at(NOW),
CollectingAuditSink::new(), policy(),
);
let sink = CollectingAuditSink::new();
let ca2 = CodeAuth::without_rate_limit(
MemCodeStore::new(),
hasher(),
FixedClock::at(NOW),
sink,
policy(),
);
let mut rng = SystemRandom::new();
let _ = ca2
.issue_code(
&mut rng,
CodeId::new("cx".into()),
Some("test".into()),
None,
None,
)
.await
.unwrap();
}