test(backend): DB-backed tests for the risky SQL; test isolation; clippy cleanup

All 40 backend tests were pure-function tests — not one line of SQL ran under `cargo test`,
even though the riskiest code in the repo is SQL. Add 12 DB-backed `#[sqlx::test]` tests
(fresh throwaway DB per test, real migrations) pinning the invariants that code is
load-bearing for, each mutation-verified to fail on broken code:

  export_epoch.rs (8): epoch is post-increment on release (a worker born pre-increment is
    inert); epoch strictly monotonic across reopen; a retired-epoch worker's writes are
    no-ops; export_current is EXACTLY the invariant (8-case table); the ViewerOnly
    carry-forward carries a done ZIP forward AND matches nothing when the ZIP is unfinished
    (the af997a8 strand bug); boot recovery doesn't clobber a live ZIP.
  upload_concurrency.rs (4): the atomic quota UPDATE stops two stale-snapshot uploads from
    both committing (sequential AND concurrent-tx via EPQ); the FOR SHARE upload lock
    serializes against release, blocking a photo from committing after the export snapshot.

Deleting FOR SHARE, or the carry-forward's `status='done'`, each makes a test fail.

Test isolation: TRUNCATE now also resets disk_cache and sse_tickets. The stale disk
reading was harmless only while quotas were globally off in e2e (they no longer are — see
the new quota spec), i.e. two holes were masking each other.

clippy: `cargo clippy --all-targets -- -D warnings` now passes (it did not). Deleted the
dead jobs.rs (an unused BackgroundJob sketch) and unused model methods; `#[allow(dead_code)]`
+ comment on the sqlx FromRow field-sets that ARE populated by the DB. No SQL or logic
changed. `cargo test` requires DATABASE_URL by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-15 07:26:08 +02:00
parent c229b560d8
commit e5201a9889
18 changed files with 1075 additions and 57 deletions

View File

