michal/tit

Diff

ccaf021500c81e0b97e01e97

.github/workflows/ci.yml

Mode 100644100644; object 5137f77b92a1a4be0dad87ef

@@ -88,8 +88,7 @@
           sudo apt-get install --no-install-recommends \
             gcc-arm-linux-gnueabihf \
             libc6-dev-armhf-cross \
-            qemu-user-binfmt
-          sudo update-binfmts --enable qemu-arm
+            qemu-user
           rustup target add "$TARGET"
       - run: cargo --version
       - run: cargo test --locked --all-targets --all-features --target "$TARGET"

CONTRIBUTING.md

Mode 100644100644; object ff96332497685ed957cd3de0

@@ -102,7 +102,8 @@
   HTML, and statically linked code.
 - dependency audit, external test driver, fixture repository, loopback address,
   quality gate, Rust toolchain, standard error, and standard output.
-- advertised hostname, hostname, onion hostname, and proxy command.
+- advertised hostname, hostname, onion hostname, proxy, proxy command, and
+  trusted proxy.
 - database constraint, foreign key, online backup, prepared statement, and
   schema migration.
 - durability gate, local filesystem, migration backup, percentile, and

Cargo.lock

Mode 100644100644; object 823e9b65ba07a3edfad70e00

@@ -40,15 +40,6 @@
 ]
 
 [[package]]
-name = "aho-corasick"
-version = "1.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
-dependencies = [
- "memchr",
-]
-
-[[package]]
 name = "allocator-api2"
 version = "0.2.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -374,7 +365,6 @@
  "iana-time-zone",
  "js-sys",
  "num-traits",
- "serde",
  "wasm-bindgen",
  "windows-link",
 ]
@@ -863,23 +853,6 @@
 version = "2.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
-
-[[package]]
-name = "feed-rs"
-version = "2.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "369995dae0733f1fe5ab0e3f345f6503a5f384179df5d8da333702031a131cf9"
-dependencies = [
- "chrono",
- "mediatype",
- "quick-xml",
- "regex",
- "serde",
- "serde_json",
- "siphasher",
- "url",
- "uuid",
-]
 
 [[package]]
 name = "ff"
@@ -2392,15 +2365,6 @@
 checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
 
 [[package]]
-name = "mediatype"
-version = "0.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7"
-dependencies = [
- "serde",
-]
-
-[[package]]
 name = "memchr"
 version = "2.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2854,16 +2818,6 @@
 checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
 
 [[package]]
-name = "quick-xml"
-version = "0.41.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
-dependencies = [
- "encoding_rs",
- "memchr",
-]
-
-[[package]]
 name = "quote"
 version = "1.0.47"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2905,33 +2859,10 @@
 ]
 
 [[package]]
-name = "regex"
-version = "1.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-automata",
- "regex-syntax",
-]
-
-[[package]]
 name = "regex-automata"
 version = "0.4.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-syntax",
-]
-
-[[package]]
-name = "regex-syntax"
-version = "0.8.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
 
 [[package]]
 name = "rfc6979"
@@ -3631,7 +3562,6 @@
  "askama",
  "axum",
  "clap",
- "feed-rs",
  "gix",
  "gix-pack",
  "httpdate",
@@ -3843,17 +3773,6 @@
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
-
-[[package]]
-name = "uuid"
-version = "1.24.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
-dependencies = [
- "getrandom 0.4.3",
- "js-sys",
- "wasm-bindgen",
-]
 
 [[package]]
 name = "vcpkg"

Cargo.toml

Mode 100644100644; object acbe4089df6a43cf72364038

@@ -46,7 +46,6 @@
 url = { version = "2.5", features = ["serde"] }
 
 [dev-dependencies]
-feed-rs = { version = "2.4.0", default-features = false }
 tempfile = "3.27"
 
 [lints.rust]

README.md

Mode 100644100644; object 6668d38d7c9ae9c9248eb7d4

@@ -39,7 +39,9 @@
 Set `public_url`, the HTTP listener, and the SSH listener and advertised
 hostname in `/srv/tit/config.toml`. Use HTTPS for a public instance. The
 included files in `release/examples/` contain service definitions and a Caddy
-example for Linux, macOS, FreeBSD, OpenBSD, and NetBSD.
+example for Linux, macOS, FreeBSD, OpenBSD, and NetBSD. Set
+`http.trusted_proxy` to the Caddy connection address. Do not set this field when
+a client can connect directly to the HTTP listener.
 
 Create the first administrator:
 

config.example.toml

Mode 100644100644; object 2134e947e98db04e7d9ad20a

@@ -3,6 +3,7 @@
 
 [http]
 listen = "127.0.0.1:3000"
+trusted_proxy = "127.0.0.1"
 
 [ssh]
 listen = "0.0.0.0:2222"

src/account.rs

Mode 100644100644; object 322c6ba645f4a99ae96d1e81

@@ -16,10 +16,6 @@
 const RECOVERY_PREFIX: &str = "tit-recovery-v1:";
 const SECRET_BYTES: usize = 32;
 const INVITATION_LIFETIME_SECONDS: i64 = 24 * 60 * 60;
-#[allow(
-    dead_code,
-    reason = "the account integration test imports this module without the Web profile route"
-)]
 const PROFILE_REPOSITORY_PAGE_SIZE: usize = 20;
 
 #[derive(Clone)]
@@ -194,10 +190,6 @@
             .map_err(Into::into)
     }
 
-    #[allow(
-        dead_code,
-        reason = "the account integration test imports this module without the Web profile route"
-    )]
     pub(crate) fn profile_page(
         &self,
         username: &str,
@@ -229,19 +221,11 @@
             .map_err(Into::into)
     }
 
-    #[allow(
-        dead_code,
-        reason = "the account integration test imports this module without Web account routes"
-    )]
     pub(crate) fn keys(&self, username: &str) -> Result<Vec<KeyInspection>, AccountError> {
         validate_username(username)?;
         Ok(Store::open(&self.database)?.inspect_account(username)?.keys)
     }
 
-    #[allow(
-        dead_code,
-        reason = "the account integration test imports this module without Web account routes"
-    )]
     pub(crate) fn complete_key_add(
         &self,
         request: &AccountKeyRequest<'_>,
@@ -281,10 +265,6 @@
         result
     }
 
-    #[allow(
-        dead_code,
-        reason = "the account integration test imports this module without Web account routes"
-    )]
     pub(crate) fn complete_key_revoke(
         &self,
         request: &AccountKeyRequest<'_>,
@@ -341,10 +321,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "integration tests compile accounts without the server"
-    )]
     pub(crate) fn database(&self) -> &Path {
         &self.database
     }
@@ -405,10 +381,6 @@
     Ok(())
 }
 
-#[allow(
-    dead_code,
-    reason = "the account integration test imports this module without Web account routes"
-)]
 fn validate_token(token: &str) -> Result<(), AccountError> {
     if token.len() != SECRET_BYTES * 2 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
         return Err(AccountError::InvalidSecret);

src/auth.rs

Mode 100644100644; object b7adc33b23b9e401fd7945ff

@@ -1,7 +1,3 @@
-use std::collections::HashMap;
-use std::sync::Mutex;
-
-use rand::TryRng;
 use sha2::{Digest, Sha256};
 use ssh_key::{Algorithm, EcdsaCurve, HashAlg, PublicKey, SshSig};
 use thiserror::Error;
@@ -9,7 +5,6 @@
 use crate::codec::{decode_lower_hex, encode_lower_hex};
 use url::Url;
 
-const CHALLENGE_HEADER: &str = "tit-auth-v1";
 const KEYLESS_CHALLENGE_HEADER: &str = "tit-auth-v2";
 const CHALLENGE_PURPOSE: &str = "web-login";
 const SIGNATURE_NAMESPACE: &str = "tit-auth";
@@ -17,7 +12,6 @@
 const MAX_CHALLENGE_BYTES: usize = 4 * 1024;
 const MAX_SIGNATURE_BYTES: usize = 16 * 1024;
 const MAX_CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60;
-const MAX_OUTSTANDING_CHALLENGES: usize = 1_024;
 const NONCE_BYTES: usize = 32;
 const MINIMUM_RSA_BITS: u32 = 3_072;
 
@@ -115,98 +109,7 @@
         username: fields.username.to_owned(),
         fingerprint: key.fingerprint().to_owned(),
         nonce_hash: hash_nonce(&fields.nonce),
-        expires_at: fields.expires_at,
     })
-}
-
-#[derive(Debug)]
-pub(crate) struct LoginChallenges {
-    origin: String,
-    nonces: Mutex<HashMap<[u8; 32], u64>>,
-}
-
-impl LoginChallenges {
-    pub(crate) fn new(public_url: &Url) -> Result<Self, AuthError> {
-        Ok(Self {
-            origin: login_origin(public_url)?,
-            nonces: Mutex::new(HashMap::new()),
-        })
-    }
-
-    pub(crate) fn issue(
-        &self,
-        username: &str,
-        key: &SshPublicKey,
-        issued_at: u64,
-        lifetime_seconds: u64,
-    ) -> Result<String, AuthError> {
-        validate_username(username)?;
-        if lifetime_seconds == 0 || lifetime_seconds > MAX_CHALLENGE_LIFETIME_SECONDS {
-            return Err(AuthError::InvalidLifetime);
-        }
-        let expires_at = issued_at
-            .checked_add(lifetime_seconds)
-            .ok_or(AuthError::InvalidLifetime)?;
-
-        let nonce = self.new_nonce(issued_at, expires_at)?;
-        Ok(format_login_challenge(
-            &self.origin,
-            username,
-            key,
-            &nonce,
-            issued_at,
-            expires_at,
-        ))
-    }
-
-    pub(crate) fn verify(
-        &self,
-        challenge: &str,
-        signature: &str,
-        expected_username: &str,
-        expected_key: &SshPublicKey,
-        now: u64,
-    ) -> Result<VerifiedLogin, AuthError> {
-        let verified = verify_login_challenge(
-            &self.origin,
-            challenge,
-            signature,
-            expected_username,
-            expected_key,
-            now,
-        )?;
-        let mut nonces = self.nonces.lock().map_err(|_| AuthError::NonceStore)?;
-        match nonces.get(&verified.nonce_hash) {
-            Some(stored_expiry) if *stored_expiry == verified.expires_at => {
-                nonces.remove(&verified.nonce_hash);
-            }
-            _ => return Err(AuthError::ConsumedChallenge),
-        }
-
-        Ok(VerifiedLogin {
-            username: verified.username,
-            fingerprint: verified.fingerprint,
-        })
-    }
-
-    fn new_nonce(&self, issued_at: u64, expires_at: u64) -> Result<[u8; NONCE_BYTES], AuthError> {
-        loop {
-            let mut nonce = [0_u8; NONCE_BYTES];
-            rand::rngs::SysRng
-                .try_fill_bytes(&mut nonce)
-                .map_err(|_| AuthError::Random)?;
-            let hash = hash_nonce(&nonce);
-            let mut nonces = self.nonces.lock().map_err(|_| AuthError::NonceStore)?;
-            nonces.retain(|_, stored_expiry| *stored_expiry >= issued_at);
-            if nonces.len() >= MAX_OUTSTANDING_CHALLENGES {
-                return Err(AuthError::NonceLimit);
-            }
-            if let std::collections::hash_map::Entry::Vacant(entry) = nonces.entry(hash) {
-                entry.insert(expires_at);
-                return Ok(nonce);
-            }
-        }
-    }
 }
 
 pub(crate) fn login_origin(public_url: &Url) -> Result<String, AuthError> {
@@ -223,79 +126,10 @@
     Ok(public_url.origin().ascii_serialization())
 }
 
-pub(crate) fn format_login_challenge(
-    origin: &str,
-    username: &str,
-    key: &SshPublicKey,
-    nonce: &[u8; NONCE_BYTES],
-    issued_at: u64,
-    expires_at: u64,
-) -> String {
-    format!(
-        "{CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={origin}\nusername={username}\nfingerprint={}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
-        key.fingerprint(),
-        encode_lower_hex(nonce)
-    )
-}
-
-pub(crate) fn verify_login_challenge(
-    origin: &str,
-    challenge: &str,
-    signature: &str,
-    expected_username: &str,
-    expected_key: &SshPublicKey,
-    now: u64,
-) -> Result<PersistentVerifiedLogin, AuthError> {
-    if challenge.len() > MAX_CHALLENGE_BYTES {
-        return Err(AuthError::InputTooLarge("login challenge"));
-    }
-    if signature.len() > MAX_SIGNATURE_BYTES {
-        return Err(AuthError::InputTooLarge("SSHSIG envelope"));
-    }
-    validate_username(expected_username)?;
-    let fields = ChallengeFields::parse(challenge)?;
-    if fields.origin != origin {
-        return Err(AuthError::WrongOrigin);
-    }
-    if fields.username != expected_username {
-        return Err(AuthError::WrongUsername);
-    }
-    if fields.fingerprint != expected_key.fingerprint() {
-        return Err(AuthError::WrongKey);
-    }
-    if fields.expires_at <= fields.issued_at
-        || fields.expires_at - fields.issued_at > MAX_CHALLENGE_LIFETIME_SECONDS
-    {
-        return Err(AuthError::InvalidLifetime);
-    }
-    if now < fields.issued_at || now > fields.expires_at {
-        return Err(AuthError::ExpiredChallenge);
-    }
-    let sshsig = SshSig::from_pem(signature).map_err(AuthError::SignatureEnvelope)?;
-    validate_signature_algorithm(expected_key.public_key(), &sshsig)?;
-    expected_key
-        .public_key()
-        .verify(SIGNATURE_NAMESPACE, challenge.as_bytes(), &sshsig)
-        .map_err(AuthError::SignatureVerification)?;
-    Ok(PersistentVerifiedLogin {
-        username: fields.username.to_owned(),
-        fingerprint: fields.fingerprint.to_owned(),
-        nonce_hash: hash_nonce(&fields.nonce),
-        expires_at: fields.expires_at,
-    })
-}
-
 pub(crate) struct PersistentVerifiedLogin {
     pub(crate) username: String,
     pub(crate) fingerprint: String,
     pub(crate) nonce_hash: [u8; 32],
-    pub(crate) expires_at: u64,
-}
-
-#[derive(Debug, Eq, PartialEq)]
-pub(crate) struct VerifiedLogin {
-    pub(crate) username: String,
-    pub(crate) fingerprint: String,
 }
 
 #[derive(Debug, Error)]
@@ -314,16 +148,12 @@
     InvalidOrigin,
     #[error("challenge lifetime is not valid")]
     InvalidLifetime,
-    #[error("cannot get random bytes")]
-    Random,
     #[error("login challenge is not valid")]
     MalformedChallenge,
     #[error("login challenge has the wrong origin")]
     WrongOrigin,
     #[error("login challenge has the wrong username")]
     WrongUsername,
-    #[error("login challenge has the wrong SSH key")]
-    WrongKey,
     #[error("login challenge has expired or is not active")]
     ExpiredChallenge,
     #[error("SSHSIG envelope is not valid: {0}")]
@@ -332,21 +162,6 @@
     UnsupportedSignatureAlgorithm,
     #[error("SSHSIG verification failed: {0}")]
     SignatureVerification(ssh_key::Error),
-    #[error("login challenge was consumed or was not issued")]
-    ConsumedChallenge,
-    #[error("login nonce store is not available")]
-    NonceStore,
-    #[error("too many login challenges are active")]
-    NonceLimit,
-}
-
-struct ChallengeFields<'a> {
-    origin: &'a str,
-    username: &'a str,
-    fingerprint: &'a str,
-    nonce: [u8; NONCE_BYTES],
-    issued_at: u64,
-    expires_at: u64,
 }
 
 struct KeylessChallengeFields<'a> {
@@ -380,41 +195,6 @@
         Ok(Self {
             origin,
             username,
-            nonce,
-            issued_at,
-            expires_at,
-        })
-    }
-}
-
-impl<'a> ChallengeFields<'a> {
-    fn parse(challenge: &'a str) -> Result<Self, AuthError> {
-        let body = challenge
-            .strip_suffix('\n')
-            .ok_or(AuthError::MalformedChallenge)?;
-        let mut lines = body.split('\n');
-        if lines.next() != Some(CHALLENGE_HEADER) || lines.next() != Some("purpose=web-login") {
-            return Err(AuthError::MalformedChallenge);
-        }
-        let origin = field(lines.next(), "origin=")?;
-        let username = field(lines.next(), "username=")?;
-        validate_username(username)?;
-        let fingerprint = field(lines.next(), "fingerprint=")?;
-        let nonce = decode_hex(field(lines.next(), "nonce=")?)?;
-        let issued_at = parse_time(field(lines.next(), "issued-at=")?)?;
-        let expires_at = parse_time(field(lines.next(), "expires-at=")?)?;
-        if lines.next().is_some()
-            || origin.is_empty()
-            || fingerprint.is_empty()
-            || origin.contains(['\r', '\n'])
-            || fingerprint.contains(['\r', '\n'])
-        {
-            return Err(AuthError::MalformedChallenge);
-        }
-        Ok(Self {
-            origin,
-            username,
-            fingerprint,
             nonce,
             issued_at,
             expires_at,

src/backup.rs

Mode 100644100644; object 660d5149e67cca44c39c132a

@@ -870,7 +870,7 @@
             .unarchive_repository_for_owner("alice", "after", "alice", 5, "backup-unarchive")
             .expect("unarchive the backup repository");
         drop(store);
-        crate::git::repository::GitRepository::create_bare(
+        GitRepository::create_bare(
             &instance
                 .path()
                 .join(REPOSITORY_DIRECTORY)

src/codec.rs

Mode 100644100644; object 1a9295a4a918ade18bc1ebbf

@@ -1,8 +1,3 @@
-#![allow(
-    dead_code,
-    reason = "integration test crates use only the codec policies required by their imported modules"
-)]
-
 pub(crate) fn encode_lower_hex(bytes: &[u8]) -> String {
     const HEX: &[u8; 16] = b"0123456789abcdef";
     let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));

src/config.rs

Mode 100644100644; object 6f01dd9655cd4e9e23f76221

@@ -20,6 +20,7 @@
     pub(crate) public_url: Url,
     pub(crate) instance_dir: PathBuf,
     pub(crate) http_listen: SocketAddr,
+    pub(crate) http_trusted_proxy: Option<IpAddr>,
     pub(crate) ssh_listen: SocketAddr,
     pub(crate) ssh_public_host: Host<String>,
     pub(crate) ssh_public_port: u16,
@@ -37,6 +38,12 @@
         validate_public_url(&self.public_url)?;
         if self.http_listen.port() == 0 {
             return Err(ConfigError::ZeroListenerPort("http.listen"));
+        }
+        if self
+            .http_trusted_proxy
+            .is_some_and(|address| address.is_unspecified() || address.is_multicast())
+        {
+            return Err(ConfigError::InvalidTrustedProxy);
         }
         if self.ssh_listen.port() == 0 {
             return Err(ConfigError::ZeroListenerPort("ssh.listen"));
@@ -122,6 +129,7 @@
 #[serde(default, deny_unknown_fields)]
 struct HttpConfig {
     listen: SocketAddr,
+    trusted_proxy: Option<IpAddr>,
 }
 
 impl Default for HttpConfig {
@@ -130,6 +138,7 @@
             listen: "127.0.0.1:3000"
                 .parse()
                 .expect("the default HTTP address is valid"),
+            trusted_proxy: None,
         }
     }
 }
@@ -229,6 +238,8 @@
     InvalidSshPublicAddress(IpAddr),
     #[error("listener port {0} must not be zero")]
     ZeroListenerPort(&'static str),
+    #[error("trusted proxy must not be an unspecified or multicast address")]
+    InvalidTrustedProxy,
     #[error("configuration limit {0} must not be zero")]
     ZeroLimit(&'static str),
     #[error("configuration limit {0} is too large")]
@@ -283,6 +294,7 @@
         public_url,
         instance_dir,
         http_listen,
+        http_trusted_proxy: file.http.trusted_proxy,
         ssh_listen,
         ssh_public_host,
         ssh_public_port,
@@ -405,6 +417,7 @@
         assert_eq!(config.public_url.as_str(), "https://tit.example/");
         assert_eq!(config.instance_dir, path.parent().expect("a parent"));
         assert_eq!(config.http_listen.to_string(), "127.0.0.1:3000");
+        assert_eq!(config.http_trusted_proxy, None);
         assert_eq!(config.ssh_listen.to_string(), "0.0.0.0:2222");
         assert_eq!(
             config.ssh_public_host,
@@ -525,6 +538,26 @@
         assert!(matches!(
             load(&cli(&path)),
             Err(ConfigError::InvalidSshPublicAddress(address)) if address.is_unspecified()
+        ));
+    }
+
+    #[test]
+    fn accepts_one_explicit_trusted_proxy_and_rejects_unsafe_addresses() {
+        let (_directory, path) = write_config(
+            "version = 1\npublic_url = \"https://tit.example/\"\n[http]\ntrusted_proxy = \"127.0.0.1\"\n",
+        );
+        let config = load(&cli(&path)).expect("load the trusted proxy");
+        assert_eq!(
+            config.http_trusted_proxy,
+            Some("127.0.0.1".parse().expect("parse the expected address"))
+        );
+
+        let (_directory, path) = write_config(
+            "version = 1\npublic_url = \"https://tit.example/\"\n[http]\ntrusted_proxy = \"0.0.0.0\"\n",
+        );
+        assert!(matches!(
+            load(&cli(&path)),
+            Err(ConfigError::InvalidTrustedProxy)
         ));
     }
 

src/control.rs

Mode 100644100644; object 1091d56f52d1ba8f33f1e941

@@ -30,10 +30,7 @@
 }
 
 impl RunningControlServer {
-    #[allow(
-        dead_code,
-        reason = "the production server uses the backup-enabled constructor"
-    )]
+    #[cfg(test)]
     pub(crate) fn start(
         instance_dir: &Path,
         accounts: AccountService,

src/git/mod.rs

Mode 100644100644; object ccea7ef8c31f211ffaaa137e

@@ -1,10 +1,7 @@
+#[cfg(test)]
 pub(crate) mod http;
 pub(crate) mod packetline;
 pub(crate) mod patch;
-#[allow(
-    dead_code,
-    reason = "some protocol tests import Git without the public HTTP routes"
-)]
 pub(crate) mod read;
 pub(crate) mod receive_pack;
 pub(crate) mod repository;

src/git/packetline.rs

Mode 100644100644; object 280639a75589e11d0729cb68

@@ -28,6 +28,7 @@
     output.extend_from_slice(b"0000");
 }
 
+#[cfg(test)]
 pub(crate) fn encode_delimiter(output: &mut Vec<u8>) {
     output.extend_from_slice(b"0001");
 }

src/git/read.rs

Mode 100644100644; object 619287c199cb85bd77d6a38b

@@ -63,6 +63,7 @@
 }
 
 impl ReadCancellation {
+    #[cfg(test)]
     pub(crate) fn cancel(&self) {
         self.cancelled.store(true, Ordering::Relaxed);
     }

src/git/receive_pack.rs

Mode 100644100644; object 0d565cc91cc3fa51f27a89c2

@@ -1048,8 +1048,6 @@
     StaleRef,
     #[error("receive-pack input is missing a pack")]
     MissingPack,
-    #[error("receive-pack input has an unexpected pack")]
-    UnexpectedPack,
     #[error("receive-pack exceeds the pack limit")]
     PackLimit,
     #[error("receive-pack exceeds the object limit")]
@@ -1093,7 +1091,6 @@
         matches!(
             self,
             Self::MissingPack
-                | Self::UnexpectedPack
                 | Self::PackLimit
                 | Self::ObjectLimit
                 | Self::Pack(_)

src/git/repository.rs

Mode 100644100644; object dc6134ed2a40db6a73e3db9d

@@ -281,6 +281,7 @@
         write_merge_commit(&self.repository, base, head, actor, created_at, message)
     }
 
+    #[cfg(test)]
     pub(crate) fn make_pack(
         &self,
         wants: &[ObjectId],
@@ -615,8 +616,6 @@
     },
     #[error("Git object count or decoded size exceeds the limit")]
     ObjectLimit,
-    #[error("generated Git pack exceeds the limit")]
-    PackLimit,
     #[error("Git pack generation was cancelled")]
     Cancelled,
     #[error("cannot generate Git pack: {0}")]

src/git/transport.rs

Mode 100644100644; object d6e63d355631c33073614a58

@@ -39,15 +39,27 @@
         })
     }
 
+    #[cfg(test)]
     pub(crate) fn new_with_pushes(
         root: &Path,
         database: &Path,
     ) -> Result<Self, RepositoryPathError> {
+        Self::new_with_pushes_and_gate(root, database, MaintenanceGate::default())
+    }
+
+    #[cfg(test)]
+    pub(crate) fn new_with_pushes_and_gate(
+        root: &Path,
+        database: &Path,
+        maintenance: MaintenanceGate,
+    ) -> Result<Self, RepositoryPathError> {
         let mut repositories = Self::new(root)?;
         repositories.push_database = Some(database.to_owned());
+        repositories.maintenance = maintenance;
         Ok(repositories)
     }
 
+    #[cfg(test)]
     pub(crate) fn new_managed_public(
         root: &Path,
         repositories: impl IntoIterator<Item = (String, String, String)>,
@@ -67,13 +79,6 @@
         Ok(service)
     }
 
-    pub(crate) fn new_managed_authorized(
-        root: &Path,
-        database: &Path,
-    ) -> Result<Self, RepositoryPathError> {
-        Self::new_managed_authorized_with_gate(root, database, MaintenanceGate::default())
-    }
-
     pub(crate) fn new_managed_authorized_with_gate(
         root: &Path,
         database: &Path,
@@ -86,6 +91,7 @@
         Ok(service)
     }
 
+    #[cfg(test)]
     pub(crate) fn resolve(
         &self,
         owner: &str,
@@ -141,6 +147,7 @@
         Ok(candidate)
     }
 
+    #[cfg(test)]
     pub(crate) fn resolve_ssh_command(
         &self,
         command: &[u8],
@@ -168,6 +175,7 @@
         self.resolve(owner, repository)
     }
 
+    #[cfg(test)]
     pub(crate) fn resolve_ssh_service(
         &self,
         command: &[u8],
@@ -183,11 +191,7 @@
         if command.starts_with(b"git-upload-pack ") {
             let (owner, repository) = parse_ssh_repository(command, "git-upload-pack '")?;
             let path = self.resolve_for(actor, &owner, &repository, RepositoryOperation::Read)?;
-            return Ok(GitSshService::Upload {
-                path,
-                owner,
-                repository,
-            });
+            return Ok(GitSshService::Upload { path });
         }
         let (owner, repository) = parse_ssh_repository(command, "git-receive-pack '")?;
         let path = self.resolve_for(actor, &owner, &repository, RepositoryOperation::Write)?;
@@ -224,6 +228,10 @@
         &self.root
     }
 
+    pub(crate) fn maintenance_gate(&self) -> MaintenanceGate {
+        self.maintenance.clone()
+    }
+
     pub(crate) async fn push_permit(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
         self.push_jobs.clone().acquire_owned().await
     }
@@ -237,6 +245,7 @@
     }
 }
 
+#[cfg(test)]
 fn valid_managed_id(value: &str) -> bool {
     value.len() == 32
         && value
@@ -247,8 +256,6 @@
 pub(crate) enum GitSshService {
     Upload {
         path: PathBuf,
-        owner: String,
-        repository: String,
     },
     Receive {
         path: PathBuf,
@@ -314,6 +321,7 @@
     },
     #[error("repository resolves outside the repository root")]
     OutsideRoot,
+    #[cfg(test)]
     #[error("managed repository catalog is not valid")]
     InvalidCatalog,
     #[error("repository access is not authorized")]

src/http/metadata_search.rs

Mode 100644100644; object 044fd88dda41e8f920e440b8

@@ -138,6 +138,10 @@
     query: &'a str,
     searched: bool,
     rows_scanned: usize,
+    #[allow(
+        dead_code,
+        reason = "the Askama template reads this field through generated code"
+    )]
     bytes_scanned: usize,
     truncated: bool,
     results: Vec<MetadataSearchResultView<'a>>,

src/http/mod.rs

Mode 100644100644; object 4c631c013b6017f41572479c

@@ -127,6 +127,7 @@
     watches: Option<WatchService>,
     readiness: Option<ListenerReadiness>,
     secure_cookies: bool,
+    trusted_proxy: Option<IpAddr>,
 }
 
 #[derive(Clone)]
@@ -159,6 +160,7 @@
     pub(crate) instance_dir: PathBuf,
     pub(crate) http_clone_base: String,
     pub(crate) ssh_clone_base: String,
+    pub(crate) trusted_proxy: Option<IpAddr>,
     pub(crate) max_request_bytes: usize,
     pub(crate) max_connections: usize,
 }
@@ -183,12 +185,14 @@
 }
 
 pub(crate) struct RunningWebServer {
+    #[cfg(test)]
     address: SocketAddr,
     shutdown: oneshot::Sender<()>,
     task: JoinHandle<std::io::Result<()>>,
 }
 
 impl RunningWebServer {
+    #[cfg(test)]
     pub(crate) async fn start(address: SocketAddr) -> Result<Self, WebError> {
         Self::start_with_state(
             address,
@@ -212,11 +216,13 @@
                 watches: None,
                 readiness: None,
                 secure_cookies: false,
+                trusted_proxy: None,
             },
         )
         .await
     }
 
+    #[cfg(test)]
     pub(crate) async fn start_public(
         address: SocketAddr,
         config: PublicWebConfig,
@@ -257,6 +263,7 @@
         let ssh_login_target = parse_ssh_login_target(&config.ssh_clone_base)?;
         let database = config.instance_dir.join(crate::store::DATABASE_FILE);
         let accounts = AccountService::new(database.clone());
+        let trusted_proxy = config.trusted_proxy;
         let public_url = url::Url::parse(&format!("{}/", config.http_clone_base))
             .map_err(WebError::CanonicalUrl)?;
         let secure_cookies = public_url.scheme() == "https";
@@ -300,6 +307,7 @@
                 watches: Some(watches),
                 readiness,
                 secure_cookies,
+                trusted_proxy,
             },
         )
         .await
@@ -307,6 +315,7 @@
 
     async fn start_with_state(address: SocketAddr, state: WebState) -> Result<Self, WebError> {
         let listener = TcpListener::bind(address).await?;
+        #[cfg(test)]
         let address = listener.local_addr()?;
         let (shutdown, receiver) = oneshot::channel();
         let task = tokio::spawn(async move {
@@ -320,12 +329,14 @@
             .await
         });
         Ok(Self {
+            #[cfg(test)]
             address,
             shutdown,
             task,
         })
     }
 
+    #[cfg(test)]
     pub(crate) fn address(&self) -> SocketAddr {
         self.address
     }
@@ -350,30 +361,6 @@
             }
         }
     }
-}
-
-pub(crate) fn router() -> Router {
-    router_with_state(WebState {
-        public: None,
-        accounts: None,
-        jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
-        requests: Arc::new(Semaphore::new(1024)),
-        login_attempts: login_attempt_limiter(),
-        account_attempts: login_attempt_limiter(),
-        max_request_bytes: 1024 * 1024,
-        telemetry: Telemetry::default(),
-        key_reloader: None,
-        login: None,
-        ssh_login_target: None,
-        repositories: None,
-        issues: None,
-        pull_requests: None,
-        feeds: None,
-        search: None,
-        watches: None,
-        readiness: None,
-        secure_cookies: false,
-    })
 }
 
 fn router_with_state(state: WebState) -> Router {
@@ -520,11 +507,12 @@
     mut request: Request,
     next: Next,
 ) -> Response {
-    let address = request
+    let peer = request
         .extensions()
         .get::<ConnectInfo<SocketAddr>>()
         .map(|peer| peer.0.ip())
         .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
+    let address = client_address(peer, request.headers(), state.trusted_proxy);
     request.extensions_mut().insert(ClientAddress(address));
     let permit = match tokio::time::timeout(
         CONCURRENCY_WAIT,
@@ -544,6 +532,20 @@
             "Request time limit exceeded.\n",
         ),
     }
+}
+
+fn client_address(peer: IpAddr, headers: &HeaderMap, trusted_proxy: Option<IpAddr>) -> IpAddr {
+    if trusted_proxy != Some(peer) {
+        return peer;
+    }
+    headers
+        .get_all("x-forwarded-for")
+        .iter()
+        .next_back()
+        .and_then(|value| value.to_str().ok())
+        .and_then(|value| value.rsplit(',').next())
+        .and_then(|value| value.trim().parse().ok())
+        .unwrap_or(peer)
 }
 
 fn limit_response(status: StatusCode, message: &'static str) -> Response {
@@ -2495,4 +2497,30 @@
     Session(#[from] SessionError),
     #[error("HTTP server task failed")]
     Join,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn accepts_forwarded_addresses_only_from_the_configured_proxy() {
+        let proxy = IpAddr::V4(Ipv4Addr::LOCALHOST);
+        let client: IpAddr = "192.0.2.10".parse().expect("parse a client address");
+        let direct: IpAddr = "203.0.113.5".parse().expect("parse a direct address");
+        let mut headers = HeaderMap::new();
+        headers.insert(
+            "x-forwarded-for",
+            HeaderValue::from_static("198.51.100.9, 192.0.2.10"),
+        );
+
+        assert_eq!(client_address(proxy, &headers, Some(proxy)), client);
+        assert_eq!(client_address(direct, &headers, Some(proxy)), direct);
+
+        headers.insert(
+            "x-forwarded-for",
+            HeaderValue::from_static("not-an-address"),
+        );
+        assert_eq!(client_address(proxy, &headers, Some(proxy)), proxy);
+    }
 }

src/http/public.rs

Mode 100644100644; object 2ad0c4f8cb0aaca97529f24b

@@ -27,7 +27,7 @@
 use crate::git::packetline::MAX_REQUEST_BYTES;
 use crate::git::patch::write_patch;
 use crate::git::read::{
-    BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits,
+    BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits, RefInfo,
     RepositoryReadService, SearchOutcome, TreeEntryInfo,
 };
 use crate::git::upload_pack::{ProtocolVersion, UploadPack};
@@ -1564,7 +1564,7 @@
         page
     }
 
-    fn refs(record: RepositoryRecord, references: Vec<crate::git::read::RefInfo>) -> Self {
+    fn refs(record: RepositoryRecord, references: Vec<RefInfo>) -> Self {
         let mut page = Self::base(record, "refs", "Refs".to_owned());
         page.refs = references
             .into_iter()
@@ -1697,7 +1697,7 @@
 
     fn search(
         record: RepositoryRecord,
-        references: Vec<crate::git::read::RefInfo>,
+        references: Vec<RefInfo>,
         selected: Option<(Vec<u8>, ObjectId)>,
         query: String,
         outcome: Option<SearchOutcome>,
@@ -1860,6 +1860,10 @@
 struct BlameView {
     start_line: u32,
     source_start_line: u32,
+    #[allow(
+        dead_code,
+        reason = "the Askama template reads this field through generated code"
+    )]
     line_count: u32,
     current_end_line: u32,
     source_end_line: u32,
@@ -1933,7 +1937,7 @@
 }
 
 fn select_search_ref(
-    references: &[crate::git::read::RefInfo],
+    references: &[RefInfo],
     requested: Option<&str>,
     default_branch: &str,
 ) -> Result<Option<(Vec<u8>, ObjectId)>, RouteError> {

src/http/pull_requests.rs

Mode 100644100644; object fb379512815ad0df38dc0b8f

@@ -8,8 +8,10 @@
 use serde::Deserialize;
 
 use crate::codec::{decode_ascii_hex, encode_lower_hex};
+use crate::git::read::{Comparison, Mergeability};
+use crate::git::repository::GitRepositoryError;
 use crate::markdown::{self, RenderedMarkdown};
-use crate::pull_request::PullRequestError;
+use crate::pull_request::{ActivityPages, NewPullRequest, PullRequestError, PullRequestReview};
 use crate::store::StoreError;
 
 use super::filters;
@@ -83,8 +85,10 @@
             number,
             Some(revision),
             actor.0.as_deref(),
-            1,
-            1,
+            ActivityPages {
+                reviews: 1,
+                timeline: 1,
+            },
         )
     })
     .await;
@@ -226,8 +230,10 @@
             path.number,
             query.revision,
             actor.0.as_deref(),
-            reviews_page,
-            timeline_page,
+            ActivityPages {
+                reviews: reviews_page,
+                timeline: timeline_page,
+            },
         )
     })
     .await;
@@ -460,18 +466,18 @@
     let repository = path.repository.clone();
     let number = path.number;
     let result = job(state, move || {
-        service.review(
-            &owner,
-            &repository,
+        service.review(&PullRequestReview {
+            owner: &owner,
+            repository: &repository,
             number,
             revision,
-            &actor,
-            &fields[2],
-            &fields[3],
-            path_bytes.as_deref(),
-            side.as_deref(),
+            actor: &actor,
+            kind: &fields[2],
+            body: &fields[3],
+            path: path_bytes.as_deref(),
+            side: side.as_deref(),
             line,
-        )
+        })
     })
     .await;
     match result {
@@ -506,15 +512,15 @@
     let owner = path.owner.clone();
     let repository = path.repository.clone();
     let result = job(state, move || {
-        service.open(
-            &owner,
-            &repository,
-            &actor,
-            &fields[1],
-            &fields[2],
-            &fields[3],
-            &fields[4],
-        )
+        service.open(&NewPullRequest {
+            owner: &owner,
+            repository: &repository,
+            actor: &actor,
+            title: &fields[1],
+            body: &fields[2],
+            base_ref: &fields[3],
+            head_ref: &fields[4],
+        })
     })
     .await;
     match result {
@@ -623,9 +629,7 @@
         | PullRequestError::MergeMethod
         | PullRequestError::Store(StoreError::PullRequestRevisionNotFound)
         | PullRequestError::Store(StoreError::PullRequestReviewAnchor)
-        | PullRequestError::Git(crate::git::repository::GitRepositoryError::MissingReference(_)) => {
-            bad_request(request_id)
-        }
+        | PullRequestError::Git(GitRepositoryError::MissingReference(_)) => bad_request(request_id),
         PullRequestError::StaleRevision | PullRequestError::Mergeability => render_error(
             StatusCode::CONFLICT,
             request_id,
@@ -822,10 +826,8 @@
     hunks: String,
 }
 
-impl From<&crate::git::read::Comparison> for ComparisonView {
-    fn from(comparison: &crate::git::read::Comparison) -> Self {
-        use crate::git::read::Mergeability;
-
+impl From<&Comparison> for ComparisonView {
+    fn from(comparison: &Comparison) -> Self {
         Self {
             merge_base: comparison
                 .merge_base

src/issue.rs

Mode 100644100644; object b72a6637278b1c40e84e1294

@@ -6,7 +6,7 @@
 use crate::domain::repository::{RepositoryNameError, validate_slug};
 use crate::store::{
     IssueChange, IssueDetail, IssueRecord, NewIssue, RecordPage, RepositoryRecord, Store,
-    StoreError,
+    StoreError, TimelinePagination,
 };
 use crate::system::unix_timestamp;
 
@@ -100,9 +100,11 @@
                 repository,
                 number,
                 actor,
-                comments_page,
-                timeline_page,
-                PAGE_SIZE,
+                TimelinePagination {
+                    primary_page: comments_page,
+                    timeline_page,
+                    page_size: PAGE_SIZE,
+                },
             )
             .map_err(IssueError::from)?;
         if (comments_page > 1 && detail.comments.is_empty())

src/lib.rs

Mode 100644100644; object 8016bfbcf2731ca09a26fb81

@@ -2,10 +2,6 @@
 
 pub(crate) mod account;
 pub(crate) mod admin;
-#[allow(
-    dead_code,
-    reason = "the current login flow does not use the legacy challenge API"
-)]
 pub(crate) mod auth;
 pub(crate) mod backup;
 pub(crate) mod bootstrap;
@@ -17,15 +13,7 @@
 pub(crate) mod domain;
 pub(crate) mod feed;
 pub(crate) mod feed_token;
-#[allow(
-    dead_code,
-    reason = "the test suite exercises Git transports that the server composes indirectly"
-)]
 pub(crate) mod git;
-#[allow(
-    dead_code,
-    reason = "the test suite starts isolated Web servers through additional constructors"
-)]
 pub(crate) mod http;
 pub(crate) mod instance;
 pub(crate) mod issue;
