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

nordisk/dwarves

dwarves-gui/src/profile.rs

//! **The Profile tab** — the operator's identity, an in-GUI SIGN-IN pane, and their
//! stored credentials (by NAME).
//!
//! Three reused halves:
//! * **Identity** — the SAME [`facett_about::Identity`] the About card renders
//!   ([`crate::about::identity`], which re-exports `nornir_appshell::Identity`). Profile
//!   does not twin it; it shows the same wordmark · version · tagline · framework · logo.
//! * **Sign in** — the ONE shared [`facett_auth_ui::LoginPane`] korp/holger also mount
//!   (LAW #5 — never a per-app device-code twin). It is auth-library-agnostic +
//!   presentational: the HOST supplies a [`facett_auth_ui::LoginSeam`]. With the demo
//!   `login` feature ON, that seam is [`crate::login_seam::DwarvesLoginSeam`] — the REAL
//!   RFC 8628 device-code grant over the SHARED `nornir-auth-client`, sealing the token
//!   in dwarves' encrypted vault via the `VaultTokenStore`, gated on the vault being
//!   unlocked (the Settings → Profile gate). With it OFF the honest network-free
//!   [`facett_auth_ui::StubLogin`] drives the pane (no IdP, no vault) so the shipped demo
//!   and robot test exercise the whole drive. This REPLACES the interim `LoginSeam` +
//!   "(see terminal for the device code)" note that used to live here.
//! * **Secrets** — the dwarves credential **vault**, consumed through the [`VaultSeam`]
//!   get/list seam. The Profile view ONLY ever lists NAMES (name · kind · added-at); it
//!   has no path to a secret VALUE — the seam trait deliberately exposes no value getter,
//!   so a value can never leak into this pane or its `state_json`.
//!
//! ## Vault wiring (landed)
//! The real encrypted vault lives in `dwarves::vault` (`ZnippyVault` + the `Vault` trait
//! and `Session::key_for_path`), landed on main. This module defines the matching seam
//! trait + the real [`DwarvesVault`] impl behind the demo `vault` feature (which enables
//! `dwarves/vault`): it reads NAMES through an unlocked `Session` key (never a value
//! getter, never a passphrase prompt).
//!
//! ## The sample names are gone (audit 2026-09-10)
//! With the feature OFF, this used to mount `SampleVault` — availability `false`, a
//! `source` marker reading `"sample (feat-vault not merged)"`, and three invented
//! credential names (`crates-io`, `codeberg-ssh`, `anthropic`) listed under a heading
//! that says **Credentials**. The marker was honest; the three rows were not, and they
//! were plausible enough to be believed. They are deleted. The seam is now [`NoVault`],
//! which lists nothing and hands the pane a NAMED [`EmptyState`] over
//! `~/.dwarves/vault.bin` saying why there is nothing to list.

use facett_core::empty::EmptyState;
use facett_core::Facet;

/// The credential feed, named the way an operator would go look it up. Both the real
/// [`DwarvesVault`] and the empty [`NoVault`] report this as their `source`, so the
/// pane's header says the same thing whether or not the store is readable.
const VAULT_FEED: &str = "dwarves vault (~/.dwarves/vault.bin)";

/// One vault entry AS METADATA — a name, its kind, and when it was added. **Never** the
/// value: there is no value field, by construction.
#[derive(Clone, Debug, PartialEq)]
pub struct SecretMeta {
    pub name: String,
    pub kind: String,
    pub added_at: String,
}

/// The credential-vault seam the Profile view consumes: LIST names (safe), and report
/// whether a real vault is wired. Mirrors dwarves' `feat-vault` `Vault::list` shape
/// (`(name, kind, added_at)`), minus any value getter — the pane must not be able to
/// read a value.
pub trait VaultSeam {
    /// True iff a real, unlocked vault backs this seam (false = sample/stub surface).
    fn available(&self) -> bool;
    /// Where the listing comes from (for an honest banner): e.g. `"dwarves-vault"` or
    /// `"sample (feat-vault not merged)"`.
    fn source(&self) -> &str;
    /// The stored credential NAMES + kind + added-at — **never** values.
    fn secret_names(&self) -> Vec<SecretMeta>;
}

