nigig-org/crates/apps/nigig-site/tests/sync_e2e.rs
Arena Agent 5d2d890f70
Some checks failed
doc-engine / engine (push) Waiting to run
doc-engine / coverage (push) Waiting to run
doc-engine / consumer (push) Waiting to run
email / gates (push) Waiting to run
email / email-domain (push) Waiting to run
email / nigig-email (push) Waiting to run
nigig-build (CAD) / cad-module (push) Waiting to run
nigig-build (CAD) / full-crate-check (push) Waiting to run
nigig-build (CAD) / doc-workspace-coverage (push) Waiting to run
nigig-build (CAD) / cad-widget-coverage (push) Waiting to run
email / supply-chain (push) Waiting to run
nigig-build (CAD) / supply-chain (push) Waiting to run
nigig-build (CAD) / cad-engine-coverage (push) Waiting to run
nigig-map / test (push) Waiting to run
sms / android (push) Waiting to run
sms / nigig-sms (push) Waiting to run
sms / supply-chain (push) Waiting to run
sms / gates (push) Waiting to run
sms / robius-sms (push) Waiting to run
spreadsheet / engine-coverage (push) Waiting to run
spreadsheet / ui-controller-coverage (push) Waiting to run
traffic / gates (push) Waiting to run
traffic / nigig-traffic (push) Waiting to run
traffic / supply-chain (push) Waiting to run
nigig-site / Owned paths and honest test contracts (push) Has been cancelled
nigig-site / Cargo check-all-targets (push) Has been cancelled
nigig-site / Cargo clippy-site-owned (push) Has been cancelled
nigig-site / Cargo contained-media-export-fixtures (push) Has been cancelled
nigig-site / Cargo containment-storage-crypto (push) Has been cancelled
nigig-site / Cargo integration-non-live (push) Has been cancelled
nigig-site / Cargo production-dependency-containment (push) Has been cancelled
nigig-site / Cargo unit (push) Has been cancelled
nigig-site / Runtime UI (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Migration and recovery (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Media limits (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Real server interoperability (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Security and supply-chain baseline (push) Has been cancelled
nigig-site / Release capability gate (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
feat(nigig-site): enforce SITE-01 fail-closed containment
Contain production capabilities, remove the production sync surface, and keep legacy media/export/transport implementations test-only.

Require authenticated existing-key storage with preservation-first recovery and sticky write disablement, backed by deterministic fault and concurrency tests plus dependency and workflow contracts.
2026-09-12 15:46:30 +00:00

232 lines
7.9 KiB
Rust

//! Live end-to-end test against a real `nimanyatta` server.
//!
//! Run: start the server first, then
//! ```sh
//! NIMANYATTA_E2E_URL=http://127.0.0.1:8080 cargo test -p nigig-site --test sync_e2e
//! ```
//! This test is explicitly ignored in ordinary test runs because it requires
//! a separately launched real server. The dedicated interoperability job runs
//! it with `--ignored --exact live_round_trip`; missing configuration is then
//! a hard failure, never an early-returning green test. It exercises a
//! test-only candidate contract: register → create room → send text → `/sync`
//! timeline → snapshot push → messages fetch → decode → merge. SITE-01 ships
//! none of this transport or snapshot-codec surface in the production library.
#[path = "support/nimanyatta_fixture.rs"]
mod nimanyatta_fixture;
#[path = "support/sync_fixture.rs"]
mod sync_fixture;
use reqwest::blocking::Client;
use serde_json::{json, Value};
fn normalize_loopback_base_url(value: &str) -> Result<String, &'static str> {
let url = reqwest::Url::parse(value.trim()).map_err(|_| "URL is invalid")?;
if url.scheme() != "http" {
return Err("only loopback HTTP is supported by the test server");
}
let is_loopback = url
.host_str()
.map(|host| host.trim_start_matches('[').trim_end_matches(']'))
.and_then(|host| host.parse::<std::net::IpAddr>().ok())
.is_some_and(|ip| ip.is_loopback());
if !is_loopback || url.port().is_none() {
return Err("an explicit loopback IP and port are required");
}
if !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| !matches!(url.path(), "" | "/")
{
return Err("credentials, query, fragment, and path are forbidden");
}
Ok(url.as_str().trim_end_matches('/').to_string())
}
fn required_base_url() -> String {
let value = std::env::var("NIMANYATTA_E2E_URL")
.expect("NIMANYATTA_E2E_URL is required by the dedicated live-server job");
normalize_loopback_base_url(&value)
.unwrap_or_else(|reason| panic!("invalid NIMANYATTA_E2E_URL: {reason}"))
}
#[test]
fn live_server_url_contract_is_loopback_only() {
assert_eq!(
normalize_loopback_base_url(" http://127.0.0.1:8080/ "),
Ok("http://127.0.0.1:8080".to_string())
);
assert_eq!(
normalize_loopback_base_url("http://[::1]:9090"),
Ok("http://[::1]:9090".to_string())
);
for invalid in [
"https://127.0.0.1:8080",
"http://localhost:8080",
"http://192.168.1.20:8080",
"http://127.0.0.1",
"http://user:secret@127.0.0.1:8080",
"http://127.0.0.1:8080/api",
"not-a-url",
] {
assert!(
normalize_loopback_base_url(invalid).is_err(),
"accepted unsafe live-server URL: {invalid}"
);
}
}
fn token_of(v: &Value) -> String {
v.pointer("/tokens/access_token/token")
.or_else(|| v.pointer("/access_token/token"))
.or_else(|| v.pointer("/access_token"))
.and_then(|t| t.as_str())
.unwrap_or_default()
.to_string()
}
fn id_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Object(_) => v
.get("id")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string(),
_ => String::new(),
}
}
#[test]
#[ignore = "requires a pinned local nimanyatta server; run only in the dedicated interoperability job"]
fn live_round_trip() {
let base = required_base_url();
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap();
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
// 1. Register.
let reg: Value = client
.post(format!("{base}/api/v1/auth/register"))
.json(&json!({
"username": format!("e2e_{stamp}"),
"password": "Password123!",
"display_name": "E2E",
"device_display_name": "e2e-test"
}))
.send()
.expect("register reachable")
.json()
.expect("register json");
let token = token_of(&reg);
assert!(
!token.is_empty(),
"server must issue an access token: {reg}"
);
// 2. Create a project room using the test-only candidate shape.
let room: Value = client
.post(format!("{base}/api/v1/rooms/create"))
.bearer_auth(&token)
.json(&json!({
"name": "Site — E2E",
"topic": "e2e",
"is_direct": false,
"invite": []
}))
.send()
.expect("create room reachable")
.json()
.expect("create room json");
let room_id = room
.get("room_id")
.map(id_string)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| panic!("create room must return room_id: {room}"));
// 3. Send a text message using the test-only candidate envelope.
let marker = format!("e2e-marker-{stamp}");
let send: Value = client
.post(format!("{base}/api/v1/rooms/{room_id}/send"))
.bearer_auth(&token)
.json(&json!({
"content": { "Text": { "body": marker, "formatted_body": null, "mentions": [] } },
"reply_to": null
}))
.send()
.expect("send reachable")
.json()
.expect("send json");
assert!(
send.get("event_id").is_some(),
"send must return event_id: {send}"
);
// 4. `/sync` must carry the message back in a parseable timeline.
let sync: Value = client
.post(format!("{base}/api/v1/sync"))
.bearer_auth(&token)
.json(&json!({ "timeout_ms": 1000 }))
.send()
.expect("sync reachable")
.json()
.expect("sync json");
let sync_text = serde_json::to_string(&sync).unwrap();
assert!(
sync_text.contains(&marker),
"sync timeline must contain our marker"
);
let timelines = nimanyatta_fixture::sync_timelines(&sync_text);
assert!(
timelines
.iter()
.any(|(_, lines)| lines.iter().any(|l| l.contains(&marker))),
"app parser must extract the marker: {timelines:?}"
);
// 5. Test-only candidate snapshot push → fetch → decode → merge.
let mut store = nigig_site::store::SiteStore::default();
store.sites.push(nigig_site::domain::site::Site::new(
"E2E Site",
nigig_site::domain::site::SiteNature::Road,
"x",
));
let chunks = sync_fixture::encode_snapshot(&store, &format!("e2e-{stamp}"));
assert!(!chunks.is_empty());
for chunk in &chunks {
let res: Value = client
.post(format!("{base}/api/v1/rooms/{room_id}/send"))
.bearer_auth(&token)
.json(&json!({
"content": { "Text": { "body": chunk, "formatted_body": null, "mentions": [] } },
"reply_to": null
}))
.send()
.expect("chunk send reachable")
.json()
.expect("chunk send json");
assert!(res.get("event_id").is_some());
}
let messages: Value = client
.post(format!("{base}/api/v1/rooms/{room_id}/messages"))
.bearer_auth(&token)
.json(&json!({ "limit": 100 }))
.send()
.expect("messages reachable")
.json()
.expect("messages json");
let bodies = nimanyatta_fixture::timeline_bodies(&serde_json::to_string(&messages).unwrap());
let pulled = sync_fixture::extract_chunks_from_bodies(&bodies);
assert_eq!(pulled.len(), chunks.len(), "all chunks must come back");
let remote = sync_fixture::decode_snapshot(&pulled).expect("snapshot must reassemble");
let mut local = nigig_site::store::SiteStore::default();
let stats = sync_fixture::merge_remote(&mut local, remote);
assert_eq!(stats.sites_added, 1);
assert_eq!(local.sites[0].name, "E2E Site");
}