Skip to content

Commit 68f75a8

Browse files
committed
Merge branch 'bug-fix-search-for-special-characters' into deploy-synology
2 parents 9c80dff + 4793da5 commit 68f75a8

File tree

3 files changed

+136
-12
lines changed

3 files changed

+136
-12
lines changed

tests/test_search.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""tests/test_search.py
2+
3+
Tests for the search functionality, focusing on correct escaping of SQL LIKE
4+
special characters (%, _, \\) in search queries.
5+
"""
6+
7+
import pytest
8+
9+
from web.utils.helpers import escape_like
10+
11+
12+
# --------------------------------------------------------------------------- #
13+
# escape_like helper #
14+
# --------------------------------------------------------------------------- #
15+
16+
17+
class TestEscapeLike:
18+
def test_plain_string_unchanged(self):
19+
"""Strings with no special characters are left as-is."""
20+
assert escape_like("hello world") == "hello world"
21+
22+
def test_percent_escaped(self):
23+
"""% is escaped to \\%."""
24+
assert escape_like("%") == "\\%"
25+
assert escape_like("100%") == "100\\%"
26+
assert escape_like("%complete%") == "\\%complete\\%"
27+
28+
def test_underscore_escaped(self):
29+
"""_ is escaped to \\_."""
30+
assert escape_like("_") == "\\_"
31+
assert escape_like("game_1") == "game\\_1"
32+
33+
def test_backslash_escaped(self):
34+
r"""\ is escaped to \\."""
35+
assert escape_like("a\\b") == "a\\\\b"
36+
37+
def test_combined_special_chars(self):
38+
"""Multiple special characters are all escaped."""
39+
assert escape_like("50% off_sale") == "50\\% off\\_sale"
40+
41+
42+
# --------------------------------------------------------------------------- #
43+
# Library search – special characters #
44+
# --------------------------------------------------------------------------- #
45+
46+
47+
@pytest.fixture
48+
def client_with_special_games(db_conn, client):
49+
"""Insert games with special characters in their names."""
50+
db_conn.execute(
51+
"INSERT INTO games (name, store, store_id) VALUES (?, ?, ?)",
52+
("100% Orange Juice", "steam", "282870"),
53+
)
54+
db_conn.execute(
55+
"INSERT INTO games (name, store, store_id) VALUES (?, ?, ?)",
56+
("Pro_game", "steam", "99999"),
57+
)
58+
db_conn.execute(
59+
"INSERT INTO games (name, store, store_id) VALUES (?, ?, ?)",
60+
("Normal Game", "steam", "11111"),
61+
)
62+
db_conn.commit()
63+
# client fixture already sets up the DB override; just return it
64+
return client
65+
66+
67+
class TestLibrarySearchSpecialChars:
68+
def test_percent_search_returns_only_matching_game(self, client_with_special_games):
69+
"""Searching for % should match games that contain % in their name, not all games."""
70+
resp = client_with_special_games.get("/library?search=%25") # URL-encoded %
71+
assert resp.status_code == 200
72+
text = resp.text
73+
# The game with % in its name should appear
74+
assert "100% Orange Juice" in text
75+
# Normal games should NOT appear
76+
assert "Normal Game" not in text
77+
78+
def test_underscore_search_returns_only_matching_game(self, client_with_special_games):
79+
"""Searching for _ should not act as a wildcard; only exact matches are returned."""
80+
resp = client_with_special_games.get("/library?search=Pro_game")
81+
assert resp.status_code == 200
82+
text = resp.text
83+
assert "Pro_game" in text
84+
# Normal Game has no underscore – should not match
85+
assert "Normal Game" not in text
86+
87+
def test_plain_search_still_works(self, client_with_special_games):
88+
"""Regular search without special chars continues to work."""
89+
resp = client_with_special_games.get("/library?search=Normal")
90+
assert resp.status_code == 200
91+
assert "Normal Game" in resp.text
92+
assert "100% Orange Juice" not in resp.text
93+
94+
def test_search_too_long_returns_422(self, client_with_special_games):
95+
"""Search strings longer than 200 chars are rejected with HTTP 422."""
96+
long_search = "a" * 201
97+
resp = client_with_special_games.get(f"/library?search={long_search}")
98+
assert resp.status_code == 422
99+
100+
def test_search_exactly_200_chars_is_accepted(self, client_with_special_games):
101+
"""Search strings of exactly 200 chars are accepted."""
102+
search = "a" * 200
103+
resp = client_with_special_games.get(f"/library?search={search}")
104+
assert resp.status_code == 200

web/routes/library.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from ..dependencies import get_db
1414
from ..utils.filters import EXCLUDE_HIDDEN_FILTER, EXCLUDE_DUPLICATES_FILTER, PLAYTIME_LABELS
15-
from ..utils.helpers import parse_json_field, get_store_url, group_games_by_igdb
15+
from ..utils.helpers import parse_json_field, get_store_url, group_games_by_igdb, escape_like
1616

1717
router = APIRouter()
1818
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
@@ -29,7 +29,7 @@ def library(
2929
request: Request,
3030
stores: list[str] = Query(default=[]),
3131
genres: list[str] = Query(default=[]),
32-
search: str = "",
32+
search: str = Query(default="", max_length=200),
3333
sort: str = "name",
3434
order: str = "asc",
3535
exclude_streaming: bool = False,
@@ -55,13 +55,13 @@ def library(
5555
# Filter by genres, preferring genres_override if set
5656
genre_conditions = []
5757
for genre in genres:
58-
genre_conditions.append("LOWER(COALESCE(genres_override, genres)) LIKE ?")
59-
params.append(f'%"{genre.lower()}"%')
58+
genre_conditions.append("LOWER(COALESCE(genres_override, genres)) LIKE ? ESCAPE '\\'")
59+
params.append(f'%"{escape_like(genre.lower())}"%')
6060
query += " AND (" + " OR ".join(genre_conditions) + ")"
6161

6262
if search:
63-
query += " AND name LIKE ?"
64-
params.append(f"%{search}%")
63+
query += " AND name LIKE ? ESCAPE '\\'"
64+
params.append(f"%{escape_like(search)}%")
6565

6666
# Collection filter
6767
if collection:
@@ -336,7 +336,7 @@ def random_game(conn: sqlite3.Connection = Depends(get_db)):
336336
@router.get("/hidden", response_class=HTMLResponse)
337337
def hidden_games(
338338
request: Request,
339-
search: str = "",
339+
search: str = Query(default="", max_length=200),
340340
conn: sqlite3.Connection = Depends(get_db)
341341
):
342342
"""Page showing all hidden games."""
@@ -346,8 +346,8 @@ def hidden_games(
346346
params = []
347347

348348
if search:
349-
query += " AND name LIKE ?"
350-
params.append(f"%{search}%")
349+
query += " AND name LIKE ? ESCAPE '\\'"
350+
params.append(f"%{escape_like(search)}%")
351351

352352
query += " ORDER BY name COLLATE NOCASE ASC"
353353

@@ -368,7 +368,7 @@ def hidden_games(
368368
@router.get("/removed", response_class=HTMLResponse)
369369
def removed_games(
370370
request: Request,
371-
search: str = "",
371+
search: str = Query(default="", max_length=200),
372372
conn: sqlite3.Connection = Depends(get_db)
373373
):
374374
"""Page showing all removed games."""
@@ -378,8 +378,8 @@ def removed_games(
378378
params = []
379379

380380
if search:
381-
query += " AND name LIKE ?"
382-
params.append(f"%{search}%")
381+
query += " AND name LIKE ? ESCAPE '\\'"
382+
params.append(f"%{escape_like(search)}%")
383383

384384
query += " ORDER BY name COLLATE NOCASE ASC"
385385

web/utils/helpers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@
55
from urllib.parse import quote
66

77

8+
def escape_like(value: str) -> str:
9+
"""Escape special SQL LIKE wildcard characters in a search string.
10+
11+
Escapes backslash, percent, and underscore so they are treated as
12+
literals rather than LIKE pattern characters. The query must use
13+
``ESCAPE '\\'`` for the escaping to take effect in SQLite.
14+
15+
Args:
16+
value: The raw user-supplied search string.
17+
18+
Returns:
19+
The string with ``\\``, ``%``, and ``_`` escaped.
20+
21+
Example:
22+
>>> escape_like("100%")
23+
'100\\\\%'
24+
"""
25+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
26+
27+
828
def parse_json_field(value):
929
"""Safely parse a JSON field."""
1030
if not value:

0 commit comments

Comments
 (0)