/// The seam when no real vault is reachable in this build.
///
/// **This replaced `SampleVault` on 2026-09-10.** `SampleVault` was honest in its
/// `available()` (`false`) and its `source()` (`"sample (feat-vault not merged)"`), and
/// it still listed three invented credential names — `crates-io`, `codeberg-ssh`,
/// `anthropic` — into a pane headed **Credentials**. Those are the names a real
/// nordisk vault plausibly holds, which is exactly what makes them dangerous: a reader
/// who sees `crates-io` in a credentials list concludes a crates.io token is stored,
/// and acts on it. An audit flagged it; the invented rows are gone.
///
/// This seam lists NOTHING and names WHY — the pane draws the shared
/// [`facett_core::empty::EmptyState`] over [`Self::empty_state`] instead of rows.
pub struct NoVault {
    /// Why the real vault is out of reach in this build/config — the detail line the
    /// empty state carries.
    detail: String,
}

impl NoVault {
    /// The seam with an explicit reason.
    pub fn new(detail: impl Into<String>) -> Self {
        Self {
            detail: detail.into(),
        }
    }

    /// The NAMED empty state this seam stands for: the vault file that is the feed, and
    /// the reason it is not readable.
    pub fn empty_state(&self) -> EmptyState {
        EmptyState::not_configured(VAULT_FEED, self.detail.clone())
            .with_hint("`dwarves vault unlock`, then `dwarves vault add <name>`")
    }
}

impl Default for NoVault {
    fn default() -> Self {
        Self::new("this build has no `vault` feature — there is no credential store to list")
    }
}

impl VaultSeam for NoVault {
    fn available(&self) -> bool {
        false
    }
    fn source(&self) -> &str {
        VAULT_FEED
    }
    /// **Empty, always.** The whole point: no names this pane did not read.
    fn secret_names(&self) -> Vec<SecretMeta> {
        Vec::new()
    }
}

/// The REAL vault seam, delegating to the landed `dwarves::vault` (names + kind +
/// added-at, read through an unlocked `Session` key). Behind the demo `vault` feature
/// (which enables `dwarves/vault`); with it OFF this isn't compiled and the default
/// [`NoVault`] is used. Keeps the value getter OUT of the Profile surface: it only
/// ever reads the entry LIST, never a secret value.
#[cfg(feature = "vault")]
pub struct DwarvesVault {
    vault: dwarves::vault::ZnippyVault,
}

#[cfg(feature = "vault")]
impl DwarvesVault {
    /// Open the default vault (`~/.dwarves/vault.bin`); the caller must have an unlocked
    /// [`dwarves::vault::Session`] for the list to be readable.
    pub fn open() -> Self {
        Self {
            vault: dwarves::vault::ZnippyVault::default_vault(),
        }
    }

    /// Open the vault at an explicit path (tests point this at a temp file + a
    /// `DWARVES_VAULT_SESSION`-isolated session).
    pub fn at(path: impl Into<std::path::PathBuf>) -> Self {
        Self {
            vault: dwarves::vault::ZnippyVault::at(path),
        }
    }
}

#[cfg(feature = "vault")]
impl VaultSeam for DwarvesVault {
    fn available(&self) -> bool {
        // Available iff the file exists AND a session key can read it.
        self.vault.exists() && dwarves::vault::Session::key_for_path(self.vault.path()).is_some()
    }
    fn source(&self) -> &str {
        VAULT_FEED
    }
    fn secret_names(&self) -> Vec<SecretMeta> {
        // ONLY the list seam — an unlocked session key, never a passphrase prompt and
        // never a value read. Any failure → an empty list (never a panic, never a value).
        let Some(key) = dwarves::vault::Session::key_for_path(self.vault.path()) else {
            return Vec::new();
        };
        match self.vault.load_with_key(&key) {
            Ok(data) => data
                .secrets
                .iter()
                .map(|(name, e)| SecretMeta {
                    name: name.clone(),
                    kind: e.kind.clone(),
                    added_at: e.added_at.clone(),
                })
                .collect(),
            Err(_) => Vec::new(),
        }
    }
}

