fix(settings): valida csv_delimiter/decimal_separator/auto_logout_minutes (#23)

PUT /api/settings/ non validava nulla: un csv_delimiter vuoto o di piu'
caratteri arrivava intatto nel DB e faceva fallire csv.writer con TypeError
dentro close_run (500, run mai chiuso). Aggiunta validazione per chiave nota
(csv_delimiter 1 carattere fra , ; TAB |, csv_decimal_separator ',' o '.',
auto_logout_minutes vuoto o intero 1-480, company_name/company_logo_path
non vuoti) -> 422 sui valori fuori dominio. Chiavi non note passano senza
validazione (store generico).

Difesa in profondita' in production_export_service._csv_format: un
csv_delimiter gia' sporco nel DB (scritto prima di questa validazione) non
fa piu' fallire close_run, usa ';' e logga un WARNING.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:29:35 +02:00
parent 51f3fe32d4
commit ea9da34899
4 changed files with 191 additions and 1 deletions
@@ -0,0 +1,57 @@
"""Unit tests for production_export_service.py.
- CSV format defenses (gitea#23): a dirty csv_delimiter read from the DB must
never make close_run fail with a csv.writer TypeError.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.setting import SystemSetting
from src.backend.services.production_export_service import _csv_format
class TestCsvFormatDefense:
"""`_csv_format` must never hand csv.writer a delimiter it will reject."""
async def test_invalid_delimiter_in_db_falls_back_to_semicolon(
self, db_session: AsyncSession
):
db_session.add(
SystemSetting(
setting_key="csv_delimiter", setting_value=";;", setting_type="string"
)
)
await db_session.flush()
delimiter, _ = await _csv_format(db_session)
assert delimiter == ";"
async def test_empty_delimiter_in_db_falls_back(self, db_session: AsyncSession):
db_session.add(
SystemSetting(
setting_key="csv_delimiter", setting_value="", setting_type="string"
)
)
await db_session.flush()
delimiter, _ = await _csv_format(db_session)
assert delimiter == ";"
async def test_valid_delimiter_passes_through_unchanged(
self, db_session: AsyncSession
):
db_session.add(
SystemSetting(
setting_key="csv_delimiter", setting_value="|", setting_type="string"
)
)
await db_session.flush()
delimiter, _ = await _csv_format(db_session)
assert delimiter == "|"
async def test_no_delimiter_setting_defaults_to_comma(
self, db_session: AsyncSession
):
delimiter, decimal = await _csv_format(db_session)
assert delimiter == ","
assert decimal == "."
+77
View File
@@ -105,3 +105,80 @@ class TestUpdateSettings:
json={"some_key": "some_value"},
)
assert resp.status_code == 403
class TestUpdateSettingsValidation:
"""gitea#23: an invalid csv_delimiter must be rejected, not accepted and
left to blow up close_run later with a csv.writer TypeError."""
async def test_csv_delimiter_two_chars_rejected(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"csv_delimiter": ";;"},
)
assert resp.status_code == 422
async def test_csv_delimiter_empty_rejected(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"csv_delimiter": ""},
)
assert resp.status_code == 422
async def test_csv_delimiter_tab_accepted(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"csv_delimiter": "\t"},
)
assert resp.status_code == 200
async def test_csv_decimal_separator_invalid_rejected(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"csv_decimal_separator": "x"},
)
assert resp.status_code == 422
async def test_auto_logout_minutes_out_of_range_rejected(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"auto_logout_minutes": "0"},
)
assert resp.status_code == 422
async def test_auto_logout_minutes_empty_disables(
self, client: AsyncClient, admin_user: User
):
"""Empty string is what the settings page sends to disable auto-logout."""
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"auto_logout_minutes": ""},
)
assert resp.status_code == 200
async def test_unknown_key_not_validated(
self, client: AsyncClient, admin_user: User
):
"""Keys with no known validator pass through (generic settings store)."""
resp = await client.put(
"/api/settings/",
headers=auth_headers(admin_user),
json={"some_future_key": "anything"},
)
assert resp.status_code == 200