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}