Fix S3 collision, alias clearing, upload limit, TOCTOU; add tests
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<Option<String>> 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
This commit is contained in:
Generated
+1
@@ -1862,6 +1862,7 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+78
-4
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppConfig>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn connect(config: Arc<AppConfig>) -> Result<Self, sqlx::Error> {
|
||||
let pool = AnyPool::connect(&config.database.url).await?;
|
||||
Ok(Self { pool, config })
|
||||
pub async fn connect(url: &str) -> Result<Self, sqlx::Error> {
|
||||
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<EmoteRow, sqlx::Error> {
|
||||
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<Option<&str>>,
|
||||
image_key: Option<&str>,
|
||||
) -> Result<Option<EmoteRow>, 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<String> = 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<bool, sqlx::Error> {
|
||||
@@ -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::<sqlx::Any>::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());
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -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<AppConfig>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
+3
-45
@@ -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<AppConfig>,
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
+76
-1
@@ -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<String>,
|
||||
pub alias: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_field")]
|
||||
pub alias: Option<Option<String>>,
|
||||
pub image_key: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_optional_field<'de, T, D>(de: D) -> Result<Option<Option<T>>, 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())));
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -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<String>,
|
||||
Json(payload): Json<UpdateEmoteRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let alias_update: Option<Option<&str>> = payload.alias.as_ref().map(|a| Some(a.as_str()));
|
||||
let alias_update: Option<Option<&str>> = payload.alias.as_ref().map(|opt| opt.as_deref());
|
||||
|
||||
match state
|
||||
.db
|
||||
|
||||
@@ -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',
|
||||
|
||||
+363
@@ -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::<sqlx::Any>::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<u8> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user