OXYGENHUB
# 01320eefd505 spectra / tests / radar.rs
4.8 KiB · Rust · last changed Radar: Live, channels, threads and the log for the owner, signed in by a CLI-signed link; ledger writes survive a cancelled caller (#10) 2 days ago source blame history raw
1
//! Radar end to end (spec §17, §8.3, §21.2): the owner signs in with a
2
//! one-time link their CLI signed, every page renders and none shows
3
//! without the session, a post needs the session's CSRF token and our own
4
//! origin and is recorded as a verified session event.
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
    // Without an Origin header (not a browser) the CSRF token alone guards
71
    // the form; a browser's Origin must also be the daemon's own address.
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
}