use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use lex_api::handlers::State;
use lex_vcs::{
Attestation, AttestationKind, AttestationResult, Operation, OperationKind, OperationRecord,
ProducerDescriptor, StageTransition,
};
use std::collections::BTreeSet;
use tempfile::TempDir;
struct Server {
addr: SocketAddr,
_join: Option<thread::JoinHandle<()>>,
}
fn start_server() -> (Server, TempDir) {
let tmp = TempDir::new().unwrap();
let server = tiny_http::Server::http(("127.0.0.1", 0))
.expect("bind ephemeral port");
let addr: SocketAddr = match server.server_addr() {
tiny_http::ListenAddr::IP(addr) => addr,
_ => panic!("expected IP listener"),
};
let state = Arc::new(State::open(tmp.path().to_path_buf()).unwrap());
let join = thread::spawn(move || {
lex_api::serve_on(server, state);
});
thread::sleep(Duration::from_millis(20));
(Server { addr, _join: Some(join) }, tmp)
}
fn http(addr: &SocketAddr, method: &str, path: &str, body: &str) -> (u16, String) {
let mut s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
);
s.write_all(req.as_bytes()).unwrap();
let mut buf = String::new();
s.read_to_string(&mut buf).unwrap();
let (head, body) = buf.split_once("\r\n\r\n").unwrap_or((&buf, ""));
let status = head.split_whitespace().nth(1).unwrap_or("0").parse().unwrap_or(0);
(status, body.to_string())
}
fn add_op(parent: Option<&str>, sig: &str, stg: &str) -> OperationRecord {
let parents: Vec<String> = parent.map(|p| p.to_string()).into_iter().collect();
OperationRecord::new(
Operation::new(
OperationKind::AddFunction {
sig_id: sig.into(),
stage_id: stg.into(),
effects: BTreeSet::new(),
budget_cost: None,
},
parents,
),
StageTransition::Create {
sig_id: sig.into(),
stage_id: stg.into(),
},
)
}
fn modify_op(parent: &str, sig: &str, from: &str, to: &str) -> OperationRecord {
OperationRecord::new(
Operation::new(
OperationKind::ModifyBody {
sig_id: sig.into(),
from_stage_id: from.into(),
to_stage_id: to.into(),
from_budget: None,
to_budget: None,
},
[parent.to_string()],
),
StageTransition::Replace {
sig_id: sig.into(),
from: from.into(),
to: to.into(),
},
)
}
fn typecheck(stage_id: &str, op_id: &str) -> Attestation {
Attestation::with_timestamp(
stage_id.to_string(),
Some(op_id.into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Passed,
ProducerDescriptor {
tool: "test".into(),
version: "0".into(),
model: None,
},
None,
1_700_000_000,
)
}
#[test]
fn round_trip_one_hundred_ops_plus_attestations() {
let (srv, _tmp) = start_server();
let mut ops: Vec<OperationRecord> = Vec::with_capacity(100);
let root = add_op(None, "fac", "stg-0");
let mut last_id = root.op_id.clone();
let mut last_stage = "stg-0".to_string();
ops.push(root);
for i in 1..100 {
let to_stg = format!("stg-{i}");
let m = modify_op(&last_id, "fac", &last_stage, &to_stg);
last_id = m.op_id.clone();
last_stage.clone_from(&to_stg);
ops.push(m);
}
assert_eq!(ops.len(), 100);
let body = serde_json::to_string(&ops).unwrap();
let (status, resp) = http(&srv.addr, "POST", "/v1/ops/batch", &body);
assert_eq!(status, 200, "first push: {resp}");
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["received"], 100);
assert_eq!(v["added"], 100);
assert_eq!(v["skipped"], 0);
let (status, resp) = http(&srv.addr, "POST", "/v1/ops/batch", &body);
assert_eq!(status, 200, "second push: {resp}");
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["received"], 100);
assert_eq!(v["added"], 0);
assert_eq!(v["skipped"], 100);
let attestations: Vec<Attestation> = ops.iter()
.map(|r| {
let stage = match &r.op.kind {
OperationKind::AddFunction { stage_id, .. } => stage_id.clone(),
OperationKind::ModifyBody { to_stage_id, .. } => to_stage_id.clone(),
_ => unreachable!(),
};
typecheck(&stage, &r.op_id)
})
.collect();
let abody = serde_json::to_string(&attestations).unwrap();
let (status, resp) = http(&srv.addr, "POST", "/v1/attestations/batch", &abody);
assert_eq!(status, 200, "attestations push: {resp}");
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["received"], 100);
assert_eq!(v["added"], 100);
let (status, resp) = http(&srv.addr, "POST", "/v1/attestations/batch", &abody);
assert_eq!(status, 200, "second attestations push: {resp}");
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["added"], 0);
assert_eq!(v["skipped"], 100);
}
#[test]
fn batch_with_unreachable_parent_returns_422_missing_parent() {
let (srv, _tmp) = start_server();
let orphan = modify_op("ghost-parent", "fac", "s0", "s1");
let body = serde_json::to_string(&vec![orphan.clone()]).unwrap();
let (status, resp) = http(&srv.addr, "POST", "/v1/ops/batch", &body);
assert_eq!(status, 422, "expected 422, got {status}: {resp}");
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["error"], "MissingParent");
assert_eq!(v["detail"]["op_id"], orphan.op_id);
assert_eq!(v["detail"]["missing_parent"], "ghost-parent");
}
#[test]
fn batch_with_in_batch_parent_resolves_correctly() {
let (srv, _tmp) = start_server();
let a = add_op(None, "fac", "s0");
let b = modify_op(&a.op_id, "fac", "s0", "s1");
let body = serde_json::to_string(&vec![a, b]).unwrap();
let (status, resp) = http(&srv.addr, "POST", "/v1/ops/batch", &body);
assert_eq!(status, 200, "{resp}");
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["added"], 2);
}
#[test]
fn op_id_mismatch_returns_409() {
let (srv, _tmp) = start_server();
let mut a = add_op(None, "fac", "s0");
a.op_id = "0".repeat(64); let body = serde_json::to_string(&vec![a]).unwrap();
let (status, resp) = http(&srv.addr, "POST", "/v1/ops/batch", &body);
assert_eq!(status, 409, "expected 409, got {status}: {resp}");
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["error"], "OpIdMismatch");
}
#[test]
fn attestation_with_unknown_op_returns_422_unknown_op() {
let (srv, _tmp) = start_server();
let att = typecheck("stg-1", "ghost-op-id");
let body = serde_json::to_string(&vec![att.clone()]).unwrap();
let (status, resp) = http(&srv.addr, "POST", "/v1/attestations/batch", &body);
assert_eq!(status, 422, "expected 422, got {status}: {resp}");
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["error"], "UnknownOp");
assert_eq!(v["detail"]["op_id"], "ghost-op-id");
assert_eq!(v["detail"]["attestation_id"], att.attestation_id);
}
#[test]
fn attestation_id_mismatch_returns_409() {
let (srv, _tmp) = start_server();
let a = add_op(None, "fac", "s0");
let op_id = a.op_id.clone();
let body = serde_json::to_string(&vec![a]).unwrap();
let (status, _) = http(&srv.addr, "POST", "/v1/ops/batch", &body);
assert_eq!(status, 200);
let mut att = typecheck("s0", &op_id);
att.attestation_id = "0".repeat(64);
let body = serde_json::to_string(&vec![att]).unwrap();
let (status, resp) = http(&srv.addr, "POST", "/v1/attestations/batch", &body);
assert_eq!(status, 409, "expected 409, got {status}: {resp}");
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["error"], "AttestationIdMismatch");
}
#[test]
fn attestation_without_op_id_is_accepted() {
let (srv, _tmp) = start_server();
let att = Attestation::with_timestamp(
"stg-1".to_string(),
None,
None,
AttestationKind::Override {
actor: "alice".into(),
reason: "manual override".into(),
target_attestation_id: None,
},
AttestationResult::Passed,
ProducerDescriptor {
tool: "test".into(),
version: "0".into(),
model: None,
},
None,
1_700_000_001,
);
let body = serde_json::to_string(&vec![att]).unwrap();
let (status, resp) = http(&srv.addr, "POST", "/v1/attestations/batch", &body);
assert_eq!(status, 200, "{resp}");
}
#[test]
fn branch_head_probe_returns_null_for_unknown_branch() {
let (srv, _tmp) = start_server();
let (status, resp) = http(&srv.addr, "GET", "/v1/branches/main/head", "");
assert_eq!(status, 200, "{resp}");
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["branch"], "main");
assert_eq!(v["head_op"], serde_json::Value::Null);
}
#[test]
fn rejection_persists_nothing() {
let (srv, _tmp) = start_server();
let a = add_op(None, "fac", "s0");
let a_id = a.op_id.clone();
let body = serde_json::to_string(&vec![a]).unwrap();
let (status, _) = http(&srv.addr, "POST", "/v1/ops/batch", &body);
assert_eq!(status, 200);
let b = add_op(None, "double", "ddd-0");
let bad = modify_op("ghost-parent", "fac", "s0", "s1");
let body = serde_json::to_string(&vec![b.clone(), bad]).unwrap();
let (status, _) = http(&srv.addr, "POST", "/v1/ops/batch", &body);
assert_eq!(status, 422);
let (_, resp) = http(&srv.addr, "GET", "/v1/branches/_no_such/head", "");
let _ = resp;
let body = serde_json::to_string(&vec![b]).unwrap();
let (status, resp) = http(&srv.addr, "POST", "/v1/ops/batch", &body);
assert_eq!(status, 200);
let v: serde_json::Value = serde_json::from_str(&resp).unwrap();
assert_eq!(
v["added"], 1,
"rejection leaked partial state into the log: {resp}",
);
let _ = a_id; }