// ── Sign in (the SHARED facett-auth-ui device-code pane) ──────────────────────
//
// The Profile "Sign in" surface is the ONE shared `facett_auth_ui::LoginPane` — the SAME
// presentational RFC 8628 device-code pane korp/holger mount (LAW #5). dwarves supplies
// the HOST `LoginSeam` behind it: the REAL `crate::login_seam::DwarvesLoginSeam` under the
// `login` feature (device-code grant over `nornir-auth-client`, token sealed in the vault
// via `VaultTokenStore`, gated on the vault being unlocked), else the honest network-free
// `facett_auth_ui::StubLogin`. This replaced the interim in-tree `LoginSeam` whose
// `sign_in` only left a "(see terminal for the device code)" note.

/// The default (network-free) login pane: the shared [`facett_auth_ui::LoginPane`] over
/// [`facett_auth_ui::StubLogin`] — the honest stub the shipped demo + robot test drive
/// when the `login` feature is OFF. `auto_poll` OFF so a headless robot drive is
/// deterministic (the live UI mount below turns it back on).
#[cfg(any(test, feature = "fixtures"))]
fn stub_login_pane() -> facett_auth_ui::LoginPane {
    facett_auth_ui::LoginPane::new(Box::new(facett_auth_ui::StubLogin::new("dev@nordisk.dev")))
        .with_title("Sign in")
}

/// What a sign-in attempt answers in a build WITHOUT the `login` feature.
pub const NO_LOGIN_NOTE: &str =
    "sign-in is not compiled into this build (feature `login`): no device code was requested and nobody is signed in";

/// The login seam of a build without the `login` feature: it REFUSES. It used to be
/// `facett_auth_ui::StubLogin`, which showed a sample device code at a sample address
/// and then reported a sample person as signed in — in the shipped UI.
struct NoLogin;

impl facett_auth_ui::LoginSeam for NoLogin {
    fn source(&self) -> &str {
        "not compiled in (feature `login`)"
    }
    fn begin_device_code(&mut self) -> facett_auth_ui::LoginState {
        facett_auth_ui::LoginState::Error(NO_LOGIN_NOTE.to_string())
    }
    fn poll(&mut self) -> facett_auth_ui::LoginState {
        facett_auth_ui::LoginState::Error(NO_LOGIN_NOTE.to_string())
    }
}

/// The login pane of a build without the `login` feature (see [`NoLogin`]).
#[cfg_attr(feature = "login", allow(dead_code))]
fn no_login_pane() -> facett_auth_ui::LoginPane {
    facett_auth_ui::LoginPane::new(Box::new(NoLogin)).with_title("Sign in")
}

/// The Profile pane: the shared Identity + the shared sign-in pane + the vault's
/// credential names.
pub struct ProfilePane {
    id: facett_about::Identity,
    vault: Box<dyn VaultSeam>,
    /// The ONE shared device-code sign-in pane (korp/holger mount the SAME one), driven
    /// by a host [`facett_auth_ui::LoginSeam`] — the real vault-backed
    /// [`crate::login_seam::DwarvesLoginSeam`] under the `login` feature, else the stub.
    login: facett_auth_ui::LoginPane,
    /// The active workspace (mirrors the selector's broadcast; context, not identity).
    active_workspace: String,
}

impl ProfilePane {
    /// Build with an explicit vault seam + the default (stub) login pane (tests inject
    /// their own; the shell uses the default seams via [`ProfilePane::new`]).
    #[cfg(any(test, feature = "fixtures"))]
    pub fn with_vault(id: facett_about::Identity, vault: Box<dyn VaultSeam>) -> Self {
        Self::with_login(id, vault, stub_login_pane())
    }

    /// Build with an explicit vault seam AND an explicit shared login pane — the general
    /// constructor tests use to drive sign-in over a chosen [`facett_auth_ui::LoginSeam`].
    pub fn with_login(
        id: facett_about::Identity,
        vault: Box<dyn VaultSeam>,
        login: facett_auth_ui::LoginPane,
    ) -> Self {
        Self {
            id,
            vault,
            login,
            active_workspace: String::new(),
        }
    }

