The chapter upload handler read every `page` part fully into a Vec before
writing any, so peak memory was the whole chapter (bounded only by the
200 MiB body limit and amplified by concurrent uploads). It also accepted
an unbounded number of pages.
Stream each page part to a `staging/{upload_id}/…` key as it arrives — at
most one page's bytes are held at a time — then, once the chapter row (and
its id) exists, promote each staged blob to its final key via a new
`Storage::rename` (LocalStorage: fs rename; default impl: stream+delete for
future backends). Finalization is all-or-nothing: on any failure the DB
rolls back and both staged and already-finalized blobs are cleaned up.
Add MAX_PAGES_PER_CHAPTER (UploadConfig, default 2000, 0 = disabled),
rejecting an over-cap upload with 413 before any DB write. Also document
the crawler-side CRAWLER_MAX_IMAGES_PER_CHAPTER (added earlier) in
.env.example + docker-compose so the env-coverage test passes.
Tests: LocalStorage rename unit tests; a 413 over-cap upload test; existing
rollback + happy-path upload tests still green (the fault-injecting storage
counts put/put_stream, so mid-upload failure still rolls back).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
793 lines
26 KiB
Rust
793 lines
26 KiB
Rust
// Shared test helpers. Each integration test binary picks the subset it needs,
|
|
// so dead-code lints on the unused helpers fire per-binary; suppress at the
|
|
// module level.
|
|
#![allow(dead_code)]
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{header, Request};
|
|
use axum::Router;
|
|
use http_body_util::BodyExt;
|
|
use serde_json::json;
|
|
use sqlx::PgPool;
|
|
use tempfile::TempDir;
|
|
use tower::ServiceExt;
|
|
|
|
use mangalord::app::{router, AppState, RuntimeControls};
|
|
use mangalord::auth::rate_limit::AuthRateLimiter;
|
|
use mangalord::config::{AnalysisConfig, AuthConfig, CrawlerConfig, UploadConfig};
|
|
use mangalord::storage::{LocalStorage, PutByteStream, Storage, StorageError, StreamingFile};
|
|
|
|
use async_trait::async_trait;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
/// The CSRF `Origin` value the default test harness allowlists and the
|
|
/// cookie-bearing helpers auto-attach. Keeping it a const here means every
|
|
/// non-CSRF-focused test inherits the matching pair without thinking about
|
|
/// it; the CSRF-focused tests use `post_json_with_cookie_origin` to drive
|
|
/// specific Origin/Referer combinations.
|
|
pub const TEST_ORIGIN: &str = "http://test";
|
|
|
|
pub struct Harness {
|
|
pub app: Router,
|
|
// Kept alive for the lifetime of the test so the temp dir is not dropped.
|
|
pub _storage_dir: TempDir,
|
|
}
|
|
|
|
pub fn harness(pool: PgPool) -> Harness {
|
|
let storage_dir = tempfile::tempdir().expect("tempdir");
|
|
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
|
harness_inner(pool, storage, storage_dir)
|
|
}
|
|
|
|
/// Variant of `harness` that swaps in a `Storage` that errors on the
|
|
/// `fail_on_put_index`-th `put` call (0-indexed). Used to exercise the
|
|
/// upload handlers' transactional rollback path without resorting to
|
|
/// fault injection at lower layers.
|
|
pub fn harness_with_failing_storage(pool: PgPool, fail_on_put_index: usize) -> Harness {
|
|
let storage_dir = tempfile::tempdir().expect("tempdir");
|
|
let inner = LocalStorage::new(storage_dir.path());
|
|
let storage = Arc::new(FailingStorage::new(inner, fail_on_put_index));
|
|
harness_inner(pool, storage, storage_dir)
|
|
}
|
|
|
|
fn harness_inner(
|
|
pool: PgPool,
|
|
storage: Arc<dyn Storage>,
|
|
storage_dir: TempDir,
|
|
) -> Harness {
|
|
harness_with_auth_config(pool, storage, storage_dir, AuthConfig {
|
|
cookie_secure: false,
|
|
..AuthConfig::default()
|
|
})
|
|
}
|
|
|
|
fn harness_with_auth_config(
|
|
pool: PgPool,
|
|
storage: Arc<dyn Storage>,
|
|
storage_dir: TempDir,
|
|
auth: AuthConfig,
|
|
) -> Harness {
|
|
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
|
|
let state = AppState {
|
|
db: pool,
|
|
storage,
|
|
auth,
|
|
upload: UploadConfig {
|
|
// Keep file caps small in tests so the size-cap path is cheap to
|
|
// exercise without producing tens of MBs of bytes.
|
|
max_request_bytes: 4 * 1024 * 1024,
|
|
max_file_bytes: 256 * 1024,
|
|
max_pages_per_chapter: 2000,
|
|
},
|
|
auth_limiter,
|
|
// Default harness has no crawler daemon wired up; admin resync
|
|
// handlers return 503 in this config. Tests that need a stub
|
|
// resync service swap it in via `harness_with_resync`. No reloader,
|
|
// so settings still persist but spawn no daemon.
|
|
runtime: Arc::new(RuntimeControls::new(false)),
|
|
reloader: None,
|
|
crawler_base: CrawlerConfig::default(),
|
|
analysis_base: AnalysisConfig::default(),
|
|
// The router's CSRF guard now fails-closed on cookie-auth POSTs
|
|
// when the allowlist is empty. Seed the default harness with the
|
|
// sentinel test origin (`TEST_ORIGIN`) so the cookie-auth helpers
|
|
// below — which auto-attach `Origin: TEST_ORIGIN` — pass through.
|
|
// The CSRF-specific tests override via
|
|
// `post_json_with_cookie_origin` and `harness_with_admin_origins`.
|
|
admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
|
|
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
|
};
|
|
Harness { app: router(state), _storage_dir: storage_dir }
|
|
}
|
|
|
|
/// Like [`harness`] but flips `ALLOW_SELF_REGISTER` off so the
|
|
/// register-disabled test exercises the 403 branch in
|
|
/// `api::auth::register`.
|
|
pub fn harness_with_self_register_disabled(pool: PgPool) -> Harness {
|
|
let storage_dir = tempfile::tempdir().expect("tempdir");
|
|
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
|
let auth = AuthConfig {
|
|
cookie_secure: false,
|
|
allow_self_register: false,
|
|
..AuthConfig::default()
|
|
};
|
|
harness_with_auth_config(pool, storage, storage_dir, auth)
|
|
}
|
|
|
|
/// Like [`harness`] but flips `PRIVATE_MODE` on so the site-wide auth
|
|
/// gate is exercised. `allow_self_register` stays at its default `true`
|
|
/// to verify that private mode force-disables self-registration on top
|
|
/// of whatever `ALLOW_SELF_REGISTER` says.
|
|
pub fn harness_with_private_mode(pool: PgPool) -> Harness {
|
|
let storage_dir = tempfile::tempdir().expect("tempdir");
|
|
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
|
let auth = AuthConfig {
|
|
cookie_secure: false,
|
|
private_mode: true,
|
|
..AuthConfig::default()
|
|
};
|
|
harness_with_auth_config(pool, storage, storage_dir, auth)
|
|
}
|
|
|
|
/// Like [`harness`] but configures a tight auth rate limit. Used by
|
|
/// the brute-force-rate-limiting test.
|
|
pub fn harness_with_auth_rate_limit(
|
|
pool: PgPool,
|
|
per_sec: u32,
|
|
burst: u32,
|
|
) -> Harness {
|
|
let storage_dir = tempfile::tempdir().expect("tempdir");
|
|
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
|
let auth = AuthConfig {
|
|
cookie_secure: false,
|
|
rate_limit: mangalord::auth::rate_limit::RateLimitConfig { per_sec, burst },
|
|
..AuthConfig::default()
|
|
};
|
|
harness_with_auth_config(pool, storage, storage_dir, auth)
|
|
}
|
|
|
|
/// Like [`harness`] but slots a caller-supplied [`ResyncService`] stub
|
|
/// into `AppState.resync`. Used by the admin resync tests so the
|
|
/// endpoint path is exercised without standing up a real Chromium.
|
|
pub fn harness_with_resync(
|
|
pool: PgPool,
|
|
resync: Arc<dyn mangalord::crawler::resync::ResyncService>,
|
|
) -> Harness {
|
|
let storage_dir = tempfile::tempdir().expect("tempdir");
|
|
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
|
let auth = AuthConfig {
|
|
cookie_secure: false,
|
|
..AuthConfig::default()
|
|
};
|
|
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
|
|
let runtime = Arc::new(RuntimeControls::new(false));
|
|
runtime.set_resync(Some(resync));
|
|
let state = AppState {
|
|
db: pool,
|
|
storage,
|
|
auth,
|
|
upload: UploadConfig {
|
|
max_request_bytes: 4 * 1024 * 1024,
|
|
max_file_bytes: 256 * 1024,
|
|
max_pages_per_chapter: 2000,
|
|
},
|
|
auth_limiter,
|
|
runtime,
|
|
reloader: None,
|
|
crawler_base: CrawlerConfig::default(),
|
|
analysis_base: AnalysisConfig::default(),
|
|
admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
|
|
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
|
};
|
|
Harness {
|
|
app: router(state),
|
|
_storage_dir: storage_dir,
|
|
}
|
|
}
|
|
|
|
/// Like [`harness`] but flips `analysis_enabled` on so the page-create
|
|
/// paths enqueue `analyze_page` jobs and the admin analysis endpoints are
|
|
/// active (rather than returning 503).
|
|
pub fn harness_with_analysis(pool: PgPool) -> Harness {
|
|
let storage_dir = tempfile::tempdir().expect("tempdir");
|
|
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
|
let auth = AuthConfig {
|
|
cookie_secure: false,
|
|
..AuthConfig::default()
|
|
};
|
|
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
|
|
let state = AppState {
|
|
db: pool,
|
|
storage,
|
|
auth,
|
|
upload: UploadConfig {
|
|
max_request_bytes: 4 * 1024 * 1024,
|
|
max_file_bytes: 256 * 1024,
|
|
max_pages_per_chapter: 2000,
|
|
},
|
|
auth_limiter,
|
|
runtime: Arc::new(RuntimeControls::new(true)),
|
|
reloader: None,
|
|
crawler_base: CrawlerConfig::default(),
|
|
analysis_base: AnalysisConfig::default(),
|
|
admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
|
|
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
|
};
|
|
Harness {
|
|
app: router(state),
|
|
_storage_dir: storage_dir,
|
|
}
|
|
}
|
|
|
|
/// Like [`harness`] but with a low `max_pages_per_chapter` so the chapter
|
|
/// upload page-count cap is cheap to exercise.
|
|
pub fn harness_with_page_cap(pool: PgPool, max_pages_per_chapter: usize) -> Harness {
|
|
let storage_dir = tempfile::tempdir().expect("tempdir");
|
|
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
|
let auth = AuthConfig {
|
|
cookie_secure: false,
|
|
..AuthConfig::default()
|
|
};
|
|
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
|
|
let state = AppState {
|
|
db: pool,
|
|
storage,
|
|
auth,
|
|
upload: UploadConfig {
|
|
max_request_bytes: 4 * 1024 * 1024,
|
|
max_file_bytes: 256 * 1024,
|
|
max_pages_per_chapter,
|
|
},
|
|
auth_limiter,
|
|
runtime: Arc::new(RuntimeControls::new(false)),
|
|
reloader: None,
|
|
crawler_base: CrawlerConfig::default(),
|
|
analysis_base: AnalysisConfig::default(),
|
|
admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
|
|
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
|
};
|
|
Harness {
|
|
app: router(state),
|
|
_storage_dir: storage_dir,
|
|
}
|
|
}
|
|
|
|
/// A [`DaemonReloader`] stub that records the configs it was asked to apply
|
|
/// and flips the shared analysis gate, without spawning any real daemon. Lets
|
|
/// settings tests assert that a `PUT` triggers a reload with the converted
|
|
/// config (and that the analysis enable gate moves).
|
|
pub struct StubReloader {
|
|
pub runtime: Arc<RuntimeControls>,
|
|
pub crawler: std::sync::Mutex<Option<CrawlerConfig>>,
|
|
pub analysis: std::sync::Mutex<Option<AnalysisConfig>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl mangalord::app::DaemonReloader for StubReloader {
|
|
async fn reload_crawler(&self, cfg: CrawlerConfig) -> anyhow::Result<()> {
|
|
*self.crawler.lock().unwrap() = Some(cfg);
|
|
Ok(())
|
|
}
|
|
async fn reload_analysis(&self, cfg: AnalysisConfig) -> anyhow::Result<()> {
|
|
self.runtime.set_analysis_enabled(cfg.enabled);
|
|
*self.analysis.lock().unwrap() = Some(cfg);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Like [`harness`] but wires a [`StubReloader`] so the settings `PUT`
|
|
/// endpoints exercise the reload path. Returns the harness plus the shared
|
|
/// stub so the test can inspect what was applied.
|
|
pub fn harness_with_settings_reloader(pool: PgPool) -> (Harness, Arc<StubReloader>) {
|
|
let storage_dir = tempfile::tempdir().expect("tempdir");
|
|
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
|
let auth = AuthConfig {
|
|
cookie_secure: false,
|
|
..AuthConfig::default()
|
|
};
|
|
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
|
|
let runtime = Arc::new(RuntimeControls::new(false));
|
|
let reloader = Arc::new(StubReloader {
|
|
runtime: Arc::clone(&runtime),
|
|
crawler: std::sync::Mutex::new(None),
|
|
analysis: std::sync::Mutex::new(None),
|
|
});
|
|
let state = AppState {
|
|
db: pool,
|
|
storage,
|
|
auth,
|
|
upload: UploadConfig {
|
|
max_request_bytes: 4 * 1024 * 1024,
|
|
max_file_bytes: 256 * 1024,
|
|
max_pages_per_chapter: 2000,
|
|
},
|
|
auth_limiter,
|
|
runtime,
|
|
reloader: Some(Arc::clone(&reloader) as Arc<dyn mangalord::app::DaemonReloader>),
|
|
crawler_base: CrawlerConfig::default(),
|
|
analysis_base: AnalysisConfig::default(),
|
|
admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
|
|
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
|
};
|
|
(
|
|
Harness {
|
|
app: router(state),
|
|
_storage_dir: storage_dir,
|
|
},
|
|
reloader,
|
|
)
|
|
}
|
|
|
|
/// Like [`harness`] but configures an admin CSRF allowlist so the
|
|
/// `/admin/*` mutating endpoints reject cross-origin browser POSTs.
|
|
/// Used by the admin CSRF integration tests.
|
|
pub fn harness_with_admin_origins(pool: PgPool, origins: Vec<String>) -> Harness {
|
|
let storage_dir = tempfile::tempdir().expect("tempdir");
|
|
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
|
let auth_limiter = Arc::new(AuthRateLimiter::new(Default::default()));
|
|
let state = AppState {
|
|
db: pool,
|
|
storage,
|
|
auth: AuthConfig {
|
|
cookie_secure: false,
|
|
..AuthConfig::default()
|
|
},
|
|
upload: UploadConfig {
|
|
max_request_bytes: 4 * 1024 * 1024,
|
|
max_file_bytes: 256 * 1024,
|
|
max_pages_per_chapter: 2000,
|
|
},
|
|
auth_limiter,
|
|
runtime: Arc::new(RuntimeControls::new(false)),
|
|
reloader: None,
|
|
crawler_base: CrawlerConfig::default(),
|
|
analysis_base: AnalysisConfig::default(),
|
|
admin_allowed_origins: Arc::new(origins),
|
|
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
|
};
|
|
Harness {
|
|
app: router(state),
|
|
_storage_dir: storage_dir,
|
|
}
|
|
}
|
|
|
|
/// Wraps a real `Storage` and fails on the N-th `put` call so tests can
|
|
/// assert that handlers roll their DB writes back when storage errors
|
|
/// mid-upload. Reads and other operations delegate to `inner`.
|
|
pub struct FailingStorage {
|
|
inner: LocalStorage,
|
|
counter: AtomicUsize,
|
|
fail_on_put_index: usize,
|
|
}
|
|
|
|
impl FailingStorage {
|
|
pub fn new(inner: LocalStorage, fail_on_put_index: usize) -> Self {
|
|
Self {
|
|
inner,
|
|
counter: AtomicUsize::new(0),
|
|
fail_on_put_index,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Storage for FailingStorage {
|
|
async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
|
|
let n = self.counter.fetch_add(1, Ordering::SeqCst);
|
|
if n == self.fail_on_put_index {
|
|
return Err(StorageError::Io(std::io::Error::other(
|
|
"FailingStorage: injected put failure",
|
|
)));
|
|
}
|
|
self.inner.put(key, bytes).await
|
|
}
|
|
async fn put_stream(
|
|
&self,
|
|
key: &str,
|
|
stream: PutByteStream<'_>,
|
|
) -> Result<u64, StorageError> {
|
|
// Count put_stream towards the same fail-index so tests that
|
|
// expect "the Nth put fails" don't care which entry point
|
|
// the caller took.
|
|
let n = self.counter.fetch_add(1, Ordering::SeqCst);
|
|
if n == self.fail_on_put_index {
|
|
return Err(StorageError::Io(std::io::Error::other(
|
|
"FailingStorage: injected put_stream failure",
|
|
)));
|
|
}
|
|
self.inner.put_stream(key, stream).await
|
|
}
|
|
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
|
|
self.inner.get(key).await
|
|
}
|
|
async fn get_stream(&self, key: &str) -> Result<StreamingFile, StorageError> {
|
|
self.inner.get_stream(key).await
|
|
}
|
|
async fn delete(&self, key: &str) -> Result<(), StorageError> {
|
|
self.inner.delete(key).await
|
|
}
|
|
async fn exists(&self, key: &str) -> Result<bool, StorageError> {
|
|
self.inner.exists(key).await
|
|
}
|
|
async fn size(&self, key: &str) -> Result<u64, StorageError> {
|
|
self.inner.size(key).await
|
|
}
|
|
// Delegate straight to the inner filesystem rename — the fault
|
|
// injection counts `put`/`put_stream` only, so promoting a staged page
|
|
// to its final key never spuriously trips the injected failure.
|
|
async fn rename(&self, from: &str, to: &str) -> Result<(), StorageError> {
|
|
self.inner.rename(from, to).await
|
|
}
|
|
}
|
|
|
|
pub async fn body_json(response: axum::response::Response) -> serde_json::Value {
|
|
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
|
serde_json::from_slice(&bytes).expect("body is JSON")
|
|
}
|
|
|
|
pub fn get(uri: &str) -> Request<Body> {
|
|
Request::builder().uri(uri).body(Body::empty()).unwrap()
|
|
}
|
|
|
|
pub fn get_with_cookie(uri: &str, cookie: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.uri(uri)
|
|
.header(header::COOKIE, cookie)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
pub fn get_with_bearer(uri: &str, token: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.uri(uri)
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
pub fn post_json(uri: &str, body: serde_json::Value) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap()
|
|
}
|
|
|
|
pub fn post_json_with_cookie(
|
|
uri: &str,
|
|
body: serde_json::Value,
|
|
cookie: &str,
|
|
) -> Request<Body> {
|
|
// Origin matches the default-harness allowlist (see TEST_ORIGIN) so
|
|
// cookie-auth POSTs survive the production-grade CSRF guard. CSRF-
|
|
// focused tests use `post_json_with_cookie_origin` to drive bespoke
|
|
// Origin/Referer values.
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header(header::COOKIE, cookie)
|
|
.header(header::ORIGIN, TEST_ORIGIN)
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap()
|
|
}
|
|
|
|
/// Same as [`post_json_with_cookie`] but also attaches `Origin` (and
|
|
/// optionally `Referer`) headers. Used by the admin CSRF tests to drive
|
|
/// the cross-origin reject + allowed-origin accept paths.
|
|
pub fn post_json_with_cookie_origin(
|
|
uri: &str,
|
|
body: serde_json::Value,
|
|
cookie: &str,
|
|
origin: Option<&str>,
|
|
referer: Option<&str>,
|
|
) -> Request<Body> {
|
|
let mut b = Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header(header::COOKIE, cookie);
|
|
if let Some(o) = origin {
|
|
b = b.header(header::ORIGIN, o);
|
|
}
|
|
if let Some(r) = referer {
|
|
b = b.header(header::REFERER, r);
|
|
}
|
|
b.body(Body::from(body.to_string())).unwrap()
|
|
}
|
|
|
|
pub fn get_with_cookie_origin(
|
|
uri: &str,
|
|
cookie: &str,
|
|
origin: Option<&str>,
|
|
) -> Request<Body> {
|
|
let mut b = Request::builder()
|
|
.uri(uri)
|
|
.header(header::COOKIE, cookie);
|
|
if let Some(o) = origin {
|
|
b = b.header(header::ORIGIN, o);
|
|
}
|
|
b.body(Body::empty()).unwrap()
|
|
}
|
|
|
|
pub fn post_json_with_bearer(
|
|
uri: &str,
|
|
body: serde_json::Value,
|
|
token: &str,
|
|
) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap()
|
|
}
|
|
|
|
pub fn patch_json(uri: &str, body: serde_json::Value) -> Request<Body> {
|
|
Request::builder()
|
|
.method("PATCH")
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap()
|
|
}
|
|
|
|
pub fn patch_json_with_cookie(
|
|
uri: &str,
|
|
body: serde_json::Value,
|
|
cookie: &str,
|
|
) -> Request<Body> {
|
|
Request::builder()
|
|
.method("PATCH")
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header(header::COOKIE, cookie)
|
|
.header(header::ORIGIN, TEST_ORIGIN)
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap()
|
|
}
|
|
|
|
pub fn put_json_with_cookie(
|
|
uri: &str,
|
|
body: serde_json::Value,
|
|
cookie: &str,
|
|
) -> Request<Body> {
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header(header::COOKIE, cookie)
|
|
.header(header::ORIGIN, TEST_ORIGIN)
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap()
|
|
}
|
|
|
|
pub fn delete_with_cookie(uri: &str, cookie: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method("DELETE")
|
|
.uri(uri)
|
|
.header(header::COOKIE, cookie)
|
|
.header(header::ORIGIN, TEST_ORIGIN)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
/// Extracts the `mangalord_session` cookie from a response's Set-Cookie
|
|
/// headers as a `name=value` pair suitable for use in a follow-up `Cookie`
|
|
/// request header. Returns `None` if no such cookie was set.
|
|
pub fn extract_session_cookie(response: &axum::response::Response) -> Option<String> {
|
|
response
|
|
.headers()
|
|
.get_all(header::SET_COOKIE)
|
|
.iter()
|
|
.find_map(|v| {
|
|
let s = v.to_str().ok()?;
|
|
if s.starts_with("mangalord_session=") {
|
|
let end = s.find(';').unwrap_or(s.len());
|
|
Some(s[..end].to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Minimal multipart builder for tests. Real clients would use a real
|
|
/// library; we hand-roll a small one so the test crate stays free of
|
|
/// http-client dependencies.
|
|
pub struct MultipartBuilder {
|
|
boundary: String,
|
|
body: Vec<u8>,
|
|
}
|
|
|
|
impl Default for MultipartBuilder {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl MultipartBuilder {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
boundary: format!("----mangalord-test-{}", uuid::Uuid::new_v4().simple()),
|
|
body: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn add_json(mut self, name: &str, value: serde_json::Value) -> Self {
|
|
self.write_part_header(name, None, Some("application/json"));
|
|
self.body.extend(value.to_string().as_bytes());
|
|
self.body.extend(b"\r\n");
|
|
self
|
|
}
|
|
|
|
pub fn add_file(
|
|
mut self,
|
|
name: &str,
|
|
filename: &str,
|
|
content_type: &str,
|
|
bytes: &[u8],
|
|
) -> Self {
|
|
self.write_part_header(name, Some(filename), Some(content_type));
|
|
self.body.extend(bytes);
|
|
self.body.extend(b"\r\n");
|
|
self
|
|
}
|
|
|
|
fn write_part_header(
|
|
&mut self,
|
|
name: &str,
|
|
filename: Option<&str>,
|
|
ct: Option<&str>,
|
|
) {
|
|
self.body
|
|
.extend(format!("--{}\r\n", self.boundary).as_bytes());
|
|
let disposition = if let Some(fname) = filename {
|
|
format!(
|
|
"Content-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\n"
|
|
)
|
|
} else {
|
|
format!("Content-Disposition: form-data; name=\"{name}\"\r\n")
|
|
};
|
|
self.body.extend(disposition.as_bytes());
|
|
if let Some(ct) = ct {
|
|
self.body.extend(format!("Content-Type: {ct}\r\n").as_bytes());
|
|
}
|
|
self.body.extend(b"\r\n");
|
|
}
|
|
|
|
/// Pub so a test can mint a multipart body for a bearer-auth Request
|
|
/// without going through the cookie helpers. Returns
|
|
/// `(boundary, body)` ready to attach as `Content-Type:
|
|
/// multipart/form-data; boundary={boundary}` and the request body.
|
|
pub fn finalize(self) -> (String, Vec<u8>) {
|
|
let mut body = self.body;
|
|
body.extend(format!("--{}--\r\n", self.boundary).as_bytes());
|
|
(self.boundary, body)
|
|
}
|
|
}
|
|
|
|
pub fn post_multipart(uri: &str, builder: MultipartBuilder) -> Request<Body> {
|
|
let (boundary, body) = builder.finalize();
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header(
|
|
header::CONTENT_TYPE,
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
}
|
|
|
|
pub fn post_multipart_with_cookie(
|
|
uri: &str,
|
|
builder: MultipartBuilder,
|
|
cookie: &str,
|
|
) -> Request<Body> {
|
|
let (boundary, body) = builder.finalize();
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header(
|
|
header::CONTENT_TYPE,
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.header(header::COOKIE, cookie)
|
|
.header(header::ORIGIN, TEST_ORIGIN)
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
}
|
|
|
|
pub fn put_multipart_with_cookie(
|
|
uri: &str,
|
|
builder: MultipartBuilder,
|
|
cookie: &str,
|
|
) -> Request<Body> {
|
|
let (boundary, body) = builder.finalize();
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri(uri)
|
|
.header(
|
|
header::CONTENT_TYPE,
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.header(header::COOKIE, cookie)
|
|
.header(header::ORIGIN, TEST_ORIGIN)
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
}
|
|
|
|
pub fn put_multipart(uri: &str, builder: MultipartBuilder) -> Request<Body> {
|
|
let (boundary, body) = builder.finalize();
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri(uri)
|
|
.header(
|
|
header::CONTENT_TYPE,
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
}
|
|
|
|
/// Realistic PNG file header bytes — enough for `infer` to identify.
|
|
pub fn fake_png_bytes() -> Vec<u8> {
|
|
vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]
|
|
}
|
|
|
|
/// Realistic JPEG file header bytes — enough for `infer` to identify.
|
|
pub fn fake_jpeg_bytes() -> Vec<u8> {
|
|
vec![
|
|
0xff, 0xd8, 0xff, 0xe0, 0, 0x10, b'J', b'F', b'I', b'F', 0, 0,
|
|
]
|
|
}
|
|
|
|
/// Create a manga via the upload API and return its id. Used by tests
|
|
/// that need a manga to exist before they exercise chapters / etc.
|
|
pub async fn seed_manga_via_api(app: &Router, cookie: &str, title: &str) -> uuid::Uuid {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(post_multipart_with_cookie(
|
|
"/api/v1/mangas",
|
|
MultipartBuilder::new().add_json("metadata", serde_json::json!({ "title": title })),
|
|
cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
axum::http::StatusCode::CREATED,
|
|
"seed_manga_via_api failed"
|
|
);
|
|
let body = body_json(resp).await;
|
|
uuid::Uuid::parse_str(body["id"].as_str().unwrap()).unwrap()
|
|
}
|
|
|
|
/// Register a brand-new user and return (username, session cookie value).
|
|
/// The username is unique per call so tests can run in parallel against a
|
|
/// single DB without colliding.
|
|
pub async fn register_user(app: &Router) -> (String, String) {
|
|
// 12-hex-digit suffix keeps the username under the 32-char cap.
|
|
let suffix: String = uuid::Uuid::new_v4().simple().to_string().chars().take(12).collect();
|
|
let username = format!("u-{suffix}");
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(post_json(
|
|
"/api/v1/auth/register",
|
|
json!({ "username": username, "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
axum::http::StatusCode::CREATED,
|
|
"register failed in test harness"
|
|
);
|
|
let cookie = extract_session_cookie(&resp).expect("session cookie on register");
|
|
(username, cookie)
|
|
}
|