use super::super::*;
#[allow(unused_imports)]
use super::support::*;
#[test]
fn console_url_includes_region_app_env() {
let url = console_url("us-east-1", "myapp", "myenv");
let url = url.expect("commercial partition has a console host");
assert!(url.contains("us-east-1.console.aws.amazon.com"));
assert!(url.contains("region=us-east-1"));
assert!(url.contains("applicationName=myapp"));
assert!(url.contains("environmentName=myenv"));
}
#[test]
fn render_secrets_overlay_empty_with_filter_explains_region_scope() {
let body = crate::app::render_secrets_overlay(&[], Some("prod-db"));
assert!(body.contains("No secrets matching 'prod-db'"));
assert!(body.contains("region-scoped"));
}
#[tokio::test]
async fn batch_action_undo_cancels_whole_fanout() {
let mut app = test_app();
app.environments = vec![
mk_env("e1", "uflexi", "Web", "Green"),
mk_env("e2", "uflexi", "Web", "Green"),
mk_env("e3", "uflexi", "Web", "Green"),
];
for name in ["e1", "e2", "e3"] {
app.multi_selected.insert(name.into());
}
app.cmd_batch_action(Action::RestartAppServer);
assert!(app.pending_dispatch.is_some());
app.cancel_pending_dispatch();
assert!(
app.pending_dispatch.is_none(),
"cancel should drop the whole batch, not just one env"
);
let msg = app.status_message.as_deref().unwrap_or("");
assert!(
msg.contains("undone") && msg.contains("3 env(s)"),
"status should call out the 3-env batch; got: {msg:?}"
);
}
#[tokio::test]
async fn rollout_advances_to_the_next_eligible_region() {
use crate::mode_action::{ActionFlow, RolloutFlow, RolloutRegion, RolloutState};
let region = |name: &str, found: bool| RolloutRegion {
region: name.into(),
current_version: Some("v1".into()),
env_found: Some(found),
preflight_error: None,
outcome: None,
};
let mut app = test_app();
app.action_flow = Some(ActionFlow::Rollout(RolloutFlow {
rollout_id: "rollout-test".into(),
env_name: "api-prod".into(),
version_label: "v2".into(),
regions: vec![
region("eu-west-1", true),
region("eu-west-2", false), region("us-east-1", true),
],
state: RolloutState::Dispatching { next_index: 0 },
wait_for_green_secs: None,
}));
app.handle_msg(AppMsg::RolloutDispatched {
gen: app.generation,
region: "eu-west-1".into(),
result: Ok(()),
});
let Some(ActionFlow::Rollout(flow)) = app.action_flow.as_ref() else {
panic!("rollout flow should still be active");
};
assert_eq!(
flow.state,
RolloutState::Dispatching { next_index: 2 },
"must skip the region that failed pre-flight"
);
assert!(flow.regions[0].outcome.is_some());
assert!(flow.regions[1].outcome.is_none(), "skipped, not dispatched");
}
#[tokio::test]
async fn rollout_halts_on_a_failed_region() {
use crate::mode_action::{ActionFlow, RolloutFlow, RolloutRegion, RolloutState};
let region = |name: &str| RolloutRegion {
region: name.into(),
current_version: Some("v1".into()),
env_found: Some(true),
preflight_error: None,
outcome: None,
};
let mut app = test_app();
app.action_flow = Some(ActionFlow::Rollout(RolloutFlow {
rollout_id: "rollout-test".into(),
env_name: "api-prod".into(),
version_label: "v2".into(),
regions: vec![region("eu-west-1"), region("us-east-1")],
state: RolloutState::Dispatching { next_index: 0 },
wait_for_green_secs: None,
}));
app.handle_msg(AppMsg::RolloutDispatched {
gen: app.generation,
region: "eu-west-1".into(),
result: Err("UpdateEnvironment refused".into()),
});
let Some(ActionFlow::Rollout(flow)) = app.action_flow.as_ref() else {
panic!("rollout flow should still be active");
};
assert_eq!(flow.state, RolloutState::Done, "halt on first failure");
assert!(flow.regions[1].outcome.is_none(), "never dispatched");
}
#[test]
fn parse_access_denied_handles_every_partition() {
for partition in ["aws", "aws-us-gov", "aws-cn", "aws-iso", "aws-iso-b"] {
let msg = format!(
"User: arn:{partition}:sts::1:assumed-role/R/S is not authorized to perform: s3:GetObject"
);
let (principal, _) = crate::app::parse_access_denied(&msg).expect("parsed");
assert_eq!(
principal,
format!("arn:{partition}:iam::1:role/R"),
"the rebuilt role ARN must stay in its own partition"
);
}
}
#[test]
fn console_url_follows_the_partition() {
let gov = console_url("us-gov-west-1", "myapp", "myenv").expect("govcloud has a console");
assert!(
gov.contains("us-gov-west-1.console.amazonaws-us-gov.com"),
"got {gov}"
);
let cn = console_url("cn-north-1", "myapp", "myenv").expect("china has a console");
assert!(cn.contains("cn-north-1.console.amazonaws.cn"), "got {cn}");
assert!(console_url("us-iso-east-1", "myapp", "myenv").is_none());
}
#[tokio::test]
async fn explain_accepts_an_arn_from_any_partition() {
for arn in [
"arn:aws:iam::123456789012:role/EbAdmin",
"arn:aws-us-gov:iam::123456789012:role/EbAdmin",
"arn:aws-cn:iam::123456789012:role/EbAdmin",
"arn:aws-iso-b:iam::123456789012:role/EbAdmin",
] {
let mut app = test_app();
app.execute_command(&format!("explain {arn} elasticbeanstalk:UpdateEnvironment"));
assert!(
!app.error_message
.as_deref()
.unwrap_or_default()
.starts_with("usage:"),
"{arn} was rejected as malformed: {:?}",
app.error_message
);
}
}
#[tokio::test]
async fn row_region_is_used_for_links_and_cli_snippets() {
let mut app = test_app();
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("eu-west-2".into());
app.environments = vec![env.clone()];
app.rebuild_view();
app.table_state.select(Some(0));
assert_eq!(app.context.region, "us-east-1", "home region differs");
assert_eq!(app.region_for(&env), "eu-west-2");
let mut homeless = env.clone();
homeless.region = None;
assert_eq!(app.region_for(&homeless), "us-east-1");
app.yank_cli();
let cmd = app.last_yanked_cli.as_deref().unwrap_or_default();
assert!(
cmd.contains("--region eu-west-2"),
"the copied CLI must name the row's region: {cmd}"
);
}
#[tokio::test]
async fn a_region_that_fails_the_fan_out_is_reported_not_dropped() {
let mut app = test_app();
app.apply_refresh(
app.fanout_epoch,
Ok(vec![mk_env("api-prod", "uflexi", "Web", "Green")]),
vec!["eu-west-2: DescribeEnvironments failed".to_string()],
);
let err = app.error_message.as_deref().unwrap_or_default();
assert!(
err.contains("eu-west-2") && err.contains("NOT shown"),
"a partially-failed fan-out must say which region is missing: {err:?}"
);
assert_eq!(
app.environments.len(),
1,
"the rows that arrived still render"
);
}
#[tokio::test]
async fn a_write_dispatches_to_the_rows_region() {
let mut app = test_app();
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("eu-west-2".into());
app.environments = vec![env];
app.rebuild_view();
app.table_state.select(Some(0));
assert_eq!(
app.client_for_region(&app.region_for_name("api-prod"))
.region_for_tests(),
"eu-west-2",
"the write client follows the row"
);
let mut homeless = mk_env("home-env", "uflexi", "Web", "Green");
homeless.region = None;
app.environments.push(homeless);
app.rebuild_view();
assert_eq!(
app.client_for_region(&app.region_for_name("home-env"))
.region_for_tests(),
"us-east-1"
);
}
#[tokio::test]
async fn demo_mode_never_resolves_a_remote_region() {
let mut app = test_app();
app.demo_mode = true;
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("ap-southeast-4".into());
app.environments = vec![env];
app.rebuild_view();
assert!(
app.client_for_region("ap-southeast-4").is_home_for_tests(),
"demo mode stays on the stub"
);
}
#[tokio::test]
async fn a_cross_region_row_under_an_assumed_role_re_assumes() {
let mut app = test_app();
app.cfg.accounts.insert(
"prod".into(),
crate::config::AccountSpec {
role_arn: "arn:aws:iam::1:role/EbmanReadOnly".into(),
region: Some("us-east-1".into()),
..Default::default()
},
);
app.context.profile = Some("prod".into());
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("eu-west-2".into());
app.environments = vec![env];
app.rebuild_view();
let client = app.client_for_region("eu-west-2");
assert_eq!(
client.account_for_tests().as_deref(),
Some("prod"),
"it must re-assume, not look for a profile named after the account"
);
assert_eq!(
client.region_for_tests(),
"eu-west-2",
"and point the assumed session at the row's region, not the spec's"
);
assert!(app.client_for_region("us-east-1").is_home_for_tests());
}
#[tokio::test]
async fn a_write_audits_the_region_it_actually_went_to() {
let mut app = test_app();
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("eu-west-2".into());
app.environments = vec![env];
app.rebuild_view();
app.table_state.select(Some(0));
assert_eq!(
app.region_for_name("api-prod"),
"eu-west-2",
"the audit region comes from this lookup at every dispatch site"
);
assert_eq!(app.region_for_name("ghost"), "us-east-1");
}
#[tokio::test]
async fn a_dispatch_and_its_completion_agree_on_the_region() {
let mut app = test_app();
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("eu-west-2".into());
app.environments = vec![env];
app.rebuild_view();
app.table_state.select(Some(0));
let path = crate::util::cache_dir().join("audit.log");
let before = std::fs::read_to_string(&path).unwrap_or_default();
app.handle_msg(AppMsg::ActionResult {
gen: app.generation,
action: crate::app::Action::RestartAppServer,
env_name: "api-prod".into(),
result: Ok(()),
});
let after = std::fs::read_to_string(&path).unwrap_or_default();
let line = after
.strip_prefix(&before)
.unwrap_or(&after)
.lines()
.find(|l| l.contains("api-prod"))
.expect("a completion line was written")
.to_string();
assert!(
line.contains("region=eu-west-2"),
"the completion must name where the work went: {line}"
);
}
#[tokio::test]
async fn a_cross_region_role_client_comes_from_the_cache() {
let _guard = crate::aws::CACHE_TEST_LOCK.lock().await;
crate::aws::clear_client_cache();
let mut app = test_app();
app.cfg.accounts.insert(
"prod".into(),
crate::config::AccountSpec {
role_arn: "arn:aws:iam::1:role/R".into(),
region: Some("us-east-1".into()),
..Default::default()
},
);
app.context.profile = Some("prod".into());
let seeded = std::sync::Arc::new(crate::aws::AwsClient::stub());
crate::aws::seed_role_cache_for_tests("prod", "eu-west-2", seeded.clone());
let client = app.client_for_region("eu-west-2");
assert_eq!(client.account_for_tests().as_deref(), Some("prod"));
let resolved = client.resolve().await.expect("cache hit, no STS call");
assert!(
std::sync::Arc::ptr_eq(&resolved, &seeded),
"resolve must come from the role cache, not a fresh AssumeRole"
);
crate::aws::clear_client_cache();
}
#[tokio::test]
async fn a_detail_env_that_left_the_table_keeps_its_region() {
let mut app = test_app();
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("eu-west-2".into());
app.environments = vec![env];
app.rebuild_view();
app.table_state.select(Some(0));
app.open_detail();
assert!(app.detail.is_some(), "detail open on the fan-out row");
app.environments.clear();
app.view.invalidate();
app.rebuild_view();
assert_eq!(
app.region_for_name("api-prod"),
"eu-west-2",
"Detail's snapshot still knows where this env lives"
);
assert_eq!(app.detail_client().region_for_tests(), "eu-west-2");
assert_eq!(app.region_for_name("ghost"), "us-east-1");
}
#[tokio::test]
async fn current_env_client_and_client_for_env_are_not_interchangeable() {
let mut app = test_app();
let mut a = mk_env("api-prod", "uflexi", "Web", "Green");
a.region = Some("eu-west-2".into());
let mut b = mk_env("api-staging", "uflexi", "Web", "Green");
b.region = Some("ap-south-1".into());
app.environments = vec![a, b];
app.rebuild_view();
app.table_state.select(Some(0));
app.open_detail();
app.table_state.select(Some(1));
assert_eq!(
app.current_env_client().region_for_tests(),
"eu-west-2",
"Detail-first: the env on screen"
);
let selected = app.selected_env().expect("row 1").name.clone();
assert_eq!(selected, "api-staging");
assert_eq!(
app.client_for_env(&selected).region_for_tests(),
"ap-south-1",
"selection-based: the env the command operates on"
);
assert_eq!(app.region_for_name(&selected), "ap-south-1");
}
#[tokio::test]
async fn a_write_whose_row_left_the_table_still_goes_to_its_region() {
let mut app = test_app();
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("eu-west-2".into());
app.handle_msg(AppMsg::Refresh {
gen: app.generation,
fanout: app.fanout_epoch,
result: Ok(vec![env]),
partial_errors: Vec::new(),
});
assert_eq!(app.region_for_name("api-prod"), "eu-west-2");
assert!(app.detail.is_none(), "no Detail snapshot to lean on");
app.handle_msg(AppMsg::Refresh {
gen: app.generation,
fanout: app.fanout_epoch,
result: Ok(vec![]),
partial_errors: vec!["region eu-west-2: throttled".into()],
});
assert!(app.environments.is_empty(), "the row is gone");
assert_eq!(
app.region_for_name("api-prod"),
"eu-west-2",
"a write in its undo window must not silently retarget the home region"
);
assert_eq!(
app.client_for_env("api-prod").region_for_tests(),
"eu-west-2"
);
assert_eq!(app.region_for_name("ghost"), "us-east-1");
}
#[tokio::test]
async fn remembered_regions_do_not_survive_a_context_switch() {
let mut app = test_app();
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("eu-west-2".into());
app.handle_msg(AppMsg::Refresh {
gen: app.generation,
fanout: app.fanout_epoch,
result: Ok(vec![env]),
partial_errors: Vec::new(),
});
assert_eq!(app.region_for_name("api-prod"), "eu-west-2");
app.handle_msg(AppMsg::Rebuild {
epoch: app.rebuild_epoch,
result: Ok(Box::new(crate::aws::AwsClient::stub())),
});
assert_eq!(
app.region_for_name("api-prod"),
app.context.region,
"the new context's home region, not the old context's answer"
);
}
#[tokio::test]
async fn the_breadcrumb_names_the_region_of_the_env_it_names() {
let mut app = test_app();
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("eu-west-2".into());
app.environments = vec![env];
app.rebuild_view();
app.table_state.select(Some(0));
assert_eq!(app.context.region, "us-east-1", "home region differs");
let out = render(&mut app, 160, 40);
assert!(
out.contains("eu-west-2"),
"the crumb must name the selected env's region:\n{out}"
);
app.open_detail();
let out = render(&mut app, 160, 40);
assert!(
!out.contains("us-east-1"),
"Detail must not show the SESSION's region beside another region's env:\n{out}"
);
let mut empty = test_app();
let out = render(&mut empty, 160, 40);
assert!(
out.contains("us-east-1"),
"session region with no env:\n{out}"
);
}
#[tokio::test]
async fn fanout_mode_change_bumps_the_fanout_epoch() {
let mut app = test_app();
assert_eq!(app.fanout_epoch, 0);
app.execute_command("region all");
assert_eq!(app.fanout_epoch, 1, ":region all changes the region set");
app.execute_command("region off");
assert_eq!(app.fanout_epoch, 2, ":region off changes it back");
let before = app.fanout_epoch;
app.execute_command("sort name");
assert_eq!(
app.fanout_epoch, before,
"unrelated commands leave it alone"
);
}
#[tokio::test]
async fn a_listing_from_a_superseded_fanout_mode_is_dropped_and_refetched() {
let mut app = test_app();
app.handle_msg(AppMsg::Refresh {
gen: app.generation,
fanout: app.fanout_epoch,
result: Ok(vec![mk_env("home-only", "uflexi", "Web", "Green")]),
partial_errors: Vec::new(),
});
assert_eq!(app.environments.len(), 1);
app.execute_command("region all");
let stale = 0;
assert_ne!(stale, app.fanout_epoch);
app.handle_msg(AppMsg::Refresh {
gen: app.generation,
fanout: stale,
result: Ok(vec![
mk_env("wrong-a", "uflexi", "Web", "Green"),
mk_env("wrong-b", "uflexi", "Web", "Green"),
]),
partial_errors: Vec::new(),
});
assert_eq!(
app.environments.len(),
1,
"a listing from the superseded mode must not replace the table"
);
assert_eq!(app.environments[0].name, "home-only");
assert!(
matches!(app.load_state, crate::app::LoadState::Loading),
"the drop must launch a replacement listing, not just discard"
);
app.handle_msg(AppMsg::Refresh {
gen: app.generation,
fanout: app.fanout_epoch,
result: Ok(vec![
mk_env("eu-1", "uflexi", "Web", "Green"),
mk_env("us-1", "uflexi", "Web", "Green"),
]),
partial_errors: Vec::new(),
});
assert_eq!(app.environments.len(), 2);
}
#[tokio::test]
async fn fanout_change_does_not_bump_generation() {
let mut app = test_app();
let gen_before = app.generation;
app.execute_command("region all");
app.execute_command("region off");
assert_eq!(
app.generation, gen_before,
"a fan-out change is not a context switch"
);
assert_eq!(app.fanout_epoch, 2, "it moved the narrower axis instead");
}
#[tokio::test]
async fn detail_header_names_the_rows_region_not_the_sessions() {
let mut app = test_app();
let mut env = mk_env("api-prod", "uflexi", "Web", "Green");
env.region = Some("eu-west-2".into());
app.environments = vec![env];
app.view.invalidate();
app.rebuild_view();
app.table_state.select(Some(0));
app.open_detail();
assert_eq!(
app.context.region, "us-east-1",
"precondition: the session is somewhere else entirely"
);
let out = render(&mut app, 160, 40);
assert!(
out.contains("Region: eu-west-2"),
"Detail must name the row's region.\n{out}"
);
assert!(
!out.contains("Region: us-east-1"),
"naming the session's region here is the bug, not the fix.\n{out}"
);
assert_eq!(
app.detail_client().region_for_tests(),
"eu-west-2",
"the header would be describing a different region than the fetch"
);
}