    /// The shell's Profile: dwarves' shared Identity + the default vault seam (the real
    /// `dwarves::vault` behind the `vault` feature, else the honest [`NoVault`]) + the
    /// shared sign-in pane (the real vault-backed [`crate::login_seam::DwarvesLoginSeam`]
    /// behind the `login` feature, else the honest [`facett_auth_ui::StubLogin`]).
    pub fn new(id: facett_about::Identity) -> Self {
        #[cfg(feature = "vault")]
        let vault: Box<dyn VaultSeam> = Box::new(DwarvesVault::open());
        // No `vault` feature ⇒ NO credential list at all, and the pane says which store
        // it cannot read. Never the three sample names this used to invent.
        #[cfg(not(feature = "vault"))]
        let vault: Box<dyn VaultSeam> = Box::new(NoVault::default());

        #[cfg(feature = "login")]
        let login = crate::login_seam::login_pane();
        // No `login` feature ⇒ a seam that REFUSES ([`NoLogin`]). The crate's own tests
        // keep the scripted stub, which is not compiled into a shipped build.
        #[cfg(all(not(feature = "login"), not(any(test, feature = "fixtures"))))]
        let login = no_login_pane();
        #[cfg(all(not(feature = "login"), any(test, feature = "fixtures")))]
        let login = stub_login_pane();

        Self::with_login(id, vault, login)
    }

    /// **The Credentials empty state** — drawn (and folded into `state_json`) whenever
    /// the vault seam lists nothing. It tells the two cases apart, which the deleted
    /// `SampleVault` could not: a vault that IS readable and simply holds no secrets
    /// (`no-rows`) is a completely different fact from a vault this build/session
    /// cannot open at all (`not-configured`).
    fn credentials_empty_state(&self) -> EmptyState {
        if self.vault.available() {
            EmptyState::no_rows(
                self.vault.source().to_string(),
                "the vault is unlocked and holds no secrets",
            )
            .with_hint("`dwarves vault add <name>`")
        } else {
            EmptyState::not_configured(
                self.vault.source().to_string(),
                "no unlocked vault — nothing was read, so nothing is listed",
            )
            .with_hint("`dwarves vault unlock`")
        }
    }
}

impl Facet for ProfilePane {
    fn title(&self) -> &str {
        "Profile"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        // ── Identity (the SAME shared Identity About renders) ────────────────────
        ui.heading(format!("{} {}", self.id.display_name, self.id.version));
        ui.label(&self.id.tagline);
        if !self.active_workspace.is_empty() {
            ui.label(format!("workspace: {}", self.active_workspace));
        }
        if !self.id.framework.is_empty() {
            ui.separator();
            ui.label("Built on:");
            for f in &self.id.framework {
                ui.label(format!("· {f}"));
            }
        }
        ui.separator();

        // ── Sign in (the ONE shared facett-auth-ui device-code pane) ─────────────
        self.login.ui(ui);
        ui.separator();

        // ── Secrets (NAMES ONLY — the vault list seam) ───────────────────────────
        ui.heading("Credentials");
        let names = self.vault.secret_names();
        if names.is_empty() {
            // NOTHING to list ⇒ the NAMED empty state (which store, why), never a row.
            self.credentials_empty_state().ui(ui);
        } else {
            ui.label(format!(
                "{} · {}",
                self.vault.source(),
                if self.vault.available() {
                    "unlocked"
                } else {
                    "locked"
                },
            ));
            for m in &names {
                // NAME + kind + added-at only — there is NO value to render.
                ui.horizontal(|ui| {
                    ui.monospace(format!("{:<16}", m.name));
                    ui.label(&m.kind);
                    ui.weak(&m.added_at);
                });
            }
        }
    }

