chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

806 lines · 27.8 KB Raw History
//! The gateway against a reference PDS: owners delegate, collaborators push
//! and commit through it, and commits to one branch are batched.

#[path = "../../knotless/tests/common/mod.rs"]
mod common;
use common::*;

use knotless_serve::gateway::Gateway;
use knotless_serve::{App, router};
use serde_json::{Value, json};
use std::sync::atomic::Ordering;
use std::sync::{Arc, Barrier};
use std::time::Duration;

struct Server {
    base: String,
    did: String,
    app: Arc<App>,
    _rt: tokio::runtime::Runtime,
    _proxy: TlsProxy,
    _sandbox: Sandbox,
}

/// A gateway with config and cache of its own, where owners' delegated
/// sessions live.
fn gateway() -> Server {
    let mut s = Sandbox::new();
    s.set("KNOTLESS_PASSWORD", "");
    s.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let rt = tokio::runtime::Runtime::new().unwrap();
    let listener = rt
        .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
        .unwrap();
    // atproto takes a did:web with a port only for localhost.
    let port = listener.local_addr().unwrap().port();
    let base = format!("http://localhost:{port}");
    let mut gw = Gateway::new(&base).unwrap();
    // Authorization servers reach a gateway, an OAuth client, over https.
    let proxy = tls_proxy(port);
    gw.oauth_url = proxy.url.clone();
    // Short, so that delegating asks more than once.
    gw.wait = Duration::from_millis(100);
    let did = gw.did.clone();
    let app = App::build(ctx(&s), Duration::ZERO, Some(gw), None, false);
    let router = router(app.clone());
    rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
    Server {
        base,
        did,
        app,
        _rt: rt,
        _proxy: proxy,
        _sandbox: s,
    }
}

fn web(mut s: Sandbox) -> Sandbox {
    s.set("KNOTLESS_DID_WEB_SCHEME", "http");
    s
}

/// A sandbox logged in as `account`, with no password in its environment,
/// so it acts only as itself, and a browser that approves as it.
fn person(account: Account) -> Sandbox {
    let mut s = web(Sandbox::with(account));
    s.set("KNOTLESS_PASSWORD", "");
    s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), ""));
    let password = s.account.password.clone();
    let handle = s.account.handle.clone();
    assert_eq!(
        s.cli(&["login", &handle, "--password-stdin"], password.as_bytes())
            .0,
        0
    );
    s
}

/// A service token for `s` to call `lxm` on `aud`.
fn token(s: &Sandbox, aud: &str, lxm: &str) -> String {
    let ctx = ctx(s);
    tokio::runtime::Runtime::new().unwrap().block_on(async {
        let id = knotless::identity::resolve(&ctx, &s.account.handle)
            .await
            .unwrap();
        let me = knotless::session::ensure(&ctx, &id).await.unwrap();
        knotless::auth::service_token(&ctx, &me, aud, lxm)
            .await
            .unwrap()
    })
}

fn post(url: &str, token: Option<&str>, body: Vec<u8>, json: bool) -> (u16, String) {
    let client = reqwest::blocking::Client::new();
    let mut req = client.post(url).body(body);
    if json {
        req = req.header("content-type", "application/json");
    }
    if let Some(t) = token {
        req = req.bearer_auth(t);
    }
    let res = req.send().unwrap();
    (res.status().as_u16(), res.text().unwrap())
}

/// `person`, logged in with OAuth rather than a password: scoped to what
/// knotless asks for.
fn oauth_person(account: Account) -> Sandbox {
    let mut s = web(Sandbox::with(account));
    s.set("KNOTLESS_PASSWORD", "");
    s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), ""));
    let handle = s.account.handle.clone();
    assert_eq!(s.cli(&["login", &handle], b"").0, 0);
    s
}

