//! The command line as `didbot --list` and a script see it: help and //! version on stdout with exit 0, a usage refusal on stderr with exit 2, and //! `operate` refusing to open a browser for nobody. use std::process::{Command, Output}; fn operator(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_didbot-operator")) .args(args) // A home that holds no session, so no verb finds one by accident. .env( "XDG_CONFIG_HOME", std::env::temp_dir().join("didbot-operator-nowhere"), ) .output() .expect("run didbot-operator") } #[test] fn help_goes_to_stdout_and_succeeds() { for args in [ &["--help"][..], &["-h"], &["operate", "--help"], &["estop", "--help"], &["account", "--help"], &["app", "--help"], ] { let output = operator(args); assert!(output.status.success(), "{args:?} exited {}", output.status); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("Usage: didbot"), "{args:?} did not spell the command the way a person types it:\n{stdout}" ); assert!(output.stderr.is_empty(), "{args:?} wrote to stderr"); } let stdout = String::from_utf8_lossy(&operator(&["--help"]).stdout).into_owned(); for verb in [ "operate", "login", "estop", "account", "app", "announce", "--server", "--json", ] { assert!( stdout.contains(verb), "--help does not mention {verb}:\n{stdout}" ); } let stdout = String::from_utf8_lossy(&operator(&["operate", "--help"]).stdout).into_owned(); for flag in [ "--check", "--kind", "--oidc", "--fingerprint", "--creates", "--creates-beneath", ] { assert!( stdout.contains(flag), "operate --help does not mention {flag}:\n{stdout}" ); } } /// `--list` reads the version off this line, and it has to be this /// binary's name, not the dispatcher's spelling. #[test] fn version_names_the_binary() { let output = operator(&["--version"]); assert!(output.status.success()); assert_eq!( String::from_utf8_lossy(&output.stdout).trim(), format!("didbot-operator {}", env!("CARGO_PKG_VERSION")) ); } #[test] fn a_wrong_command_line_exits_two_on_stderr() { for args in [ &[][..], &["frobnicate"], &["estop", "--server", "pds.example", "--pause", "--revoke"], &["login"], &["operate"], // A parked key is admitted by its fingerprint or by a question, never // by a flag that skips both. &["operate", "h1.pds.example", "op.example", "--yes"], // An allowance names kinds, and a kind is one of four labels. &[ "operate", "h1.pds.example", "op.example", "--creates", "bogus", ], &[ "operate", "h1.pds.example", "op.example", "--creates-beneath", "agent", "bogus", ], ] { let output = operator(args); assert_eq!( output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr) ); assert!(!output.stderr.is_empty(), "{args:?} refused silently"); assert!(output.stdout.is_empty(), "{args:?} put a refusal on stdout"); } } /// A claim needs the operator's own account to sign in as; asking for one /// is a usage error, before any browser or network. #[test] fn operate_without_an_operator_is_refused_before_signing_in() { let output = operator(&["operate", "pds.example"]); assert_eq!(output.status.code(), Some(2)); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.starts_with("didbot operate: "), "{stderr}"); assert!(stderr.contains(""), "{stderr}"); } /// `--oidc` is an issuer and then `claim=value` pairs; anything else is a /// command line, refused before any sign-in. #[test] fn operate_refuses_a_malformed_oidc_identity_before_signing_in() { for args in [ &[ "operate", "web.pds.example", "op.example", "--oidc", "https://issuer.example", ][..], &[ "operate", "web.pds.example", "op.example", "--oidc", "https://issuer.example", "no-equals-sign", ], ] { let output = operator(args); assert_eq!( output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr) ); assert!(output.stdout.is_empty(), "{args:?} put a refusal on stdout"); } } /// `--creates bogus` is refused by name, with the kinds it could have said, /// before any sign-in. #[test] fn operate_refuses_an_unknown_kind_in_an_allowance_before_signing_in() { let output = operator(&[ "operate", "laptop.pds.example", "op.example", "--creates", "bogus", ]); assert_eq!(output.status.code(), Some(2)); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("bogus"), "{stderr}"); for kind in ["service", "host", "pipeline", "agent"] { assert!(stderr.contains(kind), "{stderr} does not offer {kind}"); } } /// Every account verb is gated on the operator session and refuses before /// it reaches a deployment when there is none. /// /// The session is the whole of the authorization for these, so a verb that /// ran without one would be a verb reaching a deployment as nobody. #[test] fn an_account_verb_with_no_session_reaches_no_deployment() { for verb in [ "lock", "unlock", "lift-quarantine", "erase", "delete", "revoke-tokens", ] { let output = operator(&[ "account", verb, "kestrel.agents.example", "--server", "pds.example", ]); assert!(!output.status.success(), "{verb} ran without a session"); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.starts_with("didbot account:"), "{verb}'s refusal is one line naming the command:\n{stderr}" ); } } /// Ending an app's logins is gated on the operator session too, and names /// the application by `client_id` rather than by anything this deployment /// invented for it. #[test] fn ending_an_apps_logins_with_no_session_reaches_no_deployment() { let output = operator(&[ "app", "end-logins", "--client", "https://app.pds.example/client.json", "--server", "pds.example", ]); assert!(!output.status.success(), "end-logins ran without a session"); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.starts_with("didbot app:"), "the refusal is one line naming the command:\n{stderr}" ); } /// The account is optional, and its absence is what "every account" is /// spelled as. A verb that required it could not end an app everywhere. #[test] fn ending_an_apps_logins_takes_an_optional_account() { let output = operator(&[ "app", "end-logins", "--client", "https://app.pds.example/client.json", "--server", "pds.example", ]); let stderr = String::from_utf8_lossy(&output.stderr); assert!( !stderr.contains("required"), "--account must be optional:\n{stderr}" ); let stdout = String::from_utf8_lossy(&operator(&["app", "end-logins", "--help"]).stdout).into_owned(); for flag in ["--client", "--account"] { assert!( stdout.contains(flag), "app end-logins --help does not mention {flag}:\n{stdout}" ); } } /// The subtree is asked for and never assumed: `--recursive` is the flag, /// and it is on `delete` alone. #[test] fn only_delete_takes_the_subtree() { let stdout = String::from_utf8_lossy(&operator(&["account", "delete", "--help"]).stdout).into_owned(); assert!( stdout.contains("--recursive"), "account delete --help does not mention --recursive:\n{stdout}" ); for verb in [ "lock", "unlock", "lift-quarantine", "erase", "revoke-tokens", ] { let stdout = String::from_utf8_lossy(&operator(&["account", verb, "--help"]).stdout).into_owned(); assert!( !stdout.contains("--recursive"), "account {verb} --help offers --recursive:\n{stdout}" ); } }