OXYGENHUB
# c7161ec1a607 spectra / tests / radar.rs
6.4 KiB · Rust · last changed Briefs and Radar's Constellation (#29, #30); spec Draft 0.6 1 day 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
    // Briefs: set from Radar, signed by the session, delivered to the agent.
78
    assert!(get("/briefs").await.contains("Overall brief"));
79
    let brief = format!("_csrf={csrf}&project=oxygen&text=Freeze+the+protocol.%0D%0AThen+ship.").into_bytes();
80
    assert_eq!(web.send("POST", "/briefs", &no_origin, brief).await.unwrap().status, 303);
81
    let briefs = get("/briefs").await;
82
    assert!(briefs.contains("Freeze the protocol.\nThen ship."), "CRLF from the form is stored as LF: {briefs}");
83
    assert!(get("/c/oxygen").await.contains("Project brief"));
84
    let rules = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "rules", "arguments": {} } });
85
    let got = spectra_mcp::client::rpc_result(&a.mcp("2025-11-25", &rules).await.unwrap()).unwrap();
86
    assert!(got["result"]["content"][0]["text"].as_str().unwrap().contains("Freeze the protocol."), "{got}");
87
    assert_eq!(web.send("POST", "/briefs", &no_origin, b"_csrf=stale&text=x".to_vec()).await.unwrap().status, 403);
88
89
    // The Constellation: a still picture of the project and its agent, and its data.
90
    let sky = get("/constellation").await;
91
    assert!(sky.contains("class=\"prism\"") && sky.contains(">claude-oxygen</text>") && sky.contains("/radar/constellation.js"), "{sky}");
92
    assert!(!sky.contains("data-live"), "the picture is not replaced wholesale by the page refresher");
93
    let data: serde_json::Value = serde_json::from_str(&get("/radar/constellation.json").await).unwrap();
94
    assert_eq!(data["projects"][0]["agents"][0]["name"], "claude-oxygen", "{data}");
95
    assert_eq!(web.send("GET", "/radar/constellation.json", &[], vec![]).await.unwrap().status, 401, "the owner's only");
96
97
    assert_eq!(web.send("POST", "/log/verify", &no_origin, format!("_csrf={csrf}").into_bytes()).await.unwrap().status, 303);
98
    assert!(get("/log").await.contains("clean at"));
99
    srv.stop();
100
101
    let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap();
102
    assert!(v["findings"].as_array().unwrap().is_empty(), "the session post verifies: {v}");
103
    let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap();
104
    let m: (String, i64) = sqlx::query_as("SELECT actor, auth_method FROM events WHERE kind = 'message.posted' ORDER BY seq DESC LIMIT 1")
105
        .fetch_one(&pool)
106
        .await
107
        .unwrap();
108
    assert_eq!(m, ("human".to_string(), 4), "recorded as the owner, through a session");
109
}