    fn state_json(&self) -> serde_json::Value {
        // The shared Identity oracle (wordmark · has_logo · …) — the SAME shape About
        // exposes, proving Profile reuses the identity, not a twin.
        let mut obj = serde_json::Map::new();
        obj.insert("kind".into(), serde_json::json!("profile"));
        obj.insert("identity".into(), facett_about::state_json(&self.id, false));
        obj.insert(
            "active_workspace".into(),
            serde_json::json!(self.active_workspace),
        );
        obj.insert(
            "vault_available".into(),
            serde_json::json!(self.vault.available()),
        );
        obj.insert(
            "vault_source".into(),
            serde_json::json!(self.vault.source()),
        );
        // The shared sign-in pane's state AS DATA (device-code state · subject ·
        // credential NAMES) — the identity (subject) never carries the token, and the
        // pane exposes no value getter, so no secret can appear under this key.
        obj.insert("login".into(), Facet::state_json(&self.login));
        // The credential list — NAMES + kind + added-at ONLY. There is deliberately no
        // "value"/"secret" key anywhere in this object (the RED-when-broken invariant).
        let secrets: Vec<serde_json::Value> = self
            .vault
            .secret_names()
            .iter()
            .map(|m| serde_json::json!({ "name": m.name, "kind": m.kind, "added_at": m.added_at }))
            .collect();
        obj.insert("secret_count".into(), serde_json::json!(secrets.len()));
        obj.insert("secret_names".into(), serde_json::json!(secrets));
        // Nothing listed ⇒ the NAMED empty state rides along, so a test can assert the
        // pane is honestly empty rather than merely short of rows.
        if secrets.is_empty() {
            obj.insert(
                "credentials_empty".into(),
                self.credentials_empty_state().state_json(),
            );
        }
        serde_json::Value::Object(obj)
    }

    fn update_json(&mut self, msg_json: &str) {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
            if let Some(ws) = v.get("set_workspace").and_then(|t| t.as_str()) {
                self.active_workspace = ws.to_string();
            }
        }
        // Forward the device-code actions (begin/poll/sign_out) to the shared login pane.
        Facet::update_json(&mut self.login, msg_json);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A seam over an INJECTED entry list — the test double that replaced the shipped
    /// `SampleVault`. It reports `available() == true` because it stands in for a REAL
    /// unlocked vault: the point of the tests below is what the pane does with rows it
    /// actually read. `#[cfg(test)]`, so no shipped constructor can reach these names.
    struct TestVault {
        entries: Vec<SecretMeta>,
    }

    impl Default for TestVault {
        fn default() -> Self {
            let meta = |name: &str, kind: &str| SecretMeta {
                name: name.to_string(),
                kind: kind.to_string(),
                added_at: "2026-07-18T00:00:00Z".to_string(),
            };
            Self {
                entries: vec![
                    meta("crates-io", "crates-io-token"),
                    meta("codeberg-ssh", "ssh-key"),
                    meta("anthropic", "opaque"),
                ],
            }
        }
    }

    impl VaultSeam for TestVault {
        fn available(&self) -> bool {
            true
        }
        fn source(&self) -> &str {
            VAULT_FEED
        }
        fn secret_names(&self) -> Vec<SecretMeta> {
            self.entries.clone()
        }
    }