@@ -39,10 +27,6 @@
 pub(crate) mod search;
 pub(crate) mod serve;
 pub(crate) mod session;
-#[allow(
-    dead_code,
-    reason = "the test suite starts isolated SSH servers through additional constructors"
-)]
 pub(crate) mod ssh;
 pub(crate) mod store;
 pub(crate) mod system;

src/maintenance.rs

Mode 100644100644; object c085ad9b1d032b0af9e21461

@@ -1,4 +1,4 @@
-use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
+use std::sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard};
 
 use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock as AsyncRwLock};
 
@@ -6,6 +6,7 @@
 pub(crate) struct MaintenanceGate {
     synchronous: Arc<RwLock<()>>,
     asynchronous: Arc<AsyncRwLock<()>>,
+    git_operations: Arc<Mutex<()>>,
 }
 
 impl MaintenanceGate {
@@ -17,6 +18,12 @@
 
     pub(crate) async fn mutation_async(&self) -> OwnedRwLockReadGuard<()> {
         self.asynchronous.clone().read_owned().await
+    }
+
+    pub(crate) fn git_operation(&self) -> MutexGuard<'_, ()> {
+        self.git_operations
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner)
     }
 
     pub(crate) fn maintenance(&self) -> RwLockWriteGuard<'_, ()> {

src/policy.rs

Mode 100644100644; object f82876ac41f34ecaabd93da7

@@ -69,28 +69,6 @@
             RepositoryOperation::Maintain,
         )
     }
-
-    #[allow(
-        dead_code,
-        reason = "policy tests verify anonymous catalog behavior independently"
-    )]
-    pub(crate) fn public_repositories(&self) -> Result<Vec<RepositoryRecord>, PolicyError> {
-        Store::open(&self.database)?
-            .active_repositories()?
-            .into_iter()
-            .filter_map(|repository| {
-                let record = RepositoryAuthorizationRecord {
-                    repository,
-                    role: None,
-                };
-                match allows(&record, RepositoryOperation::Read) {
-                    Ok(true) => Some(Ok(record.repository)),
-                    Ok(false) => None,
-                    Err(error) => Some(Err(error)),
-                }
-            })
-            .collect()
-    }
 }
 
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -103,15 +81,10 @@
 }
 
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[allow(
-    dead_code,
-    reason = "subsequent transports use the complete repository operation matrix"
-)]
 pub(crate) enum RepositoryOperation {
     Read,
     Write,
     Maintain,
-    Own,
 }
 
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -140,7 +113,6 @@
             role,
             Some(RepositoryRole::Owner | RepositoryRole::Maintainer)
         )),
-        RepositoryOperation::Own => Ok(role == Some(RepositoryRole::Owner)),
     }
 }
 

src/pull_request.rs

Mode 100644100644; object 301265d5cb5a32b52465894a

@@ -1,6 +1,5 @@
 use std::fs;
 use std::path::{Path, PathBuf};
-use std::sync::{Arc, Mutex};
 
 use gix::hash::ObjectId;
 use thiserror::Error;
@@ -8,15 +7,16 @@
 use crate::auth::{AuthError, validate_username};
 use crate::domain::repository::{RepositoryNameError, validate_slug};
 use crate::git::read::{
-    Comparison, ReadCancellation, ReadError, ReadLimits, RepositoryReadService,
+    Comparison, Mergeability, ReadCancellation, ReadError, ReadLimits, RepositoryReadService,
 };
+use crate::git::receive_pack::{ReceivePackError, recover_incomplete_pushes};
 use crate::git::repository::{GitRepository, GitRepositoryError};
 use crate::maintenance::MaintenanceGate;
 use crate::policy::{PolicyError, RepositoryPolicy};
 use crate::store::{
     GitOperationIntent, NewPullRequestMerge, NewPullRequestRefIntent, NewPullRequestReview,
-    PullRequestDetail, PullRequestRecord, PullRequestRefIntentRecord, PullRequestRevisionRecord,
-    RecordPage, RepositoryRecord, Store, StoreError,
+    PullRequestDetail, PullRequestEdit, PullRequestRecord, PullRequestRefIntentRecord,
+    PullRequestRevisionRecord, RecordPage, RepositoryRecord, Store, StoreError, TimelinePagination,
 };
 use crate::system::{random_lower_hex, unix_timestamp};
 
@@ -29,7 +29,6 @@
 pub(crate) struct PullRequestService {
     database: PathBuf,
     repositories: PathBuf,
-    operations: Arc<Mutex<()>>,
     maintenance: MaintenanceGate,
 }
 
@@ -37,6 +36,35 @@
     pub(crate) detail: PullRequestDetail,
     pub(crate) revision: PullRequestRevisionRecord,
     pub(crate) comparison: Comparison,
+}
+
+#[derive(Clone, Copy)]
+pub(crate) struct ActivityPages {
+    pub(crate) reviews: usize,
+    pub(crate) timeline: usize,
+}
+
+pub(crate) struct NewPullRequest<'a> {
+    pub(crate) owner: &'a str,
+    pub(crate) repository: &'a str,
+    pub(crate) actor: &'a str,
+    pub(crate) title: &'a str,
+    pub(crate) body: &'a str,
+    pub(crate) base_ref: &'a str,
+    pub(crate) head_ref: &'a str,
+}
+
+pub(crate) struct PullRequestReview<'a> {
+    pub(crate) owner: &'a str,
+    pub(crate) repository: &'a str,
+    pub(crate) number: i64,
+    pub(crate) revision: i64,
+    pub(crate) actor: &'a str,
+    pub(crate) kind: &'a str,
+    pub(crate) body: &'a str,
+    pub(crate) path: Option<&'a [u8]>,
+    pub(crate) side: Option<&'a str>,
+    pub(crate) line: Option<i64>,
 }
 
 impl PullRequestService {
@@ -52,34 +80,27 @@
         Self {
             database: database.to_owned(),
             repositories: repositories.to_owned(),
-            operations: Arc::new(Mutex::new(())),
             maintenance,
         }
     }
 
-    #[allow(
-        clippy::too_many_arguments,
-        reason = "opening a pull request requires its repository, content, and two refs"
-    )]
     pub(crate) fn open(
         &self,
-        owner: &str,
-        repository: &str,
-        actor: &str,
-        title: &str,
-        body: &str,
-        base_ref: &str,
-        head_ref: &str,
+        request: &NewPullRequest<'_>,
     ) -> Result<PullRequestRecord, PullRequestError> {
+        let owner = request.owner;
+        let repository = request.repository;
+        let actor = request.actor;
+        let title = request.title;
+        let body = request.body;
+        let base_ref = request.base_ref;
+        let head_ref = request.head_ref;
         validate_context(owner, repository, actor)?;
         validate_content(title, body)?;
         validate_branch(base_ref)?;
         validate_branch(head_ref)?;
         let _maintenance = self.maintenance.mutation();
-        let _operation = self
-            .operations
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        let _operation = self.maintenance.git_operation();
         self.recover_inner()?;
 
         let authorization = Store::open(&self.database)?.repository_authorization(
@@ -129,10 +150,7 @@
             return Err(PullRequestError::Number);
         }
         let _maintenance = self.maintenance.mutation();
-        let _operation = self
-            .operations
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        let _operation = self.maintenance.git_operation();
         self.recover_inner()?;
         let current = Store::open(&self.database)?
             .pull_request(owner, repository, number, Some(actor))?
@@ -181,10 +199,6 @@
             .pull_request)
     }
 
-    #[allow(
-        dead_code,
-        reason = "the pull-request integration test imports this module without the Web list route"
-    )]
     pub(crate) fn list_page(
         &self,
         owner: &str,
@@ -202,10 +216,7 @@
             return Err(PullRequestError::State);
         }
         let _maintenance = self.maintenance.mutation();
-        let _operation = self
-            .operations
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        let _operation = self.maintenance.git_operation();
         self.recover_inner()?;
         let result = Store::open(&self.database)?
             .pull_request_page(owner, repository, actor, state, page, PAGE_SIZE)
@@ -216,10 +227,6 @@
         Ok(result)
     }
 
-    #[allow(
-        dead_code,
-        reason = "the pull-request integration test imports this module without the Web edit route"
-    )]
     pub(crate) fn edit(
         &self,
         owner: &str,
@@ -235,14 +242,18 @@
         }
         validate_content(title, body)?;
         Store::open(&self.database)?
-            .edit_pull_request(owner, repository, number, actor, title, body, timestamp()?)
+            .edit_pull_request(&PullRequestEdit {
+                owner,
+                repository,
+                number,
+                actor,
+                title,
+                body,
+                changed_at: timestamp()?,
+            })
             .map_err(Into::into)
     }
 
-    #[allow(
-        dead_code,
-        reason = "the pull-request integration test imports this module without the Web state route"
-    )]
     pub(crate) fn set_state(
         &self,
         owner: &str,
@@ -263,10 +274,7 @@
             .map_err(Into::into)
     }
 
-    #[allow(
-        dead_code,
-        reason = "integration tests and later non-Web callers read pull requests without comparison"
-    )]
+    #[cfg(test)]
     pub(crate) fn get(
         &self,
         owner: &str,
@@ -283,20 +291,14 @@
             return Err(PullRequestError::Number);
         }
         let _maintenance = self.maintenance.mutation();
-        let _operation = self
-            .operations
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        let _operation = self.maintenance.git_operation();
         self.recover_inner()?;
         Store::open(&self.database)?
             .pull_request(owner, repository, number, actor)
             .map_err(Into::into)
     }
 
-    #[allow(
-        dead_code,
-        reason = "the binary uses the paged Web comparison and integration tests use the full comparison"
-    )]
+    #[cfg(test)]
     pub(crate) fn compare(
         &self,
         owner: &str,
@@ -308,11 +310,6 @@
         self.compare_with_activity(owner, repository, number, revision, actor, None)
     }
 
-    #[allow(
-        clippy::too_many_arguments,
-        dead_code,
-        reason = "the pull-request integration test imports this module without the Web detail route"
-    )]
     pub(crate) fn compare_page(
         &self,
         owner: &str,
@@ -320,8 +317,7 @@
         number: i64,
         revision: Option<i64>,
         actor: Option<&str>,
-        reviews_page: usize,
-        timeline_page: usize,
+        pages: ActivityPages,
     ) -> Result<PullRequestComparison, PullRequestError> {
         self.compare_with_activity(
             owner,
@@ -329,14 +325,10 @@
             number,
             revision,
             actor,
-            Some((reviews_page, timeline_page)),
+            Some((pages.reviews, pages.timeline)),
         )
     }
 
-    #[allow(
-        clippy::too_many_arguments,
-        reason = "the paged comparison includes repository identity and independent activity pages"
-    )]
     fn compare_with_activity(
         &self,
         owner: &str,
@@ -360,10 +352,7 @@
             return Err(PullRequestError::Number);
         }
         let _maintenance = self.maintenance.mutation();
-        let _operation = self
-            .operations
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        let _operation = self.maintenance.git_operation();
         self.recover_inner()?;
         let store = Store::open(&self.database)?;
         let detail = match activity {
@@ -372,9 +361,11 @@
                 repository,
                 number,
                 actor,
-                reviews_page,
-                timeline_page,
-                PAGE_SIZE,
+                TimelinePagination {
+                    primary_page: reviews_page,
+                    timeline_page,
+                    page_size: PAGE_SIZE,
+                },
             )?,
             None => store.pull_request(owner, repository, number, actor)?,
         };
@@ -407,33 +398,27 @@
         })
     }
 
-    #[allow(
-        clippy::too_many_arguments,
-        reason = "a review action includes its repository, revision, content, and optional line anchor"
-    )]
     pub(crate) fn review(
         &self,
-        owner: &str,
-        repository: &str,
-        number: i64,
-        revision_number: i64,
-        actor: &str,
-        kind: &str,
-        body: &str,
-        path: Option<&[u8]>,
-        side: Option<&str>,
-        line: Option<i64>,
+        review: &PullRequestReview<'_>,
     ) -> Result<String, PullRequestError> {
+        let owner = review.owner;
+        let repository = review.repository;
+        let number = review.number;
+        let revision_number = review.revision;
+        let actor = review.actor;
+        let kind = review.kind;
+        let body = review.body;
+        let path = review.path;
+        let side = review.side;
+        let line = review.line;
         validate_context(owner, repository, actor)?;
         if number < 1 || revision_number < 1 {
             return Err(PullRequestError::Number);
         }
         validate_review(kind, body, path, side, line)?;
         let _maintenance = self.maintenance.mutation();
-        let _operation = self
-            .operations
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        let _operation = self.maintenance.git_operation();
         self.recover_inner()?;
         let detail =
             Store::open(&self.database)?.pull_request(owner, repository, number, Some(actor))?;
@@ -514,10 +499,7 @@
             return Err(PullRequestError::MergeMethod);
         }
         let _maintenance = self.maintenance.mutation();
-        let _operation = self
-            .operations
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        let _operation = self.maintenance.git_operation();
         self.recover_inner()?;
         let detail =
             Store::open(&self.database)?.pull_request(owner, repository, number, Some(actor))?;
@@ -549,8 +531,8 @@
             detail.pull_request.head_ref, detail.pull_request.title
         );
         let new_target = match (method, comparison.mergeability) {
-            ("fast-forward", crate::git::read::Mergeability::FastForward) => head,
-            ("merge-commit", crate::git::read::Mergeability::Clean) => {
+            ("fast-forward", Mergeability::FastForward) => head,
+            ("merge-commit", Mergeability::Clean) => {
                 git.prepare_merge_commit(base, head, actor, created_at, &merge_message)?
             }
             _ => return Err(PullRequestError::Mergeability),
@@ -627,40 +609,10 @@
             .pull_request)
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile the service without the Web list route"
-    )]
-    pub(crate) fn list(
-        &self,
-        owner: &str,
-        repository: &str,
-        actor: Option<&str>,
-    ) -> Result<(crate::store::RepositoryRecord, Vec<PullRequestRecord>, bool), PullRequestError>
-    {
-        validate_username(owner)?;
-        validate_slug(repository)?;
-        if let Some(actor) = actor {
-            validate_username(actor)?;
-        }
-        let _maintenance = self.maintenance.mutation();
-        let _operation = self
-            .operations
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
-        self.recover_inner()?;
-        Store::open(&self.database)?
-            .pull_requests(owner, repository, actor)
-            .map_err(Into::into)
-    }
-
     pub(crate) fn recover(&self) -> Result<(), PullRequestError> {
         let _maintenance = self.maintenance.mutation();
-        let _operation = self
-            .operations
-            .lock()
-            .unwrap_or_else(std::sync::PoisonError::into_inner);
-        crate::git::receive_pack::recover_incomplete_pushes(&self.database)?;
+        let _operation = self.maintenance.git_operation();
+        recover_incomplete_pushes(&self.database)?;
         self.recover_inner()
     }
 
@@ -855,10 +807,6 @@
     #[error("pull-request number is not valid")]
     Number,
     #[error("pull-request list state is not valid")]
-    #[allow(
-        dead_code,
-        reason = "the pull-request integration test imports this module without Web list state"
-    )]
     State,
     #[error("pull-request revision does not exist")]
     Revision,
@@ -891,7 +839,7 @@
     #[error(transparent)]
     Policy(#[from] PolicyError),
     #[error(transparent)]
-    ReceivePack(#[from] crate::git::receive_pack::ReceivePackError),
+    ReceivePack(#[from] ReceivePackError),
     #[error("cannot create a random pull-request ID")]
     Random,
     #[error("the system clock is before the Unix epoch")]

src/repository.rs

Mode 100644100644; object 9779eb1d02c122dc30248372

@@ -9,8 +9,9 @@
 use crate::git::repository::{GitRepository, GitRepositoryError};
 use crate::maintenance::MaintenanceGate;
 use crate::store::{
-    HomeRepositoryRecord, NewAuditEvent, NewDefaultBranchIntent, NewRepository, RepositoryOrigin,
-    RepositoryRecord, RepositorySettings, Store, StoreError,
+    HomeRepositoryRecord, NewAuditEvent, NewDefaultBranchIntent, NewRepository,
+    RepositoryCollaboratorUpdate, RepositoryOrigin, RepositoryRecord, RepositorySettings,
+    RepositorySettingsUpdate, Store, StoreError,
 };
 use crate::system::{random_lower_hex, unix_timestamp};
 
@@ -103,6 +104,7 @@
         validate_slug(repository)?;
         validate_username(actor)?;
         let _maintenance = self.maintenance.mutation();
+        let _operation = self.maintenance.git_operation();
         let changed_at = timestamp()?;
         let intent_id = random_id()?;
         let mut store = Store::open(&self.database)?;
@@ -132,6 +134,7 @@
 
     pub(crate) fn recover(&self) -> Result<(), RepositoryServiceError> {
         let _maintenance = self.maintenance.mutation();
+        let _operation = self.maintenance.git_operation();
         let mut store = Store::open(&self.database)?;
         for intent in store.incomplete_repository_default_branches()? {
             let git =
@@ -162,15 +165,15 @@
         validate_username(actor)?;
         validate_description(description)?;
         let mut store = Store::open(&self.database)?;
-        store.update_repository_settings(
+        store.update_repository_settings(&RepositorySettingsUpdate {
             owner,
-            repository,
+            slug: repository,
             actor,
             description,
             visibility,
-            timestamp()?,
-            &random_id()?,
-        )?;
+            changed_at: timestamp()?,
+            correlation_id: &random_id()?,
+        })?;
         Ok(())
     }
 
@@ -187,15 +190,15 @@
         validate_username(actor)?;
         validate_username(username)?;
         let mut store = Store::open(&self.database)?;
-        store.update_repository_collaborator(
+        store.update_repository_collaborator(&RepositoryCollaboratorUpdate {
             owner,
-            repository,
+            slug: repository,
             actor,
             username,
             role,
-            timestamp()?,
-            &random_id()?,
-        )?;
+            changed_at: timestamp()?,
+            correlation_id: &random_id()?,
+        })?;
         Ok(())
     }
 
@@ -286,13 +289,15 @@
         let target = format!("{owner}/{slug}");
         let result = self.create_inner(
             &mut store,
-            actor,
-            owner,
-            slug,
-            object_format,
-            object_format_name,
-            correlation_id,
-            created_at,
+            &RepositoryCreation {
+                actor,
+                owner,
+                slug,
+                object_format,
+                object_format_name,
+                correlation_id,
+                created_at,
+            },
         );
         if result.is_err() {
             store.record_audit_event(&NewAuditEvent {
@@ -307,18 +312,18 @@
         result
     }
 
-    #[allow(clippy::too_many_arguments)]
     fn create_inner(
         &self,
         store: &mut Store,
-        actor: &str,
-        owner: &str,
-        slug: &str,
-        object_format: Kind,
-        object_format_name: &str,
-        correlation_id: &str,
-        created_at: i64,
+        creation: &RepositoryCreation<'_>,
     ) -> Result<RepositoryRecord, RepositoryServiceError> {
+        let actor = creation.actor;
+        let owner = creation.owner;
+        let slug = creation.slug;
+        let object_format = creation.object_format;
+        let object_format_name = creation.object_format_name;
+        let correlation_id = creation.correlation_id;
+        let created_at = creation.created_at;
         let id = random_id()?;
         let pending_path = self.root.join(format!(".pending-{id}.git"));
         let final_path = self.root.join(format!("{id}.git"));
@@ -378,6 +383,16 @@
             archived_at: None,
         })
     }
+}
+
+struct RepositoryCreation<'a> {
+    actor: &'a str,
+    owner: &'a str,
+    slug: &'a str,
+    object_format: Kind,
+    object_format_name: &'a str,
+    correlation_id: &'a str,
+    created_at: i64,
 }
 
 fn random_id() -> Result<String, RepositoryServiceError> {

src/serve.rs

Mode 100644100644; object 241a9c2a9b79b63c8c32c660

@@ -86,6 +86,7 @@
             instance_dir: config.instance_dir.clone(),
             http_clone_base,
             ssh_clone_base,
+            trusted_proxy: config.http_trusted_proxy,
             max_request_bytes: usize::try_from(config.max_request_bytes)
                 .map_err(|_| ServeError::RequestLimit)?,
             max_connections: usize::try_from(config.max_connections)

src/session.rs

Mode 100644100644; object 471c521c801f5427acfe2c43

@@ -82,10 +82,6 @@
         Ok(IssuedLoginApproval { secret, login_csrf })
     }
 
-    #[allow(
-        dead_code,
-        reason = "the Web session integration test imports this module without account routes"
-    )]
     pub(crate) fn issue_account_approval(
         &self,
         username: &str,
@@ -233,10 +229,6 @@
             .map_err(Into::into)
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile login without HTTP handlers"
-    )]
     pub(crate) fn record_login_failure(
         &self,
         username: &str,
@@ -323,7 +315,6 @@
     Random,
     #[error("system clock is before the Unix epoch")]
     Clock,
-    #[allow(dead_code, reason = "integration tests use the service without HTTP")]
     #[error("Web login service is not available")]
     Unavailable,
 }

src/ssh.rs

Mode 100644100644; object 8af371e0244a7de0ecf62401

@@ -5,6 +5,7 @@
 use std::sync::{Arc, RwLock};
 use std::time::{Duration, Instant};
 
+#[cfg(test)]
 use rand::rng;
 use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Server, Session};
 use russh::{Channel, ChannelId, MethodKind, MethodSet, Preferred, Pty};
@@ -12,17 +13,17 @@
 use thiserror::Error;
 use tokio::io::AsyncWriteExt;
 use tokio::net::TcpListener;
-use tokio::sync::{OwnedSemaphorePermit, Semaphore, oneshot};
+use tokio::sync::{OwnedRwLockReadGuard, OwnedSemaphorePermit, Semaphore, oneshot};
 use tokio::task::JoinHandle;
 
 use crate::auth::SshPublicKey;
 use crate::git::packetline::{MAX_REQUEST_BYTES, Packet, decode, encode_data, first_flush_end};
-use crate::git::receive_pack::{ReceivePack, ReceivePackError};
+use crate::git::receive_pack::{ReceivePack, ReceivePackError, recover_incomplete_pushes};
 use crate::git::transport::{GitRepositories, GitSshService, RepositoryPathError};
 use crate::git::upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
 use crate::issue::{IssueError, IssueService, MAX_BODY_BYTES, MAX_TITLE_BYTES};
 use crate::policy::RepositoryOperation;
-use crate::pull_request::{PullRequestError, PullRequestService};
+use crate::pull_request::{NewPullRequest, PullRequestError, PullRequestService};
 use crate::rate_limit::AttemptLimiter;
 use crate::repository::{RepositoryService, RepositoryServiceError};
 use crate::store::{Store, StoreError};
@@ -101,9 +102,11 @@
 }
 
 pub(crate) struct RunningSshServer {
+    #[cfg(test)]
     address: SocketAddr,
     handle: russh::server::RunningServerHandle,
     task: JoinHandle<std::io::Result<()>>,
+    #[cfg(test)]
     audit: Arc<RequestAudit>,
 }
 
@@ -119,6 +122,7 @@
 }
 
 impl AuthorizedSshKeys {
+    #[cfg(test)]
     pub(crate) fn new(keys: &[SshPublicKey]) -> Self {
         Self {
             keys: Arc::new(RwLock::new(key_map(keys))),
@@ -146,6 +150,7 @@
     }
 }
 
+#[cfg(test)]
 fn key_map(keys: &[SshPublicKey]) -> HashMap<PublicKey, SshIdentity> {
     keys.iter()
         .map(|key| {
@@ -176,6 +181,7 @@
 }
 
 impl RunningSshServer {
+    #[cfg(test)]
     pub(crate) async fn start(
         address: SocketAddr,
         authorized_keys: &[SshPublicKey],
@@ -184,21 +190,13 @@
         Self::start_inner(address, authorized_keys, &[], None, host_key).await
     }
 
+    #[cfg(test)]
     pub(crate) async fn start_with_git(
         address: SocketAddr,
         authorized_keys: &[SshPublicKey],
         repositories: GitRepositories,
     ) -> Result<Self, SshServerError> {
         let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
-        Self::start_inner(address, authorized_keys, &[], Some(repositories), host_key).await
-    }
-
-    pub(crate) async fn start_with_git_and_host_key(
-        address: SocketAddr,
-        authorized_keys: &[SshPublicKey],
-        repositories: GitRepositories,
-        host_key: PrivateKey,
-    ) -> Result<Self, SshServerError> {
         Self::start_inner(address, authorized_keys, &[], Some(repositories), host_key).await
     }
 
@@ -227,6 +225,7 @@
         .await
     }
 
+    #[cfg(test)]
     pub(crate) async fn start_with_git_writes(
         address: SocketAddr,
         authorized_keys: &[SshPublicKey],
@@ -245,6 +244,7 @@
         .await
     }
 
+    #[cfg(test)]
     async fn start_inner(
         address: SocketAddr,
         authorized_keys: &[SshPublicKey],
@@ -276,6 +276,7 @@
         runtime: SshRuntime,
     ) -> Result<Self, SshServerError> {
         let listener = TcpListener::bind(address).await?;
+        #[cfg(test)]
         let address = listener.local_addr()?;
         let mut methods = MethodSet::empty();
         methods.push(MethodKind::PublicKey);
@@ -329,21 +330,26 @@
         });
         let handle = handle_receiver.await.map_err(|_| SshServerError::Startup)?;
         Ok(Self {
+            #[cfg(test)]
             address,
             handle,
             task,
+            #[cfg(test)]
             audit,
         })
     }
 
+    #[cfg(test)]
     pub(crate) fn address(&self) -> SocketAddr {
         self.address
     }
 
+    #[cfg(test)]
     pub(crate) fn audit(&self) -> RequestAuditSnapshot {
         self.audit.snapshot()
     }
 
+    #[cfg(test)]
     pub(crate) async fn shutdown(self) -> Result<(), SshServerError> {
         self.handle.shutdown("tit test shutdown".to_owned());
         self.task.await.map_err(|_| SshServerError::Join)??;
@@ -374,12 +380,10 @@
         .push_database()
         .ok_or_else(|| SshServerError::Recovery("push storage is not configured".to_owned()))?
         .to_owned();
-    tokio::task::spawn_blocking(move || {
-        crate::git::receive_pack::recover_incomplete_pushes(&database)
-    })
-    .await
-    .map_err(|_| SshServerError::Join)?
-    .map_err(|error| SshServerError::Recovery(error.to_string()))
+    tokio::task::spawn_blocking(move || recover_incomplete_pushes(&database))
+        .await
+        .map_err(|_| SshServerError::Join)?
+        .map_err(|error| SshServerError::Recovery(error.to_string()))
 }
 
 #[derive(Debug, Error)]
@@ -499,7 +503,7 @@
 }
 
 struct ReceiveChannel {
-    service: ReceivePack,
+    lease: ReceiveLease,
     owner: String,
     repository: String,
     identity: SshIdentity,
@@ -513,6 +517,11 @@
     last_activity: Instant,
     transferred: u64,
     _global_permit: OwnedSemaphorePermit,
+}
+
+struct ReceiveLease {
+    service: ReceivePack,
+    _maintenance: OwnedRwLockReadGuard<()>,
 }
 
 impl UploadChannel {
@@ -936,18 +945,23 @@
                             identity,
                             public_key,
                             global_permit,
+                            maintenance,
                         } = *receive;
                         let started_at = Instant::now();
                         let advertisement_bytes =
                             u64::try_from(advertisement.len()).unwrap_or(u64::MAX);
                         session.data(channel, advertisement)?;
