805f41c757
Il Content-Type del client non era verificato: un file "x.html" dichiarato image/png veniva salvato e poi servito per estensione come text/html, eseguito dal browser. Nuova detect_file_type() (PNG/JPEG/GIF/WebP/PDF) in files.py, usata anche dal logo in settings.py; l'estensione salvata segue sempre il tipo rilevato, mai il nome file dichiarato dal client. Il logo rifiuta SVG a prescindere (puo' portare script). Upload letti a blocchi da 1MB, interrotti appena superano il limite invece di bufferizzare tutto prima di controllare la dimensione. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
266 lines
8.7 KiB
Python
266 lines
8.7 KiB
Python
"""Tests for settings router (/api/settings)."""
|
|
import io
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.backend.models.orm.setting import SystemSetting
|
|
from src.backend.models.orm.user import User
|
|
from src.backend.tests.conftest import auth_headers
|
|
|
|
|
|
class TestGetSettings:
|
|
"""GET /api/settings/ tests."""
|
|
|
|
async def test_get_settings(
|
|
self,
|
|
client: AsyncClient,
|
|
admin_user: User,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Authenticated user can retrieve settings."""
|
|
# Seed a setting
|
|
setting = SystemSetting(
|
|
setting_key="test_setting",
|
|
setting_value="test_value",
|
|
setting_type="string",
|
|
)
|
|
db_session.add(setting)
|
|
await db_session.flush()
|
|
|
|
resp = await client.get(
|
|
"/api/settings/", headers=auth_headers(admin_user)
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data, dict)
|
|
assert data.get("test_setting") == "test_value"
|
|
|
|
async def test_get_settings_empty(
|
|
self, client: AsyncClient, maker_user: User
|
|
):
|
|
"""Empty settings returns empty dict."""
|
|
resp = await client.get(
|
|
"/api/settings/", headers=auth_headers(maker_user)
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {} or isinstance(resp.json(), dict)
|
|
|
|
async def test_get_setting_by_key(
|
|
self,
|
|
client: AsyncClient,
|
|
admin_user: User,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Settings dict contains specific key after creation."""
|
|
setting = SystemSetting(
|
|
setting_key="csv_delimiter",
|
|
setting_value=";",
|
|
setting_type="string",
|
|
)
|
|
db_session.add(setting)
|
|
await db_session.flush()
|
|
|
|
resp = await client.get(
|
|
"/api/settings/", headers=auth_headers(admin_user)
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["csv_delimiter"] == ";"
|
|
|
|
|
|
class TestUpdateSettings:
|
|
"""PUT /api/settings/ tests."""
|
|
|
|
async def test_update_settings(
|
|
self, client: AsyncClient, admin_user: User
|
|
):
|
|
"""Admin can update/create settings."""
|
|
resp = await client.put(
|
|
"/api/settings/",
|
|
headers=auth_headers(admin_user),
|
|
json={
|
|
"company_name": "Tielogic Srl",
|
|
"csv_delimiter": ",",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["message"].startswith("Updated")
|
|
assert "company_name" in data["updated_keys"]
|
|
assert "csv_delimiter" in data["updated_keys"]
|
|
|
|
# Verify persistence
|
|
get_resp = await client.get(
|
|
"/api/settings/", headers=auth_headers(admin_user)
|
|
)
|
|
settings = get_resp.json()
|
|
assert settings["company_name"] == "Tielogic Srl"
|
|
|
|
async def test_settings_require_admin(
|
|
self, client: AsyncClient, maker_user: User
|
|
):
|
|
"""Non-admin user cannot update settings."""
|
|
resp = await client.put(
|
|
"/api/settings/",
|
|
headers=auth_headers(maker_user),
|
|
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
|
|
|
|
|
|
class TestUploadLogo:
|
|
"""POST /api/settings/logo tests (gitea#36 point 2)."""
|
|
|
|
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
|
|
|
|
async def test_valid_png_accepted(
|
|
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
|
):
|
|
with patch("src.backend.api.routers.settings.settings") as mock_settings:
|
|
mock_settings.upload_path = tmp_path
|
|
mock_settings.max_upload_size_mb = 50
|
|
|
|
resp = await client.post(
|
|
"/api/settings/logo",
|
|
headers=auth_headers(admin_user),
|
|
files={"file": ("logo.png", io.BytesIO(self._PNG_BYTES), "image/png")},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["logo_path"].endswith(".png")
|
|
|
|
async def test_svg_refused_even_with_svg_content_type(
|
|
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
|
):
|
|
"""SVG is refused outright, not just when it lies about its type."""
|
|
svg_bytes = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
|
|
|
with patch("src.backend.api.routers.settings.settings") as mock_settings:
|
|
mock_settings.upload_path = tmp_path
|
|
mock_settings.max_upload_size_mb = 50
|
|
|
|
resp = await client.post(
|
|
"/api/settings/logo",
|
|
headers=auth_headers(admin_user),
|
|
files={"file": ("logo.svg", io.BytesIO(svg_bytes), "image/svg+xml")},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert not any(tmp_path.rglob("*.svg"))
|
|
|
|
async def test_pdf_refused_for_logo(
|
|
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
|
):
|
|
"""The logo slot only accepts images: files.py's PDF signature must
|
|
not slip through settings.py's narrower ALLOWED_IMAGE_TYPES check."""
|
|
pdf_bytes = b"%PDF-1.4\n" + b"\x00" * 20
|
|
|
|
with patch("src.backend.api.routers.settings.settings") as mock_settings:
|
|
mock_settings.upload_path = tmp_path
|
|
mock_settings.max_upload_size_mb = 50
|
|
|
|
resp = await client.post(
|
|
"/api/settings/logo",
|
|
headers=auth_headers(admin_user),
|
|
files={"file": ("logo.pdf", io.BytesIO(pdf_bytes), "application/pdf")},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
|
|
async def test_html_disguised_as_png_refused(
|
|
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
|
):
|
|
html_bytes = b"<html><body><script>alert(1)</script></body></html>"
|
|
|
|
with patch("src.backend.api.routers.settings.settings") as mock_settings:
|
|
mock_settings.upload_path = tmp_path
|
|
mock_settings.max_upload_size_mb = 50
|
|
|
|
resp = await client.post(
|
|
"/api/settings/logo",
|
|
headers=auth_headers(admin_user),
|
|
files={"file": ("logo.png", io.BytesIO(html_bytes), "image/png")},
|
|
)
|
|
|
|
assert resp.status_code == 400
|