    /// **THE SAMPLE CREDENTIALS ARE GONE** (audit 2026-09-10). The shipped pane used to
    /// mount `SampleVault`, which listed `crates-io` · `codeberg-ssh` · `anthropic`
    /// under the **Credentials** heading in any build without the `vault` feature.
    /// This pins that the no-vault seam lists NOTHING and names the store instead:
    ///
    ///   (a) `NoVault` yields zero entries;
    ///   (b) the pane's state carries a NAMED empty state over `~/.dwarves/vault.bin`,
    ///       cause `not-configured` — not merely a zero count;
    ///   (c) none of the three deleted sample names appears anywhere in the state;
    ///   (d) an UNLOCKED-but-empty vault is a different cause (`no-rows`) — "I read it
    ///       and it is empty" is not "I could not read it".
    ///
    /// RED-when-broken: hand any seam a default entry list and (a)/(c) fail.
    #[test]
    fn no_vault_lists_nothing_and_names_the_store() {
        let p = ProfilePane::with_vault(crate::about::identity(), Box::new(NoVault::default()));
        let s = p.state_json();

        // (a) + (b)
        assert_eq!(s["secret_count"], 0, "the no-vault seam lists nothing: {s}");
        assert_eq!(s["credentials_empty"]["empty"], true);
        assert_eq!(
            s["credentials_empty"]["feed"], VAULT_FEED,
            "the empty state NAMES the store"
        );
        assert_eq!(s["credentials_empty"]["cause"], "not-configured");
        assert_eq!(s["vault_source"], VAULT_FEED);

        // (c) the deleted sample names appear NOWHERE.
        let whole = serde_json::to_string(&s).unwrap();
        for gone in [
            "crates-io",
            "codeberg-ssh",
            "SampleVault",
            "feat-vault not merged",
        ] {
            assert!(
                !whole.contains(gone),
                "the deleted sample credential `{gone}` is gone: {whole}"
            );
        }

        // (d) unlocked-and-empty is a DIFFERENT named state.
        struct Unlocked;
        impl VaultSeam for Unlocked {
            fn available(&self) -> bool {
                true
            }
            fn source(&self) -> &str {
                VAULT_FEED
            }
            fn secret_names(&self) -> Vec<SecretMeta> {
                Vec::new()
            }
        }
        let open = ProfilePane::with_vault(crate::about::identity(), Box::new(Unlocked));
        assert_eq!(
            open.state_json()["credentials_empty"]["cause"],
            "no-rows",
            "read-and-empty ≠ could-not-read"
        );
    }

    /// RED-when-broken (the GATE): Profile shows the shared Identity AND the vault's
    /// credential NAMES — and NEVER a value. Asserts (1) the identity oracle is dwarves'
    /// (same as About), (2) the secret list carries names + kinds, and (3) NOTHING in the
    /// pane's state exposes a value (no `value`/`secret`/`b64` key, and the seam trait has
    /// no value getter at all).
    #[test]
    fn profile_shows_identity_and_secret_names_never_values() {
        let p = ProfilePane::with_vault(crate::about::identity(), Box::new(TestVault::default()));
        let s = p.state_json();

        // (1) the SAME shared Identity About uses.
        assert_eq!(s["kind"], "profile");
        assert_eq!(s["identity"]["wordmark"], "dwarves");
        assert_eq!(s["identity"]["has_logo"], true);

        // (2) the vault list surface: NAMES + kinds present (an injected unlocked vault).
        assert_eq!(
            s["vault_available"], true,
            "the injected seam stands in for an unlocked vault"
        );
        assert_eq!(s["secret_count"], 3);
        assert!(
            s.get("credentials_empty").is_none(),
            "rows present ⇒ no empty state: {s}"
        );
        let names = s["secret_names"].as_array().unwrap();
        assert!(names
            .iter()
            .any(|n| n["name"] == "crates-io" && n["kind"] == "crates-io-token"));
        assert!(names
            .iter()
            .any(|n| n["name"] == "codeberg-ssh" && n["kind"] == "ssh-key"));

        // (3) NO value anywhere — not on the entries, not on the object. The seam trait
        // exposes no value getter, so a value cannot reach this pane.
        for n in names {
            let obj = n.as_object().unwrap();
            assert!(
                !obj.contains_key("value"),
                "no secret value is ever exposed"
            );
            assert!(!obj.contains_key("secret"));
            assert!(!obj.contains_key("value_b64"));
        }
        let whole = serde_json::to_string(&s).unwrap();
        assert!(
            !whole.contains("value_b64"),
            "the value payload never appears in state"
        );
    }

    /// An empty vault renders the honest "no stored credentials" surface (never a panic).
    #[test]
    fn empty_vault_is_honest() {
        struct Empty;
        impl VaultSeam for Empty {
            fn available(&self) -> bool {
                true
            }
            fn source(&self) -> &str {
                "dwarves-vault"
            }
            fn secret_names(&self) -> Vec<SecretMeta> {
                Vec::new()
            }
        }
        let p = ProfilePane::with_vault(crate::about::identity(), Box::new(Empty));
        let s = p.state_json();
        assert_eq!(s["vault_available"], true);
        assert_eq!(s["secret_count"], 0);
        assert!(s["secret_names"].as_array().unwrap().is_empty());
    }

