//! Shared helpers for the integration tests that spawn the real `frogue`
//! binary.
//!
//! They live in a `support` directory rather than next to the tests so that
//! Cargo does not try to compile this module as a test binary of its own.
#![allow(dead_code)]
pub mod ap;
use std::collections::HashSet;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use std::{fs, thread};
use frogue::config::*;
use miette::IntoDiagnostic;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
/// The ports this process has already handed out.
///
/// A port can be handed to two tests in a row: the kernel is free to reassign
/// an ephemeral port the moment the listener that probed it is dropped. When
/// that happens the second server cannot bind and the test that asked for it
/// ends up talking to the first server instead - a different database, killed
/// while its requests are in flight. Remembering what was handed out makes
/// each call a port no other call in this process will get.
fn claimed_ports() -> &'static Mutex<HashSet<u16>> {
static PORTS: OnceLock<Mutex<HashSet<u16>>> = OnceLock::new();
PORTS.get_or_init(|| Mutex::new(HashSet::new()))
}
/// A port nothing is listening on and no other call in this process has been
/// given. Bound and released immediately, so the window for another process
/// stealing it before the server starts is tiny. It's a pirate life for me
pub fn free_port() -> u16 {
loop {
let port = TcpListener::bind("127.0.0.1:0")
.expect("loopback should be available to pick a free port")
.local_addr()
.expect("bound address should parse")
.port();
if claimed_ports()
.lock()
.expect("the claimed-port set should not be poisoned")
.insert(port)
{
return port;
}
}
}
pub struct Server {
child: Child,
db: PathBuf,
database_url: String,
/// An empty directory the binary was started in and that [`Server`] owns,
/// when the test asked for one. Removed on drop.
workdir: Option<PathBuf>,
/// The port the SSH server was told to listen on.
ssh_port: u16,
/// The directory bare repositories were told to live under. Removed on
/// drop, together with the host key the server generates beside it.
repo_root: PathBuf,
/// The public URL for federation, if set.
public_url: Option<String>,
}
impl Server {
/// Spawns the binary with `FROGUE_BIND_ADDR` set to `bind_addr`, and its
/// stderr captured to a file so failures can be reported.
pub fn spawn(bind_addr: &str) -> Self {
Self::spawn_in(bind_addr, None, None, &[])
}
/// Like [`Server::spawn`], but sets additional environment variables for
/// the spawned process.
pub fn spawn_with_env(bind_addr: &str, env: &[(&str, &str)]) -> Self {
Self::spawn_in(bind_addr, None, None, env)
}
/// Like [`Server::spawn`], but starts the binary in an empty temporary
/// directory of its own and removes it on drop. Tests that assert the
/// server carries what it needs inside the binary - rather than reading a
/// directory next to wherever it happens to be started - run the process
/// somewhere that directory does not exist.
pub fn spawn_elsewhere(bind_addr: &str) -> Self {
let workdir = std::env::temp_dir().join(format!("frogue-test-cwd-{}", free_port()));
fs::create_dir_all(&workdir).expect("temporary working directory should be creatable");
Self::spawn_in(bind_addr, Some(workdir), None, &[])
}
/// Spawns the server with federation enabled, setting `FROGUE_PUBLIC_URL`
/// to `http://{bind_addr}`.
pub fn spawn_federated(bind_addr: &str) -> Self {
let public_url = format!("http://{bind_addr}");
Self::spawn_in(
bind_addr,
None,
Some(public_url),
&[(FROGUE_ALLOW_PRIVATE_HOSTS_VAR_KEY, "1")],
)
}
fn spawn_in(
bind_addr: &str,
workdir: Option<PathBuf>,
public_url: Option<String>,
env: &[(&str, &str)],
) -> Self {
// A database of its own, so the tests never touch a running dev
// server's database.
let db = std::env::temp_dir().join(format!("frogue-test-{}.sqlite", free_port()));
let database_url = format!("sqlite://{}?mode=rwc", db.display());
// A port and a data directory of its own: tests run in parallel, and
// the SSH server's default port would otherwise be claimed twice. The
// repositories go in a subdirectory so the host key the server
// generates beside them is also per-test.
let ssh_port = free_port();
let data = std::env::temp_dir().join(format!("frogue-test-data-{}", free_port()));
let repo_root = data.join("repos");
fs::create_dir_all(&repo_root).expect("temporary repository root should be creatable");
let mut command = Command::new(env!("CARGO_BIN_EXE_frogue"));
command
.env(HTTP_BIND_ADDR_VAR_KEY, bind_addr)
.env(DATABASE_URL_VAR_KEY, &database_url)
.env(SSH_BIND_ADDR_VAR_KEY, format!("127.0.0.1:{ssh_port}"))
.env(REPO_ROOT_VAR_KEY, &repo_root)
.env("RUST_LOG", "frogue=debug,actix-web=info,sqlx=info,warn");
// .stdout(Stdio::inherit())
// .stderr(Stdio::inherit());
if let Some(public_url) = &public_url {
command.env(FROGUE_PUBLIC_URL_VAR_KEY, public_url);
}
for (key, value) in env {
command.env(key, value);
}
if let Some(workdir) = &workdir {
command.current_dir(workdir);
}
let child = command.spawn().expect("frogue binary should be spawnable");
Server {
child,
db,
database_url,
workdir,
ssh_port,
repo_root,
public_url,
}
}
/// The `DATABASE_URL` the server was started with.
pub fn database_url(&self) -> &str {
&self.database_url
}
/// The port the SSH server listens on, for `git` to be pointed at.
pub fn ssh_port(&self) -> u16 {
self.ssh_port
}
/// The directory bare repositories live under.
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
/// The public URL for federation, if set.
pub fn public_url(&self) -> Option<&str> {
self.public_url.as_deref()
}
/// Waits until the server accepts a TCP connection on `port`.
pub fn wait_for_connection(&mut self, port: u16) {
let deadline = Instant::now() + STARTUP_TIMEOUT;
loop {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return;
}
if let Some(status) = self.exited() {
panic!("frogue exited before listening on port {port}: {status}");
}
if Instant::now() >= deadline {
panic!("frogue never listened on port {port} within {STARTUP_TIMEOUT:?}");
}
thread::sleep(Duration::from_millis(50));
}
}
/// Waits for the process to exit on its own and returns its status.
pub fn wait_for_exit(&mut self) -> ExitStatus {
let deadline = Instant::now() + STARTUP_TIMEOUT;
loop {
if let Some(status) = self.exited() {
return status;
}
if Instant::now() >= deadline {
panic!(
"frogue was still running after {STARTUP_TIMEOUT:?} instead of failing fast"
);
}
thread::sleep(Duration::from_millis(50));
}
}
fn exited(&mut self) -> Option<ExitStatus> {
self.child.try_wait().expect("wait on frogue child")
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
remove_database(&self.db);
if let Some(workdir) = &self.workdir {
let _ = fs::remove_dir_all(workdir);
}
// The whole data directory, not just the repositories: the host key
// that was generated beside them goes with it.
if let Some(data) = self.repo_root.parent() {
let _ = fs::remove_dir_all(data);
}
}
}
/// Removes a SQLite database and the auxiliary files it may have created.
fn remove_database(db: &Path) {
let _ = fs::remove_file(db);
for suffix in ["-wal", "-shm", "-journal"] {
let mut aux = db.as_os_str().to_owned();
aux.push(suffix);
let _ = fs::remove_file(PathBuf::from(aux));
}
}
/// What a single request to the server answered with.
pub struct Response {
pub status: u16,
pub content_type: Option<String>,
pub location: Option<String>,
pub body: String,
set_cookies: Vec<String>,
headers: Vec<(String, String)>,
}
impl Response {
/// The `Set-Cookie` headers the server sent, for the tests that care why
/// no session appeared.
pub fn set_cookies(&self) -> &[String] {
&self.set_cookies
}
/// The value of `name`, looked up the way HTTP field names compare -
/// case insensitively - for the tests that care about a header the
/// struct does not name on its own, a `Cache-Control` or an `ETag`.
/// The first of any repeated headers wins, as the first is the one a
/// client acts on.
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
/// Whether the response set `name` to a non-empty value, which is how the
/// server signals a session was established.
pub fn sets_cookie(&self, name: &str) -> bool {
self.set_cookies.iter().any(|cookie| {
cookie
.split_once('=')
.is_some_and(|(key, value)| key.trim() == name && !value.trim().is_empty())
})
}
}
/// A connection to the server that keeps the cookies it is given, the way a
/// browser would. Auth is carried in a cookie, so the tests need one.
pub struct Client {
port: u16,
cookies: Vec<(String, String)>,
}
impl Client {
pub fn new(port: u16) -> Self {
Client {
port,
cookies: Vec::new(),
}
}
pub fn get(&mut self, path: &str) -> Response {
self.send(&format!("GET {path} HTTP/1.1"), None, &[])
}
/// `GET`s `path` with extra request headers, for the tests that need to
/// send one the default set does not carry - an `Accept-Language`, say.
pub fn get_with(&mut self, path: &str, headers: &[(&str, &str)]) -> Response {
self.send(&format!("GET {path} HTTP/1.1"), None, headers)
}
/// `GET`s `path` with `Accept: application/activity+json` for ActivityPub
/// content negotiation.
pub fn get_with_accept(&mut self, path: &str) -> Response {
self.get_with(path, &[("Accept", "application/activity+json")])
}
/// `GET`s `path` with `Accept: application/activity+json` and parses the
/// JSON response.
pub fn get_ap(&mut self, path: &str) -> miette::Result<serde_json::Value> {
let response = self.get_with_accept(path);
let body = &response.body;
serde_json::from_str(body).into_diagnostic()
}
/// `POST`s `fields` as an `application/x-www-form-urlencoded` body.
pub fn post_form(&mut self, path: &str, fields: &[(&str, &str)]) -> Response {
let body = fields
.iter()
.map(|(name, value)| format!("{}={}", urlencode(name), urlencode(value)))
.collect::<Vec<_>>()
.join("&");
self.send(
&format!("POST {path} HTTP/1.1"),
Some(body),
&[("Content-Type", "application/x-www-form-urlencoded")],
)
}
/// `POST`s `body` as an `application/json` body with extra headers.
pub fn post_json(&mut self, path: &str, body: &str, headers: &[(&str, &str)]) -> Response {
let mut all_headers = vec![("Content-Type", "application/json")];
all_headers.extend_from_slice(headers);
self.send_with_bytes(
&format!("POST {path} HTTP/1.1"),
Some(body.as_bytes().to_vec()),
&all_headers,
)
}
/// `POST`s `body` as JSON with fully custom headers (no default
/// Content-Type).
pub fn post_json_with_headers(
&mut self,
path: &str,
body: &str,
headers: &[(&str, &str)],
) -> Response {
self.send_with_bytes(
&format!("POST {path} HTTP/1.1"),
Some(body.as_bytes().to_vec()),
headers,
)
}
/// Posts a multipart form with a file field.
pub fn post_multipart_file(
&mut self,
path: &str,
field_name: &str,
filename: &str,
content_type: &str,
data: &[u8],
) -> Response {
let boundary = "----TestBoundary7MA4YWxkTrZu0gW";
let mut body = Vec::new();
// Write the file field
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{field_name}\"; filename=\"{filename}\"\r\n"
)
.as_bytes(),
);
body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes());
body.extend_from_slice(data);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
self.send_with_bytes(
&format!("POST {path} HTTP/1.1"),
Some(body), // once told me
&[(
"Content-Type",
&format!("multipart/form-data; boundary={boundary}"),
)],
)
}
fn send(
&mut self,
request_line: &str,
body: Option<String>,
headers: &[(&str, &str)],
) -> Response {
let mut stream =
TcpStream::connect(("127.0.0.1", self.port)).expect("server should accept the request");
let mut request = format!(
"{request_line}\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\nAccept-Encoding: identity\r\n",
self.port
);
for (name, value) in headers {
request.push_str(&format!("{name}: {value}\r\n"));
}
if !self.cookies.is_empty() {
let jar = self
.cookies
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join("; ");
request.push_str(&format!("Cookie: {jar}\r\n"));
}
if let Some(body) = &body {
request.push_str(&format!(
"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\n",
body.len()
));
}
request.push_str("\r\n");
if let Some(body) = &body {
request.push_str(body);
}
stream
.write_all(request.as_bytes())
.expect("request should be sent to the server");
let mut raw = String::new();
stream
.read_to_string(&mut raw)
.expect("server should answer with UTF-8");
let Some((head, body)) = raw.split_once("\r\n\r\n") else {
// No separator means the server sent nothing, or closed before it
// had: name the bytes so a failure says which.
panic!("response should separate headers from the body, but the server sent:\n{raw:?}");
};
let mut lines = head.lines();
let status = lines
.next()
.expect("response should start with a status line")
.split_whitespace()
.nth(1)
.expect("status line should hold a status code")
.parse()
.expect("status code should be a number");
let mut content_type = None;
let mut location = None;
let mut set_cookies = Vec::new();
let mut headers = Vec::new();
for line in lines {
let Some((name, value)) = line.split_once(':') else {
continue;
};
let value = value.trim().to_string();
headers.push((name.trim().to_string(), value.clone()));
match name.to_ascii_lowercase().as_str() {
"content-type" => content_type = Some(value),
"location" => location = Some(value),
"set-cookie" => set_cookies.push(value),
_ => {}
}
}
self.absorb_cookies(&set_cookies);
Response {
status,
content_type,
location,
body: body.to_string(),
set_cookies,
headers,
}
}
fn send_with_bytes(
&mut self,
request_line: &str,
body: Option<Vec<u8>>,
headers: &[(&str, &str)],
) -> Response {
let mut stream =
TcpStream::connect(("127.0.0.1", self.port)).expect("server should accept the request");
let mut request = format!(
"{request_line}\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\nAccept-Encoding: identity\r\n",
self.port
);
for (name, value) in headers {
request.push_str(&format!("{name}: {value}\r\n"));
}
if !self.cookies.is_empty() {
let jar = self
.cookies
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join("; ");
request.push_str(&format!("Cookie: {jar}\r\n"));
}
if let Some(body) = &body {
request.push_str(&format!("Content-Length: {}\r\n", body.len()));
}
request.push_str("\r\n");
// Write the header as bytes
stream
.write_all(request.as_bytes())
.expect("request headers should be sent to the server");
// Write the body as bytes (if present)
if let Some(body) = &body {
stream
.write_all(body)
.expect("request body should be sent to the server");
}
let mut raw = Vec::new();
stream.read_to_end(&mut raw).expect("server should answer");
let raw_str = String::from_utf8_lossy(&raw).to_string();
let Some((head, body)) = raw_str.split_once("\r\n\r\n") else {
// No separator means the server sent nothing, or closed before it
// had: name the bytes so a failure says which.
panic!(
"response should separate headers from the body, but the server sent:\n{raw_str:?}"
);
};
let mut lines = head.lines();
let status = lines
.next()
.expect("response should start with a status line")
.split_whitespace()
.nth(1)
.expect("status line should hold a status code")
.parse()
.expect("status code should be a number");
let mut content_type = None;
let mut location = None;
let mut set_cookies = Vec::new();
let mut headers = Vec::new();
for line in lines {
let Some((name, value)) = line.split_once(':') else {
continue;
};
let value = value.trim().to_string();
headers.push((name.trim().to_string(), value.clone()));
match name.to_ascii_lowercase().as_str() {
"content-type" => content_type = Some(value),
"location" => location = Some(value),
"set-cookie" => set_cookies.push(value),
_ => {}
}
}
self.absorb_cookies(&set_cookies);
Response {
status,
content_type,
location,
body: body.to_string(),
set_cookies,
headers,
}
}
/// Keeps the cookies the server sent, replacing any of the same name, the
/// way a browser's jar does. Only the `name=value` part is kept; the
/// attributes after the first `;` say nothing about identity.
fn absorb_cookies(&mut self, set_cookies: &[String]) {
for cookie in set_cookies {
let Some((name, rest)) = cookie.split_once('=') else {
continue;
};
let name = name.trim().to_string();
let value = rest.split(';').next().unwrap_or("").trim().to_string();
self.cookies.retain(|(existing, _)| existing != &name);
if !value.is_empty() {
self.cookies.push((name, value));
}
}
}
}
/// Sends a lone `GET` and returns the answer.
///
/// A hand-rolled request rather than an HTTP client dependency: the tests
/// only ever need plain requests, and `Connection: close` plus
/// `Accept-Encoding: identity` keeps the answer simple enough to read
/// straight off the socket - a header block, then a body delimited by the
/// server closing the connection.
pub fn get(port: u16, path: &str) -> Response {
Client::new(port).get(path)
}
/// Percent-encodes a form field, keeping the unreserved characters plain.
fn urlencode(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char);
}
b' ' => encoded.push('+'),
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}