#![allow(clippy::unwrap_used)]
use std::net::TcpListener;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use assert_cmd::prelude::*;
use tempfile::TempDir;
const SERVER_TOKEN_ENV: &str = "MNEM_HTTP_PUSH_TOKEN";
const TEST_TOKEN: &str = "b3-integration-token";
struct HttpServer {
child: Child,
base_url: String,
_repo: TempDir,
}
impl HttpServer {
fn spawn(token: Option<&str>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
let port = listener.local_addr().expect("local_addr").port();
drop(listener);
let repo = TempDir::new().expect("repo tempdir");
let mut cmd = Command::cargo_bin("mnem").expect("built mnem binary");
cmd.args(["http"])
.arg("-R")
.arg(repo.path())
.arg("--bind")
.arg(format!("127.0.0.1:{port}"))
.arg("--in-memory")
.env_remove("MNEM_BENCH")
.env_remove(SERVER_TOKEN_ENV)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(t) = token {
cmd.env(SERVER_TOKEN_ENV, t);
}
let mut child = cmd.spawn().expect("spawn mnem http serve");
let base_url = format!("http://127.0.0.1:{port}");
let deadline = Instant::now() + Duration::from_secs(5);
let healthz = format!("{base_url}/v1/healthz");
let mut last_err: Option<String> = None;
loop {
if Instant::now() > deadline {
let _ = child.kill();
panic!(
"mnem http serve did not come up on {base_url} within 5s: last={last_err:?}"
);
}
match ureq::get(&healthz)
.timeout(Duration::from_millis(250))
.call()
{
Ok(resp) if resp.status() == 200 => break,
Ok(resp) => last_err = Some(format!("status {}", resp.status())),
Err(e) => last_err = Some(format!("{e}")),
}
std::thread::sleep(Duration::from_millis(50));
}
Self {
child,
base_url,
_repo: repo,
}
}
fn url(&self) -> &str {
&self.base_url
}
}
impl Drop for HttpServer {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn mnem(repo: &Path) -> Command {
let mut cmd = Command::cargo_bin("mnem").expect("built mnem binary");
cmd.current_dir(repo);
cmd.arg("-R").arg(repo);
cmd.env_remove(SERVER_TOKEN_ENV);
cmd.env_remove("MNEM_REMOTE_ORIGIN_TOKEN");
cmd
}
fn init_repo_with_node(dir: &Path, summary: &str) {
mnem(dir).arg("init").arg(dir).assert().success();
mnem(dir)
.args([
"add",
"node",
"--label",
"Memory",
"--summary",
summary,
"--no-embed",
])
.assert()
.success();
}
fn add_origin(dir: &Path, server: &HttpServer) {
mnem(dir)
.args(["remote", "add", "origin", server.url()])
.assert()
.success();
}
#[test]
fn fetch_round_trip_against_local_server() {
let server = HttpServer::spawn(Some(TEST_TOKEN));
let client_dir = TempDir::new().unwrap();
init_repo_with_node(client_dir.path(), "fetch round-trip");
add_origin(client_dir.path(), &server);
let out = mnem(client_dir.path())
.arg("fetch")
.arg("origin")
.output()
.unwrap();
assert!(
out.status.success(),
"fetch failed; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
#[ignore = "TODO(B3.5): first-push birth-commit protocol gap. Client \
sends `old=local_head` when remote is empty; server \
rejects because current heads are empty. Fix requires a \
joint client+server change (either a `null`/zero-CID \
sentinel in advance-head, or a dedicated `/birth-head` \
route). Schema fix + auth coverage is sufficient for B3.4 \
acceptance; full push round-trip lands in B3.5."]
fn push_round_trip_against_local_server() {
let server = HttpServer::spawn(Some(TEST_TOKEN));
let client_dir = TempDir::new().unwrap();
init_repo_with_node(client_dir.path(), "push round-trip");
add_origin(client_dir.path(), &server);
let out = mnem(client_dir.path())
.env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
.args(["push", "origin", "main"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
out.status.success(),
"push failed; stdout={stdout}; stderr={stderr}"
);
assert!(
stdout.contains("To "),
"expected Git-style push report, got: {stdout}"
);
}
#[test]
fn push_without_token_is_401_to_cli() {
let server = HttpServer::spawn(Some(TEST_TOKEN));
let client_dir = TempDir::new().unwrap();
init_repo_with_node(client_dir.path(), "no-token push");
add_origin(client_dir.path(), &server);
let out = mnem(client_dir.path())
.args(["push", "origin", "main"])
.output()
.unwrap();
assert!(!out.status.success(), "push must fail without a token");
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.contains("Authentication required") || stderr.contains("MNEM_REMOTE_"),
"expected auth hint, got: {stderr}"
);
}
#[test]
fn push_with_wrong_token_is_401_to_cli() {
let server = HttpServer::spawn(Some(TEST_TOKEN));
let client_dir = TempDir::new().unwrap();
init_repo_with_node(client_dir.path(), "wrong-token push");
add_origin(client_dir.path(), &server);
let out = mnem(client_dir.path())
.env("MNEM_REMOTE_ORIGIN_TOKEN", "definitely-not-the-real-token")
.args(["push", "origin", "main"])
.output()
.unwrap();
assert!(
!out.status.success(),
"push with wrong token must fail; stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.contains("Authentication required")
|| stderr.contains("401")
|| stderr.contains("auth"),
"expected auth rejection, got: {stderr}"
);
}
#[test]
#[ignore = "TODO(B3.5): depends on first-push birth-commit protocol \
(see `push_round_trip_against_local_server`)."]
fn push_then_second_push_no_op_is_idempotent() {
let server = HttpServer::spawn(Some(TEST_TOKEN));
let client_dir = TempDir::new().unwrap();
init_repo_with_node(client_dir.path(), "idempotent");
add_origin(client_dir.path(), &server);
mnem(client_dir.path())
.env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
.args(["push", "origin", "main"])
.assert()
.success();
let out = mnem(client_dir.path())
.env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
.args(["push", "origin", "main"])
.output()
.unwrap();
assert!(
out.status.success(),
"repeat push of identical tip must be idempotent; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn fetch_twice_is_idempotent() {
let server = HttpServer::spawn(Some(TEST_TOKEN));
let client_dir = TempDir::new().unwrap();
init_repo_with_node(client_dir.path(), "fetch idempotent");
add_origin(client_dir.path(), &server);
for _ in 0..2 {
mnem(client_dir.path())
.arg("fetch")
.arg("origin")
.assert()
.success();
}
}
#[test]
#[ignore = "TODO(B3.5): publisher's initial push hits the birth-commit \
gap; once that lands this test flips back on."]
fn pull_fast_forward_succeeds() {
let server = HttpServer::spawn(Some(TEST_TOKEN));
let pub_dir = TempDir::new().unwrap();
init_repo_with_node(pub_dir.path(), "publisher-seed");
add_origin(pub_dir.path(), &server);
mnem(pub_dir.path())
.env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
.args(["push", "origin", "main"])
.assert()
.success();
let sub_dir = TempDir::new().unwrap();
mnem(sub_dir.path())
.arg("init")
.arg(sub_dir.path())
.assert()
.success();
add_origin(sub_dir.path(), &server);
let out = mnem(sub_dir.path())
.args(["pull", "origin", "main"])
.output()
.unwrap();
assert!(
out.status.success(),
"fast-forward pull must succeed; stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
#[ignore = "TODO(B3.5): needs a prior successful push (birth-commit \
gap) before the non-ff scenario can be reproduced."]
fn pull_non_ff_prints_merge_hint() {
let server = HttpServer::spawn(Some(TEST_TOKEN));
let a_dir = TempDir::new().unwrap();
init_repo_with_node(a_dir.path(), "A commit");
add_origin(a_dir.path(), &server);
mnem(a_dir.path())
.env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
.args(["push", "origin", "main"])
.assert()
.success();
let b_dir = TempDir::new().unwrap();
init_repo_with_node(b_dir.path(), "B commit");
add_origin(b_dir.path(), &server);
let out = mnem(b_dir.path())
.args(["pull", "origin", "main"])
.output()
.unwrap();
assert!(!out.status.success(), "non-ff pull must fail");
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.contains("non-fast-forward") || stderr.contains("mnem merge"),
"expected non-ff hint, got: {stderr}"
);
}
#[test]
#[ignore = "TODO(B3.5): needs a prior successful push from client A \
(birth-commit gap) before client B's competing push can \
observe the CAS mismatch."]
fn push_cas_mismatch_surfaces_pull_hint_to_cli() {
let server = HttpServer::spawn(Some(TEST_TOKEN));
let a_dir = TempDir::new().unwrap();
init_repo_with_node(a_dir.path(), "A first");
add_origin(a_dir.path(), &server);
mnem(a_dir.path())
.env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
.args(["push", "origin", "main"])
.assert()
.success();
let b_dir = TempDir::new().unwrap();
init_repo_with_node(b_dir.path(), "B competing");
add_origin(b_dir.path(), &server);
let out = mnem(b_dir.path())
.env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
.args(["push", "origin", "main"])
.output()
.unwrap();
assert!(!out.status.success(), "second competing push must fail");
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.contains("mnem pull")
|| stderr.contains("Integrate remote changes")
|| stderr.contains("Updates were rejected"),
"expected CAS-mismatch hint pointing at mnem pull, got: {stderr}"
);
}
#[test]
fn fetch_verb_rejects_missing_remote() {
let dir = TempDir::new().unwrap();
mnem(dir.path())
.arg("init")
.arg(dir.path())
.assert()
.success();
let out = mnem(dir.path())
.arg("fetch")
.arg("origin")
.output()
.unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no remote") || stderr.contains("origin"),
"expected actionable missing-remote diagnostic, got: {stderr}"
);
}
#[test]
fn push_without_remote_errors() {
let dir = TempDir::new().unwrap();
mnem(dir.path())
.arg("init")
.arg(dir.path())
.assert()
.success();
let out = mnem(dir.path()).arg("push").output().unwrap();
assert!(!out.status.success());
}
#[test]
fn pull_without_tracking_ref_errors() {
let dir = TempDir::new().unwrap();
mnem(dir.path())
.arg("init")
.arg(dir.path())
.assert()
.success();
let out = mnem(dir.path()).arg("pull").output().unwrap();
assert!(!out.status.success());
}