This repository has no description
1#![allow(dead_code)]
2
3use std::collections::BTreeSet;
4use std::path::Path;
5use std::sync::Arc;
6
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use axum::Router;
10use axum::body::{Body, Bytes};
11use base64::Engine;
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use http::{HeaderMap, StatusCode, header};
14use k256::ecdsa::signature::Signer;
15use k256::ecdsa::{Signature, SigningKey};
16use tower::ServiceExt;
17
18use knot_atproto::Atproto;
19use knot_cob::{CobHome, CobStore};
20use knot_cobs::{
21 CollaboratorsChange, CollaboratorsCob, Grant, MembersChange, MembersCob, Registration,
22 RegistryChange, RepoRegistryCob, register_repo,
23};
24use knot_git::{Layout, Repo};
25use knot_index::Index;
26use knot_runtime::{
27 FakeHttp, HttpRequest, HttpResponse, ManualClock, NetworkError, OsEntropy, UnixMicros,
28};
29use knot_secrets::{MasterKey, SealedStore};
30use knot_types::{
31 AccountDid, AuthorName, Email, KnotHostname, KnotId, ObjectFormat, Oid, OwnerDid, RepoDid,
32 RepoName, RepoRkey, UnixSeconds,
33};
34use knot_xrpc::{
35 ArchiveLimit, Budgets, ByteLimits, CobLocks, GlobalQuota, LimitConfig, PerActorQuota,
36 PreAuthLimiter, Reservations, ResponseLimit, XrpcState,
37};
38
39pub const KNOT_HOST: &str = "knot.nel.pet";
40pub const OWNER: &str = "did:web:olaren.dev";
41
42pub type Responder = Box<dyn Fn(&HttpRequest) -> Result<HttpResponse, NetworkError> + Send + Sync>;
43
44pub struct World {
45 _dir: tempfile::TempDir,
46 pub layout: Layout,
47 pub lfs_dir: std::path::PathBuf,
48 pub router: Router,
49 pub state: Arc<XrpcState<FakeHttp<Responder>, ManualClock>>,
50}
51
52impl World {
53 pub fn new() -> Self {
54 Self::build(true, ByteLimits::default(), ObjectFormat::SHA1)
55 }
56
57 pub fn unshed() -> Self {
58 Self::build_with_limits(
59 true,
60 ByteLimits::default(),
61 ObjectFormat::SHA1,
62 LimitConfig::unmetered(),
63 )
64 }
65
66 pub fn sha256() -> Self {
67 Self::build(true, ByteLimits::default(), ObjectFormat::SHA256)
68 }
69
70 pub fn warming() -> Self {
71 Self::build(false, ByteLimits::default(), ObjectFormat::SHA1)
72 }
73
74 pub fn with_response_limit(response: ResponseLimit) -> Self {
75 Self::build(
76 true,
77 ByteLimits {
78 response,
79 ..ByteLimits::default()
80 },
81 ObjectFormat::SHA1,
82 )
83 }
84
85 pub fn with_archive_limit(archive: ArchiveLimit) -> Self {
86 Self::build(
87 true,
88 ByteLimits {
89 archive,
90 ..ByteLimits::default()
91 },
92 ObjectFormat::SHA1,
93 )
94 }
95
96 fn build(rebuilt: bool, byte_limits: ByteLimits, object_format: ObjectFormat) -> Self {
97 Self::build_with_limits(rebuilt, byte_limits, object_format, LimitConfig::default())
98 }
99
100 fn build_with_limits(
101 rebuilt: bool,
102 byte_limits: ByteLimits,
103 object_format: ObjectFormat,
104 limits: LimitConfig,
105 ) -> Self {
106 let dir = tempfile::tempdir().unwrap();
107 let scan_path = dir.path().join("repos");
108 std::fs::create_dir_all(&scan_path).unwrap();
109 let knot = KnotId::new(format!("did:web:{KNOT_HOST}")).unwrap();
110 let layout = Layout::new(&scan_path)
111 .with_object_format(object_format)
112 .reserving_meta(&knot)
113 .unwrap();
114 layout.bootstrap_meta(&knot).unwrap();
115 let meta_path = layout.meta_path(&knot).unwrap();
116 let index = Arc::new(Index::new(meta_path.clone(), layout.clone()));
117 if rebuilt {
118 index.rebuild().unwrap();
119 }
120
121 let responder: Responder = Box::new(|request| {
122 let host = request.url.host_str().unwrap_or_default();
123 let did = match host {
124 "plc.directory" => request.url.path().trim_start_matches('/').to_string(),
125 host if request.url.path().ends_with("/.well-known/did.json") => {
126 format!("did:web:{host}")
127 }
128 _ => String::new(),
129 };
130 let body = match did.starts_with("did:") {
131 true => did_doc_for(&did),
132 false => Bytes::new(),
133 };
134 Ok(HttpResponse {
135 status: StatusCode::OK,
136 headers: http::HeaderMap::new(),
137 body,
138 })
139 });
140 let atproto = Arc::new(Atproto::new(
141 FakeHttp::new(responder),
142 ManualClock::new(UnixMicros::new(1_000_000_000)),
143 knot.clone(),
144 knot_atproto::PlcDirectory::new(url::Url::parse("https://plc.directory/").unwrap())
145 .unwrap(),
146 ));
147 let secrets = Arc::new(
148 SealedStore::open(
149 dir.path().join("keys.sealed"),
150 &MasterKey::new([7u8; 32]).unwrap(),
151 Box::new(OsEntropy),
152 )
153 .unwrap(),
154 );
155 secrets.ensure(&knot).unwrap();
156
157 let lfs_store = dir.path().join("lfs");
158 std::fs::create_dir_all(&lfs_store).unwrap();
159 let lfs_handle = knot_lfs::LfsHandle::open(
160 knot_lfs::LfsStorePath::new(&lfs_store),
161 knot_lfs::LfsSize::new(64 * 1024 * 1024),
162 knot_lfs::FreeSpaceFloor::new(0),
163 )
164 .unwrap();
165
166 let state = Arc::new(XrpcState {
167 layout: layout.clone(),
168 index,
169 atproto,
170 secrets,
171 entropy: Arc::new(OsEntropy),
172 ci_logs: None,
173 admins: BTreeSet::new(),
174 admission: knot_types::AdmissionPolicy::Closed,
175 knot_did: knot,
176 knot_hostname: KnotHostname::new(KNOT_HOST).unwrap(),
177 meta_path,
178 knot_service_url: knot_types::KnotServiceUrl::new(format!("https://{KNOT_HOST}"))
179 .unwrap(),
180 limiter: Arc::new(PreAuthLimiter::with_config(limits)),
181 cob_locks: Arc::new(CobLocks::default()),
182 reservations: Arc::new(Reservations::new(
183 knot_xrpc::ReservationTtl::new(1_000_000),
184 PerActorQuota::new(16),
185 GlobalQuota::new(16),
186 )),
187 proxy_trust: knot_types::ProxyTrust::default(),
188 committer: knot_xrpc::Committer {
189 name: AuthorName::new("Tangled"),
190 email: Email::new("noreply@tangled.sh"),
191 },
192 byte_limits,
193 budgets: Budgets::default(),
194 git_http: Arc::new(FakeHttp::new(|_request: &HttpRequest| {
195 Err(NetworkError::Connect(
196 "no git upstream is served in this test".to_string(),
197 ))
198 })),
199 pack_limits: knot_pack::PackLimits::default(),
200 service_owner: AccountDid::new(OWNER).unwrap(),
201 events: Arc::new(knot_events::EventLog::new(
202 ManualClock::new(UnixMicros::new(1_000_000_000)),
203 knot_events::ReplayBounds::new(
204 knot_events::ReplayEvents::new(1024).unwrap(),
205 knot_events::ReplayBytes::new(16 << 20).unwrap(),
206 ),
207 )),
208 subscriber_gate: Arc::new(knot_events::SubscriberGate::new(
209 knot_events::GlobalSubscriberLimit::new(16),
210 knot_events::PerPeerSubscriberLimit::new(4),
211 )),
212 maintenance: knot_maintenance::MaintenanceHandle::disabled(),
213 appview: knot_types::AppviewEndpoint::new("https://tangled.test").unwrap(),
214 slots: knot_resource::Slots::testing(8),
215 lfs: Some(knot_xrpc::LfsWeb::new(lfs_handle, 8)),
216 catalog: Arc::new(knot_messages::Catalog::defaults()),
217 });
218 let router = knot_xrpc::router(Arc::clone(&state));
219 Self {
220 _dir: dir,
221 layout,
222 lfs_dir: lfs_store,
223 router,
224 state,
225 }
226 }
227
228 pub fn register(&self, did: &RepoDid, rkey: &str) {
229 let meta = Repo::open(&self.state.meta_path).unwrap();
230 let store = CobStore::new(&meta);
231 let home = CobHome::from(&self.state.knot_did);
232 let signer = self.state.secrets.signer(&self.state.knot_did).unwrap();
233 let registration = Registration {
234 owner: OwnerDid::new(OWNER).unwrap(),
235 rkey: RepoRkey::new(rkey).unwrap(),
236 name: RepoName::new(rkey).unwrap(),
237 repo: did.clone(),
238 created_at: UnixSeconds::new(1_000),
239 };
240 match store.list::<RepoRegistryCob>().unwrap().as_slice() {
241 [] => {
242 store
243 .create(
244 &home,
245 &RegistryChange::Register(registration),
246 &signer,
247 UnixSeconds::new(1_000),
248 )
249 .unwrap();
250 }
251 [object] => {
252 register_repo(
253 &store,
254 &home,
255 *object,
256 registration,
257 &signer,
258 UnixSeconds::new(1_000),
259 )
260 .unwrap();
261 }
262 many => panic!("{} registry objects", many.len()),
263 }
264 self.state.index.refresh_registry().unwrap();
265 }
266
267 pub fn add_member(&self, subject: &str, added_by: &str, at: i64) {
268 let meta = Repo::open(&self.state.meta_path).unwrap();
269 let store = CobStore::new(&meta);
270 let home = CobHome::from(&self.state.knot_did);
271 let signer = self.state.secrets.signer(&self.state.knot_did).unwrap();
272 let change = MembersChange::Add(grant(subject, added_by, at));
273 match store.list::<MembersCob>().unwrap().as_slice() {
274 [] => {
275 store
276 .create(&home, &change, &signer, UnixSeconds::new(at))
277 .unwrap();
278 }
279 [object] => {
280 store
281 .update(&home, *object, &change, &signer, UnixSeconds::new(at))
282 .unwrap();
283 }
284 many => panic!("{} members objects", many.len()),
285 }
286 self.state.index.refresh_members().unwrap();
287 }
288
289 pub fn add_collaborator(&self, repo: &RepoDid, subject: &str, added_by: &str, at: i64) {
290 let git = self.layout.open(repo).unwrap();
291 let store = CobStore::new(&git);
292 let home = CobHome::from(repo);
293 let signer = self.state.secrets.signer(&self.state.knot_did).unwrap();
294 let change = CollaboratorsChange::Add(grant(subject, added_by, at));
295 match store.list::<CollaboratorsCob>().unwrap().as_slice() {
296 [] => {
297 store
298 .create(&home, &change, &signer, UnixSeconds::new(at))
299 .unwrap();
300 }
301 [object] => {
302 store
303 .update(&home, *object, &change, &signer, UnixSeconds::new(at))
304 .unwrap();
305 }
306 many => panic!("{} collaborators objects", many.len()),
307 }
308 self.state.index.refresh_collaborators(repo).unwrap();
309 }
310}
311
312fn grant(subject: &str, added_by: &str, at: i64) -> Grant {
313 Grant {
314 subject: AccountDid::new(subject).unwrap(),
315 added_by: AccountDid::new(added_by).unwrap(),
316 created_at: UnixSeconds::new(at),
317 }
318}
319
320fn actor_key() -> SigningKey {
321 SigningKey::from_bytes(&[9u8; 32].into()).unwrap()
322}
323
324fn did_doc_for(did: &str) -> Bytes {
325 let sec1 = actor_key()
326 .verifying_key()
327 .to_encoded_point(true)
328 .as_bytes()
329 .to_vec();
330 let multikey = knot_types::crypto::multikey(0xe7, &sec1);
331 let body = serde_json::json!({
332 "id": did,
333 "alsoKnownAs": [],
334 "verificationMethod": [{
335 "id": format!("{did}#atproto"),
336 "type": "Multikey",
337 "controller": did,
338 "publicKeyMultibase": multikey
339 }],
340 "service": [{
341 "id": "#atproto_pds",
342 "type": "AtprotoPersonalDataServer",
343 "serviceEndpoint": "https://pds.oyster.cafe"
344 }]
345 });
346 Bytes::from(serde_json::to_vec(&body).unwrap())
347}
348
349static JTI: AtomicU64 = AtomicU64::new(0);
350
351fn service_jwt(nsid: &str, actor: &str) -> String {
352 let nonce = JTI.fetch_add(1, Ordering::SeqCst);
353 let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#);
354 let claims = serde_json::json!({
355 "iss": actor,
356 "aud": format!("did:web:{KNOT_HOST}"),
357 "exp": 1_001,
358 "iat": 999,
359 "jti": format!("nonce-{nonce}"),
360 "lxm": nsid,
361 });
362 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
363 let signing_input = format!("{header}.{payload}");
364 let signature: Signature = actor_key().sign(signing_input.as_bytes());
365 format!(
366 "{signing_input}.{}",
367 URL_SAFE_NO_PAD.encode(signature.to_bytes())
368 )
369}
370
371pub async fn post_authed(
372 world: &World,
373 path: &str,
374 actor: &str,
375 value: serde_json::Value,
376) -> (StatusCode, serde_json::Value) {
377 let nsid = path
378 .strip_prefix("/xrpc/")
379 .expect("post_authed path names an xrpc method");
380 let token = service_jwt(nsid, actor);
381 let request = http::Request::builder()
382 .method("POST")
383 .uri(path)
384 .header(header::CONTENT_TYPE, "application/json")
385 .header(header::AUTHORIZATION, format!("Bearer {token}"))
386 .body(Body::from(serde_json::to_vec(&value).unwrap()))
387 .unwrap();
388 let response = world.router.clone().oneshot(request).await.unwrap();
389 let status = response.status();
390 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
391 .await
392 .unwrap();
393 (status, serde_json::from_slice(&body).unwrap())
394}
395
396pub async fn post_json(
397 world: &World,
398 path: &str,
399 value: serde_json::Value,
400) -> (StatusCode, serde_json::Value) {
401 let request = http::Request::builder()
402 .method("POST")
403 .uri(path)
404 .header(header::CONTENT_TYPE, "application/json")
405 .body(Body::from(serde_json::to_vec(&value).unwrap()))
406 .unwrap();
407 let response = world.router.clone().oneshot(request).await.unwrap();
408 let status = response.status();
409 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
410 .await
411 .unwrap();
412 (status, serde_json::from_slice(&body).unwrap())
413}
414
415pub fn git_run(cwd: &Path, when: &str, author: (&str, &str), args: &[&str]) -> String {
416 let output = knot_fixtures::command_at(cwd, when)
417 .args(args)
418 .env("GIT_AUTHOR_NAME", author.0)
419 .env("GIT_AUTHOR_EMAIL", author.1)
420 .env("GIT_COMMITTER_NAME", author.0)
421 .env("GIT_COMMITTER_EMAIL", author.1)
422 .output()
423 .expect("git is available");
424 assert!(
425 output.status.success(),
426 "git {args:?} failed:\n{}",
427 String::from_utf8_lossy(&output.stderr)
428 );
429 String::from_utf8(output.stdout).unwrap().trim().to_string()
430}
431
432pub fn sh_git_at(cwd: &Path, when: &str, args: &[&str]) -> String {
433 git_run(cwd, when, ("nel", "nel@oyster.cafe"), args)
434}
435
436pub fn sh_git(cwd: &Path, args: &[&str]) -> String {
437 sh_git_at(cwd, "2026-06-01T12:30:00+02:00", args)
438}
439
440pub fn commit_file(work: &Path, file: &str, contents: &[u8], message: &str, when: &str) {
441 let target = work.join(file);
442 if let Some(parent) = target.parent() {
443 std::fs::create_dir_all(parent).unwrap();
444 }
445 std::fs::write(target, contents).unwrap();
446 sh_git_at(work, when, &["add", "-A"]);
447 sh_git_at(work, when, &["commit", "-q", "-m", message]);
448}
449
450pub fn seeded(world: &World, rkey: &str) -> (RepoDid, tempfile::TempDir) {
451 seeded_with_format(world, rkey, ObjectFormat::SHA1)
452}
453
454pub fn seeded_with_format(
455 world: &World,
456 rkey: &str,
457 object_format: ObjectFormat,
458) -> (RepoDid, tempfile::TempDir) {
459 let did = RepoDid::new(format!("did:plc:{rkey}fixture")).unwrap();
460 world.layout.create(&did).unwrap();
461 world.register(&did, rkey);
462 let bare = world.layout.repo_path(&did).unwrap();
463 let work_dir = tempfile::tempdir().unwrap();
464 let work = work_dir.path();
465 let init = match object_format == ObjectFormat::SHA256 {
466 true => vec!["init", "-q", "--object-format=sha256", "-b", "main"],
467 false => vec!["init", "-q", "-b", "main"],
468 };
469 sh_git(work, &init);
470 commit_file(
471 work,
472 "README.md",
473 b"# coral\n\nhello\n",
474 "first",
475 "2026-06-01T12:30:00+02:00",
476 );
477 commit_file(
478 work,
479 "src/main.rs",
480 b"fn main() {\n println!(\"reef\");\n}\n",
481 "add main",
482 "2026-06-01T12:31:00+02:00",
483 );
484 commit_file(
485 work,
486 "logo.png",
487 b"\x89PNG\r\n\x1a\n0000binarybytes\x00\x01",
488 "add logo",
489 "2026-06-01T12:32:00+02:00",
490 );
491 sh_git(work, &["tag", "lightweight"]);
492 sh_git_at(
493 work,
494 "2026-06-01T12:32:30+02:00",
495 &["tag", "-a", "v1.0.0", "-m", "release one"],
496 );
497 commit_file(
498 work,
499 "README.md",
500 b"# coral\n\nhello reef\n",
501 "update readme",
502 "2026-06-01T12:33:00+02:00",
503 );
504 sh_git(
505 work,
506 &["push", "-q", "--tags", bare.to_str().unwrap(), "main"],
507 );
508 (did, work_dir)
509}
510
511pub fn empty_repo(world: &World, rkey: &str) -> (RepoDid, String, tempfile::TempDir) {
512 let did = RepoDid::new(format!("did:plc:{rkey}fixture")).unwrap();
513 world.layout.create(&did).unwrap();
514 world.register(&did, rkey);
515 let bare = world
516 .layout
517 .repo_path(&did)
518 .unwrap()
519 .to_str()
520 .unwrap()
521 .to_string();
522 let work_dir = tempfile::tempdir().unwrap();
523 sh_git(work_dir.path(), &["init", "-q", "-b", "main"]);
524 (did, bare, work_dir)
525}
526
527pub fn seeded_feature_branch(world: &World, rkey: &str) -> (RepoDid, Oid, Oid) {
528 let (did, bare, work_dir) = empty_repo(world, rkey);
529 let work = work_dir.path();
530 commit_file(
531 work,
532 "reef.txt",
533 b"one\ntwo\n",
534 "base",
535 "2026-06-01T12:30:00+02:00",
536 );
537 sh_git(work, &["checkout", "-q", "-b", "feature"]);
538 commit_file(
539 work,
540 "reef.txt",
541 b"one\nTWO\n",
542 "capitalize two\n\nbecause waves",
543 "2026-06-01T12:31:00+02:00",
544 );
545 commit_file(
546 work,
547 "kelp.txt",
548 b"frond\n",
549 "add kelp",
550 "2026-06-01T12:32:00+02:00",
551 );
552 sh_git(work, &["push", "-q", &bare, "main", "feature"]);
553 let main = Oid::from_hex(&sh_git(work, &["rev-parse", "main"])).unwrap();
554 let feature = Oid::from_hex(&sh_git(work, &["rev-parse", "feature"])).unwrap();
555 (did, main, feature)
556}
557
558pub async fn get_with_headers(
559 world: &World,
560 path_and_query: &str,
561 headers: HeaderMap,
562) -> (StatusCode, HeaderMap, Bytes) {
563 let mut request = http::Request::builder()
564 .method("GET")
565 .uri(path_and_query)
566 .body(Body::empty())
567 .unwrap();
568 request.headers_mut().extend(headers);
569 let response = world.router.clone().oneshot(request).await.unwrap();
570 let status = response.status();
571 let response_headers = response.headers().clone();
572 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
573 .await
574 .unwrap();
575 (status, response_headers, body)
576}
577
578pub async fn get(world: &World, path_and_query: &str) -> (StatusCode, HeaderMap, Bytes) {
579 get_with_headers(world, path_and_query, HeaderMap::new()).await
580}
581
582pub async fn get_json(world: &World, path_and_query: &str) -> serde_json::Value {
583 let (status, _, body) = get(world, path_and_query).await;
584 assert_eq!(
585 status,
586 StatusCode::OK,
587 "GET {path_and_query} failed: {}",
588 String::from_utf8_lossy(&body)
589 );
590 serde_json::from_slice(&body).unwrap()
591}
592
593pub async fn get_error(world: &World, path_and_query: &str) -> (StatusCode, String) {
594 let (status, _, body) = get(world, path_and_query).await;
595 assert!(!status.is_success(), "GET {path_and_query} unexpectedly ok");
596 let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
597 (status, value["error"].as_str().unwrap().to_string())
598}
599
600pub fn ref_names(value: &serde_json::Value, key: &str) -> Vec<String> {
601 value[key]
602 .as_array()
603 .unwrap()
604 .iter()
605 .map(|entry| entry["ref"].as_str().unwrap().to_string())
606 .collect()
607}
608
609pub fn repo_dids(value: &serde_json::Value) -> Vec<String> {
610 value["repos"]
611 .as_array()
612 .unwrap()
613 .iter()
614 .map(|entry| entry["repo"].as_str().unwrap().to_string())
615 .collect()
616}
617
618pub async fn archive_full(world: &World, did: &RepoDid) -> (String, String, Bytes) {
619 let (status, headers, body) = get(
620 world,
621 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"),
622 )
623 .await;
624 assert_eq!(status, StatusCode::OK);
625 let etag = headers
626 .get(header::ETAG)
627 .unwrap()
628 .to_str()
629 .unwrap()
630 .to_string();
631 let last_modified = headers
632 .get(header::LAST_MODIFIED)
633 .unwrap()
634 .to_str()
635 .unwrap()
636 .to_string();
637 (etag, last_modified, body)
638}
639
640pub async fn assert_immutable_round_trip(
641 world: &World,
642 headers: &HeaderMap,
643 full: &Bytes,
644 etag: &str,
645) {
646 let link = headers.get(header::LINK).unwrap().to_str().unwrap();
647 let immutable = link
648 .trim_start_matches('<')
649 .split('>')
650 .next()
651 .unwrap()
652 .strip_prefix(&format!("https://{KNOT_HOST}"))
653 .expect("the immutable link points at this knot");
654 let (status, immutable_headers, immutable_body) = get(world, immutable).await;
655 assert_eq!(status, StatusCode::OK);
656 assert_eq!(
657 &immutable_body, full,
658 "following the immutable link regenerates the very bytes it was attached to"
659 );
660 assert_eq!(
661 immutable_headers
662 .get(header::ETAG)
663 .unwrap()
664 .to_str()
665 .unwrap(),
666 etag,
667 "the immutable link shares the etag of the response that advertised it"
668 );
669}
670
671pub async fn assert_warming(world: &World, path: &str, expected_error: Option<&str>) {
672 let (status, error) = get_error(world, path).await;
673 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "path {path}");
674 if let Some(expected) = expected_error {
675 assert_eq!(error, expected, "path {path}");
676 }
677}
678
679pub async fn assert_post_rejected(
680 world: &World,
681 path: &str,
682 actor: &str,
683 value: serde_json::Value,
684) {
685 let (status, body) = post_authed(world, path, actor, value).await;
686 assert_eq!(status, StatusCode::BAD_REQUEST, "{path}: {body}");
687 assert_eq!(body["error"], "InvalidRequest", "{path}");
688}