crates/gunnar-front/tests/public_site_refeed.rs
//! ★ **gunnar.rs serves its public site to anonymous readers** (GRS-PUBLIC, the live bug of
//! 2026-10-28 ~19:40 CET: https://gunnar.rs/ answered `/login?next=/`, `/llms.txt` and
//! `/sitemap.xml` 401 "log in to use the console", robots.txt `Disallow: /`).
//!
//! **Why it broke.** The public site is built into gunnar.rs's front (`public-site`), and its
//! switch is gunnar's generic setting `console.public-site`; where nothing is stored, the
//! position the front was FED at lay (`console_public_site = on`, from the site's declaration
//! `[estates.gunnar-rs] public_site = true`) is the initial one. gunnar.rs was laid by its kit
//! (2026-10-13) before that declaration existed (TASKS-12, 2026-10-25), the ONE box only adopted
//! it, and a keep boot never feeds a front again. So its console was never told: OFF, for good.
//!
//! **The fix.** The ONE box feeds the declaration again (`site refeed gunnar-rs --only
//! public_site`): a signed `refeed` order carrying `console_public_site`, pulled by the main,
//! which stores it as the console's switch. The console reads the stored position every
//! recheck, and the stored position wins over the feed.
//!
//! This test is gunnar.rs as it runs: gunnar's REAL control plane (`gunnar_grpc::router` over
//! real stores on loopback) and the front's console (gunnar-ui-server, `metered,public-site`,
//! gunnar.rs's row) built from a feed that never said `console_public_site`. It holds that the
//! main takes the refeed order through the executor its pull channel runs, and that the site
//! then answers `/`, `/llms.txt`, `/sitemap.xml` and a public repository's page anonymously
//! with 200, while a private repository and the console's own paths still need a login.
//! Loopback only; the site is `gunnar.mock.invalid`.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::body::Body;
use gix_object::Kind;
use gunnar_core::{HashKind, NewRepo, RepoRef};
use gunnar_grpc::ControlPlane as _;
use gunnar_server::stores::ServerStores;
use gunnar_server::CoreControlPlane;
use gunnar_store::conformance::pack::PackBuilder;
use http::{Request, StatusCode};
use tower::ServiceExt as _;
const RP_ID: &str = "gunnar.mock.invalid";
const ORIGIN: &str = "https://gunnar.mock.invalid";
/// The front's feed key and gunnar's setting name the switch lives under.
const FEED_KEY: &str = "console_public_site";
const SETTING: &str = "console.public-site";
type Oid = gix_hash::ObjectId;
fn mint() -> (String, String) {
let pem = gunnar_core::mint_ed25519().expect("mint");
let text = std::str::from_utf8(&pem).expect("utf8").to_owned();
let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&text).expect("parse");
let line = key.public_key().to_openssh().expect("line");
(text, line)
}
fn compute(hash: gix_hash::Kind, kind: Kind, content: &[u8]) -> Oid {
gix_object::compute_hash(hash, kind, content).expect("hash")
}
fn tree_bytes(entries: &[(&str, &str, Oid)]) -> Vec<u8> {
let mut sorted = entries.to_vec();
sorted.sort_by(|a, b| a.1.as_bytes().cmp(b.1.as_bytes()));
let mut body = Vec::new();
for (mode, name, oid) in sorted {
body.extend_from_slice(mode.as_bytes());
body.push(b' ');
body.extend_from_slice(name.as_bytes());
body.push(0);
body.extend_from_slice(oid.as_bytes());
}
body
}
/// One commit on `refs/heads/main` holding a README with `words`.
fn plant(stores: &ServerStores, ns: &str, name: &str, words: &str) {
let path = RepoRef::new(ns, name).expect("a repo path");
if stores
.catalog()
.namespaces()
.unwrap()
.iter()
.all(|n| n.name != ns)
{
stores.catalog().create_namespace(ns).expect("namespace");
}
let entry = stores
.create_repo(NewRepo::new(path).hash_kind(HashKind::Sha256), false)
.expect("repo");
let store = stores.repo_store(&entry).expect("store");
let hash = store.hash_kind();
let readme_text = format!("# {name}\n\n{words}\n").into_bytes();
let readme = compute(hash, Kind::Blob, &readme_text);
let root = tree_bytes(&[("100644", "README.md", readme)]);
let tree = compute(hash, Kind::Tree, &root);
let commit = format!("tree {}\nauthor Kim <kim@mock.invalid> 1700000000 +0000\ncommitter Kim <kim@mock.invalid> 1700000000 +0000\n\nfirst\n", tree.to_hex()).into_bytes();
let commit_id = compute(hash, Kind::Commit, &commit);
let mut pb = PackBuilder::new(hash);
for (kind, content) in [
(Kind::Blob, readme_text),
(Kind::Tree, root),
(Kind::Commit, commit),
] {
pb.object(kind, content);
}
store
.serve()
.put_pack(&pb.build().expect("pack").bytes)
.expect("pack");
store
.serve()
.update_ref("refs/heads/main", None, Some(commit_id.as_bytes()))
.expect("ref");
}
fn free_addr() -> SocketAddr {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind")
.local_addr()
.expect("addr")
}
struct Site {
_dir: tempfile::TempDir,
_state_dir: tempfile::TempDir,
stores: Arc<ServerStores>,
console_pem: String,
control: String,
app: axum::Router,
}
/// gunnar's main, for real, and gunnar.rs's console in front of it, built from a feed that
/// never named `console_public_site` (the kit's, as gunnar.rs's front keeps it).
async fn gunnar_rs_as_it_runs() -> Site {
// The front sets this only where its feed says `console_public_site = on`; gunnar.rs's
// kept feed does not, so the console is built with it unset.
std::env::remove_var("GUNNAR_UI_PUBLIC_SITE");
gunnar_ui_server::console_setting::recheck_every(Duration::from_millis(50));
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let (console_pem, console_line) = mint();
stores
.auth()
.bootstrap_admin(&console_line)
.expect("the console's key is the first admin");
let (guest_pem, guest_line) = mint();
let plane = CoreControlPlane::new(Arc::clone(&stores));
stores
.auth()
.add_principal(gunnar_core::Principal {
id: "gunnar-guest".into(),
name: "gunnar-guest".into(),
kind: gunnar_core::PrincipalKind::Machine,
created: 0,
})
.expect("the guest");
stores
.auth()
.register_key("gunnar-guest", &guest_line, None)
.expect("the guest's key");
plant(
&stores,
"nordisk",
"gunnar",
"the forge itself, read by anyone",
);
plant(&stores, "kim", "secret", "kims private words");
plane
.set_visibility(
&gunnar_proto::RepoPath::new("nordisk", "gunnar"),
gunnar_proto::Visibility::Public,
)
.expect("public");
plane
.set_visibility(
&gunnar_proto::RepoPath::new("kim", "secret"),
gunnar_proto::Visibility::Private,
)
.expect("private");
let addr = free_addr();
let served: Arc<dyn gunnar_grpc::ControlPlane> =
Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
let ctx = gunnar_grpc::ServiceContext::new(
served,
Arc::new(gunnar_grpc::EventBus::new(16)),
gunnar_grpc::ServerConfig::default(),
);
tokio::spawn(async move {
let _ = gunnar_grpc::router(ctx, || 0).serve(addr).await;
});
let t0 = Instant::now();
while std::net::TcpStream::connect(addr).is_err() {
assert!(t0.elapsed() < Duration::from_secs(10), "gunnar listens");
tokio::time::sleep(Duration::from_millis(25)).await;
}
use gunnar_ui_server::{app::App, auth, grpc::Gunnar, signer::Signer};
let state_dir = tempfile::tempdir().expect("state dir");
let store = gunnar_ui_server::state::StateStore::open(state_dir.path().join("state.redb"))
.expect("state");
let control = addr.to_string();
let gunnar = Gunnar::connect_lazy(
&control,
Arc::new(Signer::from_openssh(&console_pem).expect("console key")),
)
.expect("channel");
let guest = Gunnar::connect_lazy(
&control,
Arc::new(Signer::from_openssh(&guest_pem).expect("guest key")),
)
.expect("channel");
let rp = auth::RelyingParty {
rp_id: RP_ID.to_string(),
origin: ORIGIN.to_string(),
rp_name: "gunnar".to_string(),
};
let keys = Arc::new(auth::Webauthn::new(rp, store.clone()).expect("webauthn"));
let app = App {
webauthn: Some(keys),
admin_token: None,
events: gunnar_ui_server::sse::EventHub::spawn(gunnar.clone()),
reviewers: Arc::new(gunnar_ui_server::reviewer::ReviewerKeys::new(
control.clone(),
store.clone(),
)),
tenants: Arc::new(gunnar_ui_server::tenant::TenantKeys::new(
control.clone(),
store.clone(),
)),
gunnar,
monetize: None,
twin: None,
guest: Some(guest),
forges: Arc::new(gunnar_ui_server::forges::ForgeSessions::new()),
forge_apps: Arc::new(gunnar_ui_server::forges::ForgeApps::from_env()),
imports: Arc::new(gunnar_ui_server::api::mirror_gate::Imports::from_env()),
ui_dir: None,
caches: Default::default(),
objects: Default::default(),
readmes: Arc::new(gunnar_ui_server::readme::Cache::new(Some(store.clone()))),
};
let app = gunnar_ui_server::serve::router(Arc::new(app));
Site {
_dir: dir,
_state_dir: state_dir,
stores,
console_pem,
control,
app,
}
}
impl Site {
/// An anonymous reader's GET: (status, Location, body).
async fn anon(&self, uri: &str, html: bool) -> (StatusCode, String, String) {
let mut r = Request::get(uri).header(http::header::USER_AGENT, "nordisk-dep-audit");
if html {
r = r.header(http::header::ACCEPT, "text/html");
}
let answer = self
.app
.clone()
.oneshot(r.body(Body::empty()).unwrap())
.await
.expect("an answer");
let status = answer.status();
let location = answer
.headers()
.get(http::header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
let bytes = axum::body::to_bytes(answer.into_body(), 1 << 22)
.await
.expect("a body");
(
status,
location,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
/// Asks `uri` until it answers 200 with `needle` in it (the switch is read every recheck,
/// the pages are built from gunnar once the first visitor asks): its body.
async fn until_served(&self, uri: &str, needle: &str) -> String {
let t0 = Instant::now();
loop {
let (status, _, body) = self.anon(uri, false).await;
if status == StatusCode::OK && body.contains(needle) {
return body;
}
assert!(t0.elapsed() < Duration::from_secs(30), "{uri} is never served to an anonymous reader with {needle:?} in it: {status} {body}");
// One reader, asking as a person would: the console's one throttle (GUN-GUEST,
// gunnar-ui) answers a client past ~4 requests a second 429, and blocks one that
// keeps on, so the poll stays under it.
tokio::time::sleep(Duration::from_millis(300)).await;
}
}
/// **The main takes the ONE box's `refeed` order** through the executor its pull channel
/// runs (gunnar-repair `order::decode` + `exec::execute` over the server's own estate).
async fn takes_refeed(
&self,
seq: u64,
keys: serde_json::Value,
) -> gunnar_repair::order::OrderAck {
let body = serde_json::json!({ "keys": keys }).to_string();
let order =
gunnar_repair::order::decode(seq, gunnar_repair::order::kind::REFEED, body.as_bytes())
.expect("the main takes the refeed of the public site's switch");
let estate = gunnar_server::repair::ServerEstate {
key: gunnar_core::ssh_key::PrivateKey::from_openssh(&self.console_pem).expect("a key"),
own: self.control.clone(),
entitlements: gunnar_server::repair::Entitlements::of(Arc::clone(&self.stores)),
};
gunnar_repair::exec::execute(&estate, seq, &order, &|| false).await
}
}
/// ★ The public site of a site declared `public_site = true`, laid before the declaration,
/// is served to anonymous readers once its main took the ONE box's refeed — and its console
/// still asks everything else to log in.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn gunnar_rs_serves_its_public_site_anonymously_once_its_main_takes_the_refeed() {
let site = gunnar_rs_as_it_runs().await;
// gunnar.rs as the owner found it: the console was never told, so the home asks for a login.
tokio::time::sleep(Duration::from_millis(300)).await;
let (status, location, _) = site.anon("/", true).await;
assert!(
status.is_redirection() && location.starts_with("/login?next="),
"before the refeed, as on gunnar.rs: {status} {location}"
);
assert_eq!(
site.anon("/llms.txt", false).await.0,
StatusCode::UNAUTHORIZED,
"before the refeed, as on gunnar.rs"
);
let ack = site
.takes_refeed(41, serde_json::json!({ FEED_KEY: "on" }))
.await;
assert!(ack.ok, "the main keeps the switch: {}", ack.detail);
assert_eq!(
site.stores.settings().get(SETTING).unwrap().as_deref(),
Some(&b"on"[..]),
"stored as the console's switch at gunnar"
);
// The site, to anyone, with no login.
// The home is the console's landing with the site's title (PUB-HOME, 2026-10-29); llms.txt
// and the sitemap name the public repository; its page (the console's document, its README
// pre-rendered in it, GUN-GUEST) shows its README.
site.until_served("/", "Gunnar — a European git server in Rust")
.await;
site.until_served("/llms.txt", "nordisk/gunnar").await;
site.until_served("/sitemap.xml", "/nordisk/gunnar").await;
site.until_served("/nordisk/gunnar", "the forge itself, read by anyone")
.await;
let (status, _, robots) = site.anon("/robots.txt", false).await;
assert!(
status == StatusCode::OK && robots.contains("Sitemap:"),
"robots.txt points readers at the sitemap: {robots}"
);
// Everything else still needs a login.
let (status, _, body) = site.anon("/kim/secret", false).await;
assert_ne!(
status,
StatusCode::OK,
"a private repository is never a public page: {body}"
);
assert!(!body.contains("kims private words"), "{body}");
// (`/api/repos` is the anonymous viewer's own read of public repositories, by design.)
for path in [
"/api/public-site",
"/api/principals",
"/api/keys",
"/api/machine",
] {
assert_eq!(
site.anon(path, false).await.0,
StatusCode::UNAUTHORIZED,
"{path} is the console's"
);
}
let (status, _, body) = site.anon("/api/repos/kim/secret", false).await;
assert!(
status != StatusCode::OK && !body.contains("kims private words"),
"a private repository is not read anonymously: {status} {body}"
);
let (status, location, _) = site.anon("/admin", true).await;
assert!(
status.is_redirection() && location.starts_with("/login?next="),
"/admin asks for a login: {status} {location}"
);
// The ONE box's OFF is taken the same way, and the site closes again.
let ack = site
.takes_refeed(42, serde_json::json!({ FEED_KEY: "off" }))
.await;
assert!(ack.ok, "{}", ack.detail);
let t0 = Instant::now();
while site.anon("/llms.txt", false).await.0 == StatusCode::OK {
assert!(
t0.elapsed() < Duration::from_secs(20),
"the site never closes after an OFF"
);
tokio::time::sleep(Duration::from_millis(300)).await;
}
}