/// What an OAuth login calls a gateway for, its scope allows: delegating
/// to it, and pushing and committing through it.
#[test]
fn oauth_logins_reach_a_gateway() {
    let gw = gateway();
    let owner = oauth_person(account());
    let collab = oauth_person(account());
    let url = owner.url("scoped");
    let work = owner.work("w", 2);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let (code, out) = owner.cli(&["delegate", &owner.account.handle, &gw.did], b"");
    assert_eq!(code, 0, "{out}");
    let shared = owner.cli(&["share", &url, &collab.account.handle, "--write"], b"");
    assert_eq!(shared.0, 0, "{}", shared.1);
    let clone = collab.path("clone");
    collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]);
    let sha = collab.commit_file(&clone, "theirs", "from a collaborator\n");
    collab.git(&clone, &["push", "-q", "origin", "main"]);
    let (_, refs) = owner.cli(&["refs", &url], b"");
    assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}");
    let committed = collab.cli(
        &[
            "commit", &url, "-b", "main", "-m", "Scoped", "--put", "x", "-",
        ],
        b"x\n",
    );
    assert_eq!(committed.0, 0, "{}", committed.1);
}

/// Pushes `refspec` from `dir` to its origin, as git would: the error, if
/// the push is refused.
fn refused(s: &Sandbox, dir: &std::path::Path, refspec: &str) -> Option<String> {
    let (ok, _, err) = s.git_raw(dir, &["push", "-q", "origin", refspec]);
    (!ok).then_some(err)
}

#[test]
fn protected_branches_hold_collaborators() {
    let gw = gateway();
    let owner = person(account());
    let collab = person(account());
    let url = owner.url("guarded");
    let work = owner.work("w", 2);
    owner.git(&work, &["push", "-q", &url, "main"]);
    assert_eq!(
        owner
            .cli(&["delegate", &owner.account.handle, &gw.did], b"")
            .0,
        0
    );
    let share = ["share", &url, &collab.account.handle, "--write"];
    assert_eq!(owner.cli(&share, b"").0, 0);

    // Only the owner says which branches are protected.
    let (code, out) = collab.cli(&["protect", &url, "main"], b"");
    assert_eq!(code, 1, "{out}");
    let missing = owner.url("missing");
    for bad in [
        vec!["rules", &missing],
        vec!["protect", &url, "main", "--bogus"],
        vec!["unprotect", &url, "main", "--merges"],
        vec!["unprotect", &url, "main"],
    ] {
        assert_eq!(owner.cli(&bad, b"").0, 1, "{bad:?}");
    }
    let (code, out) = owner.cli(&["protect", &url, "main"], b"");
    assert_eq!((code, out.as_str()), (0, "main\tlinear"));
    assert_eq!(collab.cli(&["rules", &url], b"").1, "main\tlinear");

    let clone = collab.path("clone");
    collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]);
    let base = collab.git(&clone, &["rev-parse", "HEAD"]);
    // A merge commit, of a side branch off where main was.
    collab.git(&clone, &["checkout", "-q", "-b", "side", "HEAD~1"]);
    collab.commit_file(&clone, "side", "side\n");
    collab.git(&clone, &["checkout", "-q", "main"]);
    collab.git(
        &clone,
        &["merge", "-q", "--no-ff", "-m", "merge side", "side"],
    );
    let err = refused(&collab, &clone, "main").unwrap();
    assert!(err.contains("no merge commits"), "{err}");
    // Nor by way of a branch that took it: what counts is what main gains.
    assert_eq!(refused(&collab, &clone, "main:refs/heads/feature"), None);
    let err = refused(&collab, &clone, "main").unwrap();
    assert!(err.contains("no merge commits"), "{err}");
    // A new protected branch takes none in all its history.
    owner.cli(&["protect", &url, "release"], b"");
    let err = refused(&collab, &clone, "main:refs/heads/release").unwrap();
    assert!(err.contains("no merge commits"), "{err}");
    collab.git(&clone, &["reset", "-q", "--hard", &base]);
    let err = refused(&collab, &clone, "+HEAD~1:main").unwrap();
    assert!(err.contains("only moves forward"), "{err}");
    let err = refused(&collab, &clone, ":main").unwrap();
    assert!(err.contains("not deleted"), "{err}");

    // Forward, one parent at a time, it lands.
    let ahead = collab.commit_file(&clone, "f", "linear\n");
    assert_eq!(refused(&collab, &clone, "main"), None);
    let (_, refs) = owner.cli(&["refs", &url], b"");
    assert!(
        refs.contains(&format!("{ahead}\trefs/heads/main")),
        "{refs}"
    );

    // The owner writes past the gateway, rules or none.
    owner.git(&work, &["push", "-q", "--force", &url, "main"]);

    // Merges allowed, merge commits land; history still only moves forward.
    let (code, out) = owner.cli(&["protect", &url, "main", "--merges"], b"");
    assert_eq!((code, out.as_str()), (0, "release\tlinear\nmain\tmerges"));
    collab.git(&clone, &["fetch", "-q", "origin"]);
    collab.git(&clone, &["reset", "-q", "--hard", "origin/main"]);
    collab.git(
        &clone,
        &["merge", "-q", "--no-ff", "-m", "merge side", "side"],
    );
    assert_eq!(refused(&collab, &clone, "main"), None);
    let err = refused(&collab, &clone, "+HEAD~1:main").unwrap();
    assert!(err.contains("only moves forward"), "{err}");
    let (code, out) = owner.cli(&["unprotect", &url, "main"], b"");
    assert_eq!((code, out.as_str()), (0, "release\tlinear"));
    assert_eq!(refused(&collab, &clone, "+HEAD~1:main"), None);
}

