polyc-web-session 2026.10.1

Browser session establishment (ADR 0007): one-time login challenges, the shared session cookie, and both the persona-passkey and wallet-passkey ceremonies that mint a polyc_crypto::session token. Consumed by polyc-control-plane; carries no ceremony-consumer naming of its own.
//! A [`PersonaSessionReader`]-plus-setup fake, standing in for the
//! State-backed production authority (`crate::persona_authority::PersonaAuthority`
//! in `polyc-control-plane`, which has its own test suite) in both login
//! ceremonies' own fixtures.
//!
//! These fixtures exercise this crate's own ceremony logic — challenge
//! verification, scope resolution, session minting — never the persona
//! directory's read/write behavior. `attribute`/`start_link`/`complete_link`
//! replicate only the one outcome these fixtures depend on: a freshly
//! linked, [`polyc_persona::STATUS_LINKED`] persona to log in as, never the
//! ceremony's full gate/expiry/cardinality behavior (`polyc-persona-authority`'s
//! own suite covers that). `seed_persona_credential_for_tests`/
//! `revoke_persona_credential`/`set_wallet_link` seed exactly the record
//! each ceremony reads.

use std::collections::HashMap;

use polyc_proto::proto::polychrome::persona::v1::{
    ExternalIdentity, PersonaCredential, PersonaProfile,
};
use tokio::sync::Mutex;

#[derive(Default)]
pub(crate) struct FakePersonaAuthority {
    profiles: Mutex<HashMap<String, PersonaProfile>>,
    identities: Mutex<HashMap<String, String>>,
    links: Mutex<HashMap<String, String>>,
    credentials: Mutex<HashMap<String, PersonaCredential>>,
    credential_index: Mutex<HashMap<Vec<u8>, String>>,
    wallets: Mutex<HashMap<String, String>>,
}

impl FakePersonaAuthority {
    fn identity_key(identity: &ExternalIdentity) -> String {
        format!(
            "{}:{}:{}",
            identity.provider, identity.scope, identity.external_id
        )
    }

    /// Mints a fresh, provisional persona id for `identity`, or returns its
    /// existing one.
    pub(crate) async fn attribute(
        &self,
        identity: ExternalIdentity,
        _conversation_id: String,
        _role: String,
        _now_ms: u64,
    ) -> Result<String, String> {
        let key = Self::identity_key(&identity);
        if let Some(persona_id) = self.identities.lock().await.get(&key) {
            return Ok(persona_id.clone());
        }
        let persona_id = uuid::Uuid::new_v4().to_string();
        self.profiles.lock().await.insert(
            persona_id.clone(),
            PersonaProfile {
                persona_id: persona_id.clone(),
                status: "provisional".to_owned(),
                ..Default::default()
            },
        );
        self.identities.lock().await.insert(key, persona_id.clone());
        Ok(persona_id)
    }

    /// Binds `code` to `actor`'s current persona — the ceremony's actor half.
    pub(crate) async fn start_link(
        &self,
        actor: ExternalIdentity,
        code: String,
        _ttl_ms: u64,
        now_ms: u64,
    ) -> Result<(), String> {
        let survivor = self
            .attribute(actor, String::new(), String::new(), now_ms)
            .await?;
        self.links.lock().await.insert(code, survivor);
        Ok(())
    }

    /// Redeems `code`, marking its actor persona [`polyc_persona::STATUS_LINKED`].
    pub(crate) async fn complete_link(
        &self,
        code: String,
        _target: ExternalIdentity,
        _now_ms: u64,
    ) -> Result<polyc_persona::CompleteLinkOutcome, String> {
        let persona_id = self
            .links
            .lock()
            .await
            .get(&code)
            .cloned()
            .ok_or_else(|| "unknown link code".to_owned())?;
        if let Some(profile) = self.profiles.lock().await.get_mut(&persona_id) {
            profile.status = polyc_persona::STATUS_LINKED.to_owned();
        }
        Ok(polyc_persona::CompleteLinkOutcome::Linked { persona_id })
    }

    /// Seeds an enrolled credential directly, with no verification —
    /// mirrors `StateExtrasAuthority::state_seed_credential_for_tests` in
    /// `polyc-control-plane`, which has its own doc on why this is test-only.
    #[allow(clippy::too_many_arguments)]
    pub(crate) async fn seed_persona_credential_for_tests(
        &self,
        persona_id: &str,
        credential_id: Vec<u8>,
        p256_public_key_sec1: Vec<u8>,
        signing_public_key: Vec<u8>,
        rp_id: String,
        origin: String,
        now_ms: u64,
    ) -> Result<PersonaCredential, String> {
        let credential = PersonaCredential {
            persona_id: persona_id.to_owned(),
            credential_id: credential_id.clone(),
            p256_public_key_sec1,
            signing_public_key,
            rp_id,
            origin,
            created_at_ms: now_ms,
            revoked: false,
            ..Default::default()
        };
        self.credentials
            .lock()
            .await
            .insert(persona_id.to_owned(), credential.clone());
        self.credential_index
            .lock()
            .await
            .insert(credential_id, persona_id.to_owned());
        Ok(credential)
    }

    /// Flags the persona's enrolled credential revoked, keeping it resolvable
    /// (so a login sees `CredentialRevoked`, never `UnknownCredential`).
    pub(crate) async fn revoke_persona_credential(
        &self,
        persona_id: &str,
        _now_ms: u64,
    ) -> Result<Option<PersonaCredential>, String> {
        let mut credentials = self.credentials.lock().await;
        Ok(credentials.get_mut(persona_id).map(|credential| {
            credential.revoked = true;
            credential.clone()
        }))
    }

    /// Indexes a wallet address to a persona, mirroring
    /// `PersonaAuthority::set_wallet_link`'s call shape. The compare-and-set
    /// expectation `polyc-control-plane`'s production write enforces has no
    /// equivalent here: `wallet_login`'s own fixtures never race two writes.
    #[allow(clippy::too_many_arguments)]
    pub(crate) async fn set_wallet_link(
        &self,
        persona_id: &str,
        wallet_address: String,
        _currency: String,
        _key_ref: String,
        _expiry_unix: u64,
        _now_ms: u64,
        _webauthn_credential_id: String,
        _key_address: String,
        _expected_identity: Option<()>,
    ) -> Result<(), String> {
        self.wallets
            .lock()
            .await
            .insert(wallet_address, persona_id.to_owned());
        Ok(())
    }
}

impl crate::PersonaSessionReader for FakePersonaAuthority {
    async fn session_profile(&self, persona_id: String) -> Result<Option<PersonaProfile>, ()> {
        Ok(self.profiles.lock().await.get(&persona_id).cloned())
    }

    async fn session_wallet_persona(&self, wallet_address: &str) -> Result<Option<String>, ()> {
        Ok(self.wallets.lock().await.get(wallet_address).cloned())
    }

    async fn session_credential_persona(&self, credential_id: &[u8]) -> Result<Option<String>, ()> {
        Ok(self
            .credential_index
            .lock()
            .await
            .get(credential_id)
            .cloned())
    }

    async fn session_credential(&self, persona_id: &str) -> Result<Option<PersonaCredential>, ()> {
        Ok(self.credentials.lock().await.get(persona_id).cloned())
    }
}