@@ -34,6 +34,17 @@ impl DiskCache {
}
}
/// Drop the cached reading so the next `snapshot()` re-measures the filesystem.
///
/// Used by the e2e TRUNCATE endpoint. Truncating deletes every uploaded file, which materially
/// changes free space — but the cached reading survives for up to the TTL, so the next test can
/// compute a quota from the PREVIOUS test's disk. That matters now that the quota tests steer
/// the per-user limit off `free_disk_bytes`: a stale reading makes the limit wrong and the test
/// flaky, for reasons that have nothing to do with the code under test.
pub fn invalidate(&self) {
*self.inner.write().unwrap() = None;
}
/// Cached `(total, free)` bytes for the filesystem that holds `media_path`.
///
/// Returns `None` when the mount can't be resolved — callers MUST treat that as
@@ -47,10 +58,11 @@ impl DiskCache {
/// caller to ask about a different volume (e.g. the exports volume, which is a separate mount)
/// would have silently got the wrong filesystem's free space.
pub fn snapshot(&self, path: &Path) -> Option<DiskInfo> {
if let Some((cached_path, info, at)) = self.inner.read().unwrap().as_ref() {
if cached_path == path && at.elapsed() < TTL {
return Some(*info);
}
if let Some((cached_path, info, at)) = self.inner.read().unwrap().as_ref()
&& cached_path == path
&& at.elapsed() < TTL
{
return Some(*info);
}
let info = read_disk_for_path(path)?;
*self.inner.write().unwrap() = Some((path.to_path_buf(), info, Instant::now()));

View File

@@ -389,6 +389,10 @@ async fn invalidate_missing_files(
/// start immediately.
pub const REGEN_DEBOUNCE: Duration = Duration::from_secs(20);
// Export worker entry point: every argument is state the spawned worker is BORN with (notably
// `epoch`). Bundling them into a struct would be a pure-refactor risk on the epoch logic for no
// gain, so the arity stands.
#[allow(clippy::too_many_arguments)]
pub fn spawn_export_jobs(
event_id: Uuid,
event_name: String,

View File

@@ -2,7 +2,6 @@ pub mod compression;
pub mod config;
pub mod disk;
pub mod export;
pub mod jobs;
pub mod maintenance;
pub mod rate_limiter;
pub mod sse_tickets;

View File

@@ -123,12 +123,58 @@ mod tests {
assert!(rl.check("k", 1, w), "the slot should expire once the window passes");
}
/// `retry_after` is not a "some number in range" — it is the time until the oldest slot
/// in the window frees up, and it is surfaced to clients as the backoff they sleep for
/// (see `upload-queue.ts`). Asserting only `(1..=60)` spans the entire reachable domain
/// of a 60s window, so a hardcoded `Err(1)` would satisfy it while telling every client
/// to hammer the server a second later. Pin the actual value.
#[test]
fn retry_after_is_between_one_and_window() {
fn retry_after_is_the_remaining_window() {
let rl = RateLimiter::new();
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
let retry = rl.check_with_retry("k", 1, MIN).unwrap_err();
assert!((1..=60).contains(&retry), "retry_after {retry} out of range");
// The slot was consumed just now, so essentially the whole window remains.
// `as_secs()` truncates the sub-second remainder, so a 30s window reports 29.
let w30 = Duration::from_secs(30);
assert!(rl.check_with_retry("a", 1, w30).is_ok());
let a = rl.check_with_retry("a", 1, w30).unwrap_err();
assert_eq!(a, 29, "retry_after must be the remaining window, got {a}");
// A different window must yield a different retry_after: no single constant can
// satisfy both this and the assertion above.
let w10 = Duration::from_secs(10);
assert!(rl.check_with_retry("b", 1, w10).is_ok());
let b = rl.check_with_retry("b", 1, w10).unwrap_err();
assert_eq!(b, 9, "retry_after must scale with the window, got {b}");
}
#[test]
fn retry_after_counts_down_as_the_window_elapses() {
let rl = RateLimiter::new();
let w = Duration::from_secs(30);
assert!(rl.check_with_retry("k", 1, w).is_ok());
let first = rl.check_with_retry("k", 1, w).unwrap_err();
std::thread::sleep(Duration::from_millis(1200));
let second = rl.check_with_retry("k", 1, w).unwrap_err();
// A client that waits 1.2s must be told to wait ~1.2s less — otherwise the advertised
// backoff is a constant, not a deadline.
let shaved = first - second;
assert!(
(1..=2).contains(&shaved),
"1.2s of waiting must shorten the advertised backoff by ~1s (got {first} then {second})"
);
}
#[test]
fn retry_after_floors_at_one_second() {
let rl = RateLimiter::new();
let w = Duration::from_millis(800);
assert!(rl.check_with_retry("k", 1, w).is_ok());
let retry = rl.check_with_retry("k", 1, w).unwrap_err();
// The sub-second remainder truncates to 0; clients must never be told "retry in 0s"
// (that's a busy-loop). The `.max(1)` floor is what prevents it.
assert_eq!(retry, 1, "a sub-second remainder must floor to 1, got {retry}");
}
#[test]
@@ -140,6 +186,59 @@ mod tests {
assert!(rl.check("k", 1, MIN), "clear() must free the window");
}
/// `prune()` is a memory-leak guard: without it a long-lived process keeps one HashMap
/// entry per IP that ever connected. Nothing in the public API observes the map size, so
/// the only way to catch a no-op body (`fn prune(&self) {}`) is to look at the map — the
/// tests module can see the private field.
#[test]
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.
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]);
// ...alongside a key that is still inside its window.
assert!(rl.check("live", 5, MIN));
assert_eq!(rl.windows.lock().unwrap().len(), 2);
rl.prune();
let map = rl.windows.lock().unwrap();
assert!(
!map.contains_key("stale"),
"prune() must drop keys whose timestamps have all expired"
);
assert!(
map.contains_key("live"),
"prune() must keep keys that still have live timestamps"
);
assert_eq!(map.len(), 1, "exactly one key should survive the prune");
}
#[test]
fn prune_does_not_reset_a_live_window() {
// The counterpart to the test above: pruning must reclaim memory, never quota. If
// prune() dropped live keys, every background sweep would hand attackers a fresh
// budget.
let rl = RateLimiter::new();
assert!(rl.check("k", 1, MIN));
assert!(!rl.check("k", 1, MIN));
rl.prune();
assert!(
!rl.check("k", 1, MIN),
"prune() must not clear a window that is still active"
);
}
#[test]
fn client_ip_takes_rightmost_forwarded_for_entry() {
// The right-most entry is the hop our trusted proxy (Caddy) appended.

View File

@@ -31,6 +31,13 @@ impl SseTicketStore {
}
}
/// Drop every outstanding ticket. Used by the e2e TRUNCATE endpoint: tickets are bound to a
/// session token hash, and TRUNCATE deletes the sessions out from under them, so anything left
/// here is a dangling reference to a user that no longer exists.
pub fn clear(&self) {
self.inner.lock().unwrap().clear();
}
/// 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();