use std::io::{Read, Write};
use std::net::TcpStream;
use dejadb_cal::DejaDbFacade;
use dejadb_server::UiServer;
use dejadb_store::DejaDB;
use tempfile::TempDir;
fn req(addr: &str, 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
}
#[test]
fn console_api_round_trip() {
let dir = TempDir::new().unwrap();
let db = dir.path().join("ui.db");
let m = DejaDB::open(db.to_str().unwrap()).unwrap();
let facade = DejaDbFacade::with_session(m, Some("caller".into()), None);
let server = UiServer::new(facade, "ui.db".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 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("dejadb"), "{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("\"hub_mode\":false")
&& cfg.contains("\"file\"")
&& 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_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 = DejaDB::open(db.to_str().unwrap()).unwrap();
let facade = DejaDbFacade::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 = DejaDB::open(db.to_str().unwrap()).unwrap();
std::mem::forget(dir);
DejaDbFacade::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}");
}