1use anyhow::{bail, Context as _, Result};
18use reqwest::Client;
19use serde_json::{json, Value};
20use sqlx::SqlitePool;
21
22use crate::atproto::{RecordEntry, WriteOp, WriteResult};
23
24use super::dpop::Endpoint;
25use super::keys::SigningKey;
26use super::request::{self, DpopBody, DpopRequest, Retry};
27use super::store::OAuthSession;
28
29const MAX_LIST_PAGES: usize = 50;
34
35const MAX_LIST_RECORDS: usize = 5_000;
39
40pub struct Repo<'a> {
42 pub http: &'a Client,
43 pub pool: &'a SqlitePool,
44 pub session: &'a OAuthSession,
45 pub key: &'a SigningKey,
47}
48
49impl Repo<'_> {
50 fn url(&self, nsid: &str) -> String {
52 format!("{}/xrpc/{nsid}", self.session.aud.trim_end_matches('/'))
53 }
54
55 async fn send_raw(
57 &self,
58 url: &str,
59 body: DpopBody<'_>,
60 nsid: &str,
61 ) -> Result<request::PostOutcome> {
62 let outcome = request::send_with_dpop(
63 self.http,
64 self.pool,
65 &DpopRequest {
66 endpoint: Endpoint::ResourceServer,
67 url,
68 key: self.key,
69 access_token: Some(&self.session.access_token),
70 body,
71 retry: Retry::Allowed,
80 },
81 )
82 .await?;
83
84 if !outcome.is_success() {
85 bail!(
86 "{nsid} failed: {}",
87 xrpc_error(&outcome.body, outcome.status)
88 );
89 }
90 Ok(outcome)
91 }
92
93 async fn send(&self, url: &str, body: DpopBody<'_>, nsid: &str) -> Result<Value> {
99 let outcome = self.send_raw(url, body, nsid).await?;
100 if outcome.body.is_empty() {
102 return Ok(Value::Null);
103 }
104 outcome.json()
105 }
106
107 fn error_fields(body: &[u8]) -> Option<String> {
120 if !super::error_body_worth_parsing(body) {
121 return None;
122 }
123 let value: Value = serde_json::from_slice(body).ok()?;
124 let kind = value.get("error").and_then(Value::as_str)?;
125 match value.get("message").and_then(Value::as_str) {
126 Some(message) => Some(format!("{kind}: {message}")),
127 None => Some(kind.to_string()),
128 }
129 }
130
131 pub async fn list_records(
133 &self,
134 collection: &str,
135 limit: Option<u32>,
136 cursor: Option<&str>,
137 ) -> Result<(Vec<RecordEntry>, Option<String>)> {
138 let page = self.list_records_page(collection, limit, cursor).await?;
139 Ok((page.records, page.cursor))
140 }
141
142 pub(crate) async fn list_records_page(
145 &self,
146 collection: &str,
147 limit: Option<u32>,
148 cursor: Option<&str>,
149 ) -> Result<crate::atproto::ListRecordsResponse> {
150 let mut url = url::Url::parse(&self.url("com.atproto.repo.listRecords"))
151 .context("building the listRecords URL")?;
152 {
153 let mut query = url.query_pairs_mut();
154 query.append_pair("repo", &self.session.sub);
155 query.append_pair("collection", collection);
156 if let Some(limit) = limit {
157 query.append_pair("limit", &limit.to_string());
158 }
159 if let Some(cursor) = cursor {
160 query.append_pair("cursor", cursor);
161 }
162 }
163
164 let outcome = self
168 .send_raw(
169 url.as_str(),
170 DpopBody::Query,
171 "com.atproto.repo.listRecords",
172 )
173 .await?;
174 crate::atproto::parse_list_records(&outcome.body)
183 }
184
185 pub async fn list_all_records(&self, collection: &str) -> Result<Vec<RecordEntry>> {
191 self.list_all_records_within(
192 collection,
193 &mut crate::atproto::ByteBudget::new(crate::atproto::MAX_LIST_BYTES),
194 )
195 .await
196 }
197
198 pub(crate) async fn list_all_records_within(
200 &self,
201 collection: &str,
202 budget: &mut crate::atproto::ByteBudget,
203 ) -> Result<Vec<RecordEntry>> {
204 let mut out = Vec::new();
205 let max_bytes = budget.max();
206 let mut cursor: Option<String> = None;
207 let mut more_offered = false;
208
209 for _ in 0..MAX_LIST_PAGES {
210 let listed = self
211 .list_records_page(collection, Some(100), cursor.as_deref())
212 .await?;
213 crate::atproto::refuse_malformed(&listed, collection)?;
216 let (page, next) = (listed.records, listed.cursor);
217 let got = page.len();
218 if !budget.admit(&page) {
223 anyhow::bail!(
224 "listRecords for {collection} exceeded the {max_bytes}-byte cap \
225 ({} held, {} bytes charged) — refusing to accumulate further",
226 out.len(),
227 budget.used(),
228 );
229 }
230 crate::atproto::extend_bounded(&mut out, page, MAX_LIST_RECORDS, collection)?;
231 match next {
232 Some(next) if got > 0 && Some(&next) != cursor.as_ref() => {
242 cursor = Some(next);
243 more_offered = true;
244 }
245 _ => {
246 more_offered = false;
252 break;
253 }
254 }
255 }
256 if more_offered {
280 anyhow::bail!(
281 "listRecords for {collection} did not finish within {MAX_LIST_PAGES} pages \
282 ({} held, and the PDS still offered more) — refusing a short list",
283 out.len(),
284 );
285 }
286 Ok(out)
287 }
288
289 pub async fn create_record<T: crate::vetted::WritableRecord>(
291 &self,
292 collection: &str,
293 record: &T,
294 ) -> Result<WriteResult> {
295 self.write(
296 "com.atproto.repo.createRecord",
297 json!({ "repo": self.session.sub, "collection": collection, "record": record }),
298 )
299 .await
300 }
301
302 pub async fn put_record<T: crate::vetted::WritableRecord>(
304 &self,
305 collection: &str,
306 rkey: &str,
307 record: &T,
308 ) -> Result<WriteResult> {
309 self.write(
310 "com.atproto.repo.putRecord",
311 json!({
312 "repo": self.session.sub,
313 "collection": collection,
314 "rkey": rkey,
315 "record": record,
316 }),
317 )
318 .await
319 }
320
321 pub async fn delete_record(&self, collection: &str, rkey: &str) -> Result<()> {
322 let body = json!({ "repo": self.session.sub, "collection": collection, "rkey": rkey });
323 self.send(
324 &self.url("com.atproto.repo.deleteRecord"),
325 DpopBody::Json(serde_json::to_vec(&body)?),
326 "com.atproto.repo.deleteRecord",
327 )
328 .await
329 .and_then(|v| crate::atproto::reject_error_envelope(&v))?;
330 Ok(())
331 }
332
333 pub async fn apply_writes(&self, writes: &[WriteOp]) -> Result<()> {
335 if writes.is_empty() {
336 return Ok(());
337 }
338 let ops: Vec<Value> = writes.iter().map(WriteOp::to_json).collect();
339 let body = json!({ "repo": self.session.sub, "writes": ops });
340 self.send(
341 &self.url("com.atproto.repo.applyWrites"),
342 DpopBody::Json(serde_json::to_vec(&body)?),
343 "com.atproto.repo.applyWrites",
344 )
345 .await
346 .and_then(|v| crate::atproto::reject_error_envelope(&v))?;
347 Ok(())
348 }
349
350 async fn write(&self, nsid: &str, body: Value) -> Result<WriteResult> {
351 let value = self
352 .send(
353 &self.url(nsid),
354 DpopBody::Json(serde_json::to_vec(&body)?),
355 nsid,
356 )
357 .await?;
358 serde_json::from_value(value).with_context(|| format!("{nsid} returned no usable result"))
359 }
360}
361
362fn xrpc_error(body: &[u8], status: u16) -> String {
364 match Repo::error_fields(body) {
365 Some(detail) => format!("status {status} ({detail})"),
366 None => format!("status {status}"),
367 }
368}
369
370impl Repo<'_> {
378 async fn list_typed<T: serde::de::DeserializeOwned>(
385 &self,
386 collection: &str,
387 ) -> Result<Vec<(String, T)>> {
388 let records = self.list_all_records(collection).await?;
389 let mut out = Vec::with_capacity(records.len());
390 for record in records {
391 let rkey = record.rkey().unwrap_or_default().to_string();
392 match record.parse::<T>() {
393 Ok(value) => out.push((rkey, value)),
394 Err(err) => tracing::warn!(
395 collection,
396 uri = %record.uri,
397 error = %err,
398 "skipping unparseable record in collection"
399 ),
400 }
401 }
402 Ok(out)
403 }
404
405 pub async fn list_subscriptions(&self) -> Result<Vec<(String, crate::lexicon::Subscription)>> {
408 self.list_typed(crate::lexicon::nsid::SUBSCRIPTION).await
409 }
410
411 pub async fn list_subscriptions_sorted(
413 &self,
414 ) -> Result<Vec<(String, crate::lexicon::Subscription)>> {
415 let mut subs = self.list_subscriptions().await?;
416 subs.sort_by(crate::lexicon::sort::subscriptions);
417 Ok(subs)
418 }
419
420 pub async fn add_subscription(
423 &self,
424 sub: &crate::vetted::VettedSubscription,
425 ) -> Result<String> {
426 Ok(self
427 .create_record(crate::lexicon::nsid::SUBSCRIPTION, sub)
428 .await?
429 .into_rkey())
430 }
431
432 pub async fn remove_subscription(&self, rkey: &str) -> Result<()> {
433 self.delete_record(crate::lexicon::nsid::SUBSCRIPTION, rkey)
434 .await
435 }
436
437 pub async fn update_subscription(
442 &self,
443 rkey: &str,
444 sub: &crate::vetted::VettedSubscription,
445 ) -> Result<WriteResult> {
446 self.put_record(crate::lexicon::nsid::SUBSCRIPTION, rkey, sub)
447 .await
448 }
449
450 pub async fn add_subscriptions_bulk(
459 &self,
460 subs: &[crate::vetted::VettedSubscription],
461 ) -> Result<Vec<String>> {
462 let mut gen = crate::atproto::TidGenerator::new();
463 let mut rkeys = Vec::with_capacity(subs.len());
464 let mut writes = Vec::with_capacity(subs.len());
465 for sub in subs {
466 let rkey = gen.next();
467 writes.push(WriteOp::Create {
468 collection: crate::lexicon::nsid::SUBSCRIPTION.to_string(),
469 rkey: Some(rkey.clone()),
470 value: serde_json::to_value(sub)?,
474 });
475 rkeys.push(rkey);
476 }
477 self.apply_writes(&writes).await?;
478 Ok(rkeys)
479 }
480
481 pub async fn list_folders(&self) -> Result<Vec<(String, crate::lexicon::Folder)>> {
484 self.list_typed(crate::lexicon::nsid::FOLDER).await
485 }
486
487 pub async fn list_folders_sorted(&self) -> Result<Vec<(String, crate::lexicon::Folder)>> {
488 let mut folders = self.list_folders().await?;
489 folders.sort_by(crate::lexicon::sort::folders);
490 Ok(folders)
491 }
492
493 pub async fn add_folder(&self, folder: &crate::lexicon::Folder) -> Result<String> {
494 Ok(self
495 .create_record(crate::lexicon::nsid::FOLDER, folder)
496 .await?
497 .into_rkey())
498 }
499
500 pub async fn remove_folder(&self, rkey: &str) -> Result<()> {
504 self.delete_record(crate::lexicon::nsid::FOLDER, rkey).await
505 }
506
507 pub async fn rename_folder(
510 &self,
511 rkey: &str,
512 folder: &crate::lexicon::Folder,
513 ) -> Result<WriteResult> {
514 self.put_record(crate::lexicon::nsid::FOLDER, rkey, folder)
515 .await
516 }
517
518 pub async fn list_saved(&self) -> Result<Vec<(String, crate::lexicon::Saved)>> {
521 self.list_typed(crate::lexicon::nsid::SAVED).await
522 }
523
524 pub async fn list_saved_sorted(&self) -> Result<Vec<(String, crate::lexicon::Saved)>> {
526 let mut saved = self.list_saved().await?;
527 saved.sort_by(crate::lexicon::sort::saved);
528 Ok(saved)
529 }
530
531 pub async fn add_saved(&self, saved: &crate::vetted::VettedSaved) -> Result<String> {
532 Ok(self
533 .create_record(crate::lexicon::nsid::SAVED, saved)
534 .await?
535 .into_rkey())
536 }
537
538 pub async fn remove_saved(&self, rkey: &str) -> Result<()> {
539 self.delete_record(crate::lexicon::nsid::SAVED, rkey).await
540 }
541
542 pub async fn list_read_states(&self) -> Result<Vec<(String, crate::lexicon::ReadState)>> {
545 self.list_typed(crate::lexicon::nsid::READ_STATE).await
546 }
547
548 pub async fn put_read_state(
550 &self,
551 rkey: &str,
552 state: &crate::lexicon::ReadState,
553 ) -> Result<()> {
554 self.put_record(crate::lexicon::nsid::READ_STATE, rkey, state)
555 .await?;
556 Ok(())
557 }
558
559 pub async fn flush_read_states(
570 &self,
571 cursors: &[(String, crate::lexicon::ReadState, bool)],
572 ) -> Result<()> {
573 if cursors.is_empty() {
574 return Ok(());
575 }
576 let writes = crate::atproto::read_state_write_ops(cursors)?;
577 self.apply_writes(&writes).await
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
598 fn read_state_writes_choose_create_or_update_per_cursor() {
599 let state = crate::lexicon::ReadState::new(
600 "https://example.com/feed",
601 Some("2026-01-01T00:00:00Z".to_string()),
602 "2026-01-01T00:00:00Z",
603 );
604 let cursors = vec![
605 ("existing".to_string(), state.clone(), true),
606 ("brand-new".to_string(), state.clone(), false),
607 ];
608
609 let ops = crate::atproto::read_state_write_ops(&cursors).expect("ops build");
610 assert_eq!(ops.len(), 2);
611
612 let rendered: Vec<Value> = ops.iter().map(|op| op.to_json()).collect();
613 assert_eq!(
614 rendered[0]["$type"], "com.atproto.repo.applyWrites#update",
615 "an existing record must be UPDATED, not re-created"
616 );
617 assert_eq!(
618 rendered[1]["$type"], "com.atproto.repo.applyWrites#create",
619 "a first flush must CREATE, or the whole atomic batch fails"
620 );
621 }
622
623 #[tokio::test]
628 async fn bulk_subscribe_writes_client_assigned_ordered_rkeys_to_the_right_collection() {
629 let (base, log) = crate::net::tests::serve_json_capturing(b"{}".to_vec()).await;
630 let port: u16 = base.rsplit(':').next().unwrap().parse().unwrap();
631 crate::net::test_host_override(
632 "bulk-pds.test",
633 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
634 );
635 let http = Client::new();
636 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
637 crate::store::init_schema(&pool).await.unwrap();
638 let key = SigningKey::generate("k");
639 let mut s = session();
640 s.aud = format!("http://bulk-pds.test:{port}");
641 let repo = repo(&http, &pool, &s, &key);
642 let subs: Vec<crate::vetted::VettedSubscription> = (0..3)
643 .map(|i| {
644 crate::vetted::VettedSubscription::new(&crate::lexicon::Subscription::new(
645 format!("https://f{i}.example/feed.xml"),
646 "2026-07-12T00:00:00.000Z",
647 ))
648 })
649 .collect();
650
651 let rkeys = repo
652 .add_subscriptions_bulk(&subs)
653 .await
654 .expect("bulk write failed");
655
656 let sent = log.lock().unwrap().clone();
657 assert_eq!(
658 sent.len(),
659 1,
660 "expected one applyWrites request, got {sent:?}"
661 );
662 let body: Value = serde_json::from_str(sent[0].split("\r\n\r\n").nth(1).unwrap())
663 .expect("request body is JSON");
664 let writes = body["writes"].as_array().expect("writes array");
665 assert_eq!(writes.len(), 3);
666 for (i, w) in writes.iter().enumerate() {
667 assert_eq!(w["collection"], crate::lexicon::nsid::SUBSCRIPTION);
668 assert_eq!(w["rkey"].as_str(), Some(rkeys[i].as_str()));
669 }
670 let mut sorted = rkeys.clone();
671 sorted.sort();
672 assert_eq!(rkeys, sorted, "client-assigned rkeys must ascend");
673 }
674
675 fn session() -> OAuthSession {
676 OAuthSession {
677 sub: "did:plc:ewvi7nxzyoun6zhxrhs64oiz".into(),
678 issuer: "https://pds.example.com".into(),
679 aud: "https://pds.example.com".into(),
680 dpop_key_jwk: "{}".into(),
681 access_token: "tok".into(),
682 refresh_token: "ref".into(),
683 token_type: "DPoP".into(),
684 granted_scope: "atproto".into(),
685 expires_at: None,
686 }
687 }
688
689 fn repo<'a>(
690 http: &'a Client,
691 pool: &'a SqlitePool,
692 session: &'a OAuthSession,
693 key: &'a SigningKey,
694 ) -> Repo<'a> {
695 Repo {
696 http,
697 pool,
698 session,
699 key,
700 }
701 }
702
703 #[tokio::test]
706 async fn endpoints_are_built_from_the_sessions_audience() {
707 let http = Client::new();
708 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
709 let key = SigningKey::generate("k");
710 let mut s = session();
711 s.aud = "https://pds.example.com/".into();
712 let repo = repo(&http, &pool, &s, &key);
713 assert_eq!(
714 repo.url("com.atproto.repo.listRecords"),
715 "https://pds.example.com/xrpc/com.atproto.repo.listRecords",
716 "a trailing slash on the audience must not double the separator"
717 );
718 }
719
720 #[test]
723 fn an_xrpc_error_is_summarised_not_echoed() {
724 let body = br#"{"error":"InvalidRequest","message":"unknown collection"}"#;
725 let rendered = xrpc_error(body, 400);
726 assert!(rendered.contains("InvalidRequest"));
727 assert!(rendered.contains("unknown collection"));
728
729 let opaque = xrpc_error(br#"{"access_token":"SECRET"}"#, 500);
731 assert_eq!(opaque, "status 500");
732 assert!(!opaque.contains("SECRET"));
733 assert_eq!(xrpc_error(b"<html>oops</html>", 502), "status 502");
734 }
735
736 #[tokio::test]
739 async fn repo_calls_fail_closed_on_an_internal_pds() {
740 let http = Client::new();
741 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
742 super::super::store::init_schema(&pool).await.unwrap();
743 let key = SigningKey::generate("k");
744 let mut s = session();
745 s.aud = "http://127.0.0.1:2583".into();
746 let repo = repo(&http, &pool, &s, &key);
747
748 let err = repo
749 .list_records("app.feather.subscription", None, None)
750 .await
751 .expect_err("must refuse a loopback PDS");
752 assert!(
753 format!("{err:#}").contains("forbidden (internal) address"),
754 "failed for the wrong reason: {err:#}"
755 );
756 }
757
758 #[tokio::test]
768 async fn a_200_error_envelope_is_not_an_empty_repo() {
769 let base = crate::net::tests::serve_body(
770 br#"{"error":"InvalidRequest","message":"bad cursor"}"#.to_vec(),
771 )
772 .await;
773 let port: u16 = base
774 .trim_end_matches('/')
775 .rsplit(':')
776 .next()
777 .unwrap()
778 .parse()
779 .unwrap();
780 crate::net::test_host_override(
781 "envelope-pds.test",
782 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
783 );
784
785 let http = Client::new();
786 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
787 crate::store::init_schema(&pool).await.unwrap();
788 let key = SigningKey::generate("k");
789 let mut s = session();
790 s.aud = format!("http://envelope-pds.test:{port}");
791 let repo = repo(&http, &pool, &s, &key);
792
793 let err = repo
794 .list_records("app.feather.subscription", None, None)
795 .await
796 .expect_err("an error envelope was read as an empty page");
797 assert!(
798 format!("{err:#}").contains("InvalidRequest"),
799 "failed for the wrong reason: {err:#}"
800 );
801 }
802
803 #[tokio::test]
810 async fn the_live_walk_spends_its_budget_across_pages() {
811 let (bodies, per_page) = crate::atproto::tests::paged_bodies(3, 4096, false);
812 let base = crate::net::tests::serve_bodies_in_sequence(bodies).await;
813 let port: u16 = base
814 .trim_end_matches('/')
815 .rsplit(':')
816 .next()
817 .unwrap()
818 .parse()
819 .unwrap();
820 crate::net::test_host_override(
821 "live-budget-pages.test",
822 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
823 );
824
825 let http = Client::new();
826 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
827 crate::store::init_schema(&pool).await.unwrap();
828 let key = SigningKey::generate("k");
829 let mut s = session();
830 s.aud = format!("http://live-budget-pages.test:{port}");
831 let repo = repo(&http, &pool, &s, &key);
832
833 let err = repo
834 .list_all_records_within(
835 "app.feather.subscription",
836 &mut crate::atproto::ByteBudget::new(per_page * 2),
837 )
838 .await
839 .expect_err("three pages cannot fit in a two-page budget");
840 let msg = format!("{err:#}");
841 assert!(msg.contains("byte cap"), "wrong bound reported: {msg}");
842 assert!(
843 msg.contains("2 held"),
844 "the live walk did not accumulate across pages: {msg}"
845 );
846 }
847
848 #[tokio::test]
852 async fn the_live_walk_that_runs_out_of_pages_refuses() {
853 let bodies: Vec<Vec<u8>> = (0..MAX_LIST_PAGES + 1)
854 .map(|i| {
855 serde_json::json!({
856 "records": [{ "uri": format!("at://did:plc:x/c/3lab{i}"), "value": {} }],
857 "cursor": format!("p{}", i + 1),
858 })
859 .to_string()
860 .into_bytes()
861 })
862 .collect();
863 let base = crate::net::tests::serve_bodies_in_sequence(bodies).await;
864 let port: u16 = base
865 .trim_end_matches('/')
866 .rsplit(':')
867 .next()
868 .unwrap()
869 .parse()
870 .unwrap();
871 crate::net::test_host_override(
872 "pages-exhausted-live.test",
873 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
874 );
875 let http = Client::new();
876 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
877 crate::store::init_schema(&pool).await.unwrap();
878 let key = SigningKey::generate("k");
879 let mut s = session();
880 s.aud = format!("http://pages-exhausted-live.test:{port}");
881 let repo = repo(&http, &pool, &s, &key);
882
883 let err = repo
884 .list_all_records("app.feather.subscription")
885 .await
886 .expect_err("a truncated list was returned as a complete one");
887 assert!(
888 format!("{err:#}").contains("did not finish"),
889 "failed for the wrong reason: {err:#}"
890 );
891 }
892
893 #[tokio::test]
896 async fn the_live_walk_refuses_a_page_with_a_malformed_record() {
897 let body = serde_json::json!({ "records": [
898 { "uri": "at://did:plc:x/c/3labGOOD", "value": {} },
899 { "cid": "bafy", "value": {} },
900 ]})
901 .to_string()
902 .into_bytes();
903 let base = crate::net::tests::serve_bodies_in_sequence(vec![body]).await;
904 let port: u16 = base
905 .trim_end_matches('/')
906 .rsplit(':')
907 .next()
908 .unwrap()
909 .parse()
910 .unwrap();
911 crate::net::test_host_override(
912 "malformed-live.test",
913 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
914 );
915 let http = Client::new();
916 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
917 crate::store::init_schema(&pool).await.unwrap();
918 let key = SigningKey::generate("k");
919 let mut s = session();
920 s.aud = format!("http://malformed-live.test:{port}");
921 let repo = repo(&http, &pool, &s, &key);
922 let err = repo
923 .list_all_records("app.feather.subscription")
924 .await
925 .expect_err("a page with a malformed record was accepted");
926 assert!(
927 err.downcast_ref::<crate::atproto::MalformedRecords>()
928 .is_some(),
929 "refused for the wrong reason: {err:#}"
930 );
931 }
932
933 #[tokio::test]
936 async fn the_live_walk_that_finishes_cleanly_returns_the_records() {
937 let mut bodies: Vec<Vec<u8>> = (0..3)
938 .map(|i| {
939 serde_json::json!({
940 "records": [{ "uri": format!("at://did:plc:x/c/3lab{i}"), "value": {} }],
941 "cursor": format!("p{}", i + 1),
942 })
943 .to_string()
944 .into_bytes()
945 })
946 .collect();
947 bodies.push(
952 serde_json::json!({
953 "records": [{ "uri": "at://did:plc:x/c/3labLAST", "value": {} }]
954 })
955 .to_string()
956 .into_bytes(),
957 );
958 let base = crate::net::tests::serve_bodies_in_sequence(bodies).await;
959 let port: u16 = base
960 .trim_end_matches('/')
961 .rsplit(':')
962 .next()
963 .unwrap()
964 .parse()
965 .unwrap();
966 crate::net::test_host_override(
967 "clean-finish-live.test",
968 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
969 );
970 let http = Client::new();
971 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
972 crate::store::init_schema(&pool).await.unwrap();
973 let key = SigningKey::generate("k");
974 let mut s = session();
975 s.aud = format!("http://clean-finish-live.test:{port}");
976 let repo = repo(&http, &pool, &s, &key);
977
978 let records = repo
979 .list_all_records("app.feather.subscription")
980 .await
981 .expect("a walk that ran out of records is not a short list");
982 assert_eq!(records.len(), 4);
983 assert!(
984 records.iter().any(|r| r.uri.ends_with("3labLAST")),
985 "the LAST page's records were dropped: {:?}",
986 records.iter().map(|r| r.uri.as_str()).collect::<Vec<_>>(),
987 );
988 }
989
990 #[tokio::test]
998 async fn a_live_walk_that_terminates_on_its_last_allowed_page_succeeds() {
999 let mut bodies: Vec<Vec<u8>> = (0..MAX_LIST_PAGES - 1)
1000 .map(|i| {
1001 serde_json::json!({
1002 "records": [{ "uri": format!("at://did:plc:x/c/3lab{i}"), "value": {} }],
1003 "cursor": format!("p{}", i + 1),
1004 })
1005 .to_string()
1006 .into_bytes()
1007 })
1008 .collect();
1009 bodies.push(
1010 serde_json::json!({
1011 "records": [{ "uri": "at://did:plc:x/c/3labLAST", "value": {} }]
1012 })
1013 .to_string()
1014 .into_bytes(),
1015 );
1016 assert_eq!(bodies.len(), MAX_LIST_PAGES);
1017 let base = crate::net::tests::serve_bodies_in_sequence(bodies).await;
1018 let port: u16 = base
1019 .trim_end_matches('/')
1020 .rsplit(':')
1021 .next()
1022 .unwrap()
1023 .parse()
1024 .unwrap();
1025 crate::net::test_host_override(
1026 "last-allowed-page-live.test",
1027 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
1028 );
1029 let http = Client::new();
1030 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
1031 crate::store::init_schema(&pool).await.unwrap();
1032 let key = SigningKey::generate("k");
1033 let mut s = session();
1034 s.aud = format!("http://last-allowed-page-live.test:{port}");
1035 let repo = repo(&http, &pool, &s, &key);
1036
1037 let records = repo
1038 .list_all_records("app.feather.subscription")
1039 .await
1040 .expect("a walk that terminated inside its budget is not a short list");
1041 assert_eq!(
1042 records.len(),
1043 MAX_LIST_PAGES,
1044 "a walk that used its whole page budget and finished lost records",
1045 );
1046 }
1047
1048 #[test]
1060 fn an_oversized_error_body_is_not_parsed_for_its_reason() {
1061 let small = br#"{"error":"InvalidSwap","message":"record changed"}"#;
1062 assert_eq!(
1063 Repo::error_fields(small).as_deref(),
1064 Some("InvalidSwap: record changed"),
1065 "a real error body must still render its reason",
1066 );
1067
1068 let mut huge = String::from(r#"{"error":"InvalidSwap","pad":["#);
1069 while huge.len() < crate::oauth::MAX_ERROR_BODY + 1_024 {
1070 huge.push_str("{},");
1071 }
1072 huge.push_str("{}]}");
1073 assert!(huge.len() > crate::oauth::MAX_ERROR_BODY);
1074 assert_eq!(
1075 Repo::error_fields(huge.as_bytes()),
1076 None,
1077 "an oversized error body was deserialised to fish out one string",
1078 );
1079 }
1080
1081 #[tokio::test]
1094 async fn the_live_write_path_refuses_a_node_explosion() {
1095 let mut body = String::from(r#"{"uri":"at://d/c/r","value":["#);
1096 for _ in 0..1_200_000 {
1097 body.push_str("{},");
1098 }
1099 body.push_str("{}]}");
1100 assert!(
1101 crate::atproto::count_structural_chars(body.as_bytes())
1102 > crate::atproto::MAX_LIST_STRUCTURAL_CHARS,
1103 "the probe body is not over the cap, so this test proves nothing",
1104 );
1105 let base = crate::net::tests::serve_body(body.into_bytes()).await;
1106 let port: u16 = base
1107 .trim_end_matches('/')
1108 .rsplit(':')
1109 .next()
1110 .unwrap()
1111 .parse()
1112 .unwrap();
1113 crate::net::test_host_override(
1114 "write-explosion.test",
1115 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
1116 );
1117 let http = Client::new();
1118 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
1119 crate::store::init_schema(&pool).await.unwrap();
1120 let key = SigningKey::generate("k");
1121 let mut s = session();
1122 s.aud = format!("http://write-explosion.test:{port}");
1123 let repo = repo(&http, &pool, &s, &key);
1124
1125 let err = repo
1126 .delete_record("c", "r")
1127 .await
1128 .expect_err("a node explosion on the write path was parsed rather than refused");
1129 assert!(
1130 format!("{err:#}").contains("structural characters"),
1131 "failed for the wrong reason: {err:#}"
1132 );
1133 }
1134
1135 #[tokio::test]
1138 async fn the_live_write_path_accepts_an_ordinary_response() {
1139 let base =
1140 crate::net::tests::serve_body(br#"{"commit":{"cid":"bafy","rev":"3lab"}}"#.to_vec())
1141 .await;
1142 let port: u16 = base
1143 .trim_end_matches('/')
1144 .rsplit(':')
1145 .next()
1146 .unwrap()
1147 .parse()
1148 .unwrap();
1149 crate::net::test_host_override(
1150 "write-ordinary.test",
1151 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
1152 );
1153 let http = Client::new();
1154 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
1155 crate::store::init_schema(&pool).await.unwrap();
1156 let key = SigningKey::generate("k");
1157 let mut s = session();
1158 s.aud = format!("http://write-ordinary.test:{port}");
1159 let repo = repo(&http, &pool, &s, &key);
1160
1161 repo.delete_record("c", "r")
1162 .await
1163 .expect("an ordinary write response was refused");
1164 }
1165
1166 #[tokio::test]
1175 async fn the_live_walk_refuses_a_duplicated_records_key() {
1176 let base = crate::net::tests::serve_body(
1177 br#"{"records":[{"uri":"at://d/c/r","value":{}}],"records":[]}"#.to_vec(),
1178 )
1179 .await;
1180 let port: u16 = base
1181 .trim_end_matches('/')
1182 .rsplit(':')
1183 .next()
1184 .unwrap()
1185 .parse()
1186 .unwrap();
1187 crate::net::test_host_override(
1188 "dup-records.test",
1189 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
1190 );
1191
1192 let http = Client::new();
1193 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
1194 crate::store::init_schema(&pool).await.unwrap();
1195 let key = SigningKey::generate("k");
1196 let mut s = session();
1197 s.aud = format!("http://dup-records.test:{port}");
1198 let repo = repo(&http, &pool, &s, &key);
1199
1200 let err = repo
1201 .list_records("app.feather.subscription", None, None)
1202 .await
1203 .expect_err("a duplicated records key was read as an empty page");
1204 assert!(
1205 format!("{err:#}").contains("duplicate"),
1206 "failed for the wrong reason: {err:#}"
1207 );
1208 }
1209
1210 #[tokio::test]
1217 async fn an_empty_200_body_is_not_an_empty_repo() {
1218 let base = crate::net::tests::serve_body(Vec::new()).await;
1219 let port: u16 = base
1220 .trim_end_matches('/')
1221 .rsplit(':')
1222 .next()
1223 .unwrap()
1224 .parse()
1225 .unwrap();
1226 crate::net::test_host_override(
1227 "empty-body.test",
1228 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
1229 );
1230 let http = Client::new();
1231 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
1232 crate::store::init_schema(&pool).await.unwrap();
1233 let key = SigningKey::generate("k");
1234 let mut s = session();
1235 s.aud = format!("http://empty-body.test:{port}");
1236 let repo = repo(&http, &pool, &s, &key);
1237
1238 let err = repo
1239 .list_records("app.feather.subscription", None, None)
1240 .await
1241 .expect_err("an empty body was read as an empty repo");
1242 assert!(
1243 format!("{err:#}").contains("no records"),
1244 "failed for the wrong reason: {err:#}"
1245 );
1246 }
1247
1248 #[tokio::test]
1252 async fn a_200_error_envelope_is_not_a_successful_write() {
1253 let base = crate::net::tests::serve_body(
1254 br#"{"error":"InvalidRequest","message":"nope"}"#.to_vec(),
1255 )
1256 .await;
1257 let port: u16 = base
1258 .trim_end_matches('/')
1259 .rsplit(':')
1260 .next()
1261 .unwrap()
1262 .parse()
1263 .unwrap();
1264 crate::net::test_host_override(
1265 "envelope-write.test",
1266 std::net::SocketAddr::from(([127, 0, 0, 1], port)),
1267 );
1268 let http = Client::new();
1269 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
1270 crate::store::init_schema(&pool).await.unwrap();
1271 let key = SigningKey::generate("k");
1272 let mut s = session();
1273 s.aud = format!("http://envelope-write.test:{port}");
1274 let repo = repo(&http, &pool, &s, &key);
1275
1276 let err = repo
1277 .delete_record("app.feather.subscription", "rk1")
1278 .await
1279 .expect_err("a failed delete was reported as success");
1280 assert!(format!("{err:#}").contains("InvalidRequest"), "{err:#}");
1281
1282 let err = repo
1283 .apply_writes(&[crate::atproto::WriteOp::Delete {
1284 collection: "app.feather.subscription".to_string(),
1285 rkey: "rk1".to_string(),
1286 }])
1287 .await
1288 .expect_err("a failed batch was reported as success");
1289 assert!(format!("{err:#}").contains("InvalidRequest"), "{err:#}");
1290 }
1291
1292 #[tokio::test]
1295 async fn an_empty_batch_is_not_sent() {
1296 let http = Client::new();
1297 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
1298 let key = SigningKey::generate("k");
1299 let mut s = session();
1300 s.aud = "http://127.0.0.1:2583".into();
1302 let repo = repo(&http, &pool, &s, &key);
1303 assert!(repo.apply_writes(&[]).await.is_ok());
1304 }
1305}