#[test]
fn collaborators_push_and_commit_through_a_gateway() {
    let gw = gateway();
    let owner = person(account());
    let collab = person(account());
    let outsider = person(account());
    let url = owner.url("shared");
    let work = owner.work("w", 2);
    owner.git(&work, &["push", "-q", &url, "main"]);

    // Sharing for writes needs a gateway to write through.
    let (code, _) = owner.cli(&["share", &url, &collab.account.handle, "--write"], b"");
    assert_eq!(code, 1);
    let (code, out) = owner.cli(&["delegate", &owner.account.handle, &gw.did], b"");
    assert_eq!(code, 0, "{out}");
    assert_eq!(
        owner
            .cli(&["share", &url, &collab.account.handle, "--write"], b"")
            .0,
        0
    );

    // A collaborator clones, commits, and pushes as git would.
    let clone = collab.path("clone");
    collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]);
    let sha = collab.commit_file(&clone, "theirs", "from a collaborator\n");
    collab.git(
        &clone,
        &["push", "-q", "origin", "main", "main:refs/heads/feature"],
    );
    let (_, refs) = owner.cli(&["refs", &url], b"");
    assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}");
    assert!(
        refs.contains(&format!("{sha}\trefs/heads/feature")),
        "{refs}"
    );
    collab.git(&clone, &["push", "-q", "origin", ":refs/heads/feature"]);
    let (_, refs) = owner.cli(&["refs", &url], b"");
    assert!(!refs.contains("feature"), "{refs}");

    // The gateway refreshes its grant itself, as the client it was granted
    // to, once the access token runs out.
    let held = gw
        ._sandbox
        .path("config/sessions")
        .join(format!("{}.json", owner.account.did.replace(':', "_")));
    let mut session: knotless::session::Session = knotless::store::read(&held).unwrap();
    let oauth = session.oauth.as_mut().unwrap();
    assert!(
        oauth
            .client_id
            .as_ref()
            .unwrap()
            .ends_with("/oauth-client-metadata.json")
    );
    oauth.expires_at = 0;
    let used = session.refresh_jwt.clone();
    knotless::store::write(&held, &session, true).unwrap();
    collab.git(&clone, &["push", "-q", "origin", "main:refs/heads/again"]);
    let refreshed: knotless::session::Session = knotless::store::read(&held).unwrap();
    assert_ne!(refreshed.refresh_jwt, used);

    // git's rules hold at the gateway.
    owner.commit_file(&work, "f", "moved on\n");
    owner.git(&work, &["pull", "-q", "--rebase", &url, "main"]);
    owner.git(&work, &["push", "-q", &url, "main"]);
    collab.commit_file(&clone, "theirs", "stale\n");
    let (ok, _, err) = collab.git_raw(&clone, &["push", "origin", "main"]);
    assert!(!ok);
    assert!(
        err.contains("non-fast-forward") || err.contains("rejected"),
        "{err}"
    );

    // Commits without a checkout go through it too, and a base that the
    // branch has moved on from conflicts on the paths it touched.
    let note = collab.path("note");
    std::fs::write(&note, "n\n").unwrap();
    let (code, out) = collab.cli(
        &[
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "note",
            "--put",
            "note",
            &note.to_string_lossy(),
            "--json",
        ],
        b"",
    );
    assert_eq!(code, 0, "{out}");
    let committed: Value = serde_json::from_str(&out).unwrap();
    let (code, out) = collab.cli(
        &[
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "late",
            "--put",
            "f",
            &note.to_string_lossy(),
            "--base",
            &sha,
        ],
        b"",
    );
    assert_eq!(code, 1, "f changed since: {out}");
    let (code, out) = collab.cli(
        &[
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "clean",
            "--put",
            "elsewhere",
            &note.to_string_lossy(),
            "--base",
            committed["sha"].as_str().unwrap(),
        ],
        b"",
    );
    assert_eq!(code, 0, "{out}");
    let (code, out) = collab.cli(
        &[
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "tidy",
            "--delete",
            "elsewhere",
        ],
        b"",
    );
    assert_eq!(code, 0, "{out}");
    // A path git refuses fails the batch it is in.
    let (code, _) = collab.cli(
        &[
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "bad",
            "--put",
            ".git/x",
            &note.to_string_lossy(),
        ],
        b"",
    );
    assert_eq!(code, 1);
    // So does a base the owner alone writes against, directly.
    let (code, out) = owner.cli(
        &[
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "late",
            "--put",
            "note",
            &note.to_string_lossy(),
            "--base",
            &sha,
        ],
        b"",
    );
    assert_eq!(code, 1, "{out}");
    assert!(out.is_empty());

    // Not on the list: refused.
    let elsewhere = outsider.path("clone");
    outsider.git(outsider.dir.path(), &["clone", "-q", &url, "clone"]);
    outsider.commit_file(&elsewhere, "x", "x\n");
    let (ok, _, err) = outsider.git_raw(&elsewhere, &["push", "origin", "main"]);
    assert!(!ok);
    assert!(err.contains("not a collaborator"), "{err}");

    // A repository with no gateway cannot take anyone else's push.
    let lonely = owner.url("lonely");
    owner.git(&work, &["push", "-q", &lonely, "main"]);
    let (ok, _, err) = outsider.git_raw(&elsewhere, &["push", &lonely, "main"]);
    assert!(!ok);
    assert!(err.contains("only its owner can"), "{err}");
    let (code, _) = outsider.cli(
        &["commit", &lonely, "-b", "main", "-m", "m", "--delete", "f"],
        b"",
    );
    assert_eq!(code, 1);
}

