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
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.
232 lines
7.9 KiB
Rust
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(®);
|
|
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");
|
|
}
|