Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

src/mine.rs

//! **The buyer's pages — what they show**, pure and host-tested; `pages/mine.rs` renders it.
//!
//! Two pages in every build, every fact on them holger's answer:
//!
//! * **My repositories** (`/mine`): the repositories holger says this person has
//!   (`AccountService.MyRepositories`), a form that makes a private one in a format holger
//!   offers (`CreateRepository`), and each one's client setup for cargo, pip, npm and maven,
//!   as holger wrote it;
//! * **My tokens** (`/tokens`): the tokens package clients carry (`TokenService`): mint one
//!   (its secret shown once, in the answer to the mint), list them, revoke one.
//!
//! A metered console has a third page between them, `/plan`, in `crate::purchase` (A28 M13:
//! an open console compiles none of it).
//!
//! **Thin.** Who has which repository, which formats may be made, who may mint: all of it is
//! holger's. This module never decides any of it; a refusal is shown as holger's code and
//! words. The doors are traits ([`AccountDoor`], [`TokenDoor`]) so the pages and the tests
//! drive the same loaders: the wasm build talks gRPC-web to holger, the tests a mock server
//! with canned answers.

#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
#![allow(async_fn_in_trait)]

#[cfg(all(feature = "metered", not(target_arch = "wasm32")))]
use crate::admin_queue::pb;
#[cfg(all(feature = "metered", target_arch = "wasm32"))]
use crate::grpc::pb;
pub use crate::security::{refusal, RefusalView, Refused};

/// holger's account, room and token messages, natively: `crate::grpc` is wasm-only, and the
/// decisions here are host-tested over the SAME generated types the pages read.
#[cfg(not(target_arch = "wasm32"))]
pub mod pb_account {
    tonic::include_proto!("holger.account.v1");
}
#[cfg(all(feature = "metered", not(target_arch = "wasm32")))]
pub mod pb_room {
    tonic::include_proto!("holger.room.v1");
}
#[cfg(not(target_arch = "wasm32"))]
pub mod pb_token {
    tonic::include_proto!("holger.token.v1");
}
#[cfg(target_arch = "wasm32")]
pub use crate::grpc::{pb_account, pb_token};
#[cfg(all(feature = "metered", target_arch = "wasm32"))]
pub use crate::grpc::pb_room;

/// The pages, in the tab strip's order: `(path, label)`.
#[cfg(not(feature = "metered"))]
pub const TABS: [(&str, &str); 3] = [("/account", "Account"), ("/mine", "My repositories"), ("/tokens", "My tokens")];
/// The pages of a metered console, in the tab strip's order: `(path, label)`.
#[cfg(feature = "metered")]
pub const TABS: [(&str, &str); 4] = [("/account", "Account"), ("/mine", "My repositories"), ("/plan", "My plan"), ("/tokens", "My tokens")];

// ── the doors ───────────────────────────────────────────────────────────────

/// **holger's account door**, as the buyer's pages use it.
pub trait AccountDoor {
    async fn mine(&self) -> Result<pb_account::MyRepositoriesReply, Refused>;
    async fn create(&self, req: pb_account::CreateRepositoryRequest) -> Result<pb_account::CreateRepositoryReply, Refused>;
    /// `UsageService.ReadUsage`: the room used (My plan, `metered` only).
    #[cfg(feature = "metered")]
    async fn usage(&self, repository: &str) -> Result<pb::ReadUsageResponse, Refused>;
    /// `RoomService.Room`: where more room is had (My plan, `metered` only).
    #[cfg(feature = "metered")]
    async fn room(&self, repository: &str) -> Result<pb_room::RoomReply, Refused>;
    /// `AccountService.Grant` (lane P1): the owner grants or revokes one named person.
    async fn grant(&self, req: pb_account::GrantRequest) -> Result<pb_account::GrantReply, Refused>;
}

/// **holger's token door** (`holger.token.v1.TokenService`, lane P3).
pub trait TokenDoor {
    async fn mint(&self, req: pb_token::MintTokenRequest) -> Result<pb_token::MintTokenReply, Refused>;
    async fn list(&self) -> Result<pb_token::ListTokensReply, Refused>;
    async fn revoke(&self, id: &str) -> Result<pb_token::RevokeTokenReply, Refused>;
}

