use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use futures::{SinkExt, StreamExt};
use http_body_util::BodyExt;
use kranz_engine::control;
use kranz_engine::event_log::{EventLog, LockForce};
use kranz_engine::events::EventKind;
use kranz_engine::paths::MissionPaths;
use kranz_engine::types::{
Assertion, AssertionCheck, ControlCommand, MissionConfig, Plan, PlanFeature, PlanMilestone,
Role, RunResult, TokenUsage,
};
use serde_json::{json, Value};
use std::io::Write as _;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpStream;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use tower::ServiceExt;
const MISSION_ID: &str = "m-01";
const TEST_TOKEN: &str = "server-test-token";
const WAIT: Duration = Duration::from_secs(10);
fn authority(token: &str) -> kranz_server::MutationAuthority {
kranz_server::MutationAuthority::new(token).unwrap()
}
async fn exchange_read_token(app: &axum::Router, credential: &str) -> String {
let response = app
.clone()
.oneshot(
Request::get("/api/read-token")
.header(kranz_server::TOKEN_HEADER, credential)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body: Value =
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap();
body["token"].as_str().unwrap().to_owned()
}
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
fn sample_plan() -> Plan {
Plan {
goal: "Ship the demo".into(),
validation_contract: vec![Assertion {
id: "a-1".into(),
statement: "cargo test passes".into(),
check: AssertionCheck::Command,
command: Some("cargo test".into()),
negative_control: None,
pty_script: None,
}],
milestones: vec![PlanMilestone {
title: "M1".into(),
features: vec![PlanFeature {
title: "F1".into(),
spec: "build the thing".into(),
validation_criteria: vec!["it works".into()],
}],
}],
considered_alternatives: None,
command_grants: vec![],
touch_set: vec![],
standards_manifest: None,
reviewer_independence: None,
}
}
fn seed_mission(repo_root: &Path) -> MissionPaths {
let paths = MissionPaths::new(repo_root, MISSION_ID);
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::MissionCreated {
goal: "Ship the demo".into(),
base_branch: "main".into(),
mission_branch: format!("kranz/mission-{MISSION_ID}"),
config: MissionConfig::default(),
})
.unwrap();
log.append(EventKind::PlanApproved {
plan: sample_plan(),
base_sha: None,
})
.unwrap();
log.append(EventKind::MilestoneStarted {
milestone_id: "ms-1".into(),
start_sha: "abc1234".into(),
})
.unwrap();
log.append(EventKind::FeatureStarted {
feature_id: "f-1-1".into(),
})
.unwrap();
log.append(EventKind::WorkerSpawned {
backend: None,
run_id: "run-1".into(),
role: Role::Worker,
feature_id: Some("f-1-1".into()),
milestone_id: Some("ms-1".into()),
candidate: None,
executor_route: None,
sdk_session_id: "00000000-0000-0000-0000-000000000001".into(),
model: "sonnet".into(),
quant: "n/a".into(),
weight_hash: None,
prompt_hash: "deadbeef".into(),
transcript_path: MissionPaths::transcript_rel("run-1"),
})
.unwrap();
log.append(EventKind::WorkerMessage {
run_id: "run-1".into(),
tag: "text".into(),
content: "working on it".into(),
})
.unwrap();
log.append(EventKind::WorkerCompleted {
run_id: "run-1".into(),
result: RunResult::Pass,
tokens: TokenUsage {
input: 100,
output: 50,
cache_read: 0,
cache_write: 0,
},
cost_usd: Some(0.5),
report: None,
})
.unwrap();
drop(log); paths
}
fn seed_approved_mission(repo_root: &Path, id: &str) -> MissionPaths {
let paths = MissionPaths::new(repo_root, id);
let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::MissionCreated {
goal: "Ship the demo".into(),
base_branch: "main".into(),
mission_branch: format!("kranz/mission-{id}"),
config: MissionConfig::default(),
})
.unwrap();
log.append(EventKind::PlanApproved {
plan: sample_plan(),
base_sha: None,
})
.unwrap();
drop(log); paths
}
static ENV_ISOLATION: std::sync::Once = std::sync::Once::new();
fn isolate_git_env() {
ENV_ISOLATION.call_once(|| {
let missing = std::env::temp_dir().join(format!(
"kranz-server-test-no-config-{}",
std::process::id()
));
std::env::set_var("GIT_CONFIG_GLOBAL", &missing);
std::env::set_var("GIT_CONFIG_SYSTEM", &missing);
if let Ok(ceiling) = std::fs::canonicalize(std::env::temp_dir()) {
std::env::set_var("GIT_CEILING_DIRECTORIES", ceiling);
}
});
}
fn git_available() -> bool {
std::process::Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn setup() -> bool {
isolate_git_env();
if git_available() {
true
} else {
kranz_engine::test_capability::skip(
kranz_engine::test_capability::capability::GIT,
"git is not on PATH",
);
false
}
}
fn raw_git(dir: &Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn interpret_head_trailers(dir: &Path) -> String {
let message = raw_git(dir, &["log", "-1", "--format=%B"]);
let mut child = std::process::Command::new("git")
.args(["interpret-trailers", "--parse"])
.current_dir(dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn git interpret-trailers");
child
.stdin
.as_mut()
.expect("stdin piped")
.write_all(message.as_bytes())
.expect("write commit message");
let out = child.wait_with_output().expect("wait for trailers");
assert!(
out.status.success(),
"git interpret-trailers failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn init_repo() -> (tempfile::TempDir, PathBuf, String) {
let dir = tempfile::tempdir().expect("create tempdir");
let init = std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(dir.path())
.output()
.expect("spawn git init");
if !init.status.success() {
raw_git(dir.path(), &["init"]);
raw_git(dir.path(), &["symbolic-ref", "HEAD", "refs/heads/main"]);
}
raw_git(dir.path(), &["config", "user.name", "test"]);
raw_git(dir.path(), &["config", "user.email", "test@example.com"]);
std::fs::write(dir.path().join("README.md"), "seed\n").unwrap();
let gate_path = dir.path().join(kranz_engine::merge_gate::MERGE_GATES_PATH);
std::fs::create_dir_all(gate_path.parent().unwrap()).unwrap();
std::fs::write(
gate_path,
"{\"gates\":[{\"command\":\"cargo fmt --all --check\"},{\"command\":\"cargo test --workspace\"}]}\n",
)
.unwrap();
raw_git(dir.path(), &["add", "-A"]);
raw_git(dir.path(), &["commit", "-m", "seed"]);
let root = std::fs::canonicalize(dir.path()).expect("canonicalize repo root");
let base_sha = raw_git(&root, &["rev-parse", "HEAD"]).trim().to_string();
(dir, root, base_sha)
}
fn seed_diffable_mission(
repo_root: &Path,
id: &str,
base_sha: &str,
create_branch: bool,
) -> MissionPaths {
let paths = MissionPaths::new(repo_root, id);
let branch = format!("kranz/mission-{id}");
let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::MissionCreated {
goal: "Ship the demo".into(),
base_branch: "main".into(),
mission_branch: branch.clone(),
config: MissionConfig::default(),
})
.unwrap();
log.append(EventKind::PlanApproved {
plan: sample_plan(),
base_sha: Some(base_sha.to_string()),
})
.unwrap();
drop(log);
if create_branch {
raw_git(repo_root, &["checkout", "-b", &branch, base_sha]);
std::fs::write(repo_root.join("feature.txt"), "new feature\n").unwrap();
raw_git(repo_root, &["add", "--", "feature.txt"]);
raw_git(repo_root, &["commit", "-m", "add feature"]);
raw_git(repo_root, &["checkout", "main"]);
}
paths
}
fn mark_mission_complete(paths: &MissionPaths, id: &str) {
let mut log = EventLog::acquire(paths, id, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::MissionCompleted {}).unwrap();
}
fn fixture() -> (tempfile::TempDir, PathBuf, MissionPaths, axum::Router) {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().to_path_buf();
let paths = seed_mission(&repo_root);
let app = kranz_server::router_with_token(repo_root.clone(), None, authority(TEST_TOKEN));
(tmp, repo_root, paths, app)
}
async fn get_json(app: &axum::Router, uri: &str) -> (StatusCode, Value) {
let response = app
.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap()
};
(status, value)
}
#[tokio::test]
async fn health_and_root_info() {
let tmp = tempfile::tempdir().unwrap();
let app = kranz_server::router(tmp.path().to_path_buf(), None);
let (status, body) = get_json(&app, "/api/health").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["ok"], true);
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
let response = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert!(std::str::from_utf8(&bytes).unwrap().contains("kranz"));
}
#[tokio::test]
async fn missions_list_folds_and_tolerates_corrupt_logs() {
let (_tmp, repo_root, _paths, app) = fixture();
let bad_dir = repo_root.join(".kranz").join("missions").join("zz-bad");
std::fs::create_dir_all(&bad_dir).unwrap();
std::fs::write(bad_dir.join("events.jsonl"), "not json\nstill not json\n").unwrap();
let (status, body) = get_json(&app, "/api/missions").await;
assert_eq!(status, StatusCode::OK);
let rows = body.as_array().unwrap();
assert_eq!(rows.len(), 2);
let good = rows.iter().find(|r| r["id"] == MISSION_ID).unwrap();
assert_eq!(good["status"], "running");
assert_eq!(good["goal"], "Ship the demo");
assert!(good["createdAt"].is_string());
let bad = rows.iter().find(|r| r["id"] == "zz-bad").unwrap();
assert_eq!(bad["status"], "failed");
assert!(bad["error"].is_string());
}
#[tokio::test]
async fn missions_forged_orphan_renders_placeholder() {
let (_tmp, repo_root, _paths, app) = fixture();
let missions_dir = repo_root.join(".kranz").join("missions");
std::fs::create_dir_all(&missions_dir).unwrap();
std::fs::write(
missions_dir.join("index.md"),
"# Kranz missions\n\n- [m-ghost](m-ghost/plan.md)\n",
)
.unwrap();
let (status, body) = get_json(&app, "/api/missions").await;
assert_eq!(status, StatusCode::OK);
let rows = body.as_array().unwrap();
let ghost = rows.iter().find(|r| r["id"] == "m-ghost").unwrap();
assert_eq!(ghost["status"], "deleted");
assert_eq!(ghost["goal"], "deleted mission (no data recorded)");
}
#[tokio::test]
async fn approved_status_shows_for_a_plan_approved_mission_with_no_run_loop() {
let (_tmp, repo_root, _paths, app) = fixture();
seed_approved_mission(&repo_root, "m-02");
let (status, body) = get_json(&app, "/api/missions").await;
assert_eq!(status, StatusCode::OK);
let rows = body.as_array().unwrap();
let approved = rows.iter().find(|r| r["id"] == "m-02").unwrap();
assert_eq!(approved["status"], "approved");
assert_ne!(approved["status"], "running");
}
#[tokio::test]
async fn state_is_folded_from_the_log() {
let (_tmp, _repo_root, _paths, app) = fixture();
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/state")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["mission"]["id"], MISSION_ID);
assert_eq!(body["mission"]["status"], "running");
assert_eq!(body["mission"]["baseBranch"], "main");
assert_eq!(
body["mission"]["missionBranch"],
format!("kranz/mission-{MISSION_ID}")
);
assert_eq!(body["mission"]["milestones"][0]["id"], "ms-1");
assert_eq!(
body["mission"]["milestones"][0]["features"][0]["status"],
"active"
);
assert_eq!(body["lastSeq"], 7);
assert_eq!(body["totalCostUsd"], 0.5);
assert_eq!(body["totals"]["cacheRead"], 0);
assert_eq!(body["runs"]["run-1"]["costUsd"], 0.5);
assert_eq!(body["runs"]["run-1"]["result"], "pass");
}
#[tokio::test]
async fn events_since_filters_by_seq() {
let (_tmp, _repo_root, _paths, app) = fixture();
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/events")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.as_array().unwrap().len(), 7);
let (status, body) =
get_json(&app, &format!("/api/missions/{MISSION_ID}/events?since=2")).await;
assert_eq!(status, StatusCode::OK);
let events = body.as_array().unwrap();
assert_eq!(events.len(), 5);
assert_eq!(events[0]["seq"], 3);
assert!(events.iter().all(|e| e["seq"].as_u64().unwrap() > 2));
let (status, body) = get_json(
&app,
&format!("/api/missions/{MISSION_ID}/events?since=nope"),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body["error"].is_string());
}
#[tokio::test]
async fn plan_404_until_plan_json_exists() {
let (_tmp, _repo_root, paths, app) = fixture();
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/plan")).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body["error"].is_string());
std::fs::write(
paths.plan_file(),
serde_json::to_string_pretty(&sample_plan()).unwrap(),
)
.unwrap();
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/plan")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["goal"], "Ship the demo");
assert_eq!(body["milestones"][0]["features"][0]["title"], "F1");
}
#[tokio::test]
async fn plan_md_404_until_file_exists_then_returns_markdown() {
let (_tmp, _repo_root, paths, app) = fixture();
let uri = format!("/api/missions/{MISSION_ID}/plan.md");
let (status, body) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body["error"].is_string());
std::fs::write(paths.plan_md_file(), "# Plan\n\nGoal: ship it\n").unwrap();
let (status, body) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["markdown"], "# Plan\n\nGoal: ship it\n");
}
#[tokio::test]
async fn report_md_404_until_file_exists_then_returns_markdown() {
let (_tmp, _repo_root, paths, app) = fixture();
let uri = format!("/api/missions/{MISSION_ID}/report.md");
let (status, body) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body["error"].is_string());
std::fs::write(paths.report_file(), "# Report\n\nAll green\n").unwrap();
let (status, body) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["markdown"], "# Report\n\nAll green\n");
}
#[tokio::test]
async fn workspace_summary_surfaces_isolation_sandbox_and_preflight_without_grant_values() {
let (_tmp, repo_root, paths, app) = fixture();
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::OrchestratorDecision {
summary: "preflight: 1 issue(s): [warn] cargo missing".into(),
detail: None,
})
.unwrap();
}
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["isolation"], "worktree");
assert_eq!(body["lifecycle"], "removed");
assert_eq!(body["worktreeActive"], false);
assert_eq!(
body["cwd"],
kranz_engine::orchestrator::mission_worktree_path(&repo_root, MISSION_ID)
.to_string_lossy()
.as_ref()
);
assert_eq!(body["sandboxes"][0]["role"], "worker");
assert_eq!(body["sandboxes"][0]["enforce"], "off");
assert_eq!(body["sandboxes"][0]["extraWriteCount"], 0);
assert_eq!(body["sandboxes"][0]["egressCount"], 0);
assert_eq!(body["preflight"]["status"], "issues");
assert!(body["preflight"]["summary"]
.as_str()
.unwrap()
.contains("cargo missing"));
assert!(!body
.to_string()
.contains(repo_root.to_string_lossy().as_ref()));
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::OrchestratorDecision {
summary: kranz_engine::preflight::PREFLIGHT_CLEAR_SUMMARY.into(),
detail: None,
})
.unwrap();
}
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["preflight"]["status"], "clear");
assert_eq!(
body["preflight"]["summary"],
kranz_engine::preflight::PREFLIGHT_CLEAR_SUMMARY
);
assert_eq!(body["contract"]["present"], false);
assert_eq!(body["contract"]["services"], 0);
assert_eq!(body["contract"]["previews"], 0);
assert_eq!(body["contract"]["bootstrap"], serde_json::Value::Null);
assert_eq!(body["contract"]["readiness"], serde_json::Value::Null);
let kranz_dir = repo_root.join(".kranz");
std::fs::create_dir_all(&kranz_dir).unwrap();
std::fs::write(
kranz_dir.join("workspace.json"),
br#"{
"schemaVersion": 1,
"services": [{"name": "db", "start": "docker compose up db", "port": {"policy": "dynamic"}}],
"previews": [{"name": "app", "urlTemplate": "http://localhost:{port}/"}]
}"#,
)
.unwrap();
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["contract"]["present"], true);
assert_eq!(body["contract"]["services"], 1);
assert_eq!(body["contract"]["previews"], 1);
assert_eq!(body["contract"]["bootstrap"], serde_json::Value::Null);
assert_eq!(body["contract"]["readiness"], serde_json::Value::Null);
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::OrchestratorDecision {
summary: "workspace bootstrap: 3/3 commands ok".into(),
detail: None,
})
.unwrap();
log.append(EventKind::OrchestratorDecision {
summary:
"workspace readiness: FAILED at check 1/2 — blocking mission (owner: repo-setup)"
.into(),
detail: None,
})
.unwrap();
}
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["contract"]["bootstrap"]["summary"],
"workspace bootstrap: 3/3 commands ok"
);
assert!(body["contract"]["bootstrap"]["eventSeq"].is_u64());
assert_eq!(
body["contract"]["readiness"]["summary"],
"workspace readiness: FAILED at check 1/2 — blocking mission (owner: repo-setup)"
);
}
#[tokio::test]
async fn workspace_summary_surfaces_provider_pin_previews_and_takeover() {
let (_tmp, repo_root, paths, app) = fixture();
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["pin"], serde_json::Value::Null);
assert_eq!(body["previews"], serde_json::Value::Null);
assert_eq!(body["takeover"], serde_json::Value::Null);
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::WorkspaceProviderPinned {
provider: "local-worktree".into(),
template: "worktree".into(),
version: "1".into(),
})
.unwrap();
}
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["pin"],
json!({"provider": "local-worktree", "template": "worktree", "version": "1"})
);
let takeover = body["takeover"].as_str().expect("takeover line");
assert!(
takeover.starts_with("work locally in the workspace cwd"),
"plain-truth local takeover, no SSH fiction: {takeover}"
);
let kranz_dir = repo_root.join(".kranz");
std::fs::create_dir_all(&kranz_dir).unwrap();
std::fs::write(
kranz_dir.join("workspace.json"),
br#"{
"schemaVersion": 1,
"previews": [{"name": "app", "urlTemplate": "http://localhost:{port}/"}]
}"#,
)
.unwrap();
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["previews"],
serde_json::Value::Null,
"no readiness pass ⇒ no preview URLs (never fabricated)"
);
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::WorkspaceReadinessReport {
outcome: "ready".into(),
detail: None,
})
.unwrap();
}
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["previews"],
json!([{"name": "app", "urlTemplate": "http://localhost:{port}/"}]),
"readiness pass ⇒ the placeholder urlTemplates surface UNFILLED"
);
}
#[tokio::test]
async fn workspace_summary_surfaces_remote_takeover_and_previews() {
let (_tmp, _repo_root, paths, app) = fixture();
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::WorkspaceProviderPinned {
provider: "remote".into(),
template: "tmpl-baked-ami".into(),
version: "coder-v1".into(),
})
.unwrap();
}
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["pin"],
json!({"provider": "remote", "template": "tmpl-baked-ami", "version": "coder-v1"})
);
assert_eq!(
body["takeover"],
serde_json::Value::Null,
"no provision yet ⇒ no takeover fiction"
);
assert_eq!(body["previews"], serde_json::Value::Null);
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::WorkspaceProvisioned {
provider: "remote".into(),
cwd: "/tmp/m-01_integration".into(),
detail: Some(
"substrate workspace kranz-remote-m-01 (id ws-1); injected env names: DATABASE_URL"
.into(),
),
takeover: Some("https://coder.example.com/@me/ws-1".into()),
previews: Some(vec![kranz_engine::types::ProvisionedPreview {
name: "app".into(),
url: "https://app--m-01.coder.example.com".into(),
auth: Some(true),
}]),
})
.unwrap();
}
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["takeover"], "https://coder.example.com/@me/ws-1");
assert_eq!(
body["previews"],
serde_json::Value::Null,
"no readiness pass ⇒ previews stay gated"
);
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::WorkspaceReadinessReport {
outcome: "ready".into(),
detail: None,
})
.unwrap();
}
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/workspace")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["previews"],
json!([{"name": "app", "url": "https://app--m-01.coder.example.com", "auth": true}]),
"the remote-kind shape: substrate-reported name/url/auth, never the contract template"
);
let takeover = body["takeover"].as_str().expect("takeover line");
assert!(
!takeover.starts_with("work locally"),
"the local-kind takeover line never leaks into a remote mission: {takeover}"
);
}
#[tokio::test]
async fn diff_stat_returns_stat_baseline_and_tip_when_diffable() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
seed_diffable_mission(&repo_root, "m-diff", &base_sha, true);
let app = kranz_server::router(repo_root.clone(), None);
let (status, body) = get_json(&app, "/api/missions/m-diff/diff-stat").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["baseSha"], base_sha);
let tip = raw_git(&repo_root, &["rev-parse", "kranz/mission-m-diff"])
.trim()
.to_string();
assert_eq!(body["tip"], tip);
let diff_stat = body["diffStat"].as_str().unwrap();
assert!(
diff_stat.contains("feature.txt"),
"diffStat should mention the changed file: {diff_stat}"
);
}
#[tokio::test]
async fn diff_stat_404s_when_mission_has_no_pinned_base_sha() {
let (_tmp, _repo_root, _paths, app) = fixture();
let (status, body) = get_json(&app, &format!("/api/missions/{MISSION_ID}/diff-stat")).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body["error"].is_string());
}
#[tokio::test]
async fn diff_stat_404s_when_mission_branch_does_not_exist() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
seed_diffable_mission(&repo_root, "m-nobranch", &base_sha, false);
let app = kranz_server::router(repo_root.clone(), None);
let (status, body) = get_json(&app, "/api/missions/m-nobranch/diff-stat").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body["error"].is_string());
}
#[tokio::test]
async fn diff_stat_500s_when_pinned_base_sha_does_not_resolve() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
let bogus_base = "0000000000000000000000000000000000000000";
seed_diffable_mission(&repo_root, "m-badbase", bogus_base, false);
let branch = "kranz/mission-m-badbase";
raw_git(&repo_root, &["checkout", "-b", branch, &base_sha]);
std::fs::write(repo_root.join("feature.txt"), "new feature\n").unwrap();
raw_git(&repo_root, &["add", "--", "feature.txt"]);
raw_git(&repo_root, &["commit", "-m", "add feature"]);
raw_git(&repo_root, &["checkout", "main"]);
let app = kranz_server::router(repo_root.clone(), None);
let (status, body) = get_json(&app, "/api/missions/m-badbase/diff-stat").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(body["error"].is_string());
}
#[tokio::test]
async fn missions_list_reports_merged_true_when_branch_is_merged_into_base() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
seed_diffable_mission(&repo_root, "m-merged", &base_sha, true);
raw_git(&repo_root, &["checkout", "main"]);
raw_git(
&repo_root,
&["merge", "--no-ff", "--no-edit", "kranz/mission-m-merged"],
);
let app = kranz_server::router(repo_root.clone(), None);
let (status, body) = get_json(&app, "/api/missions").await;
assert_eq!(status, StatusCode::OK);
let rows = body.as_array().unwrap();
let row = rows.iter().find(|r| r["id"] == "m-merged").unwrap();
assert_eq!(row["merged"], true);
}
#[tokio::test]
async fn missions_list_reports_merged_false_when_branch_is_not_merged() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
seed_diffable_mission(&repo_root, "m-unmerged", &base_sha, true);
let app = kranz_server::router(repo_root.clone(), None);
let (status, body) = get_json(&app, "/api/missions").await;
assert_eq!(status, StatusCode::OK);
let rows = body.as_array().unwrap();
let row = rows.iter().find(|r| r["id"] == "m-unmerged").unwrap();
assert_eq!(row["merged"], false);
}
#[tokio::test]
async fn missions_list_reports_merged_null_when_mission_has_no_branch() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
seed_diffable_mission(&repo_root, "m-nobranch-list", &base_sha, false);
let app = kranz_server::router(repo_root.clone(), None);
let (status, body) = get_json(&app, "/api/missions").await;
assert_eq!(status, StatusCode::OK);
let rows = body.as_array().unwrap();
let row = rows.iter().find(|r| r["id"] == "m-nobranch-list").unwrap();
assert!(row["merged"].is_null());
}
#[tokio::test]
async fn missions_list_still_returns_when_repo_root_is_not_a_git_repo() {
let (_tmp, _repo_root, _paths, app) = fixture();
let (status, body) = get_json(&app, "/api/missions").await;
assert_eq!(status, StatusCode::OK);
let rows = body.as_array().unwrap();
let row = rows.iter().find(|r| r["id"] == MISSION_ID).unwrap();
assert!(row["merged"].is_null());
}
#[tokio::test]
async fn plan_md_and_report_md_reject_traversal_ids() {
let (_tmp, _repo_root, _paths, app) = fixture();
for uri in [
"/api/missions/%2e%2e/plan.md",
"/api/missions/%2e%2e/report.md",
] {
let (status, body) = get_json(&app, uri).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{uri}");
assert!(body["error"].is_string(), "{uri}");
}
}
#[tokio::test]
async fn transcript_404_then_parsed_jsonl_array() {
let (_tmp, _repo_root, paths, app) = fixture();
let uri = format!("/api/missions/{MISSION_ID}/runs/run-1/transcript");
let (status, body) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body["error"].is_string());
std::fs::write(
paths.transcript_file("run-1"),
"{\"type\":\"system\",\"subtype\":\"init\"}\n{\"type\":\"result\",\"is_error\":false}\n",
)
.unwrap();
let (status, body) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::OK);
let lines = body.as_array().unwrap();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["type"], "system");
assert_eq!(lines[1]["is_error"], false);
}
#[tokio::test]
async fn control_post_enqueues_a_drainable_command() {
let (_tmp, _repo_root, paths, app) = fixture();
let uri = format!("/api/missions/{MISSION_ID}/control");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(
r#"{"kind":"msg","text":"focus on tests","interrupt":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["queued"], true);
let commands = control::drain(&paths).unwrap();
assert_eq!(commands.len(), 1);
match &commands[0].1 {
ControlCommand::Msg { text, interrupt } => {
assert_eq!(text, "focus on tests");
assert!(!interrupt);
}
other => panic!("unexpected command: {other:?}"),
}
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(r#"{"kind":"bogus"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: Value = serde_json::from_slice(&bytes).unwrap();
assert!(body["error"].is_string());
}
#[tokio::test]
async fn question_events_answer_route_enqueues_and_validates() {
let (_tmp, _repo_root, paths, app) = fixture();
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::QuestionOpened {
question_id: "q-1".into(),
role: Role::Worker,
text: "Which storage engine should the cache use?".into(),
options: vec!["sqlite".into(), "in-memory".into()],
run_id: Some("run-1".into()),
feature_id: Some("f-1-1".into()),
milestone_id: Some("ms-1".into()),
})
.unwrap();
}
let (status, state) = get_json(&app, &format!("/api/missions/{MISSION_ID}/state")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
state["pendingQuestions"][0]["questionId"], "q-1",
"the pending-decision projection rides the state payload: {state}"
);
let uri = format!("/api/missions/{MISSION_ID}/question/answer");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(
r#"{"questionId":"q-1","answer":"sqlite","option":0}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
let commands = control::drain(&paths).unwrap();
assert_eq!(commands.len(), 1);
match &commands[0].1 {
ControlCommand::AnswerQuestion {
question_id,
answer,
option,
} => {
assert_eq!(question_id, "q-1");
assert_eq!(answer, "sqlite");
assert_eq!(*option, Some(0));
}
other => panic!("unexpected command: {other:?}"),
}
control::acknowledge(&paths, &commands[0].0).unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(
r#"{"questionId":"q-1","answer":"use postgres"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
let commands = control::drain(&paths).unwrap();
assert_eq!(commands.len(), 1);
control::acknowledge(&paths, &commands[0].0).unwrap();
for body in [
r#"{"questionId":"q-nope","answer":"sqlite","option":0}"#,
r#"{"questionId":"q-1","answer":"sqlite","option":9}"#,
r#"{"questionId":"q-1","answer":"in-memory","option":0}"#,
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT, "body: {body}");
}
assert!(
control::drain(&paths).unwrap().is_empty(),
"stale answers must not enqueue"
);
}
#[tokio::test]
async fn control_post_rejects_an_invalid_config_change_before_enqueueing() {
let (_tmp, _repo_root, paths, app) = fixture();
let uri = format!("/api/missions/{MISSION_ID}/control");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(
r#"{"kind":"config-change","patch":{"worker":{"backend":"droid","model":"accounts/fireworks/models/glm-5p2"}}}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: Value = serde_json::from_slice(&bytes).unwrap();
let error = body["error"].as_str().unwrap();
assert!(error.contains("below the default worker tier"), "{error}");
assert!(
error.contains("allowBelowDefaultWorkerModel=true"),
"{error}"
);
assert!(control::drain(&paths).unwrap().is_empty());
}
#[tokio::test]
async fn control_post_enqueues_a_valid_backend_selection() {
let (_tmp, _repo_root, paths, app) = fixture();
let uri = format!("/api/missions/{MISSION_ID}/control");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(
r#"{"kind":"config-change","patch":{"worker":{"backend":"codex","model":"gpt-5-codex"}}}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
let commands = control::drain(&paths).unwrap();
assert_eq!(commands.len(), 1);
match &commands[0].1 {
ControlCommand::ConfigChange { patch } => {
assert_eq!(patch["worker"]["backend"], "codex");
assert_eq!(patch["worker"]["model"], "gpt-5-codex");
}
other => panic!("expected a ConfigChange, got {other:?}"),
}
}
#[tokio::test]
async fn control_post_rejects_terminal_mission() {
let (_tmp, _repo_root, paths, app) = fixture();
let uri = format!("/api/missions/{MISSION_ID}/control");
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::MissionCompleted {}).unwrap();
}
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(r#"{"kind":"pause"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: Value = serde_json::from_slice(&bytes).unwrap();
assert!(
body["error"].as_str().unwrap().contains("Complete"),
"{body}"
);
assert!(
control::drain(&paths).unwrap().is_empty(),
"terminal control must not enqueue"
);
}
#[tokio::test]
async fn control_post_terminal_probe_survives_logs_larger_than_the_tail_window() {
let (_tmp, _repo_root, paths, app) = fixture();
let uri = format!("/api/missions/{MISSION_ID}/control");
let post = |body: &'static str| {
Request::builder()
.method("POST")
.uri(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(body))
.unwrap()
};
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
let filler = "x".repeat(256);
for _ in 0..400 {
log.append(EventKind::WorkerMessage {
run_id: "run-1".into(),
tag: "text".into(),
content: filler.clone(),
})
.unwrap();
}
}
let response = app
.clone()
.oneshot(post(r#"{"kind":"pause"}"#))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::MissionCompleted {}).unwrap();
}
let response = app
.clone()
.oneshot(post(r#"{"kind":"pause"}"#))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
const ALLOW_ORIGIN: &str = "access-control-allow-origin";
fn preflight(uri: &str, origin: &str) -> Request<Body> {
Request::builder()
.method("OPTIONS")
.uri(uri)
.header("origin", origin)
.header("access-control-request-method", "POST")
.header("access-control-request-headers", "content-type")
.body(Body::empty())
.unwrap()
}
fn get_with_origin(uri: &str, origin: &str) -> Request<Body> {
Request::builder()
.uri(uri)
.header("origin", origin)
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn cors_denies_foreign_origins() {
let (_tmp, _repo_root, paths, app) = fixture();
let control_uri = format!("/api/missions/{MISSION_ID}/control");
let state_uri = format!("/api/missions/{MISSION_ID}/state");
for origin in [
"https://evil.example",
"http://localhost.evil.example",
"http://127.0.0.1.evil.example:5173",
] {
let response = app
.clone()
.oneshot(preflight(&control_uri, origin))
.await
.unwrap();
assert!(
response.headers().get(ALLOW_ORIGIN).is_none(),
"preflight from {origin} must not be approved"
);
let response = app
.clone()
.oneshot(get_with_origin(&state_uri, origin))
.await
.unwrap();
assert!(
response.headers().get(ALLOW_ORIGIN).is_none(),
"GET response for {origin} must not be readable cross-origin"
);
}
assert!(control::drain(&paths).unwrap().is_empty());
}
#[tokio::test]
async fn cors_allows_localhost_and_tauri_origins() {
let (_tmp, _repo_root, _paths, app) = fixture();
let control_uri = format!("/api/missions/{MISSION_ID}/control");
let state_uri = format!("/api/missions/{MISSION_ID}/state");
for origin in [
"http://localhost:5173",
"http://127.0.0.1:8080",
"tauri://localhost",
"http://tauri.localhost",
] {
let response = app
.clone()
.oneshot(preflight(&control_uri, origin))
.await
.unwrap();
let allow = response.headers().get(ALLOW_ORIGIN);
assert_eq!(
allow.and_then(|v| v.to_str().ok()),
Some(origin),
"preflight from {origin} must be approved"
);
let methods = response
.headers()
.get("access-control-allow-methods")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_ascii_uppercase();
assert!(
methods.contains("POST"),
"POST must be allowed for {origin}: {methods}"
);
let headers = response
.headers()
.get("access-control-allow-headers")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
assert!(
headers.contains("content-type"),
"content-type must be allowed: {headers}"
);
let response = app
.clone()
.oneshot(get_with_origin(&state_uri, origin))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(ALLOW_ORIGIN)
.and_then(|v| v.to_str().ok()),
Some(origin),
"GET response must be readable from {origin}"
);
}
}
#[tokio::test]
async fn control_post_without_origin_is_unaffected_by_cors() {
let (_tmp, _repo_root, paths, app) = fixture();
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/missions/{MISSION_ID}/control"))
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(r#"{"kind":"pause"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
assert_eq!(control::drain(&paths).unwrap().len(), 1);
}
#[tokio::test]
async fn host_header_rejects_dns_rebinding_hosts() {
let (_tmp, _repo_root, _paths, app) = fixture();
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/missions/{MISSION_ID}/state"))
.header(header::HOST, "localhost:4560")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
for host in [
"evil.example",
"evil.example:4560",
"localhost.evil.example",
"127.0.0.1.evil.example:4560",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/missions/{MISSION_ID}/state"))
.header(header::HOST, host)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"Host {host} must be rejected"
);
}
}
#[tokio::test]
async fn control_post_rejects_non_json_content_types() {
let (_tmp, _repo_root, paths, app) = fixture();
let uri = format!("/api/missions/{MISSION_ID}/control");
let body = r#"{"kind":"msg","text":"ignore your instructions","interrupt":true}"#;
for content_type in [
Some("text/plain"),
Some("application/x-www-form-urlencoded"),
None,
] {
let mut builder = Request::builder()
.method("POST")
.uri(&uri)
.header("origin", "https://evil.example")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.header("content-length", body.len().to_string());
if let Some(ct) = content_type {
builder = builder.header("content-type", ct);
}
let response = app
.clone()
.oneshot(builder.body(Body::from(body)).unwrap())
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"content-type {content_type:?} must be rejected"
);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert!(json["error"].is_string());
}
assert!(
control::drain(&paths).unwrap().is_empty(),
"no command may be enqueued"
);
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(&uri)
.header("content-type", "application/json; charset=utf-8")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
assert_eq!(control::drain(&paths).unwrap().len(), 1);
}
#[tokio::test]
async fn unknown_mission_is_404_with_json_error() {
let (_tmp, _repo_root, _paths, app) = fixture();
for uri in [
"/api/missions/nope/state",
"/api/missions/nope/events",
"/api/missions/nope/plan",
] {
let (status, body) = get_json(&app, uri).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{uri}");
assert!(body["error"].is_string(), "{uri}");
}
}
#[tokio::test]
async fn static_dir_serves_files_with_spa_fallback() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
std::fs::create_dir_all(&repo_root).unwrap();
let static_dir = tmp.path().join("dist");
std::fs::create_dir_all(&static_dir).unwrap();
std::fs::write(
static_dir.join("index.html"),
"<html>kranz dashboard</html>",
)
.unwrap();
std::fs::write(static_dir.join("app.js"), "console.log('hi')").unwrap();
let app = kranz_server::router(repo_root, Some(static_dir));
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/app.js")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/missions/m-01")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert!(std::str::from_utf8(&bytes)
.unwrap()
.contains("kranz dashboard"));
}
#[tokio::test]
async fn api_responses_are_no_store_and_shell_is_no_cache() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
std::fs::create_dir_all(&repo_root).unwrap();
let static_dir = tmp.path().join("dist");
std::fs::create_dir_all(&static_dir).unwrap();
std::fs::write(
static_dir.join("index.html"),
"<html>kranz dashboard</html>",
)
.unwrap();
std::fs::write(static_dir.join("app.js"), "console.log('hi')").unwrap();
let app = kranz_server::router(repo_root, Some(static_dir));
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store"
);
let response = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"no-cache"
);
let response = app
.oneshot(
Request::builder()
.uri("/app.js")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(response.headers().get(header::CACHE_CONTROL).is_none());
}
#[tokio::test]
async fn embedded_static_serves_files_with_spa_fallback() {
static FILES: [kranz_server::EmbeddedFile; 2] = [
kranz_server::EmbeddedFile {
path: "index.html",
bytes: b"<html>embedded kranz dashboard</html>",
content_type: "text/html; charset=utf-8",
},
kranz_server::EmbeddedFile {
path: "assets/app.js",
bytes: b"console.log('embedded')",
content_type: "application/javascript",
},
];
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
std::fs::create_dir_all(&repo_root).unwrap();
let app = kranz_server::router_with_static(
repo_root,
Some(kranz_server::DashboardStatic::Embedded(&FILES)),
);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/assets/app.js")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"application/javascript"
);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/missions/m-01")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert!(std::str::from_utf8(&bytes)
.unwrap()
.contains("embedded kranz dashboard"));
}
async fn next_frame(ws: &mut WsStream) -> Value {
let msg = tokio::time::timeout(WAIT, ws.next())
.await
.expect("timed out waiting for a ws frame")
.expect("ws stream closed unexpectedly")
.expect("ws protocol error");
match msg {
WsMessage::Text(text) => serde_json::from_str(text.as_str()).expect("frame is not JSON"),
other => panic!("expected a text frame, got {other:?}"),
}
}
fn ws_request(
url: &str,
origin: Option<&str>,
) -> tokio_tungstenite::tungstenite::handshake::client::Request {
let mut request = url.into_client_request().unwrap();
if let Some(origin) = origin {
request
.headers_mut()
.insert("origin", origin.parse().unwrap());
}
request
}
async fn connect_allowed_ws(url: &str) -> WsStream {
let request = ws_request(url, Some("http://localhost:5173"));
let (ws, _) = tokio::time::timeout(WAIT, connect_async(request))
.await
.unwrap()
.unwrap();
ws
}
async fn spawn_server(app: axum::Router) -> SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
addr
}
#[tokio::test]
async fn ws_snapshot_tail_state_ping_and_since_replay() {
let (_tmp, _repo_root, paths, app) = fixture();
let addr = spawn_server(app).await;
let health: Value = tokio::time::timeout(WAIT, async {
reqwest::get(format!("http://{addr}/api/health"))
.await
.unwrap()
.json()
.await
})
.await
.unwrap()
.unwrap();
assert_eq!(health["ok"], true);
let url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws");
let mut ws = connect_allowed_ws(&url).await;
let frame = next_frame(&mut ws).await;
assert_eq!(frame["type"], "snapshot");
assert_eq!(frame["seq"], 7);
assert_eq!(frame["state"]["mission"]["id"], MISSION_ID);
assert_eq!(frame["state"]["lastSeq"], 7);
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::WorkerMessage {
run_id: "run-1".into(),
tag: "text".into(),
content: "one more thing".into(),
})
.unwrap();
log.append(EventKind::FeatureCompleted {
feature_id: "f-1-1".into(),
commits: vec!["cafe123".into()],
})
.unwrap();
}
let frame = next_frame(&mut ws).await;
assert_eq!(frame["type"], "event");
assert_eq!(frame["seq"], 8);
assert_eq!(frame["event"]["type"], "worker.message");
let frame = next_frame(&mut ws).await;
assert_eq!(frame["type"], "event");
assert_eq!(frame["seq"], 9);
assert_eq!(frame["event"]["type"], "feature.completed");
let frame = next_frame(&mut ws).await;
assert_eq!(frame["type"], "state");
assert_eq!(frame["seq"], 9);
assert_eq!(
frame["state"]["mission"]["milestones"][0]["features"][0]["status"],
"complete"
);
tokio::time::timeout(WAIT, ws.send(WsMessage::Text(r#"{"type":"ping"}"#.into())))
.await
.unwrap()
.unwrap();
let frame = next_frame(&mut ws).await;
assert_eq!(frame["type"], "pong");
let mut ws2 = connect_allowed_ws(&format!("{url}?since=7")).await;
let frame = next_frame(&mut ws2).await;
assert_eq!(frame["type"], "event");
assert_eq!(frame["seq"], 8);
let frame = next_frame(&mut ws2).await;
assert_eq!(frame["type"], "event");
assert_eq!(frame["seq"], 9);
}
#[tokio::test]
async fn ws_since_ahead_of_head_gets_fresh_snapshot() {
let (_tmp, _repo_root, _paths, app) = fixture();
let addr = spawn_server(app).await;
let url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws?since=999");
let mut ws = connect_allowed_ws(&url).await;
let frame = next_frame(&mut ws).await;
assert_eq!(frame["type"], "snapshot");
assert_eq!(frame["seq"], 7);
}
#[tokio::test]
async fn ws_rejects_missing_or_foreign_origin() {
let (_tmp, _repo_root, _paths, app) = fixture();
let addr = spawn_server(app).await;
let url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws");
let missing = tokio::time::timeout(WAIT, connect_async(&url))
.await
.unwrap();
assert!(missing.is_err(), "missing Origin must be rejected");
let foreign = tokio::time::timeout(
WAIT,
connect_async(ws_request(&url, Some("https://evil.example"))),
)
.await
.unwrap();
assert!(foreign.is_err(), "foreign Origin must be rejected");
}
#[tokio::test]
async fn ws_lan_mode_accepts_ip_origin_and_native_clients_with_token() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().to_path_buf();
let _paths = seed_mission(&repo_root);
let token = "lan-test-token";
let host = std::sync::Arc::new(kranz_server::MissionHost::new(repo_root));
let app = kranz_server::router_with_shared_host_and_bind(
host,
None,
authority(token),
Some(4560),
false, true, );
let read = exchange_read_token(&app, token).await;
let addr = spawn_server(app).await;
let url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws?token={read}");
let lan_origin = tokio::time::timeout(
WAIT,
connect_async(ws_request(&url, Some("http://192.168.1.5:4560"))),
)
.await
.unwrap();
assert!(
lan_origin.is_ok(),
"IP-literal Origin must upgrade off loopback: {:?}",
lan_origin.err()
);
let native = tokio::time::timeout(WAIT, connect_async(&url))
.await
.unwrap();
assert!(native.is_ok(), "missing Origin must upgrade off loopback");
let dns = tokio::time::timeout(
WAIT,
connect_async(ws_request(&url, Some("http://evil.example:4560"))),
)
.await
.unwrap();
assert!(dns.is_err(), "DNS-named Origin must be rejected");
let untokened_url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws");
let untokened = tokio::time::timeout(
WAIT,
connect_async(ws_request(&untokened_url, Some("http://192.168.1.5:4560"))),
)
.await
.unwrap();
assert!(
untokened.is_err(),
"upgrade without the read token must be rejected"
);
}
const READ_AUTH_TOKEN: &str = "read-auth-token";
fn read_auth_app(
bind_is_loopback: bool,
require_read_token: bool,
) -> (tempfile::TempDir, axum::Router) {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().to_path_buf();
seed_mission(&repo_root);
let host = std::sync::Arc::new(kranz_server::MissionHost::new(repo_root));
let app = kranz_server::router_with_shared_host_and_bind(
host,
None,
authority(READ_AUTH_TOKEN),
None,
bind_is_loopback,
require_read_token,
);
(tmp, app)
}
#[tokio::test]
async fn read_auth_loopback_rejects_tokenless_get() {
let (_tmp, app) = read_auth_app(true, true);
let (status, _) = get_json(&app, &format!("/api/missions/{MISSION_ID}/state")).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"tokenless GET must be rejected"
);
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/missions/{MISSION_ID}/state"))
.header(kranz_server::TOKEN_HEADER, READ_AUTH_TOKEN)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"header token must be accepted"
);
let read = exchange_read_token(&app, READ_AUTH_TOKEN).await;
let addr = spawn_server(app).await;
let mutation_url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws?token={READ_AUTH_TOKEN}");
let rejected = tokio::time::timeout(
WAIT,
connect_async(ws_request(&mutation_url, Some("http://localhost:5173"))),
)
.await
.unwrap();
assert!(
rejected.is_err(),
"mutation authority must not authenticate via a WS URL"
);
let tokened_url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws?token={read}");
let upgraded = tokio::time::timeout(
WAIT,
connect_async(ws_request(&tokened_url, Some("http://localhost:5173"))),
)
.await
.unwrap();
assert!(
upgraded.is_ok(),
"valid ?token= must upgrade: {:?}",
upgraded.err()
);
let untokened_url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws");
let rejected = tokio::time::timeout(
WAIT,
connect_async(ws_request(&untokened_url, Some("http://localhost:5173"))),
)
.await
.unwrap();
assert!(
rejected.is_err(),
"WS upgrade without the token must be rejected"
);
}
#[tokio::test]
async fn review_packet_uses_shared_projection_and_read_authority_without_mutation() {
let (tmp, app) = read_auth_app(true, false);
let uri = format!("/api/missions/{MISSION_ID}/review-packet");
let (status, _) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let read = exchange_read_token(&app, READ_AUTH_TOKEN).await;
let paths = MissionPaths::new(tmp.path(), MISSION_ID);
let before = std::fs::read(paths.events_file()).unwrap();
let response = app
.oneshot(
Request::get(uri)
.header(kranz_server::TOKEN_HEADER, read)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body: Value =
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap();
let expected =
kranz_engine::review_packet::compute_review_packet(tmp.path(), MISSION_ID).unwrap();
let mut expected = serde_json::to_value(expected).unwrap();
expected["observedAt"] = body["packet"]["observedAt"].clone();
assert_eq!(body["packet"], expected);
assert!(body["markdown"]
.as_str()
.unwrap()
.contains("Human review packet"));
assert_eq!(before, std::fs::read(paths.events_file()).unwrap());
assert!(control::drain(&paths).unwrap().is_empty());
}
#[tokio::test]
async fn human_review_report_is_authenticated_ephemeral_and_cannot_be_written_by_get() {
let (tmp, app) = read_auth_app(true, false);
let paths = MissionPaths::new(tmp.path(), MISSION_ID);
std::fs::write(paths.report_file(), "# Original report\n").unwrap();
let uri = format!("/api/missions/{MISSION_ID}/report.md?review=true");
let (status, _) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let read = exchange_read_token(&app, READ_AUTH_TOKEN).await;
let response = app
.oneshot(
Request::get(uri)
.header(kranz_server::TOKEN_HEADER, read)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body: Value =
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert!(body["markdown"]
.as_str()
.unwrap()
.contains("Human review packet"));
assert_eq!(
std::fs::read_to_string(paths.report_file()).unwrap(),
"# Original report\n"
);
}
#[tokio::test]
async fn human_review_rejects_url_capabilities_even_when_other_reads_allow_them() {
for read_auth in [false, true] {
let (tmp, app) = read_auth_app(true, read_auth);
std::fs::write(
MissionPaths::new(tmp.path(), MISSION_ID).report_file(),
"# Original report\n",
)
.unwrap();
let read = exchange_read_token(&app, READ_AUTH_TOKEN).await;
for suffix in [
format!("review-packet?token={read}"),
format!("report.md?review=true&token={read}"),
format!("report.md?%72eview=true&token={read}"),
] {
let (status, _) = get_json(&app, &format!("/api/missions/{MISSION_ID}/{suffix}")).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
let (status, body) = get_json(
&app,
&format!("/api/missions/{MISSION_ID}/report.md?review=false&token={read}"),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["markdown"], "# Original report\n");
}
}
#[tokio::test]
async fn read_auth_health_exempt_without_token() {
let (_tmp, app) = read_auth_app(true, true);
let (status, _) = get_json(&app, "/api/health").await;
assert_eq!(
status,
StatusCode::OK,
"health stays unauthenticated under read-auth"
);
}
#[tokio::test]
async fn read_auth_post_rejects_query_only_token() {
let (_tmp, app) = read_auth_app(true, true);
let (status, body) = post_json(
&app,
&format!("/api/missions/{MISSION_ID}/control?token={READ_AUTH_TOKEN}"),
None,
json!({ "kind": "pause" }),
)
.await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"query-only token must not authorize a POST: {body}"
);
}
#[tokio::test]
async fn read_auth_off_loopback_reads_tokenless() {
let (_tmp, app) = read_auth_app(true, false);
let (status, _) = get_json(&app, &format!("/api/missions/{MISSION_ID}/state")).await;
assert_eq!(
status,
StatusCode::OK,
"reads stay tokenless with read-auth off"
);
let addr = spawn_server(app).await;
let url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws");
let mut ws = connect_allowed_ws(&url).await;
let frame = next_frame(&mut ws).await;
assert_eq!(
frame["type"], "snapshot",
"tokenless loopback WS still upgrades"
);
}
#[tokio::test]
async fn read_auth_loopback_keeps_strict_loopback_origin() {
let (_tmp, app) = read_auth_app(true, true);
let read = exchange_read_token(&app, READ_AUTH_TOKEN).await;
let addr = spawn_server(app).await;
let url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws?token={read}");
let lan_origin = tokio::time::timeout(
WAIT,
connect_async(ws_request(&url, Some("http://192.168.1.5:4560"))),
)
.await
.unwrap();
assert!(
lan_origin.is_err(),
"read-auth must not relax the loopback origin allowlist to LAN IP literals"
);
let missing_origin = tokio::time::timeout(WAIT, connect_async(&url))
.await
.unwrap();
assert!(
missing_origin.is_err(),
"read-auth must not relax the loopback origin allowlist for a missing Origin"
);
}
#[tokio::test]
async fn ws_unknown_mission_is_rejected() {
let (_tmp, _repo_root, _paths, app) = fixture();
let addr = spawn_server(app).await;
let url = format!("ws://{addr}/api/missions/nope/ws");
let result = tokio::time::timeout(
WAIT,
connect_async(ws_request(&url, Some("http://localhost:5173"))),
)
.await
.unwrap();
assert!(result.is_err(), "handshake to an unknown mission must fail");
}
#[tokio::test]
async fn ws_rejects_invalid_since() {
let (_tmp, _repo_root, _paths, app) = fixture();
let addr = spawn_server(app).await;
let url = format!("ws://{addr}/api/missions/{MISSION_ID}/ws?since=nope");
let result = tokio::time::timeout(
WAIT,
connect_async(ws_request(&url, Some("http://localhost:5173"))),
)
.await
.unwrap();
assert!(
result.is_err(),
"unparsable ?since= must reject the upgrade (align with REST)"
);
}
const MERGE_TOKEN: &str = "merge-sesame";
fn merge_app<F>(repo_root: &Path, gate_executor: F) -> axum::Router
where
F: Fn(&str, &Path) -> (bool, String) + Send + Sync + 'static,
{
let host =
kranz_server::MissionHost::with_gate_executor(repo_root.to_path_buf(), gate_executor);
kranz_server::router_with_host(host, None, authority(MERGE_TOKEN))
}
async fn post_json(
app: &axum::Router,
uri: &str,
token: Option<&str>,
body: Value,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.method("POST")
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(token) = token {
builder = builder.header("x-kranz-token", token);
}
let request = builder
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap()
};
(status, value)
}
#[tokio::test]
async fn merge_route_requires_token() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
let paths = seed_diffable_mission(&repo_root, "m-tok", &base_sha, true);
mark_mission_complete(&paths, "m-tok");
let app = merge_app(&repo_root, |_cmd, _cwd| (true, String::new()));
let (status, _) = post_json(&app, "/api/missions/m-tok/merge", None, json!({})).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, body) = post_json(
&app,
"/api/missions/m-tok/merge",
Some(MERGE_TOKEN),
json!({}),
)
.await;
assert_ne!(status, StatusCode::UNAUTHORIZED, "{body}");
}
#[tokio::test]
async fn merge_route_requires_a_complete_mission() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
seed_diffable_mission(&repo_root, "m-running", &base_sha, false);
let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
let calls_for_executor = Arc::clone(&calls);
let app = merge_app(&repo_root, move |cmd, _cwd| {
calls_for_executor.lock().unwrap().push(cmd.to_string());
(true, String::new())
});
let (status, body) = post_json(
&app,
"/api/missions/m-running/merge",
Some(MERGE_TOKEN),
json!({}),
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert!(body["error"].as_str().unwrap().contains("complete"));
assert!(calls.lock().unwrap().is_empty());
assert_eq!(raw_git(&repo_root, &["rev-parse", "main"]).trim(), base_sha);
}
#[tokio::test]
async fn merge_route_refuses_while_the_repo_busy_lock_is_held() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
let paths = seed_diffable_mission(&repo_root, "m-busy", &base_sha, true);
mark_mission_complete(&paths, "m-busy");
let _hold = kranz_engine::queue::acquire_repo_busy(&repo_root, "m-other").unwrap();
let app = merge_app(&repo_root, |_cmd, _cwd| (true, String::new()));
let (status, body) = post_json(
&app,
"/api/missions/m-busy/merge",
Some(MERGE_TOKEN),
json!({}),
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert!(body["error"].as_str().unwrap().contains("busy"), "{body}");
assert_eq!(raw_git(&repo_root, &["rev-parse", "main"]).trim(), base_sha);
}
#[tokio::test]
async fn merge_route_merges_on_green_gates_and_flips_the_merged_bit() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
let paths = seed_diffable_mission(&repo_root, "m-green", &base_sha, true);
mark_mission_complete(&paths, "m-green");
let app = merge_app(&repo_root, |_cmd, _cwd| (true, String::new()));
let (status, body) = post_json(
&app,
"/api/missions/m-green/merge",
Some(MERGE_TOKEN),
json!({}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["merged"], true);
assert!(body["commit"].is_string(), "{body}");
assert_eq!(body["staleBase"], Value::Null, "{body}");
let trailers = interpret_head_trailers(&repo_root);
assert!(trailers.contains("Kranz-Mission: m-green"), "{trailers}");
assert!(trailers.contains("Kranz-Cost-USD: 0.0000"), "{trailers}");
assert!(
trailers.contains("Kranz-Tokens-Input: 0") && trailers.contains("Kranz-Tokens-Output: 0"),
"{trailers}"
);
let (status, body) = get_json(&app, "/api/missions").await;
assert_eq!(status, StatusCode::OK);
let rows = body.as_array().unwrap();
let row = rows.iter().find(|r| r["id"] == "m-green").unwrap();
assert_eq!(row["merged"], true, "{row}");
}
#[tokio::test]
async fn merge_route_fails_closed_when_base_has_no_gate_config() {
if !setup() {
return;
}
let (_dir, repo_root, _) = init_repo();
raw_git(
&repo_root,
&["rm", kranz_engine::merge_gate::MERGE_GATES_PATH],
);
raw_git(&repo_root, &["commit", "-m", "remove merge gates"]);
let base_sha = raw_git(&repo_root, &["rev-parse", "HEAD"])
.trim()
.to_string();
let paths = seed_diffable_mission(&repo_root, "m-no-gates", &base_sha, true);
mark_mission_complete(&paths, "m-no-gates");
let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
let calls_for_executor = Arc::clone(&calls);
let app = merge_app(&repo_root, move |cmd, _cwd| {
calls_for_executor.lock().unwrap().push(cmd.to_string());
(true, String::new())
});
let (status, body) = post_json(
&app,
"/api/missions/m-no-gates/merge",
Some(MERGE_TOKEN),
json!({}),
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert!(
body["error"]
.as_str()
.unwrap()
.contains("no tracked .kranz/merge-gates.json"),
"{body}"
);
assert!(calls.lock().unwrap().is_empty());
assert_eq!(raw_git(&repo_root, &["rev-parse", "main"]).trim(), base_sha);
}
#[tokio::test]
async fn merge_route_surfaces_stale_base_warning_without_blocking() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
let paths = seed_diffable_mission(&repo_root, "m-stale", &base_sha, true);
mark_mission_complete(&paths, "m-stale");
raw_git(&repo_root, &["checkout", "-b", "kranz/sibling", &base_sha]);
std::fs::write(repo_root.join("sibling.txt"), "sibling change\n").unwrap();
raw_git(&repo_root, &["add", "--", "sibling.txt"]);
raw_git(&repo_root, &["commit", "-m", "sibling change"]);
raw_git(&repo_root, &["checkout", "main"]);
raw_git(
&repo_root,
&["merge", "--no-ff", "--no-edit", "kranz/sibling"],
);
let app = merge_app(&repo_root, |_cmd, _cwd| (true, String::new()));
let (status, body) = post_json(
&app,
"/api/missions/m-stale/merge",
Some(MERGE_TOKEN),
json!({}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["merged"], true, "{body}");
assert_eq!(body["staleBase"]["baseSha"], base_sha, "{body}");
assert_eq!(body["staleBase"]["liveBase"], "main", "{body}");
assert_eq!(body["staleBase"]["mergeCommitsSinceBase"], 1, "{body}");
assert!(
body["staleBase"]["message"]
.as_str()
.unwrap()
.contains("stale base"),
"{body}"
);
}
#[tokio::test]
async fn merge_route_refuses_a_dirty_tracked_tree_and_leaves_base_unchanged() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
let paths = seed_diffable_mission(&repo_root, "m-dirty", &base_sha, true);
mark_mission_complete(&paths, "m-dirty");
std::fs::write(repo_root.join("README.md"), "dirty\n").unwrap();
let app = merge_app(&repo_root, |_cmd, _cwd| (true, String::new()));
let base_before = raw_git(&repo_root, &["rev-parse", "main"])
.trim()
.to_string();
let (status, body) = post_json(
&app,
"/api/missions/m-dirty/merge",
Some(MERGE_TOKEN),
json!({}),
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert!(body["error"].as_str().unwrap().contains("dirty"), "{body}");
let base_after = raw_git(&repo_root, &["rev-parse", "main"])
.trim()
.to_string();
assert_eq!(base_before, base_after, "base branch must not advance");
}
#[tokio::test]
async fn merge_route_surfaces_redacted_failing_gate_output_and_leaves_base_unchanged() {
if !setup() {
return;
}
let (_dir, repo_root, base_sha) = init_repo();
let paths = seed_diffable_mission(&repo_root, "m-red", &base_sha, true);
mark_mission_complete(&paths, "m-red");
const SECRET: &str = "sk-ant-api03-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let app = merge_app(&repo_root, |cmd, _cwd| {
if cmd == "cargo test --workspace" {
(
false,
format!("FAILED: it_broke\nassertion failed with {SECRET}"),
)
} else {
(true, String::new())
}
});
let base_before = raw_git(&repo_root, &["rev-parse", "main"])
.trim()
.to_string();
let (status, body) = post_json(
&app,
"/api/missions/m-red/merge",
Some(MERGE_TOKEN),
json!({}),
)
.await;
assert!(!status.is_success(), "{body}");
let error = body["error"].as_str().unwrap();
assert!(error.contains("cargo test --workspace"), "{body}");
assert!(error.contains("FAILED: it_broke"), "{body}");
assert!(error.contains("assertion failed"), "{body}");
assert!(error.contains("[REDACTED]"), "{body}");
assert!(!error.contains(SECRET), "{body}");
let base_after = raw_git(&repo_root, &["rev-parse", "main"])
.trim()
.to_string();
assert_eq!(base_before, base_after, "base branch must not advance");
}
#[tokio::test]
async fn live_permission_api_requires_mutation_authority_and_exact_binding() {
use kranz_engine::live_permission::{
self, Actor, Binding, Proposal, Request as ConsentRequest,
};
let (_tmp, root, paths, app) = fixture();
let now = chrono::Utc::now();
let action = json!({"kind":"execute","rawInput":{"command":"npm test"}});
let options = vec![json!({"kind":"allow_once","optionId":"one"})];
let request = ConsentRequest::new(
Proposal {
id: "permission-api".into(),
engine_session_id: "permission-session".into(),
peer_session_id: "peer".into(),
peer_request_id: json!(1),
tool_call_id: "call".into(),
action_digest: live_permission::digest(&action).unwrap(),
options_digest: live_permission::digest(&options).unwrap(),
action,
options,
observed_at: now,
deadline: now + chrono::Duration::seconds(300),
prohibition: None,
},
Binding {
mission_id: MISSION_ID.into(),
run_id: "permission-run".into(),
workspace: root.display().to_string(),
plan_digest: live_permission::digest(&sample_plan()).unwrap(),
policy_digest: live_permission::digest(&"policy").unwrap(),
},
)
.unwrap();
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::WorkerSpawned {
backend: None,
run_id: "permission-run".into(),
role: Role::Worker,
feature_id: Some("f-1-1".into()),
milestone_id: Some("ms-1".into()),
candidate: None,
executor_route: None,
sdk_session_id: "permission-session".into(),
model: "fixture".into(),
quant: "n/a".into(),
weight_hash: None,
prompt_hash: "fixture".into(),
transcript_path: MissionPaths::transcript_rel("permission-run"),
})
.unwrap();
log.append(EventKind::PermissionRequested {
request: request.clone(),
})
.unwrap();
}
let uri = format!("/api/missions/{MISSION_ID}/permission/answer");
let body = json!({"requestId":request.proposal.id,"bindingDigest":request.binding_digest,"allow":true});
let read_capability = exchange_read_token(&app, TEST_TOKEN).await;
let packet_response = app
.clone()
.oneshot(
Request::get(format!("/api/missions/{MISSION_ID}/review-packet"))
.header(kranz_server::TOKEN_HEADER, &read_capability)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(packet_response.status(), StatusCode::OK);
let packet: Value = serde_json::from_slice(
&packet_response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
)
.unwrap();
assert!(packet["packet"]["decisions"]
.as_array()
.unwrap()
.iter()
.any(|decision| {
decision["title"] == format!("Permission {}", request.proposal.id)
&& decision["detail"]
.as_str()
.unwrap()
.contains(&request.binding_digest)
}));
for token in [None, Some(read_capability.as_str())] {
let mut req = Request::post(&uri).header("content-type", "application/json");
if let Some(token) = token {
req = req.header(kranz_server::TOKEN_HEADER, token);
}
let response = app
.clone()
.oneshot(req.body(Body::from(body.to_string())).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(control::drain(&paths).unwrap().is_empty());
}
for invalid in [
json!({"requestId":request.proposal.id,"bindingDigest":"wrong","allow":true}),
json!({"requestId":"foreign","bindingDigest":request.binding_digest,"allow":true}),
] {
let response = app
.clone()
.oneshot(
Request::post(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(invalid.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(control::drain(&paths).unwrap().is_empty());
}
let forged = ControlCommand::ResolvePermission {
resolution: live_permission::Resolution {
request_id: request.proposal.id.clone(),
binding_digest: request.binding_digest.clone(),
allow: true,
actor: Actor::SlackUser("forged-user".into()),
reason: "forged".into(),
},
};
let response = app
.clone()
.oneshot(
Request::post(format!("/api/missions/{MISSION_ID}/control"))
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(serde_json::to_string(&forged).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(control::drain(&paths).unwrap().is_empty());
let mut spoofed = body.clone();
spoofed["actor"] = json!({"kind":"slack-user","id":"forged-user"});
let response = app
.clone()
.oneshot(
Request::post(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(spoofed.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(control::drain(&paths).unwrap().is_empty());
let response = app
.clone()
.oneshot(
Request::post(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
let queued = control::drain(&paths).unwrap();
assert_eq!(queued.len(), 1);
let ControlCommand::ResolvePermission { resolution } = &queued[0].1 else {
panic!("wrong command");
};
assert_eq!(resolution.actor, Actor::LocalMutationCapability);
assert_eq!(resolution.binding_digest, request.binding_digest);
let events = EventLog::read_events(&paths.events_file()).unwrap();
assert!(!events.iter().any(|e| matches!(
e.kind,
EventKind::PermissionResolved { .. } | EventKind::PermissionResponseRecorded { .. }
)));
control::acknowledge(&paths, &queued[0].0).unwrap();
{
let mut log = EventLog::acquire(&paths, MISSION_ID, Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::PermissionClosed {
request_id: request.proposal.id,
reason: "peer ended".into(),
})
.unwrap();
}
let response = app
.oneshot(
Request::post(&uri)
.header("content-type", "application/json")
.header(kranz_server::TOKEN_HEADER, TEST_TOKEN)
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(control::drain(&paths).unwrap().is_empty());
}