crates/gunnar-ui-web/src/sso_page.rs
//! **What the Identity provider page decides** (Administration → Identity
//! provider), on the host side of the wasm boundary: the `?account=` it was
//! opened for, and the promises about the page held to its code
//! ([`crate::code`]); the page itself is wasm-only.
/// `account` in a query string (`?a=b&account=…`), if any — decoded, so the
/// API call encodes it once (A34-50).
pub fn account_in(search: &str) -> Option<String> {
search
.trim_start_matches('?')
.split('&')
.filter_map(|p| p.split_once('='))
.find(|(k, _)| *k == "account")
.map(|(_, v)| query_decode(v))
.filter(|v| !v.is_empty())
}
/// A query value's form encoding undone: `+` is a space, `%XX` a byte.
fn query_decode(value: &str) -> String {
let b = value.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
let hex = |at: usize| b.get(at).and_then(|c| (*c as char).to_digit(16));
match b[i] {
b'%' => match (hex(i + 1), hex(i + 2)) {
(Some(hi), Some(lo)) => {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
_ => out.push(b'%'),
},
b'+' => out.push(b' '),
c => out.push(c),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
/// The page's code, without these tests.
fn page() -> &'static str {
static CODE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
CODE.get_or_init(|| crate::code::of(include_str!("pages/identity_provider.rs")))
}
/// ★ **A34-50: `?account=` is decoded once**, so the API call encodes it once.
#[test]
fn a34_50_the_asked_account_is_decoded_before_it_is_encoded_again() {
assert_eq!(account_in("?account=ac%20me").as_deref(), Some("ac me"));
assert_eq!(account_in("?x=1&account=acme%2Eone").as_deref(), Some("acme.one"));
assert_eq!(account_in("?account=a+b").as_deref(), Some("a b"));
assert_eq!(account_in("?account=plain").as_deref(), Some("plain"));
assert_eq!(account_in("?account="), None);
assert_eq!(account_in(""), None);
assert_eq!(
crate::auth::percent_encode(&account_in("?account=ac%20me").unwrap()),
"ac%20me",
"encoded once, not ac%2520me"
);
}
/// ★ **A34-49: the provider can be removed from the page** (`DELETE /api/account/sso`).
#[test]
fn a34_49_there_is_a_remove_provider_button() {
let code = page();
assert!(code.contains("id=\"sso-remove\""), "no Remove provider button");
assert!(code.contains("\"Remove provider\""));
assert!(code.contains("\"/api/account/sso?account={}\""), "the button does not call DELETE /api/account/sso");
}
/// ★ **A34-48: the stored client secret can be removed** (the box is gunnar's
/// `clear_client_secret`).
#[test]
fn a34_48_there_is_a_remove_the_client_secret_box() {
let code = page();
assert!(code.contains("id=\"sso-clear-secret\""), "no remove box for the client secret");
assert!(code.contains("\"clear_secret\": clear_secret.get_untracked()"));
}
/// ★ **A34-41: the IdP CA field is offered for SAML too** (gunnar reads the metadata URL
/// trusting it).
#[test]
fn a34_41_the_idp_ca_field_is_offered_for_saml_too() {
let saml = &page()[page().find("id=\"sso-saml\"").expect("the SAML block")..];
let saml = &saml[..saml.find("</div>").unwrap()];
assert!(saml.contains("<CaField"), "the SAML block has no IdP CA field");
}
/// ★ **A34-26: the page says the first passkey is trusted on the IdP sign-in alone.**
#[test]
fn a34_26_the_page_says_the_first_passkey_is_trusted_on_first_use() {
assert!(
page().contains("registers their first passkey right after their first sign-in through your identity provider"),
"the page does not say how the first passkey is registered"
);
}
}