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() }));
}
}