Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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
}