-                        let pack = tokio::fs::File::create(service.incoming_pack()).await;
+                        let lease = ReceiveLease {
+                            service: *service,
+                            _maintenance: maintenance,
+                        };
+                        let pack = tokio::fs::File::create(lease.service.incoming_pack()).await;
                         match pack {
                             Ok(pack) => {
                                 self.exec_channels.insert(
                                     channel,
                                     ExecChannel::Receive(Box::new(ReceiveChannel {
-                                        service: *service,
+                                        lease,
                                         owner,
                                         repository,
                                         identity,
@@ -1048,7 +1062,7 @@
                 if receive_data(&mut git, data).await.is_err() {
                     fail_git_channel(channel, session)?;
                 } else if git.commands_complete
-                    && matches!(git.service.expects_pack(&git.commands), Ok(false))
+                    && matches!(git.lease.service.expects_pack(&git.commands), Ok(false))
                 {
                     send_receive_result(
                         channel,
@@ -1323,6 +1337,7 @@
         .ok_or(RepositoryCommandError::Unavailable)?
         .to_owned();
     let root = repositories.repository_root().to_owned();
+    let maintenance = repositories.maintenance_gate();
     let permit = repositories
         .blocking_permit()
         .await
@@ -1331,7 +1346,7 @@
     let correlation_id = format!("{:032x}", rand::random::<u128>());
     tokio::task::spawn_blocking(move || {
         let _permit = permit;
-        RepositoryService::new(&database, &root)
+        RepositoryService::new_with_gate(&database, &root, maintenance)
             .create_for_account(
                 &actor,
                 &command.slug,
@@ -2146,13 +2161,14 @@
         .ok_or(PullRequestCommandError::Unavailable)?
         .to_owned();
     let root = repositories.repository_root().to_owned();
+    let maintenance = repositories.maintenance_gate();
     let permit = repositories
         .blocking_permit()
         .await
         .map_err(|_| PullRequestCommandError::Unavailable)?;
     tokio::task::spawn_blocking(move || {
         let _permit = permit;
-        PullRequestService::new(&database, &root)
+        PullRequestService::new_with_gate(&database, &root, maintenance)
             .list_page(
                 &command.owner,
                 &command.repository,
@@ -2184,22 +2200,23 @@
         .ok_or(PullRequestCommandError::Unavailable)?
         .to_owned();
     let root = repositories.repository_root().to_owned();
+    let maintenance = repositories.maintenance_gate();
     let permit = repositories
         .blocking_permit()
         .await
         .map_err(|_| PullRequestCommandError::Unavailable)?;
     tokio::task::spawn_blocking(move || {
         let _permit = permit;
-        PullRequestService::new(&database, &root)
-            .open(
-                &command.owner,
-                &command.repository,
-                &command.actor,
-                &title,
-                &body,
-                &command.base,
-                &command.head,
-            )
+        PullRequestService::new_with_gate(&database, &root, maintenance)
+            .open(&NewPullRequest {
+                owner: &command.owner,
+                repository: &command.repository,
+                actor: &command.actor,
+                title: &title,
+                body: &body,
+                base_ref: &command.base,
+                head_ref: &command.head,
+            })
             .map(|pull_request| PullRequestMutationResult {
                 owner: command.owner,
                 repository: command.repository,
@@ -2225,13 +2242,14 @@
         .ok_or(PullRequestCommandError::Unavailable)?
         .to_owned();
     let root = repositories.repository_root().to_owned();
+    let maintenance = repositories.maintenance_gate();
     let permit = repositories
         .blocking_permit()
         .await
         .map_err(|_| PullRequestCommandError::Unavailable)?;
     tokio::task::spawn_blocking(move || {
         let _permit = permit;
-        PullRequestService::new(&database, &root)
+        PullRequestService::new_with_gate(&database, &root, maintenance)
             .set_state(
                 &command.owner,
                 &command.repository,
@@ -2547,6 +2565,7 @@
                     return Ok(None);
                 };
                 let uses_policy = repositories.uses_policy();
+                let maintenance = repositories.mutation_permit().await;
                 let permit = repositories.blocking_permit().await?;
                 let service = tokio::task::spawn_blocking(move || {
                     let _permit = permit;
@@ -2571,6 +2590,7 @@
                             identity,
                             public_key,
                             global_permit,
+                            maintenance,
                         },
                     )))
                 })
@@ -2621,6 +2641,7 @@
     identity: SshIdentity,
     public_key: PublicKey,
     global_permit: OwnedSemaphorePermit,
+    maintenance: OwnedRwLockReadGuard<()>,
 }
 
 async fn receive_data(git: &mut ReceiveChannel, data: &[u8]) -> Result<(), ()> {
@@ -2654,7 +2675,7 @@
     git: Box<ReceiveChannel>,
 ) -> ReceiveResult {
     let ReceiveChannel {
-        mut service,
+        mut lease,
         owner,
         repository,
         identity,
@@ -2674,11 +2695,9 @@
     let repositories = repositories?;
     let push_permit = repositories.push_permit().await.ok()?;
     let permit = repositories.blocking_permit().await.ok()?;
-    let maintenance = repositories.mutation_permit().await;
     tokio::task::spawn_blocking(move || {
         let _permit = permit;
         let _push_permit = push_permit;
-        let _maintenance = maintenance;
         if authorized_keys.identity(&public_key).as_ref() != Some(&identity)
             || !repositories.authorize(
                 &identity.username,
@@ -2689,14 +2708,14 @@
         {
             return None;
         }
-        Some(match service.finish(&commands) {
+        Some(match lease.service.finish(&commands) {
             Ok(response) => Ok(response),
             Err(error) => {
-                let response = service.rejection_response(&commands, &error);
-                match service.record_rejection() {
+                let response = lease.service.rejection_response(&commands, &error);
+                match lease.service.record_rejection() {
                     Ok(()) => Err((error, response)),
                     Err(audit_error) => {
-                        let response = service.rejection_response(&commands, &audit_error);
+                        let response = lease.service.rejection_response(&commands, &audit_error);
                         Err((audit_error, response))
                     }
                 }
@@ -2818,12 +2837,12 @@
 }
 
 impl RequestAudit {
+    #[cfg(test)]
     fn snapshot(&self) -> RequestAuditSnapshot {
         RequestAuditSnapshot {
             accepted_env: self.accepted_env.load(Ordering::Relaxed),
             rejected_env: self.rejected_env.load(Ordering::Relaxed),
             accepted_exec: self.accepted_exec.load(Ordering::Relaxed),
-            rejected_exec: self.rejected_exec.load(Ordering::Relaxed),
             rejected_shell: self.rejected_shell.load(Ordering::Relaxed),
             rejected_pty: self.rejected_pty.load(Ordering::Relaxed),
             rejected_agent: self.rejected_agent.load(Ordering::Relaxed),
@@ -2832,12 +2851,12 @@
     }
 }
 
+#[cfg(test)]
 #[derive(Clone, Copy, Debug, Default)]
 pub(crate) struct RequestAuditSnapshot {
     pub(crate) accepted_env: usize,
     pub(crate) rejected_env: usize,
     pub(crate) accepted_exec: usize,
-    pub(crate) rejected_exec: usize,
     pub(crate) rejected_shell: usize,
     pub(crate) rejected_pty: usize,
     pub(crate) rejected_agent: usize,

src/store/error.rs

Mode 100644; object 3428ef2f98ad

@@ -1,0 +1,112 @@
+use std::path::PathBuf;
+
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub(crate) enum StoreError {
+    #[error("SQLite error: {0}")]
+    Sqlite(#[from] rusqlite::Error),
+    #[error("cannot read migration path {path}: {source}")]
+    MigrationFilesystem {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("database schema version {0} is newer than this executable")]
+    NewerSchema(i64),
+    #[error("database schema version is {actual}, expected {expected}")]
+    SchemaVersion { expected: i64, actual: i64 },
+    #[error("database integrity check failed: {0}")]
+    Integrity(String),
+    #[error("SQLite setting {name} is {actual}, expected {expected}")]
+    Setting {
+        name: &'static str,
+        expected: &'static str,
+        actual: String,
+    },
+    #[error("Git operation intent {0} is not in the required state")]
+    IntentState(String),
+    #[error("the instance already has an administrator")]
+    AlreadyInitialized,
+    #[error("account does not exist or is not active: {0}")]
+    AccountNotFound(String),
+    #[error("username is not available: {0}")]
+    UsernameUnavailable(String),
+    #[error("signup invitation is invalid, expired, or already used")]
+    InvalidInvitation,
+    #[error("recovery credential is invalid")]
+    InvalidRecovery,
+    #[error("SSH public key already exists")]
+    KeyExists,
+    #[error("active SSH public key does not exist")]
+    KeyNotFound,
+    #[error("an account must have at least one active SSH public key")]
+    LastKey,
+    #[error("login identity does not exist or is not active")]
+    LoginIdentity,
+    #[error("too many login challenges are active")]
+    LoginNonceLimit,
+    #[error("login challenge is invalid, expired, or already used")]
+    InvalidLoginChallenge,
+    #[error("SSH login approval is invalid, expired, or already used")]
+    InvalidLoginApproval,
+    #[error("SSH login approval is waiting for SSH authentication")]
+    LoginApprovalPending,
+    #[error("Web session is invalid or expired")]
+    InvalidSession,
+    #[error("repository does not exist: {0}/{1}")]
+    RepositoryNotFound(String, String),
+    #[error("repository already exists: {0}/{1}")]
+    RepositoryExists(String, String),
+    #[error("repository ID already exists")]
+    RepositoryIdentifierCollision,
+    #[error("repository is already archived: {0}/{1}")]
+    RepositoryArchived(String, String),
+    #[error("repository visibility is not valid")]
+    InvalidRepositoryVisibility,
+    #[error("repository default branch is not valid")]
+    InvalidDefaultBranch,
+    #[error("repository default-branch intent {0} is not in the required state")]
+    DefaultBranchIntentState(String),
+    #[error("collaborator role is not valid")]
+    InvalidCollaboratorRole,
+    #[error("repository owner cannot be a collaborator")]
+    OwnerCollaborator,
+    #[error("collaborator account does not exist or is not active: {0}")]
+    CollaboratorNotFound(String),
+    #[error("repository event page limit is too large")]
+    EventLimit,
+    #[error("stored Git reference event is malformed")]
+    EventPayload,
+    #[error("audit event page limit is too large")]
+    AuditLimit,
+    #[error("issue does not exist: {0}/{1}#{2}")]
+    IssueNotFound(String, String, i64),
+    #[error("issue access is not authorized")]
+    IssueDenied,
+    #[error("issue is hidden by repository access policy")]
+    IssueHidden,
+    #[error("issue state is already {0}")]
+    IssueState(String),
+    #[error("pull request does not exist: {0}/{1}#{2}")]
+    PullRequestNotFound(String, String, i64),
+    #[error("pull-request access is not authorized")]
+    PullRequestDenied,
+    #[error("pull request is hidden by repository access policy")]
+    PullRequestHidden,
+    #[error("pull request is not open")]
+    PullRequestState,
+    #[error("pull-request revision does not exist")]
+    PullRequestRevisionNotFound,
+    #[error("pull-request review anchor does not match its revision")]
+    PullRequestReviewAnchor,
+    #[error("pull-request ref intent {0} is not in the required state")]
+    PullRequestIntentState(String),
+    #[error("repository watch access is not authorized")]
+    WatchDenied,
+    #[error("feed token is invalid or revoked")]
+    FeedTokenNotFound,
+    #[error("an account cannot have more than one active feed token")]
+    FeedTokenLimit,
+    #[error("feed token scope is not valid")]
+    InvalidFeedScope,
+}

src/store/event.rs

Mode 100644100644; object 66e8fed3be22029ce2866935

@@ -115,21 +115,21 @@
     }
 }
 
-#[allow(
-    clippy::too_many_arguments,
-    reason = "the event payload stores the complete immutable review anchor"
-)]
+pub(super) struct PullRequestReview<'a> {
+    pub(super) pull_request_id: &'a str,
+    pub(super) number: i64,
+    pub(super) review_id: &'a str,
+    pub(super) revision: i64,
+    pub(super) body: &'a str,
+    pub(super) commit_object_id: Option<&'a str>,
+    pub(super) path: Option<&'a [u8]>,
+    pub(super) side: Option<&'a str>,
+    pub(super) line: Option<i64>,
+}
+
 pub(super) fn pull_request_review(
     kind: EventKind,
-    pull_request_id: &str,
-    number: i64,
-    review_id: &str,
-    revision: i64,
-    body: &str,
-    commit_object_id: Option<&str>,
-    path: Option<&[u8]>,
-    side: Option<&str>,
-    line: Option<i64>,
+    review: &PullRequestReview<'_>,
 ) -> VersionedEvent {
     debug_assert!(matches!(
         kind,
@@ -142,35 +142,32 @@
         kind,
         payload: json!({
             "version": PAYLOAD_VERSION,
-            "pull_request_id": pull_request_id,
-            "number": number,
-            "review_id": review_id,
-            "revision": revision,
-            "body": body,
-            "commit_object_id": commit_object_id,
-            "path_hex": path.map(encode_lower_hex),
-            "side": side,
-            "line": line,
+            "pull_request_id": review.pull_request_id,
+            "number": review.number,
+            "review_id": review.review_id,
+            "revision": review.revision,
+            "body": review.body,
+            "commit_object_id": review.commit_object_id,
+            "path_hex": review.path.map(encode_lower_hex),
+            "side": review.side,
+            "line": review.line,
         })
         .to_string(),
     }
 }
 
-#[allow(
-    clippy::too_many_arguments,
-    reason = "the event payload stores the complete immutable pull-request revision"
-)]
-pub(super) fn pull_request(
-    kind: EventKind,
-    pull_request_id: &str,
-    number: i64,
-    revision: i64,
-    title: &str,
-    base_ref: &str,
-    head_ref: &str,
-    base_object_id: &str,
-    head_object_id: &str,
-) -> VersionedEvent {
+pub(super) struct PullRequestRevision<'a> {
+    pub(super) pull_request_id: &'a str,
+    pub(super) number: i64,
+    pub(super) revision: i64,
+    pub(super) title: &'a str,
+    pub(super) base_ref: &'a str,
+    pub(super) head_ref: &'a str,
+    pub(super) base_object_id: &'a str,
+    pub(super) head_object_id: &'a str,
+}
+
+pub(super) fn pull_request(kind: EventKind, revision: &PullRequestRevision<'_>) -> VersionedEvent {
     debug_assert!(matches!(
         kind,
         EventKind::PullRequestCreated | EventKind::PullRequestRevised
@@ -179,14 +176,14 @@
         kind,
         payload: json!({
             "version": PAYLOAD_VERSION,
-            "pull_request_id": pull_request_id,
-            "number": number,
-            "revision": revision,
-            "title": title,
-            "base_ref": base_ref,
-            "head_ref": head_ref,
-            "base_object_id": base_object_id,
-            "head_object_id": head_object_id,
+            "pull_request_id": revision.pull_request_id,
+            "number": revision.number,
+            "revision": revision.revision,
+            "title": revision.title,
+            "base_ref": revision.base_ref,
+            "head_ref": revision.head_ref,
+            "base_object_id": revision.base_object_id,
+            "head_object_id": revision.head_object_id,
         })
         .to_string(),
     }

src/store/mod.rs

Mode 100644100644; object ef2edd91c36440324df2ab4d

@@ -8,194 +8,31 @@
 use rusqlite::types::ValueRef;
 use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
 use serde::Serialize;
-use thiserror::Error;
 
 use crate::codec::encode_lower_hex;
+
+mod error;
 mod event;
+mod schema;
+
+pub(crate) use error::StoreError;
+use schema::{MIGRATIONS, VERSION as SCHEMA_VERSION};
 
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
 const MAX_ACTIVE_FEED_TOKENS: i64 = 1;
-const SCHEMA_VERSION: i64 = 24;
-#[allow(
-    dead_code,
-    reason = "the integration test imports this module without the CLI operation"
-)]
 pub(crate) const DATABASE_FILE: &str = "tit.sqlite3";
-#[allow(
-    dead_code,
-    reason = "some integration test crates import storage without migration operations"
-)]
-const MIGRATIONS: [&str; 24] = [
-    include_str!("migrations/001_initial.sql"),
-    include_str!("migrations/002_state.sql"),
-    include_str!("migrations/003_git_intents.sql"),
-    include_str!("migrations/004_identity.sql"),
-    include_str!("migrations/005_repository.sql"),
-    include_str!("migrations/006_repository_events.sql"),
-    include_str!("migrations/007_account_lifecycle.sql"),
-    include_str!("migrations/008_web_sessions.sql"),
-    include_str!("migrations/009_repository_authorization.sql"),
-    include_str!("migrations/010_audit_history.sql"),
-    include_str!("migrations/011_domain_events.sql"),
-    include_str!("migrations/012_issues.sql"),
-    include_str!("migrations/013_watches.sql"),
-    include_str!("migrations/014_feed_tokens.sql"),
-    include_str!("migrations/015_pull_requests.sql"),
-    include_str!("migrations/016_pull_request_reviews.sql"),
-    include_str!("migrations/017_pull_request_merges.sql"),
-    include_str!("migrations/018_streamlined_login.sql"),
-    include_str!("migrations/019_product_reduction.sql"),
-    include_str!("migrations/020_repository_profiles.sql"),
-    include_str!("migrations/021_pull_request_lifecycle.sql"),
-    include_str!("migrations/022_account_key_management.sql"),
-    include_str!("migrations/023_default_branch.sql"),
-    include_str!("migrations/024_default_branch_intents.sql"),
-];
-
-#[allow(
-    dead_code,
-    reason = "integration tests compile storage without every account operation"
-)]
-#[derive(Debug, Error)]
-pub(crate) enum StoreError {
-    #[error("SQLite error: {0}")]
-    Sqlite(#[from] rusqlite::Error),
-    #[error("cannot read migration path {path}: {source}")]
-    MigrationFilesystem {
-        path: PathBuf,
-        source: std::io::Error,
-    },
-    #[allow(
-        dead_code,
-        reason = "some integration test crates import storage without schema-version rejection"
-    )]
-    #[error("database schema version {0} is newer than this executable")]
-    NewerSchema(i64),
-    #[allow(
-        dead_code,
-        reason = "the integration test imports this module without the CLI operation"
-    )]
-    #[error("database schema version is {actual}, expected {expected}")]
-    SchemaVersion { expected: i64, actual: i64 },
-    #[error("database integrity check failed: {0}")]
-    Integrity(String),
-    #[error("SQLite setting {name} is {actual}, expected {expected}")]
-    Setting {
-        name: &'static str,
-        expected: &'static str,
-        actual: String,
-    },
-    #[error("Git operation intent {0} is not in the required state")]
-    IntentState(String),
-    #[error("the instance already has an administrator")]
-    AlreadyInitialized,
-    #[error("account does not exist or is not active: {0}")]
-    AccountNotFound(String),
-    #[error("username is not available: {0}")]
-    UsernameUnavailable(String),
-    #[error("signup invitation is invalid, expired, or already used")]
-    InvalidInvitation,
-    #[error("recovery credential is invalid")]
-    InvalidRecovery,
-    #[error("SSH public key already exists")]
-    KeyExists,
-    #[error("active SSH public key does not exist")]
-    KeyNotFound,
-    #[error("an account must have at least one active SSH public key")]
-    LastKey,
-    #[error("login identity does not exist or is not active")]
-    LoginIdentity,
-    #[error("too many login challenges are active")]
-    LoginNonceLimit,
-    #[error("login challenge is invalid, expired, or already used")]
-    InvalidLoginChallenge,
-    #[error("SSH login approval is invalid, expired, or already used")]
-    InvalidLoginApproval,
-    #[error("SSH login approval is waiting for SSH authentication")]
-    LoginApprovalPending,
-    #[error("Web session is invalid or expired")]
-    InvalidSession,
-    #[error("repository does not exist: {0}/{1}")]
-    RepositoryNotFound(String, String),
-    #[error("repository already exists: {0}/{1}")]
-    RepositoryExists(String, String),
-    #[error("repository ID already exists")]
-    RepositoryIdentifierCollision,
-    #[error("repository is already archived: {0}/{1}")]
-    RepositoryArchived(String, String),
-    #[error("repository visibility is not valid")]
-    InvalidRepositoryVisibility,
-    #[error("repository default branch is not valid")]
-    InvalidDefaultBranch,
-    #[error("repository default-branch intent {0} is not in the required state")]
-    DefaultBranchIntentState(String),
-    #[error("collaborator role is not valid")]
-    InvalidCollaboratorRole,
-    #[error("repository owner cannot be a collaborator")]
-    OwnerCollaborator,
-    #[error("collaborator account does not exist or is not active: {0}")]
-    CollaboratorNotFound(String),
-    #[allow(
-        dead_code,
-        reason = "some integration tests import storage without public event pages"
-    )]
-    #[error("repository event page limit is too large")]
-    EventLimit,
-    #[error("stored Git reference event is malformed")]
-    EventPayload,
-    #[error("audit event page limit is too large")]
-    AuditLimit,
-    #[error("issue does not exist: {0}/{1}#{2}")]
-    IssueNotFound(String, String, i64),
-    #[error("issue access is not authorized")]
-    IssueDenied,
-    #[error("issue is hidden by repository access policy")]
-    IssueHidden,
-    #[error("issue state is already {0}")]
-    IssueState(String),
-    #[error("pull request does not exist: {0}/{1}#{2}")]
-    PullRequestNotFound(String, String, i64),
-    #[error("pull-request access is not authorized")]
-    PullRequestDenied,
-    #[error("pull request is hidden by repository access policy")]
-    PullRequestHidden,
-    #[error("pull request is not open")]
-    PullRequestState,
-    #[error("pull-request revision does not exist")]
-    PullRequestRevisionNotFound,
-    #[error("pull-request review anchor does not match its revision")]
-    PullRequestReviewAnchor,
-    #[error("pull-request ref intent {0} is not in the required state")]
-    PullRequestIntentState(String),
-    #[error("repository watch access is not authorized")]
-    WatchDenied,
-    #[error("feed token is invalid or revoked")]
-    FeedTokenNotFound,
-    #[error("an account cannot have more than one active feed token")]
-    FeedTokenLimit,
-    #[error("feed token scope is not valid")]
-    InvalidFeedScope,
-}
 
 pub(crate) struct Store {
     connection: Connection,
 }
 
 impl Store {
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without audited services"
-    )]
     pub(crate) fn record_audit_event(&self, event: &NewAuditEvent<'_>) -> Result<(), StoreError> {
         insert_audit_event(&self.connection, event)?;
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without the audit CLI"
-    )]
     pub(crate) fn audit_events(&self, limit: usize) -> Result<Vec<AuditEventRecord>, StoreError> {
         if limit == 0 || limit > 1_000 {
             return Err(StoreError::AuditLimit);
@@ -221,10 +58,6 @@
             .map_err(Into::into)
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration test crates import storage without opening a database"
-    )]
     pub(crate) fn open(path: &Path) -> Result<Self, StoreError> {
         let mut store = Self::open_unmigrated(path)?;
         let current = store.schema_version()?;
@@ -235,10 +68,6 @@
         Ok(store)
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration test crates import storage without migration setup"
-    )]
     pub(crate) fn open_unmigrated(path: &Path) -> Result<Self, StoreError> {
         let connection = Connection::open(path)?;
         configure(&connection)?;
@@ -255,18 +84,10 @@
         Ok(Self { connection })
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration test crates import storage without direct migration"
-    )]
     pub(crate) fn migrate(&mut self) -> Result<(), StoreError> {
         self.migrate_with_hook(|_| {})
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration test crates import storage without migration hooks"
-    )]
     pub(crate) fn migrate_with_hook(
         &mut self,
         mut after_migration: impl FnMut(i64),
@@ -336,10 +157,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration test crates import storage without backup operations"
-    )]
     pub(crate) fn backup(&self, path: &Path) -> Result<(), StoreError> {
         let mut destination = Connection::open(path)?;
         let backup = Backup::new(&self.connection, &mut destination)?;
@@ -372,7 +189,6 @@
     }
 
     #[cfg(test)]
-    #[allow(dead_code, reason = "integration storage tests use this test boundary")]
     pub(crate) fn checkpoint(&self) -> Result<(), StoreError> {
         self.connection
             .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
@@ -380,24 +196,17 @@
     }
 
     #[cfg(test)]
-    #[allow(dead_code, reason = "integration storage tests use this test boundary")]
     pub(crate) fn vacuum(&self) -> Result<(), StoreError> {
         self.connection.execute_batch("VACUUM")?;
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "integration storage tests use this database test boundary"
-    )]
+    #[cfg(test)]
     pub(crate) fn connection(&self) -> &Connection {
         &self.connection
     }
 
-    #[allow(
-        dead_code,
-        reason = "integration storage tests use this mutable database test boundary"
-    )]
+    #[cfg(test)]
     pub(crate) fn connection_mut(&mut self) -> &mut Connection {
         &mut self.connection
     }
@@ -630,10 +439,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without receive-pack"
-    )]
     pub(crate) fn git_intent_completed(&self, id: &str) -> Result<bool, StoreError> {
         Ok(self
             .connection
@@ -714,10 +519,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without accounts"
-    )]
     pub(crate) fn create_signup_invitation(
         &self,
         code_hash: &[u8; 32],
@@ -732,10 +533,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without accounts"
-    )]
     pub(crate) fn create_account_with_invitation(
         &mut self,
         account: &InvitedAccount<'_>,
@@ -785,10 +582,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without accounts"
-    )]
     pub(crate) fn recover_account(
         &mut self,
         recovery: &AccountRecovery<'_>,
@@ -858,10 +651,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without accounts"
-    )]
     pub(crate) fn add_account_key(
         &mut self,
         username: &str,
@@ -892,10 +681,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without accounts"
-    )]
     pub(crate) fn revoke_account_key(
         &mut self,
         username: &str,
@@ -1033,10 +818,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without accounts"
-    )]
     pub(crate) fn suspend_account(
         &mut self,
         username: &str,
@@ -1084,10 +865,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without Web login"
-    )]
     pub(crate) fn create_login_nonce(
         &mut self,
         nonce: &NewLoginNonce<'_>,
@@ -1130,10 +907,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without Web login"
-    )]
     pub(crate) fn consume_login_nonce(
         &mut self,
         login: &NewWebSession<'_>,
@@ -1355,10 +1128,6 @@
         Ok(username)
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without Web login"
-    )]
     pub(crate) fn web_session(
         &self,
         session_hash: &[u8; 32],
@@ -1367,8 +1136,7 @@
     ) -> Result<WebSessionRecord, StoreError> {
         self.connection
             .query_row(
-                "SELECT account.username, account.is_administrator, web_session.expires_at,
-                        web_session.ssh_public_key_id
+                "SELECT account.username, account.is_administrator
                  FROM web_session
                  JOIN account ON account.id = web_session.account_id
                  WHERE web_session.session_hash = ?1 AND web_session.ended_at IS NULL
@@ -1379,8 +1147,6 @@
                     Ok(WebSessionRecord {
                         username: row.get(0)?,
                         is_administrator: row.get(1)?,
-                        expires_at: row.get(2)?,
-                        ssh_public_key_id: row.get(3)?,
                     })
                 },
             )
@@ -1388,10 +1154,6 @@
             .ok_or(StoreError::InvalidSession)
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without Web login"
-    )]
     pub(crate) fn end_account_sessions(
         &mut self,
         username: &str,
@@ -1614,10 +1376,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without admin commands"
-    )]
     pub(crate) fn set_repository_visibility(
         &mut self,
         owner: &str,
@@ -1658,10 +1416,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without admin commands"
-    )]
     pub(crate) fn set_repository_collaborator(
         &mut self,
         owner: &str,
@@ -1722,10 +1476,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without admin commands"
-    )]
     pub(crate) fn remove_repository_collaborator(
         &mut self,
         owner: &str,
@@ -2032,20 +1782,17 @@
             .ok_or_else(|| StoreError::Integrity(format!("repository {repository_id} disappeared")))
     }
 
-    #[allow(
-        clippy::too_many_arguments,
-        reason = "an audited repository settings change includes repository identity and audit context"
-    )]
     pub(crate) fn update_repository_settings(
         &mut self,
-        owner: &str,
-        slug: &str,
-        actor: &str,
-        description: &str,
-        visibility: &str,
-        changed_at: i64,
-        correlation_id: &str,
+        update: &RepositorySettingsUpdate<'_>,
     ) -> Result<(), StoreError> {
+        let owner = update.owner;
+        let slug = update.slug;
+        let actor = update.actor;
+        let description = update.description;
+        let visibility = update.visibility;
+        let changed_at = update.changed_at;
+        let correlation_id = update.correlation_id;
         if !matches!(visibility, "public" | "private") {
             return Err(StoreError::InvalidRepositoryVisibility);
         }
@@ -2081,20 +1828,17 @@
         Ok(())
     }
 
-    #[allow(
-        clippy::too_many_arguments,
-        reason = "an audited collaborator change includes repository identity and audit context"
-    )]
     pub(crate) fn update_repository_collaborator(
         &mut self,
-        owner: &str,
-        slug: &str,
-        actor: &str,
-        username: &str,
-        role: Option<&str>,
-        changed_at: i64,
-        correlation_id: &str,
+        update: &RepositoryCollaboratorUpdate<'_>,
     ) -> Result<(), StoreError> {
+        let owner = update.owner;
+        let slug = update.slug;
+        let actor = update.actor;
+        let username = update.username;
+        let role = update.role;
+        let changed_at = update.changed_at;
+        let correlation_id = update.correlation_id;
         if role.is_some_and(|role| !matches!(role, "maintainer" | "writer" | "reader")) {
             return Err(StoreError::InvalidCollaboratorRole);
         }
@@ -2281,10 +2025,6 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without authorization"
-    )]
     pub(crate) fn repository_authorization(
         &self,
         owner: &str,
@@ -2444,12 +2184,14 @@
         insert_pull_request_intent(
             &transaction,
             intent,
-            &access.repository.id,
-            actor_id,
-            number,
-            1,
-            None,
-            "open",
+            &PullRequestIntentInsertion {
+                repository_id: &access.repository.id,
+                author_account_id: actor_id,
+                pull_request_number: number,
+                revision_number: 1,
+                old_head_object_id: None,
+                operation: "open",
+            },
         )?;
         transaction.commit()?;
         Ok(PullRequestRefIntentRecord::from_new(
@@ -2531,12 +2273,14 @@
         insert_pull_request_intent(
             &transaction,
             intent,
-            &access.repository.id,
-            actor_id,
-            number,
-            revision,
-            Some(&old_head),
-            "revise",
+            &PullRequestIntentInsertion {
+                repository_id: &access.repository.id,
+                author_account_id: actor_id,
+                pull_request_number: number,
+                revision_number: revision,
+                old_head_object_id: Some(&old_head),
+                operation: "revise",
+            },
         )?;
         transaction.commit()?;
         Ok(PullRequestRefIntentRecord::from_new(
@@ -2619,14 +2363,16 @@
         };
         let event = event::pull_request(
             kind,
-            &intent.pull_request_id,
-            intent.pull_request_number,
-            intent.revision_number,
-            &intent.title,
-            &intent.base_ref,
-            &intent.head_ref,
-            &intent.base_object_id,
-            &intent.head_object_id,
+            &event::PullRequestRevision {
+                pull_request_id: &intent.pull_request_id,
+                number: intent.pull_request_number,
+                revision: intent.revision_number,
+                title: &intent.title,
+                base_ref: &intent.base_ref,
+                head_ref: &intent.head_ref,
+                base_object_id: &intent.base_object_id,
+                head_object_id: &intent.head_object_id,
+            },
         );
         insert_domain_event(
             &transaction,
@@ -2693,26 +2439,24 @@
         self.pull_request_with_activity(owner, repository, number, actor, None)
     }
 
-    #[allow(
-        clippy::too_many_arguments,
-        reason = "the pull-request detail has independent review and timeline pages"
-    )]
     pub(crate) fn pull_request_detail_page(
         &self,
         owner: &str,
         repository: &str,
         number: i64,
         actor: Option<&str>,
-        reviews_page: usize,
-        timeline_page: usize,
-        page_size: usize,
+        pagination: TimelinePagination,
     ) -> Result<PullRequestDetail, StoreError> {
         self.pull_request_with_activity(
             owner,
             repository,
             number,
             actor,
-            Some((reviews_page, timeline_page, page_size)),
+            Some((
+                pagination.primary_page,
+                pagination.timeline_page,
+                pagination.page_size,
+            )),
         )
     }
 
@@ -2961,15 +2705,17 @@
         };
         let event = event::pull_request_review(
             kind,
-            &pull_request.0,
-            review.number,
-            &id,
-            review.revision,
-            review.body,
-            review.commit_object_id,
-            review.path,
-            review.side,
-            review.line,
+            &event::PullRequestReview {
+                pull_request_id: &pull_request.0,
+                number: review.number,
+                review_id: &id,
+                revision: review.revision,
+                body: review.body,
+                commit_object_id: review.commit_object_id,
+                path: review.path,
+                side: review.side,
+                line: review.line,
+            },
         );
         insert_pull_request_event(
             &transaction,
@@ -2981,34 +2727,6 @@
         )?;
         transaction.commit()?;
         Ok(id)
-    }
-
-    pub(crate) fn pull_requests(
-        &self,
-        owner: &str,
-        repository: &str,
-        actor: Option<&str>,
-    ) -> Result<(RepositoryRecord, Vec<PullRequestRecord>, bool), StoreError> {
-        let access = repository_issue_access(&self.connection, owner, repository, actor)?;
-        if !access.can_read() {
-            return Err(StoreError::PullRequestHidden);
-        }
-        let mut statement = self.connection.prepare(
-            "SELECT pull_request.id, pull_request.number, pull_request.title,
-                    pull_request.body, pull_request.state, author.username,
-                    pull_request.base_ref, pull_request.head_ref,
-                    pull_request.base_object_id, pull_request.head_object_id,
-                    pull_request.created_at, pull_request.updated_at
-             FROM pull_request
-             JOIN account AS author ON author.id = pull_request.author_account_id
-             WHERE pull_request.repository_id = ?1
-             ORDER BY pull_request.number DESC",
-        )?;
-        let pull_requests = statement
-            .query_map([&access.repository.id], pull_request_from_row)?
-            .collect::<Result<Vec<_>, _>>()?;
-        let can_create = access.can_write_repository();
-        Ok((access.repository, pull_requests, can_create))
     }
 
     pub(crate) fn pull_request_page(
@@ -3064,20 +2782,17 @@
         ))
     }
 
-    #[allow(
-        clippy::too_many_arguments,
-        reason = "a pull-request edit includes repository identity, content, actor, and time"
-    )]
     pub(crate) fn edit_pull_request(
         &mut self,
-        owner: &str,
-        repository: &str,
-        number: i64,
-        actor: &str,
-        title: &str,
-        body: &str,
-        changed_at: i64,
+        edit: &PullRequestEdit<'_>,
     ) -> Result<(), StoreError> {
+        let owner = edit.owner;
+        let repository = edit.repository;
+        let number = edit.number;
+        let actor = edit.actor;
+        let title = edit.title;
+        let body = edit.body;
+        let changed_at = edit.changed_at;
         let transaction = self
             .connection
             .transaction_with_behavior(TransactionBehavior::Immediate)?;
@@ -3392,20 +3107,19 @@
         ))
     }
 
-    #[allow(
-        clippy::too_many_arguments,
-        reason = "the issue detail has independent comment and timeline pages"
-    )]
     pub(crate) fn issue_detail(
         &self,
         owner: &str,
         repository: &str,
         number: i64,
         actor: Option<&str>,
-        comments_page: usize,
-        timeline_page: usize,
-        page_size: usize,
+        pagination: TimelinePagination,
     ) -> Result<IssueDetail, StoreError> {
+        let TimelinePagination {
+            primary_page: comments_page,
+            timeline_page,
+            page_size,
+        } = pagination;
         let access = repository_issue_access(&self.connection, owner, repository, actor)?;
         if !access.can_read() {
             return Err(StoreError::IssueHidden);
@@ -3794,26 +3508,6 @@
         Ok(false)
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without authorization"
-    )]
-    pub(crate) fn active_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
-        let mut statement = self.connection.prepare(
-            "SELECT repository.id, account.username, repository.slug,
-                    repository.visibility, repository.state, repository.object_format,
-                    repository.created_at, repository.archived_at
-             FROM repository
-             JOIN account ON account.id = repository.owner_account_id
-             WHERE repository.state = 'active'
-             ORDER BY account.username, repository.slug",
-        )?;
-        statement
-            .query_map([], repository_from_row)?
-            .collect::<Result<Vec<_>, _>>()
-            .map_err(Into::into)
-    }
-
     pub(crate) fn all_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
         let mut statement = self.connection.prepare(
             "SELECT repository.id, account.username, repository.slug,
@@ -3981,10 +3675,7 @@
             .map_err(Into::into)
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests import the store without public HTTP routes"
-    )]
+    #[cfg(test)]
     pub(crate) fn public_repository(
         &self,
         owner: &str,
@@ -4011,10 +3702,7 @@
         }
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests import storage without the server"
-    )]
+    #[cfg(test)]
     pub(crate) fn active_ssh_public_keys(&self) -> Result<Vec<String>, StoreError> {
         let mut statement = self.connection.prepare(
             "SELECT ssh_public_key.canonical_key
@@ -4029,10 +3717,6 @@
             .map_err(Into::into)
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile storage without the production SSH server"
-    )]
     pub(crate) fn active_ssh_identities(&self) -> Result<Vec<ActiveSshIdentity>, StoreError> {
         let mut statement = self.connection.prepare(
             "SELECT account.username, ssh_public_key.canonical_key,
@@ -4050,26 +3734,6 @@
                     fingerprint: row.get(2)?,
                 })
             })?
-            .collect::<Result<Vec<_>, _>>()
-            .map_err(Into::into)
-    }
-
-    #[allow(
-        dead_code,
-        reason = "some integration tests import storage without the server"
-    )]
-    pub(crate) fn active_public_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
-        let mut statement = self.connection.prepare(
-            "SELECT repository.id, account.username, repository.slug,
-                    repository.visibility, repository.state, repository.object_format,
-                    repository.created_at, repository.archived_at
-             FROM repository
-             JOIN account ON account.id = repository.owner_account_id
-             WHERE repository.visibility = 'public' AND repository.state = 'active'
-             ORDER BY account.username, repository.slug",
-        )?;
-        statement
-            .query_map([], repository_from_row)?
             .collect::<Result<Vec<_>, _>>()
             .map_err(Into::into)
     }
@@ -4236,10 +3900,7 @@
         }
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests import storage without public event pages"
-    )]
+    #[cfg(test)]
     pub(crate) fn public_repository_events(
         &self,
         owner: &str,
@@ -4251,10 +3912,6 @@
         self.repository_events_for(repository, before, limit, false)
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests use only public event queries"
-    )]
     pub(crate) fn repository_events(
         &self,
         owner: &str,
@@ -4325,20 +3982,12 @@
     pub(crate) created_at: i64,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests compile storage without accounts"
-)]
 pub(crate) struct NewSshKey<'a> {
     pub(crate) canonical_key: &'a str,
     pub(crate) fingerprint: &'a str,
     pub(crate) label: &'a str,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests compile storage without accounts"
-)]
 pub(crate) struct InvitedAccount<'a> {
     pub(crate) invitation_hash: &'a [u8; 32],
     pub(crate) username: &'a str,
@@ -4348,10 +3997,6 @@
     pub(crate) correlation_id: &'a str,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests compile storage without accounts"
-)]
 pub(crate) struct AccountRecovery<'a> {
     pub(crate) username: &'a str,
     pub(crate) old_recovery_hash: &'a [u8; 32],
@@ -4361,10 +4006,6 @@
     pub(crate) correlation_id: &'a str,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests compile storage without Web login"
-)]
 pub(crate) struct NewLoginNonce<'a> {
     pub(crate) nonce_hash: &'a [u8; 32],
     pub(crate) csrf_hash: &'a [u8; 32],
@@ -4408,10 +4049,6 @@
     pub(crate) correlation_id: &'a str,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests compile storage without Web login"
-)]
 pub(crate) struct NewWebSession<'a> {
     pub(crate) nonce_hash: &'a [u8; 32],
     pub(crate) login_csrf_hash: &'a [u8; 32],
@@ -4424,15 +4061,9 @@
     pub(crate) correlation_id: &'a str,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests compile storage without Web login"
-)]
 pub(crate) struct WebSessionRecord {
     pub(crate) username: String,
     pub(crate) is_administrator: bool,
-    pub(crate) expires_at: i64,
-    pub(crate) ssh_public_key_id: Option<i64>,
 }
 
 pub(crate) struct NewRepository<'a> {
@@ -4459,10 +4090,13 @@
 }
 
 #[derive(Clone, Copy)]
-#[allow(
-    dead_code,
-    reason = "some integration tests create repositories without the import operation"
-)]
+pub(crate) struct TimelinePagination {
+    pub(crate) primary_page: usize,
+    pub(crate) timeline_page: usize,
+    pub(crate) page_size: usize,
+}
+
+#[derive(Clone, Copy)]
 pub(crate) enum RepositoryOrigin {
     Created,
     Imported,
@@ -4519,9 +4153,29 @@
     pub(crate) branches: Vec<String>,
 }
 
+pub(crate) struct RepositorySettingsUpdate<'a> {
+    pub(crate) owner: &'a str,
+    pub(crate) slug: &'a str,
+    pub(crate) actor: &'a str,
+    pub(crate) description: &'a str,
+    pub(crate) visibility: &'a str,
+    pub(crate) changed_at: i64,
+    pub(crate) correlation_id: &'a str,
+}
+
 pub(crate) struct RepositoryCollaboratorRecord {
     pub(crate) username: String,
     pub(crate) role: String,
+}
+
+pub(crate) struct RepositoryCollaboratorUpdate<'a> {
+    pub(crate) owner: &'a str,
+    pub(crate) slug: &'a str,
+    pub(crate) actor: &'a str,
+    pub(crate) username: &'a str,
+    pub(crate) role: Option<&'a str>,
+    pub(crate) changed_at: i64,
+    pub(crate) correlation_id: &'a str,
 }
 
 #[derive(Debug, Eq, PartialEq)]
@@ -4627,29 +4281,17 @@
     primary_key: i64,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests compile storage without authorization"
-)]
 pub(crate) struct RepositoryAuthorizationRecord {
     pub(crate) repository: RepositoryRecord,
     pub(crate) role: Option<String>,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests compile storage without the production SSH server"
-)]
 pub(crate) struct ActiveSshIdentity {
     pub(crate) username: String,
     pub(crate) canonical_key: String,
     pub(crate) fingerprint: String,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests import storage without public event pages"
-)]
 pub(crate) struct RepositoryEventRecord {
     pub(crate) event_id: String,
     pub(crate) sequence: i64,
@@ -4781,6 +4423,16 @@
     pub(crate) can_merge: bool,
 }
 
+pub(crate) struct PullRequestEdit<'a> {
+    pub(crate) owner: &'a str,
+    pub(crate) repository: &'a str,
+    pub(crate) number: i64,
+    pub(crate) actor: &'a str,
+    pub(crate) title: &'a str,
+    pub(crate) body: &'a str,
+    pub(crate) changed_at: i64,
+}
+
 pub(crate) struct NewPullRequestReview<'a> {
     pub(crate) owner: &'a str,
     pub(crate) repository: &'a str,
@@ -4833,7 +4485,6 @@
 }
 
 impl PullRequestRefIntentRecord {
-    #[allow(clippy::too_many_arguments)]
     fn from_new(
         intent: &NewPullRequestRefIntent<'_>,
         repository_id: String,
@@ -4957,10 +4608,6 @@
     pub(crate) created_at: i64,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests compile storage without the audit CLI"
-)]
 pub(crate) struct AuditEventRecord {
     pub(crate) id: i64,
     pub(crate) action: String,
@@ -4998,10 +4645,6 @@
     pub(crate) changed_at: i64,
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration tests compile storage without accounts"
-)]
 fn insert_ssh_key(
     transaction: &rusqlite::Transaction<'_>,
     account_id: i64,
@@ -5185,16 +4828,19 @@
     }
 }
 
-#[allow(clippy::too_many_arguments)]
-fn insert_pull_request_intent(
-    transaction: &rusqlite::Transaction<'_>,
-    intent: &NewPullRequestRefIntent<'_>,
-    repository_id: &str,
+struct PullRequestIntentInsertion<'a> {
+    repository_id: &'a str,
     author_account_id: i64,
     pull_request_number: i64,
     revision_number: i64,
-    old_head_object_id: Option<&str>,
-    operation: &str,
+    old_head_object_id: Option<&'a str>,
+    operation: &'a str,
+}
+
+fn insert_pull_request_intent(
+    transaction: &rusqlite::Transaction<'_>,
+    intent: &NewPullRequestRefIntent<'_>,
+    insertion: &PullRequestIntentInsertion<'_>,
 ) -> Result<(), StoreError> {
     transaction.execute(
         "INSERT INTO pull_request_ref_intent
@@ -5205,19 +4851,19 @@
                  ?13, ?14, ?15, 'pending', ?16)",
         rusqlite::params![
             intent.id,
-            repository_id,
+            insertion.repository_id,
             intent.pull_request_id,
-            pull_request_number,
-            revision_number,
-            operation,
+            insertion.pull_request_number,
+            insertion.revision_number,
+            insertion.operation,
             intent.title,
             intent.body,
-            author_account_id,
+            insertion.author_account_id,
             intent.actor,
             intent.base_ref,
             intent.head_ref,
             intent.base_object_id,
-            old_head_object_id,
+            insertion.old_head_object_id,
             intent.head_object_id,
             intent.created_at,
         ],
@@ -5971,10 +5617,6 @@
     Ok(())
 }
 
-#[allow(
-    dead_code,
-    reason = "the integration test imports this module without the CLI operation"
-)]
 pub(crate) fn doctor(instance_dir: &Path) -> Result<(), StoreError> {
     let path = instance_dir.join(DATABASE_FILE);
     let store = Store::open_read_only(&path)?;
@@ -5988,10 +5630,6 @@
     store.integrity_check()
 }
 
-#[allow(
-    dead_code,
-    reason = "some integration test crates import storage without migration backup paths"
-)]
 fn migration_backup_path(path: &Path, version: i64) -> PathBuf {
     let mut backup = OsString::from(path.as_os_str());
     backup.push(format!(".v{version}.backup"));

src/store/schema.rs

Mode 100644; object 7a184eeb3fa3

@@ -1,0 +1,28 @@
+pub(super) const VERSION: i64 = 24;
+
+pub(super) const MIGRATIONS: [&str; 24] = [
+    include_str!("migrations/001_initial.sql"),
+    include_str!("migrations/002_state.sql"),
+    include_str!("migrations/003_git_intents.sql"),
+    include_str!("migrations/004_identity.sql"),
+    include_str!("migrations/005_repository.sql"),
+    include_str!("migrations/006_repository_events.sql"),
+    include_str!("migrations/007_account_lifecycle.sql"),
+    include_str!("migrations/008_web_sessions.sql"),
+    include_str!("migrations/009_repository_authorization.sql"),
+    include_str!("migrations/010_audit_history.sql"),
+    include_str!("migrations/011_domain_events.sql"),
+    include_str!("migrations/012_issues.sql"),
+    include_str!("migrations/013_watches.sql"),
+    include_str!("migrations/014_feed_tokens.sql"),
+    include_str!("migrations/015_pull_requests.sql"),
+    include_str!("migrations/016_pull_request_reviews.sql"),
+    include_str!("migrations/017_pull_request_merges.sql"),
+    include_str!("migrations/018_streamlined_login.sql"),
+    include_str!("migrations/019_product_reduction.sql"),
+    include_str!("migrations/020_repository_profiles.sql"),
+    include_str!("migrations/021_pull_request_lifecycle.sql"),
+    include_str!("migrations/022_account_key_management.sql"),
+    include_str!("migrations/023_default_branch.sql"),
+    include_str!("migrations/024_default_branch_intents.sql"),
+];

src/system.rs

Mode 100644100644; object e199cfe70f7436d1999fa070

@@ -1,8 +1,3 @@
-#![allow(
-    dead_code,
-    reason = "integration test crates use only the system operations required by their imported modules"
-)]
-
 use std::time::{SystemTime, UNIX_EPOCH};
 
 use rand::TryRng;

