Skip to main content

feather_reader/oauth/
xrpc.rs

1//! DPoP-bound `com.atproto.repo.*` calls against the user's PDS.
2//!
3//! The layer the 31-method reader surface is built on. It returns the same
4//! [`RecordEntry`] / [`WriteResult`] / [`WriteOp`] types the existing
5//! [`crate::atproto`] client does, so the typed wrappers above it (subscriptions,
6//! folders, saved, read-state) transfer at cutover rather than being rewritten.
7//!
8//! Two properties that are not obvious from the call shapes:
9//!
10//! * **The PDS comes from the session's `aud`**, never re-derived per call. It
11//!   is a property of the token set — a token is valid at one PDS — and it is
12//!   AAD-bound in the session row precisely so it cannot be repointed.
13//! * **Every request carries both the proof and the token.** A resource request
14//!   is `Authorization: DPoP <token>` plus a `DPoP` proof whose `ath` binds to
15//!   that token; either alone is useless.
16
17use 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
29/// Cap on how many pages `list_all_records` will walk.
30///
31/// The same bound the existing client uses: a repo is user-controlled, and an
32/// unbounded walk is a denial-of-service against ourselves.
33const MAX_LIST_PAGES: usize = 50;
34
35/// Hard cap on records accumulated by one walk — 50 pages x the 100 we request.
36/// See [`crate::atproto::extend_bounded`] for why exceeding it is an error
37/// rather than a truncation.
38const MAX_LIST_RECORDS: usize = 5_000;
39
40/// An authenticated handle on one account's repo.
41pub struct Repo<'a> {
42    pub http: &'a Client,
43    pub pool: &'a SqlitePool,
44    pub session: &'a OAuthSession,
45    /// The session's DPoP key, already unsealed.
46    pub key: &'a SigningKey,
47}
48
49impl Repo<'_> {
50    /// The XRPC endpoint for a method, on THIS session's PDS.
51    fn url(&self, nsid: &str) -> String {
52        format!("{}/xrpc/{nsid}", self.session.aud.trim_end_matches('/'))
53    }
54
55    /// Send, and fail loudly on a non-2xx with the XRPC error if there is one.
56    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                // A repo call is repeated on a nonce challenge, on the
72                // ASSUMPTION that a PDS answering with a challenge has not acted
73                // on the request. That is how conformant servers behave and what
74                // the reference relies on, but it is not a guarantee we can
75                // verify: a PDS or intermediary that emitted `use_dpop_nonce`
76                // after a write committed would yield a duplicate record from
77                // `createRecord` or `applyWrites`, both of which are
78                // non-idempotent here.
79                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    /// [`send_raw`](Self::send_raw), then the body as JSON.
94    ///
95    /// Every call that wants a `Value` goes through here; `listRecords` does
96    /// not, because turning its body into a `Value` and then into a page
97    /// materialises the records twice.
98    async fn send(&self, url: &str, body: DpopBody<'_>, nsid: &str) -> Result<Value> {
99        let outcome = self.send_raw(url, body, nsid).await?;
100        // A 200 with an empty body is legitimate for deleteRecord/applyWrites.
101        if outcome.body.is_empty() {
102            return Ok(Value::Null);
103        }
104        outcome.json()
105    }
106
107    /// Render an XRPC error body for a message.
108    ///
109    /// Only ever called on a NON-success, where the body is an error document
110    /// rather than a token or a record — and even then only the `error` and
111    /// `message` fields, never the raw bytes.
112    ///
113    /// **Bounded by length before it is parsed.** This is the error twin of
114    /// `send`, whose success branch goes through `PostOutcome::json` and its node
115    /// guard — so without this a hostile PDS answering **500** instead of 200 got
116    /// the whole amplification the guard exists to stop, on every write and on
117    /// every listing failure. An error document is a few dozen bytes; see
118    /// [`super::MAX_ERROR_BODY`].
119    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    /// One page of a collection.
132    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    /// [`Self::list_records`] with the whole page, including how many records
143    /// were malformed and left out (#177) — which the tuple form cannot carry.
144    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        // **Raw bytes, parsed once.** This is the live backend's list walk, the
165        // largest body this client reads, and routing it through `Value` first
166        // held two copies of every page at the same time.
167        let outcome = self
168            .send_raw(
169                url.as_str(),
170                DpopBody::Query,
171                "com.atproto.repo.listRecords",
172            )
173            .await?;
174        // A 2xx carrying an error envelope is NOT an empty page: `records`
175        // defaulting to `[]` turned a PDS failure into `Ok(vec![])`, which
176        // `resolve_subscriptions` reads as "this DID follows nothing" and
177        // `sync_sub_refs` then writes through, revoking every `sub_ref`.
178        // Both invariants, through the one function every listRecords caller
179        // shares — this check was added here first and had to be fitted to the
180        // other two clients a round later. It reads bytes now rather than a
181        // `Value`; the invariants are the same ones, expressed as fields.
182        crate::atproto::parse_list_records(&outcome.body)
183    }
184
185    /// Every record in a collection, following the cursor.
186    ///
187    /// Bounded at `MAX_LIST_PAGES`: the repo is user-controlled, so an
188    /// unbounded walk is a denial-of-service against ourselves. A cursor that
189    /// does not advance also terminates the walk rather than spinning.
190    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    /// [`list_all_records`](Self::list_all_records) against a caller's budget.
199    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            // This walk is the live backend's, and its result reaches
214            // `replace_sub_refs`: a skipped record is a dropped subscription.
215            crate::atproto::refuse_malformed(&listed, collection)?;
216            let (page, next) = (listed.records, listed.cursor);
217            let got = page.len();
218            // Same cap, same reason as `atproto::extend_bounded`: MAX_LIST_PAGES
219            // bounds requests, not memory, unless the server honours our limit.
220            // This is the LIVE walk on `backend=rust` — its result reaches
221            // `replace_sub_refs`, so a truncation here is revoked access.
222            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                // `got > 0` is not defensive tidiness -- it is a whole round
233                // trip. This project's own PDS returns a cursor ALONGSIDE a
234                // short page, so without it every list fetches a second, empty
235                // page before stopping. Both of the other clients have this
236                // guard; measured, its absence was most of the remaining gap
237                // against the sidecar.
238                //
239                // The cursor-repeat check is the separate concern: a server that
240                // hands back the same cursor forever would otherwise loop.
241                Some(next) if got > 0 && Some(&next) != cursor.as_ref() => {
242                    cursor = Some(next);
243                    more_offered = true;
244                }
245                _ => {
246                    // `break` with the flag cleared, rather than an early
247                    // `return`: an early return makes the post-loop check
248                    // unreachable, so the flag reads as dead state and a
249                    // reviewer hunts for the case that clears it. It also left
250                    // the "every walk refuses" mutation alive here.
251                    more_offered = false;
252                    break;
253                }
254            }
255        }
256        // **Running out of pages is a refusal, not a short answer.** Falling out
257        // of the loop used to return `Ok(out)`, so a repo bigger than the page
258        // budget produced a truncated list indistinguishable from a complete
259        // one — and `resolve_subscriptions` needs an `Err` for its fail-closed
260        // branch. Given `Ok`, it hands the short list to `replace_sub_refs`,
261        // which DELETEs the reader's whole `sub_ref` projection and reinserts
262        // only what it was given. `extend_bounded` cannot catch this either:
263        // `MAX_LIST_PAGES` x the 100 we request is `MAX_LIST_RECORDS`, so against
264        // a server that honours our limit the page budget runs out first.
265        //
266        // **The cap is on REQUESTS, though, so the record count it bites at is
267        // the server's page size x the budget — not a number our constants fix.**
268        // A PDS answering 50 a page reaches half as far; one answering more than
269        // asked trips `extend_bounded` instead. And the last allowed page is a
270        // FALSE refusal: terminating costs one extra request when a short page
271        // still carries a cursor, so a walk holding every record it will ever
272        // hold still refuses on a cursor it never followed.
273        //
274        // This client's caps are a QUARTER of the direct client's (50 pages,
275        // 5 000 records), so with `limit=100` honoured the same reader refuses
276        // here at ~4 900 records and works to ~19 900 on the sidecar. `Saved`
277        // walks this too, one record per starred article, where 4 900 is a
278        // plausible number for a real reader.
279        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    /// Create a record, letting the PDS assign the key.
290    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    /// Create or replace a record at a known key.
303    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    /// A batch of writes in one round trip.
334    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
362/// Format an XRPC failure for an error message.
363fn 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
370/// The reader's typed surface, over [`Repo`].
371///
372/// Deliberately thin: each method is one repo call plus a parse, and the
373/// orderings come from [`crate::lexicon::sort`], SHARED with the sidecar client
374/// so the two cannot disagree across the cutover. A divergence there would not
375/// be subtle — it would reorder the user's feed list the moment the
376/// implementation swapped.
377impl Repo<'_> {
378    /// List a collection and parse each record into `T`, paired with its rkey.
379    ///
380    /// An unparseable record is SKIPPED with a warning rather than failing the
381    /// list. Records are written by other clients and by future versions of this
382    /// one; one record this build cannot read must not black out the whole feed
383    /// list.
384    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    // ── subscriptions ────────────────────────────────────────────────────────
406
407    pub async fn list_subscriptions(&self) -> Result<Vec<(String, crate::lexicon::Subscription)>> {
408        self.list_typed(crate::lexicon::nsid::SUBSCRIPTION).await
409    }
410
411    /// Every subscription, in the reader's deterministic order.
412    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    /// Subscribe to a feed. Returns the new record's rkey so the caller can
421    /// address it (rename, delete) without re-listing.
422    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    /// Replace a subscription in place — retitle, refile, change cadence.
438    /// Returns the [`WriteResult`] rather than discarding it: the caller logs
439    /// the resulting record URI, and a `()` here would have silently dropped
440    /// that field from the log line after the cutover.
441    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    /// Batch-add many subscriptions in one `applyWrites` — the OPML-import path.
451    ///
452    /// One round trip rather than N: an import of several hundred feeds is the
453    /// case this exists for.
454    /// Returns the new rkeys, which are assigned HERE rather than by the server:
455    /// client-side TIDs keep the imported feeds in input order and make the
456    /// batch reproducible. The sidecar client does the same, with the same
457    /// generator.
458    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                // Propagated, NOT defaulted: `unwrap_or(Value::Null)` here would
471                // write a null record into the user's repo on a serialization
472                // failure rather than failing the import.
473                value: serde_json::to_value(sub)?,
474            });
475            rkeys.push(rkey);
476        }
477        self.apply_writes(&writes).await?;
478        Ok(rkeys)
479    }
480
481    // ── folders ──────────────────────────────────────────────────────────────
482
483    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    /// Delete a folder. Subscriptions referencing it are left alone; a dangling
501    /// reference reads as "unfiled", which is the same behaviour the sidecar
502    /// client has.
503    pub async fn remove_folder(&self, rkey: &str) -> Result<()> {
504        self.delete_record(crate::lexicon::nsid::FOLDER, rkey).await
505    }
506
507    /// Returns the [`WriteResult`], matching the sidecar client — the caller
508    /// logs the record URI from it.
509    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    // ── saved ────────────────────────────────────────────────────────────────
519
520    pub async fn list_saved(&self) -> Result<Vec<(String, crate::lexicon::Saved)>> {
521        self.list_typed(crate::lexicon::nsid::SAVED).await
522    }
523
524    /// Saved entries, newest first.
525    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    // ── read state ───────────────────────────────────────────────────────────
543
544    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    /// Upsert one read cursor at its feed-derived rkey.
549    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    /// Flush many dirty read cursors in one `applyWrites`.
560    ///
561    /// Read state changes on nearly every page view, so this is the hottest
562    /// write path in the app; one round trip per flush rather than per feed is
563    /// the whole point.
564    /// The `bool` is whether the record already exists in the PDS. It is not
565    /// optional bookkeeping: an `#update` on a missing record ERRORS, and
566    /// `applyWrites` is atomic per repo, so one not-yet-created cursor in the
567    /// batch would drop the whole DID's flush. The op builder is shared with the
568    /// sidecar client so the two cannot decide create-vs-update differently.
569    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    /// **Both clients must build the SAME write ops.**
586    ///
587    /// `flush_read_states` is the hottest write path in the app, and the
588    /// create-vs-update choice is the one part of it that cannot be got wrong
589    /// quietly: an `#update` on a record that does not exist errors, and
590    /// `applyWrites` is atomic per repo, so a single first-flush cursor in the
591    /// batch takes the whole DID's flush down with it.
592    ///
593    /// The first version of this method here ignored the flag and emitted
594    /// `#create` unconditionally, which would have broken every feed's first
595    /// flush after the cutover. Sharing the builder is what makes that
596    /// impossible rather than merely fixed.
597    #[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    /// **Bulk import, through the real `Repo`, asserted on the bytes.** The
624    /// test this replaces exercised `TidGenerator` directly and never called
625    /// `add_subscriptions_bulk`; the function could stop assigning rkeys and
626    /// return garbage with the suite green.
627    #[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    /// **The PDS comes from the session**, so a call cannot be pointed at
704    /// another host by a caller who passes the wrong base.
705    #[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    /// An XRPC error document is summarised by its `error`/`message` fields
721    /// only — never by echoing the raw body, which on other paths holds tokens.
722    #[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        // A body that is not an XRPC error contributes nothing but the status.
730        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    /// Every repo call must fail closed on an internal target, like every other
737    /// outbound path — asserted on the guard's own error.
738    #[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    /// **A 200 carrying an error envelope must not read as an empty repo.**
759    ///
760    /// `records` was taken off the JSON with `unwrap_or(Array([]))`, so a PDS
761    /// answering `200 {"error": …}` produced `Ok(vec![])`. That is not the
762    /// fail-closed branch in `web::resolve_subscriptions`: `sync_sub_refs`
763    /// writes the empty set through and `replace_sub_refs` DELETEs the DID's
764    /// entire `sub_ref` projection — one bad response revokes the reader's
765    /// access to every feed they have. Driven through the real client against
766    /// a real server, because the bug was the missing CALL, not the check.
767    #[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    /// **The live walk spends its budget across pages too.**
804    ///
805    /// This is the `backend=rust` walk whose result reaches `replace_sub_refs`,
806    /// so a bound that silently failed to accumulate here would revoke a
807    /// reader's access to every feed past the cut. Three pages, a two-page
808    /// budget: the walk must refuse, and must say it kept two.
809    #[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    /// **The live walk refuses a short list.** Its result reaches
849    /// `replace_sub_refs`, so returning a truncated list as a complete one
850    /// deletes every subscription past the page budget.
851    #[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    /// #177 on the live backend: a malformed record in the reader's own repo
894    /// refuses the walk, by type, rather than dropping that subscription.
895    #[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    /// The live walk's clean finish must still be a success — see the sidecar's
934    /// twin for why this direction is the dangerous one.
935    #[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        // **The terminator CARRIES a record**, because a real PDS ends on a
948        // partial page and those are the records an off-by-one loses. Ending on
949        // an empty page kept "drop the last page's records" alive: the count
950        // below was right either way.
951        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    /// **The page cap is pinned exactly, not to within one.**
991    ///
992    /// `the_live_walk_that_runs_out_of_pages_refuses` serves `MAX_LIST_PAGES + 1`
993    /// pages, so a budget one page SHORT refuses too and that mutation survives
994    /// it. A walk whose last allowed request is the terminating one must come
995    /// back `Ok` — and on this backend an `Err` is `replace_sub_refs` never
996    /// running, which is the direction that costs a reader their subscriptions.
997    #[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    /// **The ERROR twin of `send`, which a review found unguarded.**
1049    ///
1050    /// `send`'s success branch goes through `PostOutcome::json` and its cap; its
1051    /// failure branch goes to `xrpc_error` → `error_fields`, which deserialised the
1052    /// whole body. So a hostile PDS answering **500** instead of 200 with the same
1053    /// explosion got the full amplification on every write and on every listing
1054    /// failure — the guard bypassed by a status code.
1055    ///
1056    /// Both directions: a small error body still yields its reason, because that
1057    /// string is what a reader's log needs to tell "your PDS said no" from "we
1058    /// broke".
1059    #[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    /// **The write path parses a PDS body too, and it had no bound before the
1082    /// parse.**
1083    ///
1084    /// `listRecords` is guarded by `parse_list_records`; every OTHER body this
1085    /// client turns into a `Value` goes through `PostOutcome::json` — the repo
1086    /// writers' responses, the PAR response, the token response, the session
1087    /// refresh. `read_capped` bounds the WIRE at 8 MB, which is the *input* to the
1088    /// amplification rather than a limit on it: 8 MB of the cheapest node shape
1089    /// measured 824 MB retained on a 512 MB box.
1090    ///
1091    /// Drives `delete_record`, which is the shortest route from a handler to
1092    /// `send` → `json`.
1093    #[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    /// The other direction: an ordinary write response still parses. Without this
1136    /// a guard that refused every body would pass the test above.
1137    #[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    /// **A duplicated `records` key must not be able to empty a page.**
1167    ///
1168    /// This is the live `backend=rust` walk, so an empty page here reaches
1169    /// `replace_sub_refs` and deletes the reader's subscriptions. serde refuses
1170    /// a repeated field outright; a `serde_json::Value` takes the last one
1171    /// silently, so routing this body through a `Value` first turns a smuggled
1172    /// second key into a successful, empty listing. The test exists to pin
1173    /// which of the two this client uses.
1174    #[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    /// **An empty 2xx body is not an empty repo either.** `send` maps a
1211    /// zero-length 2xx to `Value::Null` — deliberately, for `deleteRecord` and
1212    /// `applyWrites` — and while `listRecords` still went through it, that
1213    /// slipped past the envelope guard
1214    /// and `records.unwrap_or(Array([]))` produced `Ok(vec![])`: the same
1215    /// `sub_ref` wipe the guard was added to prevent, through the sibling door.
1216    #[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    /// The write paths discarded the body too: `delete_record` and
1249    /// `apply_writes` are `send(..).await?; Ok(())`, so a 200 carrying an
1250    /// error envelope reported success for a delete that did not happen.
1251    #[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    /// An empty batch must not produce a request at all — an `applyWrites` with
1293    /// no writes is a round trip that can only fail.
1294    #[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        // A target that would fail loudly if it were ever contacted.
1301        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}