Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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}"
        ),
    );
}