Skip to main content

vti_common/pagination/
mod.rs

1//! Cursor pagination — workspace-wide standard for every list
2//! endpoint.
3//!
4//! Implements **M0.1.4** of the VTC MVP Phase 0 plan. The cursor
5//! contract from spec §9.1:
6//!
7//! ```text
8//! GET /v1/<collection>?cursor=<opaque>&limit=<1..200>
9//!
10//! → 200 OK
11//! {
12//!   "items":          [...],
13//!   "next_cursor":    "<opaque>" | null,
14//!   "total_estimate": <u64 | null>
15//! }
16//! ```
17//!
18//! ## Design properties
19//!
20//! - **Opaque to consumers.** The cursor is a base64url-encoded
21//!   binary blob; clients pass it back verbatim. No public structure.
22//! - **Tamper-evident.** The cursor's payload is HMAC-SHA256-signed
23//!   under a per-maintainer 32-byte key. A cursor minted by one
24//!   maintainer can't be replayed against another; a guessed cursor
25//!   can't iterate past the protocol boundary. See [`Cursor::decode`]
26//!   for the verification flow.
27//!
28//!   Where that key comes from depends on what the maintainer already
29//!   has. The VTC signs under the active `audit_key` from its
30//!   [`crate::audit::AuditKeyStore`]. A maintainer with no such store
31//!   — the VTA keeps a flat audit log with no hash chain — uses
32//!   [`CursorKey`], which persists a random key in a keyspace of the
33//!   caller's choosing and is used for nothing but cursor MACs.
34//! - **No structural feedback on failure.** A tampered, forged, or
35//!   malformed cursor returns [`AppError::InvalidCursor`] (400) with
36//!   no detail. The HMAC verification + payload deserialisation share
37//!   a single error so the caller can't learn whether the tag check
38//!   or the structure check rejected them.
39//! - **`(last_key, snapshot_id)` payload.** `last_key` is the raw
40//!   storage key of the last item the previous page returned;
41//!   `snapshot_id` is the wall-clock timestamp at mint. The
42//!   snapshot is carried through opaquely (no consistency check
43//!   today — newly-inserted rows may or may not appear in the next
44//!   page). Plumbing for it is in place so a future tightening can
45//!   land without a wire-shape change.
46//!
47//! ## Limit bounds
48//!
49//! - **min**: 1 (a zero-limit request would never make progress).
50//! - **max**: 200 — pinned in code per spec §9.1.
51//! - **default**: 50 — the most common operator-facing list size.
52//!
53//! Larger reads should iterate via repeated `next_cursor` calls;
54//! the maximum is non-negotiable to keep response sizes bounded.
55//!
56//! ## Storage iteration model
57//!
58//! For Phase 0, [`paginate`] takes the already-materialised list of
59//! raw key/value pairs from [`crate::store::KeyspaceHandle::prefix_iter_raw`]
60//! and walks them in-memory. That's O(N) per page but bounded by the
61//! community's keyspace size — adequate for the small lists (members,
62//! join requests, policies, audit) that Phase 0 ships. A cursor-aware
63//! `prefix_iter_after` method on `KeyspaceHandle` is the natural
64//! follow-up if community sizes outgrow this; the wire shape doesn't
65//! change.
66
67use std::io::{Cursor as IoCursor, Read};
68
69use base64::Engine;
70use hmac::{Hmac, KeyInit, Mac};
71use serde::{Deserialize, Serialize};
72use sha2::Sha256;
73
74use crate::error::AppError;
75use crate::store::RawKvPair;
76
77const B64: base64::engine::general_purpose::GeneralPurpose =
78    base64::engine::general_purpose::URL_SAFE_NO_PAD;
79
80/// HMAC tag length in bytes. 32 (full SHA-256 output) — overhead is
81/// trivial relative to base64-encoded cursors and removes any
82/// concern about short-tag birthday-style attacks.
83const HMAC_TAG_LEN: usize = 32;
84
85/// Maximum cursor `last_key` length to accept on decode. Prevents an
86/// adversary from supplying a multi-megabyte cursor that allocates
87/// inflated buffers. Keyspace keys in the workspace are well under
88/// this in practice.
89const MAX_LAST_KEY_LEN: u32 = 1024;
90
91/// Minimum list-page size accepted from query params. See module docs.
92pub const MIN_LIMIT: usize = 1;
93/// Maximum list-page size accepted from query params. See module docs.
94pub const MAX_LIMIT: usize = 200;
95/// Default page size when the caller omits `limit`. See module docs.
96pub const DEFAULT_LIMIT: usize = 50;
97
98type HmacSha256 = Hmac<Sha256>;
99
100// ---------------------------------------------------------------------------
101// Query params + response wrapper
102// ---------------------------------------------------------------------------
103
104/// Standard query-param shape for list endpoints. Bind this in a
105/// handler with `Query<PaginationParams>` and pass `cursor` /
106/// `limit` to [`paginate`].
107#[derive(Debug, Clone, Deserialize, Default)]
108pub struct PaginationParams {
109    /// Opaque cursor returned by the previous page's `next_cursor`.
110    /// `None` requests the first page.
111    pub cursor: Option<String>,
112    /// Caller-requested page size. Clamped to `[MIN_LIMIT, MAX_LIMIT]`
113    /// before use; `None` falls back to [`DEFAULT_LIMIT`].
114    pub limit: Option<usize>,
115}
116
117impl PaginationParams {
118    /// Apply the workspace's clamping rules. Always returns a valid
119    /// limit in `[MIN_LIMIT, MAX_LIMIT]`.
120    pub fn effective_limit(&self) -> usize {
121        self.limit
122            .unwrap_or(DEFAULT_LIMIT)
123            .clamp(MIN_LIMIT, MAX_LIMIT)
124    }
125}
126
127/// Standard response wrapper for list endpoints. Carries the
128/// requested page of items, the opaque cursor for the next page
129/// (`None` when the caller has reached the end), and an optional
130/// total-count estimate.
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
133pub struct Paginated<T> {
134    pub items: Vec<T>,
135    pub next_cursor: Option<String>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub total_estimate: Option<u64>,
138}
139
140// ---------------------------------------------------------------------------
141// Cursor — internal payload + signed wire form
142// ---------------------------------------------------------------------------
143
144/// Decoded cursor payload. Public so callers that want the raw
145/// `last_key` (e.g. for debugging or custom iteration) can inspect
146/// it. Never construct one directly off the wire — use
147/// [`Cursor::decode`] so the HMAC tag gets verified.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Cursor {
150    /// Raw fjall storage key of the last item the previous page
151    /// returned. The next page begins **strictly after** this key.
152    pub last_key: Vec<u8>,
153    /// Unix-second timestamp at the time of minting. Currently
154    /// opaque metadata — see module docs.
155    pub snapshot_id: u64,
156}
157
158impl Cursor {
159    /// Construct a cursor for the page that ends at `last_key`.
160    pub fn new(last_key: Vec<u8>, snapshot_id: u64) -> Self {
161        Self {
162            last_key,
163            snapshot_id,
164        }
165    }
166
167    /// Encode + sign the cursor under `audit_key`. Returns the
168    /// base64url-encoded wire form ready for `next_cursor`.
169    pub fn encode(&self, audit_key: &[u8; 32]) -> String {
170        self.encode_bound(audit_key, &[])
171    }
172
173    /// As [`Cursor::encode`], but additionally binds `binding` into the
174    /// HMAC — without placing it on the wire.
175    ///
176    /// This is how a paginated query pins the filters a cursor was minted
177    /// under. The bytes are covered by the tag but not transmitted, so the
178    /// wire format is unchanged and a caller cannot rewrite them. Resuming
179    /// with a *different* binding fails verification and surfaces as
180    /// [`AppError::InvalidCursor`], which is what stops a consumer from
181    /// paging with one filter set and continuing with another — a silent
182    /// way to skip entries that must never happen on an audit log.
183    ///
184    /// Callers with no binding use [`Cursor::encode`], which passes an
185    /// empty slice and is therefore wire- and tag-compatible with cursors
186    /// minted before this method existed.
187    pub fn encode_bound(&self, audit_key: &[u8; 32], binding: &[u8]) -> String {
188        let mut buf = Vec::with_capacity(4 + self.last_key.len() + 8 + HMAC_TAG_LEN);
189        buf.extend_from_slice(&(self.last_key.len() as u32).to_be_bytes());
190        buf.extend_from_slice(&self.last_key);
191        buf.extend_from_slice(&self.snapshot_id.to_be_bytes());
192
193        let mut mac = HmacSha256::new_from_slice(audit_key).expect("32-byte HMAC key");
194        mac.update(&buf);
195        mac.update(binding);
196        let tag = mac.finalize().into_bytes();
197        buf.extend_from_slice(&tag);
198
199        B64.encode(&buf)
200    }
201
202    /// Decode + verify a wire-form cursor. Returns
203    /// [`AppError::InvalidCursor`] on any failure (malformed,
204    /// tampered, or signed under a different key) — the error
205    /// deliberately doesn't reveal which.
206    pub fn decode(wire: &str, audit_key: &[u8; 32]) -> Result<Self, AppError> {
207        Self::decode_bound(wire, audit_key, &[])
208    }
209
210    /// As [`Cursor::decode`], but requires the cursor to have been minted
211    /// with the same `binding` (see [`Cursor::encode_bound`]). A mismatch
212    /// is indistinguishable from a tampered cursor and yields
213    /// [`AppError::InvalidCursor`].
214    pub fn decode_bound(
215        wire: &str,
216        audit_key: &[u8; 32],
217        binding: &[u8],
218    ) -> Result<Self, AppError> {
219        let raw = B64.decode(wire).map_err(|_| AppError::InvalidCursor)?;
220        if raw.len() <= HMAC_TAG_LEN + 4 + 8 {
221            return Err(AppError::InvalidCursor);
222        }
223
224        let payload_len = raw.len() - HMAC_TAG_LEN;
225        let payload = &raw[..payload_len];
226        let received_tag = &raw[payload_len..];
227
228        let mut mac = HmacSha256::new_from_slice(audit_key).expect("32-byte HMAC key");
229        mac.update(payload);
230        mac.update(binding);
231        mac.verify_slice(received_tag)
232            .map_err(|_| AppError::InvalidCursor)?;
233
234        let mut reader = IoCursor::new(payload);
235        let mut key_len_buf = [0u8; 4];
236        reader
237            .read_exact(&mut key_len_buf)
238            .map_err(|_| AppError::InvalidCursor)?;
239        let key_len = u32::from_be_bytes(key_len_buf);
240        if key_len > MAX_LAST_KEY_LEN {
241            return Err(AppError::InvalidCursor);
242        }
243
244        let mut last_key = vec![0u8; key_len as usize];
245        reader
246            .read_exact(&mut last_key)
247            .map_err(|_| AppError::InvalidCursor)?;
248
249        let mut snapshot_buf = [0u8; 8];
250        reader
251            .read_exact(&mut snapshot_buf)
252            .map_err(|_| AppError::InvalidCursor)?;
253        let snapshot_id = u64::from_be_bytes(snapshot_buf);
254
255        // Reject trailing garbage so a tampered cursor with a valid
256        // suffix tag can't sneak through.
257        if reader.position() as usize != payload_len {
258            return Err(AppError::InvalidCursor);
259        }
260
261        Ok(Self {
262            last_key,
263            snapshot_id,
264        })
265    }
266}
267
268// ---------------------------------------------------------------------------
269// CursorKey — signing key for maintainers with no audit-key store
270// ---------------------------------------------------------------------------
271
272/// Storage key the [`CursorKey`] material lives under. Deliberately
273/// namespaced away from any `log:` / `audit_key:` prefix so a cursor
274/// key can share a keyspace with the rows it paginates.
275const CURSOR_KEY_STORAGE_KEY: &[u8] = b"pagination:cursor_key/v1";
276
277/// A keyspace-persisted 32-byte HMAC key for signing pagination
278/// cursors.
279///
280/// The VTC signs its cursors under the active `audit_key` from its
281/// [`crate::audit::AuditKeyStore`]. The VTA has no such store — it
282/// keeps a flat append-only log with no hash chain — but canonical
283/// `audit/list` still requires cursors that cannot be forged into a
284/// position the filters did not authorize. This is the minimum that
285/// buys: a random key, generated once and persisted, used for nothing
286/// but cursor MACs.
287///
288/// **Not** derived from the master seed. A cursor is ephemeral and
289/// carries no long-term secret, so seed derivation would only widen
290/// the seed's blast radius for no benefit; and a key that is *not*
291/// reproduced by a backup+restore is the safer default, since a
292/// cursor minted against one database should not silently resolve to
293/// a position in another.
294#[derive(Clone)]
295pub struct CursorKey {
296    ks: crate::store::KeyspaceHandle,
297}
298
299impl CursorKey {
300    /// Wrap a keyspace. The key is created lazily on first [`Self::get`].
301    pub fn new(ks: crate::store::KeyspaceHandle) -> Self {
302        Self { ks }
303    }
304
305    /// Read the signing key, generating and persisting one if this is
306    /// the first call against a fresh store.
307    ///
308    /// Creation is atomic via
309    /// [`crate::store::KeyspaceHandle::insert_raw_if_absent`]: a
310    /// concurrent creator loses the race and re-reads the winner's
311    /// key, so two requests arriving together never mint cursors under
312    /// different keys.
313    pub async fn get(&self) -> Result<[u8; 32], AppError> {
314        if let Some(existing) = self.read().await? {
315            return Ok(existing);
316        }
317
318        let mut fresh = [0u8; 32];
319        rand::fill(&mut fresh);
320        self.ks
321            .insert_raw_if_absent(CURSOR_KEY_STORAGE_KEY.to_vec(), fresh.to_vec())
322            .await?;
323
324        // Re-read unconditionally rather than trusting the bool: on a
325        // lost race the winner's key is the one every other request is
326        // already signing under.
327        self.read().await?.ok_or_else(|| {
328            AppError::Internal("cursor key vanished immediately after creation".into())
329        })
330    }
331
332    async fn read(&self) -> Result<Option<[u8; 32]>, AppError> {
333        let Some(raw) = self.ks.get_raw(CURSOR_KEY_STORAGE_KEY.to_vec()).await? else {
334            return Ok(None);
335        };
336        let key: [u8; 32] = raw.try_into().map_err(|_| {
337            AppError::Internal("stored cursor key is not 32 bytes; refusing to sign".into())
338        })?;
339        Ok(Some(key))
340    }
341}
342
343// ---------------------------------------------------------------------------
344// paginate helper
345// ---------------------------------------------------------------------------
346
347/// Paginate a materialised list of `(key, value)` pairs. The pairs
348/// are walked in **caller-provided order** — typically the result of
349/// `prefix_iter_raw`, which returns lexicographically-ordered keys
350/// for fjall keyspaces.
351///
352/// Behaviour:
353///
354/// - When `cursor` is `Some`, skips entries with key `<= cursor.last_key`.
355/// - Takes up to `limit` entries.
356/// - If more entries remain after `limit`, sets `next_cursor` to a
357///   freshly-signed cursor whose `last_key` is the last entry the
358///   caller saw.
359/// - Maps each entry's value bytes via `map_value`. Any per-row
360///   deserialisation error short-circuits with that row's error;
361///   callers that want resilience against bad rows should do the
362///   filtering themselves before calling this.
363///
364/// `audit_key` signs minted cursors; `snapshot_id` is the wall-clock
365/// the new cursor will carry (callers usually pass `Utc::now()` /
366/// `chrono::Utc::now().timestamp().max(0) as u64`).
367pub fn paginate<T, F>(
368    pairs: Vec<RawKvPair>,
369    cursor: Option<&Cursor>,
370    limit: usize,
371    audit_key: &[u8; 32],
372    snapshot_id: u64,
373    mut map_value: F,
374) -> Result<Paginated<T>, AppError>
375where
376    F: FnMut(&[u8]) -> Result<T, AppError>,
377{
378    let limit = limit.clamp(MIN_LIMIT, MAX_LIMIT);
379
380    let start = match cursor {
381        Some(c) => {
382            // Find the first pair whose key is strictly greater than
383            // `last_key`. Linear scan is fine for Phase 0 list sizes;
384            // see module docs for the long-term plan.
385            pairs
386                .iter()
387                .position(|(k, _)| k.as_slice() > c.last_key.as_slice())
388                .unwrap_or(pairs.len())
389        }
390        None => 0,
391    };
392
393    let mut items = Vec::with_capacity(limit.min(pairs.len().saturating_sub(start)));
394    let mut idx = start;
395    let mut last_seen_key: Option<Vec<u8>> = None;
396
397    while items.len() < limit && idx < pairs.len() {
398        let (key, value) = &pairs[idx];
399        items.push(map_value(value)?);
400        last_seen_key = Some(key.clone());
401        idx += 1;
402    }
403
404    let next_cursor = if idx < pairs.len() {
405        last_seen_key.map(|k| Cursor::new(k, snapshot_id).encode(audit_key))
406    } else {
407        None
408    };
409
410    Ok(Paginated {
411        items,
412        next_cursor,
413        total_estimate: None,
414    })
415}
416
417// ---------------------------------------------------------------------------
418// Tests
419// ---------------------------------------------------------------------------
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    const KEY_A: [u8; 32] = [0xAA; 32];
426    const KEY_B: [u8; 32] = [0xBB; 32];
427
428    fn temp_ks() -> (crate::store::KeyspaceHandle, tempfile::TempDir) {
429        let dir = tempfile::tempdir().expect("tempdir");
430        let cfg = crate::config::StoreConfig {
431            data_dir: dir.path().to_path_buf(),
432        };
433        let store = crate::store::Store::open(&cfg).expect("store");
434        let ks = store.keyspace("cursor-key-test").expect("keyspace");
435        (ks, dir)
436    }
437
438    /// The key must be stable across calls — a key regenerated per
439    /// request would invalidate every cursor the moment it was handed
440    /// out, so pagination would never advance past page one.
441    #[tokio::test]
442    async fn cursor_key_is_created_once_and_reused() {
443        let (ks, _dir) = temp_ks();
444
445        let first = CursorKey::new(ks.clone()).get().await.expect("mint");
446        let second = CursorKey::new(ks.clone()).get().await.expect("reuse");
447        assert_eq!(first, second);
448        assert_ne!(first, [0u8; 32], "the key must be random, not zeroed");
449
450        // A cursor minted under it survives a round trip through a
451        // freshly-constructed handle over the same keyspace.
452        let c = Cursor::new(b"log:00000000000000000001:abc".to_vec(), 1);
453        let wire = c.encode_bound(&first, b"binding");
454        assert_eq!(Cursor::decode_bound(&wire, &second, b"binding").unwrap(), c);
455    }
456
457    /// The key must survive an **encrypted** keyspace.
458    ///
459    /// This is the deployment case, not an edge case: the VTA's audit
460    /// keyspace — where this key lives — is encrypted at rest. The key
461    /// is stored via the raw byte API and read back with a 32-byte
462    /// `try_into`, so if encrypt-on-write and decrypt-on-read were not
463    /// symmetric the read would see ciphertext of the wrong length and
464    /// every audit list call would fail. A plaintext-only test would
465    /// never show it.
466    #[cfg(feature = "encryption")]
467    #[tokio::test]
468    async fn cursor_key_round_trips_through_an_encrypted_keyspace() {
469        let (ks, _dir) = temp_ks();
470        let ks = ks.with_encryption([0x5A; 32]);
471        assert!(
472            ks.is_encrypted(),
473            "the test must exercise the encrypted path"
474        );
475
476        let first = CursorKey::new(ks.clone()).get().await.expect("mint");
477        let second = CursorKey::new(ks).get().await.expect("read back");
478        assert_eq!(first, second, "the key must survive the encryption layer");
479        assert_ne!(first, [0u8; 32]);
480    }
481
482    /// Two keyspaces are two independent keys, so a cursor cannot be
483    /// replayed from one store against another.
484    #[tokio::test]
485    async fn cursor_keys_are_per_keyspace() {
486        let (ks_a, _a) = temp_ks();
487        let (ks_b, _b) = temp_ks();
488
489        let key_a = CursorKey::new(ks_a).get().await.expect("a");
490        let key_b = CursorKey::new(ks_b).get().await.expect("b");
491        assert_ne!(key_a, key_b);
492
493        let wire = Cursor::new(b"log:1:x".to_vec(), 1).encode(&key_a);
494        assert!(matches!(
495            Cursor::decode(&wire, &key_b),
496            Err(AppError::InvalidCursor)
497        ));
498    }
499
500    /// A bound cursor only decodes under the same binding. This is what
501    /// stops a filtered listing from being resumed under different
502    /// filters — a silent way to skip entries.
503    #[test]
504    fn a_bound_cursor_rejects_a_different_binding() {
505        let c = Cursor::new(b"audit:2026-01-01:abc".to_vec(), 7);
506        let wire = c.encode_bound(&KEY_A, b"action=MemberAdded");
507
508        assert_eq!(
509            Cursor::decode_bound(&wire, &KEY_A, b"action=MemberAdded").unwrap(),
510            c
511        );
512        assert!(matches!(
513            Cursor::decode_bound(&wire, &KEY_A, b"action=MemberRemoved"),
514            Err(AppError::InvalidCursor)
515        ));
516        // Dropping the binding entirely is also a change.
517        assert!(matches!(
518            Cursor::decode(&wire, &KEY_A),
519            Err(AppError::InvalidCursor)
520        ));
521        // And the key still has to match.
522        assert!(matches!(
523            Cursor::decode_bound(&wire, &KEY_B, b"action=MemberAdded"),
524            Err(AppError::InvalidCursor)
525        ));
526    }
527
528    /// Unbound encode/decode must stay byte-compatible with cursors
529    /// minted before binding existed, so other paginated endpoints
530    /// (e.g. policy list) are unaffected.
531    #[test]
532    fn an_unbound_cursor_round_trips_as_before() {
533        let c = Cursor::new(b"policy:001".to_vec(), 3);
534        let wire = c.encode(&KEY_A);
535        assert_eq!(Cursor::decode(&wire, &KEY_A).unwrap(), c);
536        assert_eq!(wire, c.encode_bound(&KEY_A, &[]));
537    }
538
539    fn make_pairs(count: usize) -> Vec<RawKvPair> {
540        (0..count)
541            .map(|i| {
542                (
543                    format!("item:{i:03}").into_bytes(),
544                    format!(r#"{{"n":{i}}}"#).into_bytes(),
545                )
546            })
547            .collect()
548    }
549
550    fn deserialize_value(bytes: &[u8]) -> Result<serde_json::Value, AppError> {
551        serde_json::from_slice(bytes)
552            .map_err(|e| AppError::Internal(format!("test value deserialize failed: {e}")))
553    }
554
555    // ──────────── Cursor encode/decode ────────────
556
557    #[test]
558    fn cursor_round_trips_through_encode_decode() {
559        let c = Cursor::new(b"member:did:key:z6Mk".to_vec(), 1_700_000_000);
560        let wire = c.encode(&KEY_A);
561        let back = Cursor::decode(&wire, &KEY_A).unwrap();
562        assert_eq!(back, c);
563    }
564
565    #[test]
566    fn cursor_decoded_with_different_key_is_rejected() {
567        let c = Cursor::new(b"x".to_vec(), 42);
568        let wire = c.encode(&KEY_A);
569        let err = Cursor::decode(&wire, &KEY_B).expect_err("must reject");
570        assert!(matches!(err, AppError::InvalidCursor));
571    }
572
573    /// End-to-end mapping: a cursor minted under one community's
574    /// audit key (or under a community's pre-rotation key) MUST
575    /// produce HTTP 400 when handed to a route running with a
576    /// different active key. The unit test above proves the
577    /// `Cursor::decode` arithmetic; this one nails the wire layer
578    /// by walking through the `IntoResponse` impl so a future
579    /// regression in `AppError`'s status mapping (or a misguided
580    /// "treat invalid cursor as empty page" refactor) fails here.
581    #[test]
582    fn foreign_audit_key_cursor_maps_to_http_400() {
583        use axum::response::IntoResponse;
584
585        let c = Cursor::new(b"member:did:key:zAttacker".to_vec(), 17);
586        let wire = c.encode(&KEY_A);
587        let err = Cursor::decode(&wire, &KEY_B).expect_err("must reject");
588        let response = err.into_response();
589        assert_eq!(response.status(), 400);
590    }
591
592    #[test]
593    fn cursor_with_tampered_payload_is_rejected() {
594        let c = Cursor::new(b"safe-key".to_vec(), 1);
595        let wire = c.encode(&KEY_A);
596        // Mutate one byte of the base64 form (not the trailing tag)
597        // and expect rejection.
598        let mut bytes = B64.decode(&wire).unwrap();
599        bytes[5] ^= 0xFF;
600        let tampered = B64.encode(&bytes);
601        let err = Cursor::decode(&tampered, &KEY_A).expect_err("tampered");
602        assert!(matches!(err, AppError::InvalidCursor));
603    }
604
605    #[test]
606    fn cursor_malformed_base64_is_rejected() {
607        for bad in ["", "not!base64", "AAAA"] {
608            let err = Cursor::decode(bad, &KEY_A).expect_err("malformed");
609            assert!(matches!(err, AppError::InvalidCursor), "input {bad}");
610        }
611    }
612
613    #[test]
614    fn cursor_with_oversized_key_length_is_rejected() {
615        // Construct a wire form claiming a huge last_key length.
616        let mut payload = Vec::new();
617        payload.extend_from_slice(&u32::MAX.to_be_bytes());
618        payload.extend_from_slice(&0u64.to_be_bytes());
619        let mut mac = HmacSha256::new_from_slice(&KEY_A).unwrap();
620        mac.update(&payload);
621        let tag = mac.finalize().into_bytes();
622        payload.extend_from_slice(&tag);
623        let wire = B64.encode(&payload);
624        let err = Cursor::decode(&wire, &KEY_A).expect_err("oversized");
625        assert!(matches!(err, AppError::InvalidCursor));
626    }
627
628    // ──────────── paginate ────────────
629
630    #[test]
631    fn paginate_first_page_no_cursor() {
632        let pairs = make_pairs(7);
633        let out: Paginated<serde_json::Value> =
634            paginate(pairs, None, 3, &KEY_A, 100, deserialize_value).unwrap();
635        assert_eq!(out.items.len(), 3);
636        assert_eq!(out.items[0]["n"], 0);
637        assert_eq!(out.items[2]["n"], 2);
638        assert!(out.next_cursor.is_some());
639    }
640
641    #[test]
642    fn paginate_walks_entire_collection_without_duplicates() {
643        let pairs = make_pairs(7);
644        let mut cursor: Option<Cursor> = None;
645        let mut seen = Vec::new();
646        for _ in 0..5 {
647            let out: Paginated<serde_json::Value> = paginate(
648                pairs.clone(),
649                cursor.as_ref(),
650                3,
651                &KEY_A,
652                100,
653                deserialize_value,
654            )
655            .unwrap();
656            for item in &out.items {
657                seen.push(item["n"].as_u64().unwrap());
658            }
659            match out.next_cursor {
660                Some(wire) => cursor = Some(Cursor::decode(&wire, &KEY_A).unwrap()),
661                None => break,
662            }
663        }
664        assert_eq!(seen, (0..7).collect::<Vec<_>>());
665    }
666
667    #[test]
668    fn paginate_last_page_returns_no_next_cursor() {
669        let pairs = make_pairs(3);
670        let out: Paginated<serde_json::Value> =
671            paginate(pairs, None, 10, &KEY_A, 100, deserialize_value).unwrap();
672        assert_eq!(out.items.len(), 3);
673        assert!(out.next_cursor.is_none(), "last page must not link onward");
674    }
675
676    #[test]
677    fn paginate_clamps_limit() {
678        let pairs = make_pairs(500);
679        let out: Paginated<serde_json::Value> =
680            paginate(pairs, None, 9_999, &KEY_A, 100, deserialize_value).unwrap();
681        assert_eq!(out.items.len(), MAX_LIMIT);
682    }
683
684    #[test]
685    fn paginate_skips_past_cursor_key_exclusive() {
686        let pairs = make_pairs(5);
687        let cursor = Cursor::new(b"item:001".to_vec(), 1);
688        let out: Paginated<serde_json::Value> =
689            paginate(pairs, Some(&cursor), 10, &KEY_A, 100, deserialize_value).unwrap();
690        // We saw item:000 + item:001 → next page begins at item:002.
691        let ns: Vec<_> = out.items.iter().map(|v| v["n"].as_u64().unwrap()).collect();
692        assert_eq!(ns, vec![2, 3, 4]);
693    }
694
695    #[test]
696    fn paginate_returns_empty_when_cursor_already_past_end() {
697        let pairs = make_pairs(3);
698        let cursor = Cursor::new(b"zzz".to_vec(), 1);
699        let out: Paginated<serde_json::Value> =
700            paginate(pairs, Some(&cursor), 10, &KEY_A, 100, deserialize_value).unwrap();
701        assert!(out.items.is_empty());
702        assert!(out.next_cursor.is_none());
703    }
704
705    #[test]
706    fn pagination_params_effective_limit_clamps_and_defaults() {
707        assert_eq!(
708            PaginationParams {
709                cursor: None,
710                limit: None,
711            }
712            .effective_limit(),
713            DEFAULT_LIMIT
714        );
715        assert_eq!(
716            PaginationParams {
717                cursor: None,
718                limit: Some(0),
719            }
720            .effective_limit(),
721            MIN_LIMIT
722        );
723        assert_eq!(
724            PaginationParams {
725                cursor: None,
726                limit: Some(9999),
727            }
728            .effective_limit(),
729            MAX_LIMIT
730        );
731        assert_eq!(
732            PaginationParams {
733                cursor: None,
734                limit: Some(50),
735            }
736            .effective_limit(),
737            50
738        );
739    }
740}