src/telemetry.rs

Mode 100644100644; object 8eecb5d9eaf3a9ca117582c6

@@ -32,10 +32,6 @@
 }
 
 impl Telemetry {
-    #[allow(
-        dead_code,
-        reason = "integration tests use disabled telemetry outside the production server"
-    )]
     pub(crate) fn enabled() -> Self {
         Self {
             counters: Arc::new(Counters::default()),
@@ -100,10 +96,6 @@
         self.write_ssh_event("ssh.operation", operation_id, "started");
     }
 
-    #[allow(
-        dead_code,
-        reason = "some integration tests compile telemetry without process lifecycle"
-    )]
     pub(crate) fn lifecycle(&self, event: &'static str, outcome: &'static str) {
         self.write_event(&Event {
             timestamp_ms: timestamp_ms(),

tests/auth.rs

Mode 100644100644; object 3e372612f2e6b1b5aadb40cd

@@ -3,15 +3,11 @@
 use std::fs;
 use std::path::{Path, PathBuf};
 use std::process::Command;
-use std::sync::{Arc, Barrier};
-use std::thread;
 
 use auth::{
-    AuthError, LoginChallenges, SshPublicKey, format_keyless_login_challenge,
-    verify_keyless_login_challenge,
+    AuthError, SshPublicKey, format_keyless_login_challenge, verify_keyless_login_challenge,
 };
 use tempfile::TempDir;
-use url::Url;
 
 const ISSUED_AT: u64 = 1_750_000_000;
 const LIFETIME: u64 = 120;
@@ -42,6 +38,10 @@
 fn rejects_unsupported_and_undersized_keys() {
     let directory = TempDir::new().expect("create a key directory");
 
+    assert!(matches!(
+        SshPublicKey::parse(&"x".repeat(16 * 1024 + 1)),
+        Err(AuthError::InputTooLarge("SSH public key"))
+    ));
     assert!(matches!(
         SshPublicKey::parse(DSA_PUBLIC_KEY),
         Err(AuthError::UnsupportedKeyAlgorithm)
@@ -75,33 +75,6 @@
 }
 
 #[test]
-fn verifies_stock_sshsig_envelopes_for_each_supported_key() {
-    let directory = TempDir::new().expect("create a key directory");
-    for fixture in [KeyFixture::Ed25519, KeyFixture::EcdsaP256] {
-        let private_key = directory.path().join(fixture.name());
-        generate_key(&private_key, fixture);
-        let key = parse_public_key(&private_key).expect("parse the public key");
-        let challenges = issuer("https://tit.example/");
-        let challenge = challenges
-            .issue("alice", &key, ISSUED_AT, LIFETIME)
-            .expect("issue a login challenge");
-        let signature = sign(
-            &directory,
-            fixture.name(),
-            &private_key,
-            "tit-auth",
-            &challenge,
-        );
-
-        let verified = challenges
-            .verify(&challenge, &signature, "alice", &key, ISSUED_AT + 1)
-            .expect("verify the stock SSHSIG envelope");
-        assert_eq!(verified.username, "alice");
-        assert_eq!(verified.fingerprint, key.fingerprint());
-    }
-}
-
-#[test]
 fn derives_the_key_from_a_keyless_stock_sshsig_envelope() {
     let directory = TempDir::new().expect("create a key directory");
     let private_key = directory.path().join("keyless");
@@ -129,261 +102,92 @@
 }
 
 #[test]
-fn rejects_replay_expiry_wrong_context_and_malformed_envelopes() {
+fn rejects_invalid_keyless_login_challenges() {
     let directory = TempDir::new().expect("create a key directory");
     let private_key = directory.path().join("ed25519");
     generate_key(&private_key, KeyFixture::Ed25519);
-    let key = parse_public_key(&private_key).expect("parse the public key");
-
-    let replay_issuer = issuer("https://tit.example/");
-    let replay_challenge = replay_issuer
-        .issue("alice", &key, ISSUED_AT, LIFETIME)
-        .expect("issue a replay challenge");
-    let replay_signature = sign(
-        &directory,
-        "replay",
-        &private_key,
-        "tit-auth",
-        &replay_challenge,
+    let challenge = format_keyless_login_challenge(
+        "https://tit.example",
+        "alice",
+        &[7_u8; 32],
+        ISSUED_AT,
+        ISSUED_AT + LIFETIME,
     );
-    replay_issuer
-        .verify(
-            &replay_challenge,
-            &replay_signature,
-            "alice",
-            &key,
-            ISSUED_AT + 1,
-        )
-        .expect("verify the challenge one time");
-    assert!(matches!(
-        replay_issuer.verify(
-            &replay_challenge,
-            &replay_signature,
-            "alice",
-            &key,
-            ISSUED_AT + 1
-        ),
-        Err(AuthError::ConsumedChallenge)
-    ));
+    let signature = sign(&directory, "valid", &private_key, "tit-auth", &challenge);
 
-    let expired_issuer = issuer("https://tit.example/");
-    let expired_challenge = expired_issuer
-        .issue("alice", &key, ISSUED_AT, LIFETIME)
-        .expect("issue an expiring challenge");
-    let expired_signature = sign(
-        &directory,
-        "expired",
-        &private_key,
-        "tit-auth",
-        &expired_challenge,
-    );
     assert!(matches!(
-        expired_issuer.verify(
-            &expired_challenge,
-            &expired_signature,
+        verify_keyless_login_challenge(
+            "https://other.example",
+            &challenge,
+            &signature,
             "alice",
-            &key,
-            ISSUED_AT + LIFETIME + 1
-        ),
-        Err(AuthError::ExpiredChallenge)
-    ));
-
-    let namespace_issuer = issuer("https://tit.example/");
-    let namespace_challenge = namespace_issuer
-        .issue("alice", &key, ISSUED_AT, LIFETIME)
-        .expect("issue a namespace challenge");
-    let wrong_namespace = sign(
-        &directory,
-        "namespace",
-        &private_key,
-        "other",
-        &namespace_challenge,
-    );
-    assert!(matches!(
-        namespace_issuer.verify(
-            &namespace_challenge,
-            &wrong_namespace,
-            "alice",
-            &key,
-            ISSUED_AT + 1
-        ),
-        Err(AuthError::SignatureVerification(_))
-    ));
-
-    let origin_issuer = issuer("https://tit.example/");
-    let origin_challenge = origin_issuer
-        .issue("alice", &key, ISSUED_AT, LIFETIME)
-        .expect("issue an origin challenge");
-    let origin_signature = sign(
-        &directory,
-        "origin",
-        &private_key,
-        "tit-auth",
-        &origin_challenge,
-    );
-    assert!(matches!(
-        issuer("https://other.example/").verify(
-            &origin_challenge,
-            &origin_signature,
-            "alice",
-            &key,
             ISSUED_AT + 1
         ),
         Err(AuthError::WrongOrigin)
     ));
-
-    let malformed_issuer = issuer("https://tit.example/");
-    let malformed_challenge = malformed_issuer
-        .issue("alice", &key, ISSUED_AT, LIFETIME)
-        .expect("issue a malformed-envelope challenge");
     assert!(matches!(
-        malformed_issuer.verify(
-            &malformed_challenge,
+        verify_keyless_login_challenge(
+            "https://tit.example",
+            &challenge,
+            &signature,
+            "bob",
+            ISSUED_AT + 1,
+        ),
+        Err(AuthError::WrongUsername)
+    ));
+    assert!(matches!(
+        verify_keyless_login_challenge(
+            "https://tit.example",
+            &challenge,
+            &signature,
+            "alice",
+            ISSUED_AT + LIFETIME + 1,
+        ),
+        Err(AuthError::ExpiredChallenge)
+    ));
+
+    let wrong_namespace = sign(&directory, "namespace", &private_key, "other", &challenge);
+    assert!(matches!(
+        verify_keyless_login_challenge(
+            "https://tit.example",
+            &challenge,
+            &wrong_namespace,
+            "alice",
+            ISSUED_AT + 1,
+        ),
+        Err(AuthError::SignatureVerification(_))
+    ));
+    assert!(matches!(
+        verify_keyless_login_challenge(
+            "https://tit.example",
+            &challenge,
             "not an SSHSIG envelope",
             "alice",
-            &key,
-            ISSUED_AT + 1
+            ISSUED_AT + 1,
         ),
         Err(AuthError::SignatureEnvelope(_))
     ));
-}
-
-#[test]
-fn rejects_wrong_key_username_and_rsa_keys() {
-    let directory = TempDir::new().expect("create a key directory");
-    let first_private = directory.path().join("first");
-    let second_private = directory.path().join("second");
-    generate_key(&first_private, KeyFixture::Ed25519);
-    generate_key(&second_private, KeyFixture::Ed25519);
-    let first_key = parse_public_key(&first_private).expect("parse the first public key");
-    let second_key = parse_public_key(&second_private).expect("parse the second public key");
-    let challenges = issuer("https://tit.example/");
-    let challenge = challenges
-        .issue("alice", &first_key, ISSUED_AT, LIFETIME)
-        .expect("issue a key challenge");
-    let signature = sign(
-        &directory,
-        "wrong-key",
-        &first_private,
-        "tit-auth",
-        &challenge,
-    );
 
     assert!(matches!(
-        challenges.verify(&challenge, &signature, "alice", &second_key, ISSUED_AT + 1),
-        Err(AuthError::WrongKey)
-    ));
-    assert!(matches!(
-        challenges.verify(&challenge, &signature, "bob", &first_key, ISSUED_AT + 1),
-        Err(AuthError::WrongUsername)
-    ));
-
-    let rsa_private = directory.path().join("rsa");
-    generate_key(&rsa_private, KeyFixture::Rsa3072);
-    assert!(matches!(
-        parse_public_key(&rsa_private),
-        Err(AuthError::UnsupportedKeyAlgorithm)
-    ));
-}
-
-#[test]
-fn consumes_a_nonce_atomically() {
-    let directory = TempDir::new().expect("create a key directory");
-    let private_key = directory.path().join("ed25519");
-    generate_key(&private_key, KeyFixture::Ed25519);
-    let key = parse_public_key(&private_key).expect("parse the public key");
-    let challenges = Arc::new(issuer("https://tit.example/"));
-    let challenge = challenges
-        .issue("alice", &key, ISSUED_AT, LIFETIME)
-        .expect("issue a concurrent challenge");
-    let signature = sign(
-        &directory,
-        "concurrent",
-        &private_key,
-        "tit-auth",
-        &challenge,
-    );
-    let barrier = Arc::new(Barrier::new(8));
-
-    let workers: Vec<_> = (0..8)
-        .map(|_| {
-            let challenges = Arc::clone(&challenges);
-            let barrier = Arc::clone(&barrier);
-            let challenge = challenge.clone();
-            let signature = signature.clone();
-            let key = key.clone();
-            thread::spawn(move || {
-                barrier.wait();
-                challenges
-                    .verify(&challenge, &signature, "alice", &key, ISSUED_AT + 1)
-                    .is_ok()
-            })
-        })
-        .collect();
-
-    let successes = workers
-        .into_iter()
-        .map(|worker| worker.join().expect("join a verifier"))
-        .filter(|success| *success)
-        .count();
-    assert_eq!(successes, 1);
-}
-
-#[test]
-fn enforces_input_and_active_challenge_limits() {
-    let directory = TempDir::new().expect("create a key directory");
-    let private_key = directory.path().join("ed25519");
-    generate_key(&private_key, KeyFixture::Ed25519);
-    let key = parse_public_key(&private_key).expect("parse the public key");
-
-    assert!(matches!(
-        SshPublicKey::parse(&"x".repeat(16 * 1024 + 1)),
-        Err(AuthError::InputTooLarge("SSH public key"))
-    ));
-
-    let challenges = issuer("https://tit.example/");
-    let challenge = challenges
-        .issue("alice", &key, ISSUED_AT, LIFETIME)
-        .expect("issue a size-limit challenge");
-    assert!(matches!(
-        challenges.verify(
+        verify_keyless_login_challenge(
+            "https://tit.example",
             &"x".repeat(4 * 1024 + 1),
             "signature",
             "alice",
-            &key,
-            ISSUED_AT + 1
+            ISSUED_AT + 1,
         ),
         Err(AuthError::InputTooLarge("login challenge"))
     ));
     assert!(matches!(
-        challenges.verify(
+        verify_keyless_login_challenge(
+            "https://tit.example",
             &challenge,
             &"x".repeat(16 * 1024 + 1),
             "alice",
-            &key,
-            ISSUED_AT + 1
+            ISSUED_AT + 1,
         ),
         Err(AuthError::InputTooLarge("SSHSIG envelope"))
     ));
-
-    let bounded = issuer("https://tit.example/");
-    for _ in 0..1_024 {
-        bounded
-            .issue("alice", &key, ISSUED_AT, LIFETIME)
-            .expect("fill the active challenge store");
-    }
-    assert!(matches!(
-        bounded.issue("alice", &key, ISSUED_AT, LIFETIME),
-        Err(AuthError::NonceLimit)
-    ));
-    bounded
-        .issue("alice", &key, ISSUED_AT + LIFETIME + 1, LIFETIME)
-        .expect("prune expired challenges before issuing another one");
-}
-
-fn issuer(origin: &str) -> LoginChallenges {
-    LoginChallenges::new(&Url::parse(origin).expect("parse the test origin"))
-        .expect("create a login challenge issuer")
 }
 
 fn parse_public_key(private_key: &Path) -> Result<SshPublicKey, AuthError> {

tests/cli.rs

Mode 100644100644; object d3e31bbce67e06e20cb7e1ee

@@ -1,3 +1,4 @@
+mod ssh_fixture;
 mod support;
 
 use std::fs;
@@ -6,10 +7,8 @@
 use std::process::{Command, Stdio};
 
 use sha2::{Digest, Sha256};
-use support::{
-    TestInstance, create_bare_git_fixture, create_ssh_key_fixture, free_address,
-    read_stock_ssh_configuration,
-};
+use ssh_fixture::create_ssh_key as create_ssh_key_fixture;
+use support::{TestInstance, create_bare_git_fixture, free_address, read_stock_ssh_configuration};
 use tempfile::TempDir;
 
 const CURRENT_DATABASE: &str = concat!(

tests/git_push_ssh.rs

Mode 100644100644; object 293f4bc0f399a7a20a6cfd08

@@ -1,10 +1,10 @@
-use crate::{auth, git, ssh, store};
+use crate::{auth, backup::OnlineBackupService, git, maintenance::MaintenanceGate, ssh, store};
 
 use std::env;
 use std::fs;
 use std::net::{Ipv4Addr, SocketAddr};
 use std::path::{Path, PathBuf};
-use std::process::{Child, Command, Output};
+use std::process::{Child, Command, Output, Stdio};
 use std::thread;
 use std::time::{Duration, Instant};
 
@@ -12,6 +12,103 @@
 use git::transport::GitRepositories;
 use ssh::RunningSshServer;
 use tempfile::TempDir;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn online_backup_waits_for_an_active_receive_pack() {
+    let directory = TempDir::new().expect("create a backup fixture directory");
+    let repositories_root = directory.path().join("repositories");
+    let bare = repositories_root.join("alice/example.git");
+    fs::create_dir_all(bare.parent().expect("a bare repository parent"))
+        .expect("create a repository owner directory");
+    run(Command::new("git")
+        .args(["init", "-q", "--bare", "--object-format", "sha1"])
+        .arg(&bare));
+
+    let private_key = directory.path().join("id_ed25519");
+    create_key(&private_key);
+    let key = parse_key(&private_key);
+    let database = directory.path().join(store::DATABASE_FILE);
+    store::Store::open(&database).expect("create the backup database");
+    let config = directory.path().join("config.toml");
+    fs::write(
+        &config,
+        b"version = 1\npublic_url = \"http://localhost:3000/\"\n",
+    )
+    .expect("write the backup configuration");
+    let gate = MaintenanceGate::default();
+    let repositories =
+        GitRepositories::new_with_pushes_and_gate(&repositories_root, &database, gate.clone())
+            .expect("open the repository root");
+    let server = RunningSshServer::start_with_git_writes(
+        SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
+        std::slice::from_ref(&key),
+        std::slice::from_ref(&key),
+        repositories,
+    )
+    .await
+    .expect("start the SSH Git server");
+
+    let mut receive = Command::new("ssh")
+        .args([
+            "-F",
+            "/dev/null",
+            "-p",
+            &server.address().port().to_string(),
+            "-i",
+        ])
+        .arg(&private_key)
+        .args([
+            "-o",
+            "BatchMode=yes",
+            "-o",
+            "IdentitiesOnly=yes",
+            "-o",
+            "StrictHostKeyChecking=no",
+            "-o",
+            "UserKnownHostsFile=/dev/null",
+            "-o",
+            "LogLevel=ERROR",
+            "ignored@127.0.0.1",
+            "git-receive-pack '/alice/example'",
+        ])
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .spawn()
+        .expect("start receive-pack");
+    let quarantine = bare.join("objects/tit-quarantine");
+    let deadline = Instant::now() + Duration::from_secs(10);
+    while fs::read_dir(&quarantine)
+        .map(|entries| entries.count() == 0)
+        .unwrap_or(true)
+    {
+        assert!(
+            receive.try_wait().expect("inspect receive-pack").is_none(),
+            "receive-pack stopped before it created quarantine"
+        );
+        assert!(Instant::now() < deadline, "receive-pack did not start");
+        tokio::time::sleep(Duration::from_millis(10)).await;
+    }
+
+    let backup_directory = TempDir::new().expect("create a backup output directory");
+    let output = backup_directory.path().join("instance.tar");
+    let backup = OnlineBackupService::new(directory.path().to_owned(), config, gate);
+    let backup_task = tokio::spawn({
+        let output = output.clone();
+        async move { backup.create(output).await }
+    });
+    tokio::time::sleep(Duration::from_millis(50)).await;
+    assert!(!output.exists(), "backup did not wait for receive-pack");
+
+    receive.kill().expect("stop receive-pack");
+    receive.wait().expect("wait for receive-pack");
+    backup_task
+        .await
+        .expect("join the backup task")
+        .expect("create the online backup");
+    assert!(output.exists());
+    server.shutdown().await.expect("stop the SSH Git server");
+}
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
 async fn stock_git_creates_and_updates_branches_for_both_hash_formats_over_ssh() {

tests/public_routes.rs

Mode 100644100644; object 2bc6f282dfacc30f949702d1

@@ -1,759 +1,88 @@
-use crate::{git, http, store};
+use crate::{http, store};
 
 use std::collections::BTreeMap;
 use std::fs;
 use std::io::{Read, Write};
 use std::net::{Ipv4Addr, SocketAddr, TcpStream};
-use std::path::{Path, PathBuf};
+use std::path::Path;
 use std::process::Command;
-use std::time::{SystemTime, UNIX_EPOCH};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
 
 use http::{PublicWebConfig, RunningWebServer};
 use sha2::{Digest, Sha256};
 use store::{InitialAdministrator, NewRepository, RepositoryOrigin, Store};
 use tempfile::TempDir;
 
-#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-async fn browses_and_clones_public_repositories_for_both_hash_formats() {
-    for format in ["sha1", "sha256"] {
-        let fixture = Fixture::new(format);
-        let server = RunningWebServer::start_public(
-            SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
-            PublicWebConfig {
-                instance_dir: fixture.instance.path().to_owned(),
-                http_clone_base: "https://tit.example".to_owned(),
-                ssh_clone_base: "ssh://tit.example:2222".to_owned(),
-                max_request_bytes: 1024 * 1024,
-                max_connections: 1024,
-            },
-        )
-        .await
-        .expect("start the public Web server");
+const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
 
-        let home = request(server.address(), "GET", "/", &[], &[]);
-        assert_eq!(home.status, 200);
-        assert_html_policy(&home);
-        let home_text = home.text();
-        assert!(home_text.contains("<div class=\"two-column\">"));
-        assert!(home_text.contains("<h1>A small Git CDE</h1>"));
-        assert!(home_text.contains("aria-label=\"Get started\""));
-        assert!(home_text.contains("<ul class=\"home-repository-list\">"));
-        assert!(home_text.contains("href=\"/alice/example\""));
-        assert!(home_text.contains(
-            "<section class=\"home-workflow\" aria-labelledby=\"home-workflow-heading\">"
-        ));
-        assert!(home_text.contains("<code>repo create</code>"));
-
-        let summary = request(server.address(), "GET", "/alice/example", &[], &[]);
-        assert_eq!(summary.status, 200);
-        assert_html_policy(&summary);
-        assert_repository_navigation(&summary, "alice", "example");
-        let summary_text = summary.text();
-        assert!(summary_text.contains(
-            "<h1><a href=\"/alice\">alice</a>/<a href=\"/alice/example\">example</a></h1>"
-        ));
-        assert!(summary_text.contains(
-            "<section class=\"repository-panel repository-file-panel\" aria-label=\"Files\">"
-        ));
-        assert!(!summary_text.contains("<h2 id=\"files-heading\">Files</h2>"));
-        assert!(summary_text.contains("https://tit.example/alice/example"));
-        assert!(summary_text.contains("ssh://tit.example:2222/alice/example"));
-        assert!(summary_text.contains(&fixture.head));
-        assert!(summary_text.contains("<div class=\"repository-summary-layout\">"));
-        assert!(summary_text.contains(
-            "</aside>\n  </div>\n\n  <section class=\"repository-panel repository-readme\""
-        ));
-        assert!(summary_text.contains("<strong>main</strong>"));
-        assert!(summary_text.contains(&format!(
-            "href=\"/alice/example/blob/{}/README.md\"",
-            fixture.head
-        )));
-        assert!(summary_text.contains(&format!(
-            "href=\"/alice/example/tree/{}/nested\"",
-            fixture.head
-        )));
-        assert!(summary_text.contains("README.md"));
-        assert!(summary_text.contains(">View all files</a>"));
-        assert!(!summary_text.contains("summary-overflow-50.txt"));
-        assert!(summary_text.contains("<h1>tit fixture</h1>"));
-        assert!(summary_text.contains("<strong>safe</strong>"));
-        assert!(summary_text.contains("<code>&lt;safe&gt;</code>"));
-        assert!(summary_text.contains(&format!(
-            "href=\"/alice/example/blob/{}/docs/guide.md\"",
-            fixture.head
-        )));
-        assert!(summary_text.contains(&format!(
-            "href=\"/alice/example/blob/{}/LICENSE\"",
-            fixture.head
-        )));
-        assert!(!summary_text.contains("<script"));
-        assert!(!summary_text.contains("javascript:"));
-        assert!(!summary_text.contains("<img"));
-        assert!(!summary_text.contains("tracker.example"));
-        assert!(summary_text.contains("&#60;script&#62;alert(3)&#60;/script&#62;"));
-        assert!(!summary_text.contains("/alice/example/atom.xml"));
-        assert!(summary_text.contains("/alice/example/rss.xml"));
-        assert!(summary_text.contains("/alice/example/search"));
-        assert!(summary_text.contains("/alice/example/commits\">View all commits</a>"));
-        assert!(summary_text.contains(">v0.4</a>"));
-        assert!(summary_text.contains(">v0.3</a>"));
-        assert!(summary_text.contains(">v0.2</a>"));
-        assert!(!summary_text.contains(">v0.1</a>"));
-        assert_eq!(summary_text.matches("aria-label=\"Commit ").count(), 3);
-        assert!(!summary_text.contains("Object format"));
-
-        let commits = request(server.address(), "GET", "/alice/example/commits", &[], &[]);
-        assert_eq!(commits.status, 200);
-        assert_html_policy(&commits);
-        assert_repository_navigation(&commits, "alice", "example");
-        assert!(commits.text().contains("<h2>All commits</h2>"));
-        assert_eq!(
-            commits
-                .text()
-                .matches("<li><a href=\"/alice/example/commit/")
-                .count(),
-            100
-        );
-        assert!(
-            commits
-                .text()
-                .contains("/alice/example/commits?page=2\">Older commits</a>")
-        );
-        let older_commits = request(
-            server.address(),
-            "GET",
-            "/alice/example/commits?page=2",
-            &[],
-            &[],
-        );
-        assert_eq!(older_commits.status, 200);
-        assert_eq!(
-            older_commits
-                .text()
-                .matches("<li><a href=\"/alice/example/commit/")
-                .count(),
-            2
-        );
-        assert!(
-            older_commits
-                .text()
-                .contains("/alice/example/commits?page=1\">Newer commits</a>")
-        );
-        assert_eq!(
-            request(
-                server.address(),
-                "GET",
-                "/alice/example/commits?page=3",
-                &[],
-                &[],
-            )
-            .status,
-            400
-        );
-        {
-            let path = "/alice/example/rss.xml";
-            let content_type = "application/rss+xml; charset=utf-8";
-            let feed = request(server.address(), "GET", path, &[], &[]);
-            assert_eq!(feed.status, 200);
-            assert_eq!(feed.header("content-type"), content_type);
-            assert_eq!(feed.header("cache-control"), "public, max-age=60");
-            assert!(!feed.header("etag").is_empty());
-            assert!(!feed.header("last-modified").is_empty());
-            let parsed = feed_rs::parser::parse(feed.body.as_slice()).expect("parse the feed");
-            assert_eq!(parsed.entries.len(), 1);
-            assert!(parsed.entries[0].id.starts_with("urn:tit:event:"));
-            assert!(feed.text().contains("Repository imported"));
-
-            let etag = feed.header("etag");
-            let conditional = request(
-                server.address(),
-                "GET",
-                path,
-                &[("If-None-Match", etag)],
-                &[],
-            );
-            assert_eq!(conditional.status, 304);
-            assert!(conditional.body.is_empty());
-
-            let modified = request(
-                server.address(),
-                "GET",
-                path,
-                &[("If-Modified-Since", feed.header("last-modified"))],
-                &[],
-            );
-            assert_eq!(modified.status, 304);
-            assert!(modified.body.is_empty());
-
-            let head = request(server.address(), "HEAD", path, &[], &[]);
-            assert_eq!(head.status, 200);
-            assert!(head.body.is_empty());
-            assert_eq!(head.header("etag"), feed.header("etag"));
-        }
-        assert_eq!(
-            request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]).status,
-            404
-        );
-
-        let invalid_page = request(
-            server.address(),
-            "GET",
-            "/alice/example/rss.xml?before=0",
-            &[],
-            &[],
-        );
-        assert_eq!(invalid_page.status, 400);
-
-        let database = fixture.instance.path().join("tit.sqlite3");
-        let feed_store = Store::open(&database).expect("open the feed database");
-        for timestamp in 10..35 {
-            let actor = if timestamp == 34 {
-                "x</title><script>alert(5)</script>"
-            } else {
-                "alice"
-            };
-            feed_store
-                .connection()
-                .execute(
-                    "INSERT INTO repository_event
-                     (event_id, repository_id, sequence, kind, actor, payload_version,
-                      payload, created_at)
-                     VALUES (
-                         lower(hex(randomblob(16))),
-                         ?1,
-                         (SELECT COALESCE(MAX(sequence), 0) + 1
-                          FROM repository_event WHERE repository_id = ?1),
-                         'push', ?2, 1, '{\"version\":1}', ?3
-                     )",
-                    rusqlite::params![fixture.repository_id, actor, timestamp],
-                )
-                .expect("insert a page fixture event");
-        }
-        drop(feed_store);
-        let first_page = request(server.address(), "GET", "/alice/example/rss.xml", &[], &[]);
-        let first_feed =
-            feed_rs::parser::parse(first_page.body.as_slice()).expect("parse the first page");
-        assert_eq!(first_feed.entries.len(), 20);
-        assert!(!first_page.text().contains("<script>"));
-        assert!(first_page.text().contains("x&lt;/title&gt;&lt;script&gt;"));
-        let next = first_page
-            .text()
-            .split("rel=\"next\" href=\"")
-            .nth(1)
-            .and_then(|value| value.split('\"').next())
-            .expect("find the next-page URL")
-            .replace("https://tit.example", "");
-        let second_page = request(server.address(), "GET", &next, &[], &[]);
-        let second_feed =
-            feed_rs::parser::parse(second_page.body.as_slice()).expect("parse the second page");
-        assert_eq!(second_feed.entries.len(), 6);
-        assert!(!second_page.text().contains("rel=\"next\""));
-
-        let empty = request(server.address(), "GET", "/alice/empty", &[], &[]);
-        assert_eq!(empty.status, 200);
-        assert!(empty.text().contains("This repository has no commits."));
-        let empty_search = request(server.address(), "GET", "/alice/empty/search", &[], &[]);
-        assert_eq!(empty_search.status, 200);
-        assert!(
-            empty_search
-                .text()
-                .contains("This repository has no commits to search.")
-        );
-
-        let alias = request(server.address(), "GET", "/alice/example.git", &[], &[]);
-        assert_eq!(alias.status, 308);
-        assert_eq!(alias.header("location"), "/alice/example");
-
-        let routes = [
-            "/alice/example/refs".to_owned(),
-            "/alice/example/commits".to_owned(),
-            "/alice/example/search".to_owned(),
-            format!("/alice/example/commit/{}", fixture.head),
-            format!("/alice/example/tree/{}", fixture.head),
-            format!("/alice/example/tree/{}/nested", fixture.head),
-            format!("/alice/example/blob/{}/nested/file.txt", fixture.head),
-            format!("/alice/example/diff/{}/{}", fixture.parent, fixture.head),
-            format!("/alice/example/blame/{}/nested/file.txt", fixture.head),
-        ];
-        for route in &routes {
-            let response = request(server.address(), "GET", route, &[], &[]);
-            assert_eq!(response.status, 200, "route failed: {route}");
-            assert_html_policy(&response);
-            assert!(!response.text().to_ascii_lowercase().contains("<script"));
-
-            let head = request(server.address(), "HEAD", route, &[], &[]);
-            assert_eq!(head.status, 200, "HEAD failed: {route}");
-            assert!(head.body.is_empty());
-            assert_eq!(
-                head.header("content-length"),
-                response.body.len().to_string()
-            );
-        }
-        let patch = request(
-            server.address(),
-            "GET",
-            &format!("/alice/example/commit/{}.patch", fixture.head),
-            &[],
-            &[],
-        );
-        assert_eq!(patch.status, 200);
-        assert_eq!(patch.header("content-type"), "text/x-diff; charset=utf-8");
-        assert!(patch.header("content-disposition").contains(".patch"));
-        let patch_path = fixture.instance.path().join("commit.patch");
-        fs::write(&patch_path, &patch.body).expect("write the downloaded patch");
-        run(Command::new("git")
-            .arg("-C")
-            .arg(&fixture.worktree)
-            .args(["checkout", "-q", "--detach", "HEAD^"]));
-        run(Command::new("git")
-            .arg("-C")
-            .arg(&fixture.worktree)
-            .args(["apply", "--check"])
-            .arg(&patch_path));
-
-        let search = request(
-            server.address(),
-            "GET",
-            "/alice/example/search?q=second%20line&ref=HEAD",
-            &[],
-            &[],
-        );
-        assert_eq!(search.status, 200);
-        assert_html_policy(&search);
-        let search_text = search.text();
-        assert!(search_text.contains("Found 1 matching lines."));
-        assert!(search_text.contains("nested/file.txt:2"));
-        assert!(search_text.contains(&format!(
-            "/alice/example/blob/{}/nested/file.txt",
-            fixture.head
-        )));
-        assert!(search_text.contains("<option value=\"HEAD\" selected>HEAD</option>"));
-
-        let malformed = request(
-            server.address(),
-            "GET",
-            "/alice/example/search?q=needle&ref=refs%2Fheads%2Fmain",
-            &[],
-            &[],
-        );
-        assert_eq!(malformed.status, 200);
-        assert!(malformed.text().contains("malformed.txt:1"));
-        assert!(malformed.text().contains("start � needle"));
-
-        let escaped = request(
-            server.address(),
-            "GET",
-            "/alice/example/search?q=%3Cscript%3E&ref=HEAD",
-            &[],
-            &[],
-        );
-        assert_eq!(escaped.status, 200);
-        assert!(!escaped.text().contains("value=\"<script>\""));
-        assert!(escaped.text().contains("value=\"&#60;script&#62;\""));
-        assert_eq!(
-            request(
-                server.address(),
-                "GET",
-                "/alice/example/search?q=&ref=HEAD",
-                &[],
-                &[],
-            )
-            .status,
-            400
-        );
-        assert_eq!(
-            request(
-                server.address(),
-                "GET",
-                "/alice/example/search?q=text&ref=refs%2Fheads%2Fmissing",
-                &[],
-                &[],
-            )
-            .status,
-            404
-        );
-
-        let tree = request(
-            server.address(),
-            "GET",
-            &format!("/alice/example/tree/{}", fixture.head),
-            &[],
-            &[],
-        );
-        let tree_text = tree.text();
-        assert!(tree_text.contains("README.md"));
-        assert!(tree_text.contains("binary.dat"));
-        assert!(tree_text.contains("non-å.txt"));
-        assert!(tree_text.contains("non-%C3%A5.txt"));
-        assert!(tree_text.contains("&#60;img src=x onerror=alert(4)&#62;.txt"));
-        assert!(!tree_text.contains("<img"));
-
-        let blob = request(
-            server.address(),
-            "GET",
-            &format!("/alice/example/blob/{}/nested/file.txt", fixture.head),
-            &[],
-            &[],
-        );
-        assert!(blob.text().contains("second line"));
-        assert!(blob.text().contains(&format!(
-            "/alice/example/raw/{}/nested/file.txt",
-            fixture.head
-        )));
-
-        let binary = request(
-            server.address(),
-            "GET",
-            &format!("/alice/example/blob/{}/binary.dat", fixture.head),
-            &[],
-            &[],
-        );
-        assert_eq!(binary.status, 200);
-        assert!(binary.text().contains("Binary content cannot be shown."));
-
-        let large = request(
-            server.address(),
-            "GET",
-            &format!("/alice/example/blob/{}/large.txt", fixture.head),
-            &[],
-            &[],
-        );
-        assert_eq!(large.status, 200);
-        assert!(large.body.len() > 2 * 1024 * 1024);
-        assert!(large.text().contains("large content starts"));
-        assert!(large.text().contains("large content ends"));
-
-        let raw = request(
-            server.address(),
-            "GET",
-            &format!("/alice/example/raw/{}/nested/file.txt", fixture.head),
-            &[],
-            &[],
-        );
-        assert_eq!(raw.status, 200);
-        assert_eq!(raw.header("content-type"), "application/octet-stream");
-        assert_eq!(raw.body, b"first line\nsecond line\n");
-        assert_eq!(
-            raw.header("cache-control"),
-            "public, max-age=31536000, immutable"
-        );
-        let raw_head = request(
-            server.address(),
-            "HEAD",
-            &format!("/alice/example/raw/{}/nested/file.txt", fixture.head),
-            &[],
-            &[],
-        );
-        assert_eq!(raw_head.status, 200);
-        assert!(raw_head.body.is_empty());
-        assert_eq!(
-            raw_head.header("content-length"),
-            raw.body.len().to_string()
-        );
-
-        let non_utf8 = request(
-            server.address(),
-            "GET",
-            &format!("/alice/example/raw/{}/non-%C3%A5.txt", fixture.head),
-            &[],
-            &[],
-        );
-        assert_eq!(non_utf8.status, 200);
-        assert_eq!(non_utf8.body, b"non-UTF-8 path\n");
-        let missing_non_utf8 = request(
-            server.address(),
-            "GET",
-            &format!("/alice/example/raw/{}/missing-%FF.txt", fixture.head),
-            &[],
-            &[],
-        );
-        assert_eq!(missing_non_utf8.status, 404);
-        assert_html_policy(&missing_non_utf8);
-
-        let archive = request(
-            server.address(),
-            "GET",
-            &format!("/alice/example/archive/{}.tar", fixture.head),
-            &[],
-            &[],
-        );
-        assert_eq!(archive.status, 200);
-        assert_eq!(archive.header("content-type"), "application/x-tar");
-        assert!(archive.body.ends_with(&[0_u8; 1024]));
-        let archive_head = request(
-            server.address(),
-            "HEAD",
-            &format!("/alice/example/archive/{}.tar", fixture.head),
-            &[],
-            &[],
-        );
-        assert_eq!(archive_head.status, 200);
-        assert!(archive_head.body.is_empty());
-        let archive_path = fixture.instance.path().join(format!("{format}.tar"));
-        fs::write(&archive_path, &archive.body).expect("write the public archive");
-        let listed = Command::new("tar")
-            .arg("-tf")
-            .arg(&archive_path)
-            .output()
-            .expect("run the system tar reader");
-        assert!(listed.status.success());
-        let names = String::from_utf8_lossy(&listed.stdout);
-        assert!(names.contains("README.md"));
-        assert!(names.contains("nested/file.txt"));
-
-        let discovery = request(
-            server.address(),
-            "GET",
-            "/alice/example.git/info/refs?service=git-upload-pack",
-            &[("Git-Protocol", "version=2")],
-            &[],
-        );
-        assert_eq!(discovery.status, 200);
-        assert_eq!(
-            discovery.header("content-type"),
-            "application/x-git-upload-pack-advertisement"
-        );
-        assert!(
-            discovery
-                .body
-                .windows(b"version 2".len())
-                .any(|window| window == b"version 2")
-        );
-        assert_eq!(
-            request(
-                server.address(),
-                "GET",
-                "/alice/example/info/refs?service=git-receive-pack",
-                &[],
-                &[],
-            )
-            .status,
-            400
-        );
-        assert_eq!(
-            request(
-                server.address(),
-                "GET",
-                "/alice/example/info/refs?service=git-upload-pack",
-                &[("Git-Protocol", "version=3")],
-                &[],
-            )
-            .status,
-            400
-        );
-        assert_eq!(
-            request(
-                server.address(),
-                "POST",
-                "/alice/example/git-upload-pack",
-                &[("Content-Type", "text/plain")],
-                b"0000",
-            )
-            .status,
-            415
-        );
-        assert_eq!(
-            request(
-                server.address(),
-                "POST",
-                "/alice/example/git-upload-pack",
-                &[
-                    ("Content-Type", "application/x-git-upload-pack-request"),
-                    ("Git-Protocol", "version=2"),
-                ],
-                b"zzzz",
-            )
-            .status,
-            400
-        );
-        assert_eq!(
-            request(
-                server.address(),
-                "POST",
-                "/alice/example/git-upload-pack",
-                &[("Content-Type", "application/x-git-upload-pack-request")],
-                &vec![b'0'; git::packetline::MAX_REQUEST_BYTES + 1],
-            )
-            .status,
-            413
-        );
-
-        let clone = fixture.instance.path().join(format!("clone-{format}"));
-        run(Command::new("git")
-            .args(["-c", "protocol.version=2", "clone", "-q"])
-            .arg(format!("http://{}/alice/example.git", server.address()))
-            .arg(&clone));
-        assert_eq!(rev_parse(&clone, "HEAD"), fixture.head);
-        assert_eq!(
-            fs::read(clone.join("nested/file.txt")).expect("read the cloned file"),
-            b"first line\nsecond line\n"
-        );
-
-        let tag_directory = fixture
-            .instance
-            .path()
-            .join("repositories")
-            .join(format!("{}.git", fixture.repository_id))
-            .join("refs/tags");
-        fs::create_dir_all(&tag_directory).expect("create the tag directory");
-        for index in 0..=128 {
-            fs::write(
-                tag_directory.join(format!("summary-limit-{index:03}")),
-                format!("{}\n", fixture.head),
-            )
-            .expect("write a summary-limit tag");
-        }
-        let tag_limited_summary = request(server.address(), "GET", "/alice/example", &[], &[]);
-        assert_eq!(tag_limited_summary.status, 200);
-        assert!(
-            tag_limited_summary
-                .text()
-                .contains("This repository has too many tags for the summary.")
-        );
-        assert!(!tag_limited_summary.text().contains(">v0.4</a>"));
-
-        for route in [
-            "/alice/missing",
-            "/Alice/example",
-            "/alice/example/commit/not-an-object",
-            "/alice/example/tree/0000000000000000000000000000000000000000",
-            "/alice/example/raw/0000000000000000000000000000000000000000/file",
-        ] {
-            let response = request(server.address(), "GET", route, &[], &[]);
-            assert_eq!(response.status, 404, "route leaked or accepted: {route}");
-            assert_html_policy(&response);
-        }
-
-        let database = fixture.instance.path().join(store::DATABASE_FILE);
-        let hidden = Store::open(&database).expect("open the repository database");
-        hidden
-            .connection()
-            .execute(
-                "UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
-                [],
-            )
-            .expect("make the repository private");
-        drop(hidden);
-        assert_hidden(server.address(), &fixture.head);
-
-        let archived = Store::open(&database).expect("reopen the repository database");
-        archived
-            .connection()
-            .execute(
-                "UPDATE repository
-                 SET visibility = 'public', state = 'archived', archived_at = 3
-                 WHERE slug = 'example'",
-                [],
-            )
-            .expect("archive the repository");
-        drop(archived);
-        assert_hidden(server.address(), &fixture.head);
-
-        server.shutdown().await.expect("stop the public Web server");
-    }
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-async fn runs_the_complete_issue_workflow_without_javascript() {
-    let fixture = Fixture::new("sha1");
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn renders_and_mutates_a_public_issue_without_javascript() {
+    let fixture = Fixture::new();
     let database = fixture.instance.path().join(store::DATABASE_FILE);
-    let token = "11".repeat(32);
-    let csrf = "22".repeat(32);
-    let session_hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
-    let csrf_hash: [u8; 32] = Sha256::digest(csrf.as_bytes()).into();
-    let reader_token = "44".repeat(32);
-    let reader_csrf = "55".repeat(32);
-    let reader_session_hash: [u8; 32] = Sha256::digest(reader_token.as_bytes()).into();
-    let reader_csrf_hash: [u8; 32] = Sha256::digest(reader_csrf.as_bytes()).into();
-    let now = SystemTime::now()
-        .duration_since(UNIX_EPOCH)
-        .expect("read current time")
-        .as_secs() as i64;
-    let store = Store::open(&database).expect("open the issue fixture database");
-    store
-        .connection()
-        .execute(
-            "INSERT INTO web_session
-             (session_hash, csrf_hash, account_id, created_at, expires_at)
-             SELECT ?1, ?2, id, ?3, ?4 FROM account WHERE username = 'alice'",
-            rusqlite::params![session_hash, csrf_hash, now, now + 3600,],
-        )
-        .expect("create a Web session");
-    store
-        .connection()
-        .execute(
-            "INSERT INTO account (username, is_administrator, state, created_at)
-             VALUES ('bob', 0, 'active', ?1)",
-            [now],
-        )
-        .expect("create a reader account");
-    store
-        .connection()
-        .execute(
-            "INSERT INTO web_session
-             (session_hash, csrf_hash, account_id, created_at, expires_at)
-             SELECT ?1, ?2, id, ?3, ?4 FROM account WHERE username = 'bob'",
-            rusqlite::params![reader_session_hash, reader_csrf_hash, now, now + 3600],
-        )
-        .expect("create a reader Web session");
-    store
-        .connection()
-        .execute(
-            "INSERT INTO repository_collaborator
-             (repository_id, account_id, role, created_at)
-             SELECT repository.id, account.id, 'reader', ?1
-             FROM repository, account
-             WHERE repository.slug = 'example' AND account.username = 'bob'",
-            [now],
-        )
-        .expect("create a reader collaborator");
-    drop(store);
-
     let server = RunningWebServer::start_public(
         SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
         PublicWebConfig {
             instance_dir: fixture.instance.path().to_owned(),
-            http_clone_base: "http://127.0.0.1".to_owned(),
+            http_clone_base: "https://tit.example".to_owned(),
             ssh_clone_base: "ssh://tit.example:2222".to_owned(),
+            trusted_proxy: None,
             max_request_bytes: 1024 * 1024,
-            max_connections: 1024,
+            max_connections: 32,
         },
     )
     .await
-    .expect("start the issue Web server");
+    .expect("start the public Web server");
 
-    let anonymous = request(server.address(), "GET", "/alice/example/issues", &[], &[]);
-    assert_eq!(anonymous.status, 200);
-    assert_repository_navigation(&anonymous, "alice", "example");
-    assert!(anonymous.text().contains("This repository has no issues."));
+    let summary = request(server.address(), "GET", "/alice/example", &[], &[]);
+    assert_eq!(summary.status, 200);
+    assert_html_policy(&summary);
     assert!(
-        anonymous
+        summary
             .text()
-            .contains("<section class=\"collection-panel\" aria-label=\"Issues\">")
+            .contains("alice</a>/<a href=\"/alice/example\"")
     );
-    assert!(anonymous.text().contains("<form class=\"filter-form\""));
-    assert!(anonymous.text().contains("class=\"collection-action\""));
-    assert!(!anonymous.text().contains("Create an issue</h2>"));
-    assert_eq!(
-        request(
-            server.address(),
-            "GET",
-            "/alice/example/issues?page=0",
-            &[],
-            &[],
-        )
-        .status,
-        400
-    );
+    assert!(summary.text().contains("<h1>tit fixture</h1>"));
+    assert!(!summary.text().to_ascii_lowercase().contains("<script"));
+    assert!(summary.text().contains("/alice/example/issues"));
 
+    let anonymous_issues = request(server.address(), "GET", "/alice/example/issues", &[], &[]);
+    assert_eq!(anonymous_issues.status, 200);
+    assert!(
+        anonymous_issues
+            .text()
+            .contains("This repository has no issues.")
+    );
+    assert!(!anonymous_issues.text().contains("Create an issue</h2>"));
+
+    let token = "11".repeat(32);
+    let csrf = "22".repeat(32);
+    create_session(&database, &token, &csrf);
     let cookie = format!("tit-session={token}; tit-csrf={csrf}");
     let headers = [
         ("Content-Type", "application/x-www-form-urlencoded"),
         ("Cookie", cookie.as_str()),
     ];
-    let create = form(&[
+
+    let rejected = form(&[
+        ("csrf", &"33".repeat(32)),
+        ("title", "Rejected issue"),
+        ("body", "This must not be stored."),
+    ]);
+    assert_eq!(
+        request(
+            server.address(),
+            "POST",
+            "/alice/example/issues",
+            &headers,
+            rejected.as_bytes(),
+        )
+        .status,
+        403
+    );
+
+    let issue = form(&[
         ("csrf", &csrf),
-        ("title", "Unsafe rendering check"),
+        ("title", "No JavaScript workflow"),
         ("body", "**safe**\n\n<script>alert(1)</script>"),
     ]);
     let created = request(
@@ -761,21 +90,10 @@
         "POST",
         "/alice/example/issues",
         &headers,
-        create.as_bytes(),
+        issue.as_bytes(),
     );
     assert_eq!(created.status, 303);
     assert_eq!(created.header("location"), "/alice/example/issues/1");
-
-    let issue_index = request(
-        server.address(),
-        "GET",
-        "/alice/example/issues",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    let issue_index_text = issue_index.text();
-    assert!(issue_index_text.contains("<ol class=\"issue-list\">"));
-    assert!(issue_index_text.contains("<span class=\"issue-meta\">open · opened by alice"));
 
     let detail = request(
         server.address(),
@@ -785,721 +103,18 @@
         &[],
     );
     assert_eq!(detail.status, 200);
+    assert!(detail.text().contains("#1 No JavaScript workflow"));
     assert!(detail.text().contains("<strong>safe</strong>"));
-    assert!(!detail.text().contains("<script>"));
-    assert!(detail.text().contains("Add a comment"));
-    assert!(!detail.text().contains("Organize this issue"));
-    assert_eq!(
-        request(
-            server.address(),
-            "GET",
-            "/alice/example/issues/1?comments_page=0",
-            &[],
-            &[],
-        )
-        .status,
-        400
-    );
+    assert!(!detail.text().to_ascii_lowercase().contains("<script"));
 
-    let bad_csrf = form(&[("csrf", &"33".repeat(32)), ("state", "closed")]);
-    assert_eq!(
-        request(
-            server.address(),
-            "POST",
-            "/alice/example/issues/1/state",
-            &headers,
-            bad_csrf.as_bytes(),
-        )
-        .status,
-        403
-    );
-    for (path, fields) in [
-        (
-            "/alice/example/issues/1/comments",
-            vec![
-                ("csrf", csrf.as_str()),
-                ("body", "A **comment** for @alice."),
-            ],
-        ),
-        (
-            "/alice/example/issues/1/edit",
-            vec![
-                ("csrf", csrf.as_str()),
-                ("title", "Edited issue"),
-                ("body", "Preserved _Markdown_."),
-            ],
-        ),
-        (
-            "/alice/example/issues/1/state",
-            vec![("csrf", csrf.as_str()), ("state", "closed")],
-        ),
-        (
-            "/alice/example/issues/1/state",
-            vec![("csrf", csrf.as_str()), ("state", "open")],
-        ),
-    ] {
-        let response = request(
-            server.address(),
-            "POST",
-            path,
-            &headers,
-            form(&fields).as_bytes(),
-        );
-        assert_eq!(response.status, 303, "issue mutation failed at {path}");
-    }
-
-    let final_page = request(
-        server.address(),
-        "GET",
-        "/alice/example/issues/1",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    let final_text = final_page.text();
-    assert_eq!(final_page.status, 200);
-    assert!(final_text.contains("#1 Edited issue"));
-    assert!(final_text.contains("<em>Markdown</em>"));
-    assert!(final_text.contains("<strong>comment</strong>"));
-    assert!(final_text.contains("<article class=\"comment-card\""));
-    assert!(!final_text.contains("Labels:"));
-    assert!(!final_text.contains("Assignees:"));
-    assert!(final_text.contains("created the issue"));
-    assert!(final_text.contains("reopened the issue"));
-
-    let anonymous_pull_requests =
-        request(server.address(), "GET", "/alice/example/pulls", &[], &[]);
-    assert_eq!(anonymous_pull_requests.status, 200);
-    assert_eq!(
-        request(
-            server.address(),
-            "GET",
-            "/alice/example/pulls?page=0",
-            &[],
-            &[],
-        )
-        .status,
-        400
-    );
-    assert_repository_navigation(&anonymous_pull_requests, "alice", "example");
-    assert!(
-        anonymous_pull_requests
-            .text()
-            .contains("This repository has no pull requests.")
-    );
-    assert!(
-        !anonymous_pull_requests
-            .text()
-            .contains("Open a pull request</h2>")
-    );
-    let open_pull_request = form(&[
-        ("csrf", csrf.as_str()),
-        ("title", "Review the feature"),
-        ("body", "Keep **each revision**."),
-        ("base-ref", "refs/heads/main"),
-        ("head-ref", "refs/heads/feature"),
-    ]);
-    let opened_pull_request = request(
-        server.address(),
-        "POST",
-        "/alice/example/pulls",
-        &headers,
-        open_pull_request.as_bytes(),
-    );
-    assert_eq!(opened_pull_request.status, 303);
-    assert_eq!(
-        opened_pull_request.header("location"),
-        "/alice/example/pulls/1"
-    );
-    let pull_request_index = request(
-        server.address(),
-        "GET",
-        "/alice/example/pulls",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    let pull_request_index_text = pull_request_index.text();
-    assert!(pull_request_index_text.contains("<ol class=\"issue-list\">"));
-    assert!(pull_request_index_text.contains("<span class=\"issue-meta\">open · opened by alice"));
-    let pull_request_page = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
-    assert_eq!(pull_request_page.status, 200);
-    assert!(pull_request_page.text().contains("#1 Review the feature"));
-    assert!(
-        pull_request_page
-            .text()
-            .contains("<strong>each revision</strong>")
-    );
-    assert!(
-        pull_request_page
-            .text()
-            .contains("git fetch origin refs/pull/1/head")
-    );
-    assert!(
-        pull_request_page
-            .text()
-            .contains("Comparison for revision 1")
-    );
-    assert!(
-        pull_request_page
-            .text()
-            .contains("Mergeability: already merged")
-    );
-    let worktree = fixture.instance.path().join("worktree");
-    run(Command::new("git")
-        .arg("-C")
-        .arg(&worktree)
-        .args(["switch", "-q", "feature"]));
-    fs::write(worktree.join("pull-request.txt"), b"new revision\n")
-        .expect("write a pull-request revision");
-    commit_all(&worktree, "pull-request revision");
-    let bare = fixture
-        .instance
-        .path()
-        .join("repositories")
-        .join(format!("{}.git", fixture.repository_id));
-    run(Command::new("git")
-        .arg("-C")
-        .arg(&worktree)
-        .args(["push", "-q"])
-        .arg(&bare)
-        .arg("feature"));
-    let revision = form(&[("csrf", csrf.as_str())]);
-    let revised_pull_request = request(
-        server.address(),
-        "POST",
-        "/alice/example/pulls/1/revisions",
-        &headers,
-        revision.as_bytes(),
-    );
-    assert_eq!(revised_pull_request.status, 303);
-    let revised_pull_request_page =
-        request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
-    assert_eq!(
-        revised_pull_request_page
-            .text()
-            .matches("recorded <code title=")
-            .count(),
-        2
-    );
-    assert!(
-        revised_pull_request_page
-            .text()
-            .contains("Comparison for revision 2")
-    );
-    assert!(
-        revised_pull_request_page
-            .text()
-            .contains("pull-request.txt")
-    );
-    let first_revision = request(
-        server.address(),
-        "GET",
-        "/alice/example/pulls/1?revision=1",
-        &[],
-        &[],
-    );
-    assert_eq!(first_revision.status, 200);
-    assert!(first_revision.text().contains("Comparison for revision 1"));
-    assert!(!first_revision.text().contains("pull-request.txt"));
-    let first_revision_patch = request(
-        server.address(),
-        "GET",
-        "/alice/example/pulls/1/revisions/1.patch",
-        &[],
-        &[],
-    );
-    assert_eq!(first_revision_patch.status, 200);
-    assert_eq!(
-        first_revision_patch.header("content-type"),
-        "text/x-diff; charset=utf-8"
-    );
-    let immutable_revision_patch = first_revision_patch.body.clone();
-    for fields in [
-        vec![
-            ("csrf", csrf.as_str()),
-            ("revision", "2"),
-            ("kind", "comment"),
-            ("body", "A **general review**."),
-            ("path-hex", ""),
-            ("side", ""),
-            ("line", ""),
-        ],
-        vec![
-            ("csrf", csrf.as_str()),
-            ("revision", "2"),
-            ("kind", "approved"),
-            ("body", ""),
-            ("path-hex", ""),
-            ("side", ""),
-            ("line", ""),
-        ],
-        vec![
-            ("csrf", csrf.as_str()),
-            ("revision", "2"),
-            ("kind", "line-comment"),
-            ("body", "Review this line."),
-            ("path-hex", "70756c6c2d726571756573742e747874"),
-            ("side", "head"),
-            ("line", "1"),
-        ],
-    ] {
-        let response = request(
-            server.address(),
-            "POST",
-            "/alice/example/pulls/1/reviews",
-            &headers,
-            form(&fields).as_bytes(),
-        );
-        assert_eq!(response.status, 303);
-    }
-    let reviewed = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
-    assert!(reviewed.text().contains("<strong>general review</strong>"));
-    assert!(reviewed.text().contains("<article class=\"comment-card\""));
-    assert!(reviewed.text().contains("approved the pull request"));
-    assert!(reviewed.text().contains("head line 1"));
-    assert!(!reviewed.text().contains("<strong>Outdated</strong>"));
-
-    fs::write(worktree.join("pull-request.txt"), b"later revision\n")
-        .expect("write a later pull-request revision");
-    commit_all(&worktree, "later pull-request revision");
-    run(Command::new("git")
-        .arg("-C")
-        .arg(&worktree)
-        .args(["push", "-q"])
-        .arg(&bare)
-        .arg("feature"));
-    let revised_again = request(
-        server.address(),
-        "POST",
-        "/alice/example/pulls/1/revisions",
-        &headers,
-        revision.as_bytes(),
-    );
-    assert_eq!(revised_again.status, 303);
-    let outdated = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
-    let first_revision_patch_after_revisions = request(
-        server.address(),
-        "GET",
-        "/alice/example/pulls/1/revisions/1.patch",
-        &[],
-        &[],
-    );
-    assert_eq!(
-        first_revision_patch_after_revisions.body,
-        immutable_revision_patch
-    );
-    assert!(outdated.text().contains("<strong>Outdated</strong>"));
-    let merge_page = request(
-        server.address(),
-        "GET",
-        "/alice/example/pulls/1",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    assert!(merge_page.text().contains("Fast-forward refs/heads/main"));
-    let cancelled_merge = request(
-        server.address(),
-        "POST",
-        "/alice/example/pulls/1/merge",
-        &headers,
-        form(&[
-            ("csrf", csrf.as_str()),
-            ("method", "fast-forward"),
-            ("confirm", "no"),
-        ])
-        .as_bytes(),
-    );
-    assert_eq!(cancelled_merge.status, 303);
-    assert_ne!(
-        rev_parse(&bare, "refs/heads/main"),
-        rev_parse(&bare, "refs/heads/feature")
-    );
-    let merge = request(
-        server.address(),
-        "POST",
-        "/alice/example/pulls/1/merge",
-        &headers,
-        form(&[
-            ("csrf", csrf.as_str()),
-            ("method", "fast-forward"),
-            ("confirm", "yes"),
-        ])
-        .as_bytes(),
-    );
-    assert_eq!(merge.status, 303);
-    let merged = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
-    assert!(merged.text().contains("merged · opened by alice"));
-    assert!(merged.text().contains("merged the pull request"));
-    assert_eq!(
-        rev_parse(&bare, "refs/heads/main"),
-        rev_parse(&bare, "refs/heads/feature")
-    );
-    let search_page = request(server.address(), "GET", "/search", &[], &[]);
-    assert_eq!(search_page.status, 200);
-    assert!(search_page.text().contains("Search repositories"));
-    let metadata_search = request(server.address(), "GET", "/search?q=example", &[], &[]);
-    assert_eq!(metadata_search.status, 200);
-    assert!(metadata_search.text().contains("/alice/example"));
-    assert_eq!(
-        request(server.address(), "GET", "/search?q=", &[], &[]).status,
-        400
-    );
-    let feed = request(server.address(), "GET", "/alice/example/rss.xml", &[], &[]);
-    assert_eq!(feed.status, 200);
-    assert!(feed.text().contains("alice reopened #1"));
-    {
-        let path = "/alice/example/issues/rss.xml";
-        let content_type = "application/rss+xml; charset=utf-8";
-        let issue_feed = request(server.address(), "GET", path, &[], &[]);
-        assert_eq!(issue_feed.status, 200);
-        assert_eq!(issue_feed.header("content-type"), content_type);
-        let parsed = feed_rs::parser::parse(issue_feed.body.as_slice())
-            .expect("parse the public issue feed");
-        assert!(!parsed.entries.is_empty());
-        assert!(issue_feed.text().contains("reopened #1"));
-        assert!(!issue_feed.text().contains("Repository imported"));
-    }
-
-    let anonymous_watch = request(server.address(), "GET", "/alice/example/watch", &[], &[]);
-    assert_eq!(anonymous_watch.status, 200);
-    assert_repository_navigation(&anonymous_watch, "alice", "example");
-    assert!(
-        anonymous_watch
-            .text()
-            .contains("Log in</a> to change watch preferences.")
-    );
-    let anonymous_activity = request(server.address(), "GET", "/activity", &[], &[]);
-    assert_eq!(anonymous_activity.status, 303);
-    assert_eq!(anonymous_activity.header("location"), "/login");
-    let watch_page = request(
-        server.address(),
-        "GET",
-        "/alice/example/watch",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    assert_eq!(watch_page.status, 200);
-    assert!(
-        watch_page
-            .text()
-            .contains("You do not watch this repository.")
-    );
-    let everything = form(&[("csrf", csrf.as_str()), ("state", "watch")]);
-    let watched = request(
-        server.address(),
-        "POST",
-        "/alice/example/watch",
-        &headers,
-        everything.as_bytes(),
-    );
-    assert_eq!(watched.status, 303);
-    assert_eq!(watched.header("location"), "/alice/example/watch");
-    let selected = request(
-        server.address(),
-        "GET",
-        "/alice/example/watch",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    assert!(
-        selected
-            .text()
-            .contains("You watch activity in this repository.")
-    );
-    let activity = request(
-        server.address(),
-        "GET",
-        "/activity",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    assert_eq!(activity.status, 200);
-    assert!(activity.text().contains("alice/example"));
-    assert!(activity.text().contains("reopened #1"));
-
-    let feed_tokens = request(
-        server.address(),
-        "GET",
-        "/feeds",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    assert_eq!(feed_tokens.status, 200);
-    assert!(feed_tokens.text().contains("Feed URLs are credentials."));
-    let issue_repository_token = form(&[("csrf", csrf.as_str())]);
-    let issued = request(
-        server.address(),
-        "POST",
-        "/feeds/tokens",
-        &headers,
-        issue_repository_token.as_bytes(),
-    );
-    assert_eq!(issued.status, 201);
-    assert!(issued.text().contains("tit will not show it again."));
-    let private_token = extract_feed_token(issued.text());
-    let private_path = format!("/feeds/{private_token}/rss.xml");
-    let private_feed = request(server.address(), "GET", &private_path, &[], &[]);
-    assert_eq!(private_feed.status, 200);
-    assert_eq!(private_feed.header("cache-control"), "private, no-store");
-    assert_eq!(private_feed.header("referrer-policy"), "no-referrer");
-    assert!(private_feed.text().contains("alice/example"));
-    let private_hash: [u8; 32] = Sha256::digest(private_token.as_bytes()).into();
-    let token_store = Store::open(&database).expect("open the feed token database");
-    let (private_id, stored_hash): (String, Vec<u8>) = token_store
-        .connection()
-        .query_row(
-            "SELECT id, token_hash FROM feed_token
-             WHERE scope = 'watched' AND revoked_at IS NULL",
-            [],
-            |row| Ok((row.get(0)?, row.get(1)?)),
-        )
-        .expect("read the stored feed token hash");
-    assert_eq!(stored_hash, private_hash);
-    assert_ne!(stored_hash, private_token.as_bytes());
-    let selected_events = token_store
-        .watched_activity_page("alice", None, crate::feed::PAGE_SIZE)
-        .expect("read watched Web activity");
-    assert!(!selected_events.events.is_empty());
-    for event in &selected_events.events {
-        assert!(activity.text().contains(&event.event.event_id));
-        assert!(
-            private_feed
-                .text()
-                .contains(&format!("urn:tit:event:{}", event.event.event_id))
-        );
-    }
-    drop(token_store);
-    let hidden_token = request(
-        server.address(),
-        "GET",
-        "/feeds",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    assert!(!hidden_token.text().contains(&private_token));
-
-    let private_store = Store::open(&database).expect("open the private feed database");
-    private_store
+    Store::open(&database)
+        .expect("open the repository database")
         .connection()
         .execute(
             "UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
             [],
         )
-        .expect("make the feed repository private");
-    drop(private_store);
-    let private_archive_path = format!("/alice/example/archive/{}.tar", fixture.head);
-    let owner_archive = request(
-        server.address(),
-        "GET",
-        &private_archive_path,
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    assert_eq!(owner_archive.status, 200);
-    assert_eq!(owner_archive.header("cache-control"), "private, no-store");
-    let anonymous_archive = request(server.address(), "GET", &private_archive_path, &[], &[]);
-    assert_eq!(anonymous_archive.status, 404);
-    let private_commit_patch_path = format!("/alice/example/commit/{}.patch", fixture.head);
-    assert_eq!(
-        request(
-            server.address(),
-            "GET",
-            &private_commit_patch_path,
-            &[("Cookie", cookie.as_str())],
-            &[],
-        )
-        .status,
-        200
-    );
-    assert_eq!(
-        request(
-            server.address(),
-            "GET",
-            &private_commit_patch_path,
-            &[],
-            &[],
-        )
-        .status,
-        404
-    );
-    assert_eq!(
-        request(
-            server.address(),
-            "GET",
-            "/alice/example/pulls/1/revisions/1.patch",
-            &[],
-            &[],
-        )
-        .status,
-        404
-    );
-    assert_eq!(
-        request(
-            server.address(),
-            "GET",
-            "/alice/example/issues/rss.xml",
-            &[],
-            &[],
-        )
-        .status,
-        404
-    );
-    let anonymous_private_search = request(server.address(), "GET", "/search?q=example", &[], &[]);
-    assert!(
-        anonymous_private_search
-            .text()
-            .contains("No repository matched")
-    );
-    let owner_private_search = request(
-        server.address(),
-        "GET",
-        "/search?q=example",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    assert!(owner_private_search.text().contains("/alice/example"));
-    assert_eq!(
-        request(server.address(), "GET", &private_path, &[], &[]).status,
-        200
-    );
-
-    let cancelled_rotate = form(&[("csrf", csrf.as_str()), ("confirm", "no")]);
-    let cancelled_rotation = request(
-        server.address(),
-        "POST",
-        &format!("/feeds/tokens/{private_id}/rotate"),
-        &headers,
-        cancelled_rotate.as_bytes(),
-    );
-    assert_eq!(cancelled_rotation.status, 303);
-    assert_eq!(
-        request(server.address(), "GET", &private_path, &[], &[]).status,
-        200
-    );
-    let rotate = form(&[("csrf", csrf.as_str()), ("confirm", "yes")]);
-    let rotated = request(
-        server.address(),
-        "POST",
-        &format!("/feeds/tokens/{private_id}/rotate"),
-        &headers,
-        rotate.as_bytes(),
-    );
-    assert_eq!(rotated.status, 201);
-    let rotated_token = extract_feed_token(rotated.text());
-    assert_ne!(rotated_token, private_token);
-    assert_eq!(
-        request(server.address(), "GET", &private_path, &[], &[]).status,
-        404
-    );
-    let rotated_path = format!("/feeds/{rotated_token}/rss.xml");
-    assert_eq!(
-        request(server.address(), "GET", &rotated_path, &[], &[]).status,
-        200
-    );
-    let rotated_hash: [u8; 32] = Sha256::digest(rotated_token.as_bytes()).into();
-    let rotated_store = Store::open(&database).expect("open the rotated feed database");
-    let rotated_id: String = rotated_store
-        .connection()
-        .query_row(
-            "SELECT id FROM feed_token WHERE token_hash = ?1 AND revoked_at IS NULL",
-            [rotated_hash.as_slice()],
-            |row| row.get(0),
-        )
-        .expect("read the rotated feed token ID");
-    drop(rotated_store);
-    let revoked = request(
-        server.address(),
-        "POST",
-        &format!("/feeds/tokens/{rotated_id}/revoke"),
-        &headers,
-        rotate.as_bytes(),
-    );
-    assert_eq!(revoked.status, 303);
-    assert_eq!(
-        request(server.address(), "GET", &rotated_path, &[], &[]).status,
-        404
-    );
-
-    let body = form(&[("csrf", csrf.as_str())]);
-    let issued = request(
-        server.address(),
-        "POST",
-        "/feeds/tokens",
-        &headers,
-        body.as_bytes(),
-    );
-    assert_eq!(issued.status, 201);
-    let token = extract_feed_token(issued.text());
-    let response = request(
-        server.address(),
-        "GET",
-        &format!("/feeds/{token}/rss.xml"),
-        &[],
-        &[],
-    );
-    assert_eq!(response.status, 200);
-    feed_rs::parser::parse(response.body.as_slice()).expect("parse the watched feed");
-    assert!(response.text().contains("commented on #1"));
-
-    let none = form(&[("csrf", csrf.as_str()), ("state", "unwatch")]);
-    assert_eq!(
-        request(
-            server.address(),
-            "POST",
-            "/alice/example/watch",
-            &headers,
-            none.as_bytes(),
-        )
-        .status,
-        303
-    );
-    assert_eq!(
-        Store::open(&database)
-            .expect("reopen the watch database")
-            .connection()
-            .query_row("SELECT count(*) FROM watch", [], |row| row.get::<_, i64>(0))
-            .expect("count cleared watches"),
-        0
-    );
-
-    let reader_cookie = format!("tit-session={reader_token}; tit-csrf={reader_csrf}");
-    let reader_headers = [
-        ("Content-Type", "application/x-www-form-urlencoded"),
-        ("Cookie", reader_cookie.as_str()),
-    ];
-    let denied_rename = form(&[("csrf", reader_csrf.as_str()), ("new-name", "reader-name")]);
-    assert_eq!(
-        request(
-            server.address(),
-            "POST",
-            "/alice/example/settings/rename",
-            &reader_headers,
-            denied_rename.as_bytes(),
-        )
-        .status,
-        403
-    );
-    let conflicting_rename = form(&[("csrf", csrf.as_str()), ("new-name", "empty")]);
-    assert_eq!(
-        request(
-            server.address(),
-            "POST",
-            "/alice/example/settings/rename",
-            &headers,
-            conflicting_rename.as_bytes(),
-        )
-        .status,
-        400
-    );
-    let rename = form(&[("csrf", csrf.as_str()), ("new-name", "renamed")]);
-    let renamed = request(
-        server.address(),
-        "POST",
-        "/alice/example/settings/rename",
-        &headers,
-        rename.as_bytes(),
-    );
-    assert_eq!(renamed.status, 303);
-    assert_eq!(renamed.header("location"), "/alice/renamed/settings");
+        .expect("make the repository private");
     assert_eq!(
         request(server.address(), "GET", "/alice/example", &[], &[]).status,
         404
@@ -1508,129 +123,34 @@
         request(
             server.address(),
             "GET",
-            "/alice/renamed/issues/1",
+            "/alice/example",
             &[("Cookie", cookie.as_str())],
             &[],
         )
         .status,
         200
     );
-    assert_eq!(
-        request(
-            server.address(),
-            "GET",
-            "/alice/renamed/pulls/1",
-            &[("Cookie", cookie.as_str())],
-            &[],
-        )
-        .status,
-        200
-    );
-    let bare = fixture
-        .instance
-        .path()
-        .join("repositories")
-        .join(format!("{}.git", fixture.repository_id));
-    let ref_before_archive = rev_parse(&bare, "refs/heads/main");
-    let archive_repository = form(&[("csrf", csrf.as_str()), ("confirm", "yes")]);
-    assert_eq!(
-        request(
-            server.address(),
-            "POST",
-            "/alice/renamed/settings/archive",
-            &headers,
-            archive_repository.as_bytes(),
-        )
-        .status,
-        303
-    );
-    let archived_home = request(
-        server.address(),
-        "GET",
-        "/",
-        &[("Cookie", cookie.as_str())],
-        &[],
-    );
-    assert!(
-        archived_home
-            .text()
-            .contains("<strong>alice/renamed</strong>")
-    );
-    assert!(
-        archived_home
-            .text()
-            .contains("<span class=\"repository-meta\">archived</span>")
-    );
-    assert!(archived_home.text().contains("Unarchive repository"));
-    let denied_unarchive = form(&[("csrf", reader_csrf.as_str()), ("confirm", "yes")]);
-    assert_eq!(
-        request(
-            server.address(),
-            "POST",
-            "/alice/renamed/settings/unarchive",
-            &reader_headers,
-            denied_unarchive.as_bytes(),
-        )
-        .status,
-        403
-    );
-    let unarchive = form(&[("csrf", csrf.as_str()), ("confirm", "yes")]);
-    assert_eq!(
-        request(
-            server.address(),
-            "POST",
-            "/alice/renamed/settings/unarchive",
-            &headers,
-            unarchive.as_bytes(),
-        )
-        .status,
-        303
-    );
-    assert_eq!(rev_parse(&bare, "refs/heads/main"), ref_before_archive);
-    assert_eq!(
-        request(
-            server.address(),
-            "GET",
-            "/alice/renamed",
-            &[("Cookie", cookie.as_str())],
-            &[],
-        )
-        .status,
-        200
-    );
-    let lifecycle_store = Store::open(&database).expect("open the lifecycle database");
-    let lifecycle: (String, String, i64) = lifecycle_store
-        .connection()
-        .query_row(
-            "SELECT repository.visibility, repository.state,
-                    (SELECT count(*) FROM repository_collaborator
-                     WHERE repository_id = repository.id)
-             FROM repository WHERE repository.slug = 'renamed'",
-            [],
-            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
-        )
-        .expect("read repository lifecycle state");
-    assert_eq!(lifecycle, ("private".to_owned(), "active".to_owned(), 1));
-    for action in [
-        "repository.rename",
-        "repository.archive",
-        "repository.unarchive",
-    ] {
-        assert_eq!(
-            lifecycle_store
-                .connection()
-                .query_row(
-                    "SELECT count(*) FROM audit_event
-                     WHERE action = ?1 AND actor = 'alice' AND outcome = 'success'",
-                    [action],
-                    |row| row.get::<_, i64>(0),
-                )
-                .expect("count a repository lifecycle audit event"),
-            1
-        );
-    }
 
-    server.shutdown().await.expect("stop the issue Web server");
+    server.shutdown().await.expect("stop the public Web server");
+}
+
+fn create_session(database: &Path, token: &str, csrf: &str) {
+    let session_hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
+    let csrf_hash: [u8; 32] = Sha256::digest(csrf.as_bytes()).into();
+    let now = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .expect("read the current time")
+        .as_secs() as i64;
+    Store::open(database)
+        .expect("open the session database")
+        .connection()
+        .execute(
+            "INSERT INTO web_session
+             (session_hash, csrf_hash, account_id, created_at, expires_at)
+             SELECT ?1, ?2, id, ?3, ?4 FROM account WHERE username = 'alice'",
+            rusqlite::params![session_hash, csrf_hash, now, now + 3_600],
+        )
+        .expect("create a Web session");
 }
 
 fn form(fields: &[(&str, &str)]) -> String {
@@ -1639,53 +159,21 @@
     serializer.finish()
 }
 
-fn extract_feed_token(body: &str) -> String {
-    let marker = "/feeds/";
-    let start = body.find(marker).expect("find a feed URL") + marker.len();
-    let token = body[start..].split('/').next().expect("read a feed token");
-    assert_eq!(token.len(), 64);
-    assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit()));
-    token.to_owned()
-}
-
-fn assert_hidden(address: SocketAddr, head: &str) {
-    for route in [
-        "/alice/example".to_owned(),
-        format!("/alice/example/raw/{head}/README.md"),
-        "/alice/example/atom.xml".to_owned(),
-        "/alice/example/rss.xml".to_owned(),
-        "/alice/example/search?q=text".to_owned(),
-        "/alice/example/info/refs?service=git-upload-pack".to_owned(),
-    ] {
-        let response = request(address, "GET", &route, &[], &[]);
-        assert_eq!(response.status, 404, "repository was visible at {route}");
-    }
-}
-
 struct Fixture {
     instance: TempDir,
-    worktree: PathBuf,
-    repository_id: String,
-    head: String,
-    parent: String,
 }
 
 impl Fixture {
-    fn new(format: &str) -> Self {
+    fn new() -> Self {
         let instance = TempDir::new().expect("create an instance directory");
         let repositories = instance.path().join("repositories");
         fs::create_dir(&repositories).expect("create the repository directory");
-        let id = if format == "sha1" {
-            "11111111111111111111111111111111"
-        } else {
-            "22222222222222222222222222222222"
-        };
-        let bare = repositories.join(format!("{id}.git"));
-        let empty_bare = repositories.join("33333333333333333333333333333333.git");
         let worktree = instance.path().join("worktree");
+        let repository_id = "11111111111111111111111111111111";
+        let bare = repositories.join(format!("{repository_id}.git"));
 
         run(Command::new("git")
-            .args(["init", "-q", "-b", "main", "--object-format", format])
+            .args(["init", "-q", "-b", "main"])
             .arg(&worktree));
         run(Command::new("git").arg("-C").arg(&worktree).args([
             "config",
@@ -1694,73 +182,24 @@
         ]));
         fs::write(
             worktree.join("README.md"),
-            b"# tit fixture\n\n**safe** and `<safe>`\n\n[guide](docs/guide.md) [license](LICENSE) [bad](javascript:alert(1))\n\n![tracker](https://tracker.example/pixel)\n\n<script>alert(2)</script>\n",
+            "# tit fixture\n\n**safe**\n\n<script>alert(1)</script>\n",
         )
         .expect("write the README");
-        fs::write(worktree.join("LICENSE"), b"fixture license\n").expect("write the license");
-        fs::create_dir(worktree.join("nested")).expect("create a nested directory");
-        fs::write(worktree.join("nested/file.txt"), b"first line\n").expect("write the text file");
-        fs::write(worktree.join("binary.dat"), b"binary\0content").expect("write the binary file");
-        let mut large = vec![b'x'; 2 * 1024 * 1024];
-        let prefix = b"large content starts\n";
-        large[..prefix.len()].copy_from_slice(prefix);
-        let suffix = b"\nlarge content ends\n";
-        let suffix_start = large.len() - suffix.len();
-        large[suffix_start..].copy_from_slice(suffix);
-        fs::write(worktree.join("large.txt"), large).expect("write the large file");
-        fs::write(
-            worktree.join("<img src=x onerror=alert(4)>.txt"),
-            b"escaped path\n",
-        )
-        .expect("write the hostile path");
-        fs::write(worktree.join("non-å.txt"), b"non-UTF-8 path\n")
-            .expect("write the percent-encoded path");
-        fs::write(worktree.join("malformed.txt"), b"start \xff needle\n")
-            .expect("write malformed UTF-8 content");
-        for index in 0..=50 {
-            fs::write(
-                worktree.join(format!("summary-overflow-{index:02}.txt")),
-                b"summary entry\n",
-            )
-            .expect("write a summary-limit file");
-        }
-        commit_all(&worktree, "first commit");
-        let parent = rev_parse(&worktree, "HEAD");
-        for index in 1..=100 {
-            commit_empty(&worktree, &format!("intermediate commit {index}"));
-        }
-        fs::write(
-            worktree.join("nested/file.txt"),
-            b"first line\nsecond line\n",
-        )
-        .expect("update the text file");
-        commit_all(&worktree, "<script>alert(3)</script>");
-        let head = rev_parse(&worktree, "HEAD");
-        for (tag, target) in [
-            ("v0.1", parent.as_str()),
-            ("v0.2", "HEAD~2"),
-            ("v0.3", "HEAD~1"),
-            ("v0.4", "HEAD"),
-        ] {
-            run(Command::new("git").arg("-C").arg(&worktree).args([
-                "-c",
-                "tag.gpgSign=false",
-                "tag",
-                tag,
-                target,
-            ]));
-        }
         run(Command::new("git")
             .arg("-C")
             .arg(&worktree)
-            .args(["branch", "feature"]));
-
+            .args(["add", "README.md"]));
         run(Command::new("git")
-            .args(["init", "-q", "--bare", "--object-format", format])
+            .arg("-C")
+            .arg(&worktree)
+            .args(["commit", "-q", "-m", "initial commit"])
+            .env("GIT_AUTHOR_NAME", "Fixture Author")
+            .env("GIT_AUTHOR_EMAIL", "fixture@example.test")
+            .env("GIT_COMMITTER_NAME", "Fixture Author")
+            .env("GIT_COMMITTER_EMAIL", "fixture@example.test"));
+        run(Command::new("git")
+            .args(["init", "-q", "--bare"])
             .arg(&bare));
-        run(Command::new("git")
-            .args(["init", "-q", "--bare", "--object-format", format])
-            .arg(&empty_bare));
         run(Command::new("git").arg("-C").arg(&bare).args([
             "symbolic-ref",
             "HEAD",
@@ -1771,13 +210,7 @@
             .arg(&worktree)
             .args(["push", "-q"])
             .arg(&bare)
-            .args(["main", "feature"]));
-        run(Command::new("git")
-            .arg("-C")
-            .arg(&worktree)
-            .args(["push", "-q"])
-            .arg(&bare)
-            .arg("--tags"));
+            .arg("main"));
 
         let database = instance.path().join(store::DATABASE_FILE);
         let mut store = Store::open(&database).expect("open the fixture database");
@@ -1792,10 +225,10 @@
             .expect("create the repository owner");
         store
             .create_repository(&NewRepository {
-                id,
+                id: repository_id,
                 owner: "alice",
                 slug: "example",
-                object_format: format,
+                object_format: "sha1",
                 default_branch: "refs/heads/main",
                 created_at: 2,
                 origin: RepositoryOrigin::Imported,
@@ -1804,70 +237,9 @@
                 correlation_id: "test-import",
             })
             .expect("create the repository record");
-        store
-            .create_repository(&NewRepository {
-                id: "33333333333333333333333333333333",
-                owner: "alice",
-                slug: "empty",
-                object_format: format,
-                default_branch: "refs/heads/main",
-                created_at: 2,
-                origin: RepositoryOrigin::Created,
-                initial_references: &[],
-                actor: "admin-cli",
-                correlation_id: "test-create",
-            })
-            .expect("create the empty repository record");
-        drop(store);
 
-        Self {
-            instance,
-            worktree,
-            repository_id: id.to_owned(),
-            head,
-            parent,
-        }
+        Self { instance }
     }
-}
-
-fn commit_all(worktree: &Path, message: &str) {
-    run(Command::new("git")
-        .arg("-C")
-        .arg(worktree)
-        .args(["add", "-A"]));
-    run(Command::new("git")
-        .arg("-C")
-        .arg(worktree)
-        .args(["commit", "-q", "-m", message])
-        .env("GIT_AUTHOR_NAME", "Fixture Author")
-        .env("GIT_AUTHOR_EMAIL", "fixture@example.test")
-        .env("GIT_COMMITTER_NAME", "Fixture Author")
-        .env("GIT_COMMITTER_EMAIL", "fixture@example.test"));
-}
-
-fn commit_empty(worktree: &Path, message: &str) {
-    run(Command::new("git")
-        .arg("-C")
-        .arg(worktree)
-        .args(["commit", "-q", "--allow-empty", "-m", message])
-        .env("GIT_AUTHOR_NAME", "Fixture Author")
-        .env("GIT_AUTHOR_EMAIL", "fixture@example.test")
-        .env("GIT_COMMITTER_NAME", "Fixture Author")
-        .env("GIT_COMMITTER_EMAIL", "fixture@example.test"));
-}
-
-fn rev_parse(repository: &Path, revision: &str) -> String {
-    let output = Command::new("git")
-        .arg("-C")
-        .arg(repository)
-        .args(["rev-parse", revision])
-        .output()
-        .expect("read a Git object ID");
-    assert!(output.status.success());
-    String::from_utf8(output.stdout)
-        .expect("a hexadecimal object ID")
-        .trim()
-        .to_owned()
 }
 
 fn run(command: &mut Command) {
@@ -1887,6 +259,12 @@
     body: &[u8],
 ) -> HttpResponse {
     let mut stream = TcpStream::connect(address).expect("connect to the public Web server");
+    stream
+        .set_read_timeout(Some(RESPONSE_TIMEOUT))
+        .expect("set the response timeout");
+    stream
+        .set_write_timeout(Some(RESPONSE_TIMEOUT))
+        .expect("set the request timeout");
     let mut head = format!(
         "{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n",
         body.len()
@@ -1898,14 +276,7 @@
     stream
         .write_all(head.as_bytes())
         .expect("write HTTP request headers");
-    if let Err(error) = stream.write_all(body)
-        && !matches!(
-            error.kind(),
-            std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset
-        )
-    {
-        panic!("write an HTTP request: {error}");
-    }
+    stream.write_all(body).expect("write the HTTP request");
     let mut response = Vec::new();
     if let Err(error) = stream.read_to_end(&mut response)
         && error.kind() != std::io::ErrorKind::ConnectionReset
@@ -1921,23 +292,6 @@
     assert_eq!(response.header("x-frame-options"), "DENY");
     assert_eq!(response.header("referrer-policy"), "no-referrer");
     assert_eq!(response.header("cache-control"), "no-store");
-    assert_eq!(response.header("x-request-id").len(), 32);
-}
-
-fn assert_repository_navigation(response: &HttpResponse, owner: &str, repository: &str) {
-    let text = response.text();
-    assert!(text.contains(&format!(
-        "<h1><a href=\"/{owner}\">{owner}</a>/<a href=\"/{owner}/{repository}\">{repository}</a></h1>"
-    )));
-    for suffix in [
-        "", "/refs", "/issues", "/pulls", "/watch", "/rss.xml", "/search",
-    ] {
-        let link = format!("/{owner}/{repository}{suffix}");
-        assert!(
-            text.contains(&format!("href=\"{link}\"")),
-            "repository navigation is missing {link}"
-        );
-    }
 }
 
 struct HttpResponse {

tests/pull_requests.rs

Mode 100644100644; object 8818d416de289d0c9e159a4f

@@ -1,17 +1,16 @@
-use crate::{git, pull_request, store};
+use crate::{git, maintenance::MaintenanceGate, pull_request, store};
 
 use std::fs;
 use std::os::unix::ffi::OsStringExt;
 use std::os::unix::fs::PermissionsExt;
 use std::path::{Path, PathBuf};
 use std::process::Command;
-use std::sync::Arc;
 use std::time::Duration;
 
 use git::read::{Mergeability, ReadCancellation, ReadError, ReadLimits, RepositoryReadService};
 use git::repository::GitRepository;
 use gix::hash::ObjectId;
-use pull_request::{PullRequestError, PullRequestService};
+use pull_request::{NewPullRequest, PullRequestError, PullRequestReview, PullRequestService};
 use rusqlite::params;
 use store::{GitOperationIntent, NewPullRequestMerge, NewPullRequestRefIntent, Store, StoreError};
 use tempfile::TempDir;
@@ -22,15 +21,15 @@
         let fixture = Fixture::new(object_format, index);
         let service = PullRequestService::new(&fixture.database, &fixture.repositories);
         let opened = service
-            .open(
-                "alice",
-                "project",
-                "alice",
-                "Add the feature",
-                "Keep the revision context.",
-                "refs/heads/main",
-                "refs/heads/feature",
-            )
+            .open(&NewPullRequest {
+                owner: "alice",
+                repository: "project",
+                actor: "alice",
+                title: "Add the feature",
+                body: "Keep the revision context.",
+                base_ref: "refs/heads/main",
+                head_ref: "refs/heads/feature",
+            })
             .expect("open a pull request");
         assert_eq!(opened.number, 1);
         assert_eq!(fixture.pull_ref(1), opened.head_object_id);
@@ -101,15 +100,15 @@
             opened.head_object_id
         );
         assert!(matches!(
-            service.open(
-                "alice",
-                "project",
-                "bob",
-                "Reader change",
-                "Readers cannot open pull requests.",
-                "refs/heads/main",
-                "refs/heads/feature",
-            ),
+            service.open(&NewPullRequest {
+                owner: "alice",
+                repository: "project",
+                actor: "bob",
+                title: "Reader change",
+                body: "Readers cannot open pull requests.",
+                base_ref: "refs/heads/main",
+                head_ref: "refs/heads/feature",
+            }),
             Err(PullRequestError::Store(StoreError::PullRequestDenied))
         ));
 
@@ -219,25 +218,39 @@
             pending_revision.revision_number
         );
 
-        let service = Arc::new(service);
-        let handles = ["Concurrent A", "Concurrent B"].map(|title| {
-            let service = Arc::clone(&service);
-            std::thread::spawn(move || {
-                service
-                    .open(
-                        "alice",
-                        "project",
-                        "alice",
-                        title,
-                        "Use one stable number.",
-                        "refs/heads/main",
-                        "refs/heads/feature",
-                    )
-                    .expect("open a concurrent pull request")
-                    .number
+        let gate = MaintenanceGate::default();
+        let services = [
+            PullRequestService::new_with_gate(
+                &fixture.database,
+                &fixture.repositories,
+                gate.clone(),
+            ),
+            PullRequestService::new_with_gate(&fixture.database, &fixture.repositories, gate),
+        ];
+        let handles = ["Concurrent A", "Concurrent B"]
+            .into_iter()
+            .zip(services)
+            .map(|(title, service)| {
+                std::thread::spawn(move || {
+                    service
+                        .open(&NewPullRequest {
+                            owner: "alice",
+                            repository: "project",
+                            actor: "alice",
+                            title,
+                            body: "Use one stable number.",
+                            base_ref: "refs/heads/main",
+                            head_ref: "refs/heads/feature",
+                        })
+                        .expect("open a concurrent pull request")
+                        .number
+                })
             })
-        });
-        let mut numbers = handles.map(|handle| handle.join().expect("join an opener"));
+            .collect::<Vec<_>>();
+        let mut numbers = handles
+            .into_iter()
+            .map(|handle| handle.join().expect("join an opener"))
+            .collect::<Vec<_>>();
         numbers.sort_unstable();
         assert_eq!(numbers, [3, 4]);
         assert_eq!(fixture.pull_ref(3), next_head.to_string());
@@ -278,15 +291,15 @@
         let fixture = Fixture::new(object_format, index + 30);
         let service = PullRequestService::new(&fixture.database, &fixture.repositories);
         let opened = service
-            .open(
-                "alice",
-                "project",
-                "alice",
-                "Fast-forward the feature",
-                "Move the base ref to the reviewed head.",
-                "refs/heads/main",
-                "refs/heads/feature",
-            )
+            .open(&NewPullRequest {
+                owner: "alice",
+                repository: "project",
+                actor: "alice",
+                title: "Fast-forward the feature",
+                body: "Move the base ref to the reviewed head.",
+                base_ref: "refs/heads/main",
+                head_ref: "refs/heads/feature",
+            })
             .expect("open a fast-forward pull request");
         assert!(matches!(
             service.merge("alice", "project", 1, "bob", "fast-forward"),
@@ -352,15 +365,15 @@
     let fixture = Fixture::new("sha1", 40);
     let service = PullRequestService::new(&fixture.database, &fixture.repositories);
     service
-        .open(
-            "alice",
-            "project",
-            "alice",
-            "Stale base",
-            "Do not merge a stale comparison.",
-            "refs/heads/main",
-            "refs/heads/feature",
-        )
+        .open(&NewPullRequest {
+            owner: "alice",
+            repository: "project",
+            actor: "alice",
+            title: "Stale base",
+            body: "Do not merge a stale comparison.",
+            base_ref: "refs/heads/main",
+            head_ref: "refs/heads/feature",
+        })
         .expect("open a pull request");
     fixture.commit_on("main", "base.txt", "new base\n", "move the base");
     assert!(matches!(
@@ -424,15 +437,15 @@
 
         let service = PullRequestService::new(&fixture.database, &fixture.repositories);
         service
-            .open(
-                "alice",
-                "project",
-                "alice",
-                "Merge the rename",
-                "Keep the rename and executable mode.",
-                "refs/heads/main",
-                "refs/heads/feature",
-            )
+            .open(&NewPullRequest {
+                owner: "alice",
+                repository: "project",
+                actor: "alice",
+                title: "Merge the rename",
+                body: "Keep the rename and executable mode.",
+                base_ref: "refs/heads/main",
+                head_ref: "refs/heads/feature",
+            })
             .expect("open a divergent pull request");
         service
             .merge("alice", "project", 1, "alice", "merge-commit")
@@ -469,15 +482,15 @@
     let base = rev_parse(&fixture.bare, "refs/heads/main");
     let service = PullRequestService::new(&fixture.database, &fixture.repositories);
     service
-        .open(
-            "alice",
-            "project",
-            "alice",
-            "Conflicting merge",
-            "Do not create a conflict commit.",
-            "refs/heads/main",
-            "refs/heads/feature",
-        )
+        .open(&NewPullRequest {
+            owner: "alice",
+            repository: "project",
+            actor: "alice",
+            title: "Conflicting merge",
+            body: "Do not create a conflict commit.",
+            base_ref: "refs/heads/main",
+            head_ref: "refs/heads/feature",
+        })
         .expect("open a conflicting pull request");
     assert!(matches!(
         service.merge("alice", "project", 1, "alice", "merge-commit"),
@@ -501,15 +514,15 @@
     let fixture = Fixture::new("sha1", 70);
     let service = PullRequestService::new(&fixture.database, &fixture.repositories);
     service
-        .open(
-            "alice",
-            "project",
-            "alice",
-            "Recover this merge",
-            "Complete metadata after the ref update.",
-            "refs/heads/main",
-            "refs/heads/feature",
-        )
+        .open(&NewPullRequest {
+            owner: "alice",
+            repository: "project",
+            actor: "alice",
+            title: "Recover this merge",
+            body: "Complete metadata after the ref update.",
+            base_ref: "refs/heads/main",
+            head_ref: "refs/heads/feature",
+        })
         .expect("open a recoverable pull request");
     begin_test_merge_intent(&fixture, 1, "71000000000000000000000000000000");
     let git = GitRepository::open(&fixture.bare).expect("open the recovery repository");
@@ -534,15 +547,15 @@
     let concurrent = Fixture::new("sha1", 71);
     let service = PullRequestService::new(&concurrent.database, &concurrent.repositories);
     service
-        .open(
-            "alice",
-            "project",
-            "alice",
-            "Race the base",
-            "A concurrent base update wins before this ref moves.",
-            "refs/heads/main",
-            "refs/heads/feature",
-        )
+        .open(&NewPullRequest {
+            owner: "alice",
+            repository: "project",
+            actor: "alice",
+            title: "Race the base",
+            body: "A concurrent base update wins before this ref moves.",
+            base_ref: "refs/heads/main",
+            head_ref: "refs/heads/feature",
+        })
         .expect("open a concurrent pull request");
     begin_test_merge_intent(&concurrent, 1, "72000000000000000000000000000000");
     concurrent.commit_on("main", "raced.txt", "concurrent\n", "race the merge");
@@ -593,15 +606,15 @@
         );
         let service = PullRequestService::new(&fixture.database, &fixture.repositories);
         service
-            .open(
-                "alice",
-                "project",
-                "alice",
-                "Clean divergence",
-                "The branches change different paths.",
-                "refs/heads/main",
-                "refs/heads/feature",
-            )
+            .open(&NewPullRequest {
+                owner: "alice",
+                repository: "project",
+                actor: "alice",
+                title: "Clean divergence",
+                body: "The branches change different paths.",
+                base_ref: "refs/heads/main",
+                head_ref: "refs/heads/feature",
+            })
             .expect("open a clean divergent pull request");
         let object_state = git_object_state(&fixture.bare);
         let clean = service
@@ -619,15 +632,15 @@
             "change feature content",
         );
         service
-            .open(
-                "alice",
-                "project",
-                "alice",
-                "Conflicting divergence",
-                "The branches change the same line.",
-                "refs/heads/main",
-                "refs/heads/feature",
-            )
+            .open(&NewPullRequest {
+                owner: "alice",
+                repository: "project",
+                actor: "alice",
+                title: "Conflicting divergence",
+                body: "The branches change the same line.",
+                base_ref: "refs/heads/main",
+                head_ref: "refs/heads/feature",
+            })
             .expect("open a conflicting pull request");
         let object_state = git_object_state(&fixture.bare);
         let conflicting = service
@@ -641,15 +654,15 @@
 
         fixture.merge_feature_into_main();
         service
-            .open(
-                "alice",
-                "project",
-                "alice",
-                "Merged head",
-                "The head is already in the base.",
-                "refs/heads/main",
-                "refs/heads/feature",
-            )
+            .open(&NewPullRequest {
+                owner: "alice",
+                repository: "project",
+                actor: "alice",
+                title: "Merged head",
+                body: "The head is already in the base.",
+                base_ref: "refs/heads/main",
+                head_ref: "refs/heads/feature",
+            })
             .expect("open an already merged pull request");
         let merged = service
             .compare("alice", "project", 3, None, None)
@@ -658,15 +671,15 @@
 
         fixture.create_unrelated_branch();
         service
-            .open(
-                "alice",
-                "project",
-                "alice",
-                "Unrelated head",
-                "The branches do not have a common commit.",
-                "refs/heads/main",
-                "refs/heads/unrelated",
-            )
+            .open(&NewPullRequest {
+                owner: "alice",
+                repository: "project",
+                actor: "alice",
+                title: "Unrelated head",
+                body: "The branches do not have a common commit.",
+                base_ref: "refs/heads/main",
+                head_ref: "refs/heads/unrelated",
+            })
             .expect("open an unrelated pull request");
         let unrelated = service
             .compare("alice", "project", 4, None, None)
@@ -726,76 +739,85 @@
         fixture.commit_bytes_on("feature", byte_path, b"byte path\n", "add a byte path");
         let service = PullRequestService::new(&fixture.database, &fixture.repositories);
         service
-            .open(
-                "alice",
-                "project",
-                "alice",
-                "Review anchors",
-                "Keep each review action.",
-                "refs/heads/main",
-                "refs/heads/feature",
-            )
+            .open(&NewPullRequest {
+                owner: "alice",
+                repository: "project",
+                actor: "alice",
+                title: "Review anchors",
+                body: "Keep each review action.",
+                base_ref: "refs/heads/main",
+                head_ref: "refs/heads/feature",
+            })
             .expect("open a reviewed pull request");
         service
-            .review(
-                "alice",
-                "project",
-                1,
-                1,
-                "bob",
-                "comment",
-                "A **general** comment.",
-                None,
-                None,
-                None,
-            )
+            .review(&PullRequestReview {
+                owner: "alice",
+                repository: "project",
+                number: 1,
+                revision: 1,
+                actor: "bob",
+                kind: "comment",
+                body: "A **general** comment.",
+                path: None,
+                side: None,
+                line: None,
+            })
             .expect("add a reader comment");
         service
-            .review(
-                "alice", "project", 1, 1, "bob", "approved", "", None, None, None,
-            )
+            .review(&PullRequestReview {
+                owner: "alice",
+                repository: "project",
+                number: 1,
+                revision: 1,
+                actor: "bob",
+                kind: "approved",
+                body: "",
+                path: None,
+                side: None,
+                line: None,
+            })
             .expect("approve the revision");
         service
-            .review(
-                "alice",
-                "project",
-                1,
-                1,
-                "alice",
-                "changes-requested",
-                "Change this line.",
-                None,
-                None,
-                None,
-            )
+            .review(&PullRequestReview {
+                owner: "alice",
+                repository: "project",
+                number: 1,
+                revision: 1,
+                actor: "alice",
+                kind: "changes-requested",
+                body: "Change this line.",
+                path: None,
+                side: None,
+                line: None,
+            })
             .expect("request changes");
         let line_id = service
-            .review(
-                "alice",
-                "project",
-                1,
-                1,
-                "bob",
-                "line-comment",
-                "Use a clearer value.",
-                Some(byte_path),
-                Some("head"),
-                Some(1),
-            )
+            .review(&PullRequestReview {
+                owner: "alice",
+                repository: "project",
+                number: 1,
+                revision: 1,
+                actor: "bob",
+                kind: "line-comment",
+                body: "Use a clearer value.",
+                path: Some(byte_path),
+                side: Some("head"),
+                line: Some(1),
+            })
             .expect("add a line comment");
         assert!(matches!(
-            service.review(
-                "alice",
-                "project",
-                1,
-                1,
-                "bob",
-                "line-comment",
-                "This line does not exist.",
-                Some(byte_path),
-                Some("head"),
-                Some(2),
-            ),
+            service.review(&PullRequestReview {
+                owner: "alice",
+                repository: "project",
+                number: 1,
+                revision: 1,
+                actor: "bob",
+                kind: "line-comment",
+                body: "This line does not exist.",
+                path: Some(byte_path),
+                side: Some("head"),
+                line: Some(2),
+            }),
             Err(PullRequestError::ReviewAnchor)
         ));
 
@@ -839,14 +861,34 @@
 
         let store = Store::open(&fixture.database).expect("open the review policy store");
         let first_page = store
-            .pull_request_detail_page("alice", "project", 1, Some("bob"), 1, 1, 2)
+            .pull_request_detail_page(
+                "alice",
+                "project",
+                1,
+                Some("bob"),
+                crate::store::TimelinePagination {
+                    primary_page: 1,
+                    timeline_page: 1,
+                    page_size: 2,
+                },
+            )
             .expect("read the first bounded review page");
         assert_eq!(first_page.reviews.len(), 2);
         assert!(first_page.reviews_has_next);
         assert_eq!(first_page.timeline.len(), 2);
         assert!(first_page.timeline_has_next);
         let second_page = store
-            .pull_request_detail_page("alice", "project", 1, Some("bob"), 2, 2, 2)
+            .pull_request_detail_page(
+                "alice",
+                "project",
+                1,
+                Some("bob"),
+                crate::store::TimelinePagination {
+                    primary_page: 2,
+                    timeline_page: 2,
+                    page_size: 2,
+                },
+            )
             .expect("read the second bounded review page");
         assert_eq!(second_page.reviews_page, 2);
         assert_eq!(second_page.reviews.len(), 2);
@@ -867,18 +909,18 @@
             )
             .expect("remove the reader");
         assert!(matches!(
-            service.review(
-                "alice",
-                "project",
-                1,
-                2,
-                "bob",
-                "comment",
-                "This must stay hidden.",
-                None,
-                None,
-                None,
-            ),
+            service.review(&PullRequestReview {
+                owner: "alice",
+                repository: "project",
+                number: 1,
+                revision: 2,
+                actor: "bob",
+                kind: "comment",
+                body: "This must stay hidden.",
+                path: None,
+                side: None,
+                line: None,
+            }),
             Err(PullRequestError::Store(StoreError::PullRequestHidden))
         ));
     }

tests/repository_policy.rs

Mode 100644100644; object 5627b1e60d9e2defcbea37b2

@@ -74,17 +74,11 @@
     assert_allowed(&policy, Some("maintainer"), RepositoryOperation::Read);
     assert_allowed(&policy, Some("maintainer"), RepositoryOperation::Write);
     assert_allowed(&policy, Some("maintainer"), RepositoryOperation::Maintain);
-    assert_denied(&policy, Some("maintainer"), RepositoryOperation::Own);
     assert_allowed(&policy, Some("writer"), RepositoryOperation::Read);
     assert_allowed(&policy, Some("writer"), RepositoryOperation::Write);
     assert_denied(&policy, Some("writer"), RepositoryOperation::Maintain);
-    assert_denied(&policy, Some("writer"), RepositoryOperation::Own);
     assert_allowed(&policy, Some("reader"), RepositoryOperation::Read);
-    for operation in [
-        RepositoryOperation::Write,
-        RepositoryOperation::Maintain,
-        RepositoryOperation::Own,
-    ] {
+    for operation in [RepositoryOperation::Write, RepositoryOperation::Maintain] {
         assert_denied(&policy, Some("reader"), operation);
     }
     for actor in [None, Some("stranger"), Some("suspended"), Some("missing")] {
@@ -124,23 +118,10 @@
         })
         .expect("create a policy repository");
     let policy = RepositoryPolicy::new(&database);
-    assert_eq!(
-        policy
-            .public_repositories()
-            .expect("list repositories")
-            .len(),
-        1
-    );
 
     store
         .set_repository_visibility("owner", "project", "private", 3, "admin-cli", "test")
         .expect("make the repository private");
-    assert!(
-        policy
-            .public_repositories()
-            .expect("list repositories")
-            .is_empty()
-    );
     store
         .set_repository_collaborator("owner", "project", "member", "writer", &audit(3))
         .expect("add a writer");
@@ -378,12 +359,11 @@
     );
 }
 
-fn operations() -> [RepositoryOperation; 4] {
+fn operations() -> [RepositoryOperation; 3] {
     [
         RepositoryOperation::Read,
         RepositoryOperation::Write,
         RepositoryOperation::Maintain,
-        RepositoryOperation::Own,
     ]
 }
 

tests/serve.rs

Mode 100644100644; object e54389ae6f07026cb4881a13

@@ -1,34 +1,20 @@
-#[allow(
-    dead_code,
-    reason = "the server test uses only part of the shared test support"
-)]
-mod support;
-
 use std::env;
 use std::fs;
 use std::io::{Read, Write};
-use std::net::{SocketAddr, TcpStream};
-use std::os::unix::fs::PermissionsExt;
-use std::path::Path;
-use std::process::{Child, Command, ExitStatus, Output, Stdio};
-use std::sync::{Mutex, MutexGuard};
+use std::net::{SocketAddr, TcpListener, TcpStream};
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, Stdio};
 use std::thread;
 use std::time::{Duration, Instant};
 
-use support::{create_ssh_key_fixture, free_address};
 use tempfile::TempDir;
 
-static SERVER_TEST_LOCK: Mutex<()> = Mutex::new(());
+mod ssh_fixture;
 
-fn server_test_lock() -> MutexGuard<'static, ()> {
-    SERVER_TEST_LOCK
-        .lock()
-        .unwrap_or_else(std::sync::PoisonError::into_inner)
-}
+use ssh_fixture::create_ssh_key;
 
 #[test]
 fn serves_an_imported_repository_through_http_and_ssh() {
-    let _server_test = server_test_lock();
     let instance = TempDir::new().expect("create an instance directory");
     let http = free_address();
     let ssh = free_address();
@@ -36,33 +22,29 @@
     fs::write(
         &config,
         format!(
-            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
+            "version = 1\npublic_url = \"http://{http}/\"\n\n\
+             [http]\nlisten = \"{http}\"\n\n\
+             [ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
             ssh.port()
         ),
     )
     .expect("write the server configuration");
+
     let private_key = instance.path().join("administrator");
-    create_ssh_key_fixture(&private_key);
+    create_ssh_key(&private_key);
     let public_key = fs::read_to_string(private_key.with_extension("pub"))
         .expect("read the administrator public key");
-    command(
+    run_tit(
         instance.path(),
-        [
-            "--config",
-            config.to_str().expect("a UTF-8 configuration path"),
-            "setup",
-            "admin",
-            "alice",
-            public_key.trim(),
-        ],
+        &config,
+        &["setup", "admin", "alice", public_key.trim()],
     );
 
     let source = create_source_repository(instance.path());
-    command(
+    run_tit(
         instance.path(),
-        [
-            "--config",
-            config.to_str().expect("a UTF-8 configuration path"),
+        &config,
+        &[
             "admin",
             "repository",
             "import",
@@ -71,607 +53,27 @@
             source.to_str().expect("a UTF-8 source path"),
         ],
     );
-    let _settings_member_key = provision_account(instance.path(), "carol", "active", false);
+
     let mut server = spawn_server(&config);
     wait_for_listener(http, &mut server);
     wait_for_listener(ssh, &mut server);
+
     let health = http_get(http, "/healthz");
     assert!(health.starts_with("HTTP/1.1 200"));
     assert!(health.ends_with("\r\n\r\nready\n"));
 
-    let backup_directory = TempDir::new().expect("create a backup directory");
-    let backup = backup_directory.path().join("instance.tar");
-    let backup_output = Command::new(tit_binary())
-        .args([
-            "--config",
-            config.to_str().expect("a UTF-8 configuration path"),
-            "backup",
-            backup.to_str().expect("a UTF-8 backup path"),
-        ])
-        .output()
-        .expect("request an online backup");
-    assert!(
-        backup_output.status.success(),
-        "online backup failed: {}",
-        String::from_utf8_lossy(&backup_output.stderr)
-    );
-    assert!(String::from_utf8_lossy(&backup_output.stdout).contains("contains credentials"));
-    assert_eq!(
-        fs::metadata(&backup)
-            .expect("inspect the backup")
-            .permissions()
-            .mode()
-            & 0o777,
-        0o600
-    );
-
-    let restored = TempDir::new().expect("create a restore target");
-    fs::set_permissions(restored.path(), fs::Permissions::from_mode(0o700))
-        .expect("make the restore target private");
-    let restore_output = Command::new(tit_binary())
-        .args([
-            "restore",
-            backup.to_str().expect("a UTF-8 backup path"),
-            restored.path().to_str().expect("a UTF-8 restore path"),
-        ])
-        .output()
-        .expect("restore the online backup");
-    assert!(
-        restore_output.status.success(),
-        "restore failed: {}",
-        String::from_utf8_lossy(&restore_output.stderr)
-    );
-    assert!(String::from_utf8_lossy(&restore_output.stdout).contains("is not active"));
-    let restored_database = rusqlite::Connection::open(restored.path().join("tit.sqlite3"))
-        .expect("open the restored database");
-    let restored_repository: String = restored_database
-        .query_row(
-            "SELECT id FROM repository WHERE slug = 'example'",
-            [],
-            |row| row.get(0),
-        )
-        .expect("read the restored repository");
-    drop(restored_database);
-    let restored_readme = Command::new("git")
-        .args(["--git-dir"])
-        .arg(
-            restored
-                .path()
-                .join("repositories")
-                .join(format!("{restored_repository}.git")),
-        )
-        .args(["show", "main:README.md"])
-        .output()
-        .expect("read the restored Git repository");
-    assert!(restored_readme.status.success());
-    assert_eq!(restored_readme.stdout, b"serve fixture\n");
-
-    let second = Command::new(tit_binary())
-        .args([
-            "--config",
-            config.to_str().expect("a UTF-8 configuration path"),
-            "serve",
-        ])
-        .output()
-        .expect("start a second tit server");
-    assert!(!second.status.success());
-    assert!(String::from_utf8_lossy(&second.stderr).contains("owns the instance lock"));
-
-    let control_socket = instance.path().join("control.sock");
-    assert_eq!(
-        fs::symlink_metadata(&control_socket)
-            .expect("inspect the control socket")
-            .permissions()
-            .mode()
-            & 0o777,
-        0o600
-    );
-
-    let anonymous_home = http_get(http, "/");
-    assert!(
-        anonymous_home.contains("Recently updated public repositories"),
-        "anonymous home response:\n{anonymous_home}"
-    );
-    assert!(anonymous_home.contains(">alice/example</a>"));
-    assert!(anonymous_home.contains("<a href=\"/signup\">Create account</a>"));
-    assert!(anonymous_home.contains("<a href=\"/recover\">Recover account</a>"));
-    assert!(anonymous_home.contains("<a href=\"/login\">Log in</a>"));
-    assert!(!anonymous_home.contains("<a href=\"/account\">Account</a>"));
-    assert!(http_get(http, "/alice/example").contains("trunk head"));
-
-    let login_approval = http_form(http, "/login/ssh", &[]);
-    assert!(login_approval.starts_with("HTTP/1.1 200"));
-    let secret = between(&login_approval, "name=\"secret\" value=\"", "\">");
-    let login_csrf_cookies = response_cookies(&login_approval);
-    let login_csrf = cookie_value(&login_csrf_cookies, "tit-login-csrf");
-    let removed_login = ssh_exec(ssh, &private_key, &["login", secret]);
-    assert!(!removed_login.status.success());
-    assert!(String::from_utf8_lossy(&removed_login.stderr).contains("help"));
-    let ssh_approval = ssh_exec(ssh, &private_key, &["auth", secret]);
-    assert!(ssh_approval.status.success());
-    let ssh_approval_output =
-        String::from_utf8(ssh_approval.stdout).expect("read SSH approval output");
-    assert!(ssh_approval_output.contains(&format!("Origin: http://{http}")));
-    assert!(ssh_approval_output.contains("Account: alice"));
-    let rejected_login = http_form_with_headers(
-        http,
-        "/login/ssh/complete",
-        &[("secret", secret), ("login-csrf", &"0".repeat(64))],
-        &[("Cookie", &login_csrf_cookies)],
-    );
-    assert!(rejected_login.starts_with("HTTP/1.1 400"));
-    let rejected_login_id = response_header(&rejected_login, "x-request-id").to_owned();
-    let login = http_form_with_headers(
-        http,
-        "/login/ssh/complete",
-        &[("secret", secret), ("login-csrf", login_csrf)],
-        &[("Cookie", &login_csrf_cookies)],
-    );
-    assert!(login.starts_with("HTTP/1.1 303"), "{login}");
-    let login_id = response_header(&login, "x-request-id").to_owned();
-    let cookies = response_cookies(&login);
-    let account = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
-    assert!(account.starts_with("HTTP/1.1 200"));
-    assert!(account.contains("<dd>alice</dd>"));
-    assert!(account.contains("<a href=\"/account\">Account</a>"));
-    assert!(account.contains("<a href=\"/logout\">Log out</a>"));
-    assert!(!account.contains("<a href=\"/login\">Log in</a>"));
-    assert!(!account.contains("<a href=\"/signup\">Create account</a>"));
-    assert!(!account.contains("<a href=\"/recover\">Recover account</a>"));
-    assert!(account.contains("action=\"/account/repositories\""));
-    assert!(!account.contains("object-format"));
-    assert!(account.contains("<ul class=\"key-list\">"));
-    assert!(!account.contains("<table>"));
-    for path in ["/login", "/signup", "/recover"] {
-        let response = http_get_with_headers(http, path, &[("Cookie", &cookies)]);
-        assert!(response.starts_with("HTTP/1.1 303"));
-        assert_eq!(response_header(&response, "location"), "/account");
-    }
-    let signed_in_home = http_get_with_headers(http, "/", &[("Cookie", &cookies)]);
-    assert!(signed_in_home.contains("<h1>alice</h1>"));
-    assert!(signed_in_home.contains("<h2>Your repositories</h2>"));
-    assert!(signed_in_home.contains("<h2>Recently updated public repositories</h2>"));
-    assert!(signed_in_home.contains("<div class=\"two-column\">"));
-    assert!(
-        signed_in_home
-            .find("<h2>Your repositories</h2>")
-            .expect("find the owned repository section")
-            < signed_in_home
-                .find("<h2>Recently updated public repositories</h2>")
-                .expect("find the recent repository section")
-    );
-    assert_eq!(signed_in_home.matches(">alice/example</a>").count(), 2);
-    assert!(signed_in_home.contains("<a href=\"/account\">Account</a>"));
-    assert!(signed_in_home.contains("<a href=\"/logout\">Log out</a>"));
-    assert!(!signed_in_home.contains("<a href=\"/signup\">Create account</a>"));
-    assert!(!signed_in_home.contains("<a href=\"/recover\">Recover account</a>"));
-    assert!(!signed_in_home.contains("<a href=\"/login\">Log in</a>"));
-    let signed_in_missing =
-        http_get_with_headers(http, "/this-page-does-not-exist", &[("Cookie", &cookies)]);
-    assert!(signed_in_missing.starts_with("HTTP/1.1 404"));
-    assert!(signed_in_missing.contains("<a href=\"/account\">Account</a>"));
-    assert!(signed_in_missing.contains("<a href=\"/logout\">Log out</a>"));
-    assert!(!signed_in_missing.contains("<a href=\"/login\">Log in</a>"));
-    let csrf = cookie_value(&cookies, "tit-csrf");
-    let second_key = instance.path().join("alice-second");
-    create_ssh_key_fixture(&second_key);
-    let second_public_key =
-        fs::read_to_string(second_key.with_extension("pub")).expect("read the second public key");
-    let second_fingerprint = ssh_fingerprint(&second_key);
-    let begin_add = http_form_with_headers(
-        http,
-        "/account/keys/add",
-        &[
-            ("csrf", csrf),
-            ("label", "laptop"),
-            ("public-key", second_public_key.trim()),
-        ],
-        &[("Cookie", &cookies)],
-    );
-    assert!(begin_add.starts_with("HTTP/1.1 200"), "{begin_add}");
-    assert!(begin_add.contains("ssh -p"));
-    assert!(begin_add.contains(" auth "));
-    let add_secret = between(&begin_add, "name=\"secret\" value=\"", "\">");
-    assert!(
-        ssh_exec(ssh, &private_key, &["auth", add_secret])
-            .status
-            .success()
-    );
-    let complete_add = http_form_with_headers(
-        http,
-        "/account/keys/add/complete",
-        &[
-            ("csrf", csrf),
-            ("secret", add_secret),
-            ("label", "laptop"),
-            ("public-key", second_public_key.trim()),
-        ],
-        &[("Cookie", &cookies)],
-    );
-    assert!(complete_add.starts_with("HTTP/1.1 303"), "{complete_add}");
-    let replay_add = http_form_with_headers(
-        http,
-        "/account/keys/add/complete",
-        &[
-            ("csrf", csrf),
-            ("secret", add_secret),
-            ("label", "replay"),
-            ("public-key", second_public_key.trim()),
-        ],
-        &[("Cookie", &cookies)],
-    );
-    assert!(replay_add.starts_with("HTTP/1.1 400"));
-    assert!(ssh_exec(ssh, &second_key, &["help"]).status.success());
-    let account_with_key = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
-    assert!(account_with_key.contains("laptop"));
-    assert!(account_with_key.contains(&second_fingerprint));
-
-    let begin_revoke = http_form_with_headers(
-        http,
-        "/account/keys/revoke",
-        &[("csrf", csrf), ("fingerprint", &second_fingerprint)],
-        &[("Cookie", &cookies)],
-    );
-    assert!(begin_revoke.starts_with("HTTP/1.1 200"));
-    let revoke_secret = between(&begin_revoke, "name=\"secret\" value=\"", "\">");
-    assert!(
-        ssh_exec(ssh, &private_key, &["auth", revoke_secret])
-            .status
-            .success()
-    );
-    let complete_revoke = http_form_with_headers(
-        http,
-        "/account/keys/revoke/complete",
-        &[
-            ("csrf", csrf),
-            ("secret", revoke_secret),
-            ("fingerprint", &second_fingerprint),
-        ],
-        &[("Cookie", &cookies)],
-    );
-    assert!(
-        complete_revoke.starts_with("HTTP/1.1 303"),
-        "{complete_revoke}"
-    );
-    assert!(!ssh_exec(ssh, &second_key, &["help"]).status.success());
-
-    let initial_fingerprint = ssh_fingerprint(&private_key);
-    let begin_final_revoke = http_form_with_headers(
-        http,
-        "/account/keys/revoke",
-        &[("csrf", csrf), ("fingerprint", &initial_fingerprint)],
-        &[("Cookie", &cookies)],
-    );
-    let final_secret = between(&begin_final_revoke, "name=\"secret\" value=\"", "\">");
-    assert!(
-        ssh_exec(ssh, &private_key, &["auth", final_secret])
-            .status
-            .success()
-    );
-    let final_revoke = http_form_with_headers(
-        http,
-        "/account/keys/revoke/complete",
-        &[
-            ("csrf", csrf),
-            ("secret", final_secret),
-            ("fingerprint", &initial_fingerprint),
-        ],
-        &[("Cookie", &cookies)],
-    );
-    assert!(final_revoke.starts_with("HTTP/1.1 400"));
-    assert!(final_revoke.contains("must keep at least one active SSH key"));
-
-    let profile_without_contact_email = http_get(http, "/alice");
-    assert!(profile_without_contact_email.starts_with("HTTP/1.1 200"));
-    assert!(!profile_without_contact_email.contains("mailto:"));
-    let profile_update = http_form_with_headers(
-        http,
-        "/account/profile",
-        &[
-            ("csrf", csrf),
-            ("bio", "Builds small systems."),
-            ("contact-email", "alice@example.test"),
-        ],
-        &[("Cookie", &cookies)],
-    );
-    assert!(profile_update.starts_with("HTTP/1.1 303"));
-    let public_profile = http_get(http, "/alice");
-    assert!(public_profile.starts_with("HTTP/1.1 200"));
-    assert!(public_profile.contains("Builds small systems."));
-    assert!(public_profile.contains("mailto:alice@example.test"));
-    assert!(public_profile.contains("href=\"/alice/example\""));
-    let settings = http_get_with_headers(http, "/alice/example/settings", &[("Cookie", &cookies)]);
-    assert!(settings.starts_with("HTTP/1.1 200"));
-    assert!(settings.contains("<h2>Repository settings</h2>"));
-    assert!(settings.contains("<option value=\"refs/heads/trunk\" selected>"));
-    let pulls = http_get_with_headers(http, "/alice/example/pulls", &[("Cookie", &cookies)]);
-    assert!(pulls.contains("value=\"refs/heads/trunk\" required"));
-    for invalid in [
-        "refs/heads/missing",
-        "refs/tags/v1",
-        "main",
-        "refs/heads/../bad",
-    ] {
-        let rejected = http_form_with_headers(
-            http,
-            "/alice/example/settings/default-branch",
-            &[("csrf", csrf), ("default-branch", invalid)],
-            &[("Cookie", &cookies)],
-        );
-        assert!(
-            rejected.starts_with("HTTP/1.1 400"),
-            "{invalid}: {rejected}"
-        );
-    }
-    let default_branch_update = http_form_with_headers(
-        http,
-        "/alice/example/settings/default-branch",
-        &[("csrf", csrf), ("default-branch", "refs/heads/main")],
-        &[("Cookie", &cookies)],
-    );
-    assert!(default_branch_update.starts_with("HTTP/1.1 303"));
-    let updated_pulls =
-        http_get_with_headers(http, "/alice/example/pulls", &[("Cookie", &cookies)]);
-    assert!(updated_pulls.contains("value=\"refs/heads/main\" required"));
-    assert!(!http_get(http, "/alice/example").contains("trunk head"));
-    assert_eq!(
-        fs::read_to_string(
-            instance
-                .path()
-                .join("repositories")
-                .join(format!("{restored_repository}.git"))
-                .join("HEAD")
-        )
-        .expect("read the changed symbolic HEAD"),
-        "ref: refs/heads/main\n"
-    );
-    let settings_update = http_form_with_headers(
-        http,
-        "/alice/example/settings/general",
-        &[
-            ("csrf", csrf),
-            ("description", "A repository with self-service settings."),
-            ("visibility", "public"),
-        ],
-        &[("Cookie", &cookies)],
-    );
-    assert!(settings_update.starts_with("HTTP/1.1 303"));
-    let collaborator_update = http_form_with_headers(
-        http,
-        "/alice/example/settings/collaborators",
-        &[
-            ("csrf", csrf),
-            ("username", "carol"),
-            ("role", "reader"),
-            ("action", "set"),
-        ],
-        &[("Cookie", &cookies)],
-    );
-    assert!(collaborator_update.starts_with("HTTP/1.1 303"));
-    let updated_settings =
-        http_get_with_headers(http, "/alice/example/settings", &[("Cookie", &cookies)]);
-    assert!(updated_settings.contains("A repository with self-service settings."));
-    assert!(updated_settings.contains("carol · reader"));
-    assert!(http_get(http, "/alice/example").contains(
-        "<p class=\"repository-description\">A repository with self-service settings.</p>"
-    ));
-    let logout_page = http_get_with_headers(http, "/logout", &[("Cookie", &cookies)]);
-    assert!(logout_page.starts_with("HTTP/1.1 200"));
-    assert!(logout_page.contains("<form method=\"post\" action=\"/logout\">"));
-    assert!(logout_page.contains(&format!("name=\"csrf\" value=\"{csrf}\"")));
-    let rejected_repository = http_form_with_headers(
-        http,
-        "/account/repositories",
-        &[("csrf", &"0".repeat(64)), ("name", "web-created")],
-        &[("Cookie", &cookies)],
-    );
-    assert!(rejected_repository.starts_with("HTTP/1.1 403"));
-    let created_repository = http_form_with_headers(
-        http,
-        "/account/repositories",
-        &[("csrf", csrf), ("name", "web-created")],
-        &[("Cookie", &cookies)],
-    );
-    assert!(created_repository.starts_with("HTTP/1.1 303"));
-    assert_eq!(
-        response_header(&created_repository, "location"),
-        "/alice/web-created"
-    );
-    let web_create_id = response_header(&created_repository, "x-request-id").to_owned();
-    assert!(http_get(http, "/alice/web-created").starts_with("HTTP/1.1 200"));
-    let rejected_logout = http_form_with_headers(
-        http,
-        "/logout",
-        &[("csrf", &"0".repeat(64))],
-        &[("Cookie", &cookies)],
-    );
-    assert!(rejected_logout.starts_with("HTTP/1.1 403"));
-    let cancelled_logout = http_form_with_headers(
-        http,
-        "/logout",
-        &[("csrf", csrf), ("confirm", "no")],
-        &[("Cookie", &cookies)],
-    );
-    assert!(cancelled_logout.starts_with("HTTP/1.1 303"));
-    assert_eq!(response_header(&cancelled_logout, "location"), "/account");
-    assert!(
-        http_get_with_headers(http, "/account", &[("Cookie", &cookies)])
-            .starts_with("HTTP/1.1 200")
-    );
-    let logout = http_form_with_headers(
-        http,
-        "/logout",
-        &[("csrf", csrf), ("confirm", "yes")],
-        &[("Cookie", &cookies)],
-    );
-    assert!(logout.starts_with("HTTP/1.1 303"));
-    let ended = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
-    assert!(ended.starts_with("HTTP/1.1 303"));
-
-    let upload_challenge_page = http_form(http, "/login", &[("username", "alice")]);
-    let upload_challenge = between(
-        &upload_challenge_page,
-        "<textarea id=\"challenge-display\" readonly rows=\"10\">",
-        "</textarea>",
-    );
-    let upload_signature = sign_challenge(instance.path(), &private_key, upload_challenge);
-    let browser_upload_challenge = upload_challenge.replace('\n', "\r\n");
-    let upload_csrf_cookies = response_cookies(&upload_challenge_page);
-    let upload_csrf = cookie_value(&upload_csrf_cookies, "tit-login-csrf");
-    let downloaded_challenge = http_form_with_headers(
-        http,
-        "/login/challenge.txt",
-        &[
-            ("username", "alice"),
-            ("challenge", &browser_upload_challenge),
-            ("login-csrf", upload_csrf),
-        ],
-        &[("Cookie", &upload_csrf_cookies)],
-    );
-    assert!(downloaded_challenge.starts_with("HTTP/1.1 200"));
-    assert_eq!(
-        downloaded_challenge
-            .split_once("\r\n\r\n")
-            .expect("split the challenge response")
-            .1,
-        upload_challenge
-    );
-    let wrong_upload_type = http_form_with_headers(
-        http,
-        "/login/verify-file",
-        &[("signature-file", &upload_signature)],
-        &[("Cookie", &upload_csrf_cookies)],
-    );
-    assert!(wrong_upload_type.starts_with("HTTP/1.1 400"));
-    let malformed_upload = http_body(
-        http,
-        "/login/verify-file",
-        "multipart/form-data; boundary=tit-broken-boundary",
-        "--tit-broken-boundary\r\ninvalid",
-        &[("Cookie", &upload_csrf_cookies)],
-    );
-    assert!(malformed_upload.starts_with("HTTP/1.1 400"));
-    let invalid_signature = http_form_with_headers(
-        http,
-        "/login/verify",
-        &[
-            ("username", "alice"),
-            ("challenge", &browser_upload_challenge),
-            ("signature", "not an SSHSIG envelope"),
-            ("login-csrf", upload_csrf),
-        ],
-        &[("Cookie", &upload_csrf_cookies)],
-    );
-    assert!(invalid_signature.starts_with("HTTP/1.1 400"));
-    assert!(invalid_signature.contains("The signature is not valid"));
-    let uploaded = http_multipart(
-        http,
-        "/login/verify-file",
-        &[
-            ("username", "alice"),
-            ("challenge", &browser_upload_challenge),
-            ("signature-file", &upload_signature),
-            ("login-csrf", upload_csrf),
-        ],
-        &[("Cookie", &upload_csrf_cookies)],
-    );
-    assert!(uploaded.starts_with("HTTP/1.1 303"), "{uploaded}");
-    let private_cookies = response_cookies(&uploaded);
-    let paste_challenge_page = http_form(http, "/login", &[("username", "alice")]);
-    let paste_challenge = between(
-        &paste_challenge_page,
-        "<textarea id=\"challenge-display\" readonly rows=\"10\">",
-        "</textarea>",
-    );
-    let paste_signature = sign_challenge(instance.path(), &private_key, paste_challenge);
-    let paste_cookies = response_cookies(&paste_challenge_page);
-    let paste_csrf = cookie_value(&paste_cookies, "tit-login-csrf");
-    let pasted = http_form_with_headers(
-        http,
-        "/login/verify",
-        &[
-            ("username", "alice"),
-            ("challenge", &paste_challenge.replace('\n', "\r\n")),
-            ("signature", &paste_signature.replace('\n', "\r\n")),
-            ("login-csrf", paste_csrf),
-        ],
-        &[("Cookie", &paste_cookies)],
-    );
-    assert!(pasted.starts_with("HTTP/1.1 303"), "{pasted}");
-    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
-        .expect("open the repository database");
-    database
-        .execute(
-            "UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
-            [],
-        )
-        .expect("make the repository private");
-    assert!(http_get(http, "/alice/example").starts_with("HTTP/1.1 404"));
-    let public_profile_with_private_repository = http_get(http, "/alice");
-    assert!(public_profile_with_private_repository.starts_with("HTTP/1.1 200"));
-    assert!(!public_profile_with_private_repository.contains("href=\"/alice/example\""));
-    let private_summary =
-        http_get_with_headers(http, "/alice/example", &[("Cookie", &private_cookies)]);
-    assert!(private_summary.starts_with("HTTP/1.1 200"));
-    let private_feed = http_get_with_headers(
-        http,
-        "/alice/example/rss.xml",
-        &[("Cookie", &private_cookies)],
-    );
-    assert!(private_feed.starts_with("HTTP/1.1 200"));
-    assert!(private_feed.contains("cache-control: private, no-store"));
-    database
-        .execute(
-            "UPDATE repository SET visibility = 'public' WHERE slug = 'example'",
-            [],
-        )
-        .expect("make the repository public");
-    drop(database);
-
-    let invitation_output = Command::new(tit_binary())
-        .args([
-            "--config",
-            config.to_str().expect("a UTF-8 configuration path"),
-            "invite-code",
-        ])
-        .output()
-        .expect("request an invitation");
-    assert!(invitation_output.status.success());
-    let invitation = String::from_utf8(invitation_output.stdout)
-        .expect("read the invitation output")
-        .trim()
-        .strip_prefix("Signup code: ")
-        .expect("read the invitation code")
-        .to_owned();
-    let member_key = instance.path().join("member");
-    create_ssh_key_fixture(&member_key);
-    let member_public =
-        fs::read_to_string(member_key.with_extension("pub")).expect("read the member public key");
-    let signup = http_form(
-        http,
-        "/signup",
-        &[
-            ("invitation", invitation.as_str()),
-            ("username", "bob"),
-            ("public-key", member_public.trim()),
-        ],
-    );
-    assert!(signup.starts_with("HTTP/1.1 200"), "{signup}");
-    let recovery = between(&signup, "<pre><code>", "</code></pre>");
-    assert!(recovery.starts_with("tit-recovery-v1:"));
-
-    let summary = http_get(http, "/alice/example");
-    assert!(summary.starts_with("HTTP/1.1 200"));
-    assert!(summary.contains("serve fixture"));
+    let repository = http_get(http, "/alice/example");
+    assert!(repository.starts_with("HTTP/1.1 200"));
+    assert!(repository.contains("serve fixture"));
 
     let http_clone = instance.path().join("http-clone");
-    command(
+    run_git(
         instance.path(),
-        [
+        &[
             "clone",
             "-q",
             &format!("http://{http}/alice/example.git"),
-            http_clone.to_str().expect("a UTF-8 HTTP clone path"),
+            http_clone.to_str().expect("a UTF-8 clone path"),
         ],
     );
     assert_eq!(
@@ -679,95 +81,10 @@
         b"serve fixture\n"
     );
 
-    assert!(ssh_clone_succeeds(
-        ssh,
-        &member_key,
-        &instance.path().join("member-clone")
-    ));
-
-    let replacement_key = instance.path().join("replacement");
-    create_ssh_key_fixture(&replacement_key);
-    let replacement_public = fs::read_to_string(replacement_key.with_extension("pub"))
-        .expect("read the replacement public key");
-    let recovered = http_form(
-        http,
-        "/recover",
-        &[
-            ("recovery", recovery),
-            ("username", "bob"),
-            ("public-key", replacement_public.trim()),
-        ],
-    );
-    assert!(recovered.starts_with("HTTP/1.1 200"), "{recovered}");
-    let recovery_id = response_header(&recovered, "x-request-id").to_owned();
-    assert!(!ssh_clone_succeeds(
-        ssh,
-        &member_key,
-        &instance.path().join("revoked-clone")
-    ));
-    assert!(ssh_clone_succeeds(
-        ssh,
-        &replacement_key,
-        &instance.path().join("replacement-clone")
-    ));
-
-    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
-        .expect("open the audit database");
-    let mut statement = database
-        .prepare(
-            "SELECT action, actor, target, outcome, correlation_id
-             FROM audit_event ORDER BY id",
-        )
-        .expect("prepare the audit query");
-    let audits = statement
-        .query_map([], |row| {
-            Ok((
-                row.get::<_, String>(0)?,
-                row.get::<_, String>(1)?,
-                row.get::<_, String>(2)?,
-                row.get::<_, String>(3)?,
-                row.get::<_, String>(4)?,
-            ))
-        })
-        .expect("query audit history")
-        .collect::<Result<Vec<_>, _>>()
-        .expect("read audit history");
-    assert!(audits.iter().any(|event| {
-        event.0 == "login" && event.3 == "failure" && event.4 == rejected_login_id
-    }));
-    assert!(
-        audits
-            .iter()
-            .any(|event| event.0 == "login" && event.3 == "success" && event.4 == login_id)
-    );
-    assert!(audits.iter().any(|event| {
-        event.0 == "account.recover" && event.3 == "success" && event.4 == recovery_id
-    }));
-    assert!(audits.iter().any(|event| {
-        event.0 == "repository.create"
-            && event.1 == "alice"
-            && event.2 == "alice/web-created"
-            && event.3 == "success"
-            && event.4 == web_create_id
-    }));
-    for event in &audits {
-        let visible = format!(
-            "{} {} {} {} {}",
-            event.0, event.1, event.2, event.3, event.4
-        );
-        assert!(!visible.contains(recovery));
-        assert!(!visible.contains(secret));
-        assert!(!visible.contains(upload_challenge));
-        assert!(!visible.contains(&upload_signature));
-        assert!(!visible.contains(paste_challenge));
-        assert!(!visible.contains(&paste_signature));
-    }
-    drop(statement);
-    drop(database);
-
     let ssh_clone = instance.path().join("ssh-clone");
     let ssh_command = format!(
-        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
+        "ssh -F /dev/null -i {} -o BatchMode=yes -o IdentitiesOnly=yes \
+         -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
         private_key.display()
     );
     let output = Command::new("git")
@@ -779,7 +96,7 @@
         .arg(&ssh_clone)
         .env("GIT_SSH_COMMAND", ssh_command)
         .output()
-        .expect("clone through the tit SSH server");
+        .expect("clone through the SSH server");
     assert!(
         output.status.success(),
         "SSH clone failed: {}",
@@ -790,974 +107,19 @@
         b"serve fixture\n"
     );
 
-    let locked = Command::new(tit_binary())
-        .args([
-            "--config",
-            config.to_str().expect("a UTF-8 configuration path"),
-            "admin",
-            "repository",
-            "inspect",
-            "alice",
-            "example",
-        ])
-        .output()
-        .expect("run an offline command while the server owns the instance");
-    assert_eq!(locked.status.code(), Some(1));
-    assert!(String::from_utf8_lossy(&locked.stderr).contains("owns the instance lock"));
-
-    let metrics = http_get(http, "/metrics");
-    assert!(metrics.starts_with("HTTP/1.1 200"));
-    assert!(metrics.contains("tit_http_requests_total "));
-    assert!(metrics.contains("tit_http_requests_in_flight 1"));
-    assert!(metrics.contains("tit_ssh_connections_total "));
-    assert!(metrics.contains("tit_ssh_operations_total "));
-
-    let authorization_secret = "Bearer tit-secret-authorization";
-    let cookie_secret = "tit-session=tit-secret-cookie";
-    let feed_secret = "tit-secret-feed-token";
-    let _ = http_get_with_headers(
-        http,
-        "/",
-        &[
-            ("Authorization", authorization_secret),
-            ("Cookie", cookie_secret),
-        ],
-    );
-    let _ = http_get(http, &format!("/feeds/{feed_secret}.atom"));
-
-    let logs = server.terminate_capture();
-    let logs = String::from_utf8(logs).expect("read structured server logs");
-    assert!(!logs.contains(authorization_secret));
-    assert!(!logs.contains(cookie_secret));
-    assert!(!logs.contains(feed_secret));
-    for secret in [
-        recovery,
-        secret,
-        upload_challenge,
-        paste_challenge,
-        invitation.trim(),
-        private_cookies.as_str(),
-        upload_signature.as_str(),
-        paste_signature.as_str(),
-    ] {
-        assert!(!logs.contains(secret));
-    }
-    let events: Vec<serde_json::Value> = logs
-        .lines()
-        .map(|line| serde_json::from_str(line).expect("parse a structured server log"))
-        .collect();
-    assert!(events.iter().any(|event| {
-        event["event"] == "http.request"
-            && event["request_id"]
-                .as_str()
-                .is_some_and(|request_id| request_id.len() == 32)
-    }));
-    assert!(events.iter().any(|event| {
-        event["event"] == "ssh.operation"
-            && event["operation_id"]
-                .as_str()
-                .is_some_and(|operation_id| operation_id.len() == 32)
-    }));
-    assert!(
-        events
-            .iter()
-            .any(|event| event["event"] == "server.shutdown" && event["outcome"] == "completed")
-    );
-
-    assert!(!control_socket.exists());
-    let host_key = fs::read(instance.path().join("ssh_host_ed25519_key"))
-        .expect("read the generated SSH host key");
-    assert_eq!(
-        fs::metadata(instance.path().join("ssh_host_ed25519_key"))
-            .expect("inspect the generated SSH host key")
-            .permissions()
-            .mode()
-            & 0o777,
-        0o600
-    );
-    let mut restarted = spawn_server(&config);
-    wait_for_listener(http, &mut restarted);
-    wait_for_listener(ssh, &mut restarted);
-    assert_eq!(
-        fs::read(instance.path().join("ssh_host_ed25519_key"))
-            .expect("read the reused SSH host key"),
-        host_key
-    );
-    restarted.terminate();
-}
-
-#[test]
-fn keeps_private_git_hidden_from_http_but_allows_its_owner_over_ssh() {
-    let _server_test = server_test_lock();
-    let instance = TempDir::new().expect("create an instance directory");
-    let http = free_address();
-    let ssh = free_address();
-    let config = instance.path().join("config.toml");
-    fs::write(
-        &config,
-        format!(
-            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
-            ssh.port()
-        ),
-    )
-    .expect("write the server configuration");
-    let private_key = instance.path().join("administrator");
-    create_ssh_key_fixture(&private_key);
-    let public_key = fs::read_to_string(private_key.with_extension("pub"))
-        .expect("read the administrator public key");
-    let config_text = config.to_str().expect("a UTF-8 configuration path");
-    command(
-        instance.path(),
-        [
-            "--config",
-            config_text,
-            "setup",
-            "admin",
-            "alice",
-            public_key.trim(),
-        ],
-    );
-    let source = create_source_repository(instance.path());
-    command(
-        instance.path(),
-        [
-            "--config",
-            config_text,
-            "admin",
-            "repository",
-            "import",
-            "alice",
-            "private",
-            source.to_str().expect("a UTF-8 source path"),
-        ],
-    );
-    command(
-        instance.path(),
-        [
-            "--config",
-            config_text,
-            "admin",
-            "repository",
-            "visibility",
-            "alice",
-            "private",
-            "private",
-        ],
-    );
-    let mut server = spawn_server(&config);
-    wait_for_listener(http, &mut server);
-    wait_for_listener(ssh, &mut server);
-    let discovery = http_get(http, "/alice/private.git/info/refs?service=git-upload-pack");
-    assert!(discovery.starts_with("HTTP/1.1 404"), "{discovery}");
-    let home = http_get(http, "/");
-    assert!(home.starts_with("HTTP/1.1 200"));
-    assert!(!home.contains("/alice/private"));
-    for route in [
-        "/alice/private",
-        "/alice/private/refs",
-        "/alice/private/rss.xml",
-        "/alice/private/search?q=serve&ref=HEAD",
-        "/alice/private/commit/main",
-        "/alice/private/diff/main/main",
-        "/alice/private/tree/main",
-        "/alice/private/tree/main/nested",
-        "/alice/private/blob/main/README.md",
-        "/alice/private/raw/main/README.md",
-        "/alice/private/blame/main/README.md",
-        "/alice/private/archive/main.tar",
-    ] {
-        let response = http_get(http, route);
-        assert!(
-            response.starts_with("HTTP/1.1 404"),
-            "route leaked: {route}"
-        );
-        assert!(!response.contains("serve fixture"), "route leaked: {route}");
-    }
-
-    let ssh_command = format!(
-        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
-        private_key.display()
-    );
-    let ssh_discovery = Command::new("git")
-        .args([
-            "ls-remote",
-            &format!("ssh://ignored@127.0.0.1:{}/alice/private.git", ssh.port()),
-        ])
-        .env("GIT_SSH_COMMAND", ssh_command)
-        .output()
-        .expect("query the private repository through SSH");
-    assert!(ssh_discovery.status.success());
-
-    let unknown_key = instance.path().join("unknown");
-    create_ssh_key_fixture(&unknown_key);
-    assert!(!ssh_clone_repository_succeeds(
-        ssh,
-        &unknown_key,
-        "alice",
-        "private",
-        &instance.path().join("unknown-clone")
-    ));
     server.terminate();
 }
 
-#[test]
-fn creates_owned_repositories_with_stable_ssh_command_output() {
-    let _server_test = server_test_lock();
-    let instance = TempDir::new().expect("create an instance directory");
-    let http = free_address();
-    let ssh = free_address();
-    let config = instance.path().join("config.toml");
-    fs::write(
-        &config,
-        format!(
-            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
-            ssh.port()
-        ),
-    )
-    .expect("write the server configuration");
-    let private_key = instance.path().join("administrator");
-    create_ssh_key_fixture(&private_key);
-    let public_key = fs::read_to_string(private_key.with_extension("pub"))
-        .expect("read the administrator public key");
-    let config_text = config.to_str().expect("a UTF-8 configuration path");
-    command(
-        instance.path(),
-        [
-            "--config",
-            config_text,
-            "setup",
-            "admin",
-            "alice",
-            public_key.trim(),
-        ],
-    );
-    let member_key = provision_account(instance.path(), "bob", "active", false);
-
-    let mut server = spawn_server(&config);
-    wait_for_listener(http, &mut server);
-    wait_for_listener(ssh, &mut server);
-
-    let human = ssh_exec(ssh, &member_key, &["repo", "create", "example"]);
-    assert!(human.status.success());
-    assert_eq!(
-        String::from_utf8(human.stdout).expect("read human command output"),
-        "Created repository bob/example.\n"
-    );
-    assert!(human.stderr.is_empty());
-    assert!(ssh_clone_repository_succeeds(
-        ssh,
-        &member_key,
-        "bob",
-        "example",
-        &instance.path().join("created-clone")
-    ));
-    let created_clone = instance.path().join("created-clone");
-    command(&created_clone, ["symbolic-ref", "HEAD", "refs/heads/main"]);
-    fs::write(created_clone.join("README.md"), b"base\n").expect("write pull-request base");
-    git_commit(&created_clone, "create main");
-    assert!(git_push(&member_key, &created_clone, &["main"]).success());
-    command(&created_clone, ["switch", "-q", "-c", "feature"]);
-    fs::write(created_clone.join("feature.txt"), b"feature\n").expect("write pull-request feature");
-    git_commit(&created_clone, "create feature");
-    assert!(git_push(&member_key, &created_clone, &["feature"]).success());
-    let base = git_revision(&created_clone, "main");
-    let head = git_revision(&created_clone, "feature");
-    command(&created_clone, ["switch", "-q", "main"]);
-    command(&created_clone, ["switch", "-q", "-c", "feature-two"]);
-    fs::write(created_clone.join("feature-two.txt"), b"second feature\n")
-        .expect("write the second pull-request feature");
-    git_commit(&created_clone, "create second feature");
-    assert!(git_push(&member_key, &created_clone, &["feature-two"]).success());
-    let pull_request_database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
-        .expect("open the pull-request command database");
-    let repository_id: String = pull_request_database
-        .query_row(
-            "SELECT id FROM repository WHERE slug = 'example'",
-            [],
-            |row| row.get(0),
-        )
-        .expect("read the pull-request repository ID");
-    let bare = instance
-        .path()
-        .join("repositories")
-        .join(format!("{repository_id}.git"));
-    command(
-        instance.path(),
-        [
-            "--git-dir",
-            bare.to_str().expect("a UTF-8 bare path"),
-            "update-ref",
-            "refs/pull/1/head",
-            &head,
-        ],
-    );
-    pull_request_database
-        .execute(
-            "INSERT INTO pull_request
-             (id, repository_id, number, title, body, state, author_account_id,
-              base_ref, head_ref, base_object_id, head_object_id, created_at, updated_at)
-             SELECT '11111111111111111111111111111111', ?1, 1, 'Feature', '', 'open',
-                    account.id, 'refs/heads/main', 'refs/heads/feature', ?2, ?3, 10, 10
-             FROM account WHERE username = 'bob'",
-            rusqlite::params![repository_id, base, head],
-        )
-        .expect("create a pull-request command fixture");
-    pull_request_database
-        .execute(
-            "INSERT INTO pull_request_revision
-             (id, pull_request_id, number, author_account_id, base_object_id,
-              head_object_id, created_at)
-             SELECT '22222222222222222222222222222222',
-                    '11111111111111111111111111111111', 1, account.id, ?1, ?2, 10
-             FROM account WHERE username = 'bob'",
-            rusqlite::params![base, head],
-        )
-        .expect("create a pull-request revision fixture");
-    pull_request_database
-        .execute(
-            "INSERT INTO repository_counter (repository_id, next_pull_request_number)
-             VALUES (?1, 2)
-             ON CONFLICT (repository_id)
-             DO UPDATE SET next_pull_request_number = 2",
-            [&repository_id],
-        )
-        .expect("advance the pull-request number fixture");
-    drop(pull_request_database);
-
-    let human_checkout = ssh_exec(ssh, &member_key, &["pr", "checkout", "bob/example", "1"]);
-    assert!(human_checkout.status.success());
-    assert_eq!(
-        String::from_utf8(human_checkout.stdout).expect("read human checkout output"),
-        "git fetch origin refs/pull/1/head:refs/heads/pr-1\ngit checkout pr-1\n"
-    );
-    assert!(human_checkout.stderr.is_empty());
-    let machine_checkout = ssh_exec(
-        ssh,
-        &member_key,
-        &["pr", "checkout", "bob/example", "1", "--output", "json"],
-    );
-    assert!(machine_checkout.status.success());
-    let machine_checkout: serde_json::Value =
-        serde_json::from_slice(&machine_checkout.stdout).expect("parse machine checkout output");
-    assert_eq!(machine_checkout["version"], 1);
-    assert_eq!(machine_checkout["status"], "success");
-    assert_eq!(machine_checkout["repository"]["owner"], "bob");
-    assert_eq!(machine_checkout["repository"]["name"], "example");
-    assert_eq!(machine_checkout["pull_request"]["number"], 1);
-    assert_eq!(machine_checkout["pull_request"]["ref"], "refs/pull/1/head");
-    assert_eq!(
-        machine_checkout["commands"]["fetch"],
-        "git fetch origin refs/pull/1/head:refs/heads/pr-1"
-    );
-    let listed_pull_requests = ssh_exec(
-        ssh,
-        &member_key,
-        &[
-            "pr",
-            "list",
-            "bob/example",
-            "--state",
-            "open",
-            "--output",
-            "json",
-        ],
-    );
-    assert!(listed_pull_requests.status.success());
-    let listed_pull_requests: serde_json::Value =
-        serde_json::from_slice(&listed_pull_requests.stdout).expect("parse pull-request list");
-    assert_eq!(listed_pull_requests["version"], 1);
-    assert_eq!(listed_pull_requests["pull_requests"][0]["number"], 1);
-    let closed_pull_request = ssh_exec(ssh, &member_key, &["pr", "close", "bob/example", "1"]);
-    assert!(closed_pull_request.status.success());
-    assert_eq!(
-        String::from_utf8(closed_pull_request.stdout).expect("read pull-request close output"),
-        "Closed pull request bob/example#1.\n"
-    );
-    assert!(
-        ssh_exec(ssh, &member_key, &["pr", "reopen", "bob/example", "1"])
-            .status
-            .success()
-    );
-    let created_pull_request = ssh_exec_with_input(
-        ssh,
-        &member_key,
-        &[
-            "pr",
-            "create",
-            "bob/example",
-            "refs/heads/main",
-            "refs/heads/feature-two",
-            "--output",
-            "json",
-        ],
-        b"Second feature\nOpened through SSH.\n",
-    );
-    assert!(
-        created_pull_request.status.success(),
-        "pr create failed: stdout={} stderr={}",
-        String::from_utf8_lossy(&created_pull_request.stdout),
-        String::from_utf8_lossy(&created_pull_request.stderr)
-    );
-    let created_pull_request: serde_json::Value =
-        serde_json::from_slice(&created_pull_request.stdout)
-            .expect("parse pull-request create output");
-    assert_eq!(created_pull_request["version"], 1);
-    assert_eq!(created_pull_request["pull_request"]["number"], 2);
-    let checkout_clone = instance.path().join("pull-request-checkout");
-    assert!(ssh_clone_repository_succeeds(
-        ssh,
-        &member_key,
-        "bob",
-        "example",
-        &checkout_clone
-    ));
-    assert!(
-        git_fetch_ref(
-            &member_key,
-            &checkout_clone,
-            "refs/pull/1/head:refs/heads/pr-1"
-        )
-        .success()
-    );
-    command(&checkout_clone, ["checkout", "-q", "pr-1"]);
-    assert_eq!(git_revision(&checkout_clone, "HEAD"), head);
-
-    let machine = ssh_exec(
-        ssh,
-        &private_key,
-        &["repo", "create", "hash-agile", "--output", "json"],
-    );
-    assert!(machine.status.success());
-    assert_eq!(
-        String::from_utf8(machine.stdout).expect("read machine command output"),
-        "{\"version\":1,\"status\":\"success\",\"repository\":{\"owner\":\"alice\",\"name\":\"hash-agile\"}}\n"
-    );
-    assert!(machine.stderr.is_empty());
-
-    let created_issue = ssh_exec_with_input(
-        ssh,
-        &member_key,
-        &["issue", "create", "bob/example"],
-        b"First issue\nBody with **Markdown**.\n",
-    );
-    assert!(created_issue.status.success());
-    assert_eq!(
-        String::from_utf8(created_issue.stdout).expect("read issue create output"),
-        "Created issue bob/example#1.\n"
-    );
-    assert!(created_issue.stderr.is_empty());
-    let human_issues = ssh_exec(ssh, &member_key, &["issue", "list", "bob/example"]);
-    assert!(human_issues.status.success());
-    assert_eq!(
-        String::from_utf8(human_issues.stdout).expect("read human issue list"),
-        "#1 open First issue\n"
-    );
-    let machine_issues = ssh_exec(
-        ssh,
-        &member_key,
-        &["issue", "list", "bob/example", "--output", "json"],
-    );
-    assert!(machine_issues.status.success());
-    let machine_issues: serde_json::Value =
-        serde_json::from_slice(&machine_issues.stdout).expect("parse machine issue list");
-    assert_eq!(machine_issues["version"], 1);
-    assert_eq!(machine_issues["status"], "success");
-    assert_eq!(machine_issues["repository"]["owner"], "bob");
-    assert_eq!(machine_issues["repository"]["name"], "example");
-    assert_eq!(machine_issues["issues"][0]["number"], 1);
-    assert_eq!(machine_issues["issues"][0]["title"], "First issue");
-    let issue_comment = ssh_exec_with_input(
-        ssh,
-        &member_key,
-        &["issue", "comment", "bob/example", "1", "--output", "json"],
-        b"A bounded SSH comment.",
-    );
-    assert!(issue_comment.status.success());
-    let issue_comment: serde_json::Value =
-        serde_json::from_slice(&issue_comment.stdout).expect("parse issue comment");
-    assert_eq!(issue_comment["operation"], "commented");
-    assert!(issue_comment["comment_id"].is_string());
-    assert!(
-        ssh_exec(ssh, &member_key, &["issue", "close", "bob/example", "1"])
-            .status
-            .success()
-    );
-    let reopened_issue = ssh_exec(
-        ssh,
-        &member_key,
-        &["issue", "reopen", "bob/example", "1", "--output", "json"],
-    );
-    assert!(reopened_issue.status.success());
-    assert_eq!(
-        serde_json::from_slice::<serde_json::Value>(&reopened_issue.stdout)
-            .expect("parse issue reopen")["operation"],
-        "reopened"
-    );
-
-    let access_database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
-        .expect("open the issue access database");
-    access_database
-        .execute_batch("UPDATE repository SET visibility = 'private' WHERE slug = 'example';")
-        .expect("make the issue repository private");
-    let hidden = ssh_exec(
-        ssh,
-        &private_key,
-        &["issue", "list", "bob/example", "--output", "json"],
-    );
-    assert!(!hidden.status.success());
-    assert_eq!(
-        serde_json::from_slice::<serde_json::Value>(&hidden.stdout)
-            .expect("parse hidden issue error")["error"]["code"],
-        "repository-unavailable"
-    );
-    let hidden_checkout = ssh_exec(
-        ssh,
-        &private_key,
-        &["pr", "checkout", "bob/example", "1", "--output", "json"],
-    );
-    assert!(!hidden_checkout.status.success());
-    assert_eq!(
-        serde_json::from_slice::<serde_json::Value>(&hidden_checkout.stdout)
-            .expect("parse hidden pull-request error")["error"]["code"],
-        "pull-request-unavailable"
-    );
-    access_database
-        .execute(
-            "INSERT INTO repository_collaborator
-                 (repository_id, account_id, role, created_at)
-             SELECT repository.id, account.id, 'reader', 10
-             FROM repository, account
-             WHERE repository.slug = 'example' AND account.username = 'alice'",
-            [],
-        )
-        .expect("give the administrator reader access");
-    drop(access_database);
-    let denied_pull_request = ssh_exec(
-        ssh,
-        &private_key,
-        &["pr", "close", "bob/example", "1", "--output", "json"],
-    );
-    assert!(!denied_pull_request.status.success());
-    assert_eq!(
-        serde_json::from_slice::<serde_json::Value>(&denied_pull_request.stdout)
-            .expect("parse denied pull-request mutation")["error"]["code"],
-        "permission-denied"
-    );
-    let reader_create = ssh_exec_with_input(
-        ssh,
-        &private_key,
-        &["issue", "create", "bob/example", "--output", "json"],
-        b"Reader issue\nCreated through the shared service.",
-    );
-    assert!(reader_create.status.success());
-    let reader_create: serde_json::Value =
-        serde_json::from_slice(&reader_create.stdout).expect("parse machine issue create");
-    assert_eq!(reader_create["version"], 1);
-    assert_eq!(reader_create["status"], "success");
-    assert_eq!(reader_create["issue"]["number"], 2);
-    assert_eq!(reader_create["issue"]["author"], "alice");
-
-    let invalid_issue = ssh_exec_with_input(
-        ssh,
-        &private_key,
-        &["issue", "create", "bob/example", "--output", "json"],
-        b"\nbody without a title",
-    );
-    assert!(!invalid_issue.status.success());
-    assert_eq!(
-        serde_json::from_slice::<serde_json::Value>(&invalid_issue.stdout)
-            .expect("parse invalid issue input")["error"]["code"],
-        "invalid-input"
-    );
-
-    let duplicate = ssh_exec(
-        ssh,
-        &member_key,
-        &["repo", "create", "example", "--output", "json"],
-    );
-    assert!(!duplicate.status.success());
-    assert_eq!(
-        String::from_utf8(duplicate.stdout).expect("read machine error output"),
-        "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"repository-exists\"}}\n"
-    );
-    assert!(duplicate.stderr.is_empty());
-
-    let invalid = ssh_exec(ssh, &member_key, &["repo", "create", "../bad"]);
-    assert!(!invalid.status.success());
-    assert!(invalid.stdout.is_empty());
-    assert_eq!(
-        String::from_utf8(invalid.stderr).expect("read human error output"),
-        "tit: The repository name is not valid.\n"
-    );
-    let malformed = ssh_exec(ssh, &member_key, &["repo", "create", "--output", "json"]);
-    assert!(!malformed.status.success());
-    assert_eq!(
-        String::from_utf8(malformed.stdout).expect("read invalid command output"),
-        "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"invalid-command\"}}\n"
-    );
-    assert!(malformed.stderr.is_empty());
-
-    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
-        .expect("open the account database");
-    database
-        .execute(
-            "UPDATE account SET state = 'suspended' WHERE username = 'bob'",
-            [],
-        )
-        .expect("suspend the command account");
-    drop(database);
-    let suspended = ssh_exec(
-        ssh,
-        &member_key,
-        &["repo", "create", "blocked", "--output", "json"],
-    );
-    assert!(!suspended.status.success());
-    assert_eq!(
-        String::from_utf8(suspended.stdout).expect("read suspended account output"),
-        "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"account-unavailable\"}}\n"
-    );
-    assert!(suspended.stderr.is_empty());
-    let suspended_issues = ssh_exec(
-        ssh,
-        &member_key,
-        &["issue", "list", "bob/example", "--output", "json"],
-    );
-    assert!(!suspended_issues.status.success());
-    assert_eq!(
-        serde_json::from_slice::<serde_json::Value>(&suspended_issues.stdout)
-            .expect("parse suspended issue output")["error"]["code"],
-        "repository-unavailable"
-    );
-    server.terminate();
-
-    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
-        .expect("open the repository database");
-    let repositories: Vec<(String, String, String)> = database
-        .prepare(
-            "SELECT account.username, repository.slug, repository.object_format
-             FROM repository JOIN account ON account.id = repository.owner_account_id
-             ORDER BY repository.slug",
-        )
-        .expect("prepare the repository query")
-        .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
-        .expect("query created repositories")
-        .collect::<Result<_, _>>()
-        .expect("read created repositories");
-    assert_eq!(
-        repositories,
-        vec![
-            ("bob".to_owned(), "example".to_owned(), "sha1".to_owned()),
-            (
-                "alice".to_owned(),
-                "hash-agile".to_owned(),
-                "sha1".to_owned()
-            ),
-        ]
-    );
-    let issues: Vec<(i64, String, String, String)> = database
-        .prepare(
-            "SELECT issue.number, issue.title, issue.body, account.username
-             FROM issue JOIN account ON account.id = issue.author_account_id
-             ORDER BY issue.number",
-        )
-        .expect("prepare the issue query")
-        .query_map([], |row| {
-            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
-        })
-        .expect("query created issues")
-        .collect::<Result<_, _>>()
-        .expect("read created issues");
-    assert_eq!(
-        issues,
-        vec![
-            (
-                1,
-                "First issue".to_owned(),
-                "Body with **Markdown**.\n".to_owned(),
-                "bob".to_owned(),
-            ),
-            (
-                2,
-                "Reader issue".to_owned(),
-                "Created through the shared service.".to_owned(),
-                "alice".to_owned(),
-            ),
-        ]
-    );
-    let issue_events: i64 = database
-        .query_row(
-            "SELECT count(*) FROM repository_event WHERE kind = 'issue-created'",
-            [],
-            |row| row.get(0),
-        )
-        .expect("count issue create events");
-    assert_eq!(issue_events, 2);
-    let audit: Vec<(String, String)> = database
-        .prepare(
-            "SELECT target, outcome FROM audit_event
-             WHERE action = 'repository.create' AND actor IN ('alice', 'bob')
-             ORDER BY id",
-        )
-        .expect("prepare the repository audit query")
-        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
-        .expect("query repository audit events")
-        .collect::<Result<_, _>>()
-        .expect("read repository audit events");
-    assert_eq!(
-        audit,
-        vec![
-            ("bob/example".to_owned(), "success".to_owned()),
-            ("alice/hash-agile".to_owned(), "success".to_owned()),
-            ("bob/example".to_owned(), "failure".to_owned()),
-            ("bob/blocked".to_owned(), "failure".to_owned()),
-        ]
-    );
+fn free_address() -> SocketAddr {
+    let listener = TcpListener::bind("127.0.0.1:0").expect("bind a temporary port");
+    listener.local_addr().expect("read the temporary address")
 }
 
-#[test]
-fn enforces_account_roles_and_ref_policy_through_the_production_ssh_server() {
-    let _server_test = server_test_lock();
-    let instance = TempDir::new().expect("create an instance directory");
-    let http = free_address();
-    let ssh = free_address();
-    let config = instance.path().join("config.toml");
-    fs::write(
-        &config,
-        format!(
-            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
-            ssh.port()
-        ),
-    )
-    .expect("write the server configuration");
-    let config_text = config.to_str().expect("a UTF-8 configuration path");
-    let owner_key = instance.path().join("owner");
-    create_ssh_key_fixture(&owner_key);
-    let owner_public =
-        fs::read_to_string(owner_key.with_extension("pub")).expect("read the owner public key");
-    command(
-        instance.path(),
-        [
-            "--config",
-            config_text,
-            "setup",
-            "admin",
-            "alice",
-            owner_public.trim(),
-        ],
-    );
-
-    let source = create_source_repository(instance.path());
-    for repository in ["private", "public"] {
-        command(
-            instance.path(),
-            [
-                "--config",
-                config_text,
-                "admin",
-                "repository",
-                "import",
-                "alice",
-                repository,
-                source.to_str().expect("a UTF-8 source path"),
-            ],
-        );
-    }
-    command(
-        instance.path(),
-        [
-            "--config",
-            config_text,
-            "admin",
-            "repository",
-            "visibility",
-            "alice",
-            "private",
-            "private",
-        ],
-    );
-
-    let maintainer_key = provision_account(instance.path(), "maintainer", "active", false);
-    let writer_key = provision_account(instance.path(), "writer", "active", false);
-    let reader_key = provision_account(instance.path(), "reader", "active", false);
-    let outsider_key = provision_account(instance.path(), "outsider", "active", false);
-    let suspended_key = provision_account(instance.path(), "suspended", "active", false);
-    let revoked_key = provision_account(instance.path(), "revoked", "active", true);
-    for (username, role) in [
-        ("maintainer", "maintainer"),
-        ("writer", "writer"),
-        ("reader", "reader"),
-        ("suspended", "writer"),
-        ("revoked", "writer"),
-    ] {
-        command(
-            instance.path(),
-            [
-                "--config",
-                config_text,
-                "admin",
-                "repository",
-                "collaborator-set",
-                "alice",
-                "private",
-                username,
-                role,
-            ],
-        );
-    }
-    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
-        .expect("open the repository database");
-    database
-        .execute(
-            "UPDATE account SET state = 'suspended' WHERE username = 'suspended'",
-            [],
-        )
-        .expect("suspend an account fixture");
-    drop(database);
-
-    let mut server = spawn_server(&config);
-    wait_for_listener(http, &mut server);
-    wait_for_listener(ssh, &mut server);
-    for (name, key) in [
-        ("owner", &owner_key),
-        ("maintainer", &maintainer_key),
-        ("writer", &writer_key),
-        ("reader", &reader_key),
-    ] {
-        assert!(ssh_clone_repository_succeeds(
-            ssh,
-            key,
-            "alice",
-            "private",
-            &instance.path().join(format!("{name}-private"))
-        ));
-    }
-    for (name, key) in [
-        ("outsider", &outsider_key),
-        ("suspended", &suspended_key),
-        ("revoked", &revoked_key),
-    ] {
-        assert!(!ssh_clone_repository_succeeds(
-            ssh,
-            key,
-            "alice",
-            "private",
-            &instance.path().join(format!("{name}-private"))
-        ));
-    }
-    assert!(ssh_clone_repository_succeeds(
-        ssh,
-        &outsider_key,
-        "alice",
-        "public",
-        &instance.path().join("outsider-public")
-    ));
-
-    let writer_clone = instance.path().join("writer-private");
-    command(&writer_clone, ["switch", "-q", "-c", "topic"]);
-    fs::write(writer_clone.join("writer.txt"), b"writer update\n").expect("write a writer change");
-    git_commit(&writer_clone, "writer update");
-    assert!(git_push(&writer_key, &writer_clone, &["topic"]).success());
-    fs::write(writer_clone.join("writer-2.txt"), b"second writer update\n")
-        .expect("write a second writer change");
-    git_commit(&writer_clone, "second writer update");
-    assert!(git_push(&writer_key, &writer_clone, &["topic"]).success());
-    assert!(git_push(&writer_key, &writer_clone, &["--delete", "topic"]).success());
-    assert!(!git_push(&writer_key, &writer_clone, &["HEAD:trunk"]).success());
-    assert!(!git_push(&writer_key, &writer_clone, &["HEAD:refs/notes/test"]).success());
-
-    let owner_clone = instance.path().join("owner-private");
-    fs::write(owner_clone.join("owner.txt"), b"owner update\n").expect("write an owner change");
-    git_commit(&owner_clone, "owner update");
-    assert!(git_push(&owner_key, &owner_clone, &["trunk"]).success());
-    assert!(!git_push(&owner_key, &owner_clone, &["--delete", "trunk"]).success());
-    command(&owner_clone, ["switch", "-q", "-c", "force-test"]);
-    fs::write(owner_clone.join("force.txt"), b"first history\n").expect("write a branch change");
-    git_commit(&owner_clone, "first branch history");
-    assert!(git_push(&owner_key, &owner_clone, &["force-test"]).success());
-    command(&owner_clone, ["reset", "--hard", "HEAD~1"]);
-    fs::write(owner_clone.join("force.txt"), b"replacement history\n")
-        .expect("write replacement history");
-    git_commit(&owner_clone, "replacement branch history");
-    let force_result = git_push_output(&owner_key, &owner_clone, &["--force", "force-test"]);
-    assert!(!force_result.status.success());
-    assert!(String::from_utf8_lossy(&force_result.stderr).contains("non-fast-forward"));
-
-    let reader_clone = instance.path().join("reader-write-private");
-    assert!(ssh_clone_repository_succeeds(
-        ssh,
-        &reader_key,
-        "alice",
-        "private",
-        &reader_clone
-    ));
-    fs::write(reader_clone.join("reader.txt"), b"reader update\n").expect("write a reader change");
-    git_commit(&reader_clone, "reader update");
-    assert!(!git_push(&reader_key, &reader_clone, &["main"]).success());
-
-    command(&writer_clone, ["switch", "-q", "main"]);
-    fs::write(writer_clone.join("removed-role.txt"), b"removed role\n")
-        .expect("write a change before role removal");
-    git_commit(&writer_clone, "change before role removal");
-    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
-        .expect("open the repository database");
-    database
-        .execute(
-            "DELETE FROM repository_collaborator
-             WHERE account_id = (SELECT id FROM account WHERE username = 'writer')",
-            [],
-        )
-        .expect("remove the writer role");
-    drop(database);
-    assert!(
-        !git_push(
-            &writer_key,
-            &writer_clone,
-            &["HEAD:refs/heads/removed-role"]
-        )
-        .success()
-    );
-    server.terminate();
-    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
-        .expect("open the push audit database");
-    let successful: i64 = database
-        .query_row(
-            "SELECT count(*) FROM audit_event
-             WHERE action = 'ref.update' AND actor = 'writer' AND outcome = 'success'",
-            [],
-            |row| row.get(0),
-        )
-        .expect("count successful push audit events");
-    let failed: i64 = database
-        .query_row(
-            "SELECT count(*) FROM audit_event
-             WHERE action = 'ref.update' AND actor = 'writer' AND outcome = 'failure'",
-            [],
-            |row| row.get(0),
-        )
-        .expect("count failed push audit events");
-    assert_eq!(successful, 3);
-    assert!(failed >= 2);
-}
-
-fn spawn_server(config: &Path) -> ChildGuard {
-    let child = Command::new(tit_binary())
-        .args([
-            "--config",
-            config.to_str().expect("a UTF-8 configuration path"),
-            "serve",
-        ])
-        .stdout(Stdio::piped())
-        .stderr(Stdio::piped())
-        .spawn()
-        .expect("start the tit server");
-    ChildGuard(Some(child))
-}
-
-fn create_source_repository(parent: &Path) -> std::path::PathBuf {
+fn create_source_repository(parent: &Path) -> PathBuf {
     let worktree = parent.join("source-worktree");
-    command(
+    run_git(
         parent,
-        [
+        &[
             "init",
             "-q",
             "-b",
@@ -1766,7 +128,8 @@
         ],
     );
     fs::write(worktree.join("README.md"), b"serve fixture\n").expect("write source content");
-    command(&worktree, ["add", "."]);
+    run_git(&worktree, &["add", "."]);
+
     let output = Command::new("git")
         .args(["commit", "-q", "-m", "initial"])
         .env("GIT_AUTHOR_NAME", "Tit Test")
@@ -1779,15 +142,16 @@
         .current_dir(&worktree)
         .output()
         .expect("commit source content");
-    assert!(output.status.success(), "Git commit failed");
-    command(&worktree, ["branch", "trunk"]);
-    command(&worktree, ["switch", "-q", "trunk"]);
-    fs::write(worktree.join("TRUNK.md"), b"trunk default\n").expect("write trunk content");
-    git_commit(&worktree, "trunk head");
+    assert!(
+        output.status.success(),
+        "Git commit failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+
     let bare = parent.join("source.git");
-    command(
+    run_git(
         parent,
-        [
+        &[
             "clone",
             "-q",
             "--bare",
@@ -1798,28 +162,50 @@
     bare
 }
 
-fn command<const N: usize>(directory: &Path, arguments: [&str; N]) {
-    let executable = if matches!(arguments.first(), Some(&"--config")) {
-        tit_binary()
-    } else {
-        "git".into()
-    };
-    let output = Command::new(executable)
+fn run_git(directory: &Path, arguments: &[&str]) {
+    let output = Command::new("git")
         .args(arguments)
         .current_dir(directory)
         .output()
-        .expect("run a fixture command");
+        .expect("run Git");
     assert!(
         output.status.success(),
-        "fixture command failed: {}",
+        "Git command failed: {}",
         String::from_utf8_lossy(&output.stderr)
     );
 }
 
-fn tit_binary() -> std::path::PathBuf {
+fn run_tit(directory: &Path, config: &Path, arguments: &[&str]) {
+    let output = Command::new(tit_binary())
+        .arg("--config")
+        .arg(config)
+        .args(arguments)
+        .current_dir(directory)
+        .output()
+        .expect("run tit");
+    assert!(
+        output.status.success(),
+        "tit command failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+}
+
+fn tit_binary() -> PathBuf {
     env::var_os("TIT_RELEASE_BINARY")
         .map(Into::into)
         .unwrap_or_else(|| env!("CARGO_BIN_EXE_tit").into())
+}
+
+fn spawn_server(config: &Path) -> ChildGuard {
+    let child = Command::new(tit_binary())
+        .arg("--config")
+        .arg(config)
+        .arg("serve")
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .spawn()
+        .expect("start the tit server");
+    ChildGuard(Some(child))
 }
 
 fn wait_for_listener(address: SocketAddr, server: &mut ChildGuard) {
@@ -1851,463 +237,47 @@
 }
 
 fn http_get(address: SocketAddr, path: &str) -> String {
-    http_get_with_headers(address, path, &[])
-}
-
-fn http_get_with_headers(address: SocketAddr, path: &str, headers: &[(&str, &str)]) -> String {
     let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
-    let mut request = format!("GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n");
-    for (name, value) in headers {
-        request.push_str(&format!("{name}: {value}\r\n"));
-    }
-    request.push_str("\r\n");
+    let timeout = Some(Duration::from_secs(10));
     stream
-        .write_all(request.as_bytes())
-        .expect("write an HTTP request");
+        .set_read_timeout(timeout)
+        .expect("set the HTTP read timeout");
+    stream
+        .set_write_timeout(timeout)
+        .expect("set the HTTP write timeout");
+    write!(
+        stream,
+        "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n"
+    )
+    .expect("write an HTTP request");
+
     let mut response = String::new();
     stream
         .read_to_string(&mut response)
         .expect("read an HTTP response");
     response
-}
-
-fn http_form(address: SocketAddr, path: &str, fields: &[(&str, &str)]) -> String {
-    http_form_with_headers(address, path, fields, &[])
-}
-
-fn http_form_with_headers(
-    address: SocketAddr,
-    path: &str,
-    fields: &[(&str, &str)],
-    headers: &[(&str, &str)],
-) -> String {
-    let body = url::form_urlencoded::Serializer::new(String::new())
-        .extend_pairs(fields.iter().copied())
-        .finish();
-    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
-    let mut request = format!(
-        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n",
-        body.len()
-    );
-    for (name, value) in headers {
-        request.push_str(&format!("{name}: {value}\r\n"));
-    }
-    request.push_str("\r\n");
-    request.push_str(&body);
-    stream
-        .write_all(request.as_bytes())
-        .expect("write an HTTP form");
-    let mut response = String::new();
-    stream
-        .read_to_string(&mut response)
-        .expect("read an HTTP response");
-    response
-}
-
-fn response_cookies(response: &str) -> String {
-    response
-        .lines()
-        .filter_map(|line| line.split_once(':'))
-        .filter(|(name, _)| name.eq_ignore_ascii_case("set-cookie"))
-        .map(|(_, value)| {
-            value
-                .trim()
-                .split_once(';')
-                .map_or(value.trim(), |(cookie, _)| cookie)
-        })
-        .collect::<Vec<_>>()
-        .join("; ")
-}
-
-fn response_header<'a>(response: &'a str, name: &str) -> &'a str {
-    response
-        .lines()
-        .filter_map(|line| line.split_once(':'))
-        .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
-        .map(|(_, value)| value.trim())
-        .expect("read a response header")
-}
-
-fn http_multipart(
-    address: SocketAddr,
-    path: &str,
-    fields: &[(&str, &str)],
-    headers: &[(&str, &str)],
-) -> String {
-    let boundary = "tit-test-boundary";
-    let mut body = String::new();
-    for (name, value) in fields {
-        if *name == "signature-file" {
-            body.push_str(&format!(
-                "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"signature.sig\"\r\nContent-Type: application/octet-stream\r\n\r\n{value}\r\n"
-            ));
-        } else {
-            body.push_str(&format!(
-                "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"
-            ));
-        }
-    }
-    body.push_str(&format!("--{boundary}--\r\n"));
-    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
-    let mut request = format!(
-        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: multipart/form-data; boundary={boundary}\r\nContent-Length: {}\r\nConnection: close\r\n",
-        body.len()
-    );
-    for (name, value) in headers {
-        request.push_str(&format!("{name}: {value}\r\n"));
-    }
-    request.push_str("\r\n");
-    request.push_str(&body);
-    stream
-        .write_all(request.as_bytes())
-        .expect("write a multipart HTTP form");
-    let mut response = String::new();
-    stream
-        .read_to_string(&mut response)
-        .expect("read an HTTP response");
-    response
-}
-
-fn http_body(
-    address: SocketAddr,
-    path: &str,
-    content_type: &str,
-    body: &str,
-    headers: &[(&str, &str)],
-) -> String {
-    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
-    let mut request = format!(
-        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n",
-        body.len()
-    );
-    for (name, value) in headers {
-        request.push_str(&format!("{name}: {value}\r\n"));
-    }
-    request.push_str("\r\n");
-    request.push_str(body);
-    stream
-        .write_all(request.as_bytes())
-        .expect("write an HTTP request body");
-    let mut response = String::new();
-    stream
-        .read_to_string(&mut response)
-        .expect("read an HTTP response");
-    response
-}
-
-fn cookie_value<'a>(cookies: &'a str, name: &str) -> &'a str {
-    cookies
-        .split("; ")
-        .find_map(|cookie| cookie.strip_prefix(&format!("{name}=")))
-        .expect("find the cookie")
-}
-
-fn sign_challenge(directory: &Path, private_key: &Path, challenge: &str) -> String {
-    let nonce = challenge
-        .lines()
-        .find_map(|line| line.strip_prefix("nonce="))
-        .expect("find the Web login nonce");
-    let path = directory.join(format!("web-login-{nonce}.challenge"));
-    fs::write(&path, challenge).expect("write the Web login challenge");
-    let output = Command::new("ssh-keygen")
-        .args(["-q", "-Y", "sign", "-f"])
-        .arg(private_key)
-        .args(["-n", "tit-auth"])
-        .arg(&path)
-        .output()
-        .expect("sign the Web login challenge");
-    assert!(
-        output.status.success(),
-        "cannot sign the Web login challenge: {}",
-        String::from_utf8_lossy(&output.stderr)
-    );
-    fs::read_to_string(path.with_extension("challenge.sig")).expect("read the Web login signature")
-}
-
-fn between<'a>(value: &'a str, start: &str, end: &str) -> &'a str {
-    value
-        .split_once(start)
-        .and_then(|(_, tail)| tail.split_once(end))
-        .map(|(value, _)| value)
-        .expect("find the response value")
-}
-
-fn ssh_clone_succeeds(address: SocketAddr, private_key: &Path, target: &Path) -> bool {
-    ssh_clone_repository_succeeds(address, private_key, "alice", "example", target)
-}
-
-fn ssh_clone_repository_succeeds(
-    address: SocketAddr,
-    private_key: &Path,
-    owner: &str,
-    repository: &str,
-    target: &Path,
-) -> bool {
-    let ssh_command = format!(
-        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
-        private_key.display()
-    );
-    Command::new("git")
-        .args([
-            "clone",
-            "-q",
-            &format!(
-                "ssh://ignored@127.0.0.1:{}/{owner}/{repository}.git",
-                address.port(),
-            ),
-        ])
-        .arg(target)
-        .env("GIT_SSH_COMMAND", ssh_command)
-        .output()
-        .expect("clone through the tit SSH server")
-        .status
-        .success()
-}
-
-fn ssh_exec(address: SocketAddr, private_key: &Path, command: &[&str]) -> Output {
-    Command::new("ssh")
-        .args([
-            "-F",
-            "/dev/null",
-            "-o",
-            "BatchMode=yes",
-            "-o",
-            "IdentitiesOnly=yes",
-            "-o",
-            "StrictHostKeyChecking=no",
-            "-o",
-            "UserKnownHostsFile=/dev/null",
-            "-o",
-            "LogLevel=ERROR",
-            "-i",
-        ])
-        .arg(private_key)
-        .args(["-p", &address.port().to_string()])
-        .arg(format!("ignored@{}", address.ip()))
-        .args(command)
-        .output()
-        .expect("run an SSH repository command")
-}
-
-fn ssh_exec_with_input(
-    address: SocketAddr,
-    private_key: &Path,
-    command: &[&str],
-    input: &[u8],
-) -> Output {
-    let mut child = Command::new("ssh")
-        .args([
-            "-F",
-            "/dev/null",
-            "-o",
-            "BatchMode=yes",
-            "-o",
-            "IdentitiesOnly=yes",
-            "-o",
-            "StrictHostKeyChecking=no",
-            "-o",
-            "UserKnownHostsFile=/dev/null",
-            "-o",
-            "LogLevel=ERROR",
-            "-i",
-        ])
-        .arg(private_key)
-        .args(["-p", &address.port().to_string()])
-        .arg(format!("ignored@{}", address.ip()))
-        .args(command)
-        .stdin(Stdio::piped())
-        .stdout(Stdio::piped())
-        .stderr(Stdio::piped())
-        .spawn()
-        .expect("start an SSH issue command");
-    child
-        .stdin
-        .take()
-        .expect("open SSH issue input")
-        .write_all(input)
-        .expect("write SSH issue input");
-    child
-        .wait_with_output()
-        .expect("finish an SSH issue command")
-}
-
-fn provision_account(
-    instance: &Path,
-    username: &str,
-    state: &str,
-    revoked: bool,
-) -> std::path::PathBuf {
-    let private_key = instance.join(username);
-    create_ssh_key_fixture(&private_key);
-    let public_key =
-        fs::read_to_string(private_key.with_extension("pub")).expect("read an account public key");
-    let mut fields = public_key.split_whitespace();
-    let canonical = format!(
-        "{} {}",
-        fields.next().expect("read the key algorithm"),
-        fields.next().expect("read the key data")
-    );
-    let fingerprint_output = Command::new("ssh-keygen")
-        .args(["-E", "sha256", "-lf"])
-        .arg(private_key.with_extension("pub"))
-        .output()
-        .expect("read an SSH key fingerprint");
-    assert!(fingerprint_output.status.success());
-    let fingerprint_text =
-        String::from_utf8(fingerprint_output.stdout).expect("read a UTF-8 SSH key fingerprint");
-    let fingerprint = fingerprint_text
-        .split_whitespace()
-        .nth(1)
-        .expect("read the SSH key fingerprint");
-    let database = rusqlite::Connection::open(instance.join("tit.sqlite3"))
-        .expect("open the repository database");
-    database
-        .execute(
-            "INSERT INTO account (username, is_administrator, state, created_at)
-             VALUES (?1, 0, ?2, 1)",
-            rusqlite::params![username, state],
-        )
-        .expect("create an account fixture");
-    let account_id = database.last_insert_rowid();
-    database
-        .execute(
-            "INSERT INTO ssh_public_key
-             (account_id, canonical_key, fingerprint, created_at, label, revoked_at)
-             VALUES (?1, ?2, ?3, 1, 'initial', ?4)",
-            rusqlite::params![account_id, canonical, fingerprint, revoked.then_some(2)],
-        )
-        .expect("create an SSH key fixture");
-    private_key
-}
-
-fn ssh_fingerprint(private_key: &Path) -> String {
-    let output = Command::new("ssh-keygen")
-        .args(["-E", "sha256", "-lf"])
-        .arg(private_key.with_extension("pub"))
-        .output()
-        .expect("read an SSH key fingerprint");
-    assert!(output.status.success());
-    String::from_utf8(output.stdout)
-        .expect("read a UTF-8 SSH key fingerprint")
-        .split_whitespace()
-        .nth(1)
-        .expect("read the SSH key fingerprint")
-        .to_owned()
-}
-
-fn git_commit(worktree: &Path, message: &str) {
-    command(worktree, ["add", "."]);
-    let output = Command::new("git")
-        .args(["commit", "-q", "-m", message])
-        .env("GIT_AUTHOR_NAME", "Tit Test")
-        .env("GIT_AUTHOR_EMAIL", "tit@example.test")
-        .env("GIT_COMMITTER_NAME", "Tit Test")
-        .env("GIT_COMMITTER_EMAIL", "tit@example.test")
-        .env("GIT_CONFIG_COUNT", "1")
-        .env("GIT_CONFIG_KEY_0", "commit.gpgsign")
-        .env("GIT_CONFIG_VALUE_0", "false")
-        .current_dir(worktree)
-        .output()
-        .expect("commit a Git change");
-    assert!(
-        output.status.success(),
-        "Git commit failed: {}",
-        String::from_utf8_lossy(&output.stderr)
-    );
-}
-
-fn git_push(private_key: &Path, worktree: &Path, refspecs: &[&str]) -> ExitStatus {
-    git_push_output(private_key, worktree, refspecs).status
-}
-
-fn git_push_output(private_key: &Path, worktree: &Path, refspecs: &[&str]) -> Output {
-    let ssh_command = format!(
-        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
-        private_key.display()
-    );
-    Command::new("git")
-        .arg("push")
-        .arg("origin")
-        .args(refspecs)
-        .env("GIT_SSH_COMMAND", ssh_command)
-        .current_dir(worktree)
-        .output()
-        .expect("push through the tit SSH server")
-}
-
-fn git_fetch_ref(private_key: &Path, worktree: &Path, refspec: &str) -> ExitStatus {
-    let ssh_command = format!(
-        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
-        private_key.display()
-    );
-    Command::new("git")
-        .args(["fetch", "origin", refspec])
-        .env("GIT_SSH_COMMAND", ssh_command)
-        .current_dir(worktree)
-        .status()
-        .expect("fetch through the tit SSH server")
-}
-
-fn git_revision(worktree: &Path, revision: &str) -> String {
-    let output = Command::new("git")
-        .args(["rev-parse", revision])
-        .current_dir(worktree)
-        .output()
-        .expect("resolve a Git revision");
-    assert!(
-        output.status.success(),
-        "cannot resolve Git revision: {}",
-        String::from_utf8_lossy(&output.stderr)
-    );
-    String::from_utf8(output.stdout)
-        .expect("read a Git revision")
-        .trim()
-        .to_owned()
 }
 
 struct ChildGuard(Option<Child>);
 
 impl ChildGuard {
-    fn terminate_capture(&mut self) -> Vec<u8> {
-        let child = self.0.take().expect("the server process is active");
+    fn terminate(&mut self) {
+        let mut child = self.0.take().expect("the server process is active");
         let signal = Command::new("kill")
             .args(["-TERM", &child.id().to_string()])
             .output()
             .expect("send SIGTERM to the tit server");
         assert!(signal.status.success(), "cannot send SIGTERM");
-        let output = child
-            .wait_with_output()
-            .expect("wait for the tit server and read its output");
-        assert!(
-            output.status.success(),
-            "tit serve did not stop cleanly: {}",
-            output.status
-        );
-        output.stderr
-    }
-
-    fn terminate(&mut self) {
-        if let Some(mut child) = self.0.take() {
-            let signal = Command::new("kill")
-                .args(["-TERM", &child.id().to_string()])
-                .output()
-                .expect("send SIGTERM to the tit server");
-            assert!(signal.status.success(), "cannot send SIGTERM");
-            let status = child.wait().expect("wait for the tit server");
-            assert!(status.success(), "tit serve did not stop cleanly: {status}");
-        }
-    }
-
-    fn stop(&mut self) {
-        if let Some(mut child) = self.0.take() {
-            child.kill().expect("stop the tit server");
-            child.wait().expect("wait for the tit server");
-        }
+        let status = child.wait().expect("wait for the tit server");
+        assert!(status.success(), "tit serve did not stop cleanly: {status}");
     }
 }
 
 impl Drop for ChildGuard {
     fn drop(&mut self) {
-        self.stop();
+        if let Some(mut child) = self.0.take() {
+            child.kill().expect("stop the tit server");
+            child.wait().expect("wait for the tit server");
+        }
     }
 }

tests/sqlite.rs

Mode 100644100644; object d98bbbef67726859f8acd703

@@ -10,7 +10,8 @@
 use rusqlite::{Connection, ErrorCode, TransactionBehavior, params};
 use store::{
     AuditContext, GitOperationIntent, InitialAdministrator, IssueChange, NewAuditEvent, NewIssue,
-    NewRepository, NewRepositoryReference, RepositoryOrigin, Store, StoreError,
+    NewRepository, NewRepositoryReference, RepositoryCollaboratorUpdate, RepositoryOrigin,
+    RepositorySettingsUpdate, Store, StoreError, TimelinePagination,
 };
 use tempfile::TempDir;
 
@@ -697,26 +698,26 @@
         )
         .expect("create a collaborator");
     store
-        .update_repository_settings(
-            "alice",
-            "project",
-            "alice",
-            "A small public repository.",
-            "private",
-            23,
-            "settings",
-        )
+        .update_repository_settings(&RepositorySettingsUpdate {
+            owner: "alice",
+            slug: "project",
+            actor: "alice",
+            description: "A small public repository.",
+            visibility: "private",
+            changed_at: 23,
+            correlation_id: "settings",
+        })
         .expect("update repository settings");
     store
-        .update_repository_collaborator(
-            "alice",
-            "project",
-            "alice",
-            "bob",
-            Some("reader"),
-            24,
-            "collaborator",
-        )
+        .update_repository_collaborator(&RepositoryCollaboratorUpdate {
+            owner: "alice",
+            slug: "project",
+            actor: "alice",
+            username: "bob",
+            role: Some("reader"),
+            changed_at: 24,
+            correlation_id: "collaborator",
+        })
         .expect("add a repository collaborator");
     let settings = store
         .repository_settings("alice", "project", "alice")
@@ -730,15 +731,15 @@
         Err(StoreError::PullRequestDenied)
     ));
     store
-        .update_repository_settings(
-            "alice",
-            "project",
-            "alice",
-            "A small public repository.",
-            "public",
-            25,
-            "settings-public",
-        )
+        .update_repository_settings(&RepositorySettingsUpdate {
+            owner: "alice",
+            slug: "project",
+            actor: "alice",
+            description: "A small public repository.",
+            visibility: "public",
+            changed_at: 25,
+            correlation_id: "settings-public",
+        })
         .expect("restore public visibility");
 
     assert!(matches!(
@@ -969,7 +970,17 @@
         .expect("reopen an issue as a writer");
 
     let detail = store
-        .issue_detail("alice", "project", 1, Some("maintainer"), 1, 1, 50)
+        .issue_detail(
+            "alice",
+            "project",
+            1,
+            Some("maintainer"),
+            TimelinePagination {
+                primary_page: 1,
+                timeline_page: 1,
+                page_size: 50,
+            },
+        )
         .expect("read the issue timeline");
     assert_eq!(detail.repository.slug, "project");
     assert_eq!(detail.issue.title, "Writer edit");
@@ -1000,14 +1011,34 @@
             .all(|events| events[0].sequence < events[1].sequence)
     );
     let first_activity_page = store
-        .issue_detail("alice", "project", 1, Some("maintainer"), 1, 1, 1)
+        .issue_detail(
+            "alice",
+            "project",
+            1,
+            Some("maintainer"),
+            TimelinePagination {
+                primary_page: 1,
+                timeline_page: 1,
+                page_size: 1,
+            },
+        )
         .expect("read the first bounded activity page");
     assert_eq!(first_activity_page.comments.len(), 1);
     assert!(!first_activity_page.comments_has_next);
     assert_eq!(first_activity_page.timeline.len(), 1);
     assert!(first_activity_page.timeline_has_next);
     let second_timeline_page = store
-        .issue_detail("alice", "project", 1, Some("maintainer"), 1, 2, 1)
+        .issue_detail(
+            "alice",
+            "project",
+            1,
+            Some("maintainer"),
+            TimelinePagination {
+                primary_page: 1,
+                timeline_page: 2,
+                page_size: 1,
+            },
+        )
         .expect("read the second timeline page");
     assert_eq!(second_timeline_page.timeline_page, 2);
     assert_eq!(second_timeline_page.timeline.len(), 1);

tests/ssh_fixture.rs

Mode 100644; object 834883fca4d5

@@ -1,0 +1,15 @@
+use std::path::Path;
+use std::process::Command;
+
+pub(crate) fn create_ssh_key(path: &Path) {
+    let output = Command::new("ssh-keygen")
+        .args(["-q", "-t", "ed25519", "-N", "", "-f"])
+        .arg(path)
+        .output()
+        .expect("run the stock ssh-keygen client");
+    assert!(
+        output.status.success(),
+        "create an SSH key fixture: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+}

tests/support/mod.rs

Mode 100644100644; object d249db685df236e4f67c434f

@@ -65,19 +65,6 @@
     );
 }
 
-pub(crate) fn create_ssh_key_fixture(path: &Path) {
-    let output = Command::new("ssh-keygen")
-        .args(["-q", "-t", "ed25519", "-N", "", "-f"])
-        .arg(path)
-        .output()
-        .expect("run the stock ssh-keygen client");
-    assert!(
-        output.status.success(),
-        "create an SSH key fixture: {}",
-        String::from_utf8_lossy(&output.stderr)
-    );
-}
-
 pub(crate) fn read_stock_ssh_configuration() {
     let output = Command::new("ssh")
         .args(["-G", "-F", "/dev/null", "localhost"])

tests/web_session.rs

Mode 100644100644; object 3827ad13a9e397a296e325a2

@@ -1,7 +1,7 @@
 use crate::{account, auth, session, store};
 
-#[allow(dead_code, reason = "the Web session test uses one shared test helper")]
-mod support;
+#[path = "ssh_fixture.rs"]
+mod ssh_fixture;
 
 use std::fs;
 use std::path::Path;
@@ -11,13 +11,13 @@
 use std::time::{SystemTime, UNIX_EPOCH};
 
 use sha2::{Digest, Sha256};
-use support::create_ssh_key_fixture;
 use tempfile::TempDir;
 use url::Url;
 
 use account::{AccountError, AccountKeyRequest, AccountService};
 use auth::SshPublicKey;
 use session::{SessionError, WebLoginService};
+use ssh_fixture::create_ssh_key as create_ssh_key_fixture;
 use store::{InitialAdministrator, Store, StoreError};
 
 #[test]