crates/gunnar-public-site/tests/common/mod.rs
//! The fake source and the in-process server every test here drives.
#![allow(dead_code)]
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use axum::body::Body;
use axum::Router;
use gunnar_public_site::{BoxFuture, Commit, Config, Hooks, Issue, IssueNote, Limits, Offer, RepoRef, RepoSnapshot, Site, Source, TreeEntry};
use http::{header, Request, StatusCode};
pub const NS: &str = "nordisk";
pub const PUBLIC: &str = "gunnar";
/// A private repository the fake lists BY MISTAKE (`public: false`), the way a
/// guest key enrolled with a grant would: it must appear nowhere.
pub const PRIVATE: &str = "payroll-secret-repo";
pub const PRIVATE_TEXT: &str = "salary-of-the-ceo-nobody-may-read";
pub const README_HEADING: &str = "The Public Repository";
pub const HEAD: &str = "aa11bb22cc33dd44ee55ff66aa11bb22cc33dd44";
/// What the fake has been asked, by call.
#[derive(Default)]
pub struct Calls {
pub list: AtomicUsize,
pub head: AtomicUsize,
pub snapshot: AtomicUsize,
pub offer: AtomicUsize,
/// `Forge.ListIssues`, once per public repository per refresh.
pub issues: AtomicUsize,
/// `Forge.GetIssue`, once per issue whose thread moved.
pub threads: AtomicUsize,
}
impl Calls {
pub fn total(&self) -> usize {
self.list.load(Ordering::SeqCst) + self.head.load(Ordering::SeqCst) + self.snapshot.load(Ordering::SeqCst) + self.offer.load(Ordering::SeqCst)
}
}
pub struct Fake {
pub calls: Arc<Calls>,
pub head: Mutex<String>,
pub offer: Option<Offer>,
/// More rows the fake lists, after the two above.
pub extra: Vec<RepoRef>,
/// A repository's newest commit time, by name, where it is not the default.
pub at: Vec<(String, i64)>,
/// More files of the public repository, at any depth (path, text): in the snapshot's
/// tree with their directories (their text is the console's to read, never the site's).
pub more_files: Vec<(String, String)>,
/// The public repository's issues, as gunnar's `Forge.ListIssues` folds them (no thread).
pub issues: Mutex<Vec<Issue>>,
/// Each issue's thread after its opening, as `Forge.GetIssue` returns it.
pub threads: Mutex<BTreeMap<u64, Vec<IssueNote>>>,
}
/// The public repository's issues (`ISSUE_*` below): #1 open with one comment, #2 closed.
pub const ISSUE_OPEN: &str = "The boot is dark";
pub const ISSUE_CLOSED: &str = "Push hangs on large packs";
pub const ISSUE_COMMENT: &str = "It happens on Mondays only.";
pub const ISSUE_REASON: &str = "Fixed in 1.5.1.";
/// What the private repository's issue says, if the site ever asked for it.
pub const PRIVATE_ISSUE: &str = "the-private-issue-nobody-may-read";
pub fn public_issues() -> Vec<Issue> {
vec![
Issue { number: 1, title: ISSUE_OPEN.into(), body: "PID 1 prints to a dead ttyS0. <script>alert(2)</script>".into(), open: true, opened_at_unix: 1_761_264_000, comments: 1, thread: None },
Issue { number: 2, title: ISSUE_CLOSED.into(), body: "A 2 GiB push never ends.".into(), open: false, opened_at_unix: 1_761_350_400, comments: 1, thread: None },
]
}
pub fn public_threads() -> BTreeMap<u64, Vec<IssueNote>> {
BTreeMap::from([
(1, vec![IssueNote { at_unix: 1_761_300_000, body: ISSUE_COMMENT.into(), closed: false }]),
(2, vec![IssueNote { at_unix: 1_761_360_000, body: "A fix is on master.".into(), closed: false }, IssueNote { at_unix: 1_761_436_800, body: ISSUE_REASON.into(), closed: true }]),
])
}
impl Fake {
pub fn new() -> Fake {
Fake {
calls: Arc::new(Calls::default()),
head: Mutex::new(HEAD.to_owned()),
offer: None,
extra: Vec::new(),
at: Vec::new(),
issues: Mutex::new(public_issues()),
threads: Mutex::new(public_threads()),
more_files: Vec::new(),
}
}
}
pub fn public_snapshot(head: &str) -> RepoSnapshot {
RepoSnapshot {
head: Some(Commit { oid: head.to_owned(), subject: "the newest commit".into(), at_unix: 1_761_350_400 }),
readme: Some(("README.md".into(), format!("# {README_HEADING}\n\nHello <script>alert(1)</script> world.\n"))),
tree: vec![
TreeEntry { path: "README.md".into(), dir: false },
TreeEntry { path: "src".into(), dir: true },
TreeEntry { path: "src/main.rs".into(), dir: false },
],
tree_complete: true,
}
}
impl Source for Fake {
fn public_repos(&self) -> BoxFuture<'_, Result<Vec<RepoRef>, String>> {
self.calls.list.fetch_add(1, Ordering::SeqCst);
let extra = self.extra.clone();
Box::pin(async move {
let mut rows = vec![
RepoRef { namespace: NS.into(), name: PUBLIC.into(), description: "The git server itself.".into(), public: true },
RepoRef { namespace: NS.into(), name: PRIVATE.into(), description: PRIVATE_TEXT.into(), public: false },
];
rows.extend(extra);
Ok(rows)
})
}
fn head<'a>(&'a self, repo: &'a RepoRef) -> BoxFuture<'a, Result<Option<String>, String>> {
self.calls.head.fetch_add(1, Ordering::SeqCst);
assert_ne!(repo.name, PRIVATE, "the site asked about a private repository");
let head = self.head.lock().unwrap().clone();
Box::pin(async move { Ok(Some(head)) })
}
fn snapshot<'a>(&'a self, repo: &'a RepoRef, _limits: &'a Limits) -> BoxFuture<'a, Result<RepoSnapshot, String>> {
self.calls.snapshot.fetch_add(1, Ordering::SeqCst);
assert_ne!(repo.name, PRIVATE, "the site read a private repository");
let head = self.head.lock().unwrap().clone();
let at = self.at.iter().find(|(n, _)| *n == repo.name).map(|(_, t)| *t);
let more = self.more_files.clone();
Box::pin(async move {
let mut s = public_snapshot(&head);
for (path, text) in more {
let mut dir = String::new();
for part in path.split('/').collect::<Vec<_>>().split_last().map(|(_, d)| d.to_vec()).unwrap_or_default() {
dir = if dir.is_empty() { part.to_owned() } else { format!("{dir}/{part}") };
if !s.tree.iter().any(|e| e.path == dir) {
s.tree.push(TreeEntry { path: dir.clone(), dir: true });
}
}
let _ = text;
s.tree.push(TreeEntry { path, dir: false });
}
if let Some(t) = at {
s.head.as_mut().unwrap().at_unix = t;
}
Ok(s)
})
}
fn offer(&self) -> BoxFuture<'_, Option<Offer>> {
self.calls.offer.fetch_add(1, Ordering::SeqCst);
let offer = self.offer.clone();
Box::pin(async move { offer })
}
fn issues<'a>(&'a self, repo: &'a RepoRef) -> BoxFuture<'a, Result<Vec<Issue>, String>> {
self.calls.issues.fetch_add(1, Ordering::SeqCst);
assert_ne!(repo.name, PRIVATE, "the site asked for a private repository's issues");
let rows = if repo.name == PUBLIC { self.issues.lock().unwrap().clone() } else { Vec::new() };
Box::pin(async move { Ok(rows) })
}
fn issue_thread<'a>(&'a self, repo: &'a RepoRef, number: u64) -> BoxFuture<'a, Result<Vec<IssueNote>, String>> {
self.calls.threads.fetch_add(1, Ordering::SeqCst);
assert_ne!(repo.name, PRIVATE, "the site read a private repository's issue");
let thread = self.threads.lock().unwrap().get(&number).cloned();
Box::pin(async move { thread.ok_or_else(|| format!("no issue #{number}")) })
}
}
/// The console's own landing page, as a console hands it to the site
/// ([`Config::landing`]): its title, its picture and its look.
pub const LANDING: &str = "<!doctype html>\n<html lang=\"en\" data-theme=\"light\">\n<head>\n<meta charset=\"utf-8\">\n<title>Gunnar — log in</title>\n<style>:root { --backdrop-image: url(\"/gunnar_silver.webp\"); }</style>\n</head>\n<body>\n<div class=\"art\" aria-hidden=\"true\"></div>\n<main class=\"side\"><h1>Gunnar</h1><button class=\"primary\">Log in with a passkey</button></main>\n</body>\n</html>\n";
pub fn config() -> Config {
let mut c = Config::gunnar("https://gunnar.example");
c.git_url = Some("ssh://git.gunnar.example:2222".into());
c.landing = Some(LANDING.into());
c
}
/// The cookie a signed-in person carries in these tests.
pub const SESSION: &str = "session=signed-in";
/// Every IndexNow ping the site sent, as `(endpoint, JSON body)`; answers
/// with `status`.
pub struct Pings {
pub sent: Mutex<Vec<(String, String)>>,
pub status: Mutex<Result<u16, String>>,
}
impl Pings {
pub fn new() -> Arc<Pings> {
Arc::new(Pings { sent: Mutex::new(Vec::new()), status: Mutex::new(Ok(200)) })
}
pub fn count(&self) -> usize {
self.sent.lock().unwrap().len()
}
/// The URL list of ping `i`.
pub fn urls(&self, i: usize) -> Vec<String> {
let body: serde_json::Value = serde_json::from_str(&self.sent.lock().unwrap()[i].1).unwrap();
body["urlList"].as_array().unwrap().iter().map(|u| u.as_str().unwrap().to_owned()).collect()
}
}
impl gunnar_public_site::Pinger for Pings {
fn post<'a>(&'a self, endpoint: &'a str, json: String) -> BoxFuture<'a, Result<u16, String>> {
self.sent.lock().unwrap().push((endpoint.to_owned(), json));
let answer = self.status.lock().unwrap().clone();
Box::pin(async move { answer })
}
}
/// The hooks, with every outbound call recorded by `pings`.
pub fn hooks_pinging(pings: Arc<Pings>) -> Hooks {
let mut h = hooks();
h.pinger = Some(pings);
h
}
pub fn hooks() -> Hooks {
Hooks {
pinger: None,
signed_in: Arc::new(|h: &http::HeaderMap| h.get(header::COOKIE).and_then(|v| v.to_str().ok()) == Some(SESSION)),
}
}
/// A site over the fake, refreshed once.
pub async fn site_with(fake: Fake, config: Config) -> (Arc<Site>, Arc<Calls>) {
let calls = fake.calls.clone();
let site = Site::new(config, Arc::new(fake), hooks(), true);
site.refresh().await.expect("the first refresh");
(site, calls)
}
pub async fn site() -> (Arc<Site>, Arc<Calls>) {
site_with(Fake::new(), config()).await
}
/// What the console answers today for anything the site does not serve: the
/// login redirect. A pass-through is told apart by this exact answer.
pub const TODAY: &str = "the console, as it answers today";
pub fn app(site: Arc<Site>) -> Router {
Router::new()
.fallback(|| async { (StatusCode::SEE_OTHER, [(header::LOCATION, "/login")], TODAY) })
.layer(axum::middleware::from_fn_with_state(site, gunnar_public_site::layer))
}
pub struct Answer {
pub status: StatusCode,
pub headers: http::HeaderMap,
pub body: String,
}
pub async fn get_as(app: &Router, uri: &str, cookie: Option<&str>, ua: Option<&str>, client: Option<&str>) -> Answer {
use tower::ServiceExt;
let mut r = Request::get(uri);
if let Some(c) = cookie {
r = r.header(header::COOKIE, c);
}
if let Some(u) = ua {
r = r.header(header::USER_AGENT, u);
}
if let Some(c) = client {
r = r.header("x-test-client", c);
}
let response = app.clone().oneshot(r.body(Body::empty()).unwrap()).await.unwrap();
let status = response.status();
let headers = response.headers().clone();
let body = axum::body::to_bytes(response.into_body(), 8 << 20).await.unwrap();
Answer { status, headers, body: String::from_utf8_lossy(&body).into_owned() }
}
/// An anonymous GET, each from its own client so the rate limit stays out of
/// the way of tests that are not about it.
pub async fn get(app: &Router, uri: &str) -> Answer {
static N: AtomicUsize = AtomicUsize::new(0);
let client = format!("client-{}", N.fetch_add(1, Ordering::SeqCst));
get_as(app, uri, None, None, Some(&client)).await
}
/// Every built page's path and body, for the never-leak sweeps.
pub fn every_page(site: &Site) -> Vec<(String, String)> {
let built = site.built();
let mut out: Vec<(String, String)> = built.pages.iter().map(|(p, page)| (p.clone(), page.body.to_string())).collect();
out.sort();
out
}