use std::io::{Read, Write};
use std::net::TcpStream;
use areev_cal::AreevFacade;
use areev_server::UiServer;
use areev_store::Areev;
use tempfile::TempDir;
fn req(addr: &str, raw: &str) -> String {
let raw = match raw.find("\r\n") {
Some(pos) => format!("{}\r\nAuthorization: Bearer t{}", &raw[..pos], &raw[pos..]),
None => raw.to_string(),
};
let mut s = TcpStream::connect(addr).unwrap();
s.write_all(raw.as_bytes()).unwrap();
let mut out = String::new();
s.read_to_string(&mut out).unwrap();
out
}
#[test]
fn console_api_round_trip() {
let dir = TempDir::new().unwrap();
let db = dir.path().join("ui.db");
let m = Areev::open(db.to_str().unwrap()).unwrap();
let facade = AreevFacade::with_session(m, Some("caller".into()), None);
let server = UiServer::new(facade, "ui.db".into()).with_auth("t".to_string());
let listener = UiServer::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
std::thread::spawn(move || server.serve(listener));
let page = req(&addr, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
assert!(page.contains("200 OK") && page.contains("areev"), "{page}");
let body = r#"{"query":"ADD fact SET subject = \"alice\" SET relation = \"prefers\" SET object = \"tea\" SET namespace = \"caller\" REASON \"ui\""}"#;
let post = format!(
"POST /api/cal 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
);
let resp = req(&addr, &post);
assert!(resp.contains("\"ok\":true") && resp.contains("hash"), "{resp}");
let stats = req(&addr, "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
assert!(stats.contains("\"grains\":1"), "{stats}");
let log = req(&addr, "GET /api/log HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
assert!(log.contains("\"op\":\"add\""), "{log}");
let v = req(&addr, "GET /api/verify HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
assert!(v.contains("\"integrity\":\"ok\""), "{v}");
let b = req(&addr, "GET /api/browse HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
assert!(
b.contains("\"ok\":true") && b.contains("\"type\":\"fact\"") && b.contains("\"subject\":\"alice\""),
"{b}"
);
let cfg = req(&addr, "GET /api/config HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
assert!(
cfg.contains("\"tier1_writes\":true")
&& cfg.contains("\"rrf_k0\":60.0")
&& cfg.contains("\"index_text\":true")
&& cfg.contains("\"auth_required\":true")
&& cfg.contains("\"auth_all\":true")
&& cfg.contains("\"file\"")
&& cfg.contains("\"decide\":null")
&& cfg.contains("\"warnings\":[]"),
"{cfg}"
);
let body = r#"{"query":"RECALL facts WHERE subject = \"alice\""}"#;
let evil = format!(
"POST /api/cal HTTP/1.1\r\nHost: 127.0.0.1\r\nOrigin: http://evil.example\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let e = req(&addr, &evil);
assert!(e.contains("403 Forbidden"), "{e}");
let local = format!(
"POST /api/cal HTTP/1.1\r\nHost: 127.0.0.1\r\nOrigin: http://127.0.0.1:7437\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let l = req(&addr, &local);
assert!(l.contains("\"ok\":true"), "{l}");
let bad = r#"{"query":"RECALL facts WHERE subject == \"alice\""}"#;
let post = format!(
"POST /api/cal HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
bad.len(),
bad
);
let e = req(&addr, &post);
assert!(
e.contains("\"ok\":false") && e.contains("\"code\":\"CAL-") && e.contains("\"span\""),
"{e}"
);
}
#[test]
fn console_run_honors_attached_loop_policy() {
let dir = TempDir::new().unwrap();
let db = dir.path().join("pol.db");
let mut m = Areev::open(db.to_str().unwrap()).unwrap();
use areev_core::types::{Fact, Grain};
m.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
m.add(&Fact::new("acme", "tier", "enterprise").namespace("caller")).unwrap();
let facade = AreevFacade::with_session(m, Some("caller".into()), None);
let policy = areev_loop::Policy::from_json(
r#"{"auto_apply_enabled": true,
"auto_apply": [{"analyzer": "loop.duplicate_sweep", "targets": ["memory"], "max_severity": "low"}]}"#,
)
.unwrap();
let server = UiServer::new(facade, "pol.db".into())
.with_auth("t".to_string())
.with_loop_policy(policy);
let listener = UiServer::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
std::thread::spawn(move || server.serve(listener));
let post = "POST /api/loop/run HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}";
let resp = req(&addr, post);
assert!(resp.contains("\"auto_applied\":1"), "{resp}");
}
#[test]
fn loop_outcomes_route_returns_the_gate_record() {
let dir = TempDir::new().unwrap();
let db = dir.path().join("ui.db");
let m = Areev::open(db.to_str().unwrap()).unwrap();
let facade = AreevFacade::with_session(m, Some("caller".into()), None);
let server = UiServer::new(facade, "ui.db".into()).with_auth("t".to_string());
let listener = UiServer::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
std::thread::spawn(move || server.serve(listener));
let resp = req(&addr, "GET /api/loop/outcomes HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
assert!(resp.contains("200 OK"), "{resp}");
let body = &resp[resp.find("\r\n\r\n").unwrap() + 4..];
let v: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(v["ok"], true, "{v}");
assert_eq!(v["outcomes"], serde_json::json!([]), "{v}");
}
#[test]
fn console_auth_guards_every_request() {
use std::io::Read as _;
const BASIC_OK: &str = "eDpvcGVuc2VzYW1l";
const BASIC_BAD: &str = "eDp3cm9uZw==";
let dir = TempDir::new().unwrap();
let db = dir.path().join("auth.db");
let m = Areev::open(db.to_str().unwrap()).unwrap();
let facade = AreevFacade::with_session(m, Some("caller".into()), None);
let server = UiServer::new(facade, "auth.db".into()).with_auth("opensesame".into());
let listener = UiServer::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
std::thread::spawn(move || server.serve(listener));
let status = |raw: &str| -> String {
let mut s = TcpStream::connect(&addr).unwrap();
s.write_all(raw.as_bytes()).unwrap();
let mut out = String::new();
s.read_to_string(&mut out).unwrap();
out.lines().next().unwrap_or("").to_string()
};
let g = |auth: &str| {
status(&format!(
"GET /api/config HTTP/1.1\r\nHost: 127.0.0.1\r\n{auth}Connection: close\r\n\r\n"
))
};
let no_auth = req(&addr, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
assert!(no_auth.contains("401"), "page must be guarded: {no_auth}");
assert!(
no_auth.contains("WWW-Authenticate: Basic"),
"401 must challenge so browsers prompt: {no_auth}"
);
assert!(g("").contains("401"), "no creds → 401");
assert!(g("Authorization: Bearer opensesame\r\n").contains("200"), "bearer → 200");
assert!(g(&format!("Authorization: Basic {BASIC_OK}\r\n")).contains("200"), "basic → 200");
assert!(g(&format!("Authorization: Basic {BASIC_BAD}\r\n")).contains("401"), "wrong basic → 401");
assert!(g("Authorization: Bearer nope\r\n").contains("401"), "wrong bearer → 401");
}
#[test]
fn non_loopback_host_is_rejected_unless_allow_remote() {
fn spawn(server: UiServer) -> String {
let listener = UiServer::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
std::thread::spawn(move || server.serve(listener));
addr
}
let mk = || {
let dir = TempDir::new().unwrap();
let db = dir.path().join("ui.db");
let m = Areev::open(db.to_str().unwrap()).unwrap();
std::mem::forget(dir);
AreevFacade::with_session(m, Some("caller".into()), None)
};
let addr = spawn(UiServer::new(mk(), "ui.db".into()));
let evil = req(&addr, "GET /api/browse HTTP/1.1\r\nHost: attacker.example\r\nConnection: close\r\n\r\n");
assert!(evil.contains("403") && evil.contains("non-loopback Host"), "rebinding GET must be 403: {evil}");
let ok = req(&addr, "GET /api/browse HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
assert!(ok.contains("200"), "loopback GET must pass: {ok}");
let addr = spawn(UiServer::new(mk(), "ui.db".into()).allow_remote(true));
let remote = req(&addr, "GET /api/browse HTTP/1.1\r\nHost: memories.example\r\nConnection: close\r\n\r\n");
assert!(remote.contains("200"), "allow_remote must accept non-loopback Host: {remote}");
}
#[test]
fn cal_error_payload_carries_the_store_code() {
let dir = TempDir::new().unwrap();
let db = dir.path().join("hold.db");
let m = Areev::open(db.to_str().unwrap()).unwrap();
let facade = AreevFacade::with_session(m, Some("caller".into()), None);
let mut fields = serde_json::Map::new();
fields.insert("subject".into(), serde_json::json!("j"));
fields.insert("relation".into(), serde_json::json!("stage"));
fields.insert("object".into(), serde_json::json!("v1"));
fields.insert("namespace".into(), serde_json::json!("caller"));
let hash = areev_cal::CalStoreFacade::cal_add(&facade, "fact", &fields)
.unwrap()
.to_hex();
facade
.with_store(|m| m.place_hold("caller", "SEC inquiry", "user:cco", 1_700_000_000_000))
.unwrap();
let server = UiServer::new(facade, "hold.db".into()).with_auth("t".to_string());
let listener = UiServer::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
std::thread::spawn(move || server.serve(listener));
let post = |query: &str| {
let body = serde_json::json!({ "query": query }).to_string();
let raw = format!(
"POST /api/cal 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
);
let resp = req(&addr, &raw);
let json = &resp[resp.find("\r\n\r\n").unwrap() + 4..];
serde_json::from_str::<serde_json::Value>(json).unwrap()
};
let v = post(&format!(r#"FORGET sha256:{hash} BECAUSE "cleanup""#));
assert_eq!(v["ok"], false, "{v}");
assert_eq!(v["code"], "CAL-E093", "{v}");
assert_eq!(v["store_code"], "STO-E009", "{v}");
let v = post(r#"RECALL facts WHERE subject == "j""#);
assert_eq!(v["ok"], false, "{v}");
assert!(v.get("store_code").is_none(), "{v}");
}