//! 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, RequireAdmin(_auth): RequireAdmin, ) -> Result { // 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', '60'), ('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(); // `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") }