| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
mod common; |
| 7 |
|
| 8 |
use common::{add_agent, hermetic, run, setup, text, Serving}; |
| 9 |
use serde_json::json; |
| 10 |
use spectra_mcp::client::Client; |
| 11 |
|
| 12 |
fn link(d: &std::path::Path) -> String { |
| 13 |
let o = hermetic(d).args(["open", "--no-browser"]).output().unwrap(); |
| 14 |
let t = text(&o); |
| 15 |
let url = t.split_whitespace().find(|w| w.starts_with("http://")).unwrap_or_else(|| panic!("no link in {t}")).to_string(); |
| 16 |
url.split_once("7350") |
| 17 |
.or_else(|| url.split_once(":0")) |
| 18 |
.map(|(_, p)| p.to_string()) |
| 19 |
.unwrap_or_else(|| format!("/{}", url.splitn(4, '/').nth(3).unwrap())) |
| 20 |
} |
| 21 |
|
| 22 |
#[tokio::test] |
| 23 |
async fn the_owner_signs_in_reads_every_page_and_posts() { |
| 24 |
let dir = setup(); |
| 25 |
let d = dir.path(); |
| 26 |
let agent = add_agent(d, "claude-oxygen", &[]); |
| 27 |
let srv = Serving::start(d); |
| 28 |
let a = Client::new(&srv.socket, Some(agent)); |
| 29 |
let say = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "say", "arguments": { "body": "@andy **ready** for review", "channel": "humans" } } }); |
| 30 |
assert_eq!(a.mcp("2025-11-25", &say).await.unwrap().status, 200); |
| 31 |
|
| 32 |
let web = Client::new(&srv.socket, None); |
| 33 |
for path in ["/", "/channels", "/c/general", "/log"] { |
| 34 |
let r = web.send("GET", path, &[], vec![]).await.unwrap(); |
| 35 |
assert_eq!(r.status, 401, "{path} without a session"); |
| 36 |
assert!(String::from_utf8_lossy(&r.body).contains("spectra open")); |
| 37 |
} |
| 38 |
|
| 39 |
let path = link(d); |
| 40 |
let r = web.send("GET", &path, &[], vec![]).await.unwrap(); |
| 41 |
assert_eq!(r.status, 303, "{}", String::from_utf8_lossy(&r.body)); |
| 42 |
let cookie = r.header("set-cookie").unwrap().split(';').next().unwrap().to_string(); |
| 43 |
assert!(r.header("set-cookie").unwrap().contains("HttpOnly")); |
| 44 |
assert_eq!(web.send("GET", &path, &[], vec![]).await.unwrap().status, 401, "a link works once"); |
| 45 |
|
| 46 |
let get = |p: &'static str| { |
| 47 |
let (web, cookie) = (&web, cookie.clone()); |
| 48 |
async move { |
| 49 |
let r = web.send("GET", p, &[("cookie", cookie)], vec![]).await.unwrap(); |
| 50 |
assert_eq!(r.status, 200, "{p}: {}", String::from_utf8_lossy(&r.body)); |
| 51 |
assert!(r.header("content-security-policy").unwrap().contains("default-src 'self'")); |
| 52 |
String::from_utf8_lossy(&r.body).to_string() |
| 53 |
} |
| 54 |
}; |
| 55 |
let live = get("/").await; |
| 56 |
assert!(live.contains("claude-oxygen") && live.contains("Andy McCall's agent"), "escaped by askama: {live}"); |
| 57 |
assert!(live.contains("1 unread")); |
| 58 |
assert!(get("/channels").await.contains("#humans")); |
| 59 |
let humans = get("/c/humans").await; |
| 60 |
assert!(humans.contains("<strong>ready</strong>"), "Markdown rendered: {humans}"); |
| 61 |
let log = get("/log").await; |
| 62 |
assert!(log.contains("message.posted") && log.contains("signature · level 2")); |
| 63 |
|
| 64 |
let csrf = humans.split("name=\"_csrf\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string(); |
| 65 |
let form = |csrf: &str| format!("_csrf={csrf}&body=Looks+good%2C+%40claude-oxygen").into_bytes(); |
| 66 |
let h = |origin: &str| { |
| 67 |
vec![("cookie", cookie.clone()), ("content-type", "application/x-www-form-urlencoded".to_string()), ("origin", origin.to_string())] |
| 68 |
}; |
| 69 |
assert_eq!(web.send("POST", "/c/humans", &h("http://evil.example"), form(&csrf)).await.unwrap().status, 403); |
| 70 |
|
| 71 |
|
| 72 |
let no_origin = vec![("cookie", cookie.clone()), ("content-type", "application/x-www-form-urlencoded".to_string())]; |
| 73 |
assert_eq!(web.send("POST", "/c/humans", &no_origin, form("stale")).await.unwrap().status, 403); |
| 74 |
assert_eq!(web.send("POST", "/c/humans", &no_origin, form(&csrf)).await.unwrap().status, 303); |
| 75 |
assert!(get("/c/humans").await.contains("Looks good")); |
| 76 |
|
| 77 |
assert_eq!(web.send("POST", "/log/verify", &no_origin, format!("_csrf={csrf}").into_bytes()).await.unwrap().status, 303); |
| 78 |
assert!(get("/log").await.contains("clean at")); |
| 79 |
srv.stop(); |
| 80 |
|
| 81 |
let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap(); |
| 82 |
assert!(v["findings"].as_array().unwrap().is_empty(), "the session post verifies: {v}"); |
| 83 |
let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); |
| 84 |
let m: (String, i64) = sqlx::query_as("SELECT actor, auth_method FROM events WHERE kind = 'message.posted' ORDER BY seq DESC LIMIT 1") |
| 85 |
.fetch_one(&pool) |
| 86 |
.await |
| 87 |
.unwrap(); |
| 88 |
assert_eq!(m, ("human".to_string(), 4), "recorded as the owner, through a session"); |
| 89 |
} |