facett-auth-ui/tests/robot.rs
//! **Robot device-code sign-in scenario for the shared login pane** (`egui_kittest`).
//!
//! Renders the REAL [`facett_auth_ui::LoginPane`] body over a stub [`LoginSeam`] and
//! drives it through the whole device-code flow — **SignedOut → AwaitingUser →
//! SignedIn** — asserting off the live AccessKit tree that the surface a robot (or a
//! screen reader) sees is right at each step:
//! 1. **SignedOut** — the *Sign in* button paints.
//! 2. **AwaitingUser** — the **user-code** + the **verification URL** paint (the
//! RFC 8628 prompt is shown INSIDE the app, not left as a "see terminal" note).
//! 3. **SignedIn** — the **subject** (identity) paints, and **NO secret value**
//! string appears anywhere in the pane's `state_json`.
//!
//! This is the L4 UI proof for the whole suite's Sign-in surface: dwarves' Profile,
//! korp and holger-ui delete their bespoke login body and mount THIS one (each
//! implementing `LoginSeam` over `nornir-auth-client`), so this test guards theirs.
//!
//! RED-when-broken (verified by neutralizing): if the *Sign in* button, the
//! user-code/URL prompt, or the signed-in subject regressed, an assertion goes red;
//! if a token/secret value ever leaked into `state_json`, the last block goes red.
//! One matrix cell `facett-auth-ui/login-pane`.
use std::cell::RefCell;
use std::rc::Rc;
use egui_kittest::kittest::Queryable;
use egui_kittest::Harness;
use facett_auth_ui::{Facet, LoginPane, StubLogin};
const SUBJECT: &str = "robot@vetra.se";
const USER_CODE: &str = "WDJB-MJHT";
const VERIFICATION_URI: &str = "https://sso.nordisk.dev/device";
/// Emit shim (real row with `--features testmatrix`, no-op otherwise).
#[cfg(feature = "testmatrix")]
fn emit(check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status("facett-auth-ui", check, ok, detail);
}
#[cfg(not(feature = "testmatrix"))]
fn emit(_check: &str, _ok: bool, _detail: &str) {}
#[test]
fn login_pane_drives_device_code_signin() {
// A stub that stays awaiting for a couple of polls, so the awaiting-user surface
// is observable before it flips to signed-in. Auto-poll OFF: the test drives the
// transitions deterministically between frames.
let pane = Rc::new(RefCell::new(
LoginPane::new(Box::new(
StubLogin::new(SUBJECT).with_approve_after(2),
))
.with_auto_poll(false),
));
let mut harness = Harness::builder().with_size(egui::vec2(520.0, 400.0)).build_ui({
let pane = Rc::clone(&pane);
move |ui| {
pane.borrow_mut().ui(ui);
}
});
// A label can ride into the tree as both a Button and its inner TextRun, so
// count matches (≥1 = present) rather than assuming a single node.
let seen = |h: &Harness, label: &str| h.query_all_by_label(label).count() >= 1;
// (1) SignedOut — the Sign in button paints, and no prompt/subject yet.
harness.run();
let signin_btn_seen = seen(&harness, "Sign in");
assert!(signin_btn_seen, "the Sign in button paints when signed out");
assert!(
!seen(&harness, USER_CODE),
"no device code before Sign in is pressed"
);
// Drive Begin → AwaitingUser (via the pane's writable message surface).
pane.borrow_mut().update_json(r#"{"begin":true}"#);
harness.run();
// (2) AwaitingUser — the user-code + the verification URL both paint.
let user_code_seen = seen(&harness, USER_CODE);
let uri_seen = seen(&harness, VERIFICATION_URI);
assert!(user_code_seen, "the RFC 8628 user-code paints in-app");
assert!(uri_seen, "the verification URL paints in-app (clickable)");
// Poll once — still awaiting (approve_after = 2), then poll again → SignedIn.
pane.borrow_mut().update_json(r#"{"poll":true}"#);
harness.run();
assert_eq!(pane.borrow_mut().state_json()["state"], "awaiting_user");
pane.borrow_mut().update_json(r#"{"poll":true}"#);
harness.run();
// (3) SignedIn — the subject (identity) paints.
let subject_seen = seen(&harness, SUBJECT);
assert!(subject_seen, "the signed-in subject (identity) paints");
let signout_seen = seen(&harness, "Sign out");
assert!(signout_seen, "a Sign out button paints once signed in");
// …and NO secret value ever appears in the pane's state_json (the seam exposes
// no value getter — only the subject + credential NAMES).
let s = pane.borrow_mut().state_json();
assert_eq!(s["state"], "signed_in");
assert_eq!(s["subject"], SUBJECT);
let whole = serde_json::to_string(&s).unwrap();
let no_secret = !whole.contains("TOPSECRET")
&& !whole.contains("access_token")
&& !whole.contains("value_b64")
&& s.as_object().unwrap().get("value").is_none()
&& s.as_object().unwrap().get("token").is_none();
assert!(no_secret, "no token/secret value ever leaks into the login pane state: {whole}");
let ok = signin_btn_seen && user_code_seen && uri_seen && subject_seen && signout_seen && no_secret;
emit(
"login-pane",
ok,
&format!(
"signin_btn={signin_btn_seen} user_code={user_code_seen} uri={uri_seen} \
subject={subject_seen} signout={signout_seen} no_secret={no_secret}"
),
);
}