#[test]
fn commits_to_one_branch_are_batched() {
    let gw = gateway();
    let owner = person(account());
    let url = owner.url("busy");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    assert_eq!(
        owner
            .cli(&["delegate", &owner.account.handle, &gw.did], b"")
            .0,
        0
    );
    let collab = person(account());
    assert_eq!(
        owner
            .cli(&["share", &url, &collab.account.handle, "--write"], b"")
            .0,
        0
    );

    let n = 16;
    let barrier = Arc::new(Barrier::new(n));
    let shared = Arc::new((owner, collab));
    let handles: Vec<_> = (0..n)
        .map(|i| {
            let barrier = barrier.clone();
            let shared = shared.clone();
            let url = url.clone();
            std::thread::spawn(move || {
                let (owner, collab) = &*shared;
                let who = if i % 2 == 0 { owner } else { collab };
                let file = who.path(&format!("in{i}"));
                std::fs::write(&file, format!("{i}\n")).unwrap();
                barrier.wait();
                let mut args = vec!["commit", &url, "-b", "main", "-m", "agent", "--json"];
                let path = format!("agent{i}");
                let file = file.to_string_lossy().to_string();
                args.extend(["--put", &path, &file]);
                if who.account.did == owner.account.did {
                    args.push("--batch");
                }
                let (code, out) = who.cli(&args, b"");
                assert_eq!(code, 0, "{out}");
                serde_json::from_str::<Value>(&out).unwrap()
            })
        })
        .collect();
    let results: Vec<Value> = handles.into_iter().map(|h| h.join().unwrap()).collect();
    let (owner, _) = &*shared;
    let clone = owner.path("check");
    owner.git(owner.dir.path(), &["clone", "-q", &url, "check"]);
    let count = owner.git(&clone, &["rev-list", "--count", "HEAD"]);
    assert_eq!(count, (n + 1).to_string());
    for i in 0..n {
        assert!(clone.join(format!("agent{i}")).exists());
    }
    assert!(results.iter().all(|r| r["sha"].is_string()));
    // Fewer writes than commits: they arrived together.
    let writes = gw
        .app
        .gateway
        .as_ref()
        .unwrap()
        .writes
        .load(Ordering::Relaxed);
    assert!(writes < n as u64, "{writes} writes for {n} commits");
}

