Files
mikebase/tests/routes.rs
T
Ganonmaster 2c219f5565 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
2026-04-28 11:31:25 +02:00

364 lines
12 KiB
Rust

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);
}