use super::*;
use crate::{Op, Park, RunState, StepState};
#[tokio::test]
async fn a_run_comes_up_with_its_entry_and_message_then_goes() {
let f = fixture();
let lab = f.runner.open(&f.manifest, None).unwrap();
let events = Recorder::new();
let run = lab.up(&opts(), &events).await.unwrap();
assert_eq!(f.containers(), Containers::Up);
assert_eq!(run.outputs[0].value.as_deref(), Some("http://127.0.0.1:41873/"), "the pinned port");
assert_eq!(run.message.as_deref(), Some("The portal answers at http://localhost:41873/.\n"));
assert_eq!(run.record.state, RunState::Ready);
assert_eq!(run.record.params.get("ISOLOOM_PUBLISH_ADDRESS").map(String::as_str), Some("127.0.0.1"));
let k = kinds(&events);
assert_eq!(
k.first(),
Some(&EventKind::State {
from: RunState::Absent,
to: RunState::Starting
})
);
assert!(matches!(k.last(), Some(EventKind::Result { ok: true, .. })));
assert!(
k.iter()
.any(|e| matches!(e, EventKind::Output { name, value: Some(v), .. } if name == "web_portal" && v.ends_with(":41873/")))
);
assert_eq!(f.runner.store.record(lab.name()).unwrap().unwrap().state, RunState::Ready);
let status = lab.status().await.unwrap();
assert_eq!(status.state, RunState::Ready);
assert!(status.message.is_some() && status.networks[0].native.as_deref() == Some("flag-portal_lab"));
let down = Recorder::new();
lab.down(&RunOptions::default(), &down).await.unwrap();
assert_eq!(f.containers(), Containers::None);
assert_eq!(lab.status().await.unwrap().state, RunState::Absent);
assert!(matches!(kinds(&down).last(), Some(EventKind::Result { ok: true, .. })));
}
#[tokio::test]
async fn the_flag_reaches_only_the_steps_that_create_things_and_never_an_event() {
let f = fixture();
let lab = f.runner.open(&f.manifest, None).unwrap();
let events = Recorder::new();
lab.up(&opts(), &events).await.unwrap();
for call in f.fake.calls() {
let has = call.env("CTF_FLAG_MAIN").is_some();
let creates = call.line().contains(" up --detach ") || call.line().contains("--exit-code-from web-init-1");
assert_eq!(has, creates, "{}", call.line());
}
let text = serde_json::to_string(&events.events()).unwrap();
assert!(!text.contains(FLAG) && text.contains("seeding ***"), "{text}");
let log = std::fs::read_to_string(f.runner.store.run_dir(lab.name()).unwrap().join("events.jsonl")).unwrap();
assert!(!log.contains(FLAG));
assert!(
!std::fs::read_to_string(f.runner.store.run_dir(lab.name()).unwrap().join("record.json"))
.unwrap()
.contains(FLAG)
);
}
#[tokio::test]
async fn a_slot_without_a_value_gets_its_default_so_a_resume_needs_no_inputs() {
let mut m: serde_json::Value = serde_json::from_str(FLAG_PORTAL).unwrap();
m["secrets"][0]["deliver"][0]["steps"].as_array_mut().unwrap().push("start".into());
let f = fixture_with(&m.to_string());
let lab = f.runner.open(&f.manifest, None).unwrap();
lab.up(&RunOptions::default(), &Recorder::new()).await.unwrap();
let up = f.fake.last(" up --detach ").unwrap();
assert_eq!(up.env("CTF_FLAG_MAIN"), Some(""), "an optional input left out is set, empty");
lab.park(Park::Shutdown, &Recorder::new()).await.unwrap();
let events = Recorder::new();
lab.resume(&RunOptions::default(), &events).await.unwrap();
let start = f.fake.calls().into_iter().rev().find(|c| c.line().ends_with(" start")).unwrap();
assert_eq!(start.env("CTF_FLAG_MAIN"), Some(""));
assert!(matches!(kinds(&events).last(), Some(EventKind::Result { ok: true, .. })));
}
#[tokio::test]
async fn a_slot_without_a_value_or_a_default_is_not_delivered() {
let mut m: serde_json::Value = serde_json::from_str(FLAG_PORTAL).unwrap();
m["secrets"][0].as_object_mut().unwrap().remove("default");
let f = fixture_with(&m.to_string());
let lab = f.runner.open(&f.manifest, None).unwrap();
lab.up(&RunOptions::default(), &Recorder::new()).await.unwrap();
assert_eq!(f.fake.last(" up --detach ").unwrap().env("CTF_FLAG_MAIN"), None);
}
#[tokio::test]
async fn every_operation_ends_with_exactly_one_result_or_error_and_is_logged() {
let f = fixture();
let lab = f.runner.open(&f.manifest, None).unwrap();
let ends = |r: &Recorder| {
r.events()
.iter()
.filter(|e| matches!(e.kind, EventKind::Result { .. } | EventKind::Error { .. }))
.count()
};
let (a, b) = (Recorder::new(), Recorder::new());
lab.up(&opts(), &a).await.unwrap();
assert!(lab.up(&opts(), &b).await.is_err(), "already running");
assert_eq!((ends(&a), ends(&b)), (1, 1));
let seqs: Vec<u64> = a.events().iter().map(|e| e.seq).collect();
assert_eq!(seqs, (1..=u64::try_from(seqs.len()).unwrap()).collect::<Vec<_>>());
let logged = f.runner.events(lab.name()).unwrap();
assert_eq!(logged.len(), a.events().len() + b.events().len());
assert!(logged.iter().all(|e| e.time == "2026-10-15T12:00:00Z" && e.v == 0));
}
#[tokio::test]
async fn a_run_already_up_is_refused_and_stays_ready() {
let f = fixture();
let lab = f.runner.open(&f.manifest, None).unwrap();
lab.up(&opts(), &Recorder::new()).await.unwrap();
let ups = f.called(" up --detach ");
let err = lab.up(&opts(), &Recorder::new()).await.unwrap_err();
assert_eq!(err.code(), crate::ErrorCode::AlreadyRunning);
assert_eq!(f.called(" up --detach "), ups, "nothing started again");
assert_eq!(f.runner.store.record(lab.name()).unwrap().unwrap().state, RunState::Ready);
}
#[tokio::test]
async fn a_parked_run_resumes_without_being_re_created() {
let f = fixture();
let lab = f.runner.open(&f.manifest, None).unwrap();
lab.up(&opts(), &Recorder::new()).await.unwrap();
let parked = Recorder::new();
lab.park(Park::Shutdown, &parked).await.unwrap();
assert_eq!(f.containers(), Containers::Stopped);
assert_eq!(lab.status().await.unwrap().state, RunState::Parked);
assert!(kinds(&parked).contains(&EventKind::State {
from: RunState::Parking,
to: RunState::Parked
}));
let ups = f.called(" up --detach ");
let run = lab.resume(&RunOptions::default(), &Recorder::new()).await.unwrap();
assert_eq!((f.containers(), f.called(" up --detach ")), (Containers::Up, ups), "`start`, never `up`");
assert_eq!(run.outputs[0].value.as_deref(), Some("http://127.0.0.1:41873/"), "the same address");
assert_eq!(f.runner.store.record(lab.name()).unwrap().unwrap().state, RunState::Ready);
}
#[tokio::test]
async fn up_on_a_parked_run_resumes_it() {
let f = fixture();
let lab = f.runner.open(&f.manifest, None).unwrap();
lab.up(&opts(), &Recorder::new()).await.unwrap();
lab.park(Park::Pause, &Recorder::new()).await.unwrap();
let ups = f.called(" up --detach ");
lab.up(&opts(), &Recorder::new()).await.unwrap();
assert_eq!((f.containers(), f.called(" up --detach ")), (Containers::Up, ups));
}
#[tokio::test]
async fn nothing_left_to_resume_drops_the_parked_mark() {
let f = fixture();
let lab = f.runner.open(&f.manifest, None).unwrap();
lab.up(&opts(), &Recorder::new()).await.unwrap();
lab.park(Park::Shutdown, &Recorder::new()).await.unwrap();
f.set(Containers::None);
let err = lab.resume(&RunOptions::default(), &Recorder::new()).await.unwrap_err();
assert_eq!(err.code(), crate::ErrorCode::NothingToResume);
assert_eq!(f.runner.store.record(lab.name()).unwrap().unwrap().state, RunState::Absent);
}
#[tokio::test]
async fn leftovers_are_removed_before_a_start() {
let f = fixture();
f.set(Containers::Stopped);
let lab = f.runner.open(&f.manifest, None).unwrap();
let events = Recorder::new();
lab.up(&opts(), &events).await.unwrap();
let lines = f.fake.lines();
let down = lines.iter().position(|l| l.contains(" down --volumes")).unwrap();
let up = lines.iter().position(|l| l.contains(" up --detach ")).unwrap();
assert!(down < up);
assert!(
kinds(&events)
.iter()
.any(|k| matches!(k, EventKind::Warning { code, .. } if *code == crate::WarningCode::LeftoversRemoved))
);
}
#[tokio::test]
async fn a_park_of_a_run_that_isnt_there_is_refused() {
let f = fixture();
let lab = f.runner.open(&f.manifest, None).unwrap();
let err = lab.park(Park::Shutdown, &Recorder::new()).await.unwrap_err();
assert_eq!(err.code(), crate::ErrorCode::NotRunning);
}
#[tokio::test]
async fn a_failed_start_leaves_the_run_failed_with_the_steps_error() {
let f = fixture();
let lab = f.runner.open(&f.manifest, None).unwrap();
let events = Recorder::new();
let fail = Arc::new(FakeProcesses::rules(&[(" up --detach ", Reply::fail(1, "Error: pull access denied"))]));
let mut config = f.runner.config.clone();
config.processes = fail;
let runner = Runner::new(config).unwrap();
let lab2 = runner.open(lab.path(), None).unwrap();
let err = lab2.up(&opts(), &events).await.unwrap_err();
assert_eq!(err.to_string(), "step `up` failed: Error: pull access denied (exited with code 1)");
assert!(kinds(&events).contains(&EventKind::Step {
step: "job-web-init-1".into(),
state: StepState::Skipped,
note: None
}));
assert!(matches!(kinds(&events).last(), Some(EventKind::Error { step: Some(s), .. }) if s == "up"));
assert_eq!(runner.store.record(lab2.name()).unwrap().unwrap().state, RunState::Failed);
assert_eq!(runner.store.record(lab2.name()).unwrap().unwrap().last.unwrap().op, Op::Up);
}
#[tokio::test]
async fn a_stopped_engine_fails_the_start_before_anything_is_created() {
let f = fixture();
let mut config = f.runner.config.clone();
let fake = Arc::new(FakeProcesses::rules(&[("docker info", Reply::fail(1, "Cannot connect to the Docker daemon"))]));
config.processes = crate::testing::processes(&fake);
let runner = Runner::new(config).unwrap();
let lab = runner.open(&f.manifest, None).unwrap();
let err = lab.up(&opts(), &Recorder::new()).await.unwrap_err();
assert_eq!(
err.to_string(),
"step `tools` failed: `docker` doesn't answer: Cannot connect to the Docker daemon (exited with code 1)"
);
assert_eq!(fake.lines(), ["docker info --format {{.ServerVersion}}"], "nothing but the probe ran");
}
#[tokio::test]
async fn a_lab_without_provision_steps_isnt_provisioned_again() {
let f = fixture();
let lab = f.runner.open(&f.manifest, None).unwrap();
lab.up(&opts(), &Recorder::new()).await.unwrap();
let err = lab.provision(None, &opts(), &Recorder::new()).await.unwrap_err();
assert_eq!(err.code(), crate::ErrorCode::Unsupported);
assert!(err.to_string().contains("no provision steps"), "{err}");
}