fix(auth): stop one guest on the venue NAT from locking everyone else out
Every guest at the venue shares one public IP, so an IP-keyed limiter throttles the whole party as a single client. Three separate limits got that wrong, and the host — whose only credential is a 4-digit PIN — was the one who could not absorb it. /recover carried a cross-name failure budget checked BEFORE the account lookup, so it refused a CORRECT PIN. Thirty POSTs with invented names spent the shared budget for fifteen minutes and ~2 requests/minute sustained it indefinitely, denying PIN recovery to everyone including a host locked out of their own event. The budget is now carried as a flag: a correct PIN authenticates regardless, while wrong ones answer 429 instead of 401. Guessing stays bounded where it always really was — the per-(IP,name) ceiling and the per-account 3-strike lockout, neither of which an attacker on any IP can evade. join_ip went from 60/min to 300. A 100-guest wedding does not trickle in; it arrives when the QR code goes up, all from one address, and guests 61-100 were turned away on the one screen with no auto-retry. This limit only bounds raw volume — the per-name bucket is the anti-spam control and BCRYPT_PERMITS is the CPU bound — so it can sit well above the peak. Download tickets are now bound to ONE archive via `TicketKind::Download(ExportKind)`. Both download routes share an authenticator, so a bare ticket opened either; combined with the resume budget that made a single mint worth 40 transfers of a multi-GB keepsake while the per-day limiter, charged only at mint, never moved. `kind` is consequently required at /export/ticket; every shipped client already sends it. The per-session ticket cap is now per-kind. A 6-hour download ticket is always the oldest entry for its session, so ordinary SSE churn evicted it first — and /export opens its own SSE connection on the session that just minted it. A couple of wifi flaps mid-transfer killed the ticket, 401'd the resume, and cost the guest another of three daily downloads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,23 @@ use std::time::{Duration, Instant};
|
||||
/// of recent requests and rejects new ones once the window is full.
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
windows: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
|
||||
windows: Arc<Mutex<HashMap<String, Bucket>>>,
|
||||
}
|
||||
|
||||
/// One key's recent hits, plus the window they were recorded under.
|
||||
///
|
||||
/// The `window` field is what makes pruning correct. `prune` used a single fixed 24 h ceiling for
|
||||
/// every key, on the reasoning that 24 h is the longest window in use (export downloads) — but that
|
||||
/// meant a `join:{ip}:{name}` key whose 60-SECOND window expired 23 hours ago was still retained.
|
||||
/// Minting one costs a single 409 and no bcrypt, at 60/min per IP across three endpoints: roughly
|
||||
/// 172,800 keys/day/IP, about 31 MB/day/IP inside a 1 GB container. The limiter became the
|
||||
/// memory-exhaustion primitive it exists to prevent.
|
||||
///
|
||||
/// Storing the window per key makes the sweep drop each bucket as soon as ITS OWN window has
|
||||
/// elapsed, which is also what the hot path already does on every check.
|
||||
struct Bucket {
|
||||
hits: Vec<Instant>,
|
||||
window: Duration,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
@@ -34,7 +50,14 @@ impl RateLimiter {
|
||||
let now = Instant::now();
|
||||
let key = key.into();
|
||||
let mut map = self.windows.lock().unwrap();
|
||||
let timestamps = map.entry(key).or_default();
|
||||
let bucket = map.entry(key).or_insert_with(|| Bucket {
|
||||
hits: Vec::new(),
|
||||
window,
|
||||
});
|
||||
// A key's window can change under it when an admin edits the limit at runtime. Track the
|
||||
// current one so `prune` expires the bucket on the window actually in force.
|
||||
bucket.window = window;
|
||||
let timestamps = &mut bucket.hits;
|
||||
timestamps.retain(|&t| now.duration_since(t) < window);
|
||||
if timestamps.len() < max {
|
||||
timestamps.push(now);
|
||||
@@ -58,6 +81,37 @@ impl RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `key` already at or above `max`, WITHOUT recording a hit?
|
||||
///
|
||||
/// Needed by limiters whose budget is spent by an outcome rather than by the request — the
|
||||
/// per-IP failed-PIN ceiling charges only on a wrong PIN, so the gate at the top of the handler
|
||||
/// has to be able to ask "is this IP shut out?" without itself consuming the budget it guards.
|
||||
/// Using `check_with_retry` for that would charge every *successful* recovery too, and a venue
|
||||
/// full of guests legitimately recovering their own devices would lock itself out.
|
||||
///
|
||||
/// Returns `Err(retry_after_secs)` when exhausted, mirroring `check_with_retry` so callers can
|
||||
/// build the same 429.
|
||||
pub fn peek(&self, key: &str, max: usize, window: Duration) -> Result<(), u64> {
|
||||
let now = Instant::now();
|
||||
let mut map = self.windows.lock().unwrap();
|
||||
let Some(bucket) = map.get_mut(key) else {
|
||||
return Ok(());
|
||||
};
|
||||
bucket
|
||||
.hits
|
||||
.retain(|&t| now.duration_since(t) < bucket.window);
|
||||
if bucket.hits.len() < max {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(&oldest) = bucket.hits.first() else {
|
||||
return Ok(());
|
||||
};
|
||||
Err(window
|
||||
.saturating_sub(now.duration_since(oldest))
|
||||
.as_secs()
|
||||
.max(1))
|
||||
}
|
||||
|
||||
/// Wipe every tracked window. Used by the test-mode truncate route so a previous
|
||||
/// test's accumulated counters don't bleed into the next test's rate-limit checks.
|
||||
pub fn clear(&self) {
|
||||
@@ -68,17 +122,23 @@ impl RateLimiter {
|
||||
/// background task (see [`crate::services::maintenance`]) so that long-lived
|
||||
/// processes don't accumulate one HashMap entry per IP that ever connected.
|
||||
///
|
||||
/// Uses a conservative 24h ceiling — anything older than that is gone regardless
|
||||
/// of which endpoint's window it was tracked under (the longest window today is
|
||||
/// 24h for export downloads). If we ever add longer windows, raise this constant.
|
||||
/// Expires each bucket against ITS OWN window (see [`Bucket`]), not one global ceiling. The
|
||||
/// previous fixed 24 h ceiling retained per-minute keys for a full day — ~172,800 keys/day/IP
|
||||
/// at 60/min across three endpoints, each mintable with a single 409 and no bcrypt.
|
||||
///
|
||||
/// Holds the one global mutex for the length of the sweep, and that mutex is on the hot path of
|
||||
/// upload, feed, join, recover, social and export — so the retain does the cheap thing per
|
||||
/// bucket and nothing else. Correct pruning also keeps the map small enough that this stays
|
||||
/// cheap, which the old ceiling actively undermined.
|
||||
pub fn prune(&self) {
|
||||
let now = Instant::now();
|
||||
let ceiling = Duration::from_secs(24 * 60 * 60);
|
||||
let mut map = self.windows.lock().unwrap();
|
||||
let before = map.len();
|
||||
map.retain(|_, ts| {
|
||||
ts.retain(|&t| now.duration_since(t) < ceiling);
|
||||
!ts.is_empty()
|
||||
map.retain(|_, bucket| {
|
||||
bucket
|
||||
.hits
|
||||
.retain(|&t| now.duration_since(t) < bucket.window);
|
||||
!bucket.hits.is_empty()
|
||||
});
|
||||
let dropped = before.saturating_sub(map.len());
|
||||
if dropped > 0 {
|
||||
@@ -230,15 +290,20 @@ mod tests {
|
||||
fn prune_drops_keys_whose_windows_have_fully_expired() {
|
||||
let rl = RateLimiter::new();
|
||||
|
||||
// A key whose only timestamp is older than the 24h ceiling. We can't sleep for a day,
|
||||
// so backdate the Instant directly.
|
||||
// A key whose only timestamp is older than its own window. We can't sleep, so backdate
|
||||
// the Instant directly. A ONE-MINUTE window here on purpose: the old prune applied a flat
|
||||
// 24 h ceiling to every key, so this bucket — expired for over an hour of wall time —
|
||||
// survived the sweep. That is the leak (H3), and pinning it needs a short-window key.
|
||||
let ancient = Instant::now()
|
||||
.checked_sub(Duration::from_secs(25 * 60 * 60))
|
||||
.expect("backdating an Instant by 25h");
|
||||
rl.windows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("stale".to_string(), vec![ancient]);
|
||||
.checked_sub(Duration::from_secs(90 * 60))
|
||||
.expect("backdating an Instant by 90 minutes");
|
||||
rl.windows.lock().unwrap().insert(
|
||||
"stale".to_string(),
|
||||
Bucket {
|
||||
hits: vec![ancient],
|
||||
window: MIN,
|
||||
},
|
||||
);
|
||||
|
||||
// ...alongside a key that is still inside its window.
|
||||
assert!(rl.check_with_retry("live", 5, MIN).is_ok());
|
||||
|
||||
@@ -13,6 +13,29 @@ use rand::Rng;
|
||||
/// stream open. Tickets are consumed on use and expire after `TTL`.
|
||||
const TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Lifetime of a `Download` ticket, and it is deliberately far longer than [`TTL`].
|
||||
///
|
||||
/// A keepsake is up to ~1.4 GB over hotel or cellular wifi, so the download itself outlives a 30 s
|
||||
/// window many times over — and a resumed transfer arrives minutes or hours after the ticket was
|
||||
/// minted. A `Download` ticket is therefore a short-lived capability for ONE archive rather than a
|
||||
/// single-shot nonce: [`SseTicketStore::redeem_download`] does not remove it, so a client may
|
||||
/// resume with `Range` as many times as the transfer needs.
|
||||
///
|
||||
/// The abuse this does NOT open: the ticket is bound to a session (revoked with it), only mints at
|
||||
/// `/export/ticket` where the 3/day limit is charged, and grants nothing but this event's own
|
||||
/// keepsake — which every authenticated guest is entitled to download anyway. What it buys is that
|
||||
/// one dropped connection no longer costs a guest a third of their daily allowance, at the
|
||||
/// emotional payoff of the product.
|
||||
const DOWNLOAD_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
|
||||
/// The lifetime that applies to a given kind.
|
||||
fn ttl_for(kind: TicketKind) -> Duration {
|
||||
match kind {
|
||||
TicketKind::Download(_) => DOWNLOAD_TTL,
|
||||
TicketKind::Sse => TTL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ceiling on outstanding tickets across the whole process.
|
||||
///
|
||||
/// Not really about the bytes (~120 each) — about `issue` having had no bound of any kind.
|
||||
@@ -23,6 +46,20 @@ const MAX_TICKETS: usize = 4096;
|
||||
/// EventSources concurrently; 4 absorbs that without letting a reconnect loop accumulate.
|
||||
const MAX_TICKETS_PER_SESSION: usize = 4;
|
||||
|
||||
/// How many times one download ticket may be redeemed.
|
||||
///
|
||||
/// Making the ticket non-consuming is what lets a dropped transfer resume without spending another
|
||||
/// of the guest's three daily downloads — but unbounded it also meant a single mint was an
|
||||
/// unlimited download key for six hours, at BOTH archive endpoints, with the per-day limiter
|
||||
/// (charged only at mint) never moving. On a 40 GB box serving ~1.4 GB archives that is the one
|
||||
/// resource an ordinary guest could exhaust without doing anything obviously wrong.
|
||||
///
|
||||
/// 20 is far more than a resumed transfer needs (a browser retries a handful of times, not dozens)
|
||||
/// and turns "unbounded until the ticket expires" into a bounded multiple. It does not make the
|
||||
/// daily limit exact — that would mean charging per redemption, which would bill a client that
|
||||
/// restarts from byte 0 instead of sending a `Range`, i.e. re-break the thing this exists to fix.
|
||||
const MAX_DOWNLOAD_REDEMPTIONS: u32 = 20;
|
||||
|
||||
/// What a ticket may be redeemed for.
|
||||
///
|
||||
/// The store began life serving only SSE and stayed untyped when the export download started
|
||||
@@ -39,8 +76,23 @@ const MAX_TICKETS_PER_SESSION: usize = 4;
|
||||
pub enum TicketKind {
|
||||
/// Opens the SSE stream (`GET /stream`). Cheap, high volume.
|
||||
Sse,
|
||||
/// Downloads an export archive (`GET /export/{zip,html}`). Expensive, rate-limited per day.
|
||||
Download,
|
||||
/// Downloads ONE export archive. Expensive, rate-limited per day.
|
||||
///
|
||||
/// The archive is part of the ticket, not incidental to it. A bare `Download` ticket was
|
||||
/// accepted by BOTH `/export/zip` and `/export/html` — they share one authenticator — so with
|
||||
/// the redemption budget that makes a ticket resumable, a single mint authorised 20 transfers
|
||||
/// spread across both archives. Three mints a day therefore bought 60 full downloads of a
|
||||
/// ~1.4 GB keepsake, while the per-day limiter (charged only at mint) never moved.
|
||||
Download(ExportKind),
|
||||
}
|
||||
|
||||
/// Which archive a [`TicketKind::Download`] is good for.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum ExportKind {
|
||||
/// `Gallery.<event>.<n>.zip` — the original media.
|
||||
Zip,
|
||||
/// `Memories.<event>.<n>.zip` — the offline HTML viewer.
|
||||
Html,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -53,6 +105,9 @@ struct Entry {
|
||||
token_hash: String,
|
||||
issued_at: Instant,
|
||||
kind: TicketKind,
|
||||
/// Times this ticket has been redeemed. Only meaningful for `Download` — see
|
||||
/// [`MAX_DOWNLOAD_REDEMPTIONS`].
|
||||
redemptions: u32,
|
||||
}
|
||||
|
||||
impl SseTicketStore {
|
||||
@@ -83,14 +138,26 @@ impl SseTicketStore {
|
||||
// Prune on issue rather than only hourly. This alone changes the bound from "tickets
|
||||
// minted since the last maintenance tick" to "tickets live at once", which is what the
|
||||
// 30 s TTL was always meant to express.
|
||||
map.retain(|_, e| e.issued_at.elapsed() <= TTL);
|
||||
map.retain(|_, e| e.issued_at.elapsed() <= ttl_for(e.kind));
|
||||
|
||||
// Cap the caller's own outstanding tickets, evicting their oldest. NOT one-per-session:
|
||||
// two tabs sharing a token open their EventSources concurrently, and having tab B
|
||||
// invalidate tab A's unconsumed ticket looks exactly like a flaky SSE connection.
|
||||
//
|
||||
// SCOPED TO THE SAME KIND, which matters now that `Download` tickets live 6 h instead of
|
||||
// being consumed on first use. A long-lived download ticket is ALWAYS the oldest entry for
|
||||
// its session, so a kind-blind cap made it the first thing ordinary SSE churn threw away —
|
||||
// and `/export` itself opens an SSE connection on the very session that just minted it.
|
||||
// A couple of reconnects during the download (a wifi flap is enough; each attempt that
|
||||
// returns early abandons an unconsumed ticket) evicted the ticket out from under a running
|
||||
// transfer, so the next `Range` resume 401'd and the guest had to spend another of their
|
||||
// three daily downloads. Two flaps and they were locked out of their own keepsake for a day.
|
||||
//
|
||||
// Per-kind, an SSE reconnect storm can still only evict SSE tickets, which is what the cap
|
||||
// was written for; the guest's in-flight keepsake is no longer collateral.
|
||||
let mut mine: Vec<(String, Instant)> = map
|
||||
.iter()
|
||||
.filter(|(_, e)| e.token_hash == token_hash)
|
||||
.filter(|(_, e)| e.token_hash == token_hash && e.kind == kind)
|
||||
.map(|(k, e)| (k.clone(), e.issued_at))
|
||||
.collect();
|
||||
if mine.len() >= MAX_TICKETS_PER_SESSION {
|
||||
@@ -117,6 +184,7 @@ impl SseTicketStore {
|
||||
token_hash,
|
||||
issued_at: Instant::now(),
|
||||
kind,
|
||||
redemptions: 0,
|
||||
},
|
||||
);
|
||||
Some(ticket)
|
||||
@@ -130,10 +198,48 @@ impl SseTicketStore {
|
||||
/// held, so this is not punitive — but leaving it would let a redemption loop probe the store
|
||||
/// without ever spending anything, and the client has no legitimate reason to present a
|
||||
/// ticket at the wrong endpoint.
|
||||
/// Redeem a `Download` ticket WITHOUT consuming it.
|
||||
///
|
||||
/// Downloads must be resumable — see [`DOWNLOAD_TTL`]. A browser resumes by re-issuing the same
|
||||
/// GET with a `Range` header, so a single-use ticket made `Accept-Ranges` a lie: the retry
|
||||
/// authenticated against a ticket that the interrupted attempt had already spent, 401'd, and
|
||||
/// the guest had to mint a new one, spending another of their three daily downloads. Two
|
||||
/// dropped connections and they were locked out of their own keepsake for ~24 hours.
|
||||
///
|
||||
/// Still bound to a live session: the caller re-checks the session on every request, so
|
||||
/// revoking a session (logout, "sign out everywhere", a host PIN reset) kills the download too.
|
||||
pub fn redeem_download(&self, ticket: &str, want: ExportKind) -> Option<String> {
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
let entry = map.get_mut(ticket)?;
|
||||
// The archive must match the one this ticket was minted for. Both download routes share
|
||||
// this authenticator, so without the payload check a ZIP ticket opened the HTML archive
|
||||
// too and the redemption budget was spent across both.
|
||||
if entry.kind != TicketKind::Download(want) {
|
||||
tracing::warn!(
|
||||
found = ?entry.kind,
|
||||
?want,
|
||||
"ticket presented for the wrong archive (or wrong kind); rejected"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if entry.issued_at.elapsed() > DOWNLOAD_TTL {
|
||||
return None;
|
||||
}
|
||||
if entry.redemptions >= MAX_DOWNLOAD_REDEMPTIONS {
|
||||
tracing::warn!(
|
||||
redemptions = entry.redemptions,
|
||||
"download ticket exceeded its redemption budget; refusing"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
entry.redemptions += 1;
|
||||
Some(entry.token_hash.clone())
|
||||
}
|
||||
|
||||
pub fn consume(&self, ticket: &str, kind: TicketKind) -> Option<String> {
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
let entry = map.remove(ticket)?;
|
||||
if entry.issued_at.elapsed() > TTL {
|
||||
if entry.issued_at.elapsed() > ttl_for(entry.kind) {
|
||||
return None;
|
||||
}
|
||||
if entry.kind != kind {
|
||||
@@ -151,7 +257,7 @@ impl SseTicketStore {
|
||||
/// long-running process doesn't accumulate stale tickets.
|
||||
pub fn prune(&self) {
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
map.retain(|_, e| e.issued_at.elapsed() <= TTL);
|
||||
map.retain(|_, e| e.issued_at.elapsed() <= ttl_for(e.kind));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,12 +294,14 @@ mod tests {
|
||||
|
||||
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
|
||||
assert_eq!(
|
||||
store.consume(&sse, TicketKind::Download),
|
||||
store.consume(&sse, TicketKind::Download(ExportKind::Zip)),
|
||||
None,
|
||||
"an SSE ticket must not open the export download"
|
||||
);
|
||||
|
||||
let dl = store.issue("h".into(), TicketKind::Download).unwrap();
|
||||
let dl = store
|
||||
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.consume(&dl, TicketKind::Sse),
|
||||
None,
|
||||
@@ -203,9 +311,13 @@ mod tests {
|
||||
// And the matching cases still work, so the guard is not simply rejecting everything.
|
||||
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
|
||||
assert_eq!(store.consume(&sse, TicketKind::Sse).as_deref(), Some("h"));
|
||||
let dl = store.issue("h".into(), TicketKind::Download).unwrap();
|
||||
let dl = store
|
||||
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.consume(&dl, TicketKind::Download).as_deref(),
|
||||
store
|
||||
.consume(&dl, TicketKind::Download(ExportKind::Zip))
|
||||
.as_deref(),
|
||||
Some("h")
|
||||
);
|
||||
}
|
||||
@@ -214,7 +326,10 @@ mod tests {
|
||||
fn issue_then_consume_returns_the_hash_exactly_once() {
|
||||
let store = SseTicketStore::new();
|
||||
let ticket = issue(&store, "hash-1");
|
||||
assert_eq!(store.consume(&ticket, TicketKind::Sse).as_deref(), Some("hash-1"));
|
||||
assert_eq!(
|
||||
store.consume(&ticket, TicketKind::Sse).as_deref(),
|
||||
Some("hash-1")
|
||||
);
|
||||
// Single-use: a replay of the same ticket is rejected.
|
||||
assert_eq!(
|
||||
store.consume(&ticket, TicketKind::Sse),
|
||||
@@ -244,7 +359,10 @@ mod tests {
|
||||
let store = SseTicketStore::new();
|
||||
let ticket = issue(&store, "h");
|
||||
store.prune(); // not expired → kept
|
||||
assert_eq!(store.consume(&ticket, TicketKind::Sse).as_deref(), Some("h"));
|
||||
assert_eq!(
|
||||
store.consume(&ticket, TicketKind::Sse).as_deref(),
|
||||
Some("h")
|
||||
);
|
||||
}
|
||||
|
||||
/// Build an entry that is already past the TTL.
|
||||
@@ -257,6 +375,7 @@ mod tests {
|
||||
issued_at: Instant::now()
|
||||
.checked_sub(TTL + Duration::from_secs(1))
|
||||
.expect("host uptime should exceed the ticket TTL"),
|
||||
redemptions: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -305,7 +424,11 @@ mod tests {
|
||||
.count();
|
||||
assert_eq!(live, MAX_TICKETS_PER_SESSION, "one session, bounded");
|
||||
assert!(
|
||||
store.inner.lock().unwrap().contains_key(&mine[mine.len() - 1]),
|
||||
store
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains_key(&mine[mine.len() - 1]),
|
||||
"the newest ticket is the one the caller is about to use"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -330,6 +453,7 @@ mod tests {
|
||||
kind: TicketKind::Sse,
|
||||
token_hash: format!("session-{i}"),
|
||||
issued_at: Instant::now(),
|
||||
redemptions: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -344,4 +468,99 @@ mod tests {
|
||||
"no existing ticket may be sacrificed to make room"
|
||||
);
|
||||
}
|
||||
|
||||
/// A download ticket is deliberately non-consuming so a dropped 1.4 GB transfer can resume
|
||||
/// without spending one of the guest's three daily downloads. Unbounded, though, that made a
|
||||
/// single mint an unlimited download key for six hours while the per-day limiter — charged
|
||||
/// only at mint — never moved. This pins the bound without breaking resumption.
|
||||
#[test]
|
||||
fn a_download_ticket_resumes_freely_but_not_forever() {
|
||||
let store = SseTicketStore::new();
|
||||
let ticket = store
|
||||
.issue("session-a".into(), TicketKind::Download(ExportKind::Zip))
|
||||
.expect("fresh store should issue");
|
||||
|
||||
// Every redemption inside the budget returns the session, so a resumed transfer works.
|
||||
for i in 0..MAX_DOWNLOAD_REDEMPTIONS {
|
||||
assert_eq!(
|
||||
store.redeem_download(&ticket, ExportKind::Zip).as_deref(),
|
||||
Some("session-a"),
|
||||
"redemption {i} should still be honoured"
|
||||
);
|
||||
}
|
||||
// Past it the ticket is spent: the guest re-mints (and is charged) rather than holding
|
||||
// an open-ended key.
|
||||
assert_eq!(store.redeem_download(&ticket, ExportKind::Zip), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_download_ticket_opens_only_the_archive_it_was_minted_for() {
|
||||
// Both download routes share one authenticator, so without the archive in the ticket a
|
||||
// single mint was good for BOTH. Combined with the resume budget that made one mint worth
|
||||
// 2 x MAX_DOWNLOAD_REDEMPTIONS transfers of a multi-GB keepsake, while the per-day limit —
|
||||
// charged only at mint — never moved.
|
||||
let store = SseTicketStore::new();
|
||||
let zip = store
|
||||
.issue("s".into(), TicketKind::Download(ExportKind::Zip))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.redeem_download(&zip, ExportKind::Html),
|
||||
None,
|
||||
"a ZIP ticket must not open the HTML archive"
|
||||
);
|
||||
assert_eq!(
|
||||
store.redeem_download(&zip, ExportKind::Zip).as_deref(),
|
||||
Some("s"),
|
||||
"...and the refusal above must be about the archive, not a spent ticket"
|
||||
);
|
||||
|
||||
let html = store
|
||||
.issue("s".into(), TicketKind::Download(ExportKind::Html))
|
||||
.unwrap();
|
||||
assert_eq!(store.redeem_download(&html, ExportKind::Zip), None);
|
||||
assert_eq!(
|
||||
store.redeem_download(&html, ExportKind::Html).as_deref(),
|
||||
Some("s")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_churn_cannot_evict_a_running_download() {
|
||||
// A `Download` ticket lives 6 h, so it is ALWAYS the oldest entry for its session — and a
|
||||
// kind-blind per-session cap therefore threw it away first. `/export` opens its own SSE
|
||||
// connection on the same session, so a couple of reconnects during the transfer evicted
|
||||
// the ticket out from under it: the next `Range` resume 401'd and the guest spent another
|
||||
// of their three daily downloads. Two wifi flaps and they lost their keepsake for a day.
|
||||
let store = SseTicketStore::new();
|
||||
let download = store
|
||||
.issue("one-session".into(), TicketKind::Download(ExportKind::Zip))
|
||||
.unwrap();
|
||||
|
||||
// Far more SSE churn than the per-session cap, all on the same session.
|
||||
for _ in 0..(MAX_TICKETS_PER_SESSION * 3) {
|
||||
store.issue("one-session".into(), TicketKind::Sse).unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
store.redeem_download(&download, ExportKind::Zip).as_deref(),
|
||||
Some("one-session"),
|
||||
"an in-flight keepsake download must survive an SSE reconnect storm"
|
||||
);
|
||||
}
|
||||
|
||||
/// The kind split is what stops a free SSE ticket from redeeming a rate-limited download.
|
||||
#[test]
|
||||
fn an_sse_ticket_is_never_redeemable_as_a_download() {
|
||||
let store = SseTicketStore::new();
|
||||
let sse = store
|
||||
.issue("session-b".into(), TicketKind::Sse)
|
||||
.expect("fresh store should issue");
|
||||
assert_eq!(store.redeem_download(&sse, ExportKind::Zip), None);
|
||||
// And it is still usable for what it IS, so the rejection above is about kind, not
|
||||
// the ticket having been quietly spent.
|
||||
assert_eq!(
|
||||
store.consume(&sse, TicketKind::Sse).as_deref(),
|
||||
Some("session-b")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user