#[test]
fn racing_gateways_rebuild_on_the_moved_branch() {
    let (a, b) = (gateway(), gateway());
    let owner = person(account());
    let url = owner.url("contested");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    for gw in [&a, &b] {
        assert_eq!(
            owner
                .cli(&["delegate", &owner.account.handle, &gw.did], b"")
                .0,
            0
        );
    }
    let owner = Arc::new(owner);
    let mut round = 0;
    while a
        .app
        .gateway
        .as_ref()
        .unwrap()
        .rebuilt
        .load(Ordering::Relaxed)
        + b.app
            .gateway
            .as_ref()
            .unwrap()
            .rebuilt
            .load(Ordering::Relaxed)
        == 0
    {
        round += 1;
        assert!(round < 50, "the gateways never raced");
        let barrier = Arc::new(Barrier::new(2));
        let handles: Vec<_> = [&a.did, &b.did]
            .into_iter()
            .enumerate()
            .map(|(i, did)| {
                let (owner, barrier, did) = (owner.clone(), barrier.clone(), did.clone());
                std::thread::spawn(move || {
                    let req = json!({
                        "repo": owner.account.did, "name": "contested", "branch": "main",
                        "message": "race", "changes": [{"path": format!("r{round}-{i}"), "data": {"$bytes": "eA"}}],
                    });
                    let t = token(&owner, &did, "dev.knotless.createCommit");
                    let base = did.strip_prefix("did:web:").unwrap().replace("%3A", ":");
                    barrier.wait();
                    post(&format!("http://{base}/xrpc/dev.knotless.createCommit"), Some(&t), req.to_string().into_bytes(), true)
                })
            })
            .collect();
        for h in handles {
            let (status, body) = h.join().unwrap();
            assert_eq!(status, 200, "{body}");
        }
    }
    let (_, refs) = owner.cli(&["refs", &url], b"");
    assert!(refs.contains("refs/heads/main"), "{refs}");
}

#[test]
fn private_repositories_through_a_gateway() {
    if !net().spaces {
        eprintln!("skipped: the PDS is not on the spaces alpha");
        return;
    }
    let gw = gateway();
    let owner = person(account());
    let collab = person(account());
    let url = owner.url("hidden");
    assert_eq!(owner.cli(&["create", &url, "--private"], b"").0, 0);
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    assert_eq!(
        owner
            .cli(&["delegate", &owner.account.handle, &gw.did], b"")
            .0,
        0
    );
    assert_eq!(
        owner
            .cli(&["share", &url, &collab.account.handle, "--write"], b"")
            .0,
        0
    );

    let clone = collab.path("clone");
    collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]);
    let sha = collab.commit_file(&clone, "theirs", "private work\n");
    collab.git(&clone, &["push", "-q", "origin", "main"]);
    let (_, refs) = owner.cli(&["refs", &url], b"");
    assert_eq!(refs, format!("{sha}\trefs/heads/main"));
    let (_, members) = owner.cli(&["members", &url], b"");
    assert_eq!(
        members,
        format!(
            "{}\towner\n{}\twrite",
            owner.account.did, collab.account.did
        )
    );

    // The owner on another machine, which never delegated, keeps the
    // gateway the record already names.
    let elsewhere = person(owner.account.clone());
    let third = account();
    assert_eq!(
        elsewhere
            .cli(&["share", &url, &third.handle, "--write"], b"")
            .0,
        0
    );
}

