server/lib/tests/holger_serves_the_identity_service.rs
//! **holger-server serves the ONE IdentityService** (IDENTITY-HOST step 3): the core's
//! `nornir.identity.v1.IdentityService` answers on the box's gRPC door, by code, the same
//! adapter gunnar-server mounts; and a box with no passkey method serves none.
#![cfg(feature = "auth-passkey")]
use std::io::Write;
use holger_server_lib::{read_ron_config, wire_holger, Holger};
use nornir_identity::grpc::pb::identity_service_client::IdentityServiceClient;
use nornir_identity::grpc::{pb, refusal_of};
use nornir_identity::Code;
use traits::test_support::hold_port;
struct Server {
_data: tempfile::TempDir,
_cfg: tempfile::NamedTempFile,
grpc: String,
}
fn start(passkey: bool) -> Server {
start_at(passkey.then_some("idh.mock.invalid"), |_| {})
}
/// A box whose passkey relying party is `rp` (none: no passkey method), `shape` run on the
/// config before it starts (what holger-server's boot sets from the installer's feed).
fn start_at(rp: Option<&str>, shape: impl FnOnce(&mut Holger)) -> Server {
let (grpc, http) = (hold_port(), hold_port());
let data = tempfile::tempdir().unwrap();
let method = rp
.map(|rp| format!(r#"Passkey(rp_id: "{rp}", rp_origin: "https://{rp}"),"#))
.unwrap_or_default();
let ron = format!(
r#"(
repositories: [
(ron_name: "raw", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
ron_data_dir: Some("{data}")),
],
exposed_endpoints: [
(ron_name: "default", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")),
],
storage_endpoints: [ (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{data}") ],
audit: (dir: "{data}/audit", enabled: true),
auth: Some((
methods: [
StaticToken(subject: "bootstrap", token: Some("bootstrap-admin-token-for-the-identity-test")),
{method}
],
roles: {{"bootstrap": Admin}},
group_roles: {{"holger-admins": Admin, "holger-readers": Reader}},
default_role: Some(Reader),
)),
)"#,
data = data.path().display(),
);
let mut cfg = tempfile::NamedTempFile::new().unwrap();
cfg.write_all(ron.as_bytes()).unwrap();
let mut holger: Holger = read_ron_config(cfg.path()).expect("the RON must parse");
shape(&mut holger);
holger.instantiate_backends().unwrap();
wire_holger(&mut holger).unwrap();
holger.start().unwrap();
Server {
_data: data,
_cfg: cfg,
grpc: format!("http://127.0.0.1:{grpc}"),
}
}
async fn client(at: &str) -> IdentityServiceClient<tonic::transport::Channel> {
for _ in 0..200 {
if let Ok(c) = IdentityServiceClient::connect(at.to_owned()).await {
return c;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("the gRPC door never opened at {at}");
}
/// ★ The box answers the IdentityService by code: a box that does not know where it sits is
/// invite only (`signup_closed`), an unknown session is `no_session`, a login opens a ceremony for this
/// box's relying party and refuses another's.
#[tokio::test(flavor = "multi_thread")]
async fn a_holger_with_passkeys_serves_the_identity_service() {
let s = start(true);
let mut c = client(&s.grpc).await;
let r = c
.sign_up(pb::SignUpRequest {
email: "kim@mock.invalid".into(),
display: "Kim".into(),
})
.await
.unwrap_err();
// An open build sells nothing, and a box neither enrolled nor fed a site is invite only
// (RA5-1: never a product site by its passkey host's name).
assert_eq!(refusal_of(&r), Some(Code::SignUpClosed), "{r:?}");
let r = c
.whoami(pb::WhoamiRequest {
session: "nis1.not-a-session".into(),
})
.await
.unwrap_err();
assert_eq!(refusal_of(&r), Some(Code::NoSession), "{r:?}");
let opened = c
.login_start(pb::LoginStartRequest {
rp_id: "idh.mock.invalid".into(),
origin: "https://idh.mock.invalid".into(),
client: String::new(),
})
.await
.unwrap()
.into_inner();
assert_eq!(opened.rp_id, "idh.mock.invalid");
assert!(!opened.ceremony_id.is_empty() && !opened.challenge.is_empty());
let r = c
.login_start(pb::LoginStartRequest {
rp_id: "evil.mock.invalid".into(),
origin: "https://evil.mock.invalid".into(),
client: String::new(),
})
.await
.unwrap_err();
assert_eq!(refusal_of(&r), Some(Code::Ceremony), "{r:?}");
}
/// ★ A box with no passkey method serves no IdentityService (as it serves no passkey door).
#[tokio::test(flavor = "multi_thread")]
async fn a_holger_without_passkeys_serves_none() {
let s = start(false);
let mut c = client(&s.grpc).await;
let r = c
.whoami(pb::WhoamiRequest {
session: "nis1.x".into(),
})
.await
.unwrap_err();
assert_eq!(r.code(), tonic::Code::Unimplemented, "{r:?}");
}
/// ★ RA5-1: **a shop-laid sold private Holger is invite only.** The shop lay writes no
/// `fleet/fleet.json` (only the manual `enrol` does) and feeds the estate late, so the box knows
/// nothing but its passkey host, the estate's DNS name. That name is no product site: a
/// stranger's `IdentityService/SignUp` on `repo.acme…` is refused by name (`signup_closed`),
/// never mailed a first-login link into the customer's private box.
#[tokio::test(flavor = "multi_thread")]
async fn a_shop_laid_sold_private_holger_refuses_a_strangers_sign_up() {
let s = start_at(Some("repo.acme.mock.invalid"), |_| {});
let mut c = client(&s.grpc).await;
let r = c
.sign_up(pb::SignUpRequest {
email: "stranger@mock.invalid".into(),
display: "Stranger".into(),
})
.await
.unwrap_err();
assert_eq!(
refusal_of(&r),
Some(Code::SignUpClosed),
"a sold private estate sells no sign-up: {r:?}"
);
}
/// ★ RA5-1: **the site is what the ONE box fed.** An operator site's main (fed its estate's site
/// variant) sells sign-up: the core takes the stranger's address and, fed no mail relay here,
/// refuses to mail the link (`mail_refused`); a sold estate's main (fed the sold variant) is
/// invite only. An open build sells nothing, whatever it is fed.
#[tokio::test(flavor = "multi_thread")]
async fn the_site_the_one_box_fed_decides_sign_up() {
use holger_server_lib::identity_host::{site_from_feed, SITE_VARIANT, VARIANT_FEED};
let metered = cfg!(feature = "metered");
for (variant, want) in [
(
SITE_VARIANT,
if metered {
Code::MailRefused
} else {
Code::SignUpClosed
},
),
("holger-twin-monetize", Code::SignUpClosed),
] {
let feed = [(VARIANT_FEED.to_string(), variant.to_string())].into();
let s = start_at(Some("repo.fed.mock.invalid"), |h| {
h.estate_site = site_from_feed(&feed, metered)
});
let mut c = client(&s.grpc).await;
let r = c
.sign_up(pb::SignUpRequest {
email: "kim@mock.invalid".into(),
display: "Kim".into(),
})
.await
.unwrap_err();
assert_eq!(refusal_of(&r), Some(want), "{variant}: {r:?}");
}
}