diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a7f5b86..44287ab 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.128.5" +version = "0.128.6" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d975c4d..400c712 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.128.5" +version = "0.128.6" edition = "2021" default-run = "mangalord" diff --git a/backend/src/repo/admin_view.rs b/backend/src/repo/admin_view.rs index ca79957..cf1e6fe 100644 --- a/backend/src/repo/admin_view.rs +++ b/backend/src/repo/admin_view.rs @@ -214,7 +214,7 @@ pub async fn list_mangas_with_sync_state( let search_pat = q .search .as_ref() - .map(|s| format!("%{}%", s.trim())) + .map(|s| format!("%{}%", crate::repo::escape_like(s.trim()))) .filter(|p| p.len() > 2); // sqlx::Type → text: bind the snake_case representation manually so // the SQL can compare it as text without an explicit cast. @@ -235,7 +235,7 @@ pub async fn list_mangas_with_sync_state( (SELECT MAX(last_seen_at) FROM manga_sources ms WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL) AS latest_seen_at FROM mangas m - WHERE ($1::text IS NULL OR m.title ILIKE $1) + WHERE ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\') ) SELECT * FROM classified WHERE ($2::text IS NULL OR sync_state = $2) @@ -261,7 +261,7 @@ pub async fn list_mangas_with_sync_state( WITH classified AS ( SELECT {case} AS sync_state FROM mangas m - WHERE ($1::text IS NULL OR m.title ILIKE $1) + WHERE ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\') ) SELECT COUNT(*) FROM classified WHERE ($2::text IS NULL OR sync_state = $2) diff --git a/backend/src/repo/author.rs b/backend/src/repo/author.rs index 57d9452..2dede60 100644 --- a/backend/src/repo/author.rs +++ b/backend/src/repo/author.rs @@ -81,7 +81,7 @@ pub async fn list( SELECT id, name, created_at FROM authors WHERE $1::text IS NULL - OR name ILIKE '%' || $1 || '%' + OR name ILIKE '%' || $4 || '%' ESCAPE '\' OR name % $1 ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC, lower(name) ASC @@ -91,6 +91,9 @@ pub async fn list( .bind(search) .bind(limit) .bind(offset) + // $4: LIKE-escaped term for the ILIKE branch so `%`/`_` match literally; the + // trigram/similarity branches keep the raw $1. + .bind(search.map(crate::repo::escape_like)) .fetch_all(pool) .await?; Ok(rows) diff --git a/backend/src/repo/crawler.rs b/backend/src/repo/crawler.rs index beeb88f..329949c 100644 --- a/backend/src/repo/crawler.rs +++ b/backend/src/repo/crawler.rs @@ -720,7 +720,7 @@ pub async fn list_dead_jobs( offset: i64, ) -> sqlx::Result<(Vec, i64)> { let search_pat = search - .map(|s| format!("%{}%", s.trim())) + .map(|s| format!("%{}%", crate::repo::escape_like(s.trim()))) .filter(|p| p.len() > 2); let items: Vec = sqlx::query_as( @@ -743,7 +743,7 @@ pub async fn list_dead_jobs( LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid LEFT JOIN mangas m ON m.id = c.manga_id WHERE cj.state = 'dead' - AND ($1::text IS NULL OR m.title ILIKE $1 OR cj.payload->>'title' ILIKE $1) + AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\' OR cj.payload->>'title' ILIKE $1 ESCAPE '\') ORDER BY cj.updated_at DESC LIMIT $2 OFFSET $3 "#, @@ -761,7 +761,7 @@ pub async fn list_dead_jobs( LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid LEFT JOIN mangas m ON m.id = c.manga_id WHERE cj.state = 'dead' - AND ($1::text IS NULL OR m.title ILIKE $1 OR cj.payload->>'title' ILIKE $1) + AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\' OR cj.payload->>'title' ILIKE $1 ESCAPE '\') "#, ) .bind(&search_pat) @@ -797,7 +797,7 @@ pub async fn list_active_jobs( offset: i64, ) -> sqlx::Result<(Vec, i64)> { let search_pat = search - .map(|s| format!("%{}%", s.trim())) + .map(|s| format!("%{}%", crate::repo::escape_like(s.trim()))) .filter(|p| p.len() > 2); let items: Vec = sqlx::query_as( @@ -817,7 +817,7 @@ pub async fn list_active_jobs( LEFT JOIN mangas m ON m.id = c.manga_id WHERE cj.state IN ('pending','running') AND cj.payload->>'kind' = 'sync_chapter_content' - AND ($1::text IS NULL OR m.title ILIKE $1) + AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\') ORDER BY (cj.state = 'running') DESC, cj.scheduled_at, cj.created_at LIMIT $2 OFFSET $3 "#, @@ -836,7 +836,7 @@ pub async fn list_active_jobs( LEFT JOIN mangas m ON m.id = c.manga_id WHERE cj.state IN ('pending','running') AND cj.payload->>'kind' = 'sync_chapter_content' - AND ($1::text IS NULL OR m.title ILIKE $1) + AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\') "#, ) .bind(&search_pat) @@ -913,7 +913,7 @@ pub async fn list_job_history( ) -> sqlx::Result<(Vec, i64)> { let search_pat = filter .search - .map(|s| format!("%{}%", s.trim())) + .map(|s| format!("%{}%", crate::repo::escape_like(s.trim()))) .filter(|p| p.len() > 2); // The same FROM/JOIN/WHERE drives both the page slice and the count, so @@ -951,7 +951,7 @@ pub async fn list_job_history( ) cm ON true WHERE ($1::text IS NULL OR cj.state = $1) AND ($2::text IS NULL OR cj.payload->>'kind' = $2) - AND ($3::text IS NULL OR m.title ILIKE $3 OR cj.payload->>'title' ILIKE $3) + AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\' OR cj.payload->>'title' ILIKE $3 ESCAPE '\') ORDER BY cj.updated_at DESC LIMIT $4 OFFSET $5 "#, @@ -973,7 +973,7 @@ pub async fn list_job_history( LEFT JOIN mangas m ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid) WHERE ($1::text IS NULL OR cj.state = $1) AND ($2::text IS NULL OR cj.payload->>'kind' = $2) - AND ($3::text IS NULL OR m.title ILIKE $3 OR cj.payload->>'title' ILIKE $3) + AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\' OR cj.payload->>'title' ILIKE $3 ESCAPE '\') "#, ) .bind(filter.state) @@ -1018,7 +1018,7 @@ pub async fn list_missing_cover_mangas( offset: i64, ) -> sqlx::Result<(Vec, i64)> { let search_pat = search - .map(|s| format!("%{}%", s.trim())) + .map(|s| format!("%{}%", crate::repo::escape_like(s.trim()))) .filter(|p| p.len() > 2); let items: Vec = sqlx::query_as( @@ -1030,7 +1030,7 @@ pub async fn list_missing_cover_mangas( SELECT 1 FROM manga_sources ms WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL ) - AND ($1::text IS NULL OR m.title ILIKE $1) + AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\') ORDER BY m.updated_at DESC LIMIT $2 OFFSET $3 "#, @@ -1049,7 +1049,7 @@ pub async fn list_missing_cover_mangas( SELECT 1 FROM manga_sources ms WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL ) - AND ($1::text IS NULL OR m.title ILIKE $1) + AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\') "#, ) .bind(&search_pat) diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index 1be818b..fce985e 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::domain::manga::{Manga, MangaCard, MangaDetail}; use crate::error::{AppError, AppResult}; -use crate::repo; +use crate::repo::{self, escape_like}; /// Status values mirror the CHECK constraint in 0009. Centralized so /// the API layer can validate uploads against the same vocabulary. @@ -110,13 +110,13 @@ fn manga_cols(alias: &str) -> String { /// true. const FILTER_WHERE: &str = r#" ($1::text IS NULL - OR title ILIKE '%' || $1 || '%' + OR title ILIKE '%' || $8 || '%' ESCAPE '\' OR title % $1 OR EXISTS ( SELECT 1 FROM manga_authors ma JOIN authors a ON a.id = ma.author_id WHERE ma.manga_id = mangas.id - AND (a.name ILIKE '%' || $1 || '%' OR a.name % $1) + AND (a.name ILIKE '%' || $8 || '%' ESCAPE '\' OR a.name % $1) ) ) AND ($2::text IS NULL OR status = $2) @@ -196,11 +196,15 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, Op FROM mangas WHERE {FILTER_WHERE} ORDER BY {order_by} - LIMIT $8 OFFSET $9 + LIMIT $9 OFFSET $10 "#, cols = manga_cols(""), ); + // $8 is the LIKE-escaped search term used by the ILIKE branches so `%`/`_` + // in the term match literally; the trigram `%` branches keep the raw $1. + let search_escaped = search.map(escape_like); + let rows = sqlx::query_as::<_, Manga>(&list_sql) .bind(search) .bind(status) @@ -209,6 +213,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, Op .bind(&query.tag_ids) .bind(&query.cw_include) .bind(&query.cw_exclude) + .bind(&search_escaped) .bind(query.limit) .bind(query.offset) .fetch_all(pool) @@ -235,6 +240,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, Op .bind(&query.tag_ids) .bind(&query.cw_include) .bind(&query.cw_exclude) + .bind(&search_escaped) .fetch_one(pool) .await?; Some(total) diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index 49e2d14..f7c3724 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -21,3 +21,45 @@ pub mod tag; pub mod upload_history; pub mod user; pub mod user_preferences; + +/// Escape the LIKE/ILIKE metacharacters (`%`, `_`, and the escape char `\` +/// itself) in a user-supplied search term so they match literally rather than +/// as wildcards. Pair the resulting value with `ESCAPE '\'` in the SQL — a +/// single backslash, which under `standard_conforming_strings` (Postgres +/// default) is one backslash in a single-quoted literal. +/// +/// This is a search-correctness fix, not an injection fix: every term is +/// already a bound parameter, so `%`/`_` can never break out of the string — +/// they were just silently acting as wildcards (`50%` matching everything, +/// `a_b` matching `axb`). Callers that build a substring pattern wrap the +/// escaped term themselves, e.g. `format!("%{}%", escape_like(term))`. +pub(crate) fn escape_like(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + if matches!(ch, '\\' | '%' | '_') { + out.push('\\'); + } + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::escape_like; + + #[test] + fn escapes_wildcards_and_the_escape_char() { + assert_eq!(escape_like("50%"), r"50\%"); + assert_eq!(escape_like("a_b"), r"a\_b"); + assert_eq!(escape_like(r"back\slash"), r"back\\slash"); + // A already-escaped-looking input is double-escaped so it stays literal. + assert_eq!(escape_like(r"\%"), r"\\\%"); + } + + #[test] + fn leaves_ordinary_text_untouched() { + assert_eq!(escape_like("naruto"), "naruto"); + assert_eq!(escape_like(""), ""); + } +} diff --git a/backend/src/repo/page_analysis.rs b/backend/src/repo/page_analysis.rs index 3258e46..2bc92f5 100644 --- a/backend/src/repo/page_analysis.rs +++ b/backend/src/repo/page_analysis.rs @@ -241,6 +241,10 @@ pub async fn manga_coverage( limit: i64, offset: i64, ) -> AppResult<(Vec, i64)> { + // LIKE-escape so `%`/`_` in the title filter match literally. No trigram + // branch here, so binding the escaped term directly (rather than appending a + // second param) is safe — $1 feeds only the ILIKE. + let search = search.map(crate::repo::escape_like); let rows = sqlx::query_as::<_, MangaCoverage>( r#" SELECT m.id AS manga_id, m.title, @@ -250,13 +254,13 @@ pub async fn manga_coverage( JOIN chapters c ON c.manga_id = m.id JOIN pages p ON p.chapter_id = c.id LEFT JOIN page_analysis pa ON pa.page_id = p.id - WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%') + WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%' ESCAPE '\') GROUP BY m.id, m.title ORDER BY lower(m.title), m.id LIMIT $2 OFFSET $3 "#, ) - .bind(search) + .bind(&search) .bind(limit) .bind(offset) .fetch_all(pool) @@ -269,12 +273,12 @@ pub async fn manga_coverage( FROM mangas m JOIN chapters c ON c.manga_id = m.id JOIN pages p ON p.chapter_id = c.id - WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%') + WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%' ESCAPE '\') GROUP BY m.id ) x "#, ) - .bind(search) + .bind(&search) .fetch_one(pool) .await?; Ok((rows, total)) @@ -377,7 +381,7 @@ pub async fn list_history( ) -> AppResult<(Vec, i64)> { let search_pat = filter .search - .map(|s| format!("%{}%", s.trim())) + .map(|s| format!("%{}%", crate::repo::escape_like(s.trim()))) .filter(|p| p.len() > 2); let items = sqlx::query_as::<_, AnalysisHistoryRow>( @@ -402,7 +406,7 @@ pub async fn list_history( WHERE pa.status <> 'pending' AND ($1::text IS NULL OR pa.status = $1) AND ($2::bool IS FALSE OR pa.is_nsfw) - AND ($3::text IS NULL OR m.title ILIKE $3) + AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\') ORDER BY pa.analyzed_at DESC NULLS LAST, pa.page_id LIMIT $4 OFFSET $5 "#, @@ -425,7 +429,7 @@ pub async fn list_history( WHERE pa.status <> 'pending' AND ($1::text IS NULL OR pa.status = $1) AND ($2::bool IS FALSE OR pa.is_nsfw) - AND ($3::text IS NULL OR m.title ILIKE $3) + AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\') "#, ) .bind(filter.status) diff --git a/backend/src/repo/page_tag.rs b/backend/src/repo/page_tag.rs index 0981712..c6db1d4 100644 --- a/backend/src/repo/page_tag.rs +++ b/backend/src/repo/page_tag.rs @@ -120,27 +120,11 @@ pub async fn list_for_page( Ok(rows.into_iter().map(|(t,)| t).collect()) } -/// Escape a string for use as a LIKE pattern fragment: `%`, `_`, and -/// `\` get a leading backslash so they're matched literally rather -/// than as wildcards / escapes. The matching queries below pair this -/// with `ESCAPE '\'` for explicitness — a single backslash, since the -/// SQL lives in a raw string and Postgres treats `\\` in a single- -/// quoted literal as one backslash under `standard_conforming_strings`. -/// -/// The public API rejects `%`/`_`/`\` in `normalize_tag` before -/// they reach this repo, so this is defence-in-depth — a future -/// internal caller (worker, CLI) that bypasses the normalizer can't -/// turn a prefix filter into a wildcard search by accident. -fn escape_like_prefix(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for ch in s.chars() { - if matches!(ch, '\\' | '%' | '_') { - out.push('\\'); - } - out.push(ch); - } - out -} +// LIKE-escaping the autocomplete prefix is defence-in-depth: the public API +// already rejects `%`/`_`/`\` in `normalize_tag` before they reach this repo, so +// a stray wildcard can only arrive from a future internal caller (worker, CLI) +// that bypasses the normalizer. Shared with the other search sites. +use crate::repo::escape_like as escape_like_prefix; /// Paged list of `user_id`'s tagged pages, with breadcrumb. When /// `tag_filter` is `Some(_)`, restrict to that exact tag (used by the diff --git a/backend/src/repo/tag.rs b/backend/src/repo/tag.rs index 2feb616..a81f2ca 100644 --- a/backend/src/repo/tag.rs +++ b/backend/src/repo/tag.rs @@ -124,7 +124,7 @@ pub async fn list( SELECT id, name, created_at FROM tags WHERE $1::text IS NULL - OR name ILIKE '%' || $1 || '%' + OR name ILIKE '%' || $3 || '%' ESCAPE '\' OR name % $1 ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC, lower(name) ASC @@ -133,6 +133,9 @@ pub async fn list( ) .bind(search) .bind(limit) + // $3: LIKE-escaped term for the ILIKE branch so `%`/`_` match literally; the + // trigram/similarity branches keep the raw $1. + .bind(search.map(crate::repo::escape_like)) .fetch_all(pool) .await?; Ok(rows) diff --git a/backend/src/repo/user.rs b/backend/src/repo/user.rs index caa7be8..322588f 100644 --- a/backend/src/repo/user.rs +++ b/backend/src/repo/user.rs @@ -106,14 +106,14 @@ pub async fn list_with_total( let pat = q .search .as_ref() - .map(|s| format!("%{}%", s.trim())) + .map(|s| format!("%{}%", crate::repo::escape_like(s.trim()))) .filter(|p| p.len() > 2); let items = sqlx::query_as::<_, User>( r#" SELECT id, username, password_hash, created_at, is_admin FROM users - WHERE ($1::text IS NULL OR username ILIKE $1) + WHERE ($1::text IS NULL OR username ILIKE $1 ESCAPE '\') ORDER BY username LIMIT $2 OFFSET $3 "#, @@ -125,7 +125,7 @@ pub async fn list_with_total( .await?; let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM users WHERE ($1::text IS NULL OR username ILIKE $1)", + "SELECT COUNT(*) FROM users WHERE ($1::text IS NULL OR username ILIKE $1 ESCAPE '\\')", ) .bind(&pat) .fetch_one(pool) diff --git a/backend/tests/api_admin_users.rs b/backend/tests/api_admin_users.rs index 0f95d4a..1695e3c 100644 --- a/backend/tests/api_admin_users.rs +++ b/backend/tests/api_admin_users.rs @@ -147,6 +147,46 @@ async fn list_filters_by_substring_search(pool: PgPool) { assert_eq!(body["page"]["total"], 1); } +#[sqlx::test(migrations = "./migrations")] +async fn search_treats_like_wildcards_literally(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_admin_name, cookie, _) = seed_admin(&pool, &h.app).await; + // Two usernames differing only at one position (underscores are legal in + // usernames). `_` is a LIKE single-char wildcard: unescaped, `%a_b%` matches + // BOTH; escaped, only the literal "a_b" username. Admin user search has no + // trigram OR, so length/similarity don't matter here. + for username in ["axbfindme", "a_bfindme"] { + let resp = h + .app + .clone() + .oneshot(common::post_json( + "/api/v1/auth/register", + json!({ "username": username, "password": "hunter2hunter2" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + } + + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/admin/users?search=a_b", + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let items = body["items"].as_array().unwrap(); + assert_eq!( + items.len(), + 1, + "the `_` must match literally: only a_bfindme, not axbfindme" + ); + assert_eq!(items[0]["username"], "a_bfindme"); +} + // ---- self-protection ------------------------------------------------------- #[sqlx::test(migrations = "./migrations")] diff --git a/backend/tests/api_authors.rs b/backend/tests/api_authors.rs index 4df145b..1168831 100644 --- a/backend/tests/api_authors.rs +++ b/backend/tests/api_authors.rs @@ -30,6 +30,47 @@ fn first_author_id(manga: &Value) -> String { manga["authors"][0]["id"].as_str().unwrap().to_string() } +#[sqlx::test(migrations = "./migrations")] +async fn search_treats_like_wildcards_literally(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + // Long author names differing only at one position, short search term — so + // the trigram OR (which keeps the raw term by design) stays under threshold + // and the ILIKE branch is what decides. Unescaped `%a_b%` matches both the + // "axb" and "a_b" names; escaped, only the literal "a_b" name. + create_manga( + &h.app, + &cookie, + json!({ "title": "M1", "authors": ["The Quick Brown Fox axb Jumps Over"] }), + ) + .await; + create_manga( + &h.app, + &cookie, + json!({ "title": "M2", "authors": ["The Quick Brown Fox a_b Jumps Over"] }), + ) + .await; + + let resp = h + .app + .oneshot(common::get("/api/v1/authors?search=a_b")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let names: Vec<&str> = body + .as_array() + .unwrap() + .iter() + .map(|a| a["name"].as_str().unwrap()) + .collect(); + assert_eq!( + names, + vec!["The Quick Brown Fox a_b Jumps Over"], + "the `_` in the search term must match literally, not as a wildcard" + ); +} + #[sqlx::test(migrations = "./migrations")] async fn get_returns_name_and_manga_count(pool: PgPool) { let h = common::harness(pool); diff --git a/backend/tests/api_mangas.rs b/backend/tests/api_mangas.rs index d6112ee..154ee30 100644 --- a/backend/tests/api_mangas.rs +++ b/backend/tests/api_mangas.rs @@ -135,6 +135,30 @@ async fn list_total_is_computed_only_on_the_first_page(pool: PgPool) { assert_eq!(body1["items"].as_array().unwrap().len(), 1); } +#[sqlx::test(migrations = "./migrations")] +async fn search_treats_like_wildcards_literally(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + // Two long titles differing only at one position. `_` is a LIKE single-char + // wildcard: unescaped, `%a_b%` matches BOTH ("axb" and "a_b"); escaped, only + // the literal "a_b" title matches. The titles are long and the term short so + // trigram similarity stays under threshold — the ILIKE branch decides. + seed(&h.app, &cookie, "The Quick Brown Fox Jumps axb Over The Lazy Dog").await; + seed(&h.app, &cookie, "The Quick Brown Fox Jumps a_b Over The Lazy Dog").await; + + let resp = h + .app + .oneshot(common::get("/api/v1/mangas?search=a_b")) + .await + .unwrap(); + let body = common::body_json(resp).await; + assert_eq!( + title_list(&body), + vec!["The Quick Brown Fox Jumps a_b Over The Lazy Dog"], + "the `_` in the search term must match literally, not as a wildcard" + ); +} + #[sqlx::test(migrations = "./migrations")] async fn search_via_trigram_tolerates_typos(pool: PgPool) { let h = common::harness(pool); diff --git a/frontend/package.json b/frontend/package.json index e04c3e8..7b4042f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.128.5", + "version": "0.128.6", "private": true, "type": "module", "scripts": {