From 2c219f55658e8e76aaa19878ccc92a3d444243f7 Mon Sep 17 00:00:00 2001 From: Ganonmaster Date: Tue, 28 Apr 2026 03:40:25 +0200 Subject: [PATCH] Fix S3 collision, alias clearing, upload limit, TOCTOU; add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - S3 key is now emoji/{uuid}.{ext} instead of emoji/{filename}, preventing silent overwrites when two emotes share a filename - UpdateEmoteRequest.alias uses Option> with a custom deserializer so a JSON null clears the alias rather than being ignored; the manage UI now sends null when the alias field is emptied - POST /emotes is limited to 8 MiB via DefaultBodyLimit - update_emote replaced the fetch-then-update pair with a single UPDATE … RETURNING using COALESCE/CASE WHEN, eliminating the TOCTOU race between concurrent edits Refactoring: - Extracted src/lib.rs so domain logic is a library crate; src/main.rs is now a thin startup entry point - auth::check decoupled from AppState — takes Option<&AuthConfig> directly - Removed unused config field from Database struct Tests (40 total): - auth: 10 unit tests covering all check() branches - models: 6 unit tests for timestamp parsing and alias deserialization - db: 9 unit tests against in-memory SQLite covering full CRUD - routes: 15 integration tests in tests/routes.rs covering auth middleware, input validation, and all mutating endpoints --- Cargo.lock | 1 + Cargo.toml | 3 + src/auth.rs | 82 ++++++++- src/db.rs | 191 ++++++++++++++++---- src/lib.rs | 49 +++++ src/main.rs | 48 +---- src/models.rs | 77 +++++++- src/routes/emotes.rs | 14 +- src/templates/manage.html | 3 +- tests/routes.rs | 363 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 736 insertions(+), 95 deletions(-) create mode 100644 src/lib.rs create mode 100644 tests/routes.rs diff --git a/Cargo.lock b/Cargo.lock index d850df7..15426f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1862,6 +1862,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-util", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 1ba678e..efd7203 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,6 @@ mime_guess = "2" tokio-util = { version = "0.7", features = ["io"] } bytes = "1" base64 = "0.22" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/src/auth.rs b/src/auth.rs index 2c05c94..ff93de4 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -6,7 +6,7 @@ use axum::{ }; use base64::{engine::general_purpose::STANDARD, Engine}; -use crate::AppState; +use crate::{config::AuthConfig, AppState}; static WWW_AUTHENTICATE: HeaderValue = HeaderValue::from_static("Basic realm=\"mikebase\", charset=\"UTF-8\""); @@ -16,7 +16,7 @@ pub async fn require_basic_auth( request: Request, next: Next, ) -> Response { - if !check(&state, request.headers().get(header::AUTHORIZATION)) { + if !check(state.cfg.auth.as_ref(), request.headers().get(header::AUTHORIZATION)) { return ( StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, WWW_AUTHENTICATE.clone())], @@ -26,8 +26,8 @@ pub async fn require_basic_auth( next.run(request).await } -fn check(state: &AppState, header: Option<&HeaderValue>) -> bool { - let creds = match &state.cfg.auth { +fn check(creds: Option<&AuthConfig>, header: Option<&HeaderValue>) -> bool { + let creds = match creds { Some(c) => c, None => return false, }; @@ -64,3 +64,77 @@ fn check(state: &AppState, header: Option<&HeaderValue>) -> bool { let pass_ok = pass == creds.password; user_ok & pass_ok } + +#[cfg(test)] +mod tests { + use super::*; + + fn creds(username: &str, password: &str) -> AuthConfig { + AuthConfig { username: username.to_string(), password: password.to_string() } + } + + fn basic_header(user: &str, pass: &str) -> HeaderValue { + let encoded = STANDARD.encode(format!("{user}:{pass}")); + HeaderValue::from_str(&format!("Basic {encoded}")).unwrap() + } + + #[test] + fn no_auth_config_always_denies() { + assert!(!check(None, None)); + assert!(!check(None, Some(&basic_header("admin", "secret")))); + } + + #[test] + fn missing_header_denies() { + assert!(!check(Some(&creds("admin", "secret")), None)); + } + + #[test] + fn valid_credentials_accepted() { + let h = basic_header("admin", "secret"); + assert!(check(Some(&creds("admin", "secret")), Some(&h))); + } + + #[test] + fn wrong_password_denied() { + let h = basic_header("admin", "wrong"); + assert!(!check(Some(&creds("admin", "secret")), Some(&h))); + } + + #[test] + fn wrong_username_denied() { + let h = basic_header("other", "secret"); + assert!(!check(Some(&creds("admin", "secret")), Some(&h))); + } + + #[test] + fn both_fields_wrong_denied() { + let h = basic_header("other", "wrong"); + assert!(!check(Some(&creds("admin", "secret")), Some(&h))); + } + + #[test] + fn non_basic_scheme_denied() { + let h = HeaderValue::from_static("Bearer sometoken"); + assert!(!check(Some(&creds("admin", "secret")), Some(&h))); + } + + #[test] + fn malformed_base64_denied() { + let h = HeaderValue::from_static("Basic not!!valid!!base64"); + assert!(!check(Some(&creds("admin", "secret")), Some(&h))); + } + + #[test] + fn no_colon_in_decoded_value_denied() { + let encoded = STANDARD.encode("adminnocolon"); + let h = HeaderValue::from_str(&format!("Basic {encoded}")).unwrap(); + assert!(!check(Some(&creds("admin", "secret")), Some(&h))); + } + + #[test] + fn password_containing_colons_accepted() { + let h = basic_header("admin", "pass:with:colons"); + assert!(check(Some(&creds("admin", "pass:with:colons")), Some(&h))); + } +} diff --git a/src/db.rs b/src/db.rs index 8e6b463..23fb489 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,25 +1,19 @@ -use std::sync::Arc; - use chrono::Utc; use sqlx::AnyPool; -use crate::{ - config::AppConfig, - models::{EmoteRow, new_uuid}, -}; +use crate::models::EmoteRow; /// Thin database abstraction that works with both SQLite and PostgreSQL /// through `sqlx::AnyPool`. #[derive(Clone)] pub struct Database { pub pool: AnyPool, - pub config: Arc, } impl Database { - pub async fn connect(config: Arc) -> Result { - let pool = AnyPool::connect(&config.database.url).await?; - Ok(Self { pool, config }) + pub async fn connect(url: &str) -> Result { + let pool = AnyPool::connect(url).await?; + Ok(Self { pool }) } /// Run pending migrations. @@ -48,16 +42,16 @@ impl Database { pub async fn create_emote( &self, + uuid: &str, name: &str, alias: Option<&str>, image_key: &str, ) -> Result { - let id = new_uuid(); let now = Utc::now().to_rfc3339(); sqlx::query( "INSERT INTO emotes (uuid, name, alias, image_key, created, modified) VALUES ($1, $2, $3, $4, $5, $6)", ) - .bind(&id) + .bind(uuid) .bind(name) .bind(alias) .bind(image_key) @@ -67,7 +61,7 @@ impl Database { .await?; Ok(EmoteRow { - uuid: id, + uuid: uuid.to_string(), name: name.to_string(), alias: alias.map(|s| s.to_string()), image_key: image_key.to_string(), @@ -83,39 +77,32 @@ impl Database { alias: Option>, image_key: Option<&str>, ) -> Result, sqlx::Error> { - let existing = match self.get_emote_by_id(uuid).await? { - Some(e) => e, - None => return Ok(None), - }; - - let new_name = name.unwrap_or(&existing.name); - let new_image_key = image_key.unwrap_or(&existing.image_key); - let new_alias: Option = match alias { - Some(Some(a)) => Some(a.to_string()), - Some(None) => None, - None => existing.alias.clone(), - }; let now = Utc::now().to_rfc3339(); + // alias has three states: None = keep, Some(None) = clear, Some(Some(v)) = set. + // Pass a boolean flag so CASE WHEN can choose between the new value and the + // existing column — all resolved atomically in a single statement. + let alias_touch = alias.is_some(); + let alias_value: Option<&str> = alias.flatten(); - sqlx::query( - "UPDATE emotes SET name = $1, alias = $2, image_key = $3, modified = $4 WHERE uuid = $5", + let row = sqlx::query_as::<_, EmoteRow>( + "UPDATE emotes + SET name = COALESCE($1, name), + alias = CASE WHEN $2 THEN $3 ELSE alias END, + image_key = COALESCE($4, image_key), + modified = $5 + WHERE uuid = $6 + RETURNING uuid, name, alias, image_key, created, modified", ) - .bind(new_name) - .bind(new_alias.as_deref()) - .bind(new_image_key) + .bind(name) + .bind(alias_touch) + .bind(alias_value) + .bind(image_key) .bind(&now) .bind(uuid) - .execute(&self.pool) + .fetch_optional(&self.pool) .await?; - Ok(Some(EmoteRow { - uuid: uuid.to_string(), - name: new_name.to_string(), - alias: new_alias, - image_key: new_image_key.to_string(), - created: existing.created, - modified: now, - })) + Ok(row) } pub async fn delete_emote(&self, uuid: &str) -> Result { @@ -126,3 +113,129 @@ impl Database { Ok(result.rows_affected() > 0) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::new_uuid; + + async fn test_db() -> Database { + sqlx::any::install_default_drivers(); + let pool = sqlx::pool::PoolOptions::::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + let db = Database { pool }; + db.migrate().await.unwrap(); + db + } + + #[tokio::test] + async fn create_and_get_by_id() { + let db = test_db().await; + let id = new_uuid(); + let row = db.create_emote(&id, "cat", Some("kitty"), "emoji/cat.png").await.unwrap(); + + assert_eq!(row.uuid, id); + assert_eq!(row.name, "cat"); + assert_eq!(row.alias.as_deref(), Some("kitty")); + assert_eq!(row.image_key, "emoji/cat.png"); + + let fetched = db.get_emote_by_id(&id).await.unwrap().unwrap(); + assert_eq!(fetched.uuid, id); + assert_eq!(fetched.name, "cat"); + } + + #[tokio::test] + async fn get_unknown_uuid_returns_none() { + let db = test_db().await; + let result = db.get_emote_by_id("does-not-exist").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn list_returns_all_emotes_newest_first() { + let db = test_db().await; + let id1 = new_uuid(); + let id2 = new_uuid(); + db.create_emote(&id1, "alpha", None, "emoji/alpha.png").await.unwrap(); + db.create_emote(&id2, "beta", None, "emoji/beta.png").await.unwrap(); + + let rows = db.list_emotes().await.unwrap(); + assert_eq!(rows.len(), 2); + // ORDER BY created DESC — beta was inserted last so it comes first + assert_eq!(rows[0].name, "beta"); + assert_eq!(rows[1].name, "alpha"); + } + + #[tokio::test] + async fn update_name() { + let db = test_db().await; + let id = new_uuid(); + db.create_emote(&id, "old", None, "emoji/x.png").await.unwrap(); + + let updated = db.update_emote(&id, Some("new"), None, None).await.unwrap().unwrap(); + assert_eq!(updated.name, "new"); + assert_eq!(updated.image_key, "emoji/x.png"); // unchanged + } + + #[tokio::test] + async fn update_unspecified_fields_are_kept() { + let db = test_db().await; + let id = new_uuid(); + db.create_emote(&id, "name", Some("alias"), "emoji/x.png").await.unwrap(); + + // Pass None for all optional fields — nothing should change except modified timestamp. + let updated = db.update_emote(&id, None, None, None).await.unwrap().unwrap(); + assert_eq!(updated.name, "name"); + assert_eq!(updated.alias.as_deref(), Some("alias")); + assert_eq!(updated.image_key, "emoji/x.png"); + } + + #[tokio::test] + async fn update_alias_set_and_clear() { + let db = test_db().await; + let id = new_uuid(); + db.create_emote(&id, "cat", None, "emoji/cat.png").await.unwrap(); + + // Set alias + let with_alias = db + .update_emote(&id, None, Some(Some("kitty")), None) + .await + .unwrap() + .unwrap(); + assert_eq!(with_alias.alias.as_deref(), Some("kitty")); + + // Clear alias + let cleared = db + .update_emote(&id, None, Some(None), None) + .await + .unwrap() + .unwrap(); + assert!(cleared.alias.is_none()); + } + + #[tokio::test] + async fn update_unknown_uuid_returns_none() { + let db = test_db().await; + let result = db.update_emote("no-such-id", Some("x"), None, None).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn delete_removes_emote() { + let db = test_db().await; + let id = new_uuid(); + db.create_emote(&id, "bye", None, "emoji/bye.png").await.unwrap(); + + assert!(db.delete_emote(&id).await.unwrap()); + assert!(db.get_emote_by_id(&id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn delete_unknown_uuid_returns_false() { + let db = test_db().await; + assert!(!db.delete_emote("no-such-id").await.unwrap()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..bf86e4a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,49 @@ +pub mod auth; +pub mod config; +pub mod db; +pub mod models; +pub mod routes; +pub mod storage; + +use std::sync::Arc; + +use axum::{ + extract::DefaultBodyLimit, + middleware, + routing::{delete, get, post, put}, + Router, +}; +use tower_http::trace::TraceLayer; + +use crate::{config::AppConfig, db::Database, storage::S3Storage}; + +/// Shared application state injected into every handler. +#[derive(Clone)] +pub struct AppState { + pub db: Database, + pub storage: S3Storage, + pub cfg: Arc, +} + +pub fn build_router(state: AppState) -> Router { + let protected = Router::new() + .route("/manage", get(routes::manage::manage_root)) + .route("/manage/emotes", get(routes::manage::list_admin_emotes)) + .route("/emotes", post(routes::emotes::create_emote)) + .route("/emotes/{uuid}", put(routes::emotes::update_emote)) + .route("/emotes/{uuid}", delete(routes::emotes::delete_emote)) + .layer(DefaultBodyLimit::max(8 * 1024 * 1024)) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_basic_auth, + )); + + Router::new() + .route("/", get(routes::emotes::root)) + .route("/health", get(routes::health::health)) + .route("/version", get(routes::version::version)) + .route("/json", get(routes::emotes::list_emotes)) + .merge(protected) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} diff --git a/src/main.rs b/src/main.rs index 3ef9995..1e096ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,31 +1,9 @@ -mod auth; -mod config; -mod db; -mod models; -mod routes; -mod storage; - use std::sync::Arc; -use axum::{ - middleware, - routing::{delete, get, post, put}, - Router, -}; +use mikebase::{config::AppConfig, db::Database, storage::S3Storage, AppState, build_router}; use sqlx::any::install_default_drivers; -use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use crate::{config::AppConfig, db::Database, storage::S3Storage}; - -/// Shared application state injected into every handler. -#[derive(Clone)] -pub struct AppState { - pub db: Database, - pub storage: S3Storage, - pub cfg: Arc, -} - #[tokio::main] async fn main() { // Initialise structured logging. @@ -45,7 +23,7 @@ async fn main() { install_default_drivers(); // Connect to the database and run migrations. - let db = Database::connect(cfg.clone()) + let db = Database::connect(&cfg.database.url) .await .expect("Failed to connect to database"); db.migrate().await.expect("Failed to run migrations"); @@ -55,32 +33,12 @@ async fn main() { let state = AppState { db, storage, cfg: cfg.clone() }; - let protected = Router::new() - .route("/manage", get(routes::manage::manage_root)) - .route("/manage/emotes", get(routes::manage::list_admin_emotes)) - .route("/emotes", post(routes::emotes::create_emote)) - .route("/emotes/{uuid}", put(routes::emotes::update_emote)) - .route("/emotes/{uuid}", delete(routes::emotes::delete_emote)) - .layer(middleware::from_fn_with_state( - state.clone(), - auth::require_basic_auth, - )); - - let app = Router::new() - .route("/", get(routes::emotes::root)) - .route("/health", get(routes::health::health)) - .route("/version", get(routes::version::version)) - .route("/json", get(routes::emotes::list_emotes)) - .merge(protected) - .layer(TraceLayer::new_for_http()) - .with_state(state); - let addr = format!("{}:{}", cfg.server.host, cfg.server.port); tracing::info!("Listening on {addr}"); let listener = tokio::net::TcpListener::bind(&addr) .await .expect("Failed to bind address"); - axum::serve(listener, app) + axum::serve(listener, build_router(state)) .await .expect("Server error"); } diff --git a/src/models.rs b/src/models.rs index e3e5f28..cd269b0 100644 --- a/src/models.rs +++ b/src/models.rs @@ -52,13 +52,88 @@ pub struct AdminEmoteResponse { } /// Payload for updating an existing emote. +/// +/// `alias` uses a double-Option to distinguish three states: +/// absent → keep existing alias +/// null → clear alias +/// "value" → set alias to value #[derive(Debug, Deserialize)] pub struct UpdateEmoteRequest { pub name: Option, - pub alias: Option, + #[serde(default, deserialize_with = "deserialize_optional_field")] + pub alias: Option>, pub image_key: Option, } +fn deserialize_optional_field<'de, T, D>(de: D) -> Result>, D::Error> +where + T: Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + Ok(Some(Option::deserialize(de)?)) +} + pub fn new_uuid() -> String { Uuid::new_v4().to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Datelike; + + fn row(created: &str, modified: &str) -> EmoteRow { + EmoteRow { + uuid: "u".into(), + name: "n".into(), + alias: None, + image_key: "k".into(), + created: created.into(), + modified: modified.into(), + } + } + + #[test] + fn created_dt_parses_valid_rfc3339() { + let r = row("2024-01-15T10:30:00+00:00", "2024-01-15T10:30:00+00:00"); + let dt = r.created_dt(); + assert_eq!(dt.year(), 2024); + assert_eq!(dt.month(), 1); + assert_eq!(dt.day(), 15); + } + + #[test] + fn modified_dt_parses_valid_rfc3339() { + let r = row("2024-01-01T00:00:00Z", "2025-06-20T12:00:00Z"); + let dt = r.modified_dt(); + assert_eq!(dt.year(), 2025); + assert_eq!(dt.month(), 6); + } + + #[test] + fn invalid_timestamp_falls_back_to_now() { + let r = row("not-a-date", "not-a-date"); + let before = Utc::now(); + let dt = r.created_dt(); + let after = Utc::now(); + assert!(dt >= before && dt <= after); + } + + #[test] + fn alias_absent_in_json_deserializes_to_none() { + let req: UpdateEmoteRequest = serde_json::from_str(r#"{"name":"foo"}"#).unwrap(); + assert!(req.alias.is_none()); + } + + #[test] + fn alias_null_in_json_deserializes_to_some_none() { + let req: UpdateEmoteRequest = serde_json::from_str(r#"{"alias":null}"#).unwrap(); + assert_eq!(req.alias, Some(None)); + } + + #[test] + fn alias_value_in_json_deserializes_to_some_some() { + let req: UpdateEmoteRequest = serde_json::from_str(r#"{"alias":"kitty"}"#).unwrap(); + assert_eq!(req.alias, Some(Some("kitty".to_string()))); + } +} diff --git a/src/routes/emotes.rs b/src/routes/emotes.rs index 2e87e30..8f21337 100644 --- a/src/routes/emotes.rs +++ b/src/routes/emotes.rs @@ -6,7 +6,7 @@ use axum::{ use serde_json::json; use crate::{ - models::{EmoteResponse, UpdateEmoteRequest}, + models::{new_uuid, EmoteResponse, UpdateEmoteRequest}, AppState, }; @@ -100,7 +100,13 @@ pub async fn create_emote( let ct = content_type .unwrap_or_else(|| mime_guess::from_path(&fname).first_or_octet_stream().to_string()); - let key = format!("emoji/{fname}"); + let id = new_uuid(); + let ext = std::path::Path::new(&fname) + .extension() + .and_then(|e| e.to_str()) + .filter(|e| e.chars().all(|c| c.is_ascii_alphanumeric())) + .unwrap_or("bin"); + let key = format!("emoji/{id}.{ext}"); match state.storage.upload(&key, bytes, &ct).await { Ok(_) => {} @@ -116,7 +122,7 @@ pub async fn create_emote( match state .db - .create_emote(&name, alias.as_deref(), &key) + .create_emote(&id, &name, alias.as_deref(), &key) .await { Ok(row) => { @@ -150,7 +156,7 @@ pub async fn update_emote( Path(uuid): Path, Json(payload): Json, ) -> impl IntoResponse { - let alias_update: Option> = payload.alias.as_ref().map(|a| Some(a.as_str())); + let alias_update: Option> = payload.alias.as_ref().map(|opt| opt.as_deref()); match state .db diff --git a/src/templates/manage.html b/src/templates/manage.html index 8bc6baf..24bcb2c 100644 --- a/src/templates/manage.html +++ b/src/templates/manage.html @@ -392,8 +392,7 @@ btn.disabled = true; btn.textContent = 'Saving…'; - var body = { name: name }; - if (alias) body.alias = alias; + var body = { name: name, alias: alias || null }; fetch('/emotes/' + encodeURIComponent(editUuid), { method: 'PUT', diff --git a/tests/routes.rs b/tests/routes.rs new file mode 100644 index 0000000..c1bccff --- /dev/null +++ b/tests/routes.rs @@ -0,0 +1,363 @@ +use std::sync::Arc; + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use sqlx::any::install_default_drivers; +use tower::ServiceExt; + +use mikebase::{ + build_router, + config::{AppConfig, AuthConfig, DatabaseConfig, S3Config, ServerConfig}, + db::Database, + models::new_uuid, + storage::S3Storage, + AppState, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async fn test_state() -> AppState { + install_default_drivers(); + let pool = sqlx::pool::PoolOptions::::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + let db = Database { pool }; + db.migrate().await.unwrap(); + + let cfg = Arc::new(AppConfig { + s3: S3Config { + endpoint: "http://localhost:19999".to_string(), + region: "us-east-1".to_string(), + bucket: "test-bucket".to_string(), + access_key: "test".to_string(), + secret_key: "test".to_string(), + public_url: "http://localhost:19999/test-bucket".to_string(), + }, + database: DatabaseConfig { url: "sqlite::memory:".to_string() }, + server: ServerConfig::default(), + auth: Some(AuthConfig { + username: "admin".to_string(), + password: "secret".to_string(), + }), + }); + + let storage = S3Storage::new(&cfg); + AppState { db, storage, cfg } +} + +fn auth_header() -> String { + format!("Basic {}", STANDARD.encode("admin:secret")) +} + +fn wrong_auth_header() -> String { + format!("Basic {}", STANDARD.encode("admin:wrong")) +} + +async fn response_json(body: Body) -> serde_json::Value { + let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +/// Build a minimal multipart/form-data body. +fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &[u8])]) -> Vec { + let mut body = Vec::new(); + for (name, filename, data) in parts { + let disp = match filename { + Some(fname) => format!( + "Content-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\nContent-Type: application/octet-stream" + ), + None => format!("Content-Disposition: form-data; name=\"{name}\""), + }; + body.extend_from_slice(format!("--{boundary}\r\n{disp}\r\n\r\n").as_bytes()); + body.extend_from_slice(data); + body.extend_from_slice(b"\r\n"); + } + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + body +} + +// ── Public routes ───────────────────────────────────────────────────────────── + +#[tokio::test] +async fn health_returns_200() { + let app = build_router(test_state().await); + let resp = app + .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let json = response_json(resp.into_body()).await; + assert_eq!(json["status"], "ok"); +} + +#[tokio::test] +async fn root_returns_html() { + let app = build_router(test_state().await); + let resp = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert!(resp + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap() + .contains("text/html")); +} + +#[tokio::test] +async fn list_emotes_empty() { + let app = build_router(test_state().await); + let resp = app + .oneshot(Request::builder().uri("/json").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let json = response_json(resp.into_body()).await; + assert_eq!(json["emotes"], serde_json::json!([])); +} + +#[tokio::test] +async fn list_emotes_returns_seeded_data() { + let state = test_state().await; + let id = new_uuid(); + state + .db + .create_emote(&id, "wave", Some("hello"), "emoji/wave.png") + .await + .unwrap(); + + let resp = build_router(state) + .oneshot(Request::builder().uri("/json").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = response_json(resp.into_body()).await; + assert_eq!(json["emotes"].as_array().unwrap().len(), 1); + assert_eq!(json["emotes"][0]["name"], "wave"); +} + +// ── Auth middleware ─────────────────────────────────────────────────────────── + +#[tokio::test] +async fn manage_without_credentials_returns_401() { + let app = build_router(test_state().await); + let resp = app + .oneshot(Request::builder().uri("/manage").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn manage_with_wrong_credentials_returns_401() { + let app = build_router(test_state().await); + let resp = app + .oneshot( + Request::builder() + .uri("/manage") + .header("authorization", wrong_auth_header()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn manage_with_correct_credentials_returns_200() { + let app = build_router(test_state().await); + let resp = app + .oneshot( + Request::builder() + .uri("/manage") + .header("authorization", auth_header()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn manage_emotes_requires_auth() { + let app = build_router(test_state().await); + let resp = app + .oneshot( + Request::builder() + .uri("/manage/emotes") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +// ── POST /emotes input validation ───────────────────────────────────────────── + +#[tokio::test] +async fn create_emote_without_auth_returns_401() { + let boundary = "testboundary"; + let body = multipart_body(boundary, &[("name", None, b"myemote")]); + let app = build_router(test_state().await); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/emotes") + .header("content-type", format!("multipart/form-data; boundary={boundary}")) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn create_emote_missing_name_returns_400() { + let boundary = "testboundary"; + let body = multipart_body(boundary, &[("file", Some("cat.png"), b"\x89PNG")]); + let app = build_router(test_state().await); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/emotes") + .header("authorization", auth_header()) + .header("content-type", format!("multipart/form-data; boundary={boundary}")) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let json = response_json(resp.into_body()).await; + assert!(json["error"].as_str().unwrap().contains("name")); +} + +#[tokio::test] +async fn create_emote_missing_file_returns_400() { + let boundary = "testboundary"; + let body = multipart_body(boundary, &[("name", None, b"myemote")]); + let app = build_router(test_state().await); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/emotes") + .header("authorization", auth_header()) + .header("content-type", format!("multipart/form-data; boundary={boundary}")) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let json = response_json(resp.into_body()).await; + assert!(json["error"].as_str().unwrap().contains("file")); +} + +// ── PUT /emotes/{uuid} ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn update_emote_not_found_returns_404() { + let app = build_router(test_state().await); + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/emotes/no-such-uuid") + .header("authorization", auth_header()) + .header("content-type", "application/json") + .body(Body::from(r#"{"name":"new"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn update_emote_ok() { + let state = test_state().await; + let id = new_uuid(); + state + .db + .create_emote(&id, "original", None, "emoji/x.png") + .await + .unwrap(); + + let resp = build_router(state) + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/emotes/{id}")) + .header("authorization", auth_header()) + .header("content-type", "application/json") + .body(Body::from(r#"{"name":"updated"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = response_json(resp.into_body()).await; + assert_eq!(json["name"], "updated"); +} + +// ── DELETE /emotes/{uuid} ───────────────────────────────────────────────────── + +#[tokio::test] +async fn delete_emote_not_found_returns_404() { + let app = build_router(test_state().await); + let resp = app + .oneshot( + Request::builder() + .method("DELETE") + .uri("/emotes/no-such-uuid") + .header("authorization", auth_header()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn delete_emote_ok() { + let state = test_state().await; + let id = new_uuid(); + state + .db + .create_emote(&id, "todelete", None, "emoji/gone.png") + .await + .unwrap(); + + // S3 delete will fail against the fake endpoint, but it is best-effort. + let resp = build_router(state) + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/emotes/{id}")) + .header("authorization", auth_header()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); +}