// ── My repositories ─────────────────────────────────────────────────────────

/// One snippet, as the page shows it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Snippet {
    pub client: String,
    pub file: String,
    pub text: String,
}

/// One repository row.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RepoRow {
    pub name: String,
    pub href: String,
    pub format: String,
    /// "private" or "open", from holger's flag.
    pub access: &'static str,
    /// The owner holger recorded, or "the estate's" when none.
    pub owner: String,
    pub words: String,
    pub served: bool,
    pub snippets: Vec<Snippet>,
    /// The people its owner granted, `(person, access)`, as holger lists them.
    pub grants: Vec<(String, String)>,
}

/// The page.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MineView {
    pub rows: Vec<RepoRow>,
    /// The formats holger makes for this person; empty: no form.
    pub formats: Vec<String>,
    pub words: String,
}

/// Pure: holger's answer as the page shows it.
pub fn mine(r: &pb_account::MyRepositoriesReply) -> MineView {
    MineView {
        rows: r
            .repositories
            .iter()
            .map(|m| RepoRow {
                name: m.name.clone(),
                href: format!("/repo/{}", crate::util::enc(&m.name)),
                format: m.format.clone(),
                access: if m.private { "private" } else { "open" },
                owner: if m.owner.is_empty() { "the estate's".to_owned() } else { m.owner.clone() },
                words: m.words.clone(),
                served: m.served,
                snippets: m.clients.iter().map(|c| Snippet { client: c.client.clone(), file: c.file.clone(), text: c.snippet.clone() }).collect(),
                grants: m.grants.iter().map(|g| (g.person.clone(), g.access.clone())).collect(),
            })
            .collect(),
        formats: r.formats.clone(),
        words: r.words.clone(),
    }
}

pub async fn load_mine(door: &impl AccountDoor) -> Result<MineView, RefusalView> {
    door.mine().await.map(|r| mine(&r)).map_err(|e| refusal(&e))
}

/// What a press of "Make it" came to: holger's words and the new row, or its refusal.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Made {
    Made { words: String, row: Option<RepoRow> },
    Refused(RefusalView),
}

/// Send ONE `CreateRepository` with what the person typed, as typed (trimmed): the name and
/// the format are holger's to judge.
pub async fn make(door: &impl AccountDoor, name: &str, format: &str) -> Made {
    let req = pb_account::CreateRepositoryRequest { name: name.trim().to_owned(), format: format.trim().to_owned() };
    match door.create(req).await {
        Ok(r) => Made::Made {
            row: r.repository.as_ref().map(|m| mine(&pb_account::MyRepositoriesReply { repositories: vec![m.clone()], ..Default::default() }).rows.remove(0)),
            words: r.words,
        },
        Err(e) => Made::Refused(refusal(&e)),
    }
}

/// What a press of "Grant" came to (lane P1): holger's words and the grants it holds now, or
/// its refusal (only the owner or an admin grants; holger decides).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Granted {
    Done { words: String, grants: Vec<(String, String)> },
    Refused(RefusalView),
}

/// Send ONE `Grant`: `access` is `read`, `write` or `none` (revoke), the person as typed.
pub async fn grant(door: &impl AccountDoor, repository: &str, person: &str, access: &str) -> Granted {
    let req = pb_account::GrantRequest { repository: repository.to_owned(), person: person.trim().to_owned(), access: access.to_owned() };
    match door.grant(req).await {
        Ok(r) => Granted::Done { words: r.words, grants: r.grants.iter().map(|g| (g.person.clone(), g.access.clone())).collect() },
        Err(e) => Granted::Refused(refusal(&e)),
    }
}

// ── My tokens ───────────────────────────────────────────────────────────────

/// The scopes a token is minted with: `(wire value, label)`.
pub const SCOPES: [(i32, &str); 2] = [(pb_token::TokenScope::Read as i32, "read"), (pb_token::TokenScope::ReadWrite as i32, "read and write")];

