All three are the same mistake in different clothes: a limit keyed on an IP that, behind the venue's NAT, is the entire party plus the host. * join_ip_rate_per_min was raised 60 -> 300 last round and it never took effect. A config default is only a fallback for a MISSING key, and migration 017 seeds this one, so the seed won and the raise was dead code on every real install. Migration 030 raises the seeded value the way 015 already did for upload_rate_per_hour. The e2e guard could not see this: it fires 12 concurrent joins, which is green at 60 and at 300 alike. * /recover's per-(IP, name) bucket charged EVERY request, including successful ones, and refused before verifying the PIN. Its ceiling clamps to 4. So four POSTs naming "Braut Sophie" with PIN 0000, from any phone on the venue wifi, locked Sophie out of her own recovery for fifteen minutes WITH THE CORRECT PIN — and four more every fifteen minutes sustained it indefinitely, at a rate far under every volume ceiling above it. The benign version needs no attacker: the host mistypes their own PIN four times. Hosts are promoted guests whose only credential is that PIN, and /recover is their only way back after losing a session. Now it counts failures, and a spent budget changes what a FAILURE answers instead of refusing outright. Guessing is bounded exactly as before — wrong PINs are what spend it — with the per-account lockout underneath. * /admin/login's pre-verify ceiling had the same shape, and the escape hatch was circular: admin_login_rate_enabled is only flippable through PATCH /admin/config, which needs the session being refused. One phone posting twice a minute cost the operator moderation, gallery release and every config key, including the ones that would undo it. Exceeding the ceiling now shortens the hash-permit wait rather than refusing: the CPU bound was always the semaphore, never this bucket, so a flood still sheds itself while a correct password gets a truthful answer. Adds a regression test that reads the value a fresh database actually ends up with, by replaying the migrations — the drift that made the first bullet invisible is not otherwise detectable from the code.
140 lines
6.8 KiB
Rust
140 lines
6.8 KiB
Rust
//! Test-only admin routes. **Compiled in always, but only registered when
|
|
//! `EVENTSNAP_TEST_MODE=1` is set in the environment.** The route returns a hard
|
|
//! 404 in production builds because [`crate::main`] skips registering the handler.
|
|
//!
|
|
//! These exist to give the Playwright E2E suite a quick "reset everything"
|
|
//! escape hatch without forcing tests to maintain raw SQL fixtures or spin up a
|
|
//! fresh database container per test.
|
|
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
|
|
use crate::auth::middleware::RequireAdmin;
|
|
use crate::error::AppError;
|
|
use crate::state::AppState;
|
|
|
|
/// Truncates every event-scoped table, wipes media on disk, and reseeds the `config`
|
|
/// table: numeric values from the migration defaults, but every feature toggle forced
|
|
/// OFF (production seeds them ON — see the note at the reseed below). Requires an admin
|
|
/// JWT — even with `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously.
|
|
pub async fn truncate_all(
|
|
State(state): State<AppState>,
|
|
RequireAdmin(_auth): RequireAdmin,
|
|
) -> Result<StatusCode, AppError> {
|
|
// Truncate in dependency order doesn't matter with CASCADE, but listing the
|
|
// tables explicitly makes the blast radius obvious in code review.
|
|
sqlx::query(
|
|
r#"TRUNCATE
|
|
comment_hashtag,
|
|
upload_hashtag,
|
|
hashtag,
|
|
"like",
|
|
comment,
|
|
export_job,
|
|
upload,
|
|
session,
|
|
"user",
|
|
event,
|
|
config
|
|
RESTART IDENTITY CASCADE"#,
|
|
)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
// Reseed config. The NUMERIC values mirror migrations 005/015/016/017/019; the BOOLEAN
|
|
// toggles deliberately do NOT — migration 009 seeds every one of them `true`
|
|
// (production), and this forces them `false` so the suite isn't fighting rate limits
|
|
// and quotas it isn't testing.
|
|
//
|
|
// Be aware of what that costs: this runs as an auto-fixture before EVERY test, so no
|
|
// test starts from production's config unless it explicitly turns a toggle back on
|
|
// (02-upload/rate-limit, 07-adversarial/ddos, 01-auth/rate-limit-nat, …). That blind
|
|
// spot is exactly why an entire class of per-IP limiter bugs went unnoticed: the
|
|
// limiters were simply off. When adding a limiter or quota, add a spec that enables it.
|
|
//
|
|
// Kept in sync by hand because pulling SQL out of the migration files at runtime is
|
|
// fragile — if you add a config key in a migration, add it here too.
|
|
sqlx::query(
|
|
r#"INSERT INTO config (key, value) VALUES
|
|
('max_image_size_mb', '20'),
|
|
('max_video_size_mb', '500'),
|
|
('upload_rate_per_hour', '100'),
|
|
('feed_rate_per_min', '60'),
|
|
('export_rate_per_day', '3'),
|
|
('join_ip_rate_per_min', '300'),
|
|
('recover_ip_rate_per_min', '30'),
|
|
('social_rate_per_min', '120'),
|
|
('quota_tolerance', '0.75'),
|
|
('estimated_guest_count', '100'),
|
|
('compression_concurrency', '2'),
|
|
('rate_limits_enabled', 'false'),
|
|
('upload_rate_enabled', 'false'),
|
|
('feed_rate_enabled', 'false'),
|
|
('export_rate_enabled', 'false'),
|
|
('join_rate_enabled', 'false'),
|
|
('social_rate_enabled', 'false'),
|
|
('admin_login_rate_enabled', 'false'),
|
|
('quota_enabled', 'false'),
|
|
('storage_quota_enabled', 'false'),
|
|
('upload_count_quota_enabled', 'false'),
|
|
('privacy_note', '')
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"#,
|
|
)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
// Wipe media directory. Best-effort: if it doesn't exist, that's fine.
|
|
let _ = tokio::fs::remove_dir_all(&state.config.media_path).await;
|
|
let _ = tokio::fs::create_dir_all(&state.config.media_path).await;
|
|
|
|
// Wipe the export directory too. Exports moved OUT of media_path (CR2 fix), so
|
|
// the media wipe above no longer covers them — without this a real export in
|
|
// one test would leave Gallery.zip on disk and contaminate the next.
|
|
let _ = tokio::fs::remove_dir_all(&state.config.export_path).await;
|
|
let _ = tokio::fs::create_dir_all(&state.config.export_path).await;
|
|
|
|
// The rate limiter holds an in-memory HashMap; clear it so a previous test's
|
|
// counters don't leak into the next one.
|
|
state.rate_limiter.clear();
|
|
|
|
// The reseed above wrote the `config` table directly (bypassing patch_config), so
|
|
// the cache must be invalidated too — otherwise the first request after a truncate
|
|
// could serve the previous test's toggles.
|
|
state.config_cache.invalidate();
|
|
|
|
// The other two in-memory singletons that TRUNCATE used to leave standing.
|
|
//
|
|
// `disk_cache` holds a free-space reading for up to its TTL. TRUNCATE has just deleted every
|
|
// uploaded file, which materially changes free space — so without this the next test can
|
|
// compute a storage quota from the PREVIOUS test's disk. That was harmless only while quotas
|
|
// were globally disabled in e2e (they no longer are: see specs/02-upload/quota.spec.ts, which
|
|
// steers the per-user limit off `free_disk_bytes`), i.e. two holes were masking each other.
|
|
state.disk_cache.invalidate();
|
|
|
|
// `media_total` caches SUM(user.total_upload_bytes) for the upload gate's keepsake-headroom
|
|
// check. TRUNCATE has just zeroed every one of those rows, so a surviving reading would make
|
|
// the next test's first upload measure its headroom against the previous test's gallery —
|
|
// and that gate REFUSES uploads, so the failure would look like a spurious quota rejection.
|
|
state.media_total.invalidate();
|
|
|
|
// `sse_tickets` maps a ticket to a session token hash. TRUNCATE deletes the sessions, so every
|
|
// surviving ticket is a dangling reference to a user that no longer exists.
|
|
state.sse_tickets.clear();
|
|
|
|
// Invalidate any in-flight/queued compression task spawned by the previous test. Without this a
|
|
// task still waiting on the concurrency semaphore wakes AFTER this wipe, fails to find its
|
|
// (now-deleted) file, and broadcasts upload-error/upload-deleted into the NEXT test's SSE
|
|
// stream. (Export workers are already inert across a truncate: they are epoch-guarded on the
|
|
// event row, and truncate gives the event a fresh random UUID, so their writes match nothing.)
|
|
state.compression.bump_generation();
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// Returns whether the truncate endpoint is enabled. Used by the e2e harness
|
|
/// during global-setup to fail loud if the test backend was started without
|
|
/// `EVENTSNAP_TEST_MODE=1`.
|
|
pub fn is_test_mode() -> bool {
|
|
std::env::var("EVENTSNAP_TEST_MODE").as_deref() == Ok("1")
|
|
}
|