holger-estate-front/tests/the_people_are_holgers.rs
//! **The people are holger's, through the thin console** (holger-ui thin
//! layer, 2026-10-17).
//!
//! A REAL holger (its passkey plane, its read gate, its RBAC, its HTTP door and
//! gRPC plane on loopback) and the REAL console router (holger-ui-server, linked
//! in exactly as the front links it), driven the way a browser drives them, with
//! a soft authenticator for the person's key. The console holds nothing: the
//! ceremony is holger's, the session is holger's bearer, and every call —
//! gRPC-web and the passkey door — is passed through and decided by holger.
use std::io::Write as _;
use http::{Request, StatusCode};
use nornir_auth_webauthn::softkey::SoftKey;
use tower::ServiceExt as _;
use holger_estate_front::certify::proto as pb;
const RP: &str = "holger.test";
const ORIGIN: &str = "https://holger.test";
/// holger's static bearer (subject `console`, Admin): the bootstrap that
/// enrols the first passkey, and the console's proof behind a ceremony hint.
const CONSOLE: &str = "c0nsole-bearer-0123456789abcdefghij";
const GATED: &str = "monetize-backup";
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
/// A real holger: the estate's auth block (the console's static bearer is
/// Admin, the three groups the kit emits) plus the passkey plane the installer
/// feeds, an open repository and the gated ledger-backup repository.
fn holger(data: &std::path::Path, token_file: &std::path::Path) -> (String, String) {
let (grpc, http) = (free_port(), free_port());
let ron = format!(
r#"(
repositories: [
(ron_name: "bundles", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
ron_out: Some((ron_storage_endpoint: "store", ron_exposed_endpoint: "doors")),
ron_data_dir: Some("{d}/bundles")),
(ron_name: "{GATED}", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
ron_out: Some((ron_storage_endpoint: "store", ron_exposed_endpoint: "doors")),
ron_data_dir: Some("{d}/{GATED}"), ron_require_auth: true, ron_require_role: Some(Admin)),
],
exposed_endpoints: [ (ron_name: "doors", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")) ],
storage_endpoints: [ (ron_name: "store", ron_storage_type: "znippy", ron_path: "{d}") ],
audit: (dir: "{d}/audit", enabled: true),
auth: Some((
methods: [
StaticToken(subject: "console", token_file: Some("{tf}")),
Passkey(rp_id: "{RP}", rp_origin: "{ORIGIN}"),
],
roles: {{"console": Admin}},
group_roles: {{"holger-admins": Admin, "holger-writers": Writer, "holger-readers": Reader}},
require_auth_for_reads: false,
)),
)"#,
d = data.display(),
tf = token_file.display(),
);
let mut cfg = tempfile::NamedTempFile::new().unwrap();
cfg.write_all(ron.as_bytes()).unwrap();
let mut h = holger_server_lib::read_ron_config(cfg.path()).expect("the RON parses");
h.instantiate_backends().unwrap();
holger_server_lib::wire_holger(&mut h).unwrap();
h.start().unwrap();
std::mem::forget(cfg);
(format!("127.0.0.1:{grpc}"), format!("127.0.0.1:{http}"))
}
/// The console, as the front builds it.
fn console(grpc: &str, door: &str) -> axum::Router {
holger_ui_server::router(
None,
holger_ui_server::Upstreams::new(grpc, door, Some(CONSOLE.into())),
)
}
struct Answer {
status: StatusCode,
body: serde_json::Value,
text: String,
/// The session cookie the console set, if it set one (A34-38).
cookie: Option<String>,
}
/// A JSON call to holger's passkey door, through the console.
async fn call(
app: &axum::Router,
path: &str,
bearer: Option<&str>,
body: serde_json::Value,
) -> Answer {
let mut req = Request::builder()
.method("POST")
.uri(path)
.header(http::header::CONTENT_TYPE, "application/json");
if let Some(b) = bearer {
req = req.header(http::header::AUTHORIZATION, format!("Bearer {b}"));
}
let r = app
.clone()
.oneshot(req.body(axum::body::Body::from(body.to_string())).unwrap())
.await
.unwrap();
let status = r.status();
let cookie = r
.headers()
.get_all(http::header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find_map(|v| {
v.strip_prefix("__Host-holger_session=")
.and_then(|v| v.split(';').next())
.filter(|v| !v.is_empty())
.map(str::to_owned)
});
let bytes = axum::body::to_bytes(r.into_body(), 1 << 22).await.unwrap();
let text = String::from_utf8_lossy(&bytes).into_owned();
Answer {
status,
body: serde_json::from_str(&text).unwrap_or(serde_json::Value::Null),
text,
cookie,
}
}
/// A JSON call carrying the console's session cookie instead of a bearer (what a browser sends
/// from the console's own page: its Origin is this site's).
async fn call_with_cookie(app: &axum::Router, path: &str, cookie: &str) -> Answer {
call_with_cookie_from(app, path, cookie, ORIGIN).await
}
/// [`call_with_cookie`] from the page at `origin`.
async fn call_with_cookie_from(
app: &axum::Router,
path: &str,
cookie: &str,
origin: &str,
) -> Answer {
let req = Request::builder()
.method("POST")
.uri(path)
.header(http::header::HOST, RP)
.header(http::header::ORIGIN, origin)
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::COOKIE,
format!("__Host-holger_session={cookie}"),
);
let r = app
.clone()
.oneshot(req.body(axum::body::Body::from("{}")).unwrap())
.await
.unwrap();
let status = r.status();
let bytes = axum::body::to_bytes(r.into_body(), 1 << 22).await.unwrap();
let text = String::from_utf8_lossy(&bytes).into_owned();
Answer {
status,
body: serde_json::from_str(&text).unwrap_or(serde_json::Value::Null),
text,
cookie: None,
}
}
/// One gRPC-web call through the console's `/grpc/` door, as the browser's
/// `tonic-web-wasm-client` makes it: `Ok(reply)` or `Err((grpc-status, message))`.
async fn grpc_web<Q: prost::Message, A: prost::Message + Default>(
app: &axum::Router,
method: &str,
bearer: &str,
ask: &Q,
) -> Result<A, (u32, String)> {
let msg = ask.encode_to_vec();
let mut frame = vec![0u8];
frame.extend_from_slice(&(msg.len() as u32).to_be_bytes());
frame.extend_from_slice(&msg);
let req = Request::builder()
.method("POST")
.uri(format!("/grpc/{method}"))
.header(http::header::CONTENT_TYPE, "application/grpc-web+proto")
.header("x-grpc-web", "1")
.header(http::header::AUTHORIZATION, format!("Bearer {bearer}"))
.body(axum::body::Body::from(frame))
.unwrap();
let r = app.clone().oneshot(req).await.unwrap();
assert_eq!(
r.status(),
StatusCode::OK,
"gRPC-web answers 200 and says the outcome in grpc-status"
);
let header_status = r
.headers()
.get("grpc-status")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u32>().ok());
let header_message = r
.headers()
.get("grpc-message")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
let bytes = axum::body::to_bytes(r.into_body(), 1 << 24).await.unwrap();
let (mut reply, mut status, mut message) = (None, header_status, header_message);
let mut at = 0;
while at + 5 <= bytes.len() {
let flag = bytes[at];
let len = u32::from_be_bytes([bytes[at + 1], bytes[at + 2], bytes[at + 3], bytes[at + 4]])
as usize;
let body = &bytes[at + 5..at + 5 + len];
if flag & 0x80 == 0 {
reply = Some(A::decode(body).expect("holger's message decodes"));
} else {
for line in String::from_utf8_lossy(body).lines() {
if let Some((k, v)) = line.split_once(':') {
match k.trim() {
"grpc-status" => status = v.trim().parse().ok(),
"grpc-message" => message = v.trim().to_owned(),
_ => {}
}
}
}
}
at += 5 + len;
}
match (status.unwrap_or(0), reply) {
(0, Some(r)) => Ok(r),
(0, None) => Ok(A::default()),
(s, _) => Err((s, message)),
}
}
/// A registration through the console: start → the soft key → finish.
async fn register(
app: &axum::Router,
key: &mut SoftKey,
ask: serde_json::Value,
bearer: Option<&str>,
) -> Answer {
let start = call(app, "/-/auth/passkey/register/start", bearer, ask.clone()).await;
if start.status != StatusCode::OK {
return start;
}
let challenge = start.body["publicKey"]["challenge"]
.as_str()
.unwrap()
.to_owned();
let (cd, att) = key.register(RP, ORIGIN, &challenge, true);
let mut body = serde_json::json!({ "ceremony_id": start.body["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": cd, "attestationObject": att } });
if let Some(i) = ask.get("invite") {
body["invite"] = i.clone();
}
call(app, "/-/auth/passkey/register/finish", bearer, body).await
}
/// A login through the console, `uv` or touch only: holger's session, as the console's HttpOnly
/// cookie carries it (A34-38: the answer the page reads holds no token).
async fn login(app: &axum::Router, key: &mut SoftKey, uv: bool) -> String {
let start = call(
app,
"/-/auth/passkey/login/start",
None,
if uv {
serde_json::json!({ "uv": "required" })
} else {
serde_json::json!({})
},
)
.await;
assert_eq!(start.status, StatusCode::OK, "login/start: {}", start.text);
let challenge = start.body["publicKey"]["challenge"]
.as_str()
.unwrap()
.to_owned();
let (cd, ad, sig) = key.authenticate(RP, ORIGIN, &challenge, uv);
let body = serde_json::json!({ "ceremony_id": start.body["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": cd, "authenticatorData": ad, "signature": sig } });
let fin = call(app, "/-/auth/passkey/login/finish", None, body).await;
assert_eq!(fin.status, StatusCode::OK, "login/finish: {}", fin.text);
assert!(
fin.body.get("token").is_none() && fin.body["signed_in"] == true,
"the page never reads the token: {}",
fin.text
);
fin.cookie
.expect("holger's session, in the console's cookie")
}
fn coordinate(name: &str) -> Option<pb::ArtifactId> {
Some(pb::ArtifactId {
namespace: String::new(),
name: name.into(),
version: "1".into(),
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn holger_owns_the_people_and_decides_every_call_the_console_passes_through() {
let data = tempfile::tempdir().unwrap();
let token_file = data.path().join("console-token");
std::fs::write(&token_file, CONSOLE).unwrap();
// A key file is 0600 (holger-key-1).
std::fs::set_permissions(
&token_file,
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
)
.unwrap();
let (grpc, door) = holger(&data.path().join("holger"), &token_file);
for _ in 0..200 {
if reqwest_free_get(&format!("http://{door}/-/auth/passkey/config")).await == Some(200) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
let app = console(&grpc, &door);
// ── 1. The owner's first key, enrolled with holger's static bearer.
let mut owner = SoftKey::new(0x42, b"owners-yubikey");
let r = register(
&app,
&mut owner,
serde_json::json!({ "display": "rickard", "role": "admin", "label": "a key" }),
Some(CONSOLE),
)
.await;
assert_eq!(
r.status,
StatusCode::OK,
"holger enrolled the owner: {}",
r.text
);
let owner_token = login(&app, &mut owner, true).await;
let me = call(
&app,
"/-/auth/passkey/session",
Some(&owner_token),
serde_json::json!({}),
)
.await;
let by_cookie = call_with_cookie(&app, "/-/auth/passkey/session", &owner_token).await;
assert_eq!(
(by_cookie.status, &by_cookie.body["subject"]),
(StatusCode::OK, &me.body["subject"]),
"the cookie is the person's bearer on every call: {}",
by_cookie.text
);
// RA5-5: a page on another site (a sibling of the same domain sends the SameSite=Strict
// cookie too) changes nothing with it: refused before the cookie is anybody's bearer.
let forged = call_with_cookie_from(
&app,
"/-/auth/passkey/sessions/revoke-all",
&owner_token,
"https://test2.holger.test",
)
.await;
assert_eq!(
forged.status,
StatusCode::FORBIDDEN,
"a cross-site state change rides on no cookie: {}",
forged.text
);
assert_eq!(
call_with_cookie(&app, "/-/auth/passkey/session", &owner_token)
.await
.status,
StatusCode::OK,
"the session lives on"
);
assert_eq!(
(me.body["display"].as_str(), me.body["role"].as_str()),
(Some("rickard"), Some("admin")),
"{}",
me.text
);
// The bootstrap retires once an admin passkey exists.
let mut late = SoftKey::new(0x43, b"a-second-key");
let again = register(
&app,
&mut late,
serde_json::json!({ "display": "eve", "role": "admin" }),
Some(CONSOLE),
)
.await;
assert_ne!(
again.status,
StatusCode::OK,
"the bootstrap enrols no second admin: {}",
again.text
);
// ── 2. Step-up and the invite are holger's.
let touch = login(&app, &mut owner, false).await;
let refused = call(
&app,
"/-/auth/passkey/invites",
Some(&touch),
serde_json::json!({ "role": "viewer" }),
)
.await;
assert_eq!(refused.status, StatusCode::FORBIDDEN, "{}", refused.text);
assert!(
refused.body["error"]
.as_str()
.unwrap_or("")
.starts_with("log in again"),
"the pane's step-up cue, holger's words: {}",
refused.text
);
let no_admin = call(
&app,
"/-/auth/passkey/invites",
Some(&owner_token),
serde_json::json!({ "role": "admin" }),
)
.await;
assert_eq!(
no_admin.status,
StatusCode::FORBIDDEN,
"an invite never grants admin: {}",
no_admin.text
);
let invite = call(
&app,
"/-/auth/passkey/invites",
Some(&owner_token),
serde_json::json!({ "role": "viewer" }),
)
.await;
assert_eq!(invite.status, StatusCode::OK, "{}", invite.text);
let secret = invite.body["invite"].as_str().unwrap().to_owned();
let mut vera = SoftKey::new(0x56, b"veras-phone");
let machine = register(
&app,
&mut vera,
serde_json::json!({ "invite": secret, "display": "console" }),
None,
)
.await;
assert_eq!(
machine.status,
StatusCode::BAD_REQUEST,
"a machine's name: {}",
machine.text
);
let r = register(
&app,
&mut vera,
serde_json::json!({ "invite": secret, "display": "vera" }),
None,
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text);
let vera_token = login(&app, &mut vera, true).await;
let v = call(
&app,
"/-/auth/passkey/session",
Some(&vera_token),
serde_json::json!({}),
)
.await;
assert_eq!(v.body["role"].as_str(), Some("reader"), "{}", v.text);
// ── 3. Every gRPC-web call is the person's, and holger decides.
let listed: pb::ListRepositoriesResponse = grpc_web(
&app,
"holger.v1.AdminService/ListRepositories",
&vera_token,
&pb::ListRepositoriesRequest {},
)
.await
.expect("vera may list");
let names: Vec<_> = listed
.repositories
.iter()
.map(|r| r.name.as_str())
.collect();
assert!(
names.contains(&"bundles") && !names.contains(&GATED),
"holger lists vera what she may read: {names:?}"
);
let put = |token: &str| {
let req = pb::PutArtifactRequest {
repository: "bundles".into(),
id: coordinate("a.txt"),
data: b"hello".to_vec(),
async_job: false,
};
let app = app.clone();
let token = token.to_owned();
async move {
grpc_web::<_, pb::PutArtifactResponse>(
&app,
"holger.v1.RepositoryService/PutArtifact",
&token,
&req,
)
.await
}
};
let (code, message) = put(&vera_token)
.await
.expect_err("holger refuses a reader's write");
assert_eq!(code, 7, "PERMISSION_DENIED: {message}");
let admin_view = grpc_web::<_, pb::AdminSnapshotReply>(
&app,
"holger.v1.AdminService/AdminSnapshot",
&vera_token,
&pb::AdminSnapshotRequest { log_lines: 0 },
)
.await;
assert!(admin_view.is_err(), "the admin view is not a viewer's");
assert!(
put(&owner_token)
.await
.expect("the owner writes as themselves")
.success
);
let who: pb::WhoAmIResponse = grpc_web(
&app,
"holger.v1.AdminService/WhoAmI",
&owner_token,
&pb::WhoAmIRequest::default(),
)
.await
.unwrap();
assert!(
who.method == "passkey"
&& who.subject.starts_with("u-")
&& who.global_role.eq_ignore_ascii_case("admin"),
"whoami is the person's own: {who:?}"
);
// ── 4. Logout ends the session at holger.
let out = call(
&app,
"/-/auth/passkey/logout",
Some(&vera_token),
serde_json::json!({}),
)
.await;
assert_eq!(out.status, StatusCode::OK, "{}", out.text);
let after = call(
&app,
"/-/auth/passkey/session",
Some(&vera_token),
serde_json::json!({}),
)
.await;
assert_eq!(
after.status,
StatusCode::UNAUTHORIZED,
"a logged-out session is no session: {}",
after.text
);
}
/// A plain GET's status, with no client library: the rig's readiness probe.
async fn reqwest_free_get(url: &str) -> Option<u16> {
let (hp, path) = split(url);
let mut s = tokio::net::TcpStream::connect(&hp).await.ok()?;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
s.write_all(
format!("GET {path} HTTP/1.1\r\nHost: {hp}\r\nConnection: close\r\n\r\n").as_bytes(),
)
.await
.ok()?;
let mut out = Vec::new();
s.read_to_end(&mut out).await.ok()?;
String::from_utf8_lossy(&out)
.split_whitespace()
.nth(1)?
.parse()
.ok()
}
/// A bearer POST to holger's own door: (status, body).
#[allow(dead_code)]
async fn reqwest_post_bearer(url: &str, bearer: &str) -> (u16, String) {
let (hp, path) = split(url);
let mut s = tokio::net::TcpStream::connect(&hp).await.unwrap();
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
s.write_all(format!("POST {path} HTTP/1.1\r\nHost: {hp}\r\nAuthorization: Bearer {bearer}\r\nContent-Length: 2\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{{}}").as_bytes()).await.unwrap();
let mut out = Vec::new();
s.read_to_end(&mut out).await.unwrap();
let text = String::from_utf8_lossy(&out).into_owned();
let status = text
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
(status, text)
}
fn split(url: &str) -> (String, String) {
let rest = url.trim_start_matches("http://");
let (hp, path) = rest.split_once('/').unwrap_or((rest, ""));
(hp.to_owned(), format!("/{path}"))
}