/// One token row.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TokenRow {
    pub id: String,
    pub label: String,
    pub owner: String,
    pub repositories: String,
    pub scope: &'static str,
    pub created: String,
    pub expires: String,
    pub last_used: String,
    pub state: String,
    pub tone: crate::util::Tone,
    /// holger's state is "live": a Revoke is offered (holger decides whether it is allowed).
    pub revocable: bool,
}

/// Pure: the label of a scope holger sent.
pub fn scope_label(scope: i32) -> &'static str {
    if scope == pb_token::TokenScope::ReadWrite as i32 {
        "read and write"
    } else {
        "read"
    }
}

/// Pure: one token row from holger's.
pub fn token_row(t: &pb_token::TokenRow) -> TokenRow {
    use crate::util::Tone;
    let utc = crate::security::utc;
    TokenRow {
        id: t.id.clone(),
        label: if t.label.is_empty() { "—".into() } else { t.label.clone() },
        owner: if t.owner_display.is_empty() { t.owner.clone() } else { t.owner_display.clone() },
        repositories: t.repositories.join(", "),
        scope: scope_label(t.scope),
        created: utc(t.created),
        expires: utc(t.expires),
        last_used: if t.last_used <= 0 { "never".into() } else { utc(t.last_used) },
        tone: match t.state.as_str() {
            "live" => Tone::Good,
            "expired" => Tone::Warn,
            "revoked" => Tone::Bad,
            _ => Tone::Neutral,
        },
        revocable: t.state == "live",
        state: t.state.clone(),
    }
}

pub async fn load_tokens(door: &impl TokenDoor) -> Result<Vec<TokenRow>, RefusalView> {
    door.list().await.map(|r| r.tokens.iter().map(token_row).collect()).map_err(|e| refusal(&e))
}

/// Pure: the repositories the person typed, one per comma or line, blanks dropped.
pub fn repositories_typed(s: &str) -> Vec<String> {
    s.split([',', '\n', ' ']).map(str::trim).filter(|r| !r.is_empty()).map(str::to_owned).collect()
}

/// What a mint came to: the secret (shown once) with holger's words, or its refusal.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Minted {
    Minted { secret: String, words: String, row: Option<TokenRow> },
    Refused(RefusalView),
}

/// Send ONE `Mint` with what the person chose; holger judges it. Its step-up is answered in
/// place (A28 M9): a mint refused for a stale touch (`[holger-token-7]`) asks for a fresh touch
/// (`touch`) and is sent ONCE more. A touch that fails mints nothing and says why.
pub async fn mint<Tc, TF>(door: &impl TokenDoor, touch: Tc, label: &str, repositories: &str, scope: i32, days: u32) -> Minted
where
    Tc: FnOnce() -> TF,
    TF: std::future::Future<Output = Result<(), String>>,
{
    let req = pb_token::MintTokenRequest { label: label.trim().to_owned(), repositories: repositories_typed(repositories), scope, days };
    let answer = crate::step_up::answer_in_place(
        || door.mint(req.clone()),
        |e: &Refused| crate::step_up::is_token_step_up(&refusal(e)),
        touch,
        |why| Refused { code: "PermissionDenied".into(), message: format!("[{}] {}", crate::step_up::TOKEN_STEP_UP_CODE, crate::step_up::touch_failed_words(&why)) },
    )
    .await;
    match answer {
        Ok(r) => Minted::Minted { secret: r.secret, words: r.words, row: r.token.as_ref().map(token_row) },
        Err(e) => Minted::Refused(refusal(&e)),
    }
}

/// What a revoke came to: the row as holger now holds it, or its refusal.
pub async fn revoke(door: &impl TokenDoor, id: &str) -> Result<Option<TokenRow>, RefusalView> {
    door.revoke(id).await.map(|r| r.token.as_ref().map(token_row)).map_err(|e| refusal(&e))
}

/// Pure: a snippet with the token put in where holger wrote the placeholder — what the
/// person sees right after a mint, so the copy is ready to paste.
pub fn with_token(snippet: &str, secret: &str) -> String {
    if secret.is_empty() {
        snippet.to_owned()
    } else {
        snippet.replace("YOUR-TOKEN", secret)
    }
}

// ── the gRPC doors (wasm) ───────────────────────────────────────────────────

/// The pages' door to holger, over gRPC-web.
pub struct GrpcDoor;