    /// Profile tracks the active workspace over the selector's broadcast.
    #[test]
    fn profile_tracks_active_workspace() {
        let mut p =
            ProfilePane::with_vault(crate::about::identity(), Box::new(TestVault::default()));
        p.update_json(r#"{"set_workspace":"nordisk"}"#);
        assert_eq!(p.state_json()["active_workspace"], "nordisk");
    }

    /// A build without the `login` feature REFUSES a sign-in: no device code, no
    /// address, nobody signed in — an error that says the feature is not compiled in.
    #[test]
    fn a_build_without_login_refuses_instead_of_showing_a_sample_code() {
        let mut p = ProfilePane::with_login(
            crate::about::identity(),
            Box::new(TestVault::default()),
            no_login_pane().with_auto_poll(false),
        );
        assert_eq!(p.state_json()["login"]["state"], "signed_out");
        for msg in [r#"{"begin":true}"#, r#"{"poll":true}"#] {
            p.update_json(msg);
            let s = p.state_json();
            assert_eq!(s["login"]["state"], "error", "{s}");
            assert!(
                s["login"]["user_code"].is_null() && s["login"]["subject"].is_null(),
                "no code, no subject: {s}"
            );
            assert!(
                s.to_string().contains("not compiled into this build"),
                "{s}"
            );
        }
    }

    /// A pre-cached token surfaces the identity the moment the pane opens (no sign-in
    /// action needed) — the shared pane's `current_identity` seam read, mounted in Profile.
    #[test]
    fn profile_shows_cached_identity_on_open() {
        let login = facett_auth_ui::LoginPane::new(Box::new(
            facett_auth_ui::StubLogin::new("ignored").already_signed_in("cached@vetra.se"),
        ))
        .with_auto_poll(false);
        let p = ProfilePane::with_login(
            crate::about::identity(),
            Box::new(TestVault::default()),
            login,
        );
        let s = p.state_json();
        assert_eq!(s["login"]["state"], "signed_in");
        assert_eq!(s["login"]["subject"], "cached@vetra.se");
    }

    /// ROBOT SCENARIO (the GATE for the Profile sign-in mount): the Profile pane MOUNTS
    /// the ONE shared `facett_auth_ui::LoginPane` and drives it headlessly through the
    /// device-code flow over `update_json`/`state_json`, with NO real network (the honest
    /// `facett_auth_ui::StubLogin` — the default-build seam):
    /// 1. it opens SignedOut;
    /// 2. `begin` → AwaitingUser: the pane renders the RFC 8628 prompt (user code +
    ///    verification URL) nested under Profile's `login`;
    /// 3. `poll` → SignedIn: the pane renders the subject (identity) + credential NAMES;
    /// 4. NO token/secret ever appears in the pane state (only the identity + names).
    #[test]
    fn robot_profile_drives_shared_login_pane_over_stub() {
        let login = stub_login_pane().with_auto_poll(false);
        let mut app = ProfilePane::with_login(
            crate::about::identity(),
            Box::new(TestVault::default()),
            login,
        );

        // (1) SignedOut on open.
        assert_eq!(app.state_json()["login"]["state"], "signed_out");

        // (2) begin → AwaitingUser: the device-code prompt shows under `login`.
        app.update_json(r#"{"begin":true}"#);
        let s = app.state_json();
        assert_eq!(s["login"]["state"], "awaiting_user");
        assert!(s["login"]["user_code"]
            .as_str()
            .map_or(false, |c| !c.is_empty()));
        assert!(s["login"]["verification_uri"]
            .as_str()
            .map_or(false, |u| u.contains("/device")));

        // (3) poll → SignedIn: subject + credential NAMES.
        app.update_json(r#"{"poll":true}"#);
        let s = app.state_json();
        let signed_in = s["login"]["state"] == "signed_in"
            && s["login"]["subject"] == "dev@nordisk.dev"
            && s["login"]["credential_names"]
                .as_array()
                .map_or(false, |a| a.iter().any(|n| n == "id_token"));
        assert!(
            signed_in,
            "the mounted shared pane drives sign-in and renders subject + names: {s}"
        );

        // (4) only the IDENTITY (+ names) is exposed — never a token/secret value.
        let whole = serde_json::to_string(&s).unwrap();
        assert!(
            !whole.contains("TOPSECRET")
                && !whole.contains("access_token")
                && !whole.contains("value_b64"),
            "no token/secret value ever leaks into the Profile state: {whole}"
        );

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "profile_mounts_shared_login_pane_drives_device_code",
            signed_in,
            "the Profile tab MOUNTS the ONE shared facett_auth_ui::LoginPane (korp/holger mount the same) and drives it SignedOut→AwaitingUser→SignedIn over the honest stub seam, rendering the subject + credential NAMES; no token/secret value leaks — driven headless over update_json/state_json, no real network",
        );
    }

    /// ROBOT SCENARIO (the GATE for task #69 item 4): the Profile pane's `DwarvesVault`
    /// seam reads the REAL landed `dwarves::vault` — credential NAMES come from the
    /// encrypted vault when UNLOCKED (via a `Session` key), and NEVER a value. Driven
    /// headless over `state_json`, no network. Only compiled with `--features vault`.
    #[cfg(feature = "vault")]
    #[test]
    fn robot_profile_lists_real_vault_names_when_unlocked_never_values() {
        use dwarves::vault::{Session, ZnippyVault};
        // Serialize the env mutation (DWARVES_VAULT_SESSION) across this crate's tests via
        // the ONE shared crate-level lock (never a per-module twin — L5).
        let _g = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());

        let dir = tempfile::tempdir().unwrap();
        let sess = dir.path().join("vault.session");
        let vpath = dir.path().join("vault.bin");
        // SAFETY: single-threaded under ENV; isolate the session to this dir.
        unsafe { std::env::set_var("DWARVES_VAULT_SESSION", &sess) };

        let v = ZnippyVault::at(&vpath);
        v.add(b"pw", "crates-io", "crates-io-token", b"cio_TOPSECRET")
            .unwrap();
        v.add(b"pw", "codeberg-ssh", "ssh-key", b"KEYBYTES-never-shown")
            .unwrap();

        // LOCKED: no session → the seam is unavailable and lists nothing (fail closed).
        let locked = DwarvesVault::at(&vpath);
        let p = ProfilePane::with_vault(crate::about::identity(), Box::new(locked));
        let s = p.state_json();
        assert_eq!(s["vault_source"], "dwarves-vault");
        assert_eq!(
            s["vault_available"], false,
            "a locked real vault is unavailable"
        );
        assert_eq!(s["secret_count"], 0, "locked ⇒ no names leak");

        // UNLOCKED: a Session key makes the NAMES readable (still never a value).
        Session::unlock(&v, b"pw", 3600).unwrap();
        let unlocked = DwarvesVault::at(&vpath);
        let p = ProfilePane::with_vault(crate::about::identity(), Box::new(unlocked));
        let s = p.state_json();
        let available = s["vault_available"] == true;
        assert!(available, "an unlocked real vault is available: {s}");
        assert_eq!(s["secret_count"], 2);
        let names = s["secret_names"].as_array().unwrap();
        assert!(names
            .iter()
            .any(|n| n["name"] == "crates-io" && n["kind"] == "crates-io-token"));
        assert!(names
            .iter()
            .any(|n| n["name"] == "codeberg-ssh" && n["kind"] == "ssh-key"));
        // No value ANYWHERE — not the token bytes, not the key bytes.
        let whole = serde_json::to_string(&s).unwrap();
        assert!(
            !whole.contains("TOPSECRET")
                && !whole.contains("KEYBYTES")
                && !whole.contains("value_b64"),
            "the real vault's values never reach the Profile state"
        );

        Session::lock().unwrap();
        unsafe { std::env::remove_var("DWARVES_VAULT_SESSION") };

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "profile_lists_real_vault_names_when_unlocked",
            available,
            "the Profile pane's DwarvesVault seam reads the REAL landed dwarves::vault: a locked vault is unavailable and lists nothing (fail closed), an unlocked Session makes credential NAMES readable, and no secret VALUE ever reaches the pane state — driven headless, no network",
        );
    }
}