fix(backend): JWT jti, NUL-byte guard, dev-only truncate endpoint
Two bugs surfaced while running the new E2E suite, plus a small test hook: - jwt.rs: add a per-token `jti: Uuid` claim. Without it, two `create_token` calls in the same wall-clock second for the same (sub, role, event_id) produced identical JWT bytes — and identical sha256(token) hashes — which then collided on `session.token_hash UNIQUE` with a 500. Manifests in real use when an admin clicks "Anmelden" twice fast. - auth/handlers.rs: reject display names containing 0x00. Postgres rejects NUL in TEXT columns with `invalid byte sequence for encoding "UTF8"` and the request leaks back as a 500. Now returns 400 with a clean message. - handlers/test_admin.rs + main.rs: new POST /api/v1/admin/__truncate route, compiled in always but only **registered** when EVENTSNAP_TEST_MODE=1 is set on startup. Truncates every event-scoped table, reseeds config from migration defaults, wipes media on disk, and clears the in-memory rate limiter. RequireAdmin-gated so it's not anonymous even in test mode. In production builds (no env var) the route returns 404 — verified by the startup log message. - services/rate_limiter.rs: add `clear()` so the truncate handler can wipe the in-memory window map between tests. - Dockerfile: bump rust:1.87 → rust:1.88 (current dep tree needs it) and COPY ./migrations into the build context so the `sqlx::migrate!()` macro can resolve at compile time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
85
backend/src/handlers/test_admin.rs
Normal file
85
backend/src/handlers/test_admin.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! 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 from migration defaults. 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 — mirrors migrations 005 and 009. Kept in sync by hand
|
||||
// because pulling SQL out of the migration files at runtime is fragile.
|
||||
sqlx::query(
|
||||
r#"INSERT INTO config (key, value) VALUES
|
||||
('max_image_size_mb', '20'),
|
||||
('max_video_size_mb', '500'),
|
||||
('upload_rate_per_hour', '10'),
|
||||
('feed_rate_per_min', '60'),
|
||||
('export_rate_per_day', '3'),
|
||||
('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'),
|
||||
('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;
|
||||
|
||||
// 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();
|
||||
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user