#[cfg(target_arch = "wasm32")]
fn refused(s: tonic::Status) -> Refused {
    Refused { code: format!("{:?}", s.code()), message: crate::grpc::status_text(&s) }
}

#[cfg(target_arch = "wasm32")]
impl AccountDoor for GrpcDoor {
    async fn mine(&self) -> Result<pb_account::MyRepositoriesReply, Refused> {
        crate::grpc::account().my_repositories(pb_account::MyRepositoriesRequest {}).await.map(|r| r.into_inner()).map_err(refused)
    }
    async fn create(&self, req: pb_account::CreateRepositoryRequest) -> Result<pb_account::CreateRepositoryReply, Refused> {
        crate::grpc::account().create_repository(req).await.map(|r| r.into_inner()).map_err(refused)
    }
    #[cfg(feature = "metered")]
    async fn usage(&self, repository: &str) -> Result<pb::ReadUsageResponse, Refused> {
        crate::grpc::usage().read_usage(pb::ReadUsageRequest { repository: repository.to_owned() }).await.map(|r| r.into_inner()).map_err(refused)
    }
    #[cfg(feature = "metered")]
    async fn room(&self, repository: &str) -> Result<pb_room::RoomReply, Refused> {
        crate::grpc::room().room(pb_room::RoomRequest { repository: repository.to_owned() }).await.map(|r| r.into_inner()).map_err(refused)
    }
    async fn grant(&self, req: pb_account::GrantRequest) -> Result<pb_account::GrantReply, Refused> {
        crate::grpc::account().grant(req).await.map(|r| r.into_inner()).map_err(refused)
    }
}

#[cfg(target_arch = "wasm32")]
impl TokenDoor for GrpcDoor {
    async fn mint(&self, req: pb_token::MintTokenRequest) -> Result<pb_token::MintTokenReply, Refused> {
        crate::grpc::tokens().mint(req).await.map(|r| r.into_inner()).map_err(refused)
    }
    async fn list(&self) -> Result<pb_token::ListTokensReply, Refused> {
        crate::grpc::tokens().list(pb_token::ListTokensRequest {}).await.map(|r| r.into_inner()).map_err(refused)
    }
    async fn revoke(&self, id: &str) -> Result<pb_token::RevokeTokenReply, Refused> {
        crate::grpc::tokens().revoke(pb_token::RevokeTokenRequest { id: id.to_owned() }).await.map(|r| r.into_inner()).map_err(refused)
    }
}

#[cfg(test)]
mod tests {

    use super::*;
    use crate::util::Tone;
    use std::cell::RefCell;

