//! Radar end to end (spec §17, §8.3, §21.2): the owner signs in with a //! one-time link their CLI signed, every page renders and none shows //! without the session, a post needs the session's CSRF token and our own //! origin and is recorded as a verified session event. mod common; use common::{add_agent, hermetic, run, setup, text, Serving}; use serde_json::json; use spectra_mcp::client::Client; fn link(d: &std::path::Path) -> String { let o = hermetic(d).args(["open", "--no-browser"]).output().unwrap(); let t = text(&o); let url = t.split_whitespace().find(|w| w.starts_with("http://")).unwrap_or_else(|| panic!("no link in {t}")).to_string(); url.split_once("7350") .or_else(|| url.split_once(":0")) .map(|(_, p)| p.to_string()) .unwrap_or_else(|| format!("/{}", url.splitn(4, '/').nth(3).unwrap())) } #[tokio::test] async fn the_owner_signs_in_reads_every_page_and_posts() { let dir = setup(); let d = dir.path(); let agent = add_agent(d, "claude-oxygen", &[]); let srv = Serving::start(d); let a = Client::new(&srv.socket, Some(agent)); let say = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "say", "arguments": { "body": "@andy **ready** for review", "channel": "humans" } } }); assert_eq!(a.mcp("2025-11-25", &say).await.unwrap().status, 200); let web = Client::new(&srv.socket, None); for path in ["/", "/channels", "/c/general", "/log"] { let r = web.send("GET", path, &[], vec![]).await.unwrap(); assert_eq!(r.status, 401, "{path} without a session"); assert!(String::from_utf8_lossy(&r.body).contains("spectra open")); } let path = link(d); let r = web.send("GET", &path, &[], vec![]).await.unwrap(); assert_eq!(r.status, 303, "{}", String::from_utf8_lossy(&r.body)); let cookie = r.header("set-cookie").unwrap().split(';').next().unwrap().to_string(); assert!(r.header("set-cookie").unwrap().contains("HttpOnly")); assert_eq!(web.send("GET", &path, &[], vec![]).await.unwrap().status, 401, "a link works once"); let get = |p: &'static str| { let (web, cookie) = (&web, cookie.clone()); async move { let r = web.send("GET", p, &[("cookie", cookie)], vec![]).await.unwrap(); assert_eq!(r.status, 200, "{p}: {}", String::from_utf8_lossy(&r.body)); assert!(r.header("content-security-policy").unwrap().contains("default-src 'self'")); String::from_utf8_lossy(&r.body).to_string() } }; let live = get("/").await; assert!(live.contains("claude-oxygen") && live.contains("Andy McCall's agent"), "escaped by askama: {live}"); assert!(live.contains("1 unread")); assert!(get("/channels").await.contains("#humans")); let humans = get("/c/humans").await; assert!(humans.contains("ready"), "Markdown rendered: {humans}"); let log = get("/log").await; assert!(log.contains("message.posted") && log.contains("signature · level 2")); let csrf = humans.split("name=\"_csrf\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string(); let form = |csrf: &str| format!("_csrf={csrf}&body=Looks+good%2C+%40claude-oxygen").into_bytes(); let h = |origin: &str| { vec![("cookie", cookie.clone()), ("content-type", "application/x-www-form-urlencoded".to_string()), ("origin", origin.to_string())] }; assert_eq!(web.send("POST", "/c/humans", &h("http://evil.example"), form(&csrf)).await.unwrap().status, 403); // Without an Origin header (not a browser) the CSRF token alone guards // the form; a browser's Origin must also be the daemon's own address. let no_origin = vec![("cookie", cookie.clone()), ("content-type", "application/x-www-form-urlencoded".to_string())]; assert_eq!(web.send("POST", "/c/humans", &no_origin, form("stale")).await.unwrap().status, 403); assert_eq!(web.send("POST", "/c/humans", &no_origin, form(&csrf)).await.unwrap().status, 303); assert!(get("/c/humans").await.contains("Looks good")); assert_eq!(web.send("POST", "/log/verify", &no_origin, format!("_csrf={csrf}").into_bytes()).await.unwrap().status, 303); assert!(get("/log").await.contains("clean at")); srv.stop(); let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap(); assert!(v["findings"].as_array().unwrap().is_empty(), "the session post verifies: {v}"); let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); let m: (String, i64) = sqlx::query_as("SELECT actor, auth_method FROM events WHERE kind = 'message.posted' ORDER BY seq DESC LIMIT 1") .fetch_one(&pool) .await .unwrap(); assert_eq!(m, ("human".to_string(), 4), "recorded as the owner, through a session"); }