Skip to main content

vta_cli_common/commands/
acl.rs

1use ratatui::{
2    layout::Constraint,
3    style::{Color, Modifier, Style},
4    widgets::{Block, Cell, Row, Table},
5};
6use vta_sdk::acl::{ApproveScope, ContextDirection};
7use vta_sdk::client::ChangeAclRoleRequest;
8use vta_sdk::prelude::*;
9use vti_common::acl::{Role, act_scope_for};
10
11use crate::display::{
12    NAME_HEADER, NameBook, NameSource, book_from_acl, did_cell, full_display_pairs, name_cell,
13    named_did_cell, resolve_agent_names_into,
14};
15use crate::render::{is_full_display, print_full_entry_owned, print_full_list_title, print_widget};
16
17/// Human-readable context list — **role-aware**, because an empty
18/// `allowed_contexts` means opposite things depending on the role.
19///
20/// `AuthClaims::is_super_admin` requires `Role::Admin` *and* an empty list;
21/// `has_context_access` otherwise iterates `allowed_contexts`, and an empty
22/// list matches nothing. So empty means "every context" for an admin and
23/// "no context at all" for every other role.
24///
25/// Rendering both as `(unrestricted)` misled in both directions on a
26/// security-relevant display: a correctly-scoped least-privilege approver
27/// looked like a blanket grant, and an operator auditing for over-broad
28/// access saw `(unrestricted)` on rows that were in fact inert.
29pub fn format_contexts(role: &str, contexts: &[String]) -> String {
30    // The wire form carries the role as a string, so parse it back before
31    // decoding. An unrecognised role falls to the most restrictive reading:
32    // a display must never invent authority it cannot confirm.
33    //
34    // `format_role` already renders an unrestricted admin as "super admin", so
35    // the two columns read together without repeating the term.
36    let role = Role::parse(role).unwrap_or(Role::Monitor);
37    act_scope_for(&role, contexts).to_string()
38}
39
40pub fn format_role(role: &str, contexts: &[String]) -> String {
41    if role == "admin" && contexts.is_empty() {
42        "super admin".to_string()
43    } else {
44        role.to_string()
45    }
46}
47
48/// Human-readable approve-authority — what this entry may *confer* via an
49/// approval (task-consent delegation / step-up ratification) while acting
50/// nowhere. `None` when it confers nothing, so callers omit the line entirely.
51pub fn format_approve_scope(approve_all: bool, approve_contexts: &[String]) -> Option<String> {
52    if approve_all {
53        Some("all contexts".to_string())
54    } else if !approve_contexts.is_empty() {
55        Some(format!("contexts [{}]", approve_contexts.join(", ")))
56    } else {
57        None
58    }
59}
60
61/// Human-readable signing-key filter (#818). `None` (no filter) omits the
62/// line entirely; the empty filter is the state that must never be
63/// misrendered — "no keys" and "no filter" are opposite grants, so the empty
64/// set is spelled out rather than shown as a blank list.
65pub fn format_allowed_keys(allowed_keys: Option<&[String]>) -> Option<String> {
66    match allowed_keys {
67        None => None,
68        Some([]) => Some("(none — may invoke the signing oracle on no keys)".to_string()),
69        Some(keys) => Some(format!("keys [{}]", keys.join(", "))),
70    }
71}
72
73/// Resolve the two mutually-exclusive allowed-keys flags into the wire value.
74///
75/// `None` means "leave unchanged"; `Some(None)` clears the filter
76/// (`--allowed-keys-unrestricted`); `Some(Some(keys))` replaces it. Clearing
77/// needs its own flag for the same reason `--approve-none` does: an empty
78/// `--allowed-keys` cannot mean both "no keys at all" and "no filter".
79pub fn allowed_keys_from_flags(
80    allowed_keys: Option<Vec<String>>,
81    allowed_keys_unrestricted: bool,
82) -> Option<Option<Vec<String>>> {
83    if allowed_keys_unrestricted {
84        Some(None)
85    } else {
86        allowed_keys.map(Some)
87    }
88}
89
90/// Resolve the two mutually-exclusive capability flags into the wire value.
91///
92/// `None` leaves the narrowing unchanged; `Some(vec![])` clears it
93/// (`--capabilities-all`, a privilege increase); `Some(names)` narrows to
94/// exactly those. Clearing needs its own flag because an empty
95/// `--capabilities` cannot mean both "narrowed to nothing" and "not narrowed" —
96/// and of those two readings, the one an operator would get by accident is the
97/// one that hands the entry back everything.
98pub fn capabilities_from_flags(
99    capabilities: Option<Vec<String>>,
100    capabilities_all: bool,
101) -> Option<Vec<String>> {
102    if capabilities_all {
103        Some(Vec::new())
104    } else {
105        capabilities
106    }
107}
108
109pub fn validate_role(role: &str) -> Result<(), Box<dyn std::error::Error>> {
110    match role {
111        "admin" | "initiator" | "application" | "reader" => Ok(()),
112        _ => Err(format!(
113            "invalid role '{role}', expected: admin, initiator, application, or reader"
114        )
115        .into()),
116    }
117}
118
119/// Parse the operator's `--direction` value, defaulting to the historical
120/// "who may act in this context" reading and refusing anything else with the
121/// valid set — the same table the wire uses, so flag and payload cannot drift.
122pub fn parse_direction(
123    direction: Option<&str>,
124) -> Result<ContextDirection, Box<dyn std::error::Error>> {
125    match direction {
126        None => Ok(ContextDirection::default()),
127        Some(d) => Ok(d.parse::<ContextDirection>()?),
128    }
129}
130
131/// The question a `--context` + `--direction` pair actually asked, in words.
132///
133/// On screen because the two directions are equally plausible readings of the
134/// same flag and produce different lists: a subtree sweep that quietly ran as
135/// an act-in query returns the ancestors it is not revoking and looks like a
136/// complete answer (#822).
137pub fn describe_filter(context: &str, direction: ContextDirection) -> String {
138    match direction {
139        ContextDirection::ActingIn => format!("able to act in {context}"),
140        ContextDirection::Subtree => format!("granted at or beneath {context}"),
141        ContextDirection::Any => format!("with authority touching {context}"),
142    }
143}
144
145pub async fn cmd_acl_list(
146    client: &VtaClient,
147    context: Option<&str>,
148    direction: Option<&str>,
149) -> Result<(), Box<dyn std::error::Error>> {
150    let direction = parse_direction(direction)?;
151    if context.is_none() && direction != ContextDirection::default() {
152        return Err(format!(
153            "--direction {direction} says how to read --context, and no --context was given.\n\
154             Try: pnm acl list --context <id> --direction {direction}"
155        )
156        .into());
157    }
158    let resp = client.list_acl_in_direction(context, direction).await?;
159
160    // `--json` short-circuits all rendering and emits a single JSON
161    // document. Empty result returns an empty array, NOT a printed
162    // "no entries" string — automation scripts depend on the JSON
163    // shape being consistent across populated and empty results.
164    if crate::render::is_json_output() {
165        crate::render::print_json(&resp.entries)?;
166        return Ok(());
167    }
168
169    if resp.entries.is_empty() {
170        // An empty subtree sweep is the answer that most needs qualifying:
171        // it can equally mean "nothing is granted beneath this context" and
172        // "you asked the other direction". Name the question that was asked.
173        match context {
174            Some(ctx) => println!("No ACL entries found {}.", describe_filter(ctx, direction)),
175            None => println!("No ACL entries found."),
176        }
177        return Ok(());
178    }
179
180    // One pass over the entries names every subject from its label — and,
181    // for free, the `Created By` column too, since a granting admin nearly
182    // always holds an ACL entry of their own.
183    let mut book = NameBook::new();
184    book_from_acl(&mut book, &resp.entries);
185    // Opt-in, and only over the DIDs actually on screen — including the
186    // `created_by` column, which is where an unfamiliar DID most often shows up.
187    resolve_agent_names_into(
188        &mut book,
189        resp.entries
190            .iter()
191            .flat_map(|e| [e.did.as_str(), e.created_by.as_str()]),
192    )
193    .await;
194
195    // Name the question on screen whenever a context filter narrowed the
196    // answer. Two opposite readings of the same `--context` produce two
197    // legitimate, differently-shaped lists, and an operator who cannot see
198    // which one they got cannot tell a short list from a wrong one.
199    let heading = match context {
200        Some(ctx) => format!("ACL Entries — {}", describe_filter(ctx, direction)),
201        None => "ACL Entries".to_string(),
202    };
203
204    if is_full_display() {
205        print_full_list_title(&heading, resp.entries.len());
206        for entry in &resp.entries {
207            let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
208            let role = format_role(&entry.role, &entry.allowed_contexts);
209            let approve =
210                format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts());
211
212            // Name + full DID. Full display exists so an operator can copy a
213            // complete identifier, so the DID is never abbreviated here.
214            let mut fields = full_display_pairs(&book, &entry.did);
215            fields.push(("Role", role));
216            // The raw label is normally what the Name line already shows; keep
217            // it only when something higher-ranked (an agent name) displaced it.
218            if let Some(label) = entry.label.as_deref()
219                && book.name_of(&entry.did).as_deref() != Some(label)
220            {
221                fields.push(("Label", label.to_string()));
222            }
223            fields.push(("Contexts", contexts));
224            if let Some(k) = format_allowed_keys(entry.allowed_keys.as_deref()) {
225                fields.push(("Allowed Keys", k));
226            }
227            if let Some(a) = approve {
228                fields.push(("Approve", a));
229            }
230            fields.push(("Created By", book.render_inline(&entry.created_by)));
231            print_full_entry_owned(&fields);
232        }
233        return Ok(());
234    }
235
236    // Only give up a column to names if at least one entry has one — on a VTA
237    // where nothing has been labelled, a column of dashes is worse than no
238    // column.
239    let show_names = book.names_any(resp.entries.iter().map(|e| e.did.as_str()));
240
241    let header_style = Style::default()
242        .fg(Color::White)
243        .add_modifier(Modifier::BOLD);
244    let mut header_cells = vec!["DID", "Role", "Contexts", "Created By"];
245    if show_names {
246        header_cells.insert(0, NAME_HEADER);
247    }
248    let header = Row::new(header_cells).style(header_style).bottom_margin(1);
249
250    let rows: Vec<Row> = resp
251        .entries
252        .iter()
253        .map(|entry| {
254            let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
255            let mut cells = vec![
256                did_cell(&entry.did),
257                Cell::from(format_role(&entry.role, &entry.allowed_contexts)),
258                Cell::from(contexts),
259                named_did_cell(&book, &entry.created_by),
260            ];
261            if show_names {
262                cells.insert(0, name_cell(&book, &entry.did));
263            }
264            Row::new(cells)
265        })
266        .collect();
267
268    let title = format!(" {heading} ({}) ", resp.entries.len());
269
270    // DIDs are abbreviated by `shorten_did` (SCID squeezed, domain tail kept),
271    // which frees the width the name column needs. `--full-display` and
272    // `--json` still carry every DID in full.
273    let mut constraints = vec![
274        Constraint::Min(34),    // DID
275        Constraint::Length(12), // Role
276        Constraint::Length(24), // Contexts
277        Constraint::Min(30),    // Created By
278    ];
279    if show_names {
280        constraints.insert(0, Constraint::Min(16));
281    }
282
283    let table = Table::new(rows, constraints)
284        .header(header)
285        .column_spacing(2)
286        .block(
287            Block::bordered()
288                .title(title)
289                .border_style(Style::default().fg(Color::DarkGray)),
290        );
291
292    let height = resp.entries.len() as u16 + 4;
293    print_widget(table, height);
294
295    Ok(())
296}
297
298pub async fn cmd_acl_get(client: &VtaClient, did: &str) -> Result<(), Box<dyn std::error::Error>> {
299    let entry = client.get_acl(did).await?;
300
301    let mut book = NameBook::new();
302    book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
303    resolve_agent_names_into(&mut book, [entry.did.as_str()]).await;
304
305    // Name above DID, DID in full — a single-entry view is where an operator
306    // copies an identifier from.
307    match book.name_of(&entry.did) {
308        Some(name) => {
309            println!("Name:             {name}");
310            println!("DID:              {}", entry.did);
311        }
312        None => println!("DID:              {}", entry.did),
313    }
314    println!(
315        "Role:             {}",
316        format_role(&entry.role, &entry.allowed_contexts)
317    );
318    // Normally the Name line above; shown separately only when something
319    // higher-ranked (a verified agent name) displaced the operator's label.
320    if let Some(label) = entry.label.as_deref()
321        && book.name_of(&entry.did).as_deref() != Some(label)
322    {
323        println!("Label:            {label}");
324    }
325    println!(
326        "Contexts:         {}",
327        format_contexts(&entry.role, &entry.allowed_contexts)
328    );
329    if let Some(keys) = format_allowed_keys(entry.allowed_keys.as_deref()) {
330        println!("Allowed keys:     {keys}");
331    }
332    let held = entry.capabilities();
333    if !held.is_empty() {
334        println!("Capabilities:     {}", held.join(", "));
335    }
336    if let Some(scope) =
337        format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
338    {
339        println!("Approve:          {scope}");
340    }
341    println!("Created At:       {}", entry.created_at);
342    println!("Created By:       {}", entry.created_by);
343    Ok(())
344}
345
346#[allow(clippy::too_many_arguments)]
347pub async fn cmd_acl_create(
348    client: &VtaClient,
349    did: String,
350    role: String,
351    label: Option<String>,
352    contexts: Vec<String>,
353    expires_at: Option<u64>,
354    step_up_approver: Option<String>,
355    step_up_require: Option<String>,
356    approve_all: bool,
357    approve_contexts: Vec<String>,
358    allowed_keys: Option<Vec<String>>,
359    capabilities: Option<Vec<String>>,
360) -> Result<(), Box<dyn std::error::Error>> {
361    validate_role(&role)?;
362    let mut req = CreateAclRequest::new(did, role).contexts(contexts);
363    if let Some(ref caps) = capabilities {
364        req = req.capabilities(caps.clone());
365    }
366    if let Some(keys) = allowed_keys {
367        req = req.allowed_keys(keys);
368    }
369    if let Some(l) = label {
370        req = req.label(l);
371    }
372    if let Some(secs) = expires_at {
373        req = req.expires_at(secs);
374    }
375    if let Some(ref approver) = step_up_approver {
376        req = req.step_up_approver(approver.clone());
377    }
378    if let Some(ref require) = step_up_require {
379        req = req.step_up_require(require.clone());
380    }
381    if approve_all {
382        req = req.approve_all();
383    } else if !approve_contexts.is_empty() {
384        req = req.approve_contexts(approve_contexts);
385    }
386    let entry = client.create_acl(req).await?;
387    println!("ACL entry created:");
388    println!("  DID:        {}", entry.did);
389    println!(
390        "  Role:       {}",
391        format_role(&entry.role, &entry.allowed_contexts)
392    );
393    if let Some(label) = &entry.label {
394        println!("  Label:      {label}");
395    }
396    println!(
397        "  Contexts:   {}",
398        format_contexts(&entry.role, &entry.allowed_contexts)
399    );
400    if let Some(keys) = format_allowed_keys(entry.allowed_keys.as_deref()) {
401        println!("  Allowed keys: {keys}");
402    }
403    // Echoed from the entry the VTA stored, so an operator sees the narrowing
404    // that actually took effect rather than the one they asked for.
405    let held = entry.capabilities();
406    if !held.is_empty() {
407        println!("  Capabilities: {}", held.join(", "));
408    }
409    if let Some(scope) =
410        format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
411    {
412        println!("  Approve:    {scope}");
413    }
414    if let Some(approver) = &step_up_approver {
415        println!("  Step-up approver: {approver}");
416    }
417    if let Some(require) = &step_up_require {
418        println!("  Step-up require:  {require}");
419    }
420    match entry.expires_at {
421        Some(secs) => println!(
422            "  Expires at: {} ({})",
423            crate::duration::format_local_time(secs),
424            crate::duration::format_remaining(secs),
425        ),
426        None => println!("  Expires at: (permanent)"),
427    }
428    Ok(())
429}
430
431/// Resolve the three mutually-exclusive approve flags into the wire value.
432///
433/// `None` means "leave unchanged" — which is why revoking needs its own flag
434/// rather than an empty `--approve-contexts`: an empty list cannot mean both
435/// "confer nothing" and "don't touch it".
436pub fn approve_scope_from_flags(
437    approve_all: bool,
438    approve_contexts: Option<Vec<String>>,
439    approve_none: bool,
440) -> Option<ApproveScope> {
441    if approve_none {
442        Some(ApproveScope::None)
443    } else if approve_all {
444        Some(ApproveScope::All)
445    } else {
446        approve_contexts.map(ApproveScope::Contexts)
447    }
448}
449
450/// `pnm acl change-role` — transition a subject's role with a
451/// compare-and-swap on the role they currently hold.
452pub async fn cmd_acl_change_role(
453    client: &VtaClient,
454    did: &str,
455    from_role: &str,
456    to_role: &str,
457    reason: Option<String>,
458) -> Result<(), Box<dyn std::error::Error>> {
459    validate_role(from_role)?;
460    validate_role(to_role)?;
461
462    let entry = client
463        .change_acl_role(
464            did,
465            ChangeAclRoleRequest {
466                from_role: from_role.to_string(),
467                to_role: to_role.to_string(),
468                reason,
469            },
470        )
471        .await?;
472
473    println!("ACL role changed:");
474    println!("  DID:  {}", entry.did);
475    println!(
476        "  Role: {} \u{2192} {}",
477        from_role,
478        format_role(&entry.role, &entry.allowed_contexts)
479    );
480    Ok(())
481}
482
483#[allow(clippy::too_many_arguments)]
484pub async fn cmd_acl_update(
485    client: &VtaClient,
486    did: &str,
487    role: Option<String>,
488    label: Option<String>,
489    contexts: Option<Vec<String>>,
490    step_up_approver: Option<String>,
491    step_up_require: Option<String>,
492    approve_scope: Option<ApproveScope>,
493    allowed_keys: Option<Option<Vec<String>>>,
494    capabilities: Option<Vec<String>>,
495) -> Result<(), Box<dyn std::error::Error>> {
496    // Role transitions moved to `acl change-role`, which carries the
497    // compare-and-swap that makes a concurrent edit an error instead of a
498    // silent overwrite. Rather than just refusing, look up the role the
499    // subject actually holds so the operator can copy the fixed command —
500    // `--from` is the one argument they cannot guess.
501    if let Some(ref r) = role {
502        validate_role(r)?;
503        let current = client
504            .get_acl(did)
505            .await
506            .ok()
507            .map(|e| e.role)
508            .unwrap_or_else(|| "<current-role>".to_string());
509        return Err(format!(
510            "role changes are not part of `acl update` — they need the compare-and-swap that \
511             `acl change-role` carries.\n\n  Run: pnm acl change-role --did {did} --from \
512             {current} --to {r}"
513        )
514        .into());
515    }
516    let approve_scope_echo = approve_scope.clone();
517    let allowed_keys_echo = allowed_keys.clone();
518    let req = UpdateAclRequest {
519        label,
520        allowed_contexts: contexts,
521        step_up_approver: step_up_approver.clone(),
522        step_up_require: step_up_require.clone(),
523        approve_scope,
524        allowed_keys,
525        capabilities: capabilities.clone(),
526    };
527    let entry = client.update_acl(did, req).await?;
528    println!("ACL entry updated:");
529    println!("  DID:      {}", entry.did);
530    println!(
531        "  Role:     {}",
532        format_role(&entry.role, &entry.allowed_contexts)
533    );
534    if let Some(label) = &entry.label {
535        println!("  Label:    {label}");
536    }
537    println!(
538        "  Contexts: {}",
539        format_contexts(&entry.role, &entry.allowed_contexts)
540    );
541    if let Some(approver) = &step_up_approver {
542        if approver.is_empty() {
543            println!("  Step-up approver: (cleared)");
544        } else {
545            println!("  Step-up approver: {approver}");
546        }
547    }
548    if let Some(require) = &step_up_require {
549        if require.is_empty() {
550            println!("  Step-up require:  (cleared)");
551        } else {
552            println!("  Step-up require:  {require}");
553        }
554    }
555    // Echo the filter only when this call set it — and spell out the two
556    // extremes, since "(cleared — every key in scope)" and "no keys at all"
557    // are the grants an operator most needs to see they just made.
558    if let Some(replacement) = &allowed_keys_echo {
559        let rendered = match replacement.as_deref() {
560            None => "(cleared — every key within the entry's contexts)".to_string(),
561            Some([]) => "(none — may invoke the signing oracle on no keys)".to_string(),
562            Some(keys) => format!("keys [{}]", keys.join(", ")),
563        };
564        println!("  Allowed keys: {rendered}");
565    }
566    // Echo the scope only when this call set it, so "unchanged" is visibly
567    // different from "set to confer nothing".
568    if let Some(scope) = &approve_scope_echo {
569        let rendered = match scope {
570            ApproveScope::None => "(revoked — confers nothing)".to_string(),
571            ApproveScope::All => "all contexts".to_string(),
572            ApproveScope::Contexts(cs) => format!("contexts [{}]", cs.join(", ")),
573        };
574        println!("  Approve:  {rendered}");
575    }
576    // Echoed from the entry the VTA returned, not from the flags: a narrowing
577    // an operator cannot read back is one they cannot verify, and the whole
578    // point of the field is that somebody later trusts what it says.
579    if capabilities.is_some() {
580        let held = entry.capabilities();
581        let rendered = if held.is_empty() {
582            "(cleared — everything the role allows)".to_string()
583        } else {
584            held.join(", ")
585        };
586        println!("  Capabilities: {rendered}");
587    }
588    Ok(())
589}
590
591pub async fn cmd_acl_delete(
592    client: &VtaClient,
593    did: &str,
594) -> Result<(), Box<dyn std::error::Error>> {
595    client.delete_acl(did).await?;
596    println!("ACL entry deleted: {did}");
597    Ok(())
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    // ── format_contexts ────────────────────────────────────────────
605
606    /// Empty means "every context" only for an admin. This test previously
607    /// asserted `(unrestricted)` for an empty list regardless of role, which
608    /// pinned the bug rather than the behaviour.
609    #[test]
610    fn test_format_contexts_empty_is_role_dependent() {
611        assert_eq!(format_contexts("admin", &[]), "(unrestricted)");
612        for role in ["reader", "initiator", "application"] {
613            assert_eq!(
614                format_contexts(role, &[]),
615                "(none — acts nowhere)",
616                "empty contexts must not read as unrestricted for role {role}"
617            );
618        }
619    }
620
621    /// The shape the `--approve-all` help text itself recommends: a reader
622    /// with no contexts whose authority is entirely `approve_scope`. It acts
623    /// nowhere, and the display must not suggest otherwise.
624    #[test]
625    fn test_least_privilege_approver_does_not_read_as_unrestricted() {
626        let contexts: Vec<String> = vec![];
627        assert_eq!(
628            format_contexts("reader", &contexts),
629            "(none — acts nowhere)"
630        );
631        assert_eq!(format_role("reader", &contexts), "reader");
632        assert_eq!(
633            format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
634            Some("contexts [openvtc]")
635        );
636    }
637
638    #[test]
639    fn test_format_approve_scope() {
640        assert_eq!(
641            format_approve_scope(true, &[]).as_deref(),
642            Some("all contexts")
643        );
644        assert_eq!(
645            format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
646            Some("contexts [openvtc]")
647        );
648        assert_eq!(
649            format_approve_scope(false, &["a".to_string(), "b".to_string()]).as_deref(),
650            Some("contexts [a, b]")
651        );
652        // Confers nothing ⇒ no line.
653        assert_eq!(format_approve_scope(false, &[]), None);
654    }
655
656    #[test]
657    fn test_format_contexts_single() {
658        let ctx = vec!["vta".to_string()];
659        assert_eq!(format_contexts("reader", &ctx), "vta");
660    }
661
662    #[test]
663    fn test_format_contexts_multiple() {
664        let ctx = vec!["vta".to_string(), "payments".to_string()];
665        assert_eq!(format_contexts("reader", &ctx), "vta, payments");
666    }
667
668    // ── format_allowed_keys (#818) ─────────────────────────────────
669
670    /// "No filter" and "no keys" are opposite grants; the display must never
671    /// blur them (the same lesson as #746 one axis over).
672    #[test]
673    fn test_format_allowed_keys_distinguishes_absent_from_empty() {
674        assert_eq!(format_allowed_keys(None), None, "no filter → no line");
675        assert_eq!(
676            format_allowed_keys(Some(&[])).as_deref(),
677            Some("(none — may invoke the signing oracle on no keys)")
678        );
679        assert_eq!(
680            format_allowed_keys(Some(&["k1".to_string(), "k2".to_string()])).as_deref(),
681            Some("keys [k1, k2]")
682        );
683    }
684
685    #[test]
686    fn test_allowed_keys_from_flags() {
687        // Neither flag → leave unchanged.
688        assert_eq!(allowed_keys_from_flags(None, false), None);
689        // Replace with exactly these ids.
690        assert_eq!(
691            allowed_keys_from_flags(Some(vec!["k1".into()]), false),
692            Some(Some(vec!["k1".to_string()]))
693        );
694        // Clear the filter — its own flag, because an empty `--allowed-keys`
695        // cannot mean both "no keys at all" and "no filter".
696        assert_eq!(allowed_keys_from_flags(None, true), Some(None));
697    }
698
699    // ── format_role ────────────────────────────────────────────────
700
701    #[test]
702    fn test_format_role_admin_no_contexts_is_super_admin() {
703        assert_eq!(format_role("admin", &[]), "super admin");
704    }
705
706    #[test]
707    fn test_format_role_admin_with_contexts_stays_admin() {
708        let ctx = vec!["vta".to_string()];
709        assert_eq!(format_role("admin", &ctx), "admin");
710    }
711
712    #[test]
713    fn test_format_role_initiator_unchanged() {
714        assert_eq!(format_role("initiator", &[]), "initiator");
715    }
716
717    #[test]
718    fn test_format_role_application_unchanged() {
719        let ctx = vec!["app".to_string()];
720        assert_eq!(format_role("application", &ctx), "application");
721    }
722
723    // ── validate_role ──────────────────────────────────────────────
724
725    #[test]
726    fn test_validate_role_admin_ok() {
727        assert!(validate_role("admin").is_ok());
728    }
729
730    #[test]
731    fn test_validate_role_initiator_ok() {
732        assert!(validate_role("initiator").is_ok());
733    }
734
735    #[test]
736    fn test_validate_role_application_ok() {
737        assert!(validate_role("application").is_ok());
738    }
739
740    #[test]
741    fn test_validate_role_reader_ok() {
742        assert!(validate_role("reader").is_ok());
743    }
744
745    #[test]
746    fn test_validate_role_unknown_fails() {
747        let err = validate_role("superuser").unwrap_err();
748        assert!(err.to_string().contains("invalid role 'superuser'"));
749    }
750
751    #[test]
752    fn test_validate_role_empty_fails() {
753        assert!(validate_role("").is_err());
754    }
755}