#[test]
fn gateway_refusals() {
    let gw = gateway();
    let owner = person(account());
    let url = owner.url("guarded");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let push = format!("{}/xrpc/dev.knotless.pushRefs", gw.base);
    let commit = format!("{}/xrpc/dev.knotless.createCommit", gw.base);
    let delegate = format!("{}/xrpc/dev.knotless.putDelegation", gw.base);
    let header = |updates: Value| {
        let mut body = json!({"repo": owner.account.did, "name": "guarded", "updates": updates})
            .to_string()
            .into_bytes();
        body.push(b'\n');
        body
    };

    // No token, or one for another method or service.
    assert_eq!(post(&push, None, header(json!([])), false).0, 401);
    let wrong = token(&owner, &gw.did, "dev.knotless.createCommit");
    assert_eq!(post(&push, Some(&wrong), header(json!([])), false).0, 401);
    let elsewhere = token(&owner, "did:web:elsewhere.test", "dev.knotless.pushRefs");
    assert_eq!(
        post(&push, Some(&elsewhere), header(json!([])), false).0,
        401
    );

    // A refused grant does not delegate; until one is approved, nothing
    // writes.
    let mut refuser = person(owner.account.clone());
    refuser.set("KNOTLESS_BROWSER", &browser(&owner.account, "--reject"));
    let (ok, _, err) = refuser.knotless(&["delegate", &owner.account.handle, &gw.did], b"");
    assert!(!ok);
    assert!(err.contains("rejected"), "{err}");
    let t = token(&owner, &gw.did, "dev.knotless.pushRefs");
    assert_eq!(post(&push, Some(&t), header(json!([])), false).0, 403);
    // Nobody waits on a delegation nobody started, nor takes one back.
    let t = token(&owner, &gw.did, "dev.knotless.getDelegation");
    let res = reqwest::blocking::Client::new()
        .get(format!(
            "{}/xrpc/dev.knotless.getDelegation?state=nope",
            gw.base
        ))
        .bearer_auth(&t)
        .send()
        .unwrap();
    assert_eq!(res.status().as_u16(), 404);
    let res =
        reqwest::blocking::get(format!("{}/oauth/callback?state=nope&code=x", gw.base)).unwrap();
    assert_eq!(res.status().as_u16(), 400);
    // Started, and the browser comes back with a code that is no good.
    let t = token(&owner, &gw.did, "dev.knotless.putDelegation");
    let (status, body) = post(&delegate, Some(&t), vec![], false);
    assert_eq!(status, 200, "{body}");
    let state = serde_json::from_str::<Value>(&body).unwrap()["state"]
        .as_str()
        .unwrap()
        .to_string();
    let issuer = net().pds.clone();
    let page = reqwest::blocking::get(format!(
        "{}/oauth/callback?state={state}&code=bogus&iss={issuer}",
        gw.base
    ))
    .unwrap()
    .text()
    .unwrap();
    assert!(page.contains("was not delegated"), "{page}");
    // The metadata the PDS reads the gateway's client from.
    let metadata: Value = reqwest::blocking::get(format!("{}/oauth-client-metadata.json", gw.base))
        .unwrap()
        .json()
        .unwrap();
    assert!(
        metadata["client_id"]
            .as_str()
            .unwrap()
            .starts_with("https://")
    );
    assert_eq!(metadata["jwks"]["keys"].as_array().unwrap().len(), 1);
    assert_eq!(
        owner
            .cli(&["delegate", &owner.account.handle, &gw.did], b"")
            .0,
        0
    );
    // Nothing answers for this one, and that one is no gateway.
    let (code, _) = owner.cli(
        &["delegate", &owner.account.handle, "did:web:localhost%3A1"],
        b"",
    );
    assert_eq!(code, 1);
    let mut out = Vec::new();
    let code = knotless::cli::main_with(
        vec![
            "knotless".into(),
            "delegate".into(),
            owner.account.handle.clone(),
            owner.account.did.clone(),
        ],
        owner.env(),
        &mut &b""[..],
        &mut out,
    );
    assert_eq!(code, 1);

    // Malformed pushes.
    let t = token(&owner, &gw.did, "dev.knotless.pushRefs");
    assert_eq!(post(&push, Some(&t), b"no header".to_vec(), false).0, 400);
    let bad = header(json!([{"dst": "refs/heads/x", "src": "main"}]));
    assert_eq!(post(&push, Some(&t), bad, false).0, 400);
    let bad = header(json!([{"dst": "HEAD", "src": null}]));
    assert_eq!(post(&push, Some(&t), bad, false).0, 400);
    let mut garbage = header(json!([]));
    garbage.extend(b"PACK not really");
    assert_eq!(post(&push, Some(&t), garbage, false).0, 400);
    let absent = header(json!([{"dst": "refs/heads/x", "src": "1".repeat(40)}]));
    let (status, body) = post(&push, Some(&t), absent, false);
    assert_eq!(status, 400);
    assert!(body.contains("does not hold"), "{body}");
    let missing = json!({"repo": owner.account.did, "name": "nothing-here", "updates": []});
    let mut body = missing.to_string().into_bytes();
    body.push(b'\n');
    let (status, text) = post(&push, Some(&t), body, false);
    assert_eq!(status, 404, "{text}");
    let mut body = json!({"repo": owner.account.did, "name": "bad name!", "updates": []})
        .to_string()
        .into_bytes();
    body.push(b'\n');
    assert_eq!(post(&push, Some(&t), body, false).0, 400);

    // Malformed commits.
    let t = token(&owner, &gw.did, "dev.knotless.createCommit");
    let empty = json!({"repo": owner.account.did, "name": "guarded", "branch": "", "message": "m", "changes": []});
    assert_eq!(
        post(&commit, Some(&t), empty.to_string().into_bytes(), true).0,
        400
    );
    // One that changes nothing lands nothing.
    let noop = json!({"repo": owner.account.did, "name": "guarded", "branch": "main", "message": "m", "changes": []});
    let (status, body) = post(&commit, Some(&t), noop.to_string().into_bytes(), true);
    assert_eq!(status, 200, "{body}");
    assert_eq!(
        serde_json::from_str::<Value>(&body).unwrap()["changed"],
        false
    );

    // The DID document says where to find it.
    let doc: Value = reqwest::blocking::get(format!("{}/.well-known/did.json", gw.base))
        .unwrap()
        .json()
        .unwrap();
    assert_eq!(doc["id"], gw.did);
    assert_eq!(doc["service"][0]["serviceEndpoint"], gw.base);
    assert!(Gateway::new("ftp://x").is_err());
    assert!(Gateway::new("https://").is_err());
    assert!(Gateway::new("https://a/b").is_err());
    assert_eq!(
        Gateway::new("https://knot.example/").unwrap().did,
        "did:web:knot.example"
    );
}

#[test]
fn a_server_that_is_not_a_gateway() {
    let s = Sandbox::new();
    let rt = tokio::runtime::Runtime::new().unwrap();
    let listener = rt
        .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
        .unwrap();
    let base = format!("http://{}", listener.local_addr().unwrap());
    let router = router(App::new(ctx(&s), Duration::ZERO));
    rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
    let res = reqwest::blocking::get(format!("{base}/.well-known/did.json")).unwrap();
    assert_eq!(res.status().as_u16(), 404);
    let (status, _) = post(
        &format!("{base}/xrpc/dev.knotless.pushRefs"),
        Some("x"),
        vec![],
        false,
    );
    assert_eq!(status, 404);
}