This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-xrpc / tests / common / mod.rs
23 kB 688 lines
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}