From 4f402bff9d48e1fb9698d485e3b276631e9978ec Mon Sep 17 00:00:00 2001 From: Richard Pringle Date: Mon, 10 Aug 2026 15:44:39 -0400 Subject: [PATCH 1/3] refactor(tvc): parse stored operator public keys into a typed composite --- Cargo.lock | 1 + tvc/Cargo.toml | 1 + tvc/src/commands/app/create.rs | 2 +- tvc/src/commands/app/init.rs | 2 +- .../commands/keys/init_local_quorum_key.rs | 2 +- tvc/src/commands/login.rs | 18 +-- tvc/src/config/turnkey/mod.rs | 2 +- tvc/src/config/turnkey/qos_operator_key.rs | 145 +++++++++++++++++- tvc/src/local_operator_key.rs | 5 +- tvc/tests/deploy_approve.rs | 5 +- 10 files changed, 158 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c06e8416..3d8cfadc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4233,6 +4233,7 @@ dependencies = [ "rexpect", "serde", "serde_json", + "serde_with 3.14.0", "strum", "tempfile", "thiserror 2.0.12", diff --git a/tvc/Cargo.toml b/tvc/Cargo.toml index 16dd958b..0a85c042 100644 --- a/tvc/Cargo.toml +++ b/tvc/Cargo.toml @@ -32,6 +32,7 @@ p256 = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_with = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = ["fs"] } diff --git a/tvc/src/commands/app/create.rs b/tvc/src/commands/app/create.rs index 156e07e7..08f19695 100644 --- a/tvc/src/commands/app/create.rs +++ b/tvc/src/commands/app/create.rs @@ -234,7 +234,7 @@ async fn load_saved_operator_public_key() -> Option { let (alias, org_config) = config.active_org_config()?; let local = org_config.select_local_record(alias).ok()?; let operator_key = StoredQosOperatorKey::load(&local.key_path).await.ok()??; - Some(operator_key.public_key) + Some(operator_key.public_key.to_string()) } async fn run_with_config(ctx: &mut StdCtx, args: Args, app_config: AppConfig) -> Result { diff --git a/tvc/src/commands/app/init.rs b/tvc/src/commands/app/init.rs index f5d69250..0fc91114 100644 --- a/tvc/src/commands/app/init.rs +++ b/tvc/src/commands/app/init.rs @@ -112,5 +112,5 @@ async fn load_operator_public_key() -> Option { let (alias, org_config) = config.active_org_config()?; let local = org_config.select_local_record(alias).ok()?; let operator_key = StoredQosOperatorKey::load(&local.key_path).await.ok()??; - Some(operator_key.public_key) + Some(operator_key.public_key.to_string()) } diff --git a/tvc/src/commands/keys/init_local_quorum_key.rs b/tvc/src/commands/keys/init_local_quorum_key.rs index 43a44c2b..f4b78937 100644 --- a/tvc/src/commands/keys/init_local_quorum_key.rs +++ b/tvc/src/commands/keys/init_local_quorum_key.rs @@ -73,5 +73,5 @@ async fn load_operator_public_key() -> Option { let (alias, org_config) = config.active_org_config()?; let local = org_config.select_local_record(alias).ok()?; let operator_key = StoredQosOperatorKey::load(&local.key_path).await.ok()??; - Some(operator_key.public_key) + Some(operator_key.public_key.to_string()) } diff --git a/tvc/src/commands/login.rs b/tvc/src/commands/login.rs index fb9001a1..43711248 100644 --- a/tvc/src/commands/login.rs +++ b/tvc/src/commands/login.rs @@ -2,9 +2,9 @@ use crate::client::build_turnkey_client; use crate::config::turnkey::{ - API_BASE_URL_PROD, Config, KeyCurve, OperatorRecordKind, OrgConfig, StoredApiKey, - StoredQosOperatorKey, dashboard_base_url, default_api_key_path, default_operator_key_path, - default_org_dir, + API_BASE_URL_PROD, Config, KeyCurve, OperatorRecordKind, OrgConfig, QosOperatorPublicKey, + StoredApiKey, StoredQosOperatorKey, dashboard_base_url, default_api_key_path, + default_operator_key_path, default_org_dir, }; use crate::outcome::Outcome; use crate::output::StdCtx; @@ -376,7 +376,7 @@ async fn execute_login(ctx: &mut StdCtx, mut config: Config, plan: LoginPlan) -> user_id: whoami.user_id, alias, api_public_key: api_key.public_key.clone(), - operator_public_key: operator_key.public_key.clone(), + operator_public_key: operator_key.public_key, config_file_path: crate::config::turnkey::config_file_path()? .display() .to_string(), @@ -575,12 +575,12 @@ async fn find_or_generate_operator_key( let pair = P256Pair::generate().map_err(|e| anyhow!("failed to generate operator key: {e:?}"))?; - let public_key = hex::encode(pair.public_key().to_bytes()); - let private_key = hex::encode(pair.to_master_seed()); + let public_key = QosOperatorPublicKey::try_from(pair.public_key().to_bytes().as_slice()) + .context("generated operator public key")?; let operator_key = StoredQosOperatorKey { - public_key: public_key.clone(), - private_key, + public_key, + private_key: hex::encode(pair.to_master_seed()), }; operator_key.save(&local.key_path).await?; @@ -649,7 +649,7 @@ pub struct LoggedIn { user_id: String, alias: String, api_public_key: String, - operator_public_key: String, + operator_public_key: QosOperatorPublicKey, config_file_path: String, api_key_path: String, operator_key_path: String, diff --git a/tvc/src/config/turnkey/mod.rs b/tvc/src/config/turnkey/mod.rs index ebe3f20f..bb1a2054 100644 --- a/tvc/src/config/turnkey/mod.rs +++ b/tvc/src/config/turnkey/mod.rs @@ -11,7 +11,7 @@ mod api_key; mod qos_operator_key; pub use api_key::{KeyCurve, StoredApiKey}; -pub use qos_operator_key::StoredQosOperatorKey; +pub use qos_operator_key::{QosOperatorPublicKey, StoredQosOperatorKey}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; diff --git a/tvc/src/config/turnkey/qos_operator_key.rs b/tvc/src/config/turnkey/qos_operator_key.rs index e67475a0..e1f49882 100644 --- a/tvc/src/config/turnkey/qos_operator_key.rs +++ b/tvc/src/config/turnkey/qos_operator_key.rs @@ -2,14 +2,100 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use std::path::Path; +use serde_with::{DeserializeFromStr, SerializeDisplay}; +use std::{ + fmt::{self, Debug, Display, Formatter}, + path::Path, + str::FromStr, +}; use tracing::debug; +/// Byte length of a stored operator public key: two 65-byte uncompressed +/// SEC1 points. +const QOS_OPERATOR_PUBLIC_KEY_LEN: usize = 130; + +/// A stored QOS operator public key, opaque to tvc. +/// +/// The bytes are `qos_p256::P256Public::to_bytes()`'s composite encoding — +/// `encrypt_public ‖ sign_public`, two 65-byte uncompressed SEC1 points — +/// but tvc never reads the halves, so it deliberately doesn't model the +/// split. Hex is the display and serialization form. +/// +/// TODO(TVC-270): the proper home for this type is qos_p256, as a bytemuck +/// repr-backed wire form of `P256Public` (which already holds the two keys +/// as separate fields, as does the Turnkey API's proto); move it upstream +/// and store that type here instead. +#[derive(Clone, Copy, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)] +pub struct QosOperatorPublicKey([u8; QOS_OPERATOR_PUBLIC_KEY_LEN]); + +/// Error returned when parsing a [`QosOperatorPublicKey`]. +#[derive(Debug, displaydoc::Display, thiserror::Error)] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub enum QosOperatorPublicKeyParseError { + /// must be bare hex encoded + InvalidHex, + /// must be 130 bytes, got {0} + WrongLength(usize), +} + +impl TryFrom<&[u8]> for QosOperatorPublicKey { + type Error = QosOperatorPublicKeyParseError; + + fn try_from(bytes: &[u8]) -> Result { + bytes + .try_into() + .map(Self) + .map_err(|_| QosOperatorPublicKeyParseError::WrongLength(bytes.len())) + } +} + +impl FromStr for QosOperatorPublicKey { + type Err = QosOperatorPublicKeyParseError; + + fn from_str(value: &str) -> Result { + let bytes = + hex::decode(value.trim()).map_err(|_| QosOperatorPublicKeyParseError::InvalidHex)?; + Self::try_from(bytes.as_slice()) + } +} + +impl Display for QosOperatorPublicKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.0)) + } +} + +/// Implements `Debug` as `TypeName()`. The type is passed as an +/// identifier so a rename that misses this call site fails to compile, +/// unlike a name written into a string literal. +macro_rules! impl_hex_debug { + ($ty:ident) => { + impl Debug for $ty { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, concat!(stringify!($ty), "({})"), hex::encode(self.0)) + } + } + }; +} + +impl_hex_debug!(QosOperatorPublicKey); + +/// The nil key. Exists for the payload-enumeration tests on [`crate::outcome::Outcome`], +/// which construct every outcome shape via `Default`. +impl Default for QosOperatorPublicKey { + fn default() -> Self { + // Not `Self(Default::default())`: std's array `Default` stops at 32 + // elements, so the repeat expression delegates per element instead. + Self([Default::default(); QOS_OPERATOR_PUBLIC_KEY_LEN]) + } +} + /// Operator key stored in operator.json #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredQosOperatorKey { - /// Hex-encoded compressed public key - pub public_key: String, + /// The composite public key (see [`QosOperatorPublicKey`]), hex-encoded + /// on disk. + pub public_key: QosOperatorPublicKey, /// Hex-encoded private key pub private_key: String, } @@ -30,11 +116,7 @@ impl StoredQosOperatorKey { let key: StoredQosOperatorKey = serde_json::from_str(&content) .with_context(|| format!("failed to parse operator key: {}", path.display()))?; - debug!( - operator_key_path = %path.display(), - has_public_key = !key.public_key.is_empty(), - "loaded stored operator key" - ); + debug!(operator_key_path = %path.display(), "loaded stored operator key"); Ok(Some(key)) } @@ -62,3 +144,50 @@ impl StoredQosOperatorKey { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_and_reprints_canonical_hex() { + let hex_key = hex::encode([7u8; QOS_OPERATOR_PUBLIC_KEY_LEN]); + + let key: QosOperatorPublicKey = hex_key.parse().expect("valid length and charset"); + + assert_eq!(key.to_string(), hex_key); + } + + #[test] + fn normalizes_case_and_whitespace() { + let hex_key = hex::encode([0xABu8; QOS_OPERATOR_PUBLIC_KEY_LEN]); + + let key: QosOperatorPublicKey = format!(" {} ", hex_key.to_uppercase()) + .parse() + .expect("uppercase hex with surrounding whitespace is accepted"); + + assert_eq!(key.to_string(), hex_key); + } + + #[test] + fn rejects_wrong_length_and_bad_charset() { + assert_eq!( + "abcd".parse::(), + Err(QosOperatorPublicKeyParseError::WrongLength(2)) + ); + assert_eq!( + "not hex".parse::(), + Err(QosOperatorPublicKeyParseError::InvalidHex) + ); + } + + #[test] + fn accepts_a_generated_composite_key() { + let pair = qos_p256::P256Pair::generate().expect("keygen"); + + let key = QosOperatorPublicKey::try_from(pair.public_key().to_bytes().as_slice()) + .expect("qos composite encoding is 130 bytes"); + + assert_eq!(key.to_string(), hex::encode(pair.public_key().to_bytes())); + } +} diff --git a/tvc/src/local_operator_key.rs b/tvc/src/local_operator_key.rs index e34b23ca..8f0893f0 100644 --- a/tvc/src/local_operator_key.rs +++ b/tvc/src/local_operator_key.rs @@ -101,7 +101,7 @@ async fn resolve_local_credential(source: LocalCredentialSource) -> anyhow::Resu #[cfg(test)] mod tests { use super::*; - use crate::config::turnkey::StoredQosOperatorKey; + use crate::config::turnkey::{QosOperatorPublicKey, StoredQosOperatorKey}; use crate::pair::Pair; use std::fs; use tempfile::TempDir; @@ -147,7 +147,8 @@ mod tests { fs::write( &path, serde_json::to_string(&StoredQosOperatorKey { - public_key: "unused".to_string(), + // The resolve path only reads the seed; a nil key stands in. + public_key: QosOperatorPublicKey::default(), private_key: private_key.clone(), }) .unwrap(), diff --git a/tvc/tests/deploy_approve.rs b/tvc/tests/deploy_approve.rs index 36b7938e..41f0f383 100644 --- a/tvc/tests/deploy_approve.rs +++ b/tvc/tests/deploy_approve.rs @@ -6,7 +6,7 @@ use std::fs; use tempfile::TempDir; use tvc::config::turnkey::{ Config, HostedOperatorRecord, LocalOperatorRecord, OperatorKind, OperatorRecord, - OperatorRecordKind, OrgConfig, StoredQosOperatorKey, + OperatorRecordKind, OrgConfig, QosOperatorPublicKey, StoredQosOperatorKey, }; use uuid::Uuid; @@ -268,7 +268,8 @@ fn auto_selected_hosted_id_controls_signer_resolution_in_mixed_registry() { fs::write( &operator_key_path, serde_json::to_string(&StoredQosOperatorKey { - public_key: "unused".to_string(), + // The signer path only reads the seed; a nil key stands in. + public_key: QosOperatorPublicKey::default(), private_key: fixture_seed_hex(), }) .unwrap(), From 91ddd644bf182fe9963851208fb88fae24f95968 Mon Sep 17 00:00:00 2001 From: Richard Pringle Date: Mon, 10 Aug 2026 15:48:59 -0400 Subject: [PATCH 2/3] feat(tvc): add keys backup-operator-key command --- tvc/src/cli.rs | 4 + tvc/src/commands/keys/backup_operator_key.rs | 319 +++++++++++++++++++ tvc/src/commands/keys/mod.rs | 1 + tvc/src/commands/login.rs | 2 +- tvc/src/outcome.rs | 2 + tvc/tests/common.rs | 100 ++++++ tvc/tests/keys_backup_operator_key.rs | 183 +++++++++++ tvc/tests/non_interactive.rs | 16 + tvc/tests/pty.rs | 41 +++ 9 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 tvc/src/commands/keys/backup_operator_key.rs create mode 100644 tvc/tests/common.rs create mode 100644 tvc/tests/keys_backup_operator_key.rs diff --git a/tvc/src/cli.rs b/tvc/src/cli.rs index 389dbc6f..d1da034f 100644 --- a/tvc/src/cli.rs +++ b/tvc/src/cli.rs @@ -235,6 +235,7 @@ impl Commands { AppCommands::Delete(args) => commands::app::delete::run(ctx, args).await, }, Commands::Keys { command } => match command { + KeysCommands::BackupOperatorKey(args) => args.run(ctx).await.map(Into::into), KeysCommands::CreateQuorumKey(args) => { commands::keys::create_quorum_key::run(ctx, args).await } @@ -393,6 +394,8 @@ enum AppCommands { #[derive(Debug, Subcommand)] enum KeysCommands { + /// Back up a local operator key by copying its key file to a chosen destination. + BackupOperatorKey(commands::keys::backup_operator_key::Args), /// Create a hosted quorum key encrypted to hosted operator keys. CreateQuorumKey(commands::keys::create_quorum_key::Args), /// Generate and shamir-split a local quorum key, encrypting each share to an operator key. @@ -419,6 +422,7 @@ impl AppCommands { impl KeysCommands { fn name(&self) -> &'static str { match self { + KeysCommands::BackupOperatorKey(_) => "keys backup-operator-key", KeysCommands::CreateQuorumKey(_) => "keys create-quorum-key", KeysCommands::GenerateLocalQuorumKey(_) => "keys generate-local-quorum-key", KeysCommands::InitLocalQuorumKey(_) => "keys init-local-quorum-key", diff --git a/tvc/src/commands/keys/backup_operator_key.rs b/tvc/src/commands/keys/backup_operator_key.rs new file mode 100644 index 00000000..33ad0e51 --- /dev/null +++ b/tvc/src/commands/keys/backup_operator_key.rs @@ -0,0 +1,319 @@ +//! Operator key backup command - copies a local operator key file to a +//! user-chosen destination. + +use crate::{ + commands::{Run, login::find_org}, + config::turnkey::{Config, QosOperatorPublicKey, StoredQosOperatorKey}, + outcome::Outcome, + output::StdCtx, + prompts::{self, error_required_in_non_interactive}, +}; +use anyhow::{Context, Result, anyhow, bail}; +use clap::Args as ClapArgs; +use serde::Serialize; +use std::{ + fmt::{self, Display, Formatter}, + path::PathBuf, +}; + +/// Back up a local operator key by copying its key file to a chosen +/// destination. +#[derive(Debug, ClapArgs)] +#[command(about, long_about = None)] +pub struct Args { + /// Organization alias or ID whose operator key to back up. + /// Defaults to the active organization. + #[arg(long, env = "TVC_ORG", value_name = "ORG")] + org: Option, + /// Destination file for the backup copy. + #[arg(short, long, value_name = "PATH", env = "TVC_OPERATOR_KEY_BACKUP_OUT")] + output: Option, + /// Overwrite the destination if it already exists. + #[arg(long)] + overwrite: bool, +} + +impl Run for Args { + type Outcome = OperatorKeyBackedUp; + + async fn run(self, ctx: &mut StdCtx) -> Result { + // Reject before loading config or resolving the organization when + // there is no way to prompt for the destination: --non-interactive, + // JSON mode, or a non-TTY stdin. + let can_prompt = !ctx.is_non_interactive() && prompts::stdin_can_prompt(); + + if !can_prompt && self.output.is_none() { + return Err(error_required_in_non_interactive("--output")); + } + + let config = Config::load().await?; + + let (alias, org_config) = match &self.org { + Some(query) => find_org(&config, query).ok_or_else(|| { + anyhow!( + "Login profile '{query}' not found. \ + Run `tvc login` to see configured profiles." + ) + })?, + None => config + .active_org_config() + .ok_or_else(|| anyhow!("No active organization. Run `tvc login` first."))?, + }; + + let source = &org_config.select_local_record(alias)?.key_path; + + let destination = match self.output { + // --output is a CLI argument: validate it, honoring --overwrite + // and the non-interactive fence. + Some(output) => { + if output.is_dir() { + bail!( + "destination {} is a directory; include a file name", + output.display() + ); + } + + if output.exists() && !self.overwrite { + if !can_prompt { + bail!( + "destination {} already exists; pass --overwrite to replace it", + output.display() + ); + } + + prompts::confirm_or_bail( + &format!("Overwrite {}?", output.display()), + "backup", + )?; + } + + output + } + // No --output: the shared interactive flow. Declining the + // overwrite cancels the command - backing up is all it does. + None => prompt_for_backup_destination(alias)? + .ok_or_else(|| anyhow!("operation cancelled by user: backup"))?, + }; + + back_up(alias.to_string(), source.clone(), destination).await + } +} + +/// Prompt for a backup destination: the default file name, the +/// directory-destination rejection, and the overwrite question live here, so +/// every interactive caller asks them the same way. Returns `None` when the +/// user declines the overwrite; callers own what declining means. +pub(crate) fn prompt_for_backup_destination(alias: &str) -> Result> { + let destination: PathBuf = prompts::text( + "Backup file path", + Some(&format!("operator-{alias}-backup.json")), + )? + .into(); + + if destination.is_dir() { + bail!( + "destination {} is a directory; include a file name", + destination.display() + ); + } + + if destination.exists() + && !prompts::confirm(&format!("Overwrite {}?", destination.display()), false)? + { + return Ok(None); + } + + Ok(Some(destination)) +} + +/// Copy the operator key at `source` to `destination` byte-for-byte and +/// return the backup report. +/// +/// The source is parsed to validate it and capture the public key, but the +/// original bytes are written verbatim so any unknown fields survive the +/// copy. This is the only place an [`OperatorKeyBackedUp`] is constructed. +pub(crate) async fn back_up( + alias: String, + source: PathBuf, + destination: PathBuf, +) -> Result { + let bytes = tokio::fs::read(&source).await.map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => anyhow!( + "No operator key found at {}. Run `tvc login` first.", + source.display() + ), + _ => anyhow::Error::new(e) + .context(format!("failed to read operator key: {}", source.display())), + })?; + + let key: StoredQosOperatorKey = serde_json::from_slice(&bytes).with_context(|| { + format!( + "operator key at {} is not a valid operator key file", + source.display() + ) + })?; + + // A bare-filename destination has the empty path as its parent, which + // `create_dir_all` accepts as a no-op. + if let Some(parent) = destination.parent() { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("failed to create backup directory: {}", parent.display()))?; + } + + // Written with default (umask) permissions, matching + // `StoredQosOperatorKey::save`; tightening both is tracked by TVC-241. + tokio::fs::copy(&source, &destination) + .await + .with_context(|| format!("failed to write backup: {}", destination.display()))?; + + Ok(OperatorKeyBackedUp { + alias, + public_key: key.public_key, + source_path: source, + backup_path: destination, + }) +} + +#[derive(Default, Serialize)] +#[cfg_attr(test, derive(Debug))] +#[serde(rename_all = "camelCase")] +pub struct OperatorKeyBackedUp { + alias: String, + public_key: QosOperatorPublicKey, + source_path: PathBuf, + backup_path: PathBuf, +} + +impl From for Outcome { + fn from(backed_up: OperatorKeyBackedUp) -> Self { + Outcome::OperatorKeyBackedUp(backed_up) + } +} + +impl Display for OperatorKeyBackedUp { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + r#"Operator key backed up! + +Org: {} +Public key: {} +Source: {} +Backup: {} + +The backup contains the PRIVATE key. Store it somewhere safe - a password +manager or an encrypted offline drive - never in source control or chat. + +To restore: copy the backup file back to the source path above, then run +`tvc login`."#, + self.alias, + self.public_key, + self.source_path.display(), + self.backup_path.display() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn backs_up_key_bytes_verbatim() { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("operator.json"); + let destination = temp.path().join("backups/operator-backup.json"); + // Unknown fields must survive the copy: the file is written verbatim, + // not re-serialized. + let public_hex = hex::encode( + qos_p256::P256Pair::generate() + .unwrap() + .public_key() + .to_bytes(), + ); + let content = format!( + r#"{{ + "public_key": "{public_hex}", + "private_key": "priv-hex", + "future_field": 42 +}}"# + ); + std::fs::write(&source, &content).unwrap(); + + let report = back_up("default".to_string(), source.clone(), destination.clone()) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(&destination).unwrap(), content); + assert_eq!(report.public_key.to_string(), public_hex); + } + + #[tokio::test] + async fn missing_source_names_path() { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("operator.json"); + + let error = back_up( + "default".to_string(), + source.clone(), + temp.path().join("out.json"), + ) + .await + .expect_err("missing source must fail"); + + assert_eq!( + error.to_string(), + format!( + "No operator key found at {}. Run `tvc login` first.", + source.display() + ) + ); + } + + #[tokio::test] + async fn malformed_source_names_path() { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("operator.json"); + std::fs::write(&source, "not json").unwrap(); + + let error = back_up( + "default".to_string(), + source.clone(), + temp.path().join("out.json"), + ) + .await + .expect_err("malformed source must fail"); + + assert_eq!( + error.to_string(), + format!( + "operator key at {} is not a valid operator key file", + source.display() + ) + ); + } + + #[test] + fn outcome_serializes_expected_json() { + let public_key = QosOperatorPublicKey::default(); + let outcome = Outcome::from(OperatorKeyBackedUp { + alias: "default".to_string(), + public_key, + source_path: PathBuf::from("/keys/operator.json"), + backup_path: PathBuf::from("/backups/operator-backup.json"), + }); + + assert_eq!( + serde_json::to_value(&outcome).unwrap(), + serde_json::json!({ + "reason": "operator_key_backed_up", + "alias": "default", + "publicKey": public_key.to_string(), + "sourcePath": "/keys/operator.json", + "backupPath": "/backups/operator-backup.json", + }) + ); + } +} diff --git a/tvc/src/commands/keys/mod.rs b/tvc/src/commands/keys/mod.rs index a90a544c..1398ed56 100644 --- a/tvc/src/commands/keys/mod.rs +++ b/tvc/src/commands/keys/mod.rs @@ -1,5 +1,6 @@ //! Key management commands. +pub mod backup_operator_key; pub mod create_quorum_key; pub mod generate_local_quorum_key; pub mod init_local_quorum_key; diff --git a/tvc/src/commands/login.rs b/tvc/src/commands/login.rs index 43711248..1e86ff06 100644 --- a/tvc/src/commands/login.rs +++ b/tvc/src/commands/login.rs @@ -461,7 +461,7 @@ impl Display for OrgChoice { } } -fn find_org<'a>(config: &'a Config, org: &str) -> Option<(&'a String, &'a OrgConfig)> { +pub(crate) fn find_org<'a>(config: &'a Config, org: &str) -> Option<(&'a String, &'a OrgConfig)> { if let Some((alias, org_config)) = config.orgs.get_key_value(org) { return Some((alias, org_config)); } diff --git a/tvc/src/outcome.rs b/tvc/src/outcome.rs index ab739b4f..6e458960 100644 --- a/tvc/src/outcome.rs +++ b/tvc/src/outcome.rs @@ -53,6 +53,7 @@ pub enum Outcome { AppConfigCreated(app::init::AppConfigCreated), LiveDeploymentSet(app::set_live_deploy::LiveDeploymentSet), AppDeleted(app::delete::AppDeleted), + OperatorKeyBackedUp(keys::backup_operator_key::OperatorKeyBackedUp), QuorumKeyCreated(keys::create_quorum_key::QuorumKeyCreated), QuorumKeyGenerated(keys::generate_local_quorum_key::QuorumKeyGenerated), QuorumKeyConfigCreated(keys::init_local_quorum_key::QuorumKeyConfigCreated), @@ -88,6 +89,7 @@ impl Display for Outcome { Outcome::AppConfigCreated(msg) => msg.fmt(f), Outcome::LiveDeploymentSet(msg) => msg.fmt(f), Outcome::AppDeleted(msg) => msg.fmt(f), + Outcome::OperatorKeyBackedUp(msg) => msg.fmt(f), Outcome::QuorumKeyCreated(msg) => msg.fmt(f), Outcome::QuorumKeyGenerated(msg) => msg.fmt(f), Outcome::QuorumKeyConfigCreated(msg) => msg.fmt(f), diff --git a/tvc/tests/common.rs b/tvc/tests/common.rs new file mode 100644 index 00000000..48832171 --- /dev/null +++ b/tvc/tests/common.rs @@ -0,0 +1,100 @@ +//! Fixtures shared across the tvc integration-test binaries. +//! +//! Each test binary that declares `mod common;` compiles its own copy and +//! uses a subset of these helpers, so unused items are expected in every +//! build of this file; the crate-level allow keeps those builds quiet. + +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; +use turnkey_api_key_stamper::TurnkeyP256ApiKey; +use tvc::config::turnkey::{ + Config, KeyCurve, OperatorKind, OperatorRecord, OrgConfig, QosOperatorPublicKey, StoredApiKey, + StoredQosOperatorKey, +}; + +/// Dead port: connection attempts fail immediately, so commands stop at their +/// first network step without hanging. +pub const LOCAL_API_BASE_URL: &str = "http://127.0.0.1:1"; + +fn org_dir(home: &Path, alias: &str) -> PathBuf { + home.join(".config/turnkey/orgs").join(alias) +} + +/// Write a v1 `tvc.config.toml` under `home` with one profile per +/// `(alias, org_id)` pair, using the default alias-keyed key-file layout and +/// a dead-port API base URL. +pub fn write_profiles_config(home: &Path, profiles: &[(&str, &str)], active_org: Option<&str>) { + let turnkey_dir = home.join(".config/turnkey"); + fs::create_dir_all(&turnkey_dir).unwrap(); + + let orgs: HashMap<_, _> = profiles + .iter() + .map(|(alias, org_id)| { + let dir = org_dir(home, alias); + ( + alias.to_string(), + OrgConfig { + id: org_id.to_string(), + api_key_path: dir.join("api_key.json"), + api_base_url: LOCAL_API_BASE_URL.to_string(), + default_operator_kind: OperatorKind::Local, + operators: vec![OperatorRecord::local(dir.join("operator.json"))], + extra: toml::Table::new(), + }, + ) + }) + .collect(); + + let config = Config { + active_org: active_org.map(String::from), + orgs, + last_created_app_id: HashMap::new(), + last_operator_ids: HashMap::new(), + extra: toml::Table::new(), + }; + + fs::write( + turnkey_dir.join("tvc.config.toml"), + format!("version = 1\n{}", toml::to_string_pretty(&config).unwrap()), + ) + .unwrap(); +} + +/// Create the default-layout key files for `alias`: a valid generated +/// `StoredApiKey` (login loads it before its first network step) and a real +/// generated operator key. Returns the operator public key so tests can +/// assert on rendered output. +pub fn write_profile_key_files(home: &Path, alias: &str) -> QosOperatorPublicKey { + let dir = org_dir(home, alias); + fs::create_dir_all(&dir).unwrap(); + + let stamper = TurnkeyP256ApiKey::generate(); + let api_key = StoredApiKey { + public_key: hex::encode(stamper.compressed_public_key()), + private_key: hex::encode(stamper.private_key()), + curve: KeyCurve::P256, + }; + + fs::write( + dir.join("api_key.json"), + serde_json::to_string_pretty(&api_key).unwrap(), + ) + .unwrap(); + + let pair = qos_p256::P256Pair::generate().unwrap(); + let operator_key = StoredQosOperatorKey { + public_key: QosOperatorPublicKey::try_from(pair.public_key().to_bytes().as_slice()) + .unwrap(), + private_key: hex::encode(pair.to_master_seed()), + }; + fs::write( + dir.join("operator.json"), + serde_json::to_string_pretty(&operator_key).unwrap(), + ) + .unwrap(); + + operator_key.public_key +} diff --git a/tvc/tests/keys_backup_operator_key.rs b/tvc/tests/keys_backup_operator_key.rs new file mode 100644 index 00000000..bf43f86a --- /dev/null +++ b/tvc/tests/keys_backup_operator_key.rs @@ -0,0 +1,183 @@ +mod common; + +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::prelude::*; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +const NON_INTERACTIVE_ENV: &str = "TVC_NON_INTERACTIVE"; + +fn operator_key_path(home: &TempDir, alias: &str) -> PathBuf { + home.path() + .join(".config/turnkey/orgs") + .join(alias) + .join("operator.json") +} + +#[test] +fn backs_up_with_org_and_output() { + let temp = TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-backup")], Some("alias-a")); + let operator_public_key = common::write_profile_key_files(temp.path(), "alias-a"); + let destination = temp.path().join("backups/operator-backup.json"); + + cargo_bin_cmd!("tvc") + .env("HOME", temp.path()) + .env(NON_INTERACTIVE_ENV, "1") + .arg("keys") + .arg("backup-operator-key") + .arg("--org") + .arg("alias-a") + .arg("--output") + .arg(&destination) + .assert() + .success() + .stdout(predicate::str::contains("Operator key backed up!")) + .stdout(predicate::str::contains(operator_public_key.to_string())); + + assert_eq!( + fs::read(&destination).unwrap(), + fs::read(operator_key_path(&temp, "alias-a")).unwrap() + ); +} + +#[test] +fn defaults_to_active_org() { + let temp = TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-backup")], Some("alias-a")); + common::write_profile_key_files(temp.path(), "alias-a"); + let destination = temp.path().join("operator-backup.json"); + + cargo_bin_cmd!("tvc") + .env("HOME", temp.path()) + .env(NON_INTERACTIVE_ENV, "1") + .arg("keys") + .arg("backup-operator-key") + .arg("--output") + .arg(&destination) + .assert() + .success() + .stdout(predicate::str::contains("Operator key backed up!")); + + assert!(destination.exists()); +} + +#[test] +fn existing_destination_requires_overwrite() { + let temp = TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-backup")], Some("alias-a")); + common::write_profile_key_files(temp.path(), "alias-a"); + let destination = temp.path().join("operator-backup.json"); + fs::write(&destination, "previous backup").unwrap(); + + cargo_bin_cmd!("tvc") + .env("HOME", temp.path()) + .env(NON_INTERACTIVE_ENV, "1") + .arg("keys") + .arg("backup-operator-key") + .arg("--output") + .arg(&destination) + .assert() + .failure() + .stderr(predicate::str::contains("pass --overwrite to replace it")); + + assert_eq!(fs::read_to_string(&destination).unwrap(), "previous backup"); + + cargo_bin_cmd!("tvc") + .env("HOME", temp.path()) + .env(NON_INTERACTIVE_ENV, "1") + .arg("keys") + .arg("backup-operator-key") + .arg("--output") + .arg(&destination) + .arg("--overwrite") + .assert() + .success(); + + assert_eq!( + fs::read(&destination).unwrap(), + fs::read(operator_key_path(&temp, "alias-a")).unwrap() + ); +} + +// The harness runs the binary with piped stdin, so with TVC_NON_INTERACTIVE +// unset these exercise the no-TTY half of the prompt fence rather than the +// explicit non-interactive one (covered in tests/non_interactive.rs). +#[test] +fn piped_stdin_without_output_errors() { + let temp = TempDir::new().unwrap(); + + cargo_bin_cmd!("tvc") + .env("HOME", temp.path()) + .env_remove(NON_INTERACTIVE_ENV) + .arg("keys") + .arg("backup-operator-key") + .assert() + .failure() + .stderr(predicate::str::contains( + "--output is required in non-interactive mode", + )); +} + +#[test] +fn piped_stdin_existing_destination_requires_overwrite_flag() { + let temp = TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-backup")], Some("alias-a")); + common::write_profile_key_files(temp.path(), "alias-a"); + let destination = temp.path().join("operator-backup.json"); + fs::write(&destination, "previous backup").unwrap(); + + cargo_bin_cmd!("tvc") + .env("HOME", temp.path()) + .env_remove(NON_INTERACTIVE_ENV) + .arg("keys") + .arg("backup-operator-key") + .arg("--output") + .arg(&destination) + .assert() + .failure() + .stderr(predicate::str::contains("pass --overwrite to replace it")); + + assert_eq!(fs::read_to_string(&destination).unwrap(), "previous backup"); +} + +#[test] +fn missing_operator_key_file_errors() { + let temp = TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-backup")], Some("alias-a")); + + cargo_bin_cmd!("tvc") + .env("HOME", temp.path()) + .env(NON_INTERACTIVE_ENV, "1") + .arg("keys") + .arg("backup-operator-key") + .arg("--output") + .arg(temp.path().join("operator-backup.json")) + .assert() + .failure() + .stderr(predicate::str::contains("No operator key found at")) + .stderr(predicate::str::contains("Run `tvc login` first.")); +} + +#[test] +fn json_message_format_emits_reason_tag() { + let temp = TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-backup")], Some("alias-a")); + common::write_profile_key_files(temp.path(), "alias-a"); + + cargo_bin_cmd!("tvc") + .env("HOME", temp.path()) + .arg("--message-format") + .arg("json") + .arg("keys") + .arg("backup-operator-key") + .arg("--output") + .arg(temp.path().join("operator-backup.json")) + .assert() + .success() + .stdout(predicate::str::contains( + r#""reason":"operator_key_backed_up""#, + )) + .stdout(predicate::str::contains(r#""alias":"alias-a""#)); +} diff --git a/tvc/tests/non_interactive.rs b/tvc/tests/non_interactive.rs index 10475a16..b7c0daa2 100644 --- a/tvc/tests/non_interactive.rs +++ b/tvc/tests/non_interactive.rs @@ -393,3 +393,19 @@ fn deploy_init_template_does_not_require_readable_existing_config() { assert!(output.exists(), "deploy init should write the template"); } + +#[test] +fn keys_backup_operator_key_without_output_errors_when_non_interactive() { + let temp = TempDir::new().unwrap(); + + cargo_bin_cmd!("tvc") + .env("HOME", temp.path()) + .env(NON_INTERACTIVE_ENV, "1") + .arg("keys") + .arg("backup-operator-key") + .assert() + .failure() + .stderr(predicate::str::contains( + "--output is required in non-interactive mode", + )); +} diff --git a/tvc/tests/pty.rs b/tvc/tests/pty.rs index 28024f41..47b1c301 100644 --- a/tvc/tests/pty.rs +++ b/tvc/tests/pty.rs @@ -8,7 +8,11 @@ #![cfg(unix)] +mod common; + use rexpect::session::PtySession; +use std::path::Path; +use std::process::Command; /// Default per-step timeout. Generous enough for CI-runner cold cargo builds /// of the binary; tight enough to fail fast if an `exp_string` mismatches. @@ -21,6 +25,23 @@ fn spawn(args: &str) -> PtySession { .unwrap_or_else(|e| panic!("spawn failed: {e}\n cmd: {cmd}")) } +/// Spawn the binary in a PTY with `HOME` pointed at an isolated directory and +/// ambient `TVC_*` variables scrubbed so developer shells can't leak into the +/// prompts under test. +fn spawn_with_home(home: &Path, args: &[&str]) -> PtySession { + let bin = env!("CARGO_BIN_EXE_tvc"); + + let mut cmd = Command::new(bin); + cmd.args(args) + .env("HOME", home) + .env_remove("TVC_ORG") + .env_remove("TVC_API_BASE_URL") + .env_remove("TVC_NON_INTERACTIVE"); + + rexpect::session::spawn_command(cmd, Some(TIMEOUT_MS)) + .unwrap_or_else(|e| panic!("spawn failed: {e}\n cmd: {bin} {}", args.join(" "))) +} + /// `tvc deploy approve` walks all five section confirmations in order and /// emits the signed approval JSON when the user accepts every section. /// @@ -118,3 +139,23 @@ fn login_with_empty_org_id_bails() { session.exp_string("Organization ID is required").unwrap(); session.exp_eof().unwrap(); } + +/// Interactive `keys backup-operator-key` prompts for the destination and +/// reports the copy. +#[test] +fn keys_backup_operator_key_prompts_for_destination() { + let temp = tempfile::TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-backup")], Some("alias-a")); + common::write_profile_key_files(temp.path(), "alias-a"); + let destination = temp.path().join("operator-backup.json"); + + let mut session = spawn_with_home(temp.path(), &["keys", "backup-operator-key"]); + + session.exp_string("Backup file path").unwrap(); + session.send_line(destination.to_str().unwrap()).unwrap(); + + session.exp_string("Operator key backed up!").unwrap(); + session.exp_eof().unwrap(); + + assert!(destination.exists()); +} From 268091e565906db98ff0dd118a789965cb71d58f Mon Sep 17 00:00:00 2001 From: Richard Pringle Date: Mon, 10 Aug 2026 15:50:47 -0400 Subject: [PATCH 3/3] feat(tvc): offer operator key backup during login onboarding --- tvc/src/commands/login.rs | 53 +++++++++ tvc/tests/pty.rs | 221 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) diff --git a/tvc/src/commands/login.rs b/tvc/src/commands/login.rs index 1e86ff06..bdd6774b 100644 --- a/tvc/src/commands/login.rs +++ b/tvc/src/commands/login.rs @@ -1,6 +1,9 @@ //! Login command for authenticating with Turnkey. use crate::client::build_turnkey_client; +use crate::commands::keys::backup_operator_key::{ + OperatorKeyBackedUp, back_up, prompt_for_backup_destination, +}; use crate::config::turnkey::{ API_BASE_URL_PROD, Config, KeyCurve, OperatorRecordKind, OrgConfig, QosOperatorPublicKey, StoredApiKey, StoredQosOperatorKey, dashboard_base_url, default_api_key_path, @@ -566,6 +569,7 @@ async fn find_or_generate_operator_key( if let Some(operator_key) = StoredQosOperatorKey::load(&local.key_path).await? { debug!("using existing operator key"); shell_println!(ctx, "Using existing operator key.")?; + shell_println!(ctx, "Tip: back it up with `tvc keys backup-operator-key`.")?; return Ok(operator_key); } @@ -599,6 +603,55 @@ async fn find_or_generate_operator_key( "Make sure to register this as an operator in your organization." )?; + // Onboarding nudge for the freshly generated key. JSON mode already + // forces non-interactive; the TTY check keeps piped runs from hanging on + // the prompt. + if !ctx.is_non_interactive() && prompts::stdin_can_prompt() { + shell_println!(ctx)?; + shell_println!( + ctx, + "WARNING: This key exists only on this machine; if it's lost you \ + cannot approve deployments with it." + )?; + + // Everything below is advisory: a prompt the user escapes out of and a + // backup that fails both degrade to a warning, because the config and + // both key files are already saved by this point and the login outcome + // must still land. + let attempt: Result> = async { + if !prompts::confirm("Back up your operator key now?", true)? { + return Ok(None); + } + + let Some(destination) = prompt_for_backup_destination(org_alias)? else { + return Ok(None); + }; + + back_up(org_alias.to_string(), local.key_path.clone(), destination) + .await + .map(Some) + } + .await; + + let backed_up = match attempt { + Ok(report) => report, + Err(error) => { + shell_eprintln!(ctx, "WARNING: backup skipped: {error:#}")?; + None + } + }; + + if let Some(report) = backed_up { + shell_println!(ctx)?; + shell_println!(ctx, "{report}")?; + } else { + shell_println!( + ctx, + "You can back up any time with `tvc keys backup-operator-key`." + )?; + } + } + Ok(operator_key) } diff --git a/tvc/tests/pty.rs b/tvc/tests/pty.rs index 47b1c301..75bf4610 100644 --- a/tvc/tests/pty.rs +++ b/tvc/tests/pty.rs @@ -11,8 +11,11 @@ mod common; use rexpect::session::PtySession; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; use std::path::Path; use std::process::Command; +use std::thread::{self, JoinHandle}; /// Default per-step timeout. Generous enough for CI-runner cold cargo builds /// of the binary; tight enough to fail fast if an `exp_string` mismatches. @@ -42,6 +45,56 @@ fn spawn_with_home(home: &Path, args: &[&str]) -> PtySession { .unwrap_or_else(|e| panic!("spawn failed: {e}\n cmd: {bin} {}", args.join(" "))) } +/// One-shot mock Turnkey API that answers the whoami query, enough to carry +/// login past its credential verification and into the operator-key flow. +/// Same shape as `tests/error_output.rs::spawn_json_server`. +fn spawn_whoami_server() -> (String, JoinHandle<()>) { + // Port 0 requests an available ephemeral port instead of sharing a fixed + // test port that could collide with another process or parallel test. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + let actual_path = request_line + .split_whitespace() + .nth(1) + .expect("request line should contain a path"); + assert_eq!(actual_path, "/public/v1/query/whoami"); + + let mut content_length = 0; + loop { + let mut header = String::new(); + reader.read_line(&mut header).unwrap(); + if header == "\r\n" { + break; + } + if let Some(value) = header + .strip_prefix("content-length:") + .or_else(|| header.strip_prefix("Content-Length:")) + { + content_length = value.trim().parse().unwrap(); + } + } + let mut request_body = vec![0; content_length]; + reader.read_exact(&mut request_body).unwrap(); + drop(reader); + + let body = r#"{"organizationId":"org-e2e","organizationName":"E2E Org","userId":"user-1","username":"e2e"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.flush().unwrap(); + }); + + (format!("http://{address}"), handle) +} + /// `tvc deploy approve` walks all five section confirmations in order and /// emits the signed approval JSON when the user accepts every section. /// @@ -159,3 +212,171 @@ fn keys_backup_operator_key_prompts_for_destination() { assert!(destination.exists()); } + +/// TVC-53: generating a fresh operator key during login offers a backup; +/// accepting prompts for a destination, writes the copy, and login still +/// succeeds. The mock whoami server carries login past its network step. +#[test] +fn login_fresh_operator_key_offers_backup() { + let temp = tempfile::TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-e2e")], Some("alias-a")); + common::write_profile_key_files(temp.path(), "alias-a"); + std::fs::remove_file( + temp.path() + .join(".config/turnkey/orgs/alias-a/operator.json"), + ) + .unwrap(); + + let (api_base_url, server) = spawn_whoami_server(); + let destination = temp.path().join("operator-backup.json"); + + let mut session = spawn_with_home( + temp.path(), + &["login", "--org", "alias-a", "--api-base-url", &api_base_url], + ); + + session.exp_string("Verifying credentials...").unwrap(); + session.exp_string("Operator Key Generated!").unwrap(); + session + .exp_string("Back up your operator key now?") + .unwrap(); + session.send_line("y").unwrap(); + session.exp_string("Backup file path").unwrap(); + session.send_line(destination.to_str().unwrap()).unwrap(); + + session.exp_string("Operator key backed up!").unwrap(); + session.exp_string("Successfully logged in!").unwrap(); + session.exp_eof().unwrap(); + server.join().unwrap(); + + assert_eq!( + std::fs::read(&destination).unwrap(), + std::fs::read( + temp.path() + .join(".config/turnkey/orgs/alias-a/operator.json") + ) + .unwrap() + ); +} + +/// TVC-53: declining the backup nudge points at the standalone command and +/// login still succeeds. +#[test] +fn login_backup_decline_points_at_command() { + let temp = tempfile::TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-e2e")], Some("alias-a")); + common::write_profile_key_files(temp.path(), "alias-a"); + std::fs::remove_file( + temp.path() + .join(".config/turnkey/orgs/alias-a/operator.json"), + ) + .unwrap(); + + let (api_base_url, server) = spawn_whoami_server(); + + let mut session = spawn_with_home( + temp.path(), + &["login", "--org", "alias-a", "--api-base-url", &api_base_url], + ); + + session + .exp_string("Back up your operator key now?") + .unwrap(); + session.send_line("n").unwrap(); + + session + .exp_string("You can back up any time with `tvc keys backup-operator-key`.") + .unwrap(); + session.exp_string("Successfully logged in!").unwrap(); + session.exp_eof().unwrap(); + server.join().unwrap(); +} + +/// Cancelling the backup confirm prompt (Ctrl-D; Ctrl-C and Esc reach the +/// same `InquireError` path) degrades to a warning: the config and key files +/// are already saved by the time the nudge runs, so login must still succeed. +#[test] +fn login_backup_confirm_cancel_still_succeeds() { + let temp = tempfile::TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-e2e")], Some("alias-a")); + common::write_profile_key_files(temp.path(), "alias-a"); + std::fs::remove_file( + temp.path() + .join(".config/turnkey/orgs/alias-a/operator.json"), + ) + .unwrap(); + + let (api_base_url, server) = spawn_whoami_server(); + + let mut session = spawn_with_home( + temp.path(), + &["login", "--org", "alias-a", "--api-base-url", &api_base_url], + ); + + session + .exp_string("Back up your operator key now?") + .unwrap(); + session.send_control('d').unwrap(); + + session.exp_string("WARNING: backup skipped:").unwrap(); + session.exp_string("Successfully logged in!").unwrap(); + session.exp_eof().unwrap(); + server.join().unwrap(); +} + +/// Cancelling at the destination prompt has the same contract as cancelling +/// at the confirm: warning, then a successful login. +#[test] +fn login_backup_destination_cancel_still_succeeds() { + let temp = tempfile::TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-e2e")], Some("alias-a")); + common::write_profile_key_files(temp.path(), "alias-a"); + std::fs::remove_file( + temp.path() + .join(".config/turnkey/orgs/alias-a/operator.json"), + ) + .unwrap(); + + let (api_base_url, server) = spawn_whoami_server(); + + let mut session = spawn_with_home( + temp.path(), + &["login", "--org", "alias-a", "--api-base-url", &api_base_url], + ); + + session + .exp_string("Back up your operator key now?") + .unwrap(); + session.send_line("y").unwrap(); + session.exp_string("Backup file path").unwrap(); + session.send_control('d').unwrap(); + + session.exp_string("WARNING: backup skipped:").unwrap(); + session.exp_string("Successfully logged in!").unwrap(); + session.exp_eof().unwrap(); + server.join().unwrap(); +} + +/// TVC-53: re-logins with an existing operator key get a single backup tip, +/// no prompts. +#[test] +fn login_existing_operator_key_prints_backup_tip() { + let temp = tempfile::TempDir::new().unwrap(); + common::write_profiles_config(temp.path(), &[("alias-a", "org-e2e")], Some("alias-a")); + common::write_profile_key_files(temp.path(), "alias-a"); + + let (api_base_url, server) = spawn_whoami_server(); + + let mut session = spawn_with_home( + temp.path(), + &["login", "--org", "alias-a", "--api-base-url", &api_base_url], + ); + + session.exp_string("Using existing operator key.").unwrap(); + session + .exp_string("Tip: back it up with `tvc keys backup-operator-key`.") + .unwrap(); + session.exp_string("Successfully logged in!").unwrap(); + session.exp_eof().unwrap(); + server.join().unwrap(); +}