The suite was almost entirely e2e; pure logic had no direct coverage. Add fast, DB-free unit tests on both sides. Backend (cargo test — inline #[cfg(test)] modules): - rate_limiter: allow-up-to-max-then-block, per-key independence, sliding-window expiry, retry-after bounds, clear(), and client_ip X-Forwarded-For parsing (first entry / whitespace-trim / fallback). - sse_tickets: single-use consume (replay → None), unknown ticket, uniqueness + hex shape, prune keeps fresh tickets, and an expired ticket (injected past-TTL entry) consumes to None. - hashtag: fill the boundary gaps the 3 existing tests missed — stop-at-non-word, the 40-char cap (drops, doesn't truncate), no-dedup contract, and the German umlaut truncation limitation (pinned so a future Unicode fix is deliberate). Frontend (Vitest — new, standalone config that stubs $app/environment so server-safe module paths import cleanly under node): - avatar: avatarPalette (neutral for empty, deterministic, real palette entry) and initials (?, single word, two words, whitespace collapse). - data-mode-store: pickMediaUrl across original/saver modes and the preview→thumbnail→original fallback chain. - `npm run test:unit` script added. Backend: 20 passing. Frontend: 11 passing. svelte-check: 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
130 lines
4.2 KiB
Rust
130 lines
4.2 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use rand::Rng;
|
|
|
|
/// Short-lived single-use tickets that let `EventSource` clients open the SSE
|
|
/// stream without putting the JWT in the URL (where it would leak via access
|
|
/// logs / referer / browser history).
|
|
///
|
|
/// Flow: client `POST /api/v1/stream/ticket` with `Authorization: Bearer <jwt>`,
|
|
/// server returns an opaque ticket, client passes it as `?ticket=...` on the
|
|
/// stream open. Tickets are consumed on use and expire after `TTL`.
|
|
const TTL: Duration = Duration::from_secs(30);
|
|
|
|
#[derive(Clone)]
|
|
pub struct SseTicketStore {
|
|
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Entry {
|
|
token_hash: String,
|
|
issued_at: Instant,
|
|
}
|
|
|
|
impl SseTicketStore {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
inner: Arc::new(Mutex::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
/// Mint a new ticket bound to the caller's session (identified by token hash).
|
|
pub fn issue(&self, token_hash: String) -> String {
|
|
let ticket = random_ticket();
|
|
let mut map = self.inner.lock().unwrap();
|
|
map.insert(
|
|
ticket.clone(),
|
|
Entry {
|
|
token_hash,
|
|
issued_at: Instant::now(),
|
|
},
|
|
);
|
|
ticket
|
|
}
|
|
|
|
/// Consume a ticket. Returns `Some(token_hash)` if the ticket exists and is
|
|
/// not expired. Single-use: the ticket is removed regardless of whether it
|
|
/// was still fresh, so a replay can't slip through after expiry.
|
|
pub fn consume(&self, ticket: &str) -> Option<String> {
|
|
let mut map = self.inner.lock().unwrap();
|
|
let entry = map.remove(ticket)?;
|
|
if entry.issued_at.elapsed() > TTL {
|
|
return None;
|
|
}
|
|
Some(entry.token_hash)
|
|
}
|
|
|
|
/// Drop expired entries — called from the background maintenance task so a
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
fn random_ticket() -> String {
|
|
// 192 bits of randomness, base32-ish hex. Plenty of entropy and URL-safe.
|
|
let mut rng = rand::rng();
|
|
let mut bytes = [0u8; 24];
|
|
rng.fill(&mut bytes);
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn issue_then_consume_returns_the_hash_exactly_once() {
|
|
let store = SseTicketStore::new();
|
|
let ticket = store.issue("hash-1".into());
|
|
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
|
|
// Single-use: a replay of the same ticket is rejected.
|
|
assert_eq!(store.consume(&ticket), None, "a consumed ticket must not be reusable");
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_ticket_consumes_to_none() {
|
|
let store = SseTicketStore::new();
|
|
assert_eq!(store.consume("never-issued"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn issued_tickets_are_unique_and_hex() {
|
|
let store = SseTicketStore::new();
|
|
let a = store.issue("h".into());
|
|
let b = store.issue("h".into());
|
|
assert_ne!(a, b, "each ticket must be unique");
|
|
assert_eq!(a.len(), 48, "24 random bytes → 48 hex chars");
|
|
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_ticket_survives_prune() {
|
|
let store = SseTicketStore::new();
|
|
let ticket = store.issue("h".into());
|
|
store.prune(); // not expired → kept
|
|
assert_eq!(store.consume(&ticket).as_deref(), Some("h"));
|
|
}
|
|
|
|
#[test]
|
|
fn expired_ticket_consumes_to_none() {
|
|
// Construct an entry that is already past the TTL and confirm consume() rejects it.
|
|
let store = SseTicketStore::new();
|
|
let stale = "stale-ticket".to_string();
|
|
store.inner.lock().unwrap().insert(
|
|
stale.clone(),
|
|
Entry {
|
|
token_hash: "h".into(),
|
|
issued_at: Instant::now()
|
|
.checked_sub(TTL + Duration::from_secs(1))
|
|
.expect("host uptime should exceed the ticket TTL"),
|
|
},
|
|
);
|
|
assert_eq!(store.consume(&stale), None, "an expired ticket must not authenticate");
|
|
}
|
|
}
|