use std::collections::BTreeMap;
use std::io::{Cursor, Read};
use std::num::NonZeroU32;
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use flate2::read::GzDecoder;
use lys_pass::Error;
use serde_json::{Value, json};
use super::{Site, answer};
use crate::door::http::{Request, Response, read};
use crate::door::packed::Packed;
use crate::door::signin::{Door, Issuer, SignedIn, Started};
use crate::serve::frame;
use crate::serve::protocol::{PROTOCOL, RECEIPT_CAPABILITY};
type TestResult = Result<(), Box<dyn std::error::Error>>;
const NOW: u64 = 1_000;
const ORIGIN: &str = "https://studio.example";
struct Lys;
impl Issuer for Lys {
fn start(&self) -> Result<Started, Error> {
Ok(Started {
url: "https://lys.example/oauth/authorize?state=s1".to_owned(),
state: "s1".to_owned(),
verifier: "v1".to_owned(),
browser: "b1".to_owned(),
})
}
fn finish(
&self,
code: &str,
_: &str,
browser: &str,
started: &Started,
) -> Result<SignedIn, Error> {
if browser != started.browser {
return Err(Error::Invalid("another browser"));
}
Ok(SignedIn {
token: format!("pass-for-{code}"),
holder: "clinician".to_owned(),
expires_at: NOW + 600,
refresh: None,
})
}
fn refresh(&self, _: &str) -> Result<SignedIn, Error> {
Err(Error::CannotAsk("no refresh in this double"))
}
}
fn site(files: &std::path::Path) -> Site {
Site {
origin: ORIGIN.to_owned(),
files: files.to_owned(),
stores: BTreeMap::from([("wards".to_owned(), files.join("absent.sock"))]),
timeout: Duration::from_secs(5),
packed: Packed::default(),
}
}
fn request(text: &str) -> Result<Request, Box<dyn std::error::Error>> {
Ok(read(
&mut Cursor::new(text.as_bytes().to_vec()),
16_384,
1 << 20,
)?)
}
fn written(response: Response) -> Result<String, Box<dyn std::error::Error>> {
let mut out = Vec::new();
response.write(&mut out)?;
Ok(String::from_utf8(out)?)
}
fn cookie<'a>(text: &'a str, name: &str) -> Option<&'a str> {
text.lines()
.filter_map(|line| line.strip_prefix("Set-Cookie: "))
.find(|cookie| cookie.starts_with(&format!("{name}=")))
}
#[test]
fn a_sign_in_round_trip_sets_an_opaque_strict_session_cookie() -> TestResult {
let files = tempfile::tempdir()?;
let site = site(files.path());
let door = Door::new(Lys);
let begun = written(answer(
&door,
&site,
&request("GET /signin?return=%23%2Fdata HTTP/1.1\r\n\r\n")?,
NOW,
))?;
assert!(
begun.contains("Location: https://lys.example/oauth/authorize?state=s1\r\n"),
"{begun}"
);
let state = cookie(&begun, "haem_state").ok_or("no state cookie")?;
assert!(
state.contains("SameSite=Lax") && state.contains("HttpOnly") && state.contains("Secure"),
"{state}"
);
let back = written(answer(
&door,
&site,
&request(
"GET /callback?code=c1&state=s1 HTTP/1.1\r\nCookie: haem_state=b1; haem_return=%23%2Fdata\r\n\r\n",
)?,
NOW,
))?;
let session = cookie(&back, "haem_session").ok_or("no session cookie")?;
assert!(
session.contains("SameSite=Strict")
&& session.contains("HttpOnly")
&& session.contains("Secure"),
"{session}"
);
assert!(
!back.contains("pass-for-c1"),
"the pass reached the browser: {back}"
);
assert!(back.contains("Location: /#/data\r\n"), "{back}");
let id = session
.trim_start_matches("haem_session=")
.split(';')
.next()
.ok_or("no id")?;
let line = written(answer(
&door,
&site,
&request(&format!(
"GET /api/session HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"
))?,
NOW,
))?;
let body: Value = serde_json::from_str(line.split("\r\n\r\n").nth(1).ok_or("no body")?)?;
assert_eq!(body["holder"], "clinician");
Ok(())
}
#[test]
fn the_stores_answer_is_the_object_studio_reads() -> TestResult {
let files = tempfile::tempdir()?;
let site = site(files.path());
let door = Door::new(Lys);
written(answer(
&door,
&site,
&request("GET /signin HTTP/1.1\r\n\r\n")?,
NOW,
))?;
let back = written(answer(
&door,
&site,
&request("GET /callback?code=c1&state=s1 HTTP/1.1\r\nCookie: haem_state=b1\r\n\r\n")?,
NOW,
))?;
let session = cookie(&back, "haem_session").ok_or("no session cookie")?;
let id = session
.trim_start_matches("haem_session=")
.split(';')
.next()
.ok_or("no id")?;
let listed = written(answer(
&door,
&site,
&request(&format!(
"GET /api/haem HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"
))?,
NOW,
))?;
assert!(listed.starts_with("HTTP/1.1 200"), "{listed}");
let body: Value = serde_json::from_str(listed.split("\r\n\r\n").nth(1).ok_or("no body")?)?;
assert_eq!(body, serde_json::json!({"stores": [{"name": "wards"}]}));
Ok(())
}
#[test]
fn an_api_call_without_a_session_is_refused_session_unknown() -> TestResult {
let files = tempfile::tempdir()?;
let site = site(files.path());
let door = Door::new(Lys);
for text in [
"GET /api/haem HTTP/1.1\r\n\r\n".to_owned(),
format!(
"POST /api/haem/wards HTTP/1.1\r\nOrigin: {ORIGIN}\r\nContent-Length: 20\r\n\r\n{{\"method\":\"hello\"}} "
),
] {
let refused = written(answer(&door, &site, &request(&text)?, NOW))?;
assert!(refused.starts_with("HTTP/1.1 401"), "{refused}");
assert!(refused.contains("\"session_unknown\""), "{refused}");
}
Ok(())
}
#[test]
fn a_verb_from_another_origin_is_refused() -> TestResult {
let files = tempfile::tempdir()?;
let site = site(files.path());
let door = Door::new(Lys);
let refused = written(answer(
&door,
&site,
&request(
"POST /api/haem/wards HTTP/1.1\r\nOrigin: https://evil.example\r\nContent-Length: 2\r\n\r\n{}",
)?,
NOW,
))?;
assert!(refused.starts_with("HTTP/1.1 403"), "{refused}");
assert!(refused.contains("\"origin_refused\""), "{refused}");
Ok(())
}
#[test]
fn a_sign_out_forgets_the_session_and_clears_its_cookie() -> TestResult {
let files = tempfile::tempdir()?;
let site = site(files.path());
let door = Door::new(Lys);
let id = signed_in(&door, &site)?;
let out = written(answer(
&door,
&site,
&request(&format!(
"POST /signout HTTP/1.1\r\nOrigin: {ORIGIN}\r\nCookie: haem_session={id}\r\n\r\n"
))?,
NOW,
))?;
assert!(out.starts_with("HTTP/1.1 204"), "{out}");
let cleared = cookie(&out, "haem_session").ok_or("the cookie was not cleared")?;
assert!(
cleared.starts_with("haem_session=;")
&& cleared.contains("Max-Age=0")
&& cleared.contains("Path=/"),
"{cleared}"
);
let after = written(answer(
&door,
&site,
&request(&format!(
"GET /api/session HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"
))?,
NOW,
))?;
assert!(after.starts_with("HTTP/1.1 401"), "{after}");
assert!(after.contains("\"session_unknown\""), "{after}");
let again = written(answer(
&door,
&site,
&request(&format!(
"POST /signout HTTP/1.1\r\nOrigin: {ORIGIN}\r\nCookie: haem_session={id}\r\n\r\n"
))?,
NOW,
))?;
assert!(again.starts_with("HTTP/1.1 204"), "{again}");
Ok(())
}
#[test]
fn a_sign_out_from_another_origin_is_refused() -> TestResult {
let files = tempfile::tempdir()?;
let site = site(files.path());
let door = Door::new(Lys);
let id = signed_in(&door, &site)?;
for origin in ["Origin: https://evil.example\r\n", ""] {
let refused = written(answer(
&door,
&site,
&request(&format!(
"POST /signout HTTP/1.1\r\n{origin}Cookie: haem_session={id}\r\n\r\n"
))?,
NOW,
))?;
assert!(refused.starts_with("HTTP/1.1 403"), "{origin}: {refused}");
assert!(refused.contains("\"origin_refused\""), "{refused}");
assert!(cookie(&refused, "haem_session").is_none(), "{refused}");
}
let line = written(answer(
&door,
&site,
&request(&format!(
"GET /api/session HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"
))?,
NOW,
))?;
assert!(line.starts_with("HTTP/1.1 200"), "{line}");
Ok(())
}
#[test]
fn studios_files_are_served_and_nothing_outside_them() -> TestResult {
let files = tempfile::tempdir()?;
std::fs::write(files.path().join("index.html"), "<html>studio</html>")?;
std::fs::write(files.path().join("app.js"), "let a = 1;")?;
let site = site(files.path());
let door = Door::new(Lys);
let script = written(answer(
&door,
&site,
&request("GET /app.js HTTP/1.1\r\n\r\n")?,
NOW,
))?;
assert!(script.contains("Content-Type: text/javascript"), "{script}");
assert!(script.ends_with("let a = 1;"));
let index = written(answer(
&door,
&site,
&request("GET / HTTP/1.1\r\n\r\n")?,
NOW,
))?;
assert!(index.ends_with("<html>studio</html>"));
for escape in [
"GET /../secret HTTP/1.1\r\n\r\n",
"GET /a/..%2F..%2Fsecret HTTP/1.1\r\n\r\n",
] {
let refused = written(answer(&door, &site, &request(escape)?, NOW))?;
assert!(refused.starts_with("HTTP/1.1 404"), "{escape}: {refused}");
}
Ok(())
}
#[test]
fn a_refused_sign_in_is_logged() -> TestResult {
let files = tempfile::tempdir()?;
let site = site(files.path());
let door = Door::new(Lys);
written(answer(
&door,
&site,
&request("GET /signin HTTP/1.1\r\n\r\n")?,
NOW,
))?;
let before = super::callback_refusals();
let refused = written(answer(
&door,
&site,
&request("GET /callback?code=c1&state=s1 HTTP/1.1\r\nCookie: haem_state=other\r\n\r\n")?,
NOW,
))?;
assert!(!refused.starts_with("HTTP/1.1 3"), "{refused}");
assert_eq!(super::callback_refusals(), before + 1);
let missing = written(answer(
&door,
&site,
&request("GET /callback HTTP/1.1\r\n\r\n")?,
NOW,
))?;
assert!(missing.contains("\"sign_in_refused\""), "{missing}");
assert_eq!(super::callback_refusals(), before + 2);
Ok(())
}
fn written_bytes(response: Response) -> Result<(String, Vec<u8>), Box<dyn std::error::Error>> {
let mut out = Vec::new();
response.write(&mut out)?;
let at = out
.windows(4)
.position(|window| window == b"\r\n\r\n")
.ok_or("no head")?;
let body = out.split_off(at + 4);
Ok((String::from_utf8(out)?, body))
}
const ASSET: &str = "/assets/index-BdF3x9kZ.js";
const PNG: [u8; 4] = [137, 80, 78, 71];
fn gunzip(bytes: &[u8]) -> Result<String, std::io::Error> {
let mut text = String::new();
GzDecoder::new(bytes).read_to_string(&mut text)?;
Ok(text)
}
#[test]
fn a_hashed_asset_is_kept_for_good_and_the_index_is_never_stored() -> TestResult {
let files = tempfile::tempdir()?;
std::fs::create_dir(files.path().join("assets"))?;
std::fs::write(files.path().join("index.html"), "<html>studio</html>")?;
std::fs::write(files.path().join("app.js"), "let a = 1;")?;
std::fs::write(files.path().join("assets/index-BdF3x9kZ.js"), "let b = 2;")?;
let site = site(files.path());
let door = Door::new(Lys);
let hashed = written(answer(
&door,
&site,
&request("GET /assets/index-BdF3x9kZ.js HTTP/1.1\r\n\r\n")?,
NOW,
))?;
assert!(
hashed.contains("\r\nCache-Control: public, max-age=31536000, immutable\r\n"),
"{hashed}"
);
assert!(!hashed.contains("no-store"), "{hashed}");
for address in ["/", "/index.html", "/app.js", "/assets/absent-BdF3x9kZ.js"] {
let answered = written(answer(
&door,
&site,
&request(&format!("GET {address} HTTP/1.1\r\n\r\n"))?,
NOW,
))?;
assert!(
answered.contains("\r\nCache-Control: no-store\r\n"),
"{address}: {answered}"
);
assert!(!answered.contains("immutable"), "{address}: {answered}");
}
Ok(())
}
#[test]
fn gzip_is_sent_only_when_asked_and_decodes_to_the_file() -> TestResult {
let files = tempfile::tempdir()?;
std::fs::create_dir(files.path().join("assets"))?;
let script = "export const studio = 1;\n".repeat(100);
std::fs::write(files.path().join(ASSET.trim_start_matches('/')), &script)?;
std::fs::write(files.path().join("index.html"), "<html>first</html>")?;
std::fs::write(files.path().join("logo.png"), PNG)?;
let site = site(files.path());
let door = Door::new(Lys);
let get = |address: &str, accept: Option<&str>| {
let accept = accept
.map(|accept| format!("Accept-Encoding: {accept}\r\n"))
.unwrap_or_default();
request(&format!("GET {address} HTTP/1.1\r\n{accept}\r\n"))
.map(|asked| answer(&door, &site, &asked, NOW))
};
let (plain, body) = written_bytes(get(ASSET, None)?)?;
assert!(!plain.contains("Content-Encoding"), "{plain}");
assert!(plain.contains("\r\nVary: Accept-Encoding\r\n"), "{plain}");
assert_eq!(body, script.as_bytes());
let (refused, body) = written_bytes(get(ASSET, Some("br, gzip;q=0"))?)?;
assert!(!refused.contains("Content-Encoding"), "{refused}");
assert_eq!(body, script.as_bytes());
let (packed, body) = written_bytes(get(ASSET, Some("gzip, deflate, br"))?)?;
assert!(
packed.contains("\r\nContent-Encoding: gzip\r\n"),
"{packed}"
);
assert!(packed.contains("\r\nVary: Accept-Encoding\r\n"), "{packed}");
assert!(
packed.contains(&format!("\r\nContent-Length: {}\r\n", body.len())),
"{packed}"
);
assert!(body.len() < script.len(), "gzip made it no smaller");
assert_eq!(gunzip(&body)?, script);
let (first, body) = written_bytes(get("/", Some("gzip"))?)?;
assert!(first.contains("Content-Encoding: gzip"), "{first}");
assert_eq!(gunzip(&body)?, "<html>first</html>");
std::fs::write(files.path().join("index.html"), "<html>second</html>")?;
let (_, body) = written_bytes(get("/", Some("gzip"))?)?;
assert_eq!(gunzip(&body)?, "<html>second</html>");
let (image, body) = written_bytes(get("/logo.png", Some("gzip"))?)?;
assert!(!image.contains("Content-Encoding"), "{image}");
assert!(!image.contains("Vary"), "{image}");
assert_eq!(body, PNG);
Ok(())
}
const FRAME: NonZeroU32 = NonZeroU32::MIN.saturating_add(65_535);
fn follow_store(
socket: &Path,
pass: Value,
first: Value,
then: Vec<Value>,
) -> Result<mpsc::Receiver<Value>, Box<dyn std::error::Error>> {
let listener = UnixListener::bind(socket)?;
let (sent, read) = mpsc::channel();
std::thread::spawn(move || -> std::io::Result<()> {
let (mut stream, _) = listener.accept()?;
let hello = json!({"result": {
"protocol": PROTOCOL, "capabilities": [RECEIPT_CAPABILITY],
"max_page": 10, "max_frame": FRAME.get(),
}});
let mut id = Value::Null;
for answer in [hello, pass, first] {
let Some(bytes) = frame::read(&mut stream, FRAME)? else {
return Ok(());
};
let request: Value = serde_json::from_slice(&bytes)?;
id = request["id"].clone();
if sent.send(request).is_err() {
return Ok(());
}
let mut reply = answer;
reply["id"] = id.clone();
frame::write(&mut stream, &serde_json::to_vec(&reply)?, FRAME)?;
}
for answer in then {
let mut reply = answer;
reply["id"] = id.clone();
frame::write(&mut stream, &serde_json::to_vec(&reply)?, FRAME)?;
}
Ok(())
});
Ok(read)
}
fn signed_in(door: &Door<Lys>, site: &Site) -> Result<String, Box<dyn std::error::Error>> {
written(answer(
door,
site,
&request("GET /signin HTTP/1.1\r\n\r\n")?,
NOW,
))?;
let back = written(answer(
door,
site,
&request("GET /callback?code=c1&state=s1 HTTP/1.1\r\nCookie: haem_state=b1\r\n\r\n")?,
NOW,
))?;
let session = cookie(&back, "haem_session").ok_or("no session cookie")?;
let id = session
.trim_start_matches("haem_session=")
.split(';')
.next()
.ok_or("no id")?;
Ok(id.to_owned())
}
fn head_at(sequence: &str) -> Value {
json!({"branch": "main", "version": {"generation": "7", "sequence": sequence}})
}
fn follow_site(files: &Path, socket: &Path) -> Site {
let mut site = site(files);
site.stores.insert("wards".to_owned(), socket.to_owned());
site
}
#[test]
fn a_follow_streams_an_event_per_head_then_the_refusal_that_ends_it() -> TestResult {
let files = tempfile::tempdir()?;
let socket = files.path().join("s.sock");
let refusal = json!({"code": "pass_expired", "message": "the Lys pass was refused"});
let read = follow_store(
&socket,
json!({"result": {"holder": "clinician", "expires": crate::serve::pass::now() + 600}}),
json!({"result": head_at("1")}),
vec![json!({"result": head_at("2")}), json!({"error": refusal})],
)?;
let site = follow_site(files.path(), &socket);
let door = Door::new(Lys);
let id = signed_in(&door, &site)?;
let streamed = written(answer(
&door,
&site,
&request(&format!(
"GET /api/haem/wards/follow?branch=main HTTP/1.1\r\nOrigin: {ORIGIN}\r\nCookie: haem_session={id}\r\n\r\n"
))?,
NOW,
))?;
let (head, body) = streamed.split_once("\r\n\r\n").ok_or("no head")?;
assert!(head.starts_with("HTTP/1.1 200 OK\r\n"), "{head}");
assert!(
head.contains("\r\nContent-Type: text/event-stream\r\n"),
"{head}"
);
assert!(head.contains("\r\nCache-Control: no-store\r\n"), "{head}");
assert!(!head.contains("Content-Length"), "{head}");
assert!(!head.contains("Content-Encoding"), "{head}");
assert_eq!(
body,
format!(
"retry: 1000\nevent: head\ndata: {}\n\nevent: head\ndata: {}\n\nevent: refused\ndata: {refusal}\n\n",
head_at("1"),
head_at("2")
)
);
let asked: Vec<Value> = read.iter().collect();
assert_eq!(asked.len(), 3, "{asked:?}");
assert_eq!(asked[0]["operation"]["method"], "hello");
assert_eq!(
asked[1]["operation"],
json!({"method": "session.pass", "params": {"pass": "pass-for-c1"}})
);
assert_eq!(
asked[2]["operation"],
json!({"method": "head.follow", "params": {"branch": "main"}})
);
Ok(())
}
#[test]
fn a_follow_refused_by_the_store_is_answered_and_never_streamed() -> TestResult {
let files = tempfile::tempdir()?;
let socket = files.path().join("s.sock");
let read = follow_store(
&socket,
json!({"error": {"code": "pass_expired", "message": "the Lys pass was refused"}}),
json!({"result": head_at("1")}),
Vec::new(),
)?;
let site = follow_site(files.path(), &socket);
let door = Door::new(Lys);
let id = signed_in(&door, &site)?;
let refused = written(answer(
&door,
&site,
&request(&format!(
"GET /api/haem/wards/follow?branch=main HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"
))?,
NOW,
))?;
assert!(refused.starts_with("HTTP/1.1 400 "), "{refused}");
assert!(refused.contains("\"pass_expired\""), "{refused}");
assert!(!refused.contains("event:"), "{refused}");
assert_eq!(
read.iter().count(),
2,
"a follow was sent after a refused pass"
);
Ok(())
}
#[test]
fn a_follow_without_a_session_or_for_an_unknown_store_is_refused() -> TestResult {
let files = tempfile::tempdir()?;
let site = site(files.path());
let door = Door::new(Lys);
let id = signed_in(&door, &site)?;
for (text, status, code) in [
(
"GET /api/haem/wards/follow?branch=main HTTP/1.1\r\n\r\n".to_owned(),
"401",
"session_unknown",
),
(
format!(
"GET /api/haem/nowhere/follow?branch=main HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"
),
"404",
"store_unknown",
),
(
format!(
"GET /api/haem/wards/follow?branch=main HTTP/1.1\r\nOrigin: https://evil.example\r\nCookie: haem_session={id}\r\n\r\n"
),
"403",
"origin_refused",
),
(
format!("GET /api/haem/wards/follow HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"),
"400",
"invalid_params",
),
] {
let refused = written(answer(&door, &site, &request(&text)?, NOW))?;
assert!(
refused.starts_with(&format!("HTTP/1.1 {status} ")),
"{text}: {refused}"
);
assert!(
refused.contains(&format!("\"{code}\"")),
"{text}: {refused}"
);
}
Ok(())
}