feat: serve rendered pages over http, spartan and nex
This commit is contained in:
parent
46f12b10da
commit
2f38b8b929
30 changed files with 2896 additions and 90 deletions
403
bin/tests/listeners.rs
Normal file
403
bin/tests/listeners.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
//! The three listeners over real sockets, bound on port 0 in-process.
|
||||
//!
|
||||
//! Two sites on one HTTP port, so virtual-host routing is exercised rather than
|
||||
//! assumed.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use itsybitsy::serve;
|
||||
use itsybitsy_core::config::ServerConfig;
|
||||
use itsybitsy_core::siteset::SiteSet;
|
||||
|
||||
/// A running server, with the address of each listener by name.
|
||||
struct Server {
|
||||
addrs: BTreeMap<String, SocketAddr>,
|
||||
_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
fn start() -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "one/index.md", "# One\n\nFirst site.\n");
|
||||
write(dir.path(), "one/about.md", "# About\n\nAbout the first.\n");
|
||||
write(dir.path(), "one/img.png", "not really a png");
|
||||
write(dir.path(), "one/.itsybitsy.toml", "[defaults]\ncache_control = 120\n");
|
||||
write(dir.path(), "two/index.md", "# Two\n\nSecond site.\n");
|
||||
|
||||
let text = format!(
|
||||
r#"
|
||||
version = 1
|
||||
|
||||
[site.one]
|
||||
root = "{one}"
|
||||
hosts = ["one.test"]
|
||||
|
||||
[site.two]
|
||||
root = "{two}"
|
||||
hosts = ["two.test"]
|
||||
|
||||
[listener.web]
|
||||
protocol = "http"
|
||||
bind = "127.0.0.1:0"
|
||||
formats = ["xhtmlmp", "html"]
|
||||
default_site = "one"
|
||||
|
||||
[listener.strict]
|
||||
protocol = "http"
|
||||
bind = "127.0.0.1:0"
|
||||
formats = ["html"]
|
||||
|
||||
[listener.spartan]
|
||||
protocol = "spartan"
|
||||
bind = "127.0.0.1:0"
|
||||
formats = ["gemtext"]
|
||||
default_site = "one"
|
||||
|
||||
[listener.nex]
|
||||
protocol = "nex"
|
||||
bind = "127.0.0.1:0"
|
||||
formats = ["gemtext"]
|
||||
site = "two"
|
||||
"#,
|
||||
one = dir.path().join("one").to_str().unwrap(),
|
||||
two = dir.path().join("two").to_str().unwrap(),
|
||||
);
|
||||
let config: ServerConfig = toml::from_str(&text).unwrap();
|
||||
let registry = Arc::new(itsybitsy::registry::build());
|
||||
let formats: Vec<String> =
|
||||
["gemtext", "html", "xhtmlmp"].iter().map(|s| s.to_string()).collect();
|
||||
let sites = Arc::new(SiteSet::build(&config, registry.clone(), formats).unwrap());
|
||||
|
||||
let bound = serve::bind(&config, registry, sites).unwrap();
|
||||
let addrs = bound
|
||||
.iter()
|
||||
.map(|entry| (entry.listener.name.clone(), entry.local_addr().unwrap()))
|
||||
.collect();
|
||||
serve::spawn_all(bound);
|
||||
Server { addrs, _dir: dir }
|
||||
}
|
||||
|
||||
fn addr(&self, name: &str) -> SocketAddr {
|
||||
self.addrs[name]
|
||||
}
|
||||
|
||||
/// Send raw bytes to a listener and read the whole reply.
|
||||
fn send(&self, listener: &str, request: &[u8]) -> String {
|
||||
let mut stream = TcpStream::connect(self.addr(listener)).unwrap();
|
||||
stream.write_all(request).unwrap();
|
||||
stream.flush().unwrap();
|
||||
let mut reply = Vec::new();
|
||||
stream.read_to_end(&mut reply).unwrap();
|
||||
String::from_utf8_lossy(&reply).into_owned()
|
||||
}
|
||||
|
||||
fn get(&self, host: &str, target: &str) -> String {
|
||||
self.request("web", host, target, "")
|
||||
}
|
||||
|
||||
fn request(&self, listener: &str, host: &str, target: &str, extra: &str) -> String {
|
||||
let request = format!("GET {target} HTTP/1.1\r\nHost: {host}\r\n{extra}\r\n");
|
||||
self.send(listener, request.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
fn write(root: &Path, rel: &str, body: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, body).unwrap();
|
||||
}
|
||||
|
||||
/// Split a response into its status line and body.
|
||||
fn split(response: &str) -> (&str, &str) {
|
||||
let status = response.lines().next().unwrap_or("");
|
||||
let body = response.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or("");
|
||||
(status, body)
|
||||
}
|
||||
|
||||
// -- HTTP ----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn serves_a_document() {
|
||||
let server = Server::start();
|
||||
let response = server.get("one.test", "/");
|
||||
let (status, body) = split(&response);
|
||||
assert_eq!(status, "HTTP/1.1 200 OK");
|
||||
assert!(body.contains("<h1>One</h1>"), "{body}");
|
||||
assert!(response.contains("Vary: Accept"), "{response}");
|
||||
assert!(response.contains("Cache-Control: max-age=120"), "{response}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_two_sites_on_one_port() {
|
||||
let server = Server::start();
|
||||
assert!(server.get("one.test", "/").contains("<h1>One</h1>"));
|
||||
assert!(server.get("two.test", "/").contains("<h1>Two</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_host_falls_back_to_the_default_site() {
|
||||
let server = Server::start();
|
||||
assert!(server.get("elsewhere.test", "/").contains("<h1>One</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_host_with_no_default_is_not_found() {
|
||||
let server = Server::start();
|
||||
let (status, body) = {
|
||||
let response = server.request("strict", "elsewhere.test", "/", "");
|
||||
(response.lines().next().unwrap().to_string(), response)
|
||||
};
|
||||
assert_eq!(status, "HTTP/1.1 404 Not Found");
|
||||
assert!(body.ends_with("Not found\n"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_host_header_is_a_bad_request_not_a_missing_page() {
|
||||
// 404 would imply the server looked somewhere.
|
||||
let server = Server::start();
|
||||
let response = server.send("web", b"GET / HTTP/1.1\r\n\r\n");
|
||||
assert_eq!(split(&response).0, "HTTP/1.1 400 Bad Request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_md_form_redirects_to_the_canonical_url() {
|
||||
let server = Server::start();
|
||||
let response = server.get("one.test", "/about.md");
|
||||
assert_eq!(split(&response).0, "HTTP/1.1 301 Moved Permanently");
|
||||
assert!(response.contains("Location: /about"), "{response}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_page_is_not_found_with_a_fixed_body() {
|
||||
// The body never echoes the request path.
|
||||
let server = Server::start();
|
||||
let response = server.get("one.test", "/nope");
|
||||
let (status, body) = split(&response);
|
||||
assert_eq!(status, "HTTP/1.1 404 Not Found");
|
||||
assert_eq!(body, "Not found\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_file_is_served_with_its_media_type_and_no_vary() {
|
||||
let server = Server::start();
|
||||
let response = server.get("one.test", "/img.png");
|
||||
assert!(response.contains("Content-Type: image/png"), "{response}");
|
||||
// Its bytes do not depend on Accept, so a shared cache need not vary on it.
|
||||
assert!(!response.contains("Vary:"), "{response}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_sends_the_headers_of_a_get_and_no_body() {
|
||||
let server = Server::start();
|
||||
let get = server.get("one.test", "/");
|
||||
let head = server.send("web", b"HEAD / HTTP/1.1\r\nHost: one.test\r\n\r\n");
|
||||
let length = |response: &str| {
|
||||
response.lines().find_map(|line| line.strip_prefix("Content-Length: ")).unwrap().to_string()
|
||||
};
|
||||
assert_eq!(length(&get), length(&head));
|
||||
assert_eq!(split(&head).1, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsupported_method_is_refused_with_the_allowed_set() {
|
||||
let server = Server::start();
|
||||
let response = server.send("web", b"POST / HTTP/1.1\r\nHost: one.test\r\n\r\n");
|
||||
let (status, body) = split(&response);
|
||||
assert_eq!(status, "HTTP/1.1 405 Method Not Allowed");
|
||||
assert!(response.contains("Allow: GET, HEAD"), "{response}");
|
||||
// The declared length has to match what is sent, or the response truncates.
|
||||
assert_eq!(body, "Method not allowed\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_response_body_matches_its_declared_length() {
|
||||
let server = Server::start();
|
||||
let cases: Vec<Vec<u8>> = vec![
|
||||
b"GET / HTTP/1.1\r\nHost: one.test\r\n\r\n".to_vec(),
|
||||
b"GET /nope HTTP/1.1\r\nHost: one.test\r\n\r\n".to_vec(),
|
||||
b"POST / HTTP/1.1\r\nHost: one.test\r\n\r\n".to_vec(),
|
||||
b"GARBAGE\r\n\r\n".to_vec(),
|
||||
b"GET /?format=bogus HTTP/1.1\r\nHost: one.test\r\n\r\n".to_vec(),
|
||||
];
|
||||
for request in cases {
|
||||
let response = server.send("web", &request);
|
||||
let (_, body) = split(&response);
|
||||
let declared: usize = response
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Content-Length: "))
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
body.len(),
|
||||
declared,
|
||||
"for {:?} -> {response:?}",
|
||||
String::from_utf8_lossy(&request)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absolute_form_target_is_refused() {
|
||||
// A reverse proxy in front sends origin-form.
|
||||
let server = Server::start();
|
||||
let response = server.send("web", b"GET http://one.test/ HTTP/1.1\r\nHost: one.test\r\n\r\n");
|
||||
assert_eq!(split(&response).0, "HTTP/1.1 400 Bad Request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_request_line_is_refused() {
|
||||
let server = Server::start();
|
||||
let mut request = b"GET /".to_vec();
|
||||
request.extend(std::iter::repeat_n(b'a', 9000));
|
||||
request.extend_from_slice(b" HTTP/1.1\r\nHost: one.test\r\n\r\n");
|
||||
assert_eq!(split(&server.send("web", &request)).0, "HTTP/1.1 400 Bad Request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_headers_are_refused() {
|
||||
let server = Server::start();
|
||||
let mut request = b"GET / HTTP/1.1\r\nHost: one.test\r\n".to_vec();
|
||||
for index in 0..200 {
|
||||
request.extend_from_slice(format!("X-Pad-{index}: x\r\n").as_bytes());
|
||||
}
|
||||
request.extend_from_slice(b"\r\n");
|
||||
assert_eq!(split(&server.send("web", &request)).0, "HTTP/1.1 400 Bad Request");
|
||||
}
|
||||
|
||||
// -- Negotiation ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_browser_accept_header_gets_html() {
|
||||
let server = Server::start();
|
||||
let response = server.request(
|
||||
"web",
|
||||
"one.test",
|
||||
"/",
|
||||
"Accept: text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\r\n",
|
||||
);
|
||||
assert!(response.contains("Content-Type: text/html"), "{response}");
|
||||
assert!(!split(&response).1.starts_with("<?xml"), "the html form carries no declaration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_literal_wap_accept_header_gets_xhtml_mobile_profile() {
|
||||
let server = Server::start();
|
||||
let response =
|
||||
server.request("web", "one.test", "/", "Accept: application/vnd.wap.xhtml+xml\r\n");
|
||||
assert!(response.contains("Content-Type: application/vnd.wap.xhtml+xml"), "{response}");
|
||||
assert!(split(&response).1.starts_with("<?xml version=\"1.0\""), "{response}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_format_query_overrides_negotiation() {
|
||||
let server = Server::start();
|
||||
let response = server.get("one.test", "/?format=xhtmlmp");
|
||||
assert!(response.contains("Content-Type: application/vnd.wap.xhtml+xml"), "{response}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_format_query_is_a_bad_request() {
|
||||
let server = Server::start();
|
||||
let response = server.get("one.test", "/?format=bogus");
|
||||
assert_eq!(split(&response).0, "HTTP/1.1 400 Bad Request");
|
||||
}
|
||||
|
||||
// -- Spartan -------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn spartan_serves_gemtext() {
|
||||
let server = Server::start();
|
||||
let response = server.send("spartan", b"one.test / 0\r\n");
|
||||
assert!(response.starts_with("2 text/gemini; charset=utf-8\r\n"), "{response}");
|
||||
assert!(response.contains("# One"), "{response}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spartan_routes_by_the_host_field_smolweb_discards() {
|
||||
let server = Server::start();
|
||||
assert!(server.send("spartan", b"one.test / 0\r\n").contains("# One"));
|
||||
assert!(server.send("spartan", b"two.test / 0\r\n").contains("# Two"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spartan_redirects_the_md_form() {
|
||||
let server = Server::start();
|
||||
assert_eq!(server.send("spartan", b"one.test /about.md 0\r\n"), "3 /about\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spartan_reports_a_missing_page() {
|
||||
let server = Server::start();
|
||||
assert_eq!(server.send("spartan", b"one.test /nope 0\r\n"), "4 Not found\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spartan_drains_an_upload_then_refuses_it() {
|
||||
// Draining keeps the connection in sync rather than leaving a body to be
|
||||
// read as the next request.
|
||||
let server = Server::start();
|
||||
let response = server.send("spartan", b"one.test / 5\r\nhello");
|
||||
assert_eq!(response, "4 Uploads are not accepted\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spartan_refuses_a_malformed_request_line() {
|
||||
let server = Server::start();
|
||||
assert_eq!(server.send("spartan", b"only-two-fields 0\r\n"), "4 Bad request\r\n");
|
||||
assert_eq!(server.send("spartan", b"one.test / notanumber\r\n"), "4 Bad request\r\n");
|
||||
}
|
||||
|
||||
// -- Nex -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn nex_sends_bytes_with_no_status_or_headers() {
|
||||
let server = Server::start();
|
||||
// This listener names site two, which has no hosts in its request at all.
|
||||
let response = server.send("nex", b"/\r\n");
|
||||
assert!(response.starts_with("# Two"), "{response}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nex_resolves_a_redirect_server_side() {
|
||||
// There is no redirect status to bounce with, so the target's content comes
|
||||
// back on the first request.
|
||||
let server = Server::start();
|
||||
let response = server.send("nex", b"/index.md\r\n");
|
||||
assert!(response.starts_with("# Two"), "{response}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nex_reports_a_missing_page_as_a_plain_body() {
|
||||
let server = Server::start();
|
||||
assert_eq!(server.send("nex", b"/nope\r\n"), "Not found\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_nex_selector_is_the_root() {
|
||||
let server = Server::start();
|
||||
assert!(server.send("nex", b"\r\n").starts_with("# Two"));
|
||||
}
|
||||
|
||||
// -- Containment ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn nothing_outside_the_root_is_reachable_over_any_protocol() {
|
||||
let server = Server::start();
|
||||
for target in ["/../two/index.md", "/%2e%2e/two/index.md", "/.itsybitsy.toml"] {
|
||||
let response = server.get("one.test", target);
|
||||
let status = split(&response).0;
|
||||
assert!(
|
||||
status.starts_with("HTTP/1.1 404") || status.starts_with("HTTP/1.1 301"),
|
||||
"{target} -> {status}"
|
||||
);
|
||||
assert!(!response.contains("<h1>Two</h1>"), "{target} reached the other site");
|
||||
}
|
||||
assert_eq!(server.send("spartan", b"one.test /.itsybitsy.toml 0\r\n"), "4 Not found\r\n");
|
||||
assert_eq!(server.send("nex", b"/.itsybitsy.toml\r\n"), "Not found\n");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue