chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
//! 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(¬e, "n\n").unwrap();
let (code, out) = collab.cli(
&[
"commit",
&url,
"-b",
"main",
"-m",
"note",
"--put",
"note",
¬e.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",
¬e.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",
¬e.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",
¬e.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",
¬e.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);
}