crates/gunnar-ui-server/src/anonymous/public_site.rs
//! **gunnar.rs's public face, end to end through the console** (TASKS-12, the
//! owner's SLIM RULE): the default build serves none of the site's routes, and
//! a `public-site` build serves them to a visitor, built from the grantless
//! guest key's answers, naming no private repository, and switched off at run
//! time by an administrator.
use super::{anon, console, FakeGunnar, NS, PUBLIC};
/// The paths the public site serves (TASKS-12), for the default build's half.
fn site_paths() -> Vec<String> {
vec![
"/llms.txt".into(),
"/llms-full.txt".into(),
"/robots.txt".into(),
"/sitemap.xml".into(),
format!("/{NS}/{PUBLIC}/readme.md"),
format!("/{NS}/{PUBLIC}/tree.txt"),
// GUN-GUEST: `/{ns}/{name}[/…]` is the console's address, no site page
// (`anonymous::console_address`).
format!("/{NS}/{PUBLIC}/issues.md"),
]
}
/// One issue in `repo`, open, with one comment in its thread (PUB-ISSUES).
#[cfg(feature = "public-site")]
fn seed_issue(fake: &FakeGunnar, repo: &str, title: &str, comment: &str) {
let mut issues = fake.issues.lock().unwrap();
let number = issues.iter().filter(|i| i.repo == repo).count() as u64 + 1;
issues.push(crate::pb::IssueState {
repo: repo.to_owned(),
number,
title: title.to_owned(),
body: format!("{title}, in words."),
author: "the-filers-principal-id".to_owned(),
open: true,
comments: 1,
opened_at_unix_ms: 1_761_264_000_000,
});
let comment = crate::pb::ForgeEvent {
repo: repo.to_owned(),
pull: number,
actor: "the-commenters-principal-id".to_owned(),
at_unix_ms: 1_761_300_000_000,
kind: Some(crate::pb::forge_event::Kind::IssueCommented(crate::pb::IssueCommented { body: comment.to_owned() })),
};
fake.issue_threads.lock().unwrap().insert((repo.to_owned(), number), vec![comment]);
}
#[cfg(feature = "public-site")]
const PUBLIC_ISSUE: &str = "The boot is dark";
#[cfg(feature = "public-site")]
const PUBLIC_COMMENT: &str = "It happens on Mondays only.";
#[cfg(feature = "public-site")]
const PRIVATE_ISSUE: &str = "payroll-issue-nobody-may-read";
/// ★ **The default build contains none of it**: every site path answers as a
/// path the console has never heard of does, and nothing on it says Gunnar.
#[cfg(not(feature = "public-site"))]
#[tokio::test]
async fn the_default_build_serves_no_public_site_route() {
assert_eq!(crate::PUBLIC_SITE, cfg!(feature = "public-site"));
let (app, _passkeys, _feed) = console(FakeGunnar::new(), true).await;
for path in site_paths() {
let (status, body) = anon(&app, &path).await;
assert_ne!(status, http::StatusCode::OK, "{path} was served by the default build: {body}");
assert!(!body.contains("European git server"), "{path}: {body}");
}
let (status, body) = super::send(&app, http::Method::GET, "/api/public-site", Some("break-glass")).await;
assert!(!body.contains("\"on\""), "the switch exists in the default build: {status} {body}");
}
/// ★ **GUI-PUBSW: the console tells an administrator which switches this build
/// carries** (`switches` in `/auth/status`), so `/admin` draws the Public site
/// switch on every `public-site` build, whatever its bundle and whether or not
/// the switch's own read answers; nobody else is told, and the default build
/// names none.
#[tokio::test]
async fn the_status_names_the_switches_this_build_carries_to_an_administrator() {
let (app, passkeys, _feed) = console(FakeGunnar::new(), true).await;
let switches = |body: &str| -> Vec<String> {
let v: serde_json::Value = serde_json::from_str(body).expect("the status is JSON");
v.get("switches").and_then(|s| s.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default()
};
let (_, admin) = super::signed_in(&app, &passkeys, "root", crate::role::Role::Admin, "/auth/status").await;
let mut want: Vec<String> = Vec::new();
if cfg!(feature = "public-site") {
want.push("public-site".into());
}
if cfg!(feature = "mcp") {
want.push("mcp".into());
}
assert_eq!(switches(&admin), want, "an administrator is told this build's switches: {admin}");
let (_, operator) = super::signed_in(&app, &passkeys, "otto", crate::role::Role::Operator, "/auth/status").await;
assert!(switches(&operator).is_empty(), "only an administrator flips a switch, so only one is told: {operator}");
let (_, visitor) = anon(&app, "/auth/status").await;
assert!(switches(&visitor).is_empty(), "a visitor is told no switch: {visitor}");
}
#[cfg(feature = "public-site")]
mod served {
use super::*;
use crate::anonymous::{signed_in, signer, GUEST_SEED, PRIVATE};
/// The site's pages are built by a refresh on the timer, started with the
/// router; wait for the first one to land. Slowly: every poll is a request
/// from the same client, and the site's rate limit counts it.
async fn built(app: &axum::Router, passkeys: &std::sync::Arc<crate::auth::Webauthn>) -> String {
switch_on(app, passkeys).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
for _ in 0..40 {
let (status, body) = anon(app, "/llms.txt").await;
if status == http::StatusCode::OK && body.contains(&format!("{NS}/{PUBLIC}")) {
return body;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
panic!("the public site never built its pages");
}
/// The site starts OFF (the owner, 2026-10-25); an administrator turns it on.
async fn switch_on(app: &axum::Router, passkeys: &std::sync::Arc<crate::auth::Webauthn>) {
use tower::ServiceExt;
let admin = passkeys.make_session("root", crate::role::Role::Admin, true);
let put = http::Request::put("/api/public-site")
.header(http::header::COOKIE, format!("{}={admin}", crate::auth::SESSION_COOKIE))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::ORIGIN, passkeys.origin())
.body(axum::body::Body::from("{\"on\":true}"))
.unwrap();
assert_eq!(app.clone().oneshot(put).await.unwrap().status(), http::StatusCode::OK);
}
/// ★ **Off unless told `on`** (owner 2026-10-25: a sold private estate is the
/// airgap default and is never indexed): with `GUNNAR_UI_PUBLIC_SITE` unset, a
/// `public-site` build answers as the default build, robots.txt disallows
/// everything, and the guest key is never used.
#[tokio::test]
async fn unset_the_site_is_off_and_robots_txt_disallows_everything() {
assert!(std::env::var(gunnar_public_site::ENV_SWITCH).is_err(), "the test environment sets the switch");
let fake = FakeGunnar::new();
let (app, _passkeys, _feed) = console(fake.clone(), true).await;
let (status, robots) = anon(&app, "/robots.txt").await;
assert_eq!((status, robots.as_str()), (http::StatusCode::OK, "User-agent: *\nDisallow: /\n"));
for path in site_paths().into_iter().filter(|p| p != "/robots.txt") {
let (status, body) = anon(&app, &path).await;
assert_ne!(status, http::StatusCode::OK, "{path} is served with the switch unset: {body}");
}
let (status, _) = anon(&app, "/").await;
assert_ne!(status, http::StatusCode::OK, "the public home is served with the switch unset");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(fake.fingerprints().is_empty(), "the switched-off site asked gunnar: {:?}", fake.fingerprints());
}
#[tokio::test]
async fn a_visitor_reads_llms_txt_and_the_public_readme_through_the_guest_key() {
let fake = FakeGunnar::new();
let (app, passkeys, _feed) = console(fake.clone(), true).await;
let llms = built(&app, &passkeys).await;
assert!(llms.starts_with("# Gunnar"), "{llms}");
assert!(!llms.contains(PRIVATE), "llms.txt names a private repository: {llms}");
let (status, readme) = anon(&app, &format!("/{NS}/{PUBLIC}/readme.md")).await;
assert_eq!(status, http::StatusCode::OK, "{readme}");
assert!(readme.contains(crate::anonymous::README_HEADING), "{readme}");
let (status, full) = anon(&app, "/llms-full.txt").await;
assert_eq!(status, http::StatusCode::OK);
assert!(!full.contains(PRIVATE), "{full}");
let guest = signer(GUEST_SEED).fingerprint().to_owned();
let seen = fake.fingerprints();
assert!(!seen.is_empty() && seen.iter().all(|f| *f == guest), "every call the site made must be the GUEST's: {seen:?}");
}
/// ★ PUB-HOME (owner 2026-10-29): with the switch ON, a visitor's `/` is the
/// console's own landing page, exactly as before the site was on (the
/// picture, the look), with only the site's search tags and JSON-LD put in
/// its `<head>`; a signed-in person still gets the console there.
#[tokio::test]
async fn a_visitor_gets_the_consoles_landing_with_the_search_tags_and_a_signed_in_person_the_console() {
let (app, passkeys, _feed) = console(FakeGunnar::new(), true).await;
built(&app, &passkeys).await;
let (status, home) = anon(&app, "/").await;
assert_eq!(status, http::StatusCode::OK, "{home}");
let landing = crate::auth::routes::login_html();
let at = landing.find("</head>").expect("the landing has a head");
assert!(home.ends_with(&landing[at..]), "everything a visitor sees is the landing's, byte for byte");
assert!(home.contains("url(\"/gunnar_silver.webp\")"), "the picture");
let head = &home[..home.find("</head>").expect("a head")];
for tag in [
"<title>Gunnar — a European git server in Rust</title>",
"<meta name=\"description\" content=\"Gunnar is a European git server in Rust: self-hosted or SaaS, with SLSA Level 3 releases.\">",
"<link rel=\"canonical\" href=\"",
"<meta property=\"og:title\"",
"<meta name=\"twitter:card\"",
"<script type=\"application/ld+json\">",
] {
assert_eq!(head.matches(tag).count(), 1, "{tag} once, in the head");
}
assert!(!home.contains("<h2>What it does</h2>") && !home.contains("<ul class=\"cards"), "the old text home is drawn");
assert!(!home.contains(PRIVATE), "{home}");
let (_, console_root) = signed_in(&app, &passkeys, "root", crate::role::Role::Admin, "/").await;
assert!(!console_root.contains("application/ld+json"), "a signed-in person was handed the public home: {console_root}");
}
/// S-2 through the console: robots.txt is served to a visitor, before the
/// login redirect, and names no repository.
#[tokio::test]
async fn a_crawler_reads_robots_txt_without_a_login() {
let (app, passkeys, _feed) = console(FakeGunnar::new(), true).await;
built(&app, &passkeys).await;
let (status, robots) = anon(&app, "/robots.txt").await;
assert_eq!(status, http::StatusCode::OK, "{robots}");
for line in ["User-agent: GPTBot", "User-agent: ClaudeBot", "Disallow: /login", "Disallow: /auth/", "Crawl-delay: 10", "Sitemap: "] {
assert!(robots.contains(line), "{line}: {robots}");
}
assert!(!robots.contains(PRIVATE) && !robots.contains(&format!("{NS}/")), "{robots}");
}
/// S-3 through the console: the sitemap is served to a visitor and names
/// the public repository, never the private one.
#[tokio::test]
async fn a_crawler_reads_the_sitemap_without_a_login() {
let (app, passkeys, _feed) = console(FakeGunnar::new(), true).await;
built(&app, &passkeys).await;
let (status, map) = anon(&app, "/sitemap.xml").await;
assert_eq!(status, http::StatusCode::OK, "{map}");
assert!(map.contains("<urlset") && map.contains(&format!("/{NS}/{PUBLIC}</loc>")), "{map}");
assert!(!map.contains(PRIVATE), "{map}");
}
/// ★ **PUB-ISSUES through the console**: a visitor reads, searches and
/// fetches the archive of a PUBLIC repository's issues, built from the
/// grantless guest's `Forge.ListIssues`/`GetIssue`; a private repository's
/// issues answer exactly as a repository that does not exist, are named on
/// no page, and no page names who filed or answered.
#[tokio::test]
async fn a_visitor_reads_and_searches_the_public_issues_through_the_guest_key() {
let fake = FakeGunnar::new();
seed_issue(&fake, &format!("{NS}/{PUBLIC}"), PUBLIC_ISSUE, PUBLIC_COMMENT);
seed_issue(&fake, &format!("{NS}/{PRIVATE}"), PRIVATE_ISSUE, "a private comment");
let (app, passkeys, _feed) = console(fake.clone(), true).await;
built(&app, &passkeys).await;
// GUN-GUEST: the list, an issue and the search are the console's pages
// (`anonymous::console_address`); the archive is the site's file.
let mut archive = (http::StatusCode::NOT_FOUND, String::new());
for _ in 0..40 {
archive = anon(&app, &format!("/{NS}/{PUBLIC}/issues.md")).await;
if archive.1.contains(PUBLIC_COMMENT) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
let (status, archive) = archive;
assert_eq!(status, http::StatusCode::OK);
assert!(archive.contains(PUBLIC_ISSUE) && archive.contains(PUBLIC_COMMENT), "{archive}");
// The private repository: as one that does not exist.
for leaf in ["issues.md"] {
let private = anon(&app, &format!("/{NS}/{PRIVATE}/{leaf}")).await;
let nobody = anon(&app, &format!("/{NS}/no-such-repository/{leaf}")).await;
assert_eq!(private, nobody, "{leaf}: a private repository's issues must answer as none");
assert_ne!(private.0, http::StatusCode::OK);
}
let pages = ["/llms.txt".to_owned(), "/llms-full.txt".to_owned(), "/sitemap.xml".to_owned(), "/".to_owned(), format!("/{NS}/{PUBLIC}/issues"), format!("/{NS}/{PUBLIC}/issues/1"), format!("/{NS}/{PUBLIC}/issues?q=x"), format!("/{NS}/{PUBLIC}/issues.md")];
for page in pages {
let (_, body) = anon(&app, &page).await;
assert!(!body.contains(PRIVATE_ISSUE) && !body.contains("a private comment"), "{page} names the private issue: {body}");
assert!(!body.contains("principal-id"), "{page} names who filed or answered: {body}");
}
let guest = signer(GUEST_SEED).fingerprint().to_owned();
let seen = fake.fingerprints();
assert!(seen.iter().all(|f| *f == guest), "every call the site made must be the GUEST's: {seen:?}");
// GUN-GUEST: a guest reads the public issues through the console's own read, as the guest.
assert_eq!(anon(&app, &format!("/api/repos/{NS}/{PUBLIC}/issues")).await.0, http::StatusCode::OK);
// Filing one is the console's too.
let (status, _) = crate::anonymous::send(&app, http::Method::POST, &format!("/{NS}/{PUBLIC}/issues"), None).await;
assert_ne!(status, http::StatusCode::OK, "a visitor's POST was taken");
}
/// The gunnar README's own first line (gunnar.rs, 2026-10-29), its picture
/// beside it in `.nornir/assets/`.
const GUNNAR_README: &str = "<p align=\"center\"><img src=\".nornir/assets/gunnar.webp\" alt=\"gunnar\" width=\"320\"></p>\n\n# The Public Repository\n";
/// A WEBP's first bytes: RIFF, a length, WEBP, a VP8 chunk.
const WEBP: &[u8] = b"RIFF\x1a\x00\x00\x00WEBPVP8 \x0e\x00\x00\x00\x30\x01\x00\x9d\x01\x2a\x01\x00\x01\x00\x02\x00";
/// One tree as gunnar hands it back: `{mode} {name}\0` and 32 raw bytes.
fn tree(entries: &[(&str, &str, u8)]) -> Vec<u8> {
let mut out = Vec::new();
for (mode, name, tag) in entries {
out.extend_from_slice(format!("{mode} {name}\0").as_bytes());
let mut raw = [0u8; 32];
raw[0] = *tag;
raw[31] = *tag;
out.extend_from_slice(&raw);
}
out
}
fn oid(tag: u8) -> String {
format!("{tag:02x}{}{tag:02x}", "0".repeat(60))
}
/// Give `repo` gunnar's README shape: `README.md` with the picture's
/// `<img>`, and `.nornir/assets/gunnar.webp`.
fn with_the_gunnar_readme(fake: &FakeGunnar, repo: &str) {
let mut repos = fake.repos.lock().unwrap();
let held = repos.get_mut(&format!("{NS}/{repo}")).expect("a repository this fake holds");
held.objects.insert("tree-oid".to_owned(), tree(&[("40000", ".nornir", 0x41), ("100644", "README.md", 0x42)]));
held.objects.insert(oid(0x41), tree(&[("40000", "assets", 0x43)]));
held.objects.insert(oid(0x43), tree(&[("100644", "gunnar.webp", 0x44)]));
held.objects.insert(oid(0x42), GUNNAR_README.as_bytes().to_vec());
held.objects.insert(oid(0x44), WEBP.to_vec());
}
/// GET with the response's headers and bytes.
async fn fetch(app: &axum::Router, uri: &str) -> (http::StatusCode, http::HeaderMap, Vec<u8>) {
use tower::ServiceExt;
let r = app.clone().oneshot(http::Request::get(uri).body(axum::body::Body::empty()).unwrap()).await.unwrap();
let (status, headers) = (r.status(), r.headers().clone());
(status, headers, axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap().to_vec())
}
/// ★ **PUB-IMG (2026-10-29): the README's picture is an anonymous 200
/// image.** gunnar.rs/nordisk/gunnar drew `<img src=".../blob/.nornir/assets/
/// gunnar.webp">`, and `/blob/` is an HTML file page for a text file only, so
/// the URL fell through to the console's 401. The picture now points at the
/// console's own anonymous raw route, read as the guest: a public
/// repository's picture is image/webp under nosniff and a sandbox CSP; a
/// private one is refused; and the switch changes nothing about that route.
#[tokio::test]
async fn the_gunnar_readmes_picture_is_an_anonymous_200_image() {
let fake = FakeGunnar::new();
with_the_gunnar_readme(&fake, PUBLIC);
with_the_gunnar_readme(&fake, PRIVATE);
let (app, passkeys, _feed) = console(fake.clone(), true).await;
// Off first: the console's raw route answers as it always has.
let raw = format!("/api/repos/{NS}/{PUBLIC}/raw?path=.nornir/assets/gunnar.webp");
let (off_status, _, off_bytes) = fetch(&app, &raw).await;
built(&app, &passkeys).await;
let (status, page) = anon(&app, &format!("/{NS}/{PUBLIC}")).await;
assert_eq!(status, http::StatusCode::OK, "{page}");
let src = page
.split("<img ")
.filter_map(|tag| tag.split("src=\"").nth(1).and_then(|v| v.split('"').next()))
.find(|v| v.contains("gunnar.webp"))
.unwrap_or_else(|| panic!("the README's picture is on the page: {page}"));
assert!(!src.contains("/blob/"), "a picture must not point at a file page: {src}");
let path = &src[src.find("/api/repos/").expect("the console's raw route")..];
assert_eq!(path, raw, "{src}");
let (status, headers, bytes) = fetch(&app, path).await;
assert_eq!(status, http::StatusCode::OK, "{}", String::from_utf8_lossy(&bytes));
assert_eq!(headers[http::header::CONTENT_TYPE], "image/webp");
assert_eq!(headers[http::header::X_CONTENT_TYPE_OPTIONS], "nosniff");
assert!(headers[http::header::CONTENT_SECURITY_POLICY].to_str().unwrap().contains("sandbox"), "{headers:?}");
assert_eq!(bytes, WEBP);
assert_eq!((off_status, off_bytes), (status, bytes), "the switch changes nothing on the raw route");
// A private repository's picture: refused to a visitor, on or off.
let (status, _, body) = fetch(&app, &format!("/api/repos/{NS}/{PRIVATE}/raw?path=.nornir/assets/gunnar.webp")).await;
assert_ne!(status, http::StatusCode::OK, "a private picture was served: {}", String::from_utf8_lossy(&body));
assert_ne!(body, WEBP);
}
#[tokio::test]
async fn a_private_repository_answers_as_it_does_today() {
let (app, passkeys, _feed) = console(FakeGunnar::new(), true).await;
built(&app, &passkeys).await;
let private = anon(&app, &format!("/{NS}/{PRIVATE}")).await;
let nobody = anon(&app, &format!("/{NS}/no-such-repository")).await;
assert_eq!(private, nobody, "a private repository's page must answer as one that does not exist");
assert_ne!(private.0, http::StatusCode::OK);
for leaf in ["readme.md", "tree.txt", "tree", "commits"] {
let private = anon(&app, &format!("/{NS}/{PRIVATE}/{leaf}")).await;
let nobody = anon(&app, &format!("/{NS}/no-such-repository/{leaf}")).await;
assert_eq!(private, nobody, "{leaf}: a private repository must answer as one that does not exist");
assert_ne!(private.0, http::StatusCode::OK);
}
}
#[tokio::test]
async fn a_second_request_does_not_reach_gunnar() {
let fake = FakeGunnar::new();
let (app, passkeys, _feed) = console(fake.clone(), true).await;
built(&app, &passkeys).await;
let before = fake.fingerprints().len();
for _ in 0..3 {
assert_eq!(anon(&app, &format!("/{NS}/{PUBLIC}/tree.txt")).await.0, http::StatusCode::OK);
assert_eq!(anon(&app, "/llms.txt").await.0, http::StatusCode::OK);
}
assert_eq!(fake.fingerprints().len(), before, "a visitor's request reached gunnar");
}
#[tokio::test]
async fn without_a_guest_key_no_repository_is_published() {
let (app, passkeys, _feed) = console(FakeGunnar::new(), false).await;
switch_on(&app, &passkeys).await;
let (status, body) = anon(&app, "/llms.txt").await;
assert_eq!(status, http::StatusCode::OK);
assert!(!body.contains(&format!("[{NS}/{PUBLIC}]")), "{body}");
assert!(!body.contains(PRIVATE));
}
/// ★ **The runtime off switch**: an administrator flips it on the running
/// console, and every site path answers as the default build's does.
#[tokio::test]
async fn an_administrator_switches_the_site_off_and_on_at_run_time() {
use tower::ServiceExt;
let fake = FakeGunnar::new();
let (app, passkeys, _feed) = console(fake.clone(), true).await;
built(&app, &passkeys).await;
// ★ Owner 2026-10-26: the switch is gunnar's generic setting `console.public-site`.
assert_eq!(fake.setting("console.public-site").as_deref(), Some(&b"on"[..]), "the flip on is stored at gunnar");
let put = |cookie: String, on: bool| {
http::Request::put("/api/public-site")
.header(http::header::COOKIE, format!("{}={cookie}", crate::auth::SESSION_COOKIE))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::ORIGIN, passkeys.origin())
.body(axum::body::Body::from(format!("{{\"on\":{on}}}")))
.unwrap()
};
let viewer = passkeys.make_session("vera", crate::role::Role::Viewer, true);
let refused = app.clone().oneshot(put(viewer, false)).await.unwrap();
assert_eq!(refused.status(), http::StatusCode::FORBIDDEN);
assert_eq!(anon(&app, "/llms.txt").await.0, http::StatusCode::OK, "a viewer switched it off");
assert_eq!(fake.setting("console.public-site").as_deref(), Some(&b"on"[..]), "a refused flip stores nothing");
let admin = passkeys.make_session("root", crate::role::Role::Admin, true);
let done = app.clone().oneshot(put(admin.clone(), false)).await.unwrap();
assert_eq!(done.status(), http::StatusCode::OK);
assert_eq!(fake.setting("console.public-site").as_deref(), Some(&b"off"[..]));
let (_, state) = signed_in(&app, &passkeys, "root", crate::role::Role::Admin, "/api/public-site").await;
assert!(state.contains("\"on\":false"), "{state}");
for path in site_paths().into_iter().filter(|p| p != "/robots.txt") {
let off = anon(&app, &path).await;
assert_ne!(off.0, http::StatusCode::OK, "{path} is still served with the switch off: {}", off.1);
}
let back = app.clone().oneshot(put(admin, true)).await.unwrap();
assert_eq!(back.status(), http::StatusCode::OK);
assert_eq!(anon(&app, "/llms.txt").await.0, http::StatusCode::OK);
}
/// ★ **The stored position wins over the environment's** (owner 2026-10-26): with
/// `GUNNAR_UI_PUBLIC_SITE` unset the site starts off, but a console started over a gunnar
/// that holds `console.public-site = on` takes it at start, with no flip.
#[tokio::test]
async fn a_console_takes_the_stored_switch_at_start() {
let fake = FakeGunnar::new();
fake.settings.lock().unwrap().insert("console.public-site".into(), b"on".to_vec());
let (app, passkeys, _feed) = console(fake, true).await;
for _ in 0..80 {
let (_, state) = signed_in(&app, &passkeys, "root", crate::role::Role::Admin, "/api/public-site").await;
if state.contains("\"on\":true") {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("the console never took the stored switch");
}
/// ★ A32-16: **IndexNow's key is gunnar's generic setting** `console.public-site.indexnow`
/// (owner decision 2026-10-26, item 5), set by an administrator only: stored first, then
/// applied to the running site (its key file is served), and cleared the same way. A viewer
/// and a key that is not one are refused and store nothing.
#[tokio::test]
async fn an_administrator_sets_and_clears_the_indexnow_key_as_a_stored_setting() {
use tower::ServiceExt;
const KEY: &str = "0123456789abcdef0123456789abcdef";
let fake = FakeGunnar::new();
let (app, passkeys, _feed) = console(fake.clone(), true).await;
built(&app, &passkeys).await;
let put = |cookie: String, body: &str| {
http::Request::put("/api/public-site/indexnow")
.header(http::header::COOKIE, format!("{}={cookie}", crate::auth::SESSION_COOKIE))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::ORIGIN, passkeys.origin())
.body(axum::body::Body::from(body.to_owned()))
.unwrap()
};
let (_, state) = signed_in(&app, &passkeys, "root", crate::role::Role::Admin, "/api/public-site").await;
assert!(state.contains("\"indexnow\":false"), "{state}");
let viewer = passkeys.make_session("vera", crate::role::Role::Viewer, true);
assert_eq!(app.clone().oneshot(put(viewer, &format!("{{\"key\":\"{KEY}\"}}"))).await.unwrap().status(), http::StatusCode::FORBIDDEN);
assert_eq!(fake.setting("console.public-site.indexnow"), None, "a refused set stores nothing");
let admin = passkeys.make_session("root", crate::role::Role::Admin, true);
assert_eq!(app.clone().oneshot(put(admin.clone(), "{\"key\":\"not a key\"}")).await.unwrap().status(), http::StatusCode::BAD_REQUEST);
assert_eq!(fake.setting("console.public-site.indexnow"), None, "a key that is not one stores nothing");
assert_eq!(app.clone().oneshot(put(admin.clone(), &format!("{{\"key\":\"{KEY}\"}}"))).await.unwrap().status(), http::StatusCode::OK);
assert_eq!(fake.setting("console.public-site.indexnow").as_deref(), Some(KEY.as_bytes()), "the key is stored at gunnar");
let (status, body) = anon(&app, &format!("/{KEY}.txt")).await;
assert_eq!((status, body.as_str()), (http::StatusCode::OK, KEY), "the key file is served once the key is set");
let (_, state) = signed_in(&app, &passkeys, "root", crate::role::Role::Admin, "/api/public-site").await;
assert!(state.contains("\"indexnow\":true"), "{state}");
assert_eq!(app.clone().oneshot(put(admin, "{\"key\":\"\"}")).await.unwrap().status(), http::StatusCode::OK);
assert_eq!(fake.setting("console.public-site.indexnow").as_deref(), Some(&b""[..]), "cleared is stored as an empty key");
assert_ne!(anon(&app, &format!("/{KEY}.txt")).await.1, KEY, "the key file is served after the key was cleared");
}
/// ★ A32-16: a console started over a gunnar that stores an IndexNow key takes it, with no
/// environment key and no set.
#[tokio::test]
async fn a_console_takes_the_stored_indexnow_key_at_start() {
const KEY: &str = "fedcba9876543210fedcba9876543210";
assert!(std::env::var(gunnar_public_site::ENV_INDEXNOW_KEY).is_err(), "the test environment sets an IndexNow key");
let fake = FakeGunnar::new();
fake.settings.lock().unwrap().insert("console.public-site.indexnow".into(), KEY.as_bytes().to_vec());
let (app, passkeys, _feed) = console(fake, true).await;
for _ in 0..80 {
let (_, state) = signed_in(&app, &passkeys, "root", crate::role::Role::Admin, "/api/public-site").await;
if state.contains("\"indexnow\":true") {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("the console never took the stored IndexNow key");
}
/// ★ RA2-1: **a flip made elsewhere is seen.** Another console (or a direct
/// `Admin.SetSetting`) stores `on` at gunnar after this console started: this console
/// reads it again and follows, with no restart.
#[tokio::test]
async fn a_flip_made_elsewhere_is_seen_by_a_running_console() {
crate::console_setting::recheck_every(std::time::Duration::from_millis(100));
let fake = FakeGunnar::new();
let (app, passkeys, _feed) = console(fake.clone(), true).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let (_, state) = signed_in(&app, &passkeys, "root", crate::role::Role::Admin, "/api/public-site").await;
assert!(state.contains("\"on\":false"), "{state}");
fake.settings.lock().unwrap().insert("console.public-site".into(), b"on".to_vec());
for _ in 0..120 {
let (_, state) = signed_in(&app, &passkeys, "root", crate::role::Role::Admin, "/api/public-site").await;
if state.contains("\"on\":true") {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
panic!("a flip made at gunnar was never seen by the running console");
}
/// ★ **GUN-LANDING (owner 2026-10-30): gunnar.rs's front page, in a real browser.**
/// "gunnar.rs just shows a long button Log in now — where is the sign up | login?" and
/// "it stands flashing" (on gunnar.rs, the host holding the session cookie; www, without
/// it, stood still). A selling console with the site on, in headless Chromium:
///
/// * a visitor's `/` is the landing with **Sign up and Log in side by side**, one row,
/// the same width, the picture behind them;
/// * **signed in, the front page settles**: what the sign-in does next (`location.href =
/// '/'` on the landing) lands on the console once, and the landing is never drawn
/// again; within 5 s at most one more document loads, and so it does for `/repos`.
#[cfg(feature = "metered")]
mod in_the_browser {
use std::time::Duration;
use serde_json::{json, Value};
use super::*;
use crate::passkeys_at_gunnar::first_touch::{chrome, Chromium, ONE_BROWSER};
/// Every document this tab loads, kept in its `sessionStorage`: its path, and whether
/// it was the landing (the Log in button) or the console's page.
const LOADS: &str = r#"(() => {
addEventListener('DOMContentLoaded', () => {
let all = [];
try { all = JSON.parse(sessionStorage.getItem('__loads') || '[]'); } catch (e) {}
all.push({ path: location.pathname, landing: !!document.getElementById('login'), console: !!document.getElementById('console'), at: Date.now() });
try { sessionStorage.setItem('__loads', JSON.stringify(all)); } catch (e) {}
});
})();"#;
/// The console's bundle, as small as it gets: one page that names itself.
const CONSOLE_INDEX: &str = "<!doctype html><html><head><meta charset=\"utf-8\"><title>Gunnar</title></head><body><h1 id=\"console\">Repositories</h1></body></html>\n";
fn loads(c: &Chromium) -> Vec<Value> {
c.js("JSON.parse(sessionStorage.getItem('__loads') || '[]')").as_array().cloned().unwrap_or_default()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_front_page_offers_sign_up_beside_log_in_and_settles_once_signed_in() {
let Some(chrome) = chrome() else { return };
let ui = tempfile::tempdir().expect("a bundle dir");
std::fs::write(ui.path().join("index.html"), CONSOLE_INDEX).expect("the bundle's page");
// A selling console: monetize is behind it (a lazy channel nobody answers here).
let (app, passkeys, _feed) = crate::anonymous::console_waiting(FakeGunnar::new(), Some(GUEST_SEED), Some("http://127.0.0.1:9".into()), None, None, None, Some(ui.path().to_owned())).await;
built(&app, &passkeys).await;
let session = passkeys.make_session("vera", crate::role::Role::Viewer, true);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a loopback port");
let base = format!("http://localhost:{}", listener.local_addr().expect("its address").port());
let served = app.clone();
tokio::spawn(async move { axum::serve(listener, served.into_make_service_with_connect_info::<std::net::SocketAddr>()).await });
tokio::task::spawn_blocking(move || {
let _one = ONE_BROWSER.lock().unwrap_or_else(|p| p.into_inner());
let c = Chromium::launch(&chrome);
c.on_page("Page.addScriptToEvaluateOnNewDocument", json!({ "source": LOADS }));
// ── a visitor's front page: Sign up | Log in, one row, the same width ──
c.open(&format!("{base}/"));
c.until("the landing's Log in", "(() => { const e = document.getElementById('login'); return !!e && e.offsetParent !== null; })()");
let row = c.js("(() => { const s = document.getElementById('signup'), l = document.getElementById('login'); if (!s || !l || s.offsetParent === null) return null; const a = s.getBoundingClientRect(), b = l.getBoundingClientRect(); return { signup: s.textContent.trim(), href: s.getAttribute('href'), login: l.textContent.trim(), same_row: Math.abs(a.top - b.top) < 1, same_width: Math.abs(a.width - b.width) < 1, signup_first: a.left < b.left, title: document.title, picture: getComputedStyle(document.body).backgroundImage + getComputedStyle(document.documentElement).backgroundImage }; })()");
// Every finding is said at the end, so a RED names all of them.
let mut red: Vec<String> = Vec::new();
let way_in = (row["signup"].clone(), row["href"].clone(), row["login"].clone(), row["same_row"].clone(), row["same_width"].clone(), row["signup_first"].clone());
if way_in != (json!("Sign up"), json!("/signup"), json!("Log in"), json!(true), json!(true), json!(true)) {
red.push(format!("the front page's way in is not Sign up | Log in side by side: {row}"));
}
if row["title"] != "Gunnar — a European git server in Rust" {
red.push(format!("the title: {row}"));
}
// ── signed in (the cookie a passkey sign-in leaves), the landing goes on to `/` ──
c.on_page("Network.setCookie", json!({ "name": crate::auth::SESSION_COOKIE, "value": session, "url": format!("{base}/"), "path": "/", "secure": true, "httpOnly": true, "sameSite": "Strict" }));
let before = loads(&c).len();
c.js("(() => { location.href = '/'; return true; })()");
std::thread::sleep(Duration::from_secs(5));
let after: Vec<Value> = loads(&c).into_iter().skip(before).collect();
if after.len() > 1 {
red.push(format!("the signed-in front page did not settle: {} documents in 5 s (at most 1), the first: {:?}", after.len(), after.iter().take(3).collect::<Vec<_>>()));
}
if after.iter().any(|l| l["landing"] == true) {
red.push(format!("the landing was drawn again for a signed-in person ({} times)", after.iter().filter(|l| l["landing"] == true).count()));
}
if c.js("!!document.getElementById('console')") != json!(true) {
red.push(format!("the signed-in front page is not the console: {}", c.js("location.href")));
}
// ── and the console's own page, opened signed in ──
c.open(&format!("{base}/repos"));
c.until("a document at /repos", "location.pathname === '/repos' && document.readyState === 'complete'");
let before = loads(&c).len();
std::thread::sleep(Duration::from_secs(5));
let more: Vec<Value> = loads(&c).into_iter().skip(before).collect();
if !more.is_empty() || c.js("!!document.getElementById('console') && location.pathname === '/repos'") != json!(true) {
red.push(format!("/repos did not settle on the console: {} more documents, at {}", more.len(), c.js("location.href")));
}
assert!(red.is_empty(), "GUN-LANDING:\n- {}\n--- the page says ---\n{}", red.join("\n- "), c.text());
})
.await
.expect("the browser half");
}
}
}