use super::super::*;
#[allow(unused_imports)]
use super::support::*;
#[test]
fn render_config_diff_overlay_states() {
let body = crate::app::render_config_diff_overlay("staging", "prod", &[]);
assert!(body.contains("identical"));
let diffs = vec![crate::app::ConfigDiff {
namespace: "aws:autoscaling:asg".into(),
name: "MinSize".into(),
left: Some("2".into()),
right: None,
}];
let body = crate::app::render_config_diff_overlay("staging", "prod", &diffs);
assert!(body.contains("aws:autoscaling:asg"));
assert!(body.contains("MinSize"));
assert!(body.contains("L: 2"));
assert!(body.contains("R: (unset)"));
}
#[test]
fn render_explain_overlay_marks_decisions_and_suggests_fix() {
let rows = vec![
crate::aws::IamSimResult {
action: "elasticbeanstalk:RebuildEnvironment".into(),
resource: "*".into(),
decision: "implicitDeny".into(),
matched_statements: vec![],
missing_context: vec![],
blocked_by_scp: false,
blocked_by_boundary: false,
},
crate::aws::IamSimResult {
action: "ec2:DescribeInstances".into(),
resource: "*".into(),
decision: "allowed".into(),
matched_statements: vec!["arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess @ 0:0".into()],
missing_context: vec![],
blocked_by_scp: false,
blocked_by_boundary: false,
},
];
let body =
crate::app::render_explain_overlay("arn:aws:iam::123:role/EbmanReadOnly", &rows, false);
assert!(body.contains("Action: elasticbeanstalk:RebuildEnvironment"));
assert!(body.contains("✗ implicitDeny"));
assert!(body.contains("Action: ec2:DescribeInstances"));
assert!(body.contains("✓ allowed"));
assert!(body.contains("\"Effect\": \"Allow\""));
assert!(body.contains("\"Action\": \"elasticbeanstalk:RebuildEnvironment\""));
assert!(body.matches("To allow, add this statement").count() == 1);
assert!(body.contains("AmazonEC2ReadOnlyAccess"));
}
#[test]
fn render_explain_overlay_flags_scp_and_boundary_blockers() {
let rows = vec![crate::aws::IamSimResult {
action: "ec2:TerminateInstances".into(),
resource: "*".into(),
decision: "explicitDeny".into(),
matched_statements: vec!["org-scp/SCPDenyTerminate @ 0:0".into()],
missing_context: vec![],
blocked_by_scp: true,
blocked_by_boundary: true,
}];
let body = crate::app::render_explain_overlay("arn:aws:iam::123:role/X", &rows, false);
assert!(body.contains("Organizations SCP"));
assert!(body.contains("permission boundary"));
assert!(body.contains("explicit Deny always wins"));
assert!(!body.contains("\"Effect\": \"Allow\""));
}
#[tokio::test]
async fn first_run_hint_stays_false_for_subsequent_launches() {
let app = test_app();
assert!(
!app.first_run_hint,
"test harness must default first_run_hint=false (state.toml presumed present)"
);
}
#[test]
fn render_options_overlay_handles_unknown_namespace() {
let rows = vec![opt("aws:autoscaling:asg", "MinSize", Some("2"), None)];
let body = crate::app::render_options_overlay(&rows, Some("aws:bogus:ns"), "uflexi-prod");
assert!(body.contains("No options found"));
assert!(body.contains("aws:bogus:ns"));
}
#[test]
fn render_secrets_overlay_empty_no_filter_hints_at_iam() {
let body = crate::app::render_secrets_overlay(&[], None);
assert!(body.contains("No Secrets Manager secrets"));
assert!(body.contains("ListSecrets"));
}
#[test]
fn render_secrets_overlay_lists_metadata_only() {
let now = chrono::Utc::now();
let rows = vec![crate::aws::SecretSummary {
name: "prod/db/password".into(),
arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db/password-AbCdEf".into(),
description: Some("RDS master".into()),
last_changed: Some(now - chrono::Duration::days(3)),
last_rotated: Some(now - chrono::Duration::days(30)),
kms_key_id: Some("alias/aws/secretsmanager".into()),
}];
let body = crate::app::render_secrets_overlay(&rows, None);
assert!(body.contains("prod/db/password"));
assert!(body.contains("RDS master"));
assert!(body.contains("arn:aws:secretsmanager"));
assert!(body.contains("changed:"));
assert!(body.contains("rotated:"));
assert!(body.contains("alias/aws/secretsmanager"));
assert!(!body.to_lowercase().contains("password:"));
}
#[test]
fn render_secrets_overlay_marks_never_rotated() {
let now = chrono::Utc::now();
let rows = vec![crate::aws::SecretSummary {
name: "api-key".into(),
arn: "arn:aws:secretsmanager:us-east-1:1:secret:api-key-x".into(),
description: None,
last_changed: Some(now - chrono::Duration::hours(2)),
last_rotated: None,
kms_key_id: None,
}];
let body = crate::app::render_secrets_overlay(&rows, None);
assert!(body.contains("rotated: never"));
}
#[test]
fn render_secret_value_overlay_redacts_when_redact_on() {
let body = crate::app::render_secret_value_overlay("api-key", "hunter2", true);
assert!(body.contains("<redacted; 7 chars"));
assert!(body.contains("fingerprint"));
assert!(!body.contains("hunter2"));
assert!(body.contains(":redact off"));
}
#[test]
fn render_secret_value_overlay_shows_value_when_redact_off() {
let body = crate::app::render_secret_value_overlay("api-key", "hunter2", false);
assert!(body.contains("hunter2"));
assert!(body.contains("yank"));
}
#[test]
fn render_secret_value_overlay_pretty_prints_json() {
let body = crate::app::render_secret_value_overlay(
"prod/db",
r#"{"USERNAME":"app","PASSWORD":"x"}"#,
false,
);
assert!(body.contains("USERNAME"));
assert!(body.contains("PASSWORD"));
assert!(
body.matches('\n').count() >= 4,
"should pretty-print: {body}"
);
}
#[test]
fn render_secret_value_overlay_leaves_non_json_alone() {
let body = crate::app::render_secret_value_overlay("flat", "ABC-DEF-GHI", false);
assert!(body.contains("ABC-DEF-GHI"));
}
#[test]
fn render_options_overlay_truncates_long_value_options_list() {
let mut row = opt("aws:foo", "Enum", Some("a"), None);
row.value_options = (0..20).map(|i| format!("v{i}")).collect();
let rows = vec![row];
let body = crate::app::render_options_overlay(&rows, None, "env");
assert!(body.contains("oneof: v0, v1, v2, v3, v4, … +15"));
}
#[test]
fn render_changes_overlay_states() {
let ev = |msg: &str, vl: Option<&str>| crate::aws::Event {
at: None,
env: "e".into(),
application: "a".into(),
message: msg.into(),
severity: "INFO".into(),
version_label: vl.map(String::from),
};
let noise = vec![ev("Environment health transitioned to Ok.", None)];
assert!(crate::app::render_changes_overlay("prod", &noise).contains("No deploy"));
let evs = vec![
ev("Deploying new version to instance(s).", Some("build-9")),
ev("Environment health transitioned to Ok.", None),
];
let body = crate::app::render_changes_overlay("prod", &evs);
assert!(body.contains("Deploying new version"));
assert!(body.contains("[build-9]"));
assert!(!body.contains("health transitioned"));
}
#[test]
fn classify_update_kind_platform_update() {
let evs = vec![make_event(
"Updating environment to use platform 'arn:aws:elasticbeanstalk:…:platform/Corretto 17'.",
)];
assert_eq!(
crate::app::classify_update_kind(&evs),
crate::app::UpdateKind::Platform
);
}
#[tokio::test]
async fn deploy_wait_for_green_threads_secs_through_to_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 --wait-for-green 5m");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert_eq!(modal.params.deploy_version.as_deref(), Some("build-900"));
assert_eq!(modal.params.wait_for_green_secs, Some(300));
assert!(modal.params.auto_rollback_secs.is_none());
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn handle_unavailability_estimate_stuffs_line_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::UnavailabilityEstimate {
gen: app.generation,
env_name: "prod".into(),
line: Some((
"deploy plan: Rolling → max 1/4 instance unavailable".into(),
true,
)),
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_unavailability);
let (text, caution) = modal.unavailability_line.as_ref().unwrap();
assert!(text.contains("max 1/4"));
assert!(*caution);
}
_ => panic!("expected confirm modal"),
}
}
#[tokio::test]
async fn deploy_modal_opens_with_version_preview_loading_flag_set() {
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");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(
modal.loading_version_preview,
"Deploy modal must reserve space for the inline preview"
);
assert!(modal.version_preview.is_none());
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn deploy_modal_handle_version_preview_stuffs_body_in_slot() {
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");
let body = "candidate: build-900\ncurrent: build-820\n".to_string();
app.handle_msg(AppMsg::VersionPreview {
gen: app.generation,
env_name: "prod".into(),
result: Ok(body.clone()),
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_version_preview);
assert_eq!(modal.version_preview.as_deref(), Some(body.as_str()));
}
_ => panic!("expected confirm modal still open"),
}
}
#[tokio::test]
async fn deploy_modal_handle_version_preview_error_renders_inline() {
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::VersionPreview {
gen: app.generation,
env_name: "prod".into(),
result: Err("ListApplicationVersions throttled".into()),
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_version_preview);
let preview = modal.version_preview.as_deref().unwrap_or("");
assert!(
preview.contains("version preview unavailable") && preview.contains("throttled"),
"expected inline error, got: {preview}"
);
}
_ => panic!("expected confirm modal still open"),
}
}
#[tokio::test]
async fn diff_two_arg_form_opens_overlay_for_named_envs() {
let mut app = test_app();
app.environments = vec![
mk_env("staging", "uflexi", "Web", "Green"),
mk_env("prod", "uflexi", "Web", "Green"),
];
app.rebuild_view();
app.execute_command("diff staging prod");
assert!(
matches!(app.current_overlay, Some(Overlay::Diff(_))),
"expected Overlay::Diff, got {:?}",
app.current_overlay.is_some()
);
assert!(
app.error_message.is_none(),
"unexpected error: {:?}",
app.error_message
);
}
#[tokio::test]
async fn diff_two_arg_form_rejects_same_env_twice() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "uflexi", "Web", "Green")];
app.rebuild_view();
app.execute_command("diff prod prod");
assert!(
app.current_overlay.is_none(),
"shouldn't open overlay for same-env diff"
);
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("different envs"),
"expected 'different envs' guidance, got: {err}"
);
}
#[tokio::test]
async fn diff_two_arg_form_errors_on_unknown_env() {
let mut app = test_app();
app.environments = vec![mk_env("staging", "uflexi", "Web", "Green")];
app.rebuild_view();
app.execute_command("diff staging missing-env");
assert!(app.current_overlay.is_none());
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("missing-env"),
"expected error to name the missing env, got: {err}"
);
}
#[tokio::test]
async fn question_mark_opens_help_and_escape_dismisses_it() {
let mut app = test_app();
assert_eq!(app.mode, Mode::Normal);
press(&mut app, KeyCode::Char('?'), KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Help);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Normal);
}
#[tokio::test]
async fn event_tail_poller_reaped_when_overlay_replaced() {
let mut app = test_app();
app.event_tail_session = 1;
app.handle_msg(AppMsg::EventTailOpened {
gen: app.generation,
session_id: 1,
});
app.event_tail_task = Some(tokio::spawn(async {
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
}
}));
app.current_overlay = Some(Overlay::TextDump {
title: "something else".into(),
body: "…".into(),
});
app.handle_msg(AppMsg::EventTailEvents {
gen: app.generation,
session_id: 1,
result: Ok(vec![]),
});
assert!(app.event_tail_task.is_none(), "poller must be reaped");
assert_eq!(
app.event_tail_session, 2,
"session bump drops already-queued messages"
);
assert!(
matches!(app.current_overlay, Some(Overlay::TextDump { .. })),
"the replacement overlay is untouched"
);
}
#[tokio::test]
async fn log_tail_poller_reaped_when_overlay_replaced() {
let mut app = test_app();
app.log_tail_session = 1;
app.handle_msg(AppMsg::LogTailOpened {
gen: app.generation,
session_id: 1,
env_name: "api-prod".into(),
log_group: "/aws/elasticbeanstalk/api-prod/web.stdout.log".into(),
since_ms: 0,
});
app.log_tail_task = Some(tokio::spawn(async {
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
}
}));
app.current_overlay = Some(Overlay::TextDump {
title: "something else".into(),
body: "…".into(),
});
app.handle_msg(AppMsg::LogTailEvents {
gen: app.generation,
session_id: 1,
next_since_ms: 5,
result: Ok(vec![]),
});
assert!(app.log_tail_task.is_none(), "poller must be reaped");
assert_eq!(app.log_tail_session, 2);
}
#[tokio::test]
async fn the_form_overlay_renders_its_fields() {
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));
let mut form = crate::form::Form::loading(
"FORM-CANARY-TITLE",
"api-prod",
"resize the ASG",
vec![
crate::form::FormField::text("minsize", "MinSize-CANARY", None::<String>),
crate::form::FormField::text("maxsize", "MaxSize-CANARY", Some("upper bound")),
],
crate::form::FormSubmit::OptionSettings {
mappings: Vec::new(),
},
);
form.state = crate::form::FormState::Ready;
app.form = Some(form);
app.mode = crate::app::Mode::Form;
let out = render(&mut app, 160, 44);
assert!(
out.contains("FORM-CANARY-TITLE"),
"the title renders:\n{out}"
);
assert!(
out.contains("MinSize-CANARY") && out.contains("MaxSize-CANARY"),
"every field renders, not just the focused one:\n{out}"
);
assert!(
out.contains("api-prod"),
"and the env it will act on — this form writes:\n{out}"
);
}
#[tokio::test]
async fn every_body_carrying_overlay_renders_its_body() {
use crate::app::Overlay;
let cases: Vec<(&str, Overlay)> = vec![
("CANARYDESCRIBE", Overlay::Describe("CANARYDESCRIBE".into())),
("CANARYWHATSNEW", Overlay::Whatsnew("CANARYWHATSNEW".into())),
("CANARYHISTORY", Overlay::History("CANARYHISTORY".into())),
(
"CANARYALARMS",
Overlay::Alarms {
env_name: "api-prod".into(),
body: "CANARYALARMS".into(),
},
),
("CANARYDIFF", Overlay::Diff("CANARYDIFF".into())),
(
"CANARYSAVEDCFG",
Overlay::SavedConfigs("CANARYSAVEDCFG".into()),
),
(
"CANARYTEXTDUMP",
Overlay::TextDump {
title: "dump".into(),
body: "CANARYTEXTDUMP".into(),
},
),
(
"CANARYREPORTBUG",
Overlay::ReportBug {
body: "CANARYREPORTBUG".into(),
},
),
(
"CANARYCFGITEM",
Overlay::SavedConfigsInteractive {
items: vec![("CANARYCFGITEM".into(), "/tmp/x.cfg.yml".into())],
cursor: 0,
confirm_delete: false,
},
),
];
for (needle, overlay) in cases {
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.current_overlay = Some(overlay);
let out = render(&mut app, 160, 44);
assert!(
out.contains(needle),
"overlay did not render its own body ({needle}):\n{out}"
);
}
}
#[tokio::test]
async fn the_command_palette_renders_its_items() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Green")];
app.view.invalidate();
app.rebuild_view();
app.open_palette();
assert_eq!(app.mode, crate::app::Mode::Palette, "palette opened");
assert!(!app.palette_items.is_empty(), "and has something to show");
let out = render(&mut app, 160, 44);
let first = app.palette_items[0].label.clone();
assert!(
out.contains(&first),
"the first palette entry ({first}) has to be on screen:\n{out}"
);
}
#[tokio::test]
async fn the_picker_renders_its_choices() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Green")];
app.view.invalidate();
app.rebuild_view();
app.picker = Some(crate::app::Picker {
kind: crate::app::PickerKind::Region,
items: vec!["eu-west-2-CANARY".into(), "us-east-1".into()],
filter: tui_common::TextInput::new(),
list_state: Default::default(),
});
app.mode = crate::app::Mode::Picker;
let out = render(&mut app, 160, 44);
assert!(out.contains("eu-west-2-CANARY"), "choices render:\n{out}");
}
#[tokio::test]
async fn toasts_render_over_everything_else() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Green")];
app.view.invalidate();
app.rebuild_view();
app.toasts.push_back(crate::app::Toast {
text: "TOASTCANARY restart dispatched".into(),
kind: crate::app::ToastKind::Info,
shown_at: std::time::Instant::now(),
});
let out = render(&mut app, 160, 44);
assert!(out.contains("TOASTCANARY"), "the toast renders:\n{out}");
}
#[tokio::test]
async fn the_apps_scope_renders_its_own_table() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "APPCANARY", "Web", "Green")];
app.applications = vec![crate::aws::Application {
name: "APPCANARY".into(),
description: "the one app".into(),
date_created: None,
date_updated: None,
version_count: 3,
templates: Vec::new(),
latest_version_created: None,
latest_version_label: None,
}];
app.view.invalidate();
app.rebuild_view();
app.scope = crate::app::Scope::Apps;
app.rebuild_view();
let out = render(&mut app, 160, 44);
assert!(
out.contains("VERSIONS") && out.contains("DESCRIPTION"),
"the apps table draws its own columns:\n{out}"
);
assert!(
out.contains("the one app"),
"and the row body, which only this table renders:\n{out}"
);
}
#[tokio::test]
async fn every_help_topic_renders_something_of_its_own() {
use crate::app::HelpTopic;
for (topic, title) in [
(HelpTopic::Global, "ebman — keybindings"),
(HelpTopic::Detail, "Detail view — keybindings"),
(HelpTopic::Dlq, "Queue viewer — keybindings"),
(HelpTopic::Action, "Action menu — keybindings"),
(HelpTopic::Shell, "Embedded shell — keybindings"),
(
HelpTopic::SavedConfigs,
"Saved configurations — keybindings",
),
] {
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.help.topic = topic;
app.help.scroll = 0;
app.mode = crate::app::Mode::Help;
let out = render(&mut app, 200, 44);
assert!(
out.contains(title),
"help topic {topic:?} did not draw its own title {title:?}:\n{out}"
);
}
}
#[tokio::test]
async fn the_about_overlay_renders() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Green")];
app.view.invalidate();
app.rebuild_view();
app.current_overlay = Some(crate::app::Overlay::About(std::time::Instant::now()));
let out = render(&mut app, 160, 44);
assert!(
out.contains(env!("CARGO_PKG_VERSION")),
"About names the version it is:\n{out}"
);
}
#[tokio::test]
async fn the_why_red_overlay_renders_its_findings() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Red")];
app.view.invalidate();
app.rebuild_view();
app.table_state.select(Some(0));
app.current_overlay = Some(crate::app::Overlay::WhyRed {
env_name: "api-prod".into(),
tier: "Web".into(),
events: Some(Ok(vec![make_event("WHYREDCANARY deploy failed")])),
alarms: Some(Ok(Vec::new())),
instances: Some(Ok(Vec::new())),
deploys: Some(Ok(Vec::new())),
queues: None,
dlq_messages: None,
session_id: 1,
cursor: 0,
});
let out = render(&mut app, 180, 44);
assert!(
out.contains("WHYREDCANARY"),
"the event that explains the red renders:\n{out}"
);
}
#[tokio::test]
async fn the_log_tail_overlay_renders_its_lines() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Green")];
app.view.invalidate();
app.rebuild_view();
let mut events = std::collections::VecDeque::new();
events.push_back(crate::aws::LogEvent {
timestamp_ms: 1_700_000_000_000,
stream: "i-0abc/web.stdout".into(),
message: "LOGTAILCANARY 500 internal error".into(),
});
app.current_overlay = Some(crate::app::Overlay::LogTail {
log_group: "/aws/elasticbeanstalk/api-prod/var/log/web.stdout.log".into(),
env_name: "api-prod".into(),
events,
since_ms: 0,
view: Default::default(),
last_err: None,
session_id: 1,
});
let out = render(&mut app, 180, 44);
assert!(
out.contains("LOGTAILCANARY"),
"the tailed line renders:\n{out}"
);
}
#[tokio::test]
async fn the_apps_action_menu_renders_its_actions() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "MENUCANARY", "Web", "Green")];
app.view.invalidate();
app.rebuild_view();
app.current_overlay = Some(crate::app::Overlay::AppsActionMenu {
app_name: "MENUCANARY".into(),
env_names: vec!["api-prod".into(), "api-staging".into()],
cursor: 0,
});
let out = render(&mut app, 180, 44);
assert!(
out.contains("Rebuild all 2 env(s)"),
"the menu offers its fan-out actions with the env count:\n{out}"
);
assert!(
out.contains("MENUCANARY"),
"and names the application:\n{out}"
);
}