use std::collections::BTreeMap;
use std::io::Cursor;
use std::time::Duration;
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};
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(),
store_children: BTreeMap::new(),
}
}
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(())
}
#[path = "route_asset_tests.rs"]
mod assets;
#[path = "route_follow_tests.rs"]
mod follows;
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())
}