use super::super::*;
#[allow(unused_imports)]
use super::support::*;
#[test]
fn action_destructive_covers_terminate_and_ssm_run() {
assert!(Action::Terminate.destructive());
assert!(Action::SsmRun.destructive());
assert!(!Action::Rebuild.destructive());
assert!(!Action::RestartAppServer.destructive());
assert!(!Action::SwapCnames.destructive());
assert!(!Action::Deploy.destructive());
assert!(!Action::UpgradePlatform.destructive());
assert!(!Action::Clone.destructive());
assert!(!Action::Scale.destructive());
assert!(!Action::Capacity.destructive());
assert!(!Action::AbortUpdate.destructive());
assert!(!Action::ConfigSave.destructive());
assert!(!Action::ConfigDelete.destructive());
assert!(!Action::ConfigApply.destructive());
assert!(!Action::TerminateInstance.destructive());
}
#[test]
fn deny_write_refuses_in_demo_mode_even_when_not_read_only() {
let mut app = test_app();
app.demo_mode = true;
assert!(!app.read_only);
let denied = app.deny_write("any-env", "rebuild");
assert!(denied, "demo mode must deny writes");
let err = app
.error_message
.as_deref()
.expect("demo-mode deny_write should set error_message");
assert!(
err.contains("demo mode"),
"expected demo-mode reason in toast, got: {err}"
);
assert!(
!err.contains("would also refuse"),
"no pin configured → no compose suffix, got: {err}"
);
}
#[test]
fn deny_write_demo_mode_composes_pin_reason_in_toast() {
let mut app = test_app();
app.demo_mode = true;
app.cfg.safety_envs.insert("prod-eu-1".into(), true);
let denied = app.deny_write("prod-eu-1", "rebuild");
assert!(denied);
let err = app.error_message.as_deref().unwrap();
assert!(err.contains("demo mode"), "got: {err}");
assert!(
err.contains("would also refuse"),
"expected pin compose suffix, got: {err}"
);
assert!(
err.contains("safety.envs.prod-eu-1"),
"expected pin source in suffix, got: {err}"
);
}
#[test]
fn deny_write_allows_writes_when_not_demo_and_not_read_only() {
let mut app = test_app();
let denied = app.deny_write("any-env", "rebuild");
assert!(!denied, "non-demo non-readonly path must allow writes");
assert!(
app.error_message.is_none(),
"no error toast on allowed write, got: {:?}",
app.error_message
);
}
#[tokio::test]
async fn rollback_to_label_opens_confirm_for_named_label() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
app.rebuild_view();
app.table_state.select(Some(0));
app.deploy_snapshots.insert(
"prod".into(),
DeploySnapshot {
previous_version_label: "build-snap".into(),
taken_at: chrono::Utc::now(),
},
);
app.execute_command("rollback --to build-820");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert_eq!(modal.params.deploy_version.as_deref(), Some("build-820"));
assert!(modal.params.auto_rollback_secs.is_none());
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn freeze_deploys_blocks_writes_with_reason_surfaced() {
let _marker_guard = crate::freeze::MARKER_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut app = test_app();
app.execute_command("freeze-deploys incident #1234");
assert!(app.deploy_freeze.is_some(), "freeze should be set");
assert!(
app.is_read_only_for("any-env"),
"freeze must block every env"
);
let reason = app.read_only_reason("any-env").unwrap_or_default();
assert!(
reason.contains("deploys frozen") && reason.contains("incident #1234"),
"expected reason to surface, got: {reason}"
);
}
#[tokio::test]
async fn freeze_deploys_with_no_reason_still_blocks() {
let _marker_guard = crate::freeze::MARKER_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut app = test_app();
app.execute_command("freeze-deploys");
assert!(app.deploy_freeze.is_some());
let reason = app.read_only_reason("env").unwrap_or_default();
assert!(
reason.contains("deploys frozen") && !reason.contains(": "),
"no-reason wording shouldn't include `: <reason>`, got: {reason}"
);
}
#[tokio::test]
async fn thaw_deploys_clears_the_freeze() {
let _marker_guard = crate::freeze::MARKER_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut app = test_app();
app.execute_command("freeze-deploys testing");
assert!(app.deploy_freeze.is_some());
app.execute_command("thaw-deploys");
assert!(app.deploy_freeze.is_none(), "thaw should clear freeze");
assert!(
!app.is_read_only_for("env"),
"thaw must restore writes (no other locks set in this test)"
);
}
#[tokio::test]
async fn freeze_overrides_per_env_pin_in_read_only_reason() {
let _marker_guard = crate::freeze::MARKER_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut app = test_app();
app.cfg.safety_envs.insert("prod".into(), true);
app.execute_command("freeze-deploys incident");
let reason = app.read_only_reason("prod").unwrap_or_default();
assert!(
reason.contains("deploys frozen"),
"freeze reason must win over per-env pin, got: {reason}"
);
}
#[tokio::test]
async fn incident_start_freezes_and_end_thaws() {
let _marker_guard = crate::freeze::MARKER_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut app = test_app();
app.execute_command("incident START \"checkout 5xx spike\"");
assert!(app.incident.is_some(), "incident should be active");
assert!(app.deploy_freeze.is_some(), "START must set the freeze");
assert!(
app.is_read_only_for("any-env"),
"incident freeze blocks every env"
);
let reason = app.read_only_reason("any-env").unwrap_or_default();
assert!(
reason.contains("incident: checkout 5xx spike"),
"freeze reason carries the headline, got: {reason}"
);
app.execute_command("incident END");
assert!(app.incident.is_none(), "END clears the incident");
assert!(app.deploy_freeze.is_none(), "END thaws deploys");
let status = app.status_message.clone().unwrap_or_default();
assert!(
status.contains("incident closed") && status.contains("checkout 5xx spike"),
"END summary names the incident, got: {status}"
);
}
#[tokio::test]
async fn promote_env_opens_deploy_confirm_on_target_with_sources_version() {
let mut app = test_app();
let mut staging = mk_env("staging", "shop", "Web", "Green");
staging.version_label = "build-900".into();
let mut prod = mk_env("prod", "shop", "Web", "Green");
prod.version_label = "build-820".into();
app.environments = vec![staging, prod];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("promote-env staging prod");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert_eq!(modal.target_env, "prod");
assert_eq!(modal.params.deploy_version.as_deref(), Some("build-900"));
assert!(matches!(modal.action, Action::Deploy));
}
_ => panic!("expected confirm modal open on target"),
}
}
#[tokio::test]
async fn handle_confirm_modal_lint_stuffs_issues_into_modal() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900");
app.handle_msg(AppMsg::ConfirmModalLint {
gen: app.generation,
env_name: "prod".into(),
issues: vec![],
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_lint, "loading flag should clear");
assert_eq!(modal.lint_issues.as_ref().map(|v| v.len()), Some(0));
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn handle_confirm_modal_lint_drops_stale_target_results() {
let mut app = test_app();
app.environments = vec![
mk_env("prod", "shop", "Web", "Green"),
mk_env("staging", "shop", "Web", "Green"),
];
app.rebuild_view();
app.table_state.select(Some(1));
app.execute_command("deploy build-900");
app.handle_msg(AppMsg::ConfirmModalLint {
gen: app.generation,
env_name: "prod".into(),
issues: vec![crate::lint::Issue {
rule_id: "EBL001".into(),
severity: crate::lint::Severity::Warn,
env_name: Some("prod".into()),
title: "stale".into(),
detail: "stale".into(),
suggestion: None,
fields: Default::default(),
}],
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(
modal.loading_lint,
"loading flag must stay true on stale result"
);
assert!(
modal.lint_issues.is_none(),
"stale result must not populate"
);
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn is_read_only_for_layers_global_env_and_account() {
let mut app = test_app();
app.read_only = true;
assert!(app.is_read_only_for("any-env"));
assert!(app.read_only_reason("any-env").unwrap().contains("global"));
let mut app = test_app();
app.cfg.safety_envs.insert("uflexi-prod".into(), true);
app.cfg.safety_envs.insert("uflexi-staging".into(), false);
assert!(app.is_read_only_for("uflexi-prod"));
assert!(!app.is_read_only_for("uflexi-staging"));
assert!(!app.is_read_only_for("uflexi-dev"));
assert!(app
.read_only_reason("uflexi-prod")
.unwrap()
.contains("safety.envs.uflexi-prod"));
let mut app = test_app();
app.context.profile = Some("prod-acct".into());
app.cfg.safety_accounts.insert("prod-acct".into(), true);
assert!(app.is_read_only_for("any-env"));
assert!(app
.read_only_reason("any-env")
.unwrap()
.contains("safety.accounts.prod-acct"));
app.context.profile = Some("dev-acct".into());
assert!(!app.is_read_only_for("any-env"));
let app = test_app();
assert!(!app.is_read_only_for("any-env"));
assert!(app.read_only_reason("any-env").is_none());
}
#[tokio::test]
async fn deny_write_batch_refuses_when_any_selected_env_is_pinned() {
let mut app = test_app();
app.cfg.safety_envs.insert("prod-web".into(), true);
let selection = vec!["staging-web".to_string(), "prod-web".to_string()];
assert!(
app.deny_write_batch(&selection, "batch action"),
"a pinned env in the selection must refuse the batch"
);
let msg = app.error_message.clone().unwrap();
assert!(msg.contains("prod-web"), "got: {msg}");
assert!(msg.contains("safety.envs.prod-web"), "got: {msg}");
assert!(msg.contains("1 of 2"), "got: {msg}");
}
#[tokio::test]
async fn deny_write_batch_allows_when_no_env_pinned() {
let mut app = test_app();
app.cfg.safety_envs.insert("other-env".into(), true); let selection = vec!["staging-web".to_string(), "dev-web".to_string()];
assert!(
!app.deny_write_batch(&selection, "batch action"),
"no selected env pinned → batch proceeds"
);
assert!(app.error_message.is_none());
}
#[tokio::test]
async fn deny_write_batch_global_flag_uses_fleet_message_not_per_env_list() {
let mut app = test_app();
app.read_only = true;
let selection = vec!["a".to_string(), "b".to_string()];
assert!(app.deny_write_batch(&selection, "batch action"));
let msg = app.error_message.clone().unwrap();
assert!(msg.contains("read-only mode"), "got: {msg}");
assert!(!msg.contains(" of "), "got: {msg}");
}
#[tokio::test]
async fn render_greens_type_to_confirm_only_on_exact_match() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "uflexi", "Web", "Red")];
app.rebuild_view();
app.mode = Mode::Action;
let mut modal = mk_modal(Action::Terminate, "prod");
modal.kind = ConfirmKind::TypeName;
let theme = app.theme.clone();
modal.typed = "pro".into();
app.action_flow = Some(ActionFlow::Confirm(modal.clone()));
let no_match_green = count_fg(&render_buf(&mut app, 120, 30), theme.health_green);
modal.typed = "prod".into();
app.action_flow = Some(ActionFlow::Confirm(modal));
let matched_green = count_fg(&render_buf(&mut app, 120, 30), theme.health_green);
assert!(
matched_green > no_match_green,
"exact type-to-confirm match should paint more green than a partial match \
(matched={matched_green}, no_match={no_match_green})"
);
}
#[tokio::test]
async fn every_write_command_is_refused_in_read_only_mode() {
for cmd in WRITE_COMMANDS.iter().chain(APPLICATION_SCOPED_WRITES) {
let mut app = read_only_app_with_env();
app.execute_command(cmd);
let err = app.error_message.as_deref().unwrap_or_default();
assert!(
err.contains("read-only mode"),
":{cmd} was not refused by the safety gate — got {err:?}\n\
(a write that doesn't reach `deny_write` ignores --deny-write \
and safety.envs.*.read_only)"
);
}
}
#[tokio::test]
async fn an_application_scoped_write_still_honours_the_global_toggle() {
for cmd in APPLICATION_SCOPED_WRITES {
let mut app = read_only_app_with_env();
app.execute_command(cmd);
let err = app.error_message.as_deref().unwrap_or_default();
assert!(err.contains("read-only mode"), ":{cmd} — got {err:?}");
}
}
#[tokio::test]
async fn every_write_command_is_refused_by_a_per_env_safety_pin() {
for cmd in WRITE_COMMANDS {
let mut app = read_only_app_with_env();
app.read_only = false;
app.cfg.safety_envs.insert("api-prod".into(), true);
app.execute_command(cmd);
let err = app.error_message.as_deref().unwrap_or_default();
assert!(
err.contains("safety.envs"),
":{cmd} ignored the per-env safety pin — got {err:?}"
);
}
}
#[tokio::test]
async fn every_batch_write_is_refused_in_read_only_mode() {
for cmd in BATCH_WRITE_COMMANDS {
let mut app = read_only_app_with_env();
app.multi_selected = ["api-prod".to_string()].into_iter().collect();
app.execute_command(cmd);
let err = app.error_message.as_deref().unwrap_or_default();
assert!(
err.contains("read-only"),
":{cmd} was not refused by the batch safety gate — got {err:?}"
);
}
}
#[tokio::test]
async fn every_batch_write_is_refused_when_one_member_is_pinned() {
for cmd in BATCH_WRITE_COMMANDS {
let mut app = read_only_app_with_env();
app.environments = vec![
mk_env("api-prod", "uflexi", "Web", "Green"),
mk_env("api-staging", "uflexi", "Web", "Green"),
];
app.rebuild_view();
app.table_state.select(Some(0));
app.read_only = false;
app.cfg.safety_envs.insert("api-prod".into(), true);
app.multi_selected = ["api-prod".to_string(), "api-staging".to_string()]
.into_iter()
.collect();
app.execute_command(cmd);
let err = app.error_message.as_deref().unwrap_or_default();
assert!(
err.contains("safety.envs"),
":{cmd} wrote to a batch containing a pinned env — got {err:?}"
);
}
}
#[tokio::test]
async fn the_confirm_modal_commands_arm_the_right_action() {
for (cmd, expected) in [
("restart", Action::RestartAppServer),
("rebuild", Action::Rebuild),
("stop", Action::Scale),
("start", Action::Scale),
] {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Green")];
app.view.invalidate();
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command(cmd);
let Some(ActionFlow::Confirm(modal)) = app.action_flow.as_ref() else {
panic!(
":{cmd} armed no confirm modal at all (error: {:?})",
app.error_message
);
};
assert_eq!(modal.action, expected, ":{cmd} armed the wrong action");
assert_eq!(
modal.target_env, "api-prod",
":{cmd} aimed at the wrong env"
);
assert_eq!(app.mode, Mode::Action, ":{cmd} left the mode behind");
}
}
#[tokio::test]
async fn terminate_routes_to_the_strict_typed_name_guard() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Green")];
app.view.invalidate();
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("terminate");
let Some(ActionFlow::Confirm(modal)) = app.action_flow.as_ref() else {
panic!(
":terminate armed no confirm at all (error: {:?})",
app.error_message
);
};
assert_eq!(modal.action, Action::Terminate);
assert_eq!(modal.target_env, "api-prod");
assert_eq!(
modal.kind,
ConfirmKind::TypeName,
":terminate must demand the typed env name, not a Y/N"
);
}
#[tokio::test]
async fn destructive_commands_still_refuse_under_deny_write() {
for cmd in ["restart", "rebuild", "terminate", "stop", "start"] {
let mut app = read_only_app_with_env();
app.execute_command(cmd);
assert!(
app.action_flow.is_none(),
":{cmd} armed an action despite --deny-write"
);
assert!(
app.error_message.is_some(),
":{cmd} refused silently — the operator needs to be told"
);
}
}
#[tokio::test]
async fn every_gated_command_is_refused_in_read_only_mode() {
for cmd in GATED_COMMANDS {
let mut app = read_only_app_with_env();
app.execute_command(cmd);
let err = app.error_message.as_deref().unwrap_or_default();
assert!(
err.contains("read-only mode"),
":{cmd} was not refused by the safety gate — got {err:?}"
);
}
}
#[tokio::test]
async fn swap_is_refused_in_read_only_mode() {
let mut app = read_only_app_with_env();
app.environments
.push(mk_env("api-staging", "uflexi", "Web", "Green"));
app.view.invalidate();
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("swap api-staging");
let err = app.error_message.as_deref().unwrap_or_default();
assert!(err.contains("read-only mode"), ":swap got {err:?}");
}
#[tokio::test]
async fn ssm_run_is_refused_in_read_only_mode() {
let mut app = read_only_app_with_env();
app.open_detail();
if let Some(d) = app.detail.as_mut() {
d.instances = vec![crate::aws::Instance {
id: "i-0abc".into(),
health: "Ok".into(),
color: "Green".into(),
causes: Vec::new(),
instance_type: "t3.medium".into(),
availability_zone: "eu-west-2a".into(),
launched_at: None,
}];
}
app.execute_command("ssm-run uptime");
let err = app.error_message.as_deref().unwrap_or_default();
assert!(err.contains("read-only mode"), ":ssm-run got {err:?}");
}
#[tokio::test]
async fn a_read_only_env_cannot_be_swapped_into() {
let mut app = test_app();
app.environments = vec![
mk_env("blue", "shop", "WebServer", "Green"),
mk_env("green", "shop", "WebServer", "Green"),
];
app.rebuild_view();
app.cfg.safety_envs.insert("green".into(), true);
assert!(app.is_read_only_for("green"), "the pin must be in effect");
assert!(!app.is_read_only_for("blue"), "the source is writable");
app.table_state.select(Some(0));
assert!(app.open_action_menu(), "`blue` is writable");
app.advance_action_flow(crate::app::Action::SwapCnames);
let picking = matches!(
app.action_flow,
Some(crate::app::ActionFlow::SwapTarget { .. })
);
assert!(picking, "swap opens a target picker");
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
let confirmed = matches!(app.action_flow, Some(crate::app::ActionFlow::Confirm(_)));
assert!(
!confirmed,
"a swap INTO a read-only env must be refused before the confirm \
modal, not dispatched"
);
let msg = app
.error_message
.clone()
.or_else(|| app.status_message.clone())
.unwrap_or_default();
assert!(
msg.contains("green"),
"the refusal must name the pinned env so the operator knows which \
pin stopped it, got: {msg:?}"
);
}
#[tokio::test]
async fn swap_cnames_command_also_gates_the_target() {
let mut app = test_app();
app.environments = vec![
mk_env("blue", "shop", "WebServer", "Green"),
mk_env("green", "shop", "WebServer", "Green"),
];
app.rebuild_view();
app.cfg.safety_envs.insert("green".into(), true);
app.table_state.select(Some(0));
app.execute_command("swap green");
let confirmed = matches!(app.action_flow, Some(crate::app::ActionFlow::Confirm(_)));
assert!(
!confirmed,
"`:swap green` must be refused when `green` is pinned \
read-only, even though the selected env `blue` is writable"
);
let msg = app
.error_message
.clone()
.or_else(|| app.status_message.clone())
.unwrap_or_default();
assert!(
msg.contains("green"),
"the refusal must name the pinned env, got: {msg:?}"
);
}
#[tokio::test]
async fn the_swap_picker_carries_its_target_into_the_confirm_modal() {
let mut app = test_app();
app.environments = vec![
mk_env("blue", "shop", "WebServer", "Green"),
mk_env("green", "shop", "WebServer", "Green"),
];
app.rebuild_view();
app.table_state.select(Some(0));
assert!(app.open_action_menu());
app.advance_action_flow(crate::app::Action::SwapCnames);
assert!(
matches!(
app.action_flow,
Some(crate::app::ActionFlow::SwapTarget { .. })
),
"swap opens a target picker"
);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
let Some(crate::app::ActionFlow::Confirm(modal)) = &app.action_flow else {
panic!("choosing a target must open the confirm modal");
};
assert_eq!(
modal.action,
crate::app::Action::SwapCnames,
"and it must be the swap it was opened for"
);
assert_eq!(
modal.params.swap_with.as_deref(),
Some("green"),
"the chosen target must ride on the modal — without it the dispatch \
reaches `swap target missing` only after the operator confirms"
);
assert_eq!(
modal.target_env, "blue",
"and the source must be the selected env, not the picked one"
);
}
#[tokio::test]
async fn a_rollback_result_does_not_target_whatever_env_is_selected_now() {
let mut app = test_app();
app.environments = vec![
mk_env("api-prod", "shop", "WebServer", "Green"),
mk_env("worker-prod", "shop", "Worker", "Green"),
];
app.rebuild_view();
app.table_state.select(Some(0));
let ev = |vl: Option<&str>| crate::aws::Event {
at: None,
env: "api-prod".into(),
application: "shop".into(),
message: "Deploying new version".into(),
severity: "INFO".into(),
version_label: vl.map(String::from),
};
let events = vec![ev(Some("v2")), ev(Some("v1"))];
app.table_state.select(Some(1));
let gen = app.generation;
app.handle_msg(crate::app::AppMsg::RollbackTarget {
gen,
env_name: "api-prod".to_string(),
current_version: "v2".to_string(),
result: Ok(events),
});
assert!(
app.action_flow.is_none(),
"no confirm modal may open: the modal targets the SELECTED env, \
which is now worker-prod, and this result is about api-prod"
);
let msg = app.error_message.clone().unwrap_or_default();
assert!(
msg.contains("selection moved"),
"and the operator must be told why nothing happened, got: {msg:?}"
);
}
#[tokio::test]
async fn a_rollback_result_opens_the_modal_when_the_cursor_stayed_put() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "shop", "WebServer", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
let ev = |vl: Option<&str>| crate::aws::Event {
at: None,
env: "api-prod".into(),
application: "shop".into(),
message: "Deploying new version".into(),
severity: "INFO".into(),
version_label: vl.map(String::from),
};
let gen = app.generation;
app.handle_msg(crate::app::AppMsg::RollbackTarget {
gen,
env_name: "api-prod".to_string(),
current_version: "v2".to_string(),
result: Ok(vec![ev(Some("v2")), ev(Some("v1"))]),
});
let Some(crate::app::ActionFlow::Confirm(modal)) = &app.action_flow else {
panic!("the rollback should open a deploy-confirm modal");
};
assert_eq!(modal.target_env, "api-prod");
assert_eq!(
modal.params.deploy_version.as_deref(),
Some("v1"),
"and it must offer the PREVIOUS version, not the current one"
);
}
#[tokio::test]
async fn terminate_requires_the_env_name_typed_exactly() {
async fn modal_for_terminate() -> App {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "shop", "WebServer", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
assert!(app.open_action_menu(), "the action menu should open");
app.advance_action_flow(crate::app::Action::Terminate);
let Some(crate::app::ActionFlow::Confirm(m)) = &app.action_flow else {
panic!("terminate should open a confirm modal");
};
assert_eq!(
m.kind,
crate::app::ConfirmKind::TypeName,
"terminate must be type-to-confirm, not Y/N"
);
app
}
let mut app = modal_for_terminate().await;
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert!(
app.pending_dispatch.is_none(),
"Enter on an EMPTY type-to-confirm field must not queue a terminate"
);
assert!(app.action_flow.is_some(), "and the modal must stay open");
let mut app = modal_for_terminate().await;
for c in "api-prd".chars() {
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
}
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert!(
app.pending_dispatch.is_none(),
"a MISTYPED env name must not queue a terminate — this is the guard \
the modal's own comment calls the thing that prevents accidental \
dispatch"
);
let mut app = modal_for_terminate().await;
for c in "api-prod".chars() {
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
}
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert!(
app.pending_dispatch.is_some(),
"the EXACT env name must confirm, or terminate is unreachable"
);
assert!(
app.action_flow.is_none(),
"and the modal closes into the cancel window"
);
}
enum TypedUse {
Gate(&'static str),
NotAGate(&'static str),
}
const CONFIRM_STATE: &[(&str, TypedUse)] = &[
(
"src/mode_dlq.rs::confirm_purge",
TypedUse::Gate("a_purge_fires_only_when_the_typed_name_matches"),
),
(
"src/mode_dlq.rs::confirm_delete_id",
TypedUse::Gate("a_delete_confirm_takes_yes_and_cancels_on_anything_else"),
),
(
"src/app/types.rs::confirm_delete",
TypedUse::Gate("a_delete_confirm_can_be_declined_and_ignores_navigation"),
),
(
"src/mode_detail.rs::config_delete_confirm",
TypedUse::Gate("x_arms_the_delete_belonging_to_the_focused_tab_and_no_other"),
),
(
"src/mode_detail.rs::instance_terminate_confirm",
TypedUse::Gate("x_arms_the_delete_belonging_to_the_focused_tab_and_no_other"),
),
(
"src/ui/overlays.rs::confirm_delete",
TypedUse::NotAGate(
"a render function's parameter, not state — it draws the \
prompt the gate in app/types.rs owns.",
),
),
];
const TYPED_COMPARISONS: &[(&str, TypedUse)] = &[
(
"src/app/mode_dlq_handlers.rs",
TypedUse::Gate("a_purge_fires_only_when_the_typed_name_matches"),
),
(
"src/app/action_flow.rs",
TypedUse::Gate("terminate_requires_the_env_name_typed_exactly"),
),
(
"src/ui/action.rs",
TypedUse::NotAGate(
"render only — colours the typed text by whether it matches. \
The gate it mirrors is action_flow.rs's.",
),
),
(
"src/ui/dlq.rs",
TypedUse::NotAGate("render only — same mirror for the purge prompt."),
),
(
"src/app/config_edit.rs",
TypedUse::NotAGate(
"change detection, not confirmation: an unedited value is \
submitted as a no-op rather than refused.",
),
),
];
#[test]
fn every_confirmation_gate_names_its_test() {
let mut found: Vec<String> = Vec::new();
for (path, text) in super::scan::source_files() {
if super::scan::is_test_path(&path) {
continue;
}
for line in text.lines() {
if super::scan::strip_line_comment(line).contains(".text() == ") {
found.push(path.clone());
}
}
}
found.sort();
found.dedup();
let listed: Vec<&str> = TYPED_COMPARISONS.iter().map(|(p, _)| *p).collect();
for f in &found {
assert!(
listed.contains(&f.as_str()),
"{f} compares typed input against an expected value and is not \
classified in TYPED_COMPARISONS. If it gates an irreversible \
operation it needs a test that refuses a near-miss; if it \
doesn't, say so there."
);
}
for l in &listed {
assert!(
found.contains(&l.to_string()),
"TYPED_COMPARISONS names {l}, which no longer compares typed \
input — drop the entry rather than leave it asserting nothing."
);
}
let all_tests: String = super::scan::source_files()
.into_iter()
.filter(|(p, _)| super::scan::is_test_path(p))
.map(|(_, t)| t)
.collect();
for (path, use_) in TYPED_COMPARISONS {
if let TypedUse::Gate(test_name) = use_ {
assert!(
all_tests.contains(&format!("fn {test_name}(")),
"{path} is a confirmation gate on an irreversible \
operation, and the test it names — {test_name} — does not \
exist. Either it was renamed, or the gate is uncovered."
);
}
}
for (path, use_) in TYPED_COMPARISONS {
if let TypedUse::NotAGate(why) = use_ {
assert!(
why.len() > 20,
"{path} is classified as not a confirmation gate, which is \
the classification that exempts it from needing a test. \
Say why in more than a few words: {why:?}"
);
}
}
let mut found_state: Vec<String> = Vec::new();
for (path, text) in super::scan::source_files() {
if super::scan::is_test_path(&path) {
continue;
}
for line in text.lines() {
let t = super::scan::strip_line_comment(line).trim();
let Some(rest) = t.strip_prefix("pub ").or(Some(t)) else {
continue;
};
let rest = rest.strip_prefix("pub(crate) ").unwrap_or(rest);
let name_part = rest.split(':').next().unwrap_or("").trim();
if !(name_part.starts_with("confirm_") || name_part.ends_with("_confirm")) {
continue;
}
let Some((field, ty)) = rest.split_once(':') else {
continue;
};
let ty = ty.trim().trim_end_matches(',').trim();
if !(ty == "bool" || ty.starts_with("Option<") || ty.starts_with("String")) {
continue;
}
found_state.push(format!("{path}::{}", field.trim()));
}
}
found_state.sort();
found_state.dedup();
assert!(
found_state.len() >= 5,
"expected at least five confirmation declarations; found \
{found_state:?} — the scan is broken"
);
let listed_state: Vec<&str> = CONFIRM_STATE.iter().map(|(p, _)| *p).collect();
for f in &found_state {
assert!(
listed_state.contains(&f.as_str()),
"{f} is confirmation state and is not classified in \
CONFIRM_STATE. If it gates an irreversible operation it \
needs a test that the DECLINE path works; if it doesn't, \
say so there."
);
}
for l in &listed_state {
assert!(
found_state.contains(&l.to_string()),
"CONFIRM_STATE names {l}, which no longer exists — drop it \
rather than leave it asserting nothing."
);
}
for (path, use_) in CONFIRM_STATE {
match use_ {
TypedUse::Gate(test_name) => assert!(
all_tests.contains(&format!("fn {test_name}(")),
"{path} is a confirmation gate and the test it names — \
{test_name} — does not exist."
),
TypedUse::NotAGate(why) => assert!(
why.len() > 20,
"{path} is exempted from needing a test; say why in more \
than a few words: {why:?}"
),
}
}
let gates = TYPED_COMPARISONS
.iter()
.filter(|(_, u)| matches!(u, TypedUse::Gate(_)))
.count();
assert!(
gates >= 2,
"expected at least the Terminate and DLQ-purge gates; found {gates}"
);
}
#[tokio::test]
async fn ctrl_chords_do_not_move_the_swap_target_picker() {
let selected = |app: &App| -> Option<String> {
match app.action_flow.as_ref() {
Some(crate::app::ActionFlow::SwapTarget { picker, .. }) => picker.selected_value(),
_ => panic!("not in the swap picker"),
}
};
let open = || {
let mut app = test_app();
app.environments = vec![
mk_env("blue", "shop", "WebServer", "Green"),
mk_env("green", "shop", "WebServer", "Green"),
mk_env("amber", "shop", "WebServer", "Green"),
];
app.rebuild_view();
app.table_state.select(Some(0));
assert!(app.open_action_menu());
app.advance_action_flow(crate::app::Action::SwapCnames);
app
};
for key in [KeyCode::Char('j'), KeyCode::Down] {
let mut app = open();
let before = selected(&app);
press(&mut app, key, KeyModifiers::NONE);
assert_ne!(selected(&app), before, "{key:?} should move the picker");
}
for key in [KeyCode::Char('j'), KeyCode::Char('k')] {
let mut app = open();
let before = selected(&app);
press(&mut app, key, KeyModifiers::CONTROL);
assert_eq!(
selected(&app),
before,
"Ctrl-{key:?} moved the swap target — reaching for the palette \
must not re-aim a production DNS swap"
);
}
}
#[tokio::test]
async fn a_refused_write_is_recorded_with_its_rule_and_remedy() {
let env_name = "refusal-audit-probe-env";
let path = crate::util::cache_dir().join("audit.log");
let before = std::fs::read_to_string(&path).unwrap_or_default();
let mut app = test_app();
app.cfg.safety_envs.insert(env_name.into(), true);
assert!(app.deny_write(env_name, "Terminate"), "must refuse");
let after = std::fs::read_to_string(&path).unwrap_or_default();
let delta = after
.strip_prefix(&before)
.expect("the audit log is append-only");
let lines: Vec<&str> = delta.lines().filter(|l| l.contains(env_name)).collect();
assert_eq!(lines.len(), 1, "exactly one line for the refusal: {delta}");
let line = lines[0];
assert!(
line.contains("stage=refused"),
"a policy denial is not stage=skipped, which means a benign \
non-dispatch: {line}"
);
assert!(line.contains("action=Terminate"), "{line}");
assert!(
line.contains("rule=env_pinned"),
"the rule must be named by a stable token, not by prose that \
moves when a toast is reworded: {line}"
);
assert!(
line.contains(&format!("safety.envs.{env_name}.read_only")),
"the remedy must name the control that would have to change: {line}"
);
}
#[tokio::test]
async fn demo_mode_refuses_without_writing_an_audit_line() {
let env_name = "demo-refusal-probe-env";
let path = crate::util::cache_dir().join("audit.log");
let before = std::fs::read_to_string(&path).unwrap_or_default();
let mut app = test_app();
app.demo_mode = true;
app.cfg.safety_envs.insert(env_name.into(), true);
assert!(app.deny_write(env_name, "Terminate"), "demo must refuse");
let after = std::fs::read_to_string(&path).unwrap_or_default();
let delta = after.strip_prefix(&before).unwrap_or(&after);
assert!(
!delta.contains(env_name),
"demo mode writes NO audit lines: {delta}"
);
}
#[tokio::test]
async fn an_allowed_write_files_no_refusal() {
let env_name = "allowed-write-probe-env";
let path = crate::util::cache_dir().join("audit.log");
let before = std::fs::read_to_string(&path).unwrap_or_default();
let mut app = test_app();
assert!(!app.deny_write(env_name, "Restart"), "must allow");
let after = std::fs::read_to_string(&path).unwrap_or_default();
let delta = after.strip_prefix(&before).unwrap_or(&after);
assert!(
!delta.contains(env_name),
"an allowed write is not a refusal: {delta}"
);
}
#[tokio::test]
async fn an_unreadable_safety_config_announces_itself_at_startup() {
let cfg = crate::config::parse("safety.envs.uflexi-prod = true\n");
assert_eq!(
cfg.safety_parse_errors.len(),
1,
"fixture must actually be malformed, or this test proves nothing"
);
let app = App::for_tests(crate::aws::AwsClient::stub(), cfg);
let msg = app
.error_message
.as_deref()
.expect("a policy that refuses every write must say so up front");
assert!(msg.contains("writes refused"), "{msg}");
assert!(
msg.contains("uflexi-prod"),
"the banner must name the offending line, not just that one exists: {msg}"
);
let clean = crate::config::parse("safety.envs.uflexi-prod.read_only = true\n");
let app = App::for_tests(crate::aws::AwsClient::stub(), clean);
assert!(
app.error_message.is_none(),
"a healthy session must not open with a warning: {:?}",
app.error_message
);
}
#[tokio::test]
async fn a_batch_refusal_files_one_matchable_line_per_env() {
let a = "batch-refusal-probe-a";
let b = "batch-refusal-probe-b";
let path = crate::util::cache_dir().join("audit.log");
let before = std::fs::read_to_string(&path).unwrap_or_default();
let mut app = test_app();
for (n, region) in [(a, "us-east-1"), (b, "ap-south-1")] {
let mut env = mk_env(n, "uflexi", "Web", "Green");
env.region = Some(region.into());
app.environments.push(env);
app.cfg.safety_envs.insert(n.into(), true);
}
app.rebuild_view();
assert!(
app.deny_write_batch(&[a.to_string(), b.to_string()], "Terminate"),
"both envs are pinned"
);
let after = std::fs::read_to_string(&path).unwrap_or_default();
let delta = after
.strip_prefix(&before)
.expect("the audit log is append-only");
for (n, region) in [(a, "us-east-1"), (b, "ap-south-1")] {
let lines: Vec<&str> = delta
.lines()
.filter(|l| l.contains(&format!("target={n}")))
.collect();
assert_eq!(lines.len(), 1, "one matchable line for {n}: {delta}");
assert!(
lines[0].contains(&format!("region={region}")),
"each refusal takes its own env's region, not home: {}",
lines[0]
);
}
assert!(
!delta.contains(&format!("{a},{b}")),
"no joined target — it matches no env: {delta}"
);
}
#[tokio::test]
async fn the_safety_banner_survives_a_refresh() {
let cfg = crate::config::parse("safety.envs.uflexi-prod = true\n");
assert_eq!(
cfg.safety_parse_errors.len(),
1,
"fixture must be malformed"
);
let mut app = App::for_tests(crate::aws::AwsClient::stub(), cfg);
assert!(app.error_message.is_some(), "banner shows at startup");
app.status_snapshot_at_refresh = Some((app.status_message.clone(), app.error_message.clone()));
app.apply_refresh(app.fanout_epoch, Ok(vec![]), vec![]);
let msg = app
.error_message
.as_deref()
.expect("the banner must survive — the writes are still refused");
assert!(msg.contains("writes refused"), "{msg}");
let clean = crate::config::parse("safety.envs.uflexi-prod.read_only = true\n");
let mut app = App::for_tests(crate::aws::AwsClient::stub(), clean);
app.status_snapshot_at_refresh = Some((app.status_message.clone(), app.error_message.clone()));
app.apply_refresh(app.fanout_epoch, Ok(vec![]), vec![]);
assert!(
app.error_message.is_none(),
"a healthy session must not gain a banner: {:?}",
app.error_message
);
}
#[tokio::test]
async fn a_fleet_wide_refusal_is_not_reported_as_per_env_pins() {
let cfg = crate::config::parse("safety.envs.uflexi-prod = true\n");
let mut app = App::for_tests(crate::aws::AwsClient::stub(), cfg);
let envs = vec!["env-a".to_string(), "env-b".to_string()];
assert!(app.deny_write_batch(&envs, "Terminate"), "must refuse");
let msg = app.error_message.as_deref().expect("a refusal toast");
assert!(
msg.contains("safety config unreadable"),
"the whole-fleet reason must lead: {msg}"
);
assert!(
!msg.contains("selected env(s) locked"),
"one broken config line is not a set of per-env pins: {msg}"
);
let mut app = test_app();
app.cfg.safety_envs.insert("env-a".into(), true);
assert!(app.deny_write_batch(&envs, "Terminate"));
let msg = app.error_message.as_deref().expect("a refusal toast");
assert!(
msg.contains("selected env(s) locked"),
"a real per-env pin must still name what to deselect: {msg}"
);
}
#[tokio::test]
async fn the_safety_banner_yields_to_the_partial_failure_notice() {
let cfg = crate::config::parse("safety.envs.uflexi-prod = true\n");
let mut app = App::for_tests(crate::aws::AwsClient::stub(), cfg);
app.status_snapshot_at_refresh = Some((app.status_message.clone(), app.error_message.clone()));
app.apply_refresh(
app.fanout_epoch,
Ok(vec![]),
vec!["eu-west-2: throttled".to_string()],
);
let msg = app.error_message.as_deref().expect("a notice");
assert!(
msg.contains("NOT shown"),
"missing data is the notice with no second channel: {msg}"
);
app.status_snapshot_at_refresh = Some((app.status_message.clone(), app.error_message.clone()));
app.apply_refresh(app.fanout_epoch, Ok(vec![]), vec![]);
assert!(
app.error_message
.as_deref()
.is_some_and(|m| m.contains("writes refused")),
"the banner returns once the slot is free: {:?}",
app.error_message
);
}