fix(files): un Maker non cancella statistics/reports/logo, GET ristretto (#21)
DELETE /api/files/{path} non aveva nessun legame con ricetta/proprietario:
un Maker poteva cancellare qualunque file sotto uploads, compresi i CSV di
statistica di produzioni altrui. Ora: statistics/, reports/, logos/ sono
403 sempre (il logo si cambia da PUT /api/settings/logo); un file di
ricetta (task.file_path, subtask.image_path, recipe.image_path) e' 409 se
referenziato da una versione non corrente o che ha gia' misurazioni - un
file non referenziato resta cancellabile. GET di statistics/* e reports/*
richiede Metrologist, Supervisor o admin (prima bastava essere autenticati).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,15 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
|
from src.backend.database import get_db
|
||||||
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -20,6 +26,59 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
|||||||
ALLOWED_PDF_TYPE = "application/pdf"
|
ALLOWED_PDF_TYPE = "application/pdf"
|
||||||
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
|
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
|
||||||
|
|
||||||
|
# Top-level folders nothing may DELETE through this router: statistics/reports are
|
||||||
|
# audit records, the logo is only ever replaced via POST /api/settings/logo.
|
||||||
|
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/")
|
||||||
|
|
||||||
|
# GET under these needs a role that is meant to see production data, not just
|
||||||
|
# any authenticated user (a Maker or MeasurementTec has no business reading
|
||||||
|
# every production's statistics/report file by guessing its name).
|
||||||
|
_RESTRICTED_GET_PREFIXES = ("statistics/", "reports/")
|
||||||
|
|
||||||
|
|
||||||
|
def _can_read_restricted(user: User) -> bool:
|
||||||
|
return user.is_admin or user.has_role("Metrologist") or user.has_role("Supervisor")
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_recipe_file_protected(db: AsyncSession, relative_path: str) -> bool:
|
||||||
|
"""True when `relative_path` (uploads-relative, forward slashes) must not be
|
||||||
|
deleted: it is a recipe's cover image, or a task/subtask file belonging to a
|
||||||
|
version that is either not the current one (kept for history) or already has
|
||||||
|
measurements against it (the audit trail needs it to make sense of them).
|
||||||
|
|
||||||
|
A path not referenced anywhere is safe to delete (orphaned upload).
|
||||||
|
"""
|
||||||
|
recipe_hit = (await db.execute(
|
||||||
|
select(Recipe.id).where(Recipe.image_path == relative_path)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if recipe_hit is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
version_id = (await db.execute(
|
||||||
|
select(RecipeTask.version_id).where(RecipeTask.file_path == relative_path)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if version_id is None:
|
||||||
|
version_id = (await db.execute(
|
||||||
|
select(RecipeTask.version_id)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id)
|
||||||
|
.where(RecipeSubtask.image_path == relative_path)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if version_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
version = (await db.execute(
|
||||||
|
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if version is None or not version.is_current:
|
||||||
|
return True
|
||||||
|
|
||||||
|
has_measurements = (await db.execute(
|
||||||
|
select(Measurement.id).where(Measurement.version_id == version_id).limit(1)
|
||||||
|
)).scalar_one_or_none() is not None
|
||||||
|
return has_measurements
|
||||||
|
|
||||||
|
|
||||||
def validate_file_type(content_type: str) -> bool:
|
def validate_file_type(content_type: str) -> bool:
|
||||||
"""Validate if file type is allowed."""
|
"""Validate if file type is allowed."""
|
||||||
@@ -195,8 +254,14 @@ async def get_file(
|
|||||||
):
|
):
|
||||||
"""Serve a file from the uploads directory.
|
"""Serve a file from the uploads directory.
|
||||||
|
|
||||||
Requires authentication but no specific role.
|
Requires authentication; statistics/ and reports/ additionally require
|
||||||
|
Metrologist, Supervisor or admin, since those are production data.
|
||||||
"""
|
"""
|
||||||
|
if file_path.startswith(_RESTRICTED_GET_PREFIXES) and not _can_read_restricted(user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Metrologist, Supervisor or admin role required",
|
||||||
|
)
|
||||||
full_path = resolve_upload_path(file_path)
|
full_path = resolve_upload_path(file_path)
|
||||||
return FileResponse(full_path)
|
return FileResponse(full_path)
|
||||||
|
|
||||||
@@ -205,13 +270,29 @@ async def get_file(
|
|||||||
async def delete_file(
|
async def delete_file(
|
||||||
file_path: str,
|
file_path: str,
|
||||||
user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Delete a file from the uploads directory.
|
"""Delete a file from the uploads directory.
|
||||||
|
|
||||||
Also deletes associated thumbnail if it exists.
|
Also deletes associated thumbnail if it exists. statistics/, reports/ and
|
||||||
|
logos/ can never be deleted here (403); a recipe file still referenced by
|
||||||
|
its current version or by a version with measurements is kept (409).
|
||||||
"""
|
"""
|
||||||
|
if file_path.startswith(_UNDELETABLE_PREFIXES):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="This folder cannot be deleted through the files API.",
|
||||||
|
)
|
||||||
|
|
||||||
full_path = resolve_upload_path(file_path)
|
full_path = resolve_upload_path(file_path)
|
||||||
|
|
||||||
|
relative_path = str(full_path.relative_to(settings.upload_path)).replace("\\", "/")
|
||||||
|
if await _is_recipe_file_protected(db, relative_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="File is referenced by a current or measured recipe version.",
|
||||||
|
)
|
||||||
|
|
||||||
# Delete thumbnail if it exists
|
# Delete thumbnail if it exists
|
||||||
if full_path.parent.name != "thumbnails":
|
if full_path.parent.name != "thumbnails":
|
||||||
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.tests.conftest import auth_headers
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
class TestUploadFile:
|
class TestUploadFile:
|
||||||
@@ -134,6 +138,79 @@ class TestGetFile:
|
|||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRestrictedFile:
|
||||||
|
"""gitea#21: GET statistics/* and reports/* need Metrologist/Supervisor/admin."""
|
||||||
|
|
||||||
|
async def test_measurement_tec_forbidden_on_statistics(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
stats_dir = tmp_path / "statistics"
|
||||||
|
stats_dir.mkdir()
|
||||||
|
(stats_dir / "production_1_x.csv").write_text("a,b\n1,2\n")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/files/statistics/production_1_x.csv",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
async def test_metrologist_allowed_on_statistics(
|
||||||
|
self, client: AsyncClient, metrologist_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
stats_dir = tmp_path / "statistics"
|
||||||
|
stats_dir.mkdir()
|
||||||
|
(stats_dir / "production_1_x.csv").write_text("a,b\n1,2\n")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/files/statistics/production_1_x.csv",
|
||||||
|
headers=auth_headers(metrologist_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async def test_admin_allowed_on_reports(
|
||||||
|
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
reports_dir = tmp_path / "reports"
|
||||||
|
reports_dir.mkdir()
|
||||||
|
(reports_dir / "spc_1.pdf").write_bytes(b"%PDF-1.4")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/files/reports/spc_1.pdf",
|
||||||
|
headers=auth_headers(admin_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async def test_maker_unrestricted_path_still_allowed(
|
||||||
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""The restriction is scoped to statistics/reports, not every file."""
|
||||||
|
test_file = tmp_path / "1" / "1" / "drawing.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/files/1/1/drawing.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteFile:
|
class TestDeleteFile:
|
||||||
"""DELETE /api/files/{file_path} tests."""
|
"""DELETE /api/files/{file_path} tests."""
|
||||||
|
|
||||||
@@ -155,6 +232,242 @@ class TestDeleteFile:
|
|||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
assert not test_file.exists()
|
assert not test_file.exists()
|
||||||
|
|
||||||
|
async def test_delete_unreferenced_recipe_image_allowed(
|
||||||
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""A file under a recipe/version path but referenced nowhere is safe."""
|
||||||
|
test_file = tmp_path / "5" / "12" / "images" / "orphan.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/5/12/images/orphan.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert not test_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteProtectedFolders:
|
||||||
|
"""gitea#21: statistics/, reports/, logos/ can never be deleted here."""
|
||||||
|
|
||||||
|
async def test_maker_delete_statistics_forbidden(
|
||||||
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
stats_dir = tmp_path / "statistics"
|
||||||
|
stats_dir.mkdir()
|
||||||
|
test_file = stats_dir / "production_1_x.csv"
|
||||||
|
test_file.write_text("a,b\n1,2\n")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/statistics/production_1_x.csv",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
async def test_admin_delete_reports_forbidden(
|
||||||
|
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""Even an admin cannot delete through this route: not just a role gate."""
|
||||||
|
reports_dir = tmp_path / "reports"
|
||||||
|
reports_dir.mkdir()
|
||||||
|
test_file = reports_dir / "spc_1.pdf"
|
||||||
|
test_file.write_bytes(b"%PDF-1.4")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/reports/spc_1.pdf",
|
||||||
|
headers=auth_headers(admin_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
async def test_delete_logo_forbidden(
|
||||||
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""The logo is only ever replaced via POST /api/settings/logo."""
|
||||||
|
logos_dir = tmp_path / "logos"
|
||||||
|
logos_dir.mkdir()
|
||||||
|
test_file = logos_dir / "company_logo.png"
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/logos/company_logo.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteReferencedRecipeFile:
|
||||||
|
"""gitea#21: a recipe file backing the current version, a historical
|
||||||
|
version, or measured data must survive a DELETE (409)."""
|
||||||
|
|
||||||
|
async def test_current_version_no_measurements_is_allowed(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
maker_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
"""Still a draft (current version, nothing measured against it yet):
|
||||||
|
swapping the drawing out is exactly what editing a recipe does."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-A")
|
||||||
|
version = recipe.versions[0]
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalar_one()
|
||||||
|
task.file_path = "recipe-a/1/drawing.png"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
test_file = tmp_path / "recipe-a" / "1" / "drawing.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/recipe-a/1/drawing.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert not test_file.exists()
|
||||||
|
|
||||||
|
async def test_current_version_with_measurements_is_409(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
maker_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
"""Once something has been measured against this version, its drawing
|
||||||
|
must stay: the audit trail needs it to make sense of the numbers."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-D")
|
||||||
|
version = recipe.versions[0]
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalar_one()
|
||||||
|
task.file_path = "recipe-d/1/drawing.png"
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
|
||||||
|
subtask = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalar_one()
|
||||||
|
db_session.add(Measurement(
|
||||||
|
subtask_id=subtask.id,
|
||||||
|
version_id=version.id,
|
||||||
|
measured_by=maker_user.id,
|
||||||
|
value=10.0,
|
||||||
|
pass_fail="pass",
|
||||||
|
input_method="manual",
|
||||||
|
))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
test_file = tmp_path / "recipe-d" / "1" / "drawing.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/recipe-d/1/drawing.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
async def test_noncurrent_version_task_file_is_409(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
maker_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
"""A file kept only for a superseded version is history, not garbage."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-B")
|
||||||
|
|
||||||
|
old_version = RecipeVersion(
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
version_number=2,
|
||||||
|
is_current=False,
|
||||||
|
created_by=maker_user.id,
|
||||||
|
change_notes="Superseded",
|
||||||
|
)
|
||||||
|
db_session.add(old_version)
|
||||||
|
await db_session.flush()
|
||||||
|
old_task = RecipeTask(
|
||||||
|
version_id=old_version.id,
|
||||||
|
order_index=0,
|
||||||
|
title="Old drawing",
|
||||||
|
task_type="drawing",
|
||||||
|
file_path="recipe-b/2/old-drawing.png",
|
||||||
|
)
|
||||||
|
db_session.add(old_task)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
test_file = tmp_path / "recipe-b" / "2" / "old-drawing.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/recipe-b/2/old-drawing.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
async def test_recipe_cover_image_is_409(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
maker_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-C")
|
||||||
|
recipe.image_path = "recipe-c/cover.png"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
test_file = tmp_path / "recipe-c" / "cover.png"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_bytes(b"\x89PNG")
|
||||||
|
|
||||||
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/recipe-c/cover.png",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
|
||||||
class TestFilenameSanitization:
|
class TestFilenameSanitization:
|
||||||
"""Path traversal protection tests."""
|
"""Path traversal protection tests."""
|
||||||
|
|||||||
Reference in New Issue
Block a user