mod common;
use common::IgnitionMock;
use ignition_core::client::{GatewayApi, ReqwestGatewayApi};
use ignition_core::config::{Credential, Secret};
fn token_credential() -> Credential {
Credential::Token(Secret::new("eam:tokengeneratedlive"))
}
fn history_page() -> serde_json::Value {
serde_json::json!({
"items": [
{
"taskId": "c3d5ebc2-0b91-40fc-8417-3af372071547",
"taskName": "nightly-backup (forced)",
"taskStart": 1787930000000_i64,
"taskEnd": 1787930009000_i64,
"target": "_controller",
"level": "Failed",
"detail": "Gateway network for agent '_controller' is currently not connected, the connection status is 'NotDefined'",
"taskType": "eam_backup"
},
{
"taskId": "d4e6fcd3-1c92-410d-8528-4ba483082658",
"taskName": "nightly-backup",
"taskStart": 1787920000000_i64,
"taskEnd": 1787920005000_i64,
"target": "_controller",
"level": "Success",
"detail": null,
"taskType": "eam_backup"
}
],
"metadata": {"total": 2, "matching": 2, "limit": 200, "offset": 0}
})
}
fn definition_list_page() -> serde_json::Value {
serde_json::json!({
"items": [
{
"name": "nightly-backup",
"collection": "eam-tasks",
"type": "com.inductiveautomation.eam",
"config": {
"profile": {
"type": "eam_backup",
"scheduleMode": "OnDemand"
},
"settings": {
"targetGateways": ["gw-a"],
"targetGroups": [],
"concurrentBackups": 2,
"forceBackups": true
}
}
}
],
"metadata": {"total": 1, "matching": 1, "limit": -1, "offset": 0}
})
}
fn definition_find_body() -> serde_json::Value {
serde_json::json!({
"name": "nightly-backup",
"collection": "eam-tasks",
"type": "com.inductiveautomation.eam",
"config": {"profile": {"type": "eam_backup", "scheduleMode": "OnDemand"}},
"signature": "sig-abc123",
"scheduledTaskState": {
"currentState": "IDLE",
"details": {"owner": "eam", "nextScheduled": null}
}
})
}
const HISTORY_PATH: &str = "/data/eam/api/v1/eam-tasks/history";
const TASKS_LIST_PATH: &str = "/data/api/v1/resources/list/com.inductiveautomation.eam/eam-tasks";
const TASKS_FIND_PATH: &str =
"/data/api/v1/resources/find/com.inductiveautomation.eam/eam-tasks/nightly%2Dbackup";
#[tokio::test]
async fn eam_history_sends_explicit_limit_and_search() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(HISTORY_PATH))
.and(wiremock::matchers::query_param("limit", "50"))
.and(wiremock::matchers::query_param("offset", "0"))
.and(wiremock::matchers::query_param("search", "backup"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(history_page()))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_history(&api, Some(50), Some("backup"))
.await
.expect("history reads");
assert_eq!(result.count, 2);
assert_eq!(result.items[0].task_name, "nightly-backup (forced)");
assert_eq!(result.items[0].level.as_deref(), Some("Failed"));
assert!(
result.items[0]
.detail
.as_deref()
.is_some_and(|d| d.contains("not connected"))
);
assert_eq!(guard.received_requests().await.len(), 1);
}
#[tokio::test]
async fn eam_history_defaults_to_the_explicit_200_limit() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(HISTORY_PATH))
.and(wiremock::matchers::query_param("limit", "200"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(history_page()))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
ignition_core::actions::eam::eam_history(&api, None, None)
.await
.expect("history reads");
assert_eq!(guard.received_requests().await.len(), 1);
}
#[tokio::test]
async fn eam_tasks_list_rides_the_config_resource_seam() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(TASKS_LIST_PATH))
.and(wiremock::matchers::query_param("limit", "-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(definition_list_page()))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_tasks(&api)
.await
.expect("definitions list");
assert_eq!(result.tasks.len(), 1);
assert_eq!(result.tasks[0].name, "nightly-backup");
assert_eq!(result.tasks[0].task_type.as_deref(), Some("eam_backup"));
assert_eq!(result.tasks[0].schedule_mode.as_deref(), Some("OnDemand"));
assert_eq!(
result.tasks[0].current_state, None,
"list records carry no state — null, honestly"
);
assert_eq!(guard.received_requests().await.len(), 1);
}
#[tokio::test]
async fn eam_task_detail_carries_definition_and_state() {
let mock = IgnitionMock::start().await;
mock.list_json("GET", TASKS_FIND_PATH, definition_find_body())
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_detail(&api, "nightly-backup")
.await
.expect("find reads");
assert_eq!(result.name, "nightly-backup");
assert_eq!(
result.state["currentState"],
serde_json::json!("IDLE"),
"the healthcheck rides as data"
);
assert_eq!(
result.definition["scheduledTaskState"]["details"]["owner"],
serde_json::json!("eam"),
"the owner (force's target) round-trips"
);
}
#[tokio::test]
async fn eam_task_detail_unknown_name_is_not_found() {
let mock = IgnitionMock::start().await;
mock.html_error(
"GET",
"/data/api/v1/resources/find/com.inductiveautomation.eam/eam-tasks/nope",
404,
)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = ignition_core::actions::eam::eam_task_detail(&api, "nope")
.await
.expect_err("404 classifies NotFound");
assert_eq!(err.exit_code(), 6);
assert_eq!(err.code(), "not_found");
}
#[tokio::test]
async fn controller_403_classifies_eam_not_controller() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(HISTORY_PATH))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_raw(
"<html><head><title>Error 403</title></head><body><h2>HTTP ERROR 403 Forbidden</h2><table><tr><th>MESSAGE:</th><td>This operation can only be performed when EAM is configured as a controller.</td></tr></table></body></html>".as_bytes().to_vec(),
"text/html;charset=iso-8859-1",
))
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = ignition_core::actions::eam::eam_history(&api, None, None)
.await
.expect_err("the controller 403 refuses");
assert_eq!(err.exit_code(), 6, "target state, not auth");
assert_eq!(err.code(), "eam_not_controller");
let hint = err.hint().expect("hint required");
assert!(
hint.contains("installMode") && hint.contains("Controller"),
"the hint names the manual flip: {hint}"
);
}
#[tokio::test]
async fn eam_403_without_the_message_stays_auth() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(HISTORY_PATH))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_raw(
"<html><head><title>Error 403</title></head><body><h2>HTTP ERROR 403 Forbidden</h2><table><tr><th>MESSAGE:</th><td>Forbidden</td></tr></table></body></html>".as_bytes().to_vec(),
"text/html;charset=iso-8859-1",
))
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = ignition_core::actions::eam::eam_history(&api, None, None)
.await
.expect_err("a plain 403 stays auth");
assert_eq!(err.exit_code(), 5);
assert_eq!(err.code(), "auth_rejected");
}
#[tokio::test]
async fn non_eam_403_with_the_message_stays_auth() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/gateway-info"))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_raw(
"This operation can only be performed when EAM is configured as a controller."
.as_bytes()
.to_vec(),
"text/plain",
),
)
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = api
.gateway_info()
.await
.expect_err("off-path 403 stays auth");
assert_eq!(err.exit_code(), 5);
assert_eq!(err.code(), "auth_rejected");
}
const TASKS_CREATE_PATH: &str = "/data/api/v1/resources/com.inductiveautomation.eam/eam-tasks";
#[tokio::test]
async fn task_create_posts_array_body_with_typed_settings() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(TASKS_CREATE_PATH))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_create(
&api,
"nightly-backup",
"eam_backup",
&["gw-a".to_string()],
&[
"concurrentBackups=2".to_string(),
"forceBackups=true".to_string(),
],
None,
"OnDemand",
)
.await
.expect("create posts");
assert_eq!(result.task_type, "eam_backup");
let requests = guard.received_requests().await;
assert_eq!(requests.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).expect("body parses");
assert_eq!(
body,
serde_json::json!([{
"config": {
"profile": {"scheduleMode": "OnDemand", "type": "eam_backup"},
"settings": {
"concurrentBackups": 2,
"forceBackups": true,
"targetGateways": ["gw-a"],
"targetGroups": []
}
},
"name": "nightly-backup"
}]),
"the ARRAY body, composed definition verbatim — the live 8.3.3 \
profile/settings split with settings TYPED"
);
}
#[tokio::test]
async fn task_create_deep_merges_the_definition_file() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(TASKS_CREATE_PATH))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let overlay = serde_json::json!({
"targetGateways": ["gw-a", "gw-b"],
"targetGroups": [],
"concurrentBackups": 3,
"forceBackups": false
});
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
ignition_core::actions::eam::eam_task_create(
&api,
"fleet-backup",
"eam_backup",
&[],
&[],
Some(&overlay),
"OnDemand",
)
.await
.expect("create posts");
let requests = guard.received_requests().await;
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).expect("body parses");
assert_eq!(
body,
serde_json::json!([{
"config": {
"profile": {"scheduleMode": "OnDemand", "type": "eam_backup"},
"settings": {
"concurrentBackups": 3,
"forceBackups": false,
"targetGateways": ["gw-a", "gw-b"],
"targetGroups": []
}
},
"name": "fleet-backup"
}]),
"the overlay's typed/array settings deep-merged over the composed config.settings"
);
}
#[tokio::test]
async fn task_create_refused_type_never_reaches_the_wire() {
let mock = IgnitionMock::start().await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
for refused in [
"eam_restoreBackup",
"eam_installModules",
"eam_remoteUpgrade",
] {
let err = ignition_core::actions::eam::eam_task_create(
&api,
"danger",
refused,
&["gw-a".to_string()],
&[],
None,
"OnDemand",
)
.await
.expect_err("fleet-destructive types refuse");
assert_eq!(err.exit_code(), 6);
assert_eq!(err.code(), "eam_task_type_refused");
let message = err.to_string();
assert!(
message.contains(refused) && message.contains("fleet-destructive"),
"the message names the type + consequence: {message}"
);
assert!(
message.contains("EXT-03"),
"the message points at the v2 scope: {message}"
);
}
assert!(
mock.server
.received_requests()
.await
.unwrap_or_default()
.is_empty(),
"refusals do no network work"
);
}
#[tokio::test]
async fn task_create_malformed_setting_refuses_pre_network() {
let mock = IgnitionMock::start().await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = ignition_core::actions::eam::eam_task_create(
&api,
"t",
"eam_backup",
&[],
&["noequalsign".to_string()],
None,
"OnDemand",
)
.await
.expect_err("malformed K=V refuses");
assert_eq!(err.exit_code(), 2);
assert_eq!(err.code(), "invalid_input");
assert!(
mock.server
.received_requests()
.await
.unwrap_or_default()
.is_empty()
);
}
#[tokio::test]
async fn task_force_is_the_five_request_sequence() {
let mock = IgnitionMock::start().await;
mock.list_json(
"GET",
"/data/api/v1/resources/find/com.inductiveautomation.eam/eam-tasks/nightly%2Dbackup",
serde_json::json!({
"name": "nightly-backup",
"config": {"profile": {"type": "eam_backup", "scheduleMode": "OnDemand"}},
"scheduledTaskState": {
"currentState": "IDLE",
"details": {"owner": "eam"}
}
}),
)
.await;
mock.list_json("GET", SCHEDULED_FALSE_PATH, scheduled_true_page())
.await;
mock.list_json("GET", SCHEDULED_TRUE_PATH, scheduled_true_page())
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/data/eam/api/v1/eam-tasks/force/eam/nightly-backup",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&mock.server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(HISTORY_PATH))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [
{
"taskId": "e5f7ade4-2da3-421e-9639-5cb594193769",
"taskName": "nightly-backup (forced)",
"taskStart": 1787930000000_i64,
"taskEnd": 1787930009000_i64,
"target": "_controller",
"level": "Failed",
"detail": "Gateway network for agent '_controller' is currently not connected",
"taskType": "eam_backup"
}
],
"metadata": {"total": 1, "matching": 1, "limit": 20, "offset": 0}
})),
)
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_force(&api, "nightly-backup")
.await
.expect("the sequence completes");
assert_eq!(result.owner, "eam", "owner resolved from the healthcheck");
assert!(result.dispatched);
let entry = result.history.expect("the forced entry is visible");
assert_eq!(entry.task_name, "nightly-backup (forced)");
assert_eq!(
entry.level.as_deref(),
Some("Failed"),
"the outcome is data"
);
let requests = mock.server.received_requests().await.unwrap_or_default();
assert_eq!(
requests.len(),
5,
"find → scheduled/false → scheduled/true → force → history, exactly"
);
let sequence: Vec<(&str, String)> = requests
.iter()
.map(|request| (request.method.as_str(), request.url.path().to_string()))
.collect();
assert_eq!(
sequence,
vec![
(
"GET",
"/data/api/v1/resources/find/com.inductiveautomation.eam/eam-tasks/nightly%2Dbackup"
.to_string()
),
("GET", SCHEDULED_FALSE_PATH.to_string()),
("GET", SCHEDULED_TRUE_PATH.to_string()),
(
"POST",
"/data/eam/api/v1/eam-tasks/force/eam/nightly-backup".to_string()
),
("GET", "/data/eam/api/v1/eam-tasks/history".to_string()),
],
"the request SEQUENCE is the contract"
);
}
#[tokio::test]
async fn task_force_owner_falls_back_to_eam() {
let mock = IgnitionMock::start().await;
mock.list_json(
"GET",
"/data/api/v1/resources/find/com.inductiveautomation.eam/eam-tasks/bare",
serde_json::json!({"name": "bare", "config": {}}),
)
.await;
mock.list_json("GET", SCHEDULED_FALSE_PATH, scheduled_true_page())
.await;
mock.list_json("GET", SCHEDULED_TRUE_PATH, scheduled_true_page())
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/data/eam/api/v1/eam-tasks/force/eam/bare",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&mock.server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(HISTORY_PATH))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [],
"metadata": {"total": 0, "matching": 0, "limit": 20, "offset": 0}
})),
)
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_force(&api, "bare")
.await
.expect("fallback owner forces");
assert_eq!(result.owner, "eam");
assert!(result.history.is_none(), "no entry yet — null, honestly");
}
const SUSPEND_PATH: &str = "/data/eam/api/v1/eam-tasks/suspend/nightly-backup";
const RESUME_PATH: &str = "/data/eam/api/v1/eam-tasks/resume/nightly-backup";
const CANCEL_PATH: &str = "/data/eam/api/v1/eam-tasks/cancel/nightly-backup";
const SCHEDULED_FALSE_PATH: &str = "/data/eam/api/v1/eam-tasks/scheduled/false";
const SCHEDULED_TRUE_PATH: &str = "/data/eam/api/v1/eam-tasks/scheduled/true";
const TASKS_DELETE_PATH: &str =
"/data/api/v1/resources/com.inductiveautomation.eam/eam-tasks/nightly%2Dbackup/sig%2Dabc123";
fn controller_403_body() -> Vec<u8> {
"<html><head><title>Error 403</title></head><body><h2>HTTP ERROR 403 Forbidden</h2><table><tr><th>MESSAGE:</th><td>This operation can only be performed when EAM is configured as a controller.</td></tr></table></body></html>".as_bytes().to_vec()
}
fn scheduled_false_page() -> serde_json::Value {
serde_json::json!({
"items": [
{
"name": "ign-p10-scratch-sched",
"owner": "eam",
"type": "Collect Backup",
"execStart": null,
"message": "",
"repeats": true,
"canPause": true,
"canResume": false,
"canCancel": true,
"taskState": "Scheduled",
"isForced": false,
"isRunning": false,
"progress": 0.0
}
],
"metadata": {"total": 1, "matching": 1, "limit": -1, "offset": 0}
})
}
fn scheduled_true_page() -> serde_json::Value {
serde_json::json!({
"items": [],
"metadata": {"total": 0, "matching": 0, "limit": -1, "offset": 0}
})
}
#[tokio::test]
async fn suspend_204_pins_post_path_and_empty_body() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(SUSPEND_PATH))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
api.eam_task_suspend("nightly-backup")
.await
.expect("the 204 is the success shape");
let requests = guard.received_requests().await;
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].url.path(), SUSPEND_PATH);
assert!(
requests[0].body.is_empty(),
"lifecycle POSTs carry NO body — params ride nothing"
);
}
#[tokio::test]
async fn resume_204_pins_post_path_and_empty_body() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(RESUME_PATH))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
api.eam_task_resume("nightly-backup")
.await
.expect("the 204 is the success shape");
let requests = guard.received_requests().await;
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].url.path(), RESUME_PATH);
assert!(requests[0].body.is_empty());
}
#[tokio::test]
async fn cancel_204_pins_post_path_and_empty_body() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(CANCEL_PATH))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
api.eam_task_cancel("nightly-backup")
.await
.expect("the 204 is the success shape");
let requests = guard.received_requests().await;
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].url.path(), CANCEL_PATH);
assert!(requests[0].body.is_empty());
}
#[tokio::test]
async fn suspend_controller_403_classifies_eam_not_controller() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(SUSPEND_PATH))
.respond_with(
wiremock::ResponseTemplate::new(403)
.set_body_raw(controller_403_body(), "text/html;charset=iso-8859-1"),
)
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = api
.eam_task_suspend("nightly-backup")
.await
.expect_err("the controller 403 refuses");
assert_eq!(err.exit_code(), 6, "target state, not auth");
assert_eq!(err.code(), "eam_not_controller");
}
#[tokio::test]
async fn resume_controller_403_classifies_eam_not_controller() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(RESUME_PATH))
.respond_with(
wiremock::ResponseTemplate::new(403)
.set_body_raw(controller_403_body(), "text/html;charset=iso-8859-1"),
)
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = api
.eam_task_resume("nightly-backup")
.await
.expect_err("the controller 403 refuses");
assert_eq!(err.exit_code(), 6);
assert_eq!(err.code(), "eam_not_controller");
}
#[tokio::test]
async fn cancel_controller_403_classifies_eam_not_controller() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(CANCEL_PATH))
.respond_with(
wiremock::ResponseTemplate::new(403)
.set_body_raw(controller_403_body(), "text/html;charset=iso-8859-1"),
)
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = api
.eam_task_cancel("nightly-backup")
.await
.expect_err("the controller 403 refuses");
assert_eq!(err.exit_code(), 6);
assert_eq!(err.code(), "eam_not_controller");
}
#[tokio::test]
async fn new_runtime_verb_403_without_the_message_stays_auth() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(SUSPEND_PATH))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_raw(
"<html><head><title>Error 403</title></head><body><h2>HTTP ERROR 403 Forbidden</h2><table><tr><th>MESSAGE:</th><td>Forbidden</td></tr></table></body></html>".as_bytes().to_vec(),
"text/html;charset=iso-8859-1",
),
)
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = api
.eam_task_suspend("nightly-backup")
.await
.expect_err("a plain 403 stays auth");
assert_eq!(err.exit_code(), 5);
assert_eq!(err.code(), "auth_rejected");
}
#[tokio::test]
async fn scheduled_false_read_parses_the_captured_row() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(SCHEDULED_FALSE_PATH))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(scheduled_false_page()))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let tasks = api
.eam_tasks_scheduled(false)
.await
.expect("the read parses");
assert_eq!(tasks.len(), 1);
let row = &tasks[0];
assert_eq!(row.name, "ign-p10-scratch-sched");
assert_eq!(row.owner, "eam");
assert_eq!(
row.task_type.as_deref(),
Some("Collect Backup"),
"the human label rides verbatim"
);
assert_eq!(row.exec_start, None, "execStart null while scheduled");
assert!(row.can_pause && !row.can_resume && row.can_cancel);
assert_eq!(
row.task_state, "Scheduled",
"the captured taskState string, verbatim"
);
assert!(!row.is_forced && !row.is_running);
assert_eq!(row.progress, 0.0);
assert_eq!(guard.received_requests().await.len(), 1);
}
#[tokio::test]
async fn scheduled_true_read_parses_the_empty_body() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(SCHEDULED_TRUE_PATH))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(scheduled_true_page()))
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let tasks = api.eam_tasks_scheduled(true).await.expect("empty parses");
assert!(tasks.is_empty(), "the quiet body is an honest empty list");
}
#[tokio::test]
async fn task_modify_puts_full_array_body_and_parses_the_outcome() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(TASKS_CREATE_PATH))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"success": true,
"changes": [
{
"name": "ign-p10-scratch-sched",
"type": "com.inductiveautomation.eam/eam-tasks",
"collection": "core",
"newSignature": "0d0dfea2919abb1f02fc86baea73d99696626524169a9ac36526044f89ac16e0"
}
],
"problem": null
}),
))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let full_record = serde_json::json!({
"name": "ign-p10-scratch-sched",
"type": "com.inductiveautomation.eam",
"collection": "core",
"enabled": true,
"version": 1,
"signature": "e5ac8bee3a6ba85e40923c0e02d29507600c57519eb8e4d78bd8c258197fe9c6",
"config": {
"profile": {
"type": "eam_backup",
"isSuspended": false,
"scheduleMode": "Scheduled",
"scheduleDetails": "0/30 * * * * ?"
},
"settings": {
"targetGateways": ["_controller"],
"targetGroups": [],
"concurrentBackups": 0,
"forceBackups": false
}
},
"data": ["config.json"]
});
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let outcome = api
.eam_task_modify(&full_record)
.await
.expect("the 200 body parses")
.expect("the body is present (not the lenient None)");
assert!(outcome.success);
assert_eq!(outcome.changes.len(), 1);
assert_eq!(
outcome.changes[0].new_signature.as_deref(),
Some("0d0dfea2919abb1f02fc86baea73d99696626524169a9ac36526044f89ac16e0"),
"newSignature is authoritative for the NEXT mutation"
);
assert_eq!(outcome.problem, None, "problem null on every success");
let requests = guard.received_requests().await;
assert_eq!(requests.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).expect("body parses");
let array = body.as_array().expect("the body is a JSON ARRAY");
assert_eq!(array.len(), 1, "single-element array — the §6a shape");
let sent = &array[0];
assert!(
sent["config"]["settings"].is_object()
&& !sent["config"]["settings"].as_object().unwrap().is_empty(),
"config.settings RIDES — omitting it is the 422 trap (§6b)"
);
assert!(
sent["signature"].is_string(),
"the ORIGINAL signature key is present — the modify contract"
);
assert_eq!(
sent, &full_record,
"echo-modify: the full record lands verbatim"
);
}
#[tokio::test]
async fn task_delete_pins_query_params_and_parses_the_success_body() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(TASKS_DELETE_PATH))
.and(wiremock::matchers::query_param("collection", "core"))
.and(wiremock::matchers::query_param_is_missing("confirm"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"success": true,
"changes": [
{
"name": "ign-p10-scratch-sched",
"type": "com.inductiveautomation.eam/eam-tasks",
"collection": "core",
"newSignature": "ec961ee921c63b18013094870ed2664331e965c4770fdf84bfe136e0b4164244"
}
],
"problem": null,
"references": []
}),
))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let outcome = api
.eam_task_delete("nightly-backup", "sig-abc123", false)
.await
.expect("the captured success body parses");
assert!(outcome.success);
assert_eq!(outcome.changes[0].name, "ign-p10-scratch-sched");
assert_eq!(
outcome.changes[0].new_signature.as_deref(),
Some("ec961ee921c63b18013094870ed2664331e965c4770fdf84bfe136e0b4164244"),
"the DELETED resource's final signature rides changes[].newSignature"
);
assert_eq!(
outcome.references,
Some(Vec::new()),
"references [] on success, honestly empty"
);
let requests = guard.received_requests().await;
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].url.query(),
Some("collection=core"),
"the exact captured query — confirm absent on the default delete"
);
}
#[tokio::test]
async fn task_delete_confirm_opt_in_rides_the_query() {
let mock = IgnitionMock::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(TASKS_DELETE_PATH))
.and(wiremock::matchers::query_param("collection", "core"))
.and(wiremock::matchers::query_param("confirm", "true"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"success": true,
"changes": [
{
"name": "ign-p10-scratch-sched",
"type": "com.inductiveautomation.eam/eam-tasks",
"collection": "core",
"newSignature": "e3610cfe01c7df086da6596ccfbb7735abd5b8f2ceafd5916944919975902acf"
}
],
"problem": null,
"references": []
}),
))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
api.eam_task_delete("nightly-backup", "sig-abc123", true)
.await
.expect("the confirmed delete parses");
let requests = guard.received_requests().await;
assert_eq!(
requests[0].url.query(),
Some("collection=core&confirm=true"),
"the §3c captured request shape"
);
}
#[tokio::test]
async fn task_delete_signature_mismatch_500_is_the_recorded_finding() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(TASKS_DELETE_PATH))
.and(wiremock::matchers::query_param("collection", "core"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_json(
serde_json::json!({
"success": false,
"changes": [],
"problem": {
"message": "DELETE illegal: signature mismatch for 'ResourceId{resourcePath=com.inductiveautomation.eam/eam-tasks/ign-p10-scratch-sched, collectionName=core}'",
"stacktrace": [
"com.inductiveautomation.ignition.common.resourcecollection.PushException: DELETE illegal: signature mismatch for …",
"\tat com.inductiveautomation.ignition.gateway.resourcecollection.ChangeOperationValidationHandler$AtomicPushValidationHandler.throwIfInvalid(ChangeOperationValidationHandler.java:61)"
]
},
"references": null
}),
))
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = api
.eam_task_delete("nightly-backup", "sig-abc123", false)
.await
.expect_err("the 500 refusal is not a success");
assert_eq!(
err.exit_code(),
1,
"the FINDING: exit-1 for a client-fixable mismatch"
);
assert_eq!(
err.code(),
"internal",
"no honest slug yet — 10-03/10-04 decides"
);
}
#[tokio::test]
async fn suspend_500_html_failure_surfaces_the_page_message() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(SUSPEND_PATH))
.respond_with(
wiremock::ResponseTemplate::new(500).set_body_raw(
"<html>\n<head>\n<meta http-equiv=\"Content-Type\" content=\"text/html;charset=ISO-8859-1\"/>\n<title>Error 500</title>\n</head>\n<body><h2>HTTP ERROR 500 Task could not be suspended</h2>\n<table>\n<tr><th>URI:</th><td>/data/eam/api/v1/eam-tasks/suspend/ign-p10-scratch</td></tr>\n<tr><th>STATUS:</th><td>500</td></tr>\n<tr><th>MESSAGE:</th><td>Task could not be suspended</td></tr>\n</table>\n\n</body>\n</html>\n".as_bytes().to_vec(),
"text/html;charset=iso-8859-1",
),
)
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = api
.eam_task_suspend("nightly-backup")
.await
.expect_err("the captured 500 refusal");
assert_eq!(err.exit_code(), 1);
assert_eq!(err.code(), "internal");
assert!(
err.to_string().contains("Task could not be suspended"),
"the Jetty page's message rides the error detail: {err}"
);
}
#[tokio::test]
async fn runtime_verb_404_is_the_defensive_not_found_classification() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(SUSPEND_PATH))
.respond_with(wiremock::ResponseTemplate::new(404))
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = api
.eam_task_suspend("nightly-backup")
.await
.expect_err("404 classifies NotFound");
assert_eq!(err.exit_code(), 6);
assert_eq!(err.code(), "not_found");
}
fn find_responder(
first: serde_json::Value,
then: serde_json::Value,
) -> impl Fn(&wiremock::Request) -> wiremock::ResponseTemplate {
use std::sync::Mutex;
let calls = Mutex::new(0usize);
move |_request| {
let mut calls = calls.lock().expect("counter locks");
*calls += 1;
let body = if *calls == 1 {
first.clone()
} else {
then.clone()
};
wiremock::ResponseTemplate::new(200).set_body_json(body)
}
}
#[tokio::test]
async fn suspend_action_finds_then_posts_then_readbacks_the_flag() {
let mock = IgnitionMock::start().await;
let find = |suspended: bool| {
serde_json::json!({
"name": "nightly-backup",
"config": {"profile": {"type": "eam_backup", "isSuspended": suspended, "scheduleMode": "Scheduled"}},
"signature": "sig-abc123",
"scheduledTaskState": {"currentState": "Scheduled", "details": {"owner": "eam"}}
})
};
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(TASKS_FIND_PATH))
.respond_with(find_responder(find(false), find(true)))
.expect(2)
.mount(&mock.server)
.await;
let post = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(SUSPEND_PATH))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_suspend(&api, "nightly-backup")
.await
.expect("the lifecycle sequence completes");
assert_eq!(result.task, "nightly-backup");
assert_eq!(result.action, "suspended");
assert_eq!(
result.previous_state.as_deref(),
Some("Scheduled"),
"the find healthcheck's currentState, pre-write"
);
assert_eq!(
result.config_suspended,
Some(true),
"the read-back proves the flag PERSISTED (capture Decision 1)"
);
assert_eq!(result.pending, None);
assert!(result.fired);
assert_eq!(result.reason, None);
assert_eq!(post.received_requests().await.len(), 1, "one POST rode");
}
#[tokio::test]
async fn suspend_action_refuses_already_suspended_pre_write() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(TASKS_FIND_PATH))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"name": "nightly-backup",
"config": {"profile": {"type": "eam_backup", "isSuspended": true, "scheduleMode": "Scheduled"}},
"scheduledTaskState": {"currentState": "Suspended", "details": {"owner": "eam"}}
}),
))
.expect(1)
.mount(&mock.server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(SUSPEND_PATH))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(0)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = ignition_core::actions::eam::eam_task_suspend(&api, "nightly-backup")
.await
.expect_err("already-suspended refuses pre-write");
assert_eq!(err.exit_code(), 2);
assert_eq!(err.code(), "invalid_input");
let message = err.to_string();
assert!(
message.contains("nightly-backup") && message.contains("already suspended"),
"the refusal names the task + state: {message}"
);
}
#[tokio::test]
async fn cancel_action_without_pending_is_an_honest_noop() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(TASKS_FIND_PATH))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"name": "nightly-backup",
"config": {"profile": {"type": "eam_backup", "isSuspended": false, "scheduleMode": "OnDemand"}},
"scheduledTaskState": {"currentState": "Stopped", "details": {"owner": "eam"}}
}),
))
.expect(1)
.mount(&mock.server)
.await;
for running in ["false", "true"] {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/data/eam/api/v1/eam-tasks/scheduled/{running}"
)))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(scheduled_true_page()))
.expect(1)
.mount(&mock.server)
.await;
}
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(CANCEL_PATH))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(0)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_cancel(&api, "nightly-backup")
.await
.expect("the no-op is a success-shaped result");
assert_eq!(result.action, "cancelled");
assert!(!result.fired, "nothing pending — the POST did not ride");
assert_eq!(
result.reason.as_deref(),
Some("no pending execution"),
"the honest reason rides the always-keys model"
);
assert_eq!(result.previous_state.as_deref(), Some("Stopped"));
}
#[tokio::test]
async fn cancel_action_fires_against_a_cancellable_row() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(TASKS_FIND_PATH))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"name": "nightly-backup",
"config": {"profile": {"type": "eam_backup", "isSuspended": false, "scheduleMode": "Scheduled"}},
"scheduledTaskState": {"currentState": "Scheduled", "details": {"owner": "eam"}}
}),
))
.expect(1)
.mount(&mock.server)
.await;
let row_page = |name: &str| {
let mut page = scheduled_false_page();
page["items"][0]["name"] = serde_json::json!(name);
page
};
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(SCHEDULED_FALSE_PATH))
.respond_with(find_responder(
row_page("nightly-backup"),
scheduled_true_page(),
))
.expect(2)
.mount(&mock.server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(SCHEDULED_TRUE_PATH))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(scheduled_true_page()))
.expect(2)
.mount(&mock.server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(CANCEL_PATH))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_cancel(&api, "nightly-backup")
.await
.expect("the cancel sequence completes");
assert!(result.fired);
assert_eq!(result.reason, None);
assert_eq!(
result.pending, None,
"the post-write read proves the execution is gone"
);
assert_eq!(result.previous_state.as_deref(), Some("Scheduled"));
}
fn modify_find_fixture() -> serde_json::Value {
serde_json::json!({
"type": "com.inductiveautomation.eam/eam-tasks",
"name": "ign-p10-scratch-sched",
"description": "scratch",
"enabled": true,
"version": 1,
"collection": "core",
"collections": ["core"],
"signature": "e5ac8bee3a6ba85e40923c0e02d29507600c57519eb8e4d78bd8c258197fe9c6",
"config": {
"profile": {
"type": "eam_backup",
"isSuspended": false,
"scheduleMode": "Scheduled",
"scheduleDetails": "0/30 * * * * ?"
},
"settings": {
"targetGateways": ["_controller"],
"targetGroups": [],
"concurrentBackups": 0,
"forceBackups": false
}
},
"data": ["config.json"],
"attributes": {"uuid": "c1aa2b52-46ad-46ea-962b-9d2498f35db1", "enabled": true},
"metrics": {},
"healthchecks": {
"scheduledTaskState": {
"currentState": "Scheduled",
"details": {"owner": "eam", "nextScheduled": "1788947835694"}
}
}
})
}
const MODIFY_FIND_PATH: &str =
"/data/api/v1/resources/find/com.inductiveautomation.eam/eam-tasks/ign%2Dp10%2Dscratch%2Dsched";
#[tokio::test]
async fn modify_action_preserves_the_full_record_on_the_wire() {
let mock = IgnitionMock::start().await;
let fixture = modify_find_fixture();
let mut post_write = fixture.clone();
post_write["enabled"] = serde_json::json!(false);
post_write["signature"] =
serde_json::json!("0d0dfea2919abb1f02fc86baea73d99696626524169a9ac36526044f89ac16e0");
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(MODIFY_FIND_PATH))
.respond_with(find_responder(fixture.clone(), post_write.clone()))
.expect(2)
.mount(&mock.server)
.await;
let mut expected_body = fixture.clone();
expected_body["enabled"] = serde_json::json!(false);
let put = wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(TASKS_CREATE_PATH))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"success": true,
"changes": [
{
"name": "ign-p10-scratch-sched",
"type": "com.inductiveautomation.eam/eam-tasks",
"collection": "core",
"newSignature": "0d0dfea2919abb1f02fc86baea73d99696626524169a9ac36526044f89ac16e0"
}
],
"problem": null
}),
))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_modify(
&api,
"ign-p10-scratch-sched",
ignition_core::actions::eam::TaskChange {
enabled: Some(false),
..Default::default()
},
)
.await
.expect("the full-record RMW completes");
assert_eq!(result.task, "ign-p10-scratch-sched");
assert_eq!(result.changed, vec!["enabled".to_string()]);
assert!(
result
.put_outcome
.as_ref()
.expect("the 200 body rode")
.success
);
assert_eq!(
result.put_outcome.as_ref().unwrap().changes[0]
.new_signature
.as_deref(),
Some("0d0dfea2919abb1f02fc86baea73d99696626524169a9ac36526044f89ac16e0"),
"newSignature is authoritative for the NEXT mutation (§6a)"
);
assert_eq!(
result.readback["signature"], post_write["signature"],
"the read-back proves the landing"
);
assert_eq!(
result.definition["config"]["settings"], fixture["config"]["settings"],
"the verbatim PUT body preserved config.settings"
);
let requests = put.received_requests().await;
assert_eq!(requests.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).expect("body parses");
assert_eq!(
body,
serde_json::json!([expected_body]),
"never-compose-from-scratch ON THE WIRE: every find key rides, only the targeted key moved"
);
}
#[tokio::test]
async fn delete_action_derives_the_signature_and_sends_no_confirm() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(TASKS_FIND_PATH))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": "nightly-backup",
"collection": "core",
"signature": "sig-abc123",
"config": {"profile": {"type": "eam_backup", "scheduleMode": "OnDemand"}},
"scheduledTaskState": {"currentState": "Stopped", "details": {"owner": "eam"}}
})),
)
.expect(1)
.mount(&mock.server)
.await;
let guard = wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(TASKS_DELETE_PATH))
.and(wiremock::matchers::query_param("collection", "core"))
.and(wiremock::matchers::query_param_is_missing("confirm"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"success": true,
"changes": [
{
"name": "nightly-backup",
"type": "com.inductiveautomation.eam/eam-tasks",
"collection": "core",
"newSignature": "ec961ee921c63b18013094870ed2664331e965c4770fdf84bfe136e0b4164244"
}
],
"problem": null,
"references": []
}),
))
.expect(1)
.mount_as_scoped(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_delete(&api, "nightly-backup")
.await
.expect("the delete completes");
assert_eq!(result.task, "nightly-backup");
assert!(result.deleted);
assert_eq!(
result.changes[0].new_signature.as_deref(),
Some("ec961ee921c63b18013094870ed2664331e965c4770fdf84bfe136e0b4164244"),
"the DELETED resource's final signature rides changes[] (§9)"
);
assert_eq!(
result.affected,
vec!["nightly-backup".to_string()],
"a lone-resource delete touches exactly the deleted resource"
);
let requests = guard.received_requests().await;
assert_eq!(
requests[0].url.query(),
Some("collection=core"),
"NO confirm on the default delete (Decision 3)"
);
}
#[tokio::test]
async fn delete_action_retries_with_confirm_on_the_demand_shape() {
let mock = IgnitionMock::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(TASKS_FIND_PATH))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": "nightly-backup",
"collection": "core",
"signature": "sig-abc123",
"config": {"profile": {"type": "eam_backup", "scheduleMode": "Scheduled"}}
})),
)
.expect(1)
.mount(&mock.server)
.await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(TASKS_DELETE_PATH))
.and(wiremock::matchers::query_param("collection", "core"))
.and(wiremock::matchers::query_param_is_missing("confirm"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"success": false,
"changes": [],
"problem": null,
"references": [
{"name": "dependent-thing", "type": "some/dependent-type"}
]
})),
)
.expect(1)
.mount(&mock.server)
.await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(TASKS_DELETE_PATH))
.and(wiremock::matchers::query_param("collection", "core"))
.and(wiremock::matchers::query_param("confirm", "true"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"success": true,
"changes": [
{
"name": "nightly-backup",
"type": "com.inductiveautomation.eam/eam-tasks",
"collection": "core",
"newSignature": "e3610cfe01c7df086da6596ccfbb7735abd5b8f2ceafd5916944919975902acf"
}
],
"problem": null,
"references": []
}),
))
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_delete(&api, "nightly-backup")
.await
.expect("the confirm retry lands the delete");
assert!(result.deleted);
assert_eq!(
result.affected,
vec!["nightly-backup".to_string()],
"the confirmed delete's references are honestly empty"
);
}
#[tokio::test]
async fn delete_action_maps_a_proven_stale_signature_to_exit_2() {
let mock = IgnitionMock::start().await;
let fresh = |sig: &str| {
serde_json::json!({
"name": "nightly-backup",
"collection": "core",
"signature": sig,
"config": {"profile": {"type": "eam_backup", "scheduleMode": "OnDemand"}}
})
};
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(TASKS_FIND_PATH))
.respond_with(find_responder(fresh("sig-abc123"), fresh("sig-concurrent")))
.expect(2)
.mount(&mock.server)
.await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(TASKS_DELETE_PATH))
.and(wiremock::matchers::query_param("collection", "core"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_json(
serde_json::json!({
"success": false,
"changes": [],
"problem": {
"message": "DELETE illegal: signature mismatch for 'ResourceId{resourcePath=com.inductiveautomation.eam/eam-tasks/nightly-backup, collectionName=core}'",
"stacktrace": ["com.inductiveautomation.ignition.common.resourcecollection.PushException: DELETE illegal: signature mismatch for …"]
},
"references": null
}),
))
.expect(1)
.mount(&mock.server)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let err = ignition_core::actions::eam::eam_task_delete(&api, "nightly-backup")
.await
.expect_err("the stale-signature write refuses");
assert_eq!(err.exit_code(), 2, "client-fixable — the usage class");
assert_eq!(err.code(), "invalid_input");
let message = err.to_string();
assert!(
message.contains("changed concurrently") && message.contains("signature mismatch"),
"the diagnostic names the conflict + the re-run path: {message}"
);
}
#[tokio::test]
async fn force_action_composes_the_blast_radius_preview() {
let mock = IgnitionMock::start().await;
mock.list_json(
"GET",
"/data/api/v1/resources/find/com.inductiveautomation.eam/eam-tasks/nightly%2Dbackup",
serde_json::json!({
"name": "nightly-backup",
"config": {
"profile": {"type": "eam_backup", "scheduleMode": "Scheduled"},
"settings": {"targetGateways": ["gw-a", "gw-b"], "targetGroups": []}
},
"scheduledTaskState": {
"currentState": "Scheduled",
"details": {"owner": "eam"}
}
}),
)
.await;
let row_page = {
let mut page = scheduled_false_page();
page["items"][0]["name"] = serde_json::json!("nightly-backup");
page
};
mock.list_json("GET", SCHEDULED_FALSE_PATH, row_page).await;
mock.list_json("GET", SCHEDULED_TRUE_PATH, scheduled_true_page())
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/data/eam/api/v1/eam-tasks/force/eam/nightly-backup",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&mock.server)
.await;
mock.list_json(
"GET",
HISTORY_PATH,
serde_json::json!({
"items": [],
"metadata": {"total": 0, "matching": 0, "limit": 20, "offset": 0}
}),
)
.await;
let api = ReqwestGatewayApi::for_tests(&mock.uri(), Some(token_credential()));
let result = ignition_core::actions::eam::eam_task_force(&api, "nightly-backup")
.await
.expect("the force sequence completes");
let preview = &result.preview;
assert_eq!(preview.verb, "force");
assert_eq!(preview.task, "nightly-backup");
assert_eq!(
preview.target_gateways,
vec!["gw-a".to_string(), "gw-b".to_string()],
"the AGENTS the force touches"
);
assert_eq!(preview.pending_executions.len(), 1);
assert!(preview.pending_executions[0].can_cancel);
assert_eq!(preview.owner.as_deref(), Some("eam"));
let impact = &preview.controller_impact;
assert!(
impact.contains("dispatches task nightly-backup") && impact.contains("2 agents"),
"the force impact names task + agents: {impact}"
);
let line = ignition_core::actions::eam::render_preview_line(preview);
assert!(
line.starts_with("force nightly-backup:")
&& line.contains("targets: [gw-a, gw-b] pending: 1"),
"the confirmation-line format: {line}"
);
}