use super::{
desk_refusal, dir_of, hello_allows, parse_range, Span, Ui, ABOUT_JS, APP_CSS, APP_JS, BOOT_JS,
BROWSE_JS, DESK_JS, DIFF_JS, FIND_JS, FRAME_JS, GAME_JS, HOME_JS, INDEX_HTML, KEYS_JS, LOOK_JS,
MENU_JS, MMD_JS, NOTE_JS, PALETTE_JS, PATHS_JS, TIP_JS, TOAST_JS,
};
use super::{
new_app, router, Body, Paths, Router, StatusCode, Store, CAPABILITY_HEADER, NOT_THIS_HOST,
NOT_THIS_ORIGIN, WINDOW_HEADER,
};
use crate::capability::Capabilities;
use axum::http::{header, HeaderMap, HeaderValue};
use tower::ServiceExt;
#[test]
fn a_file_opens_beside_itself() {
let tmp = crate::store::tempdir::Dir::new("snyvi-reveal");
let file = tmp.path.join("notes.md");
std::fs::write(&file, "x").unwrap();
assert_eq!(dir_of(file), Some(tmp.path.clone()));
assert_eq!(dir_of(tmp.path.clone()), Some(tmp.path.clone()));
}
#[test]
fn ranges_are_read_the_way_players_send_them() {
assert_eq!(parse_range("bytes=0-", 1000), Span::Part(0, 999));
assert_eq!(parse_range("bytes=100-199", 1000), Span::Part(100, 199));
assert_eq!(
parse_range("bytes=900-5000", 1000),
Span::Part(900, 999),
"clamped to the end"
);
assert_eq!(
parse_range("bytes=-500", 1000),
Span::Part(500, 999),
"a suffix"
);
assert_eq!(parse_range("bytes=-5000", 1000), Span::Part(0, 999));
assert_eq!(parse_range("bytes=1000-", 1000), Span::Unsatisfiable);
assert_eq!(
parse_range("bytes=0-", 0),
Span::Unsatisfiable,
"an empty file"
);
assert_eq!(parse_range("bytes=-0", 1000), Span::Unsatisfiable);
assert_eq!(
parse_range("bytes=0-1,5-9", 1000),
Span::Whole,
"several fall back to all"
);
assert_eq!(parse_range("items=0-1", 1000), Span::Whole);
assert_eq!(parse_range("bytes=9-3", 1000), Span::Whole);
assert_eq!(parse_range("bytes=x-", 1000), Span::Whole);
}
#[test]
fn only_a_frame_carrying_a_live_capability_opens_a_desk() {
let caps = Capabilities::default();
let cap = caps.mint().unwrap();
assert!(hello_allows(
&caps,
Some(&format!(r#"{{"capability":"{cap}"}}"#))
));
assert!(!hello_allows(&caps, None));
assert!(!hello_allows(
&caps,
Some(&format!(r#"{{"capability":"{}"}}"#, "b".repeat(64)))
));
assert!(!hello_allows(&caps, Some(r#"{"capability":""}"#)));
assert!(!hello_allows(
&caps,
Some(&format!(r#"{{"token":"{cap}"}}"#))
));
assert!(!hello_allows(&caps, Some(&format!(r#""{cap}""#))));
assert!(!hello_allows(&caps, Some("")));
assert!(!hello_allows(&caps, Some("not json at all")));
}
#[test]
fn the_window_count_is_never_consulted_on_the_desk_path() {
let src = include_str!("ws.rs");
let from = src
.find("async fn desk_socket")
.expect("the desk socket should be in ws.rs");
let to = src[from..]
.find("\npub(crate) fn hello_allows")
.expect("hello_allows follows the socket")
+ from;
let path = &src[from..to];
for forgeable in ["has_window", "windows", "is_window", "EventsQ"] {
assert!(
!path.contains(forgeable),
"the desk path reads `{forgeable}`, which a browser tab can forge"
);
}
assert!(
src.contains(
"fn hello_allows(caps: &crate::capability::Capabilities, frame: Option<&str>)"
),
"the desk gate should see a capability and a frame, and nothing else"
);
}
#[test]
fn a_desk_route_takes_its_capability_from_a_header_and_nowhere_else() {
let caps = Capabilities::default();
let cap = caps.mint().unwrap();
let none = std::collections::HashMap::new();
let ours = |cap: &str| {
let mut h = HeaderMap::new();
h.insert(
header::ORIGIN,
HeaderValue::from_str(&crate::config::base_url()).unwrap(),
);
h.insert("sec-fetch-site", HeaderValue::from_static("same-origin"));
if !cap.is_empty() {
h.insert(
super::CAPABILITY_HEADER,
HeaderValue::from_str(cap).unwrap(),
);
}
h
};
assert_eq!(desk_refusal(&caps, &ours(&cap), &none), None);
assert_eq!(desk_refusal(&caps, &ours(""), &none), Some("no capability"));
assert_eq!(
desk_refusal(&caps, &ours(&"c".repeat(64)), &none),
Some("no capability")
);
let query = std::collections::HashMap::from([("cap".to_string(), cap.clone())]);
assert_eq!(
desk_refusal(&caps, &ours(&cap), &query),
Some("the capability is not a query parameter")
);
let query = std::collections::HashMap::from([("capability".to_string(), cap.clone())]);
assert_eq!(
desk_refusal(&caps, &ours(&cap), &query),
Some("the capability is not a query parameter")
);
let mut elsewhere = ours(&cap);
elsewhere.insert(
header::ORIGIN,
HeaderValue::from_static("http://evil.example"),
);
assert_eq!(
desk_refusal(&caps, &elsewhere, &none),
Some("not from this page")
);
let mut bare = HeaderMap::new();
bare.insert(
super::CAPABILITY_HEADER,
HeaderValue::from_str(&cap).unwrap(),
);
assert_eq!(
desk_refusal(&caps, &bare, &none),
Some("not from this page")
);
let read = |host: &str, site: Option<&'static str>| {
let mut h = HeaderMap::new();
h.insert(header::HOST, HeaderValue::from_str(host).unwrap());
if let Some(s) = site {
h.insert("sec-fetch-site", HeaderValue::from_static(s));
}
h.insert(
super::CAPABILITY_HEADER,
HeaderValue::from_str(&cap).unwrap(),
);
h
};
let here = format!("127.0.0.1:{}", crate::config::port());
assert_eq!(
desk_refusal(&caps, &read(&here, Some("same-origin")), &none),
None
);
assert_eq!(desk_refusal(&caps, &read(&here, None), &none), None);
let rebound = format!("evil.example:{}", crate::config::port());
assert_eq!(
desk_refusal(&caps, &read(&rebound, Some("same-origin")), &none),
Some("not from this page")
);
assert_eq!(
desk_refusal(&caps, &read(&here, Some("cross-site")), &none),
Some("not from this page")
);
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Gate {
Open,
Reader,
Desk,
Token,
Window,
Mint,
}
const ROUTES: &[(&str, &str, Option<&str>, Gate, bool)] = &[
("GET", "/", None, Gate::Open, true),
("GET", "/inbox", None, Gate::Open, true),
("GET", "/api/home", None, Gate::Open, true),
("GET", "/connect", None, Gate::Open, true),
("GET", "/start", None, Gate::Open, true),
("GET", "/welcome", None, Gate::Open, true),
("GET", "/d/nope", None, Gate::Open, true),
("GET", "/b/nope", None, Gate::Open, true),
("GET", "/b/nope/x.md", None, Gate::Open, true),
("GET", "/files/nope/x.png", None, Gate::Open, true),
("GET", "/assets/mermaid.js", None, Gate::Open, true),
("GET", "/assets/app.js", None, Gate::Open, true),
("GET", "/assets/fonts/x.woff2", None, Gate::Open, true),
("GET", "/api/health", None, Gate::Open, true),
("GET", "/api/about", None, Gate::Open, true),
("GET", "/api/agents", None, Gate::Open, true),
(
"POST",
"/api/agents/claude/connect",
None,
Gate::Desk,
false,
),
("GET", "/api/tree", None, Gate::Open, true),
("GET", "/api/projects/1/tree", None, Gate::Open, true),
("GET", "/api/workflows/1/tree", None, Gate::Open, true),
("GET", "/api/inbox", None, Gate::Open, true),
("GET", "/api/search?q=x", None, Gate::Open, true),
("POST", "/api/docs", Some("{}"), Gate::Token, true),
("GET", "/api/docs/nope", None, Gate::Open, true),
(
"POST",
"/api/docs/nope/pin",
Some(r#"{"pinned":true}"#),
Gate::Reader,
true,
),
("POST", "/api/docs/nope/read", None, Gate::Reader, true),
("GET", "/api/queue", None, Gate::Open, true),
("POST", "/api/queue/clear", None, Gate::Reader, true),
(
"POST",
"/api/queue/unread",
Some(r#"{"ids":[]}"#),
Gate::Reader,
true,
),
("POST", "/api/docs/nope/delete", None, Gate::Reader, true),
("POST", "/api/docs/nope/undelete", None, Gate::Reader, true),
("GET", "/api/removed", None, Gate::Open, true),
("GET", "/api/docs/nope/history", None, Gate::Open, true),
(
"POST",
"/api/projects/1/rename",
Some(r#"{"name":"x"}"#),
Gate::Reader,
true,
),
(
"POST",
"/api/workflows/1/rename",
Some(r#"{"name":"x"}"#),
Gate::Reader,
true,
),
("GET", "/api/docs/nope/split", None, Gate::Open, true),
("GET", "/api/docs/nope/outline", None, Gate::Open, true),
("GET", "/api/notes", None, Gate::Open, true),
(
"POST",
"/api/notes",
Some(r#"{"text":"x"}"#),
Gate::Token,
true,
),
("POST", "/api/notes/seen", None, Gate::Reader, true),
(
"POST",
"/api/notes/dismiss",
Some(r#"{"ids":[]}"#),
Gate::Reader,
true,
),
(
"POST",
"/api/notes/restore",
Some(r#"{"ids":[]}"#),
Gate::Reader,
true,
),
("POST", "/api/focus", None, Gate::Reader, true),
("POST", "/api/shutdown", None, Gate::Window, true),
("POST", "/api/restart", Some("{}"), Gate::Window, true),
("DELETE", "/api/restart", None, Gate::Window, true),
(
"POST",
"/api/update/check",
Some(r#"{"lift":false}"#),
Gate::Window,
true,
),
(
"POST",
"/api/update/auto",
Some(r#"{"on":false}"#),
Gate::Window,
true,
),
("POST", "/api/update/later", Some("{}"), Gate::Window, true),
("GET", "/api/reset", None, Gate::Open, true),
(
"POST",
"/api/reset",
Some(r#"{"documents":999}"#),
Gate::Reader,
true,
),
("POST", "/api/terminal", Some("{}"), Gate::Reader, true),
("POST", "/api/reveal", Some("{}"), Gate::Reader, true),
(
"POST",
"/api/resolve",
Some(r#"{"word":"x"}"#),
Gate::Desk,
true,
),
("GET", "/api/browse", None, Gate::Open, true),
(
"POST",
"/api/browse",
Some(r#"{"path":"."}"#),
Gate::Token,
true,
),
("POST", "/api/browse/pick", None, Gate::Desk, false),
("POST", "/api/studio/pick", None, Gate::Desk, false),
(
"POST",
"/api/desks/1/studio-folder",
Some(r#"{"path":"/nowhere/at/all"}"#),
Gate::Desk,
true,
),
("GET", "/api/studio/1/look?rel=", None, Gate::Desk, true),
(
"POST",
"/api/studio/1/keep",
Some(r#"{"rel":"a.png"}"#),
Gate::Desk,
true,
),
(
"POST",
"/api/studio/1/hide",
Some(r#"{"rel":"a.png"}"#),
Gate::Desk,
true,
),
(
"POST",
"/api/studio/1/unhide",
Some(r#"{"rel":"a.png"}"#),
Gate::Desk,
true,
),
("GET", "/api/studio/1/raw/a.png", None, Gate::Open, true),
(
"POST",
"/api/studio/1/selection",
Some(r#"{"rel":"a.png"}"#),
Gate::Desk,
true,
),
(
"GET",
"/api/panes/0123456789abcdef0123456789abcdef/studio",
None,
Gate::Token,
true,
),
("POST", "/api/browse/nope/close", None, Gate::Reader, true),
("POST", "/api/browse/nope/reopen", None, Gate::Reader, true),
("GET", "/api/browse/nope/tree", None, Gate::Open, true),
(
"GET",
"/api/browse/nope/file?path=x.md",
None,
Gate::Open,
true,
),
(
"GET",
"/api/browse/nope/raw?path=x.md",
None,
Gate::Open,
true,
),
("GET", "/api/browse/nope/raw/x.md", None, Gate::Open, true),
("GET", "/api/browse/nope/find?q=x", None, Gate::Open, true),
(
"GET",
"/api/browse/nope/outline?path=x.md",
None,
Gate::Open,
true,
),
("GET", "/api/docs/nope/raw", None, Gate::Open, true),
("GET", "/api/docs/nope/blob", None, Gate::Open, true),
("GET", "/api/compare/a/b", None, Gate::Open, true),
("GET", "/api/events", None, Gate::Open, true),
("POST", "/api/capability", None, Gate::Mint, true),
("GET", "/api/desk", None, Gate::Open, true),
("GET", "/api/desks", None, Gate::Desk, true),
("POST", "/api/desks", Some("{}"), Gate::Desk, true),
(
"POST",
"/api/desks/1/rename",
Some(r#"{"name":"x"}"#),
Gate::Desk,
true,
),
(
"POST",
"/api/desks/1/layout",
Some(r#"{"col":0.5,"row":0.5}"#),
Gate::Desk,
true,
),
(
"POST",
"/api/desks/1/move",
Some(r#"{"from":1,"to":2}"#),
Gate::Desk,
true,
),
("POST", "/api/desks/1/delete", None, Gate::Desk, true),
("POST", "/api/desks/1/reopen", None, Gate::Desk, true),
("POST", "/api/desks/1/panes", Some("{}"), Gate::Desk, true),
("GET", "/api/desks/1/docs", None, Gate::Desk, true),
("POST", "/api/desks/1/docs/d/remove", None, Gate::Desk, true),
(
"POST",
"/api/desks/1/docs/d/restore",
None,
Gate::Desk,
true,
),
("GET", "/api/desks/1/notes", None, Gate::Desk, true),
(
"POST",
"/api/desks/1/notes",
Some(r#"{"text":"x"}"#),
Gate::Desk,
true,
),
("POST", "/api/desks/1/notes/1", Some("{}"), Gate::Desk, true),
(
"POST",
"/api/desks/1/notes/1/remove",
None,
Gate::Desk,
true,
),
(
"POST",
"/api/desks/1/notes/1/restore",
None,
Gate::Desk,
true,
),
("POST", "/api/desks/1/notes/1/keep", None, Gate::Desk, true),
(
"POST",
"/api/desks/1/leftoff",
Some(r#"{"text":"x","at":0}"#),
Gate::Desk,
true,
),
("GET", "/api/desks/1/keys", None, Gate::Desk, true),
(
"POST",
"/api/desks/1/keys",
Some(r#"{"name":"X_KEY","value":"y"}"#),
Gate::Desk,
false,
),
(
"POST",
"/api/desks/1/keys/X_KEY/remove",
Some("{}"),
Gate::Desk,
false,
),
("POST", "/api/desks/1/visit", None, Gate::Desk, true),
("GET", "/api/desks/1/git", None, Gate::Desk, true),
(
"POST",
"/api/desks/order",
Some(r#"{"ids":[1]}"#),
Gate::Desk,
true,
),
("POST", "/api/desks/1/park", Some("{}"), Gate::Desk, true),
(
"POST",
"/api/desks/1/week",
Some(r#"{"title":"t","content":"c"}"#),
Gate::Desk,
true,
),
("POST", "/api/desks/1/notes/1/image", None, Gate::Desk, true),
(
"POST",
"/api/desks/1/notes/1/images",
Some(r#"{"images":[]}"#),
Gate::Desk,
true,
),
(
"GET",
"/api/desks/1/note-images/x.png",
None,
Gate::Desk,
true,
),
("GET", "/api/brief", None, Gate::Desk, true),
(
"POST",
"/api/brief",
Some(r#"{"on":true}"#),
Gate::Desk,
true,
),
("POST", "/api/panes/nope/delete", None, Gate::Desk, true),
("POST", "/api/panes/nope/restore", None, Gate::Desk, true),
(
"POST",
"/api/panes/nope/rename",
Some(r#"{"name":"x"}"#),
Gate::Desk,
true,
),
(
"POST",
"/api/panes/nope/start",
Some(r#"{"cols":80,"rows":24}"#),
Gate::Desk,
true,
),
("POST", "/api/panes/nope/stop", None, Gate::Desk, true),
(
"POST",
"/api/panes/nope/agent",
Some("{}"),
Gate::Token,
true,
),
("GET", "/api/panes/nope/notes", None, Gate::Token, true),
(
"POST",
"/api/panes/nope/notes/1/tick",
None,
Gate::Token,
true,
),
(
"POST",
"/api/panes/nope/notes/1/mark",
None,
Gate::Token,
true,
),
(
"POST",
"/api/panes/nope/name",
Some(r#"{"name":"x"}"#),
Gate::Token,
true,
),
("GET", "/api/panes/nope/brief", None, Gate::Token, true),
("GET", "/api/panes/nope/changes", None, Gate::Token, true),
(
"POST",
"/api/panes/nope/leftoff",
Some("{}"),
Gate::Token,
true,
),
(
"POST",
"/api/panes/nope/suggest",
Some("{}"),
Gate::Token,
true,
),
("POST", "/api/panes/nope/paste", None, Gate::Desk, true),
("GET", "/desks", None, Gate::Open, true),
("GET", "/desk/1", None, Gate::Open, true),
];
async fn ask(
router: &Router,
method: &str,
path: &str,
body: Option<&str>,
headers: &[(&str, &str)],
) -> (StatusCode, String) {
let mut req = axum::http::Request::builder().method(method).uri(path);
for (k, v) in headers {
req = req.header(*k, *v);
}
let req = match body {
Some(b) => req
.header("content-type", "application/json")
.body(Body::from(b.to_string()))
.unwrap(),
None => req.body(Body::empty()).unwrap(),
};
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let text = if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {
let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
String::from_utf8_lossy(&bytes).into_owned()
} else {
String::new()
};
(status, text)
}
struct Leaves {
host: String,
origin: String,
bearer: String,
cap: String,
window: String,
}
fn gated_router(name: &str) -> (crate::store::tempdir::Dir, Router, Leaves) {
let tmp = crate::store::tempdir::Dir::new(name);
let paths = Paths {
data_dir: tmp.path.join("data"),
config_dir: tmp.path.join("config"),
docs_dir: tmp.path.join("data").join("docs"),
db_path: tmp.path.join("data").join("snyvi.db"),
token_path: tmp.path.join("config").join("token"),
};
let token = crate::config::load_or_create_token(&paths).unwrap();
let window = crate::config::load_or_create_window_secret(&paths).unwrap();
assert_ne!(token, window, "two secrets, two jobs");
let store = Store::open(&paths).unwrap();
let app = new_app(&paths, store, token.clone(), window.clone(), None, None);
let cap = app.capabilities.mint().unwrap();
let host = format!("127.0.0.1:{}", crate::config::port());
let leaves = Leaves {
origin: format!("http://{host}"),
host,
bearer: format!("Bearer {token}"),
cap,
window,
};
(tmp, router(app), leaves)
}
#[tokio::test]
async fn every_route_answers_to_its_gate_and_to_this_host_only() {
let (_tmp, router, leaves) = gated_router("snyvi-routes");
let src = include_str!("mod.rs").replace("\r\n", "\n");
let routes_in = |name: &str| {
let routed = &src[src.find(name).unwrap()..];
let routed = &routed[..routed.find("\n}\n").unwrap()];
routed.matches("get(").count()
+ routed.matches("post(").count()
+ routed.matches(".delete(").count()
};
let n = routes_in("\nfn router(")
+ routes_in("\nfn pane_routes(")
+ routes_in("\nfn studio_routes(");
assert_eq!(
n,
ROUTES.len(),
"every route is in ROUTES, and nothing else is"
);
for &route in ROUTES {
answers_to_its_gate(&router, &leaves, route).await;
}
}
async fn answers_to_its_gate(
router: &Router,
l: &Leaves,
(method, path, body, gate, go): (&str, &str, Option<&str>, Gate, bool),
) {
let (host, origin, bearer, cap, window) = (
l.host.as_str(),
l.origin.as_str(),
l.bearer.as_str(),
l.cap.as_str(),
l.window.as_str(),
);
let router = router.clone();
let refused = |s: StatusCode| s == StatusCode::UNAUTHORIZED || s == StatusCode::FORBIDDEN;
let what = format!("{method} {path}");
let (s, t) = ask(
&router,
method,
path,
body,
&[
("host", "evil.example:7777"),
("authorization", bearer),
(CAPABILITY_HEADER, cap),
(WINDOW_HEADER, window),
],
)
.await;
assert_eq!(s, StatusCode::FORBIDDEN, "{what}: another host");
assert!(
t.contains(NOT_THIS_HOST),
"{what}: the gate refuses another host, not a handler: {t}"
);
let (s, t) = ask(
&router,
method,
path,
body,
&[
("host", host),
("origin", "http://evil.example"),
("authorization", bearer),
],
)
.await;
assert_eq!(s, StatusCode::FORBIDDEN, "{what}: another origin");
assert!(
t.contains(NOT_THIS_ORIGIN),
"{what}: the gate refuses another origin: {t}"
);
let (bare, _) = ask(&router, method, path, body, &[("host", host)]).await;
if gate == Gate::Open {
assert!(!refused(bare), "{what}: open, but {bare}");
return;
}
assert!(refused(bare), "{what}: nothing offered, but {bare}");
let wrong: Vec<(&str, &str)> = match gate {
Gate::Token => vec![("host", host), ("origin", origin), (CAPABILITY_HEADER, cap)],
Gate::Window | Gate::Mint => vec![("host", host), ("authorization", bearer)],
Gate::Desk => vec![
("host", host),
("origin", origin),
("authorization", bearer),
],
Gate::Reader => vec![("host", host), (CAPABILITY_HEADER, cap)],
Gate::Open => unreachable!(),
};
let (s, _) = ask(&router, method, path, body, &wrong).await;
assert!(refused(s), "{what}: the wrong leave let through: {s}");
if gate == Gate::Mint {
let (s, _) = ask(
&router,
method,
path,
body,
&[("host", host), (CAPABILITY_HEADER, cap)],
)
.await;
assert!(refused(s), "{what}: a capability mints nothing");
}
if !go {
return;
}
let rights: Vec<Vec<(&str, &str)>> = match gate {
Gate::Reader => vec![
vec![("host", host), ("origin", origin)],
vec![("host", host), ("authorization", bearer)],
],
Gate::Desk => vec![vec![
("host", host),
("origin", origin),
(CAPABILITY_HEADER, cap),
]],
Gate::Token => vec![vec![("host", host), ("authorization", bearer)]],
Gate::Window => vec![
vec![("host", host), (WINDOW_HEADER, window)],
vec![("host", host), ("origin", origin), (CAPABILITY_HEADER, cap)],
],
Gate::Mint => vec![vec![("host", host), (WINDOW_HEADER, window)]],
Gate::Open => unreachable!(),
};
for h in rights {
let (s, t) = ask(&router, method, path, body, &h).await;
assert!(!refused(s), "{what}: refused with the right leave: {s} {t}");
}
}
#[tokio::test]
async fn a_write_from_another_site_is_refused_and_a_read_from_the_address_bar_is_not() {
let (_tmp, router, l) = gated_router("snyvi-fetch-site");
let port = crate::config::port();
let (host, origin) = (l.host.as_str(), l.origin.as_str());
let (s, t) = ask(
&router,
"POST",
"/api/focus",
None,
&[
("host", host),
("origin", origin),
("sec-fetch-site", "cross-site"),
],
)
.await;
assert_eq!(s, StatusCode::FORBIDDEN);
assert!(t.contains(NOT_THIS_ORIGIN));
let (s, _) = ask(
&router,
"GET",
"/api/health",
None,
&[("host", host), ("sec-fetch-site", "none")],
)
.await;
assert_eq!(s, StatusCode::OK);
let (s, _) = ask(
&router,
"GET",
"/api/health",
None,
&[("host", format!("localhost:{port}").as_str())],
)
.await;
assert_eq!(s, StatusCode::OK, "localhost is this host too");
}
#[test]
fn the_page_never_puts_the_capability_in_a_url() {
assert!(
APP_JS.contains("/api/desk"),
"the desk socket should be opened from here"
);
for (name, src) in [("app.js", APP_JS), ("desk.js", DESK_JS)] {
for bad in ["cap=${", "capability=${", "?cap=", "&cap=", "?capability="] {
assert!(
!src.contains(bad),
"the capability is in a URL in {name}: {bad}"
);
}
}
}
#[test]
fn the_page_asks_for_the_desk_view_only_in_a_window_opening_a_desk() {
assert_eq!(APP_JS.matches("import(`/assets/desk.js").count(), 1);
let import = APP_JS.find("import(`/assets/desk.js").unwrap();
let sentence = APP_JS
.find("This is a browser tab, and a browser tab cannot start one")
.expect("a tab is told why there is no desk");
let refusal = APP_JS[..sentence]
.rfind("if (!capability)")
.expect("the sentence is what a page without the capability gets");
assert!(refusal < import, "the import sits past the tab's refusal");
assert!(sentence < import);
for seam in [
"export function open(",
"export function update(",
"export function close(",
] {
assert!(DESK_JS.contains(seam), "desk.js should export `{seam}`");
}
}
#[test]
fn the_page_asks_for_the_game_only_when_the_rocket_is_pressed() {
assert_eq!(APP_JS.matches("import(`/assets/game.js").count(), 1);
let import = APP_JS.find("import(`/assets/game.js").unwrap();
let press = APP_JS
.find(r##"$("#btn-game")"##)
.expect("the rocket is the button the game is behind");
assert!(press < import, "the import sits inside the rocket's press");
for seam in [
"export function open(",
"export function close(",
"export function isOpen(",
] {
assert!(GAME_JS.contains(seam), "game.js should export `{seam}`");
}
}
#[test]
fn the_page_asks_for_the_panels_only_when_one_is_opened() {
assert_eq!(APP_JS.matches("import(`/assets/about.js").count(), 1);
for button in [r##"on("#btn-about""##, r##"on("#btn-reset""##] {
assert!(
ABOUT_JS.contains(button),
"{button} is wired where the card is built"
);
}
for button in [r##"$("#btn-about")"##, r##"$("#btn-reset")"##] {
assert!(
!APP_JS.contains(button),
"{button} belongs to the chunk now"
);
}
assert!(
ABOUT_JS.contains("export function open("),
"about.js should export `open`"
);
for gone in ["/api/about", "#about-facts", "#reset-go"] {
assert!(
!APP_JS.contains(gone),
"`{gone}` belongs to the chunk now, not to app.js"
);
}
}
#[test]
fn the_page_asks_for_the_diagram_driver_only_when_a_document_holds_one() {
assert_eq!(
APP_JS.matches("import(`/assets/mmd.js").count(),
1,
"one import, so there is one place the laziness can be lost"
);
assert!(
APP_JS.contains(r#"if (docEl.querySelector("pre.mermaid")) mmdLoad()"#),
"the import should sit behind the check for a diagram in this document"
);
for gone in [
"mermaid.run",
"mermaidLib",
"mmdRender",
"mmdReserve",
"mmdDrain",
"mmdQueue",
] {
assert!(!APP_JS.contains(gone), "`{gone}` is back in app.js");
}
for kept in [
"export function prepare(",
"export function retheme(",
"export function escape(",
"export function key(",
] {
assert!(MMD_JS.contains(kept), "mmd.js should export `{kept}`");
}
}
#[test]
fn a_live_ui_serves_the_file_on_disk_and_a_shipped_one_cannot() {
let dir = std::env::temp_dir().join(format!("snyvi-ui-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let css = dir.join("app.css");
std::fs::write(&css, "body { --probe: 1 }").unwrap();
let live = Ui {
dir: Some(dir.clone()),
};
assert_eq!(live.text("app.css", APP_CSS), "body { --probe: 1 }");
assert!(live.live());
let before = live.version("shipped");
std::fs::write(&css, "body { --probe: 2 }").unwrap();
assert_ne!(live.version("shipped"), before);
std::fs::remove_file(&css).unwrap();
assert_eq!(live.text("app.css", APP_CSS), APP_CSS);
let shipped = Ui { dir: None };
assert!(!shipped.live());
assert_eq!(shipped.text("app.css", APP_CSS), APP_CSS);
assert_eq!(shipped.version("shipped"), "shipped");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn every_id_the_script_uses_unguarded_is_in_the_page() {
let mut missing = Vec::new();
for (file, src) in [
("app.js", APP_JS),
("desk.js", DESK_JS),
("frame.js", FRAME_JS),
("game.js", GAME_JS),
("about.js", ABOUT_JS),
("find.js", FIND_JS),
("keys.js", KEYS_JS),
("menu.js", MENU_JS),
("palette.js", PALETTE_JS),
("look.js", LOOK_JS),
("note.js", NOTE_JS),
("tip.js", TIP_JS),
("home.js", HOME_JS),
("toast.js", TOAST_JS),
("diff.js", DIFF_JS),
("browse.js", BROWSE_JS),
("paths.js", PATHS_JS),
] {
for (i, _) in src.match_indices("$(\"#") {
let rest = &src[i + 4..];
let end = rest.find('"').expect("unterminated selector");
let id = &rest[..end];
let used_at_once = rest[end..].starts_with("\").");
let built = format!("id=\"{id}\"");
if used_at_once && !INDEX_HTML.contains(&built) && !src.contains(&built) {
missing.push(format!("{file}: {id}"));
}
}
}
assert!(missing.is_empty(), "not in index.html: {missing:?}");
}
#[test]
fn the_hash_behind_the_version_covers_every_chunk_the_page_can_fetch() {
let src = include_str!("mod.rs");
let from = src.find("let asset_v = {").expect("the startup hash");
let to = from + src[from..].find("\n };").expect("the end of it");
let block = &src[from..to];
assert!(
block.contains("INDEX_HTML"),
"the page itself is in the hash"
);
assert!(block.contains("in ASSETS"), "the hash walks ASSETS");
assert!(
block.contains("MERMAID_JS_GZ"),
"the diagram bundle is in the hash"
);
let ui = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/ui"));
let mut on_disk: Vec<String> = std::fs::read_dir(ui)
.unwrap()
.filter_map(|e| e.ok())
.filter_map(|e| {
let p = e.path();
let name = p.file_name()?.to_str()?.to_string();
if p.is_dir() && name != "fonts" && !ui.join(format!("{name}.js")).exists() {
return Some(format!("{name}.js"));
}
(name.ends_with(".js") || name.ends_with(".css")).then_some(name)
})
.collect();
on_disk.sort();
let mut named: Vec<String> = super::assets::ASSETS
.iter()
.map(|(n, _, _)| n.to_string())
.collect();
named.sort();
assert_eq!(
on_disk, named,
"every .js and .css in ui/ is in ASSETS, and nothing else is"
);
}
#[test]
fn a_name_is_cleaned_before_it_is_stored() {
use super::clean_name;
assert_eq!(clean_name(" Auth work ").unwrap(), "Auth work");
assert_eq!(clean_name("Auth\n\twork").unwrap(), "Auth work");
assert_eq!(clean_name("Auth work").unwrap(), "Auth work");
assert!(clean_name("").is_none());
assert!(clean_name(" \n ").is_none(), "whitespace is not a name");
let long = "é".repeat(400);
assert_eq!(clean_name(&long).unwrap().chars().count(), 120);
}
#[test]
fn settings_written_by_the_app_are_applied_before_first_paint() {
for key in [
"theme.light",
"theme.dark",
"theme.follow",
"font",
"side",
"wide",
"wrap",
] {
let k = format!("snyvi.{key}");
assert!(
APP_JS.contains(&k) || LOOK_JS.contains(&k),
"{k} is not used by app.js or look.js"
);
assert!(BOOT_JS.contains(&k), "{k} is not applied by boot.js");
}
}