pas-external 0.5.0

Ppoppo Accounts System (PAS) external SDK -- OAuth2 PKCE, PASETO verification, Axum middleware, session liveness
Documentation
//! Sync unit tests for [`SvCore`]. No `tokio`, no fakes, no
//! `PrivateCookieJar` — drives the state machine directly.

#![allow(clippy::unwrap_used, clippy::panic, unused_must_use)]

use super::core::{
    CiphertextFeed, ExpiryCause, PasRefreshFeed, PersistFeed, RefetchFeed, SvCore, SvDecision,
    SvStep, UserinfoFeed,
};
use super::super::sv_cache::CheckResult;
use crate::session_liveness::EncryptedRefreshToken;

fn assert_done(step: SvStep) -> SvDecision {
    match step {
        SvStep::Done(d) => d,
        other => panic!("expected SvStep::Done, got {other:?}"),
    }
}

/// Stub ciphertext payload for tests. SvCore never inspects the bytes —
/// it just threads the value to the driver via `SvStep::PasRefresh`.
fn stub_ciphertext() -> EncryptedRefreshToken {
    EncryptedRefreshToken::from_stored("test-ciphertext-stub".to_owned())
}

fn ct_available() -> CiphertextFeed {
    CiphertextFeed::Available { ciphertext: stub_ciphertext() }
}

fn pas_refreshed() -> PasRefreshFeed {
    PasRefreshFeed::Refreshed { access_token: "test-access-token".to_owned() }
}

// ---- Cache happy path ------------------------------------------------

#[test]
fn cache_fresh_short_circuits_with_no_io() {
    let (mut core, first) = SvCore::start();
    assert!(matches!(first, SvStep::QueryCache));
    let step = core.feed_check(CheckResult::Fresh);
    assert_eq!(assert_done(step), SvDecision::FreshFromCache);
}

// ---- Ciphertext lookup branches --------------------------------------

#[test]
fn ciphertext_absent_expires_with_distinct_cause() {
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    let step = core.feed_ciphertext(CiphertextFeed::Absent);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::RefreshCiphertextAbsent),
    );
}

#[test]
fn ciphertext_lookup_failure_expires_distinctly() {
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Unknown);
    let step = core.feed_ciphertext(CiphertextFeed::LookupFailed);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::RefreshLoadFailed),
    );
}

#[test]
fn cipher_missing_expires_distinctly() {
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    let step = core.feed_ciphertext(CiphertextFeed::NoCipherConfigured);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::CipherMissing),
    );
}

// ---- pas_refresh branches --------------------------------------------

#[test]
fn pas_refresh_rejected_fails_closed() {
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    core.feed_ciphertext(ct_available());
    let step = core.feed_pas_refresh(PasRefreshFeed::Rejected);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::PasRefreshRejected),
    );
}

#[test]
fn pas_refresh_transient_fails_closed_s_l6() {
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    core.feed_ciphertext(ct_available());
    let step = core.feed_pas_refresh(PasRefreshFeed::Transient);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::PasRefreshTransient),
    );
}

#[test]
fn cipher_failure_during_pas_refresh_fails_closed() {
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    core.feed_ciphertext(ct_available());
    let step = core.feed_pas_refresh(PasRefreshFeed::CipherFailed);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::CipherFailed),
    );
}

// ---- userinfo branches -----------------------------------------------

#[test]
fn userinfo_missing_sv_fails_closed() {
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    core.feed_ciphertext(ct_available());
    core.feed_pas_refresh(pas_refreshed());
    let step = core.feed_userinfo(UserinfoFeed::MissingSv);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::UserinfoMissingSv),
    );
}

#[test]
fn userinfo_rejected_fails_closed() {
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    core.feed_ciphertext(ct_available());
    core.feed_pas_refresh(pas_refreshed());
    let step = core.feed_userinfo(UserinfoFeed::Rejected);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::UserinfoRejected),
    );
}

#[test]
fn userinfo_transient_fails_closed() {
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    core.feed_ciphertext(ct_available());
    core.feed_pas_refresh(pas_refreshed());
    let step = core.feed_userinfo(UserinfoFeed::Transient);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::UserinfoTransient),
    );
}

// ---- Persist + record + refetch --------------------------------------

#[test]
fn persist_failure_expires_without_recording_cache() {
    // Pins the invariant: update_sv MUST land before policy.record.
    // If persist fails, the state machine must NOT emit RecordCache.
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    core.feed_ciphertext(ct_available());
    core.feed_pas_refresh(pas_refreshed());
    core.feed_userinfo(UserinfoFeed::Ok { new_sv: 42 });
    let step = core.feed_persist(PersistFeed::Failed);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::PersistFailed),
    );
}

#[test]
fn persist_ok_emits_record_cache_with_same_sv() {
    // Pins the ordering: update_sv → policy.record uses the SAME sv
    // value (cache + store cannot diverge).
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    core.feed_ciphertext(ct_available());
    core.feed_pas_refresh(pas_refreshed());
    core.feed_userinfo(UserinfoFeed::Ok { new_sv: 99 });
    let step = core.feed_persist(PersistFeed::Ok);
    match step {
        SvStep::RecordCache { sv } => assert_eq!(sv, 99),
        other => panic!("expected RecordCache {{ sv: 99 }}, got {other:?}"),
    }
}

#[test]
fn refetch_missing_expires() {
    let (mut core, _) = SvCore::start();
    core.feed_check(CheckResult::Stale);
    core.feed_ciphertext(ct_available());
    core.feed_pas_refresh(pas_refreshed());
    core.feed_userinfo(UserinfoFeed::Ok { new_sv: 1 });
    core.feed_persist(PersistFeed::Ok);
    core.feed_record();
    let step = core.feed_refetch(RefetchFeed::Missing);
    assert_eq!(
        assert_done(step),
        SvDecision::Expired(ExpiryCause::ReFetchMissing),
    );
}

#[test]
fn full_happy_path_yields_refreshed() {
    let (mut core, first) = SvCore::start();
    assert!(matches!(first, SvStep::QueryCache));
    assert!(matches!(
        core.feed_check(CheckResult::Stale),
        SvStep::LoadCiphertext
    ));
    assert!(matches!(
        core.feed_ciphertext(ct_available()),
        SvStep::PasRefresh { .. }
    ));
    assert!(matches!(
        core.feed_pas_refresh(pas_refreshed()),
        SvStep::UserInfo { .. }
    ));
    assert!(matches!(
        core.feed_userinfo(UserinfoFeed::Ok { new_sv: 7 }),
        SvStep::PersistSv { new_sv: 7 }
    ));
    assert!(matches!(
        core.feed_persist(PersistFeed::Ok),
        SvStep::RecordCache { sv: 7 }
    ));
    assert!(matches!(core.feed_record(), SvStep::ReFetch));
    assert_eq!(
        assert_done(core.feed_refetch(RefetchFeed::Found)),
        SvDecision::Refreshed,
    );
}