    fn block_on<F: std::future::Future>(f: F) -> F::Output {
        let mut f = std::pin::pin!(f);
        let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
        loop {
            if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) {
                return v;
            }
        }
    }

    /// **The mock server**: canned answers, and every request it was sent.
    #[derive(Default)]
    struct Mock {
        mine: Option<Result<pb_account::MyRepositoriesReply, Refused>>,
        create: Option<Result<pb_account::CreateRepositoryReply, Refused>>,
        #[cfg(feature = "metered")]
        usage: Vec<(String, Result<pb::ReadUsageResponse, Refused>)>,
        #[cfg(feature = "metered")]
        room: Vec<(String, Result<pb_room::RoomReply, Refused>)>,
        mint: Option<Result<pb_token::MintTokenReply, Refused>>,
        list: Option<Result<pb_token::ListTokensReply, Refused>>,
        revoke: Option<Result<pb_token::RevokeTokenReply, Refused>>,
        sent_create: RefCell<Vec<pb_account::CreateRepositoryRequest>>,
        sent_mint: RefCell<Vec<pb_token::MintTokenRequest>>,
        sent_revoke: RefCell<Vec<String>>,
        grant: Option<Result<pb_account::GrantReply, Refused>>,
        sent_grant: RefCell<Vec<pb_account::GrantRequest>>,
    }

    fn unanswered<T>() -> Result<T, Refused> {
        Err(Refused { code: "Unimplemented".into(), message: "the mock was not given this answer".into() })
    }

    #[cfg(feature = "metered")]
    fn by_name<T: Clone>(rows: &[(String, Result<T, Refused>)], name: &str) -> Result<T, Refused> {
        rows.iter().find(|(n, _)| n == name).map(|(_, r)| r.clone()).unwrap_or_else(unanswered)
    }

    impl AccountDoor for Mock {
        async fn mine(&self) -> Result<pb_account::MyRepositoriesReply, Refused> {
            self.mine.clone().unwrap_or_else(unanswered)
        }
        async fn create(&self, req: pb_account::CreateRepositoryRequest) -> Result<pb_account::CreateRepositoryReply, Refused> {
            self.sent_create.borrow_mut().push(req);
            self.create.clone().unwrap_or_else(unanswered)
        }
        #[cfg(feature = "metered")]
        async fn usage(&self, repository: &str) -> Result<pb::ReadUsageResponse, Refused> {
            by_name(&self.usage, repository)
        }
        #[cfg(feature = "metered")]
        async fn room(&self, repository: &str) -> Result<pb_room::RoomReply, Refused> {
            by_name(&self.room, repository)
        }
        async fn grant(&self, req: pb_account::GrantRequest) -> Result<pb_account::GrantReply, Refused> {
            self.sent_grant.borrow_mut().push(req);
            self.grant.clone().unwrap_or_else(unanswered)
        }
    }

    /// ★ Lane P1: **the owner's grant is one call, holger's to decide** — the person as typed
    /// (trimmed), the access as picked; holger's words and its grants shown; a refusal shown
    /// with its code.
    #[test]
    fn a_grant_is_one_call_and_shows_what_holger_answered() {
        let mut m = Mock::default();
        m.grant = Some(Ok(pb_account::GrantReply {
            repository: "acme-private".into(),
            grants: vec![pb_account::RepositoryGrant { person: "carl".into(), subject: "passkey:c".into(), access: "read".into() }],
            words: "carl may read 'acme-private'".into(),
        }));
        let g = block_on(grant(&m, "acme-private", "  carl ", "read"));
        assert_eq!(g, Granted::Done { words: "carl may read 'acme-private'".into(), grants: vec![("carl".into(), "read".into())] });
        let sent = m.sent_grant.borrow();
        assert_eq!((sent[0].repository.as_str(), sent[0].person.as_str(), sent[0].access.as_str()), ("acme-private", "carl", "read"));
        drop(sent);
        let mut no = Mock::default();
        no.grant = Some(Err(Refused { code: "PermissionDenied".into(), message: "[holger-account-5] 'acme-private' is not yours".into() }));
        assert!(matches!(block_on(grant(&no, "acme-private", "carl", "write")), Granted::Refused(r) if r.text.contains("not yours")));
    }

    impl TokenDoor for Mock {
        async fn mint(&self, req: pb_token::MintTokenRequest) -> Result<pb_token::MintTokenReply, Refused> {
            self.sent_mint.borrow_mut().push(req);
            self.mint.clone().unwrap_or_else(unanswered)
        }
        async fn list(&self) -> Result<pb_token::ListTokensReply, Refused> {
            self.list.clone().unwrap_or_else(unanswered)
        }
        async fn revoke(&self, id: &str) -> Result<pb_token::RevokeTokenReply, Refused> {
            self.sent_revoke.borrow_mut().push(id.to_owned());
            self.revoke.clone().unwrap_or_else(unanswered)
        }
    }

    fn private_row() -> pb_account::MyRepository {
        pb_account::MyRepository {
            name: "acme-private".into(),
            format: "cargo".into(),
            private: true,
            owner: "ida@example.org".into(),
            served: true,
            clients: vec![pb_account::ClientSetup { client: "cargo".into(), file: "~/.cargo/credentials.toml".into(), snippet: "[registries.acme-private]\ntoken = \"YOUR-TOKEN\"\n".into() }],
            words: "private: every read needs a credential of this server".into(),
            plan: "holger/acme-private/5gib".into(),
            plan_state: "paid".into(),
            paid_until_unix_ms: 4_000_000_000_000,
            plan_words: "paid until 2096-10-02".into(),
            grants: vec![pb_account::RepositoryGrant { person: "carl".into(), subject: "passkey:c".into(), access: "write".into() }],
        }
    }

    fn open_row() -> pb_account::MyRepository {
        pb_account::MyRepository { name: "crates".into(), format: "raw".into(), served: true, words: "open: anyone who reaches this server may read it".into(), plan_state: "free".into(), plan_words: "nothing is bought for it: it is free".into(), ..Default::default() }
    }

    fn reply() -> pb_account::MyRepositoriesReply {
        pb_account::MyRepositoriesReply { repositories: vec![private_row(), open_row()], formats: vec!["cargo".into(), "raw".into()], words: "you are an admin of this server".into() }
    }

    #[cfg(feature = "metered")]
    fn usage(bytes: u64, cap: Option<u64>) -> pb::ReadUsageResponse {
        pb::ReadUsageResponse {
            meters: vec![pb::MeterReading { name: "disk_bytes".into(), value: bytes, scope: pb::MeterScope::Repository as i32, source: "files /d/acme-private (1024 B)".into(), counted: true, ..Default::default() }],
            measured_at_unix_ms: 1,
            caps: cap.map(|c| vec![pb::MeterCap { meter: "disk_bytes".into(), limit: c }]).unwrap_or_default(),
        }
    }

    /// ★ **My repositories renders holger's rows, its formats and its client setup.**
    #[test]
    fn my_repositories_renders_the_servers_rows_formats_and_snippets() {
        let mock = Mock { mine: Some(Ok(reply())), ..Default::default() };
        let v = block_on(load_mine(&mock)).expect("the mock answers");
        assert_eq!(v.formats, ["cargo", "raw"]);
        assert_eq!(v.words, "you are an admin of this server");
        let r = &v.rows[0];
        assert_eq!((r.name.as_str(), r.format.as_str(), r.access, r.owner.as_str(), r.served), ("acme-private", "cargo", "private", "ida@example.org", true));
        assert_eq!(r.href, "/repo/acme-private");
        assert_eq!(r.snippets, [Snippet { client: "cargo".into(), file: "~/.cargo/credentials.toml".into(), text: "[registries.acme-private]\ntoken = \"YOUR-TOKEN\"\n".into() }]);
        assert_eq!((v.rows[1].access, v.rows[1].owner.as_str()), ("open", "the estate's"));
    }

    /// ★ **Making one sends ONE CreateRepository with what was typed**, and shows holger's
    /// words and row, or holger's refusal by its code and words.
    #[test]
    fn making_a_repository_sends_what_was_typed_and_shows_holgers_answer() {
        let mock = Mock { create: Some(Ok(pb_account::CreateRepositoryReply { repository: Some(private_row()), words: "'acme-private' is made and served: private, and yours".into() })), ..Default::default() };
        let m = block_on(make(&mock, "  acme-private ", "cargo"));
        assert_eq!(mock.sent_create.borrow().as_slice(), [pb_account::CreateRepositoryRequest { name: "acme-private".into(), format: "cargo".into() }]);
        let Made::Made { words, row } = m else { panic!("{m:?}") };
        assert!(words.contains("made and served"));
        assert_eq!(row.unwrap().access, "private");
        let mock = Mock { create: Some(Err(Refused { code: "AlreadyExists".into(), message: "[holger-account-2] 'crates' is taken: this server serves a repository by that name".into() })), ..Default::default() };
        let m = block_on(make(&mock, "crates", "raw"));
        assert_eq!(m, Made::Refused(RefusalView { code: "holger-account-2".into(), text: "'crates' is taken: this server serves a repository by that name".into() }));
    }

    /// ★ **My plan: the room used against the room bought, where the plan stands, and the
    /// shop link** — each from holger's own answer.
    #[cfg(feature = "metered")]
    #[test]
    fn my_plan_shows_room_used_against_bought_the_plan_state_and_the_shop_link() {
        let mock = Mock {
            mine: Some(Ok(reply())),
            usage: vec![("acme-private".into(), Ok(usage(1_000_000_000, Some(5_000_000_000)))), ("crates".into(), Ok(usage(2048, None)))],
            room: vec![
                ("acme-private".into(), Ok(pb_room::RoomReply { repository: "acme-private".into(), shop_url: "/signup?plan=account".into(), words: "more room for this repository is bought in the shop".into() })),
                ("crates".into(), Err(Refused { code: "PermissionDenied".into(), message: "reader required".into() })),
            ],
            ..Default::default()
        };
        let v = block_on(crate::purchase::load_plan(&mock)).expect("the mock answers");
        let p = &v.rows[0];
        assert_eq!((p.state.as_str(), p.tone, p.words.as_str(), p.plan.as_str()), ("paid", Tone::Good, "paid until 2096-10-02", "holger/acme-private/5gib"));
        assert_eq!((p.room.as_str(), p.percent), ("1.0 GB of 5.0 GB", Some(20)));
        assert!(p.shop_url == "/signup?plan=account" && p.shop_words.contains("bought in the shop"), "{p:?}");
        let c = &v.rows[1];
        assert_eq!((c.state.as_str(), c.room.as_str(), c.percent), ("free", "2.0 kB (no cap)", None));
        assert_eq!((c.shop_url.as_str(), c.shop_words.as_str()), ("", "reader required"), "a refused link says holger's words");
        assert_eq!(crate::purchase::plan_tone("lapsed"), Tone::Bad);
        assert_eq!(crate::purchase::plan_tone("grace"), Tone::Warn);
        assert_eq!(crate::purchase::plan_tone("something new"), Tone::Neutral);
    }

    #[cfg(feature = "metered")]
    #[test]
    fn a_plan_the_server_refuses_shows_its_code_and_words() {
        let mock = Mock { mine: Some(Err(Refused { code: "Unauthenticated".into(), message: "sign in".into() })), ..Default::default() };
        assert_eq!(block_on(crate::purchase::load_plan(&mock)).unwrap_err(), RefusalView { code: "Unauthenticated".into(), text: "sign in".into() });
        let (line, pct) = crate::purchase::room_line(None, Some(&RefusalView { code: "x".into(), text: "no meter here".into() }));
        assert_eq!((line.as_str(), pct), ("not counted: no meter here", None));
    }

    fn token(state: &str) -> pb_token::TokenRow {
        pb_token::TokenRow {
            id: "b3:abcd".into(),
            label: "laptop cargo".into(),
            owner: "ida".into(),
            owner_display: "Ida".into(),
            repositories: vec!["acme-private".into(), "crates".into()],
            scope: pb_token::TokenScope::ReadWrite as i32,
            created: 1_800_000_000,
            expires: 1_807_776_000,
            last_used: 0,
            state: state.into(),
            ..Default::default()
        }
    }

    /// ★ **My tokens lists holger's tokens, mints one (the secret shown once) and revokes.**
    #[test]
    fn my_tokens_lists_mints_and_revokes_through_holger() {
        let mock = Mock {
            list: Some(Ok(pb_token::ListTokensReply { tokens: vec![token("live"), token("revoked")] })),
            mint: Some(Ok(pb_token::MintTokenReply { secret: "holger_rt_s3cret".into(), token: Some(token("live")), words: "shown once: copy it now".into() })),
            revoke: Some(Ok(pb_token::RevokeTokenReply { token: Some(token("revoked")) })),
            ..Default::default()
        };
        let rows = block_on(load_tokens(&mock)).unwrap();
        let r = &rows[0];
        assert_eq!((r.label.as_str(), r.owner.as_str(), r.repositories.as_str(), r.scope, r.last_used.as_str()), ("laptop cargo", "Ida", "acme-private, crates", "read and write", "never"));
        assert_eq!((r.state.as_str(), r.tone, r.revocable), ("live", Tone::Good, true));
        assert_eq!(r.created, "2027-01-15 08:00 UTC");
        assert_eq!((rows[1].tone, rows[1].revocable), (Tone::Bad, false), "a revoked token is shown, not offered again");

        let m = block_on(mint(&mock, no_touch, " laptop cargo ", "acme-private, crates\n", pb_token::TokenScope::ReadWrite as i32, 30));
        let sent = mock.sent_mint.borrow()[0].clone();
        assert_eq!((sent.label.as_str(), sent.repositories.clone(), sent.scope, sent.days), ("laptop cargo", vec!["acme-private".to_owned(), "crates".to_owned()], 2, 30));
        let Minted::Minted { secret, words, .. } = m else { panic!("{m:?}") };
        assert_eq!((secret.as_str(), words.as_str()), ("holger_rt_s3cret", "shown once: copy it now"));
        assert_eq!(with_token("token = \"YOUR-TOKEN\"", &secret), "token = \"holger_rt_s3cret\"");
        assert_eq!(with_token("token = \"YOUR-TOKEN\"", ""), "token = \"YOUR-TOKEN\"");

        let after = block_on(revoke(&mock, "b3:abcd")).unwrap().unwrap();
        assert_eq!(mock.sent_revoke.borrow().as_slice(), ["b3:abcd"]);
        assert_eq!(after.state, "revoked");
    }

    async fn no_touch() -> Result<(), String> {
        panic!("no touch was asked for")
    }

    /// A door whose mints answer in turn, for the step-up retry.
    struct Turns(RefCell<Vec<Result<pb_token::MintTokenReply, Refused>>>, RefCell<usize>);
    impl TokenDoor for Turns {
        async fn mint(&self, _req: pb_token::MintTokenRequest) -> Result<pb_token::MintTokenReply, Refused> {
            *self.1.borrow_mut() += 1;
            self.0.borrow_mut().remove(0)
        }
        async fn list(&self) -> Result<pb_token::ListTokensReply, Refused> {
            unanswered()
        }
        async fn revoke(&self, _id: &str) -> Result<pb_token::RevokeTokenReply, Refused> {
            unanswered()
        }
    }

    /// ★ A28 M9: a mint holger refuses for a stale touch is answered with a touch in place and
    /// sent once more; a cancelled touch mints nothing and says it needs a fresh touch.
    #[test]
    fn a_stale_mint_is_touched_in_place_and_sent_once_more() {
        let stale = || Err(Refused { code: "PermissionDenied".into(), message: "[holger-token-7] minting a repository token needs a fresh touch of your key".into() });
        let ok = || Ok(pb_token::MintTokenReply { secret: "holger_rt_s3cret".into(), token: None, words: "shown once".into() });
        let door = Turns(RefCell::new(vec![stale(), ok()]), RefCell::new(0));
        let touched = std::cell::Cell::new(0);
        let m = block_on(mint(&door, || { touched.set(touched.get() + 1); async { Ok(()) } }, "ci", "crates", pb_token::TokenScope::Read as i32, 0));
        assert!(matches!(&m, Minted::Minted { secret, .. } if secret == "holger_rt_s3cret"), "{m:?}");
        assert_eq!((*door.1.borrow(), touched.get()), (2, 1));

        let door = Turns(RefCell::new(vec![stale()]), RefCell::new(0));
        let m = block_on(mint(&door, || async { Err("The touch was cancelled or timed out.".to_owned()) }, "ci", "crates", 0, 0));
        let Minted::Refused(r) = m else { panic!("{m:?}") };
        assert_eq!(r.code, "holger-token-7");
        assert!(r.text.starts_with(crate::step_up::NEEDS_TOUCH) && r.text.ends_with("cancelled or timed out."), "{}", r.text);
        assert_eq!(*door.1.borrow(), 1, "nothing is sent again without a touch");

        let door = Turns(RefCell::new(vec![Err(Refused { code: "PermissionDenied".into(), message: "[holger-token-8] beyond its owner".into() })]), RefCell::new(0));
        let m = block_on(mint(&door, || async { panic!("no touch for another refusal") }, "ci", "crates", 0, 0));
        assert!(matches!(m, Minted::Refused(ref r) if r.code == "holger-token-8"), "{m:?}");
    }

    #[test]
    fn a_mint_holger_refuses_shows_its_code_and_words() {
        let mock = Mock { mint: Some(Err(Refused { code: "PermissionDenied".into(), message: "[holger-token-8] a repository token cannot grant more than its owner holds".into() })), ..Default::default() };
        let m = block_on(mint(&mock, no_touch, "ci", "crates", pb_token::TokenScope::Read as i32, 0));
        assert_eq!(m, Minted::Refused(RefusalView { code: "holger-token-8".into(), text: "a repository token cannot grant more than its owner holds".into() }));
    }
}