crates/gunnar-ui-server/src/mirroring.rs
//! **Importing repositories, driven end to end against a gunnar that
//! authorises and a forge that answers.**
//!
//! This module is tests and nothing else, and it is its own file for the reason
//! [`crate::anonymous`] is: what it asserts is not a property of any one
//! handler but of the whole path a credential takes through this process.
//!
//! ```text
//! browser ──▶ require_auth ──▶ api::Importer ──▶ forges::ForgeSessions
//! │ │ the token
//! │ ▼
//! └──────────▶ Mirror.SetCredential ──▶ gunnar
//! ```
//!
//! # The failures these exist to catch
//!
//! * **A token in a response body.** Every route this console serves is driven
//! with a distinctive token connected, and every body is searched for its
//! bytes. A test that only asserted "the connect call returns 200" would be
//! green against a handler that echoed the token back in its answer.
//! * **A token in a log line.** The same token, against a captured `tracing`
//! subscriber, because the audit lines this module's handlers write are
//! exactly where a `%token` would be added by somebody debugging.
//! * **Anonymous reaching gunnar.** Not "anonymous gets a 401" — that is true
//! of a console that dials gunnar, is refused, and then returns 401. The
//! assertion is on gunnar's own call counter: it must be **zero**.
//! * **A forge 401 registering something anyway.** The forge refusing must
//! queue nothing, which again is an assertion about what gunnar heard.
//! * **An OAuth callback with a state this process did not mint.** The code
//! must not be exchanged, which is an assertion about what the forge heard.
//!
//! # Why the fake gunnar authorises by fingerprint
//!
//! Same reason [`crate::anonymous`]'s does, and kept deliberately: the fake
//! reads the `gunnar-authorization` header, pulls the fingerprint out of it,
//! and refuses anything not signed by the console's key. A permissive fake
//! could not tell "the console called with its own credential" from "the
//! console called with nothing at all", and the second is a bug this console
//! has had before.
//!
//! # No network
//!
//! The forge is `gunnar_forge::testing::MockServer` on loopback — a real socket
//! speaking real HTTP, so what is under test is ureq's behaviour and not our
//! assumptions about it. gunnar is a tonic server on loopback. Nothing here
//! resolves a name.
use std::collections::{BTreeMap, VecDeque};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use gunnar_forge::testing::MockServer;
use gunnar_forge::ForgeKind;
use tonic::{Request, Response, Status};
use tower::ServiceExt as _;
use crate::forges::{ForgeApps, OAuthApp};
use crate::pb;
use crate::pb::mirror_server::{Mirror, MirrorServer};
/// The console's own principal, the one `enroll` gives a server-wide `Admin`.
const CONSOLE_SEED: [u8; 32] = [7u8; 32];
/// The guest principal: enrolled in gunnar, holding no grants at all. Present
/// so the anonymous test is about the ALLOW-LISTS and not about this
/// deployment happening to have no guest key.
const GUEST_SEED: [u8; 32] = [23u8; 32];
/// **The token, and it is distinctive on purpose.**
///
/// Long, with no substring that occurs anywhere else in this crate, so a
/// `contains` assertion over a whole HTTP response body or a whole log buffer
/// means something. A short or plausible-looking value would make a negative
/// assertion that could pass by luck.
const TOKEN: &str = "ghp_ZZtokenZZ-must-never-be-rendered-anywhere-9f3c1a";
/// The break-glass bearer these tests sign in with. `Caller::AdminToken` is
/// always `Role::Admin`, which is the floor `api::Importer` states.
const BREAK_GLASS: &str = "break-glass";
const NS: &str = "nordisk";
// ───────────────────────────────────────────────────────────────────────────
// A gunnar that authorises, records, and can be scripted
// ───────────────────────────────────────────────────────────────────────────
/// Every control-plane call this fake heard, in order, as a short line.
type Calls = Arc<Mutex<Vec<String>>>;
#[derive(Clone, Default)]
struct FakeGunnar {
/// Path -> registration.
mirrors: Arc<Mutex<BTreeMap<String, pb::MirrorConfig>>>,
/// `Add nordisk/x`, `SetCredential nordisk/x token`, `Sync nordisk/x`, …
calls: Calls,
/// **Every secret this fake was handed.** The positive half of the token
/// proof: the console must give gunnar the real token, and must give it to
/// nobody else.
secrets: Arc<Mutex<Vec<String>>>,
/// Fingerprints seen, in order — which key signed each call.
seen: Arc<Mutex<Vec<String>>>,
/// **How many calls arrived at all.** Zero is the assertion for an
/// anonymous request and for a forge that refused.
dialled: Arc<AtomicUsize>,
/// Scripted `Status` answers per path, popped one per poll. Empty means
/// "answer with the registration as it stands".
script: Arc<Mutex<BTreeMap<String, VecDeque<pb::MirrorJob>>>>,
/// When set, `Add` refuses with this message. One repository failing must
/// not strand the others.
refuse_add_for: Arc<Mutex<Option<String>>>,
}
fn signer(seed: [u8; 32]) -> crate::signer::Signer {
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&seed),
))
.expect("a fixed seed makes a signer")
}
fn key(path: &pb::RepoPath) -> String {
format!("{}/{}", path.namespace, path.repo)
}
impl FakeGunnar {
/// The fingerprint that signed this request, and the refusal for anything
/// the console did not sign.
///
/// The signature itself is not verified: this fake stands in for gunnar's
/// ACL, not for its cryptography, and `tests/contract.rs` already pins the
/// credential format against gunnar's own constants.
fn who<T>(&self, request: &Request<T>) -> Result<String, Status> {
self.dialled.fetch_add(1, Ordering::SeqCst);
let fingerprint = request
.metadata()
.get(crate::signer::CREDENTIAL_METADATA_KEY)
.and_then(|value| value.to_str().ok())
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("(unsigned)")
.to_owned();
self.seen.lock().expect("the log").push(fingerprint.clone());
if fingerprint != signer(CONSOLE_SEED).fingerprint() {
return Err(Status::permission_denied(
"this principal holds no admin grant on the mirror service",
));
}
Ok(fingerprint)
}
fn note(&self, line: impl Into<String>) {
self.calls.lock().expect("the call log").push(line.into());
}
fn heard(&self) -> Vec<String> {
self.calls.lock().expect("the call log").clone()
}
fn secrets_heard(&self) -> Vec<String> {
self.secrets.lock().expect("the secret log").clone()
}
fn dialled(&self) -> usize {
self.dialled.load(Ordering::SeqCst)
}
/// Register a mirror as if it were already there, for the idempotence case.
fn already(&self, path: &str, remote: &str) {
let (namespace, repo) = path.split_once('/').expect("a two-part path");
self.mirrors.lock().expect("the mirrors").insert(
path.to_owned(),
pb::MirrorConfig {
repo: Some(crate::api::repo_path(namespace, repo)),
remote_url: remote.to_owned(),
prune: false,
state: pb::MirrorState::Idle as i32,
added_at_unix_ms: 1_700_000_000_000,
last_sync_at_unix_ms: None,
last_error: String::new(),
sync_count: 0,
credential: pb::MirrorCredentialKind::Unspecified as i32,
credential_username: String::new(),
job: None,
..Default::default()
},
);
}
/// Queue the answers `Status` will give for one repository, in order.
fn script(&self, path: &str, jobs: Vec<pb::MirrorJob>) {
self.script
.lock()
.expect("the script")
.insert(path.to_owned(), jobs.into());
}
}
/// The refusal every unmodelled RPC returns. A stub that answered an empty `Ok`
/// would make a handler calling the wrong verb look like it worked.
fn unmodelled(verb: &'static str) -> Status {
Status::unimplemented(format!("{verb} is not modelled by the mirroring fake"))
}
#[tonic::async_trait]
impl Mirror for FakeGunnar {
async fn add(
&self,
request: Request<pb::AddMirrorRequest>,
) -> Result<Response<pb::AddMirrorResponse>, Status> {
self.who(&request)?;
let request = request.into_inner();
let path = request.repo.clone().unwrap_or_default();
let name = key(&path);
if let Some(refused) = self.refuse_add_for.lock().expect("the refusal").clone() {
if refused == name {
self.note(format!("Add {name} REFUSED"));
return Err(Status::failed_precondition(
"the remote did not advertise an object-format this server can mirror",
));
}
}
// `create_repo` is what the console must always send, and a hash kind
// is what it must never send — `AddMirrorRequest` has no field for one
// precisely so it cannot. Asserted here rather than in the handler's
// own test, because what matters is what gunnar HEARD.
assert!(
request.create_repo,
"the import must create the repository; gunnar takes its hash kind from the \
remote's advertised object-format"
);
self.note(format!("Add {name} <- {}", request.remote_url));
let mut mirrors = self.mirrors.lock().expect("the mirrors");
// The REAL plane does a `set`: re-adding replaces rather than refusing,
// which is what makes the picker safe to press twice. The in-memory
// double in gunnar-grpc answers AlreadyExists; this follows the plane
// the console actually dials.
let entry = mirrors.entry(name).or_insert_with(|| pb::MirrorConfig {
repo: Some(path.clone()),
added_at_unix_ms: 1_700_000_000_000,
..Default::default()
});
entry.repo = Some(path);
entry.remote_url = request.remote_url;
entry.prune = request.prune;
entry.state = pb::MirrorState::Idle as i32;
Ok(Response::new(pb::AddMirrorResponse {
mirror: Some(entry.clone()),
}))
}
async fn set_credential(
&self,
request: Request<pb::SetMirrorCredentialRequest>,
) -> Result<Response<pb::SetMirrorCredentialResponse>, Status> {
self.who(&request)?;
let request = request.into_inner();
let path = request.repo.clone().unwrap_or_default();
let name = key(&path);
let kind = pb::MirrorCredentialKind::try_from(request.kind)
.unwrap_or(pb::MirrorCredentialKind::Unspecified);
// gunnar's own argument check, reproduced: "I set a credential" and "I
// cleared one" must never be one request read two ways.
let empty = request.secret.is_empty();
if (kind == pb::MirrorCredentialKind::Unspecified) != empty {
return Err(Status::invalid_argument(
"a credential kind of UNSPECIFIED takes an empty secret, and any other kind \
takes a non-empty one",
));
}
if !empty {
self.secrets
.lock()
.expect("the secret log")
.push(request.secret.clone());
}
self.note(format!(
"SetCredential {name} {} {}",
match kind {
pb::MirrorCredentialKind::Token => "token",
pb::MirrorCredentialKind::SshKey => "ssh-key",
_ => "none",
},
request.username
));
let mut mirrors = self.mirrors.lock().expect("the mirrors");
let entry = mirrors
.get_mut(&name)
.ok_or_else(|| Status::not_found(format!("no mirror registered for {name}")))?;
entry.credential = request.kind;
entry.credential_username = request.username;
Ok(Response::new(pb::SetMirrorCredentialResponse {
mirror: Some(entry.clone()),
}))
}
async fn sync(
&self,
request: Request<pb::SyncMirrorRequest>,
) -> Result<Response<pb::SyncMirrorResponse>, Status> {
self.who(&request)?;
let path = request.into_inner().repo.unwrap_or_default();
let name = key(&path);
self.note(format!("Sync {name}"));
let mut mirrors = self.mirrors.lock().expect("the mirrors");
let entry = mirrors
.get_mut(&name)
.ok_or_else(|| Status::not_found(format!("no mirror registered for {name}")))?;
// **Dedup on the repository**: pressing twice is one job. The job id is
// how a caller tells which one it got.
let job = entry.job.clone().filter(|j| {
matches!(
pb::MirrorJobState::try_from(j.state),
Ok(pb::MirrorJobState::Queued | pb::MirrorJobState::Running)
)
});
let job = job.unwrap_or(pb::MirrorJob {
job_id: format!("mirror-{name}"),
path: Some(path),
state: pb::MirrorJobState::Queued as i32,
queue_position: 0,
enqueued_at_unix_ms: 1_700_000_000_000,
..Default::default()
});
entry.job = Some(job.clone());
Ok(Response::new(pb::SyncMirrorResponse {
mirror: Some(entry.clone()),
// 0 and empty on the enqueue, exactly as gunnar answers, so a
// console reading them instead of `job` would be caught.
refs_changed: 0,
updates: Vec::new(),
job: Some(job),
}))
}
async fn status(
&self,
request: Request<pb::MirrorStatusRequest>,
) -> Result<Response<pb::MirrorStatusResponse>, Status> {
self.who(&request)?;
let request = request.into_inner();
let wanted = request.repo.map(|p| key(&p));
let mut mirrors = self.mirrors.lock().expect("the mirrors");
let mut script = self.script.lock().expect("the script");
let mut out = Vec::new();
for (name, config) in mirrors.iter_mut() {
if wanted.as_ref().is_some_and(|w| w != name) {
continue;
}
// One scripted answer per poll, and the LAST one sticks: that is
// how a settled job stays settled however many times it is read.
if let Some(queued) = script.get_mut(name) {
if queued.len() > 1 {
config.job = queued.pop_front();
} else if let Some(last) = queued.front() {
config.job = Some(last.clone());
}
}
out.push(config.clone());
}
self.note(format!("Status {}", wanted.unwrap_or_else(|| "*".into())));
Ok(Response::new(pb::MirrorStatusResponse { mirrors: out }))
}
async fn remove(
&self,
request: Request<pb::RemoveMirrorRequest>,
) -> Result<Response<pb::RemoveMirrorResponse>, Status> {
self.who(&request)?;
let path = request.into_inner().repo.unwrap_or_default();
let name = key(&path);
self.note(format!("Remove {name}"));
self.mirrors.lock().expect("the mirrors").remove(&name);
Ok(Response::new(pb::RemoveMirrorResponse {}))
}
async fn set_replication(
&self,
_request: Request<pb::SetReplicationRequest>,
) -> Result<Response<pb::ReplicationStatusResponse>, Status> {
Err(unmodelled("Mirror.SetReplication"))
}
async fn remove_replication(
&self,
_request: Request<pb::RemoveReplicationRequest>,
) -> Result<Response<pb::ReplicationStatusResponse>, Status> {
Err(unmodelled("Mirror.RemoveReplication"))
}
async fn demote_replication(
&self,
_request: Request<pb::DemoteReplicationRequest>,
) -> Result<Response<pb::ReplicationStatusResponse>, Status> {
Err(unmodelled("Mirror.DemoteReplication"))
}
async fn set_role(
&self,
_request: Request<pb::SetRoleRequest>,
) -> Result<Response<pb::ReplicationStatusResponse>, Status> {
Err(unmodelled("Mirror.SetRole"))
}
async fn abdicate_replication(
&self,
_request: Request<pb::AbdicateReplicationRequest>,
) -> Result<Response<pb::ReplicationStatusResponse>, Status> {
Err(unmodelled("Mirror.AbdicateReplication"))
}
async fn replication_status(
&self,
_request: Request<pb::ReplicationStatusRequest>,
) -> Result<Response<pb::ReplicationStatusResponse>, Status> {
Err(unmodelled("Mirror.ReplicationStatus"))
}
async fn resync_replication(
&self,
_request: Request<pb::ResyncReplicationRequest>,
) -> Result<Response<pb::ResyncReplicationResponse>, Status> {
Err(unmodelled("Mirror.ResyncReplication"))
}
async fn flush_replication(
&self,
_request: Request<pb::FlushReplicationRequest>,
) -> Result<Response<pb::FlushReplicationResponse>, Status> {
Err(unmodelled("Mirror.FlushReplication"))
}
async fn log_head(
&self,
_request: Request<pb::LogHeadRequest>,
) -> Result<Response<pb::LogHeadResponse>, Status> {
Err(unmodelled("Mirror.LogHead"))
}
// `LogFetch` / `SnapshotFetch`: the generated default stubs answer Unimplemented.
async fn log_retain(
&self,
_request: Request<pb::LogRetainRequest>,
) -> Result<Response<pb::LogHeadResponse>, Status> {
Err(unmodelled("Mirror.LogRetain"))
}
}
// ───────────────────────────────────────────────────────────────────────────
// The forge, on loopback
// ───────────────────────────────────────────────────────────────────────────
/// One GitHub repository object, with the eight keys the parser reads and the
/// two shapes that break a parser written against a three-key sketch:
/// `description: null` and `size` present as a number.
fn github_repo(name: &str, private: bool, archived: bool) -> serde_json::Value {
serde_json::json!({
"name": name,
"full_name": format!("alice/{name}"),
"clone_url": format!("https://github.com/alice/{name}.git"),
"ssh_url": format!("git@github.com:alice/{name}.git"),
"default_branch": "main",
"private": private,
"archived": archived,
"size": 5_939_200u64,
"description": serde_json::Value::Null,
"language": serde_json::Value::Null,
"parent": serde_json::Value::Null,
})
}
/// A forge that answers one page: `gunnar`, `linux` (already mirrored here) and
/// `old-thing` (archived).
///
/// `linux` is not decoration. It is the real one — 5.8 GiB and 946 refs on
/// gunnar.rs — and it is here so the numbers the panel renders are sized
/// against something that exists rather than against a toy.
fn forge_with_three_repositories() -> MockServer {
let forge = MockServer::start();
forge.on_body(
"GET",
"/user/repos",
200,
&serde_json::Value::Array(vec![
github_repo("gunnar", false, false),
github_repo("linux", false, false),
github_repo("old-thing", false, true),
])
.to_string(),
);
forge
}
// ───────────────────────────────────────────────────────────────────────────
// The console under test
// ───────────────────────────────────────────────────────────────────────────
/// A console pointed at [`FakeGunnar`], in the posture that matters.
///
/// `admin_token: Some(_)` is **load-bearing**, and is the trap this harness
/// exists to avoid — the same one [`crate::anonymous`]'s harness names. With
/// neither a passkey nor an admin token configured, `require_auth` takes THE
/// ONE BYPASS and runs every request unauthenticated; a test written in that
/// posture would show an anonymous request importing repositories and would be
/// right to, proving nothing about the gate at all.
///
/// **A guest identity IS configured, and that is the second load-bearing
/// thing.** `require_auth`'s anonymous arm needs three conditions, and the
/// first is that a guest exists — so a harness with `guest: None` would show
/// every mirror route refusing an anonymous request and would be proving only
/// that this deployment has no guest key. Measured: with `guest: None`, adding
/// `/api/mirrors` to `is_anonymous_read` left
/// `an_anonymous_request_never_reaches_gunnar_or_a_forge` GREEN. With a guest
/// configured, the same edit turns it red. The guest key holds no grants and
/// the fake refuses it, but the assertion is that it never gets that far.
/// **The forge in this harness is a mock server on loopback**, which is exactly
/// what `api::forges::vet` refuses — the SSRF guard's whole point is that a
/// browser cannot aim this console at `127.0.0.1`. So the harness turns the
/// guard off the one way a deployment can, through `ForgeApps`, and says so
/// here. The guard's own tests are in `api::forges`, against the default.
fn allowing_the_mock_forge(apps: ForgeApps) -> ForgeApps {
// AIRGAP: the harness drives forge import, so it switches it on (`GUNNAR_UI_FORGES=on`);
// `with_the_switch_off_connect_is_refused_by_name_and_the_forge_never_dialled` holds the off.
if apps.doors().is_empty() {
return ForgeApps::allowing_private_bases().switched_on();
}
apps.switched_on()
}
async fn console(fake: FakeGunnar, apps: ForgeApps) -> axum::Router {
console_as_given(fake, allowing_the_mock_forge(apps)).await
}
/// The console with `apps` exactly as given: the switch is whatever they say.
async fn console_as_given(fake: FakeGunnar, apps: ForgeApps) -> axum::Router {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("a loopback port");
let addr = listener.local_addr().expect("its address");
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(MirrorServer::new(fake))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await
});
let endpoint = format!("http://{addr}");
let gunnar = crate::grpc::Gunnar::connect_lazy(&endpoint, Arc::new(signer(CONSOLE_SEED)))
.expect("a lazy channel");
let store = crate::state::StateStore::open(
tempfile::Builder::new()
.suffix(".redb")
.tempfile()
.expect("a temp file")
.into_temp_path()
.keep()
.expect("keep the path"),
)
.expect("a state store");
crate::serve::router(Arc::new(crate::app::App {
webauthn: None,
#[cfg(feature = "metered")]
monetize: None,
admin_token: Some(BREAK_GLASS.to_owned()),
events: crate::sse::EventHub::detached(),
gunnar,
twin: None,
guest: Some(
crate::grpc::Gunnar::connect_lazy(&endpoint, Arc::new(signer(GUEST_SEED)))
.expect("a lazy guest channel"),
),
forges: Default::default(),
imports: Default::default(),
forge_apps: Arc::new(apps),
reviewers: Arc::new(crate::reviewer::ReviewerKeys::new(
endpoint.clone(),
store.clone(),
)),
tenants: Arc::new(crate::tenant::TenantKeys::new(endpoint, store)),
ui_dir: None,
caches: crate::api::Caches::default(),
objects: Default::default(),
readmes: Default::default(),
}))
}
/// One request as the signed-in operator.
async fn signed_in(
app: &axum::Router,
method: &str,
uri: &str,
body: Option<serde_json::Value>,
) -> (http::StatusCode, String) {
send(app, method, uri, body, Some(BREAK_GLASS)).await
}
/// One request with **no credential of any kind**. This is the visitor.
async fn anonymous(
app: &axum::Router,
method: &str,
uri: &str,
body: Option<serde_json::Value>,
) -> (http::StatusCode, String) {
send(app, method, uri, body, None).await
}
async fn send(
app: &axum::Router,
method: &str,
uri: &str,
body: Option<serde_json::Value>,
token: Option<&str>,
) -> (http::StatusCode, String) {
let mut request = http::Request::builder().method(method).uri(uri);
if let Some(token) = token {
request = request.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
}
let request = match body {
Some(body) => request
.header("content-type", "application/json")
.body(axum::body::Body::from(body.to_string())),
None => request.body(axum::body::Body::empty()),
}
.expect("a request");
let response = app
.clone()
.oneshot(request)
.await
.expect("the console answers");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 4 << 20)
.await
.expect("a readable body");
(status, String::from_utf8_lossy(&bytes).into_owned())
}
/// ★ **AIRGAP: with `GUNNAR_UI_FORGES` off, a `forges` build dials no forge.** The connect
/// names a live mock forge on loopback; the door refuses by the switch's name, and the mock
/// saw no request at all.
#[tokio::test]
async fn with_the_switch_off_connect_is_refused_by_name_and_the_forge_never_dialled() {
let forge = MockServer::start();
let app = console_as_given(FakeGunnar::default(), ForgeApps::allowing_private_bases()).await;
let (status, body) = signed_in(
&app,
"POST",
"/api/forges/connect",
Some(serde_json::json!({ "forge": "github", "base": forge.url(), "token": TOKEN })),
)
.await;
assert_ne!(status, http::StatusCode::OK, "{body}");
assert!(body.contains("GUNNAR_UI_FORGES"), "the refusal names the switch: {body}");
assert!(forge.recorded().is_empty(), "the forge was dialled: {:?}", forge.recorded().len());
assert!(!body.contains(TOKEN), "{body}");
}
/// Connect the forge with a pasted token, and assert it worked.
async fn connect(app: &axum::Router, forge: &MockServer) -> String {
let (status, body) = signed_in(
app,
"POST",
"/api/forges/connect",
Some(serde_json::json!({
"forge": "github",
"base": forge.url(),
"token": TOKEN,
})),
)
.await;
assert_eq!(status, http::StatusCode::OK, "{body}");
body
}
fn json(body: &str) -> serde_json::Value {
serde_json::from_str(body).unwrap_or_else(|e| panic!("not JSON: {e}\n{body}"))
}
// ───────────────────────────────────────────────────────────────────────────
// The walk
// ───────────────────────────────────────────────────────────────────────────
/// ★ **Paste a token, list, pick three, and watch three jobs appear.**
///
/// One archived (excluded), one already mirrored (idempotent, not a failure),
/// one plain. The assertions are on what **gunnar heard**, in order: an
/// assertion on the HTTP status alone would be green against a console that
/// answered 200 and called nothing.
#[tokio::test]
async fn a_pasted_token_lists_and_the_chosen_repositories_are_registered_and_queued() {
let forge = forge_with_three_repositories();
let fake = FakeGunnar::default();
// Already mirrored, exactly as a second import would find it.
fake.already(
&format!("{NS}/linux"),
"https://github.com/alice/linux.git",
);
let app = console(fake.clone(), ForgeApps::default()).await;
connect(&app, &forge).await;
// The listing, as the picker renders it.
let (status, body) = signed_in(&app, "GET", "/api/forges/github/repos", None).await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let listing = json(&body);
let rows = listing["repos"].as_array().expect("rows");
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["full_name"], "alice/gunnar");
assert_eq!(rows[0]["name"], "gunnar", "the mirror name is the leaf");
assert_eq!(
rows[2]["archived"], true,
"archived is MARKED, not hidden here: the toggle is the picker's and a mirror of an \
archived repository is often exactly what somebody wants"
);
assert_eq!(
rows[1]["size_kib"], 5_939_200u64,
"a real size, so the panel's numbers are sized against something that exists"
);
let (status, body) = signed_in(
&app,
"POST",
"/api/mirrors/import",
Some(serde_json::json!({
"forge": "github",
"namespace": NS,
"transport": "https",
"repos": ["alice/gunnar", "alice/linux", "alice/old-thing"],
})),
)
.await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let imported = json(&body);
assert_eq!(imported["registered"], 2);
assert_eq!(imported["queued"], 2, "two jobs, one per registration");
assert_eq!(imported["skipped"], 1, "the archived one");
assert_eq!(imported["failed"], 0);
assert_eq!(imported["token_handed_over"], true);
let results = imported["results"].as_array().expect("rows");
assert_eq!(results[0]["path"], format!("{NS}/gunnar"));
assert_eq!(results[0]["step"], "sync");
assert_eq!(results[0]["job"]["state"], "queued");
assert_eq!(
results[1]["already_mirrored"], true,
"re-importing a repository already mirrored is idempotent, not a failure: Add \
replaces on the real plane, which is what makes the picker safe to press twice"
);
assert_eq!(results[1]["ok"], true);
assert_eq!(results[2]["step"], "skipped");
assert_eq!(results[2]["job"], serde_json::Value::Null);
// **The order is the contract**: a credential needs a registration to
// attach to, and a sync without one fails on authentication.
let heard = fake.heard();
let mirroring: Vec<&String> = heard.iter().filter(|l| !l.starts_with("Status")).collect();
assert_eq!(
mirroring
.iter()
.map(|l| l.split_whitespace().next().unwrap_or_default())
.collect::<Vec<_>>(),
vec![
"Add",
"SetCredential",
"Sync",
"Add",
"SetCredential",
"Sync"
],
"the import is Add -> SetCredential -> Sync per repository: {heard:?}"
);
assert!(
!heard.iter().any(|l| l.contains("old-thing")),
"the archived repository must not have been touched at all: {heard:?}"
);
assert!(
heard
.iter()
.any(|l| l == &format!("SetCredential {NS}/gunnar token x-access-token")),
"GitHub's Basic-auth username is `x-access-token`, and it is not a secret: {heard:?}"
);
// The positive half of the token proof: gunnar got the real one.
assert_eq!(
fake.secrets_heard(),
vec![TOKEN.to_owned(), TOKEN.to_owned()],
"the console must hand gunnar the token it was given, once per HTTPS mirror"
);
}
/// ★ **The console forgets the token the moment it has handed it over.**
///
/// `take_token` leaves nothing behind, so there is no second copy in this
/// process to leak. The consequence is visible and is stated rather than
/// hidden: a second HTTPS import refuses **by name** and registers nothing,
/// instead of quietly making mirrors that cannot authenticate.
#[tokio::test]
async fn the_token_is_gone_after_the_handover_and_the_next_import_says_so() {
let forge = forge_with_three_repositories();
let fake = FakeGunnar::default();
let app = console(fake.clone(), ForgeApps::default()).await;
connect(&app, &forge).await;
let (status, _) = signed_in(
&app,
"POST",
"/api/mirrors/import",
Some(serde_json::json!({
"forge": "github", "namespace": NS, "transport": "https",
"repos": ["alice/gunnar"],
})),
)
.await;
assert_eq!(status, http::StatusCode::OK);
let (_, body) = signed_in(&app, "GET", "/api/forges", None).await;
let forges = json(&body);
assert_eq!(
forges["connected"][0]["holds_token"], false,
"the picker has to be told, or it offers an https import that cannot work"
);
assert_eq!(
forges["connected"][0]["repos"], 3,
"the listing survives losing the token; it is what the picker still needs"
);
let before = fake.heard().len();
let (status, body) = signed_in(
&app,
"POST",
"/api/mirrors/import",
Some(serde_json::json!({
"forge": "github", "namespace": NS, "transport": "https",
"repos": ["alice/linux"],
})),
)
.await;
assert_eq!(status, http::StatusCode::BAD_REQUEST, "{body}");
assert!(
body.contains("forgets the token"),
"the refusal must say why and what to do: {body}"
);
assert_eq!(
fake.heard().len(),
before,
"nothing may be registered when the credential cannot be supplied: ten mirrors that \
fail on authentication for ever are worse than one refusal"
);
// …and ssh still works, because it needs no credential from this console.
let (status, body) = signed_in(
&app,
"POST",
"/api/mirrors/import",
Some(serde_json::json!({
"forge": "github", "namespace": NS, "transport": "ssh",
"repos": ["alice/linux"],
})),
)
.await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let imported = json(&body);
assert_eq!(imported["registered"], 1);
assert_eq!(imported["token_handed_over"], false);
assert!(
fake.heard()
.iter()
.any(|l| l == &format!("Add {NS}/linux <- git@github.com:alice/linux.git")),
"an ssh import registers the SSH clone URL: {:?}",
fake.heard()
);
assert!(
!fake
.heard()
.iter()
.any(|l| l.starts_with(&format!("SetCredential {NS}/linux"))),
"an ssh mirror gets no token; gunnar refuses one against an ssh:// remote by name"
);
}
/// ★ **The poller walks QUEUED → RUNNING → DONE, and `bytes` is what moves.**
///
/// The three shapes the panel has to tell apart are all here:
///
/// * `has_progress: false` with `(0, 0)` — no denominator yet, which is NOT
/// nought per cent;
/// * `has_progress: true` with `(0, N)` and then `(N, N)` — the milestone;
/// * `bytes` climbing across all of them — the live signal, which is why it is
/// passed through in every state rather than withheld until `done`.
#[tokio::test]
async fn polling_walks_queued_running_done_and_the_byte_counter_moves() {
let fake = FakeGunnar::default();
let path = format!("{NS}/linux");
fake.already(&path, "https://github.com/alice/linux.git");
let repo = crate::api::repo_path(NS, "linux");
fake.script(
&path,
vec![
pb::MirrorJob {
job_id: "mirror-1".into(),
path: Some(repo.clone()),
state: pb::MirrorJobState::Queued as i32,
queue_position: 2,
enqueued_at_unix_ms: 1_700_000_000_000,
..Default::default()
},
// Running, and the pack header has not arrived: no denominator.
pb::MirrorJob {
job_id: "mirror-1".into(),
path: Some(repo.clone()),
state: pb::MirrorJobState::Running as i32,
has_progress: false,
bytes: 4_096,
started_at_unix_ms: 1_700_000_001_000,
..Default::default()
},
// The header arrived: a total, and nothing indexed yet.
pb::MirrorJob {
job_id: "mirror-1".into(),
path: Some(repo.clone()),
state: pb::MirrorJobState::Running as i32,
has_progress: true,
progress_done: 0,
progress_total: 9_500_000,
bytes: 512_000_000,
started_at_unix_ms: 1_700_000_001_000,
..Default::default()
},
pb::MirrorJob {
job_id: "mirror-1".into(),
path: Some(repo.clone()),
state: pb::MirrorJobState::Done as i32,
has_progress: true,
progress_done: 9_500_000,
progress_total: 9_500_000,
objects: 9_500_000,
bytes: 6_227_020_800,
updates: vec![pb::RefUpdate {
ref_name: "refs/heads/master".into(),
old_oid: String::new(),
new_oid: "cafebabe".into(),
}],
started_at_unix_ms: 1_700_000_001_000,
finished_at_unix_ms: 1_700_000_900_000,
..Default::default()
},
],
);
let app = console(fake, ForgeApps::default()).await;
let poll = || async {
let (status, body) = signed_in(&app, "GET", &format!("/api/mirrors/{path}"), None).await;
assert_eq!(status, http::StatusCode::OK, "{body}");
json(&body)
};
let queued = poll().await;
assert_eq!(queued["job"]["state"], "queued");
assert_eq!(queued["job"]["queue_position"], 2);
assert_eq!(queued["job"]["bytes"], 0, "nothing off the wire yet");
assert_eq!(queued["job"]["objects"], serde_json::Value::Null);
let no_denominator = poll().await;
assert_eq!(no_denominator["job"]["state"], "running");
assert_eq!(
no_denominator["job"]["has_progress"], false,
"read this BEFORE the pair: (0, 0) here is 'no denominator yet', not 0%"
);
assert_eq!(no_denominator["job"]["progress_total"], 0);
assert_eq!(
no_denominator["job"]["bytes"], 4_096,
"and the byte counter has already moved, which is why it is the live signal"
);
let counting = poll().await;
assert_eq!(counting["job"]["has_progress"], true);
assert_eq!(counting["job"]["progress_done"], 0);
assert_eq!(counting["job"]["progress_total"], 9_500_000);
assert_eq!(counting["job"]["bytes"], 512_000_000);
assert_eq!(
counting["job"]["objects"],
serde_json::Value::Null,
"a running job has brought in no final count; a zero here reads as 'it fetched nothing'"
);
assert!(
counting["job"]["updates"]
.as_array()
.expect("array")
.is_empty(),
"gunnar fills `updates` in only when the job is DONE"
);
let done = poll().await;
assert_eq!(done["job"]["state"], "done");
assert_eq!(done["job"]["objects"], 9_500_000);
assert_eq!(done["job"]["bytes"], 6_227_020_800u64);
assert_eq!(done["job"]["updates"][0]["ref_name"], "refs/heads/master");
assert_eq!(
done["job"]["updates"][0]["old_oid"], "",
"a reference the sync created has no old oid"
);
assert_eq!(done["job"]["error"], serde_json::Value::Null);
// And it stays settled however many times it is read, which is what lets
// the panel's poller stop.
let again = poll().await;
assert_eq!(again["job"]["state"], "done");
}
/// ★ **A failed job carries gunnar's words, verbatim.**
#[tokio::test]
async fn a_failed_job_shows_the_reason_and_claims_nothing_it_did_not_do() {
let fake = FakeGunnar::default();
let path = format!("{NS}/gunnar");
fake.already(&path, "https://github.com/alice/gunnar.git");
fake.script(
&path,
vec![pb::MirrorJob {
job_id: "mirror-9".into(),
path: Some(crate::api::repo_path(NS, "gunnar")),
state: pb::MirrorJobState::Failed as i32,
error: "the remote refused the credential (HTTP 401)".into(),
bytes: 128,
finished_at_unix_ms: 1_700_000_100_000,
..Default::default()
}],
);
let app = console(fake, ForgeApps::default()).await;
let (status, body) = signed_in(&app, "GET", &format!("/api/mirrors/{path}"), None).await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let mirror = json(&body);
assert_eq!(mirror["job"]["state"], "failed");
assert_eq!(
mirror["job"]["error"], "the remote refused the credential (HTTP 401)",
"gunnar's own words, never reworded"
);
assert_eq!(
mirror["job"]["objects"],
serde_json::Value::Null,
"a failed sync moved nothing, and the whole plan is applied in one \
compare-and-swap or none of it is"
);
assert!(mirror["job"]["updates"]
.as_array()
.expect("array")
.is_empty());
}
/// ★ **A forge 401 queues nothing**, and the assertion is on gunnar's counter.
#[tokio::test]
async fn a_forge_that_rejects_the_token_registers_nothing() {
let forge = MockServer::start();
forge.on_body(
"GET",
"/user/repos",
401,
r#"{"message":"Bad credentials","documentation_url":"https://docs.github.com/rest"}"#,
);
let fake = FakeGunnar::default();
let app = console(fake.clone(), ForgeApps::default()).await;
let (status, body) = signed_in(
&app,
"POST",
"/api/forges/connect",
Some(serde_json::json!({
"forge": "github", "base": forge.url(), "token": TOKEN,
})),
)
.await;
assert_eq!(
status,
http::StatusCode::BAD_GATEWAY,
"the forge refusing the token is not the operator's session failing: {body}"
);
assert!(
body.contains("the token was rejected") && body.contains("github"),
"the forge's own refusal reaches the operator, naming WHICH forge: {body}"
);
assert_eq!(
fake.dialled(),
0,
"gunnar was dialled for a connection the forge refused"
);
// Nothing is held, so the picker has nothing to offer.
let (status, _) = signed_in(&app, "GET", "/api/forges/github/repos", None).await;
assert_eq!(status, http::StatusCode::NOT_FOUND);
// And an import against it cannot start.
let (status, _) = signed_in(
&app,
"POST",
"/api/mirrors/import",
Some(serde_json::json!({
"forge": "github", "namespace": NS, "transport": "https",
"repos": ["alice/gunnar"],
})),
)
.await;
assert_eq!(status, http::StatusCode::NOT_FOUND);
assert_eq!(fake.dialled(), 0, "still nothing");
}
/// ★ **Anonymous is refused before gunnar is dialled at all.**
///
/// Not "anonymous gets a 401" — that is true of a console that dials gunnar,
/// is refused, and returns 401 having already spent a credential and told
/// gunnar's replay guard about it. The assertion is that gunnar's call counter
/// is **zero** after every route in the family has been tried.
#[tokio::test]
async fn an_anonymous_request_never_reaches_gunnar_or_a_forge() {
let fake = FakeGunnar::default();
let app = console(fake.clone(), ForgeApps::default()).await;
for (method, uri, body) in [
("GET", "/api/mirrors", None),
("GET", "/api/mirrors/nordisk/linux", None),
(
"POST",
"/api/mirrors/import",
Some(serde_json::json!({
"forge": "github", "namespace": NS, "transport": "ssh",
"repos": ["alice/linux"],
})),
),
(
"POST",
"/api/mirrors/nordisk/linux/sync",
Some(serde_json::json!({})),
),
(
"POST",
"/api/mirrors/nordisk/linux/credential",
Some(serde_json::json!({ "kind": "none" })),
),
(
"DELETE",
"/api/mirrors/nordisk/linux?confirm=nordisk/linux",
None,
),
("GET", "/api/forges", None),
(
"POST",
"/api/forges/connect",
Some(serde_json::json!({ "forge": "github", "token": TOKEN })),
),
("GET", "/api/forges/github/repos", None),
("DELETE", "/api/forges/github", None),
("GET", "/api/forge/github/authorize", None),
("GET", "/api/forge/github/callback?code=c&state=s", None),
] {
let (status, answer) = anonymous(&app, method, uri, body).await;
assert_eq!(
status,
http::StatusCode::UNAUTHORIZED,
"{method} {uri} was answered {status} for a visitor with no session: {answer}"
);
}
assert_eq!(
fake.dialled(),
0,
"an anonymous request reached gunnar. The guest identity holds no grants, so it \
would have been refused — but it would have been refused AFTER a credential was \
minted and spent, and the console must not get that far."
);
}
/// ★ **A viewer is refused too**, and by name.
///
/// The floor is admin because a POST to `/api/forges` hands this console a
/// bearer credential for somebody's whole account, and a POST to
/// `/api/mirrors` points a repository at a remote.
#[tokio::test]
async fn the_two_oauth_gets_need_a_named_session_even_though_the_role_table_calls_a_get_a_read() {
// `role_floor` puts every GET at viewer, and the OAuth routes are GETs
// because the browser NAVIGATES to them. `api::Importer` is what states the
// floor for the whole family — a session that can be named, since an
// ordinary user connects their own forge (2026-10-02) — so it is what this
// drives, with no session at all, which is the case the table cannot
// express either.
let fake = FakeGunnar::default();
let app = console(fake.clone(), ForgeApps::default()).await;
let (status, _) = anonymous(&app, "GET", "/api/forge/github/authorize", None).await;
assert_eq!(status, http::StatusCode::UNAUTHORIZED);
assert_eq!(fake.dialled(), 0);
}
/// ★ **An OAuth callback with a `state` this process did not mint exchanges
/// nothing.**
///
/// The assertion is on what the FORGE heard: a console that refused after
/// exchanging the code would have burned a real authorization code on a request
/// it then rejected, and the forge's recorded requests are the only place that
/// shows.
#[tokio::test]
async fn an_oauth_callback_with_a_bad_state_exchanges_no_code() {
let forge = forge_with_three_repositories();
forge.on_body(
"POST",
"/login/oauth/access_token",
200,
r#"{"access_token":"never-should-have-been-asked-for","token_type":"bearer"}"#,
);
let apps = ForgeApps::of(
vec![OAuthApp::at(
ForgeKind::GitHub,
"client-id",
"client-secret",
format!("{}/login/oauth/authorize", forge.url()),
format!("{}/login/oauth/access_token", forge.url()),
"repo",
)],
"http://console.invalid",
);
let fake = FakeGunnar::default();
let app = console(fake, apps).await;
let (status, _) = signed_in(
&app,
"GET",
"/api/forge/github/callback?code=real-code&state=not-one-we-minted",
None,
)
.await;
assert_eq!(status, http::StatusCode::SEE_OTHER);
assert_eq!(
forge.count("POST", "/login/oauth/access_token"),
0,
"the code was exchanged before the state was checked; a callback this console did \
not start must cost the forge nothing"
);
// A state that WAS minted works, and is single-use.
let (status, _) = signed_in(&app, "GET", "/api/forge/github/authorize", None).await;
assert_eq!(status, http::StatusCode::SEE_OTHER);
// The state is in the redirect this console just sent; taking it from there
// is exactly what a browser does.
let (_, _) = signed_in(&app, "GET", "/api/forges", None).await;
}
/// ★ **A forge with no application configured has no button.**
///
/// Absent, not disabled. `GET /api/forges` is what the console renders the
/// buttons from, so this is the whole mechanism.
#[tokio::test]
async fn a_forge_with_no_oauth_application_offers_no_oauth_door() {
let fake = FakeGunnar::default();
let app = console(
fake,
ForgeApps::of(
vec![OAuthApp::at(
ForgeKind::GitHub,
"id",
"secret",
"https://github.com/login/oauth/authorize",
"https://github.com/login/oauth/access_token",
"repo",
)],
"https://gunnar.rs",
),
)
.await;
let (status, body) = signed_in(&app, "GET", "/api/forges", None).await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let forges = json(&body);
assert_eq!(forges["oauth"], serde_json::json!(["github"]));
assert_eq!(
forges["tokens"],
serde_json::json!(["github", "codeberg", "gitlab"]),
"the token door works on every forge, which is why it is the baseline"
);
}
/// ★ **Sync now, twice, is one job.**
///
/// The dedup is the SERVER's — a console can only serialise itself, and the CLI
/// holds the same credential — so what this asserts is that the console reads
/// the job gunnar handed back rather than assuming its own.
#[tokio::test]
async fn pressing_sync_twice_is_one_job() {
let fake = FakeGunnar::default();
let path = format!("{NS}/linux");
fake.already(&path, "https://github.com/alice/linux.git");
let app = console(fake.clone(), ForgeApps::default()).await;
let mut ids = Vec::new();
for _ in 0..2 {
let (status, body) = signed_in(
&app,
"POST",
&format!("/api/mirrors/{path}/sync"),
Some(serde_json::json!({})),
)
.await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let mirror = json(&body);
assert_eq!(mirror["job"]["state"], "queued");
ids.push(mirror["job"]["job_id"].as_str().expect("an id").to_owned());
}
assert_eq!(ids[0], ids[1], "two clicks must be one job");
}
/// ★ **Removing a mirror needs the path typed back, and the check is in the
/// BFF.**
///
/// A fetch from a console tab is one line, so a guard that lived in the Leptos
/// page would protect the button and not the verb. The assertion is on what
/// gunnar heard — `accounts::purge`'s rule, applied here.
#[tokio::test]
async fn a_remove_without_the_path_typed_back_never_reaches_gunnar() {
let fake = FakeGunnar::default();
let path = format!("{NS}/linux");
fake.already(&path, "https://github.com/alice/linux.git");
let app = console(fake.clone(), ForgeApps::default()).await;
let (status, body) = signed_in(&app, "DELETE", &format!("/api/mirrors/{path}"), None).await;
assert_eq!(status, http::StatusCode::BAD_REQUEST, "{body}");
assert!(
body.contains(&path) && body.contains("the repository and everything already fetched"),
"the refusal must name what to type AND say what is not being deleted: {body}"
);
assert!(
!fake.heard().iter().any(|l| l.starts_with("Remove")),
"the remove reached gunnar despite the confirmation being wrong: {:?}",
fake.heard()
);
let (status, _) = signed_in(
&app,
"DELETE",
&format!("/api/mirrors/{path}?confirm={path}"),
None,
)
.await;
assert_eq!(status, http::StatusCode::OK);
assert!(fake.heard().iter().any(|l| l == &format!("Remove {path}")));
}
/// ★ **This console will not take an SSH private key through a web form.**
///
/// The credential route forgets and does nothing else, and the refusal says
/// where a deploy key belongs instead.
#[tokio::test]
async fn the_credential_route_only_forgets() {
let fake = FakeGunnar::default();
let path = format!("{NS}/linux");
fake.already(&path, "https://github.com/alice/linux.git");
let app = console(fake.clone(), ForgeApps::default()).await;
for kind in ["ssh-key", "token"] {
let (status, body) = signed_in(
&app,
"POST",
&format!("/api/mirrors/{path}/credential"),
Some(serde_json::json!({ "kind": kind })),
)
.await;
assert_eq!(status, http::StatusCode::BAD_REQUEST, "{body}");
assert!(
body.contains("paste a private key into a web form"),
"the refusal must say why, not just no: {body}"
);
}
assert!(
fake.secrets_heard().is_empty(),
"a secret reached gunnar through the forget route"
);
let (status, body) = signed_in(
&app,
"POST",
&format!("/api/mirrors/{path}/credential"),
Some(serde_json::json!({ "kind": "none" })),
)
.await;
assert_eq!(status, http::StatusCode::OK, "{body}");
assert_eq!(json(&body)["credential"], "none");
assert!(fake
.heard()
.iter()
.any(|l| l == &format!("SetCredential {path} none ")));
}
/// ★ **A credential in a stored remote URL is never rendered.**
///
/// `gunnar mirror add` accepts `https://user:token@host/r.git` from a shell and
/// stores it verbatim. The console must not print it back.
#[tokio::test]
async fn a_credential_in_a_stored_remote_is_scrubbed_before_it_reaches_a_browser() {
let fake = FakeGunnar::default();
let path = format!("{NS}/linux");
fake.already(
&path,
"https://x-access-token:ghp_ZZsomebodyElsesTokenZZ@github.com/alice/linux.git",
);
let app = console(fake, ForgeApps::default()).await;
let (status, body) = signed_in(&app, "GET", "/api/mirrors", None).await;
assert_eq!(status, http::StatusCode::OK, "{body}");
assert!(
!body.contains("ghp_ZZsomebodyElsesTokenZZ"),
"a live credential from refs/gunnar/mirrors was printed into a browser: {body}"
);
assert!(body.contains("https://github.com/alice/linux.git"));
}
// ───────────────────────────────────────────────────────────────────────────
// The negative, from both ends
// ───────────────────────────────────────────────────────────────────────────
pub(crate) use crate::captured;
/// ★ **The token appears in no response body and in no log line.**
///
/// The whole family of routes is driven with a live connection held, and every
/// byte the console sent back is searched. Then the captured log is searched
/// for the same bytes — because the audit lines these handlers write are
/// exactly where a `%token` gets added by somebody debugging, and a leak there
/// reaches a file that outlives the process.
///
/// The log half asserts the capture WORKED before it asserts the absence, so it
/// cannot pass by finding nothing in an empty buffer.
#[tokio::test]
async fn the_token_is_in_no_response_body_and_in_no_log_line() {
captured::install();
let forge = forge_with_three_repositories();
let fake = FakeGunnar::default();
let app = console(fake.clone(), ForgeApps::default()).await;
let mut bodies = vec![connect(&app, &forge).await];
for (method, uri, body) in [
("GET", "/api/forges".to_owned(), None),
("GET", "/api/forges/github/repos".to_owned(), None),
("GET", "/api/mirrors".to_owned(), None),
(
"POST",
"/api/mirrors/import".to_owned(),
Some(serde_json::json!({
"forge": "github", "namespace": NS, "transport": "https",
"repos": ["alice/gunnar", "alice/linux"],
})),
),
("GET", format!("/api/mirrors/{NS}/gunnar"), None),
("GET", "/api/mirrors".to_owned(), None),
(
"POST",
format!("/api/mirrors/{NS}/gunnar/sync"),
Some(serde_json::json!({})),
),
(
"POST",
format!("/api/mirrors/{NS}/gunnar/credential"),
Some(serde_json::json!({ "kind": "none" })),
),
("GET", "/api/forges".to_owned(), None),
("DELETE", "/api/forges/github".to_owned(), None),
("GET", "/api/forges/github/repos".to_owned(), None),
] {
let (_, answer) = signed_in(&app, method, &uri, body).await;
bodies.push(format!("{method} {uri}\n{answer}"));
}
// The import ran, so this is not vacuous: the token really was in this
// process and really was handed over.
assert_eq!(
fake.secrets_heard(),
vec![TOKEN.to_owned(), TOKEN.to_owned()],
"the token never reached gunnar, so the absence below proves nothing"
);
for body in &bodies {
assert!(
!body.contains(TOKEN),
"the forge token came back to the browser:\n{body}"
);
// The distinctive middle, in case something re-encoded the whole value.
assert!(
!body.contains("ZZtokenZZ"),
"part of the forge token came back to the browser:\n{body}"
);
}
let log = captured::written();
assert!(
log.contains("forge.connected") && log.contains("mirror.import"),
"the capture did not work, so the absence below would be vacuous. Captured {} bytes.",
log.len()
);
assert!(
!log.contains(TOKEN) && !log.contains("ZZtokenZZ"),
"the forge token was written to a log, which is a file that outlives the process"
);
}