//! 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, _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, 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 { 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::(&out).unwrap() }) }) .collect(); let results: Vec = 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::(&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::(&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); }