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