mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Security hardening (maziggy/bambuddy-security #29)
This commit is contained in:
@@ -75,14 +75,6 @@
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 30,
|
||||
"endColumn": 67,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportOptionalMemberAccess",
|
||||
"range": {
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Reques
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from backend.app.core import database
|
||||
from backend.app.core.auth import (
|
||||
@@ -177,6 +178,17 @@ async def _claimed_timelapse_stems(db, printer_id: int | None, exclude_archive_i
|
||||
return {Path(p).stem for p in rows.scalars().all() if p}
|
||||
|
||||
|
||||
def _visible_archive_clauses(user, can_read_all: bool, printer_scope: PrinterScope) -> list:
|
||||
"""WHERE clauses for the archives a caller may see, as _ensure_archive_visible decides one by one."""
|
||||
clauses = []
|
||||
if (scope_clause := printer_scope.where(PrintArchive.printer_id)) is not None:
|
||||
clauses.append(scope_clause)
|
||||
if not can_read_all:
|
||||
# Own archives only; an ownerless one needs read-all, and no user sees none.
|
||||
clauses.append(PrintArchive.created_by_id == user.id if user is not None else PrintArchive.id.is_(None))
|
||||
return clauses
|
||||
|
||||
|
||||
def _ensure_archive_visible(
|
||||
archive: PrintArchive | None,
|
||||
user: User | None,
|
||||
@@ -450,8 +462,10 @@ async def list_archives(
|
||||
printer_scope=printer_scope,
|
||||
)
|
||||
|
||||
# Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries)
|
||||
duplicate_hashes, duplicate_name_hash_pairs = await service.get_duplicate_hashes_and_names()
|
||||
# Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries),
|
||||
# counted among the archives this caller may see, as the duplicates view lists them.
|
||||
visible = _visible_archive_clauses(user, can_read_all, printer_scope)
|
||||
duplicate_hashes, duplicate_name_hash_pairs = await service.get_duplicate_hashes_and_names(visible)
|
||||
|
||||
# Batch-load duplicate groups once for the current page keys.
|
||||
duplicate_hashes_in_page = {
|
||||
@@ -482,7 +496,7 @@ async def list_archives(
|
||||
PrintArchive.created_at,
|
||||
PrintArchive.content_hash,
|
||||
func.lower(PrintArchive.print_name).label("print_name_lower"),
|
||||
).where(or_(*duplicate_group_conditions), PrintArchive.deleted_at.is_(None))
|
||||
).where(or_(*duplicate_group_conditions), PrintArchive.deleted_at.is_(None), *visible)
|
||||
)
|
||||
|
||||
duplicate_groups_by_hash: dict[str, list[tuple[int, datetime]]] = defaultdict(list)
|
||||
@@ -617,6 +631,7 @@ async def no_3mf_warning(
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Whether to nudge the user about a print that archived without its 3MF,
|
||||
and why. True iff any archive in the last 30 days was created via the
|
||||
@@ -654,6 +669,8 @@ async def no_3mf_warning(
|
||||
]
|
||||
if user is not None and not can_read_all:
|
||||
conditions.append(PrintArchive.created_by_id == user.id)
|
||||
if (scope_clause := printer_scope.where(PrintArchive.printer_id)) is not None:
|
||||
conditions.append(scope_clause)
|
||||
result = await db.execute(select(PrintArchive.extra_data).where(*conditions))
|
||||
reasons: set[str] = set()
|
||||
has_fallback = False
|
||||
@@ -1633,6 +1650,7 @@ async def get_all_tags(
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""List all unique tags with usage counts.
|
||||
|
||||
@@ -1640,9 +1658,11 @@ async def get_all_tags(
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
# Query all archives with non-null tags
|
||||
tag_conditions = [PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None)]
|
||||
tag_conditions: list[ColumnElement[bool]] = [PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None)]
|
||||
if user is not None and not can_read_all:
|
||||
tag_conditions.append(PrintArchive.created_by_id == user.id)
|
||||
if (scope_clause := printer_scope.where(PrintArchive.printer_id)) is not None:
|
||||
tag_conditions.append(scope_clause)
|
||||
result = await db.execute(select(PrintArchive.tags).where(*tag_conditions))
|
||||
all_tags_rows = result.all()
|
||||
|
||||
@@ -1668,6 +1688,7 @@ async def rename_tag(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Rename a tag across all archives.
|
||||
|
||||
@@ -1685,7 +1706,11 @@ async def rename_tag(
|
||||
|
||||
# Find all archives containing the old tag
|
||||
result = await db.execute(
|
||||
select(PrintArchive).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
|
||||
select(PrintArchive).where(
|
||||
PrintArchive.tags.isnot(None),
|
||||
PrintArchive.deleted_at.is_(None),
|
||||
*_visible_archive_clauses(None, True, printer_scope),
|
||||
)
|
||||
)
|
||||
archives = list(result.scalars().all())
|
||||
|
||||
@@ -1716,6 +1741,7 @@ async def delete_tag(
|
||||
tag_name: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Delete a tag from all archives.
|
||||
|
||||
@@ -1723,7 +1749,11 @@ async def delete_tag(
|
||||
"""
|
||||
# Find all archives containing the tag
|
||||
result = await db.execute(
|
||||
select(PrintArchive).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
|
||||
select(PrintArchive).where(
|
||||
PrintArchive.tags.isnot(None),
|
||||
PrintArchive.deleted_at.is_(None),
|
||||
*_visible_archive_clauses(None, True, printer_scope),
|
||||
)
|
||||
)
|
||||
archives = list(result.scalars().all())
|
||||
|
||||
@@ -1766,6 +1796,7 @@ async def get_archive(
|
||||
content_hash=archive.content_hash,
|
||||
print_name=archive.print_name,
|
||||
makerworld_model_id=makerworld_id,
|
||||
visible=_visible_archive_clauses(user, can_read_all, printer_scope),
|
||||
)
|
||||
run_aggregates = await _load_run_aggregates(db, [archive.id])
|
||||
return archive_to_response(archive, duplicates, run_aggregate=run_aggregates.get(archive.id))
|
||||
@@ -1860,7 +1891,9 @@ async def find_similar_archives(
|
||||
|
||||
service = ArchiveComparisonService(db)
|
||||
try:
|
||||
return await service.find_similar_archives(archive_id, limit=limit)
|
||||
return await service.find_similar_archives(
|
||||
archive_id, limit=limit, visible=_visible_archive_clauses(user, can_read_all, printer_scope)
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
@@ -2133,12 +2166,17 @@ async def rescan_archive(
|
||||
async def recalculate_all_costs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Recalculate costs for all archives based on filament usage and prices."""
|
||||
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
result = await db.execute(select(PrintArchive))
|
||||
# Only the archives of the caller's printers, as the archive list shows them.
|
||||
query = select(PrintArchive)
|
||||
if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
|
||||
query = query.where(clause)
|
||||
result = await db.execute(query)
|
||||
archives = list(result.scalars().all())
|
||||
|
||||
# Load all filaments for lookup
|
||||
@@ -2211,11 +2249,16 @@ async def recalculate_all_costs(
|
||||
async def rescan_all_archives(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Rescan all archives and update their metadata."""
|
||||
from backend.app.services.archive import ThreeMFParser
|
||||
|
||||
result = await db.execute(select(PrintArchive))
|
||||
# Only the archives of the caller's printers, as the archive list shows them.
|
||||
query = select(PrintArchive)
|
||||
if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
|
||||
query = query.where(clause)
|
||||
result = await db.execute(query)
|
||||
archives = list(result.scalars().all())
|
||||
|
||||
updated = 0
|
||||
@@ -2280,6 +2323,7 @@ async def get_archive_duplicates(
|
||||
content_hash=archive.content_hash,
|
||||
print_name=archive.print_name,
|
||||
makerworld_model_id=makerworld_id,
|
||||
visible=_visible_archive_clauses(user, can_read_all, printer_scope),
|
||||
)
|
||||
return {"duplicates": duplicates, "count": len(duplicates)}
|
||||
|
||||
@@ -2288,9 +2332,14 @@ async def get_archive_duplicates(
|
||||
async def backfill_content_hashes(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Compute and store content hashes for all archives missing them."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None)))
|
||||
result = await db.execute(
|
||||
select(PrintArchive).where(
|
||||
PrintArchive.content_hash.is_(None), *_visible_archive_clauses(None, True, printer_scope)
|
||||
)
|
||||
)
|
||||
archives = list(result.scalars().all())
|
||||
|
||||
updated = 0
|
||||
@@ -5468,6 +5517,7 @@ async def upload_source_3mf_by_name(
|
||||
print_name: str = Query(None, description="Match archive by print name"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Upload source 3MF and match to archive by print name.
|
||||
|
||||
@@ -5488,9 +5538,11 @@ async def upload_source_3mf_by_name(
|
||||
print_name = print_name[:-7]
|
||||
|
||||
# Find matching archive - try exact match first, then fuzzy
|
||||
# Only an archive on the caller's printers can be matched, as it is listed.
|
||||
visible = _visible_archive_clauses(None, True, printer_scope)
|
||||
result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(PrintArchive.print_name == print_name)
|
||||
.where(PrintArchive.print_name == print_name, *visible)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -5500,7 +5552,7 @@ async def upload_source_3mf_by_name(
|
||||
# Try matching filename without .gcode.3mf
|
||||
result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(PrintArchive.filename.like(f"{print_name}%"))
|
||||
.where(PrintArchive.filename.like(f"{print_name}%"), *visible)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -5510,7 +5562,7 @@ async def upload_source_3mf_by_name(
|
||||
# Try case-insensitive partial match on print_name
|
||||
result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(PrintArchive.print_name.ilike(f"%{print_name}%"))
|
||||
.where(PrintArchive.print_name.ilike(f"%{print_name}%"), *visible)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@@ -31,6 +31,31 @@ def _may_receive(connection: WebSocket, printer_id: int | None) -> bool:
|
||||
return scope is not None and scope.allows(printer_id)
|
||||
|
||||
|
||||
# A printer id no limited scope holds: what an archive event carries when its
|
||||
# printer cannot be told, so only sockets that see every printer receive it.
|
||||
_UNKNOWN_PRINTER = -1
|
||||
|
||||
|
||||
async def _archive_printer_id(archive_id: Any) -> int | None:
|
||||
"""The printer an archive belongs to (None: printer-less, seen by everyone)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
if not isinstance(archive_id, int):
|
||||
return _UNKNOWN_PRINTER
|
||||
try:
|
||||
async with async_session() as db:
|
||||
row = (await db.execute(select(PrintArchive.printer_id).where(PrintArchive.id == archive_id))).first()
|
||||
except Exception:
|
||||
logger.exception("Could not look up the printer of archive %s for a live update", archive_id)
|
||||
return _UNKNOWN_PRINTER
|
||||
if row is None:
|
||||
return _UNKNOWN_PRINTER
|
||||
return row[0]
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections and broadcasts."""
|
||||
|
||||
@@ -257,13 +282,15 @@ class ConnectionManager:
|
||||
)
|
||||
|
||||
async def send_archive_updated(self, archive: dict):
|
||||
"""Notify clients that an archive was updated."""
|
||||
await self.broadcast(
|
||||
{
|
||||
"type": "archive_updated",
|
||||
"data": archive,
|
||||
}
|
||||
)
|
||||
"""Notify clients that an archive was updated.
|
||||
|
||||
Only sockets that may see the archive's printer receive it: callers send
|
||||
just the changed fields, so the printer is looked up here when missing.
|
||||
"""
|
||||
message: dict[str, Any] = {"type": "archive_updated", "data": archive}
|
||||
if _message_printer_id(message) is None:
|
||||
message["printer_id"] = await _archive_printer_id(archive.get("id"))
|
||||
await self.broadcast(message)
|
||||
|
||||
async def send_queue_item_uploading(
|
||||
self,
|
||||
|
||||
@@ -6,8 +6,10 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
from collections.abc import Sequence
|
||||
from datetime import date, datetime, time, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from defusedxml import ElementTree as ET
|
||||
from sqlalchemy import and_, or_, select, text
|
||||
@@ -1110,9 +1112,13 @@ class ArchiveService:
|
||||
sha256.update(chunk)
|
||||
return sha256.hexdigest()
|
||||
|
||||
async def get_duplicate_hashes_and_names(self) -> tuple[set[str], set[tuple[str, str]]]:
|
||||
async def get_duplicate_hashes_and_names(
|
||||
self, visible: Sequence[Any] = ()
|
||||
) -> tuple[set[str], set[tuple[str, str]]]:
|
||||
"""Get all content hashes and (print name, hash) pairs that appear more than once.
|
||||
|
||||
``visible`` limits the count to archives the caller may see.
|
||||
|
||||
For hashes: returns all hashes with > 1 archive (true duplicates).
|
||||
For name/hash pairs: returns only pairs that have > 1 archive
|
||||
(i.e., same file archived multiple times, not different files with same name).
|
||||
@@ -1127,7 +1133,7 @@ class ArchiveService:
|
||||
# duplicate even though the user only sees one row.
|
||||
result = await self.db.execute(
|
||||
select(PrintArchive.content_hash)
|
||||
.where(PrintArchive.content_hash.isnot(None), PrintArchive.deleted_at.is_(None))
|
||||
.where(PrintArchive.content_hash.isnot(None), PrintArchive.deleted_at.is_(None), *visible)
|
||||
.group_by(PrintArchive.content_hash)
|
||||
.having(func.count(PrintArchive.id) > 1)
|
||||
)
|
||||
@@ -1141,6 +1147,7 @@ class ArchiveService:
|
||||
PrintArchive.print_name.isnot(None),
|
||||
PrintArchive.content_hash.isnot(None),
|
||||
PrintArchive.deleted_at.is_(None),
|
||||
*visible,
|
||||
)
|
||||
.group_by(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
|
||||
.having(func.count(PrintArchive.id) > 1)
|
||||
@@ -1155,9 +1162,12 @@ class ArchiveService:
|
||||
content_hash: str | None = None,
|
||||
print_name: str | None = None,
|
||||
makerworld_model_id: str | None = None,
|
||||
visible: Sequence[Any] = (),
|
||||
) -> list[dict]:
|
||||
"""Find duplicate archives based on hash or name matching.
|
||||
|
||||
``visible`` holds WHERE clauses limiting matches to archives the caller may see.
|
||||
|
||||
Returns list of dicts with id, print_name, created_at, match_type.
|
||||
"""
|
||||
duplicates = []
|
||||
@@ -1171,6 +1181,7 @@ class ArchiveService:
|
||||
PrintArchive.content_hash == content_hash,
|
||||
PrintArchive.id != archive_id,
|
||||
PrintArchive.deleted_at.is_(None),
|
||||
*visible,
|
||||
)
|
||||
)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
@@ -1190,7 +1201,7 @@ class ArchiveService:
|
||||
# Prefer strict name+hash matching when hash exists; fallback to name-only for legacy/manual
|
||||
# archives that may not have a content_hash.
|
||||
if print_name or makerworld_model_id:
|
||||
conditions = [PrintArchive.id != archive_id, PrintArchive.deleted_at.is_(None)]
|
||||
conditions = [PrintArchive.id != archive_id, PrintArchive.deleted_at.is_(None), *visible]
|
||||
|
||||
name_conditions = []
|
||||
if print_name:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -181,12 +184,14 @@ class ArchiveComparisonService:
|
||||
self,
|
||||
archive_id: int,
|
||||
limit: int = 10,
|
||||
visible: Sequence[Any] = (),
|
||||
) -> list[dict]:
|
||||
"""Find archives with similar settings for comparison.
|
||||
|
||||
Args:
|
||||
archive_id: The archive to find similar ones for
|
||||
limit: Maximum number of results
|
||||
visible: WHERE clauses limiting matches to archives the caller may see
|
||||
|
||||
Returns:
|
||||
List of similar archives with match reasons
|
||||
@@ -210,6 +215,7 @@ class ArchiveComparisonService:
|
||||
PrintArchive.id != archive_id,
|
||||
PrintArchive.print_name == reference.print_name,
|
||||
PrintArchive.deleted_at.is_(None),
|
||||
*visible,
|
||||
)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(limit)
|
||||
@@ -236,6 +242,7 @@ class ArchiveComparisonService:
|
||||
PrintArchive.id != archive_id,
|
||||
PrintArchive.content_hash == reference.content_hash,
|
||||
PrintArchive.deleted_at.is_(None),
|
||||
*visible,
|
||||
)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(limit - len(similar))
|
||||
@@ -262,6 +269,8 @@ class ArchiveComparisonService:
|
||||
.where(
|
||||
PrintArchive.id != archive_id,
|
||||
PrintArchive.filament_type == reference.filament_type,
|
||||
PrintArchive.deleted_at.is_(None),
|
||||
*visible,
|
||||
)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(limit - len(similar))
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Bulk archive actions keep to the caller's printers.
|
||||
|
||||
Recalculating costs and rescanning all archives went over every archive,
|
||||
so a member of a group limited to some printers rewrote other printers'
|
||||
archives and got their ids back in the error list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.tests.integration.test_printer_scope_1727 import TEAM_PERMISSIONS, _admin_token, _auth, _group, _user
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def _member(client: AsyncClient, printer_ids: list[int]) -> dict[str, str]:
|
||||
admin = await _admin_token(client)
|
||||
perms = await _group(client, admin, "perms", permissions=[*TEAM_PERMISSIONS, "archives:update_all"])
|
||||
team = await _group(client, admin, "team", printer_ids=printer_ids)
|
||||
return _auth((await _user(client, admin, "member", [perms, team]))[0])
|
||||
|
||||
|
||||
async def test_rescan_all_reports_only_the_team_printers_archives(
|
||||
async_client: AsyncClient, printer_factory, archive_factory
|
||||
):
|
||||
a, b = await printer_factory(name="A"), await printer_factory(name="B")
|
||||
# No file behind either archive, so each one lands in the error list.
|
||||
on_a = await archive_factory(a.id, file_path="archives/missing-a.3mf")
|
||||
await archive_factory(b.id, file_path="archives/missing-b.3mf")
|
||||
member = await _member(async_client, [a.id])
|
||||
|
||||
response = await async_client.post("/api/v1/archives/rescan-all", headers=member)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert [error["id"] for error in response.json()["errors"]] == [on_a.id]
|
||||
|
||||
|
||||
async def test_recalculating_costs_leaves_other_printers_alone(
|
||||
async_client: AsyncClient, db_session, printer_factory, archive_factory
|
||||
):
|
||||
a, b = await printer_factory(name="A"), await printer_factory(name="B")
|
||||
on_a = await archive_factory(a.id, cost=99.0, filament_used_grams=100, filament_type="PLA")
|
||||
on_b = await archive_factory(b.id, cost=99.0, filament_used_grams=100, filament_type="PLA")
|
||||
ids = (on_a.id, on_b.id)
|
||||
member = await _member(async_client, [a.id])
|
||||
|
||||
response = await async_client.post("/api/v1/archives/recalculate-costs", headers=member)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
db_session.expire_all()
|
||||
costs = dict(
|
||||
(await db_session.execute(select(PrintArchive.id, PrintArchive.cost).where(PrintArchive.id.in_(ids)))).all()
|
||||
)
|
||||
assert costs[ids[0]] == 2.5 # 100 g at the default 25.00 per kg
|
||||
assert costs[ids[1]] == 99.0
|
||||
|
||||
|
||||
async def test_tags_keep_to_the_team_printers(async_client: AsyncClient, db_session, printer_factory, archive_factory):
|
||||
a, b = await printer_factory(name="A"), await printer_factory(name="B")
|
||||
on_a = await archive_factory(a.id, tags="shared, alpha")
|
||||
on_b = await archive_factory(b.id, tags="shared, beta")
|
||||
ids = (on_a.id, on_b.id)
|
||||
member = await _member(async_client, [a.id])
|
||||
|
||||
listed = await async_client.get("/api/v1/archives/tags", headers=member)
|
||||
renamed = await async_client.put("/api/v1/archives/tags/shared", headers=member, json={"new_name": "common"})
|
||||
deleted = await async_client.delete("/api/v1/archives/tags/common", headers=member)
|
||||
|
||||
assert sorted(t["name"] for t in listed.json()) == ["alpha", "shared"]
|
||||
assert (renamed.json()["affected"], deleted.json()["affected"]) == (1, 1)
|
||||
db_session.expire_all()
|
||||
tags = dict(
|
||||
(await db_session.execute(select(PrintArchive.id, PrintArchive.tags).where(PrintArchive.id.in_(ids)))).all()
|
||||
)
|
||||
assert tags[ids[1]] == "shared, beta"
|
||||
|
||||
|
||||
async def test_duplicates_and_similar_show_only_visible_archives(
|
||||
async_client: AsyncClient, printer_factory, archive_factory
|
||||
):
|
||||
a, b = await printer_factory(name="A"), await printer_factory(name="B")
|
||||
mine = await archive_factory(a.id, print_name="Benchy", content_hash="h1")
|
||||
same_team = await archive_factory(a.id, print_name="Benchy", content_hash="h1")
|
||||
await archive_factory(b.id, print_name="Benchy", content_hash="h1")
|
||||
member = await _member(async_client, [a.id])
|
||||
|
||||
duplicates = await async_client.get(f"/api/v1/archives/{mine.id}/duplicates", headers=member)
|
||||
detail = await async_client.get(f"/api/v1/archives/{mine.id}", headers=member)
|
||||
similar = await async_client.get(f"/api/v1/archives/{mine.id}/similar", headers=member)
|
||||
|
||||
assert [d["id"] for d in duplicates.json()["duplicates"]] == [same_team.id]
|
||||
assert [d["id"] for d in detail.json()["duplicates"]] == [same_team.id]
|
||||
assert {s["archive"]["id"] for s in similar.json()} == {same_team.id}
|
||||
|
||||
|
||||
async def test_backfill_hashes_reports_only_the_team_printers_archives(
|
||||
async_client: AsyncClient, printer_factory, archive_factory
|
||||
):
|
||||
a, b = await printer_factory(name="A"), await printer_factory(name="B")
|
||||
on_a = await archive_factory(a.id, file_path="archives/none-a.3mf", content_hash=None)
|
||||
await archive_factory(b.id, file_path="archives/none-b.3mf", content_hash=None)
|
||||
member = await _member(async_client, [a.id])
|
||||
|
||||
response = await async_client.post("/api/v1/archives/backfill-hashes", headers=member)
|
||||
|
||||
assert [e["id"] for e in response.json()["errors"]] == [on_a.id]
|
||||
|
||||
|
||||
async def test_uploading_a_source_by_name_matches_only_the_team_printers(
|
||||
async_client: AsyncClient, printer_factory, archive_factory
|
||||
):
|
||||
a, b = await printer_factory(name="A"), await printer_factory(name="B")
|
||||
await archive_factory(b.id, print_name="Secret Part")
|
||||
member = await _member(async_client, [a.id])
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/archives/upload-source",
|
||||
headers=member,
|
||||
params={"print_name": "Secret"},
|
||||
files={"file": ("Secret Part.3mf", b"PK\x03\x04", "application/octet-stream")},
|
||||
)
|
||||
|
||||
assert response.status_code == 404, response.text
|
||||
assert "Secret Part" not in response.text
|
||||
|
||||
|
||||
async def test_the_list_counts_duplicates_among_visible_archives_only(
|
||||
async_client: AsyncClient, printer_factory, archive_factory
|
||||
):
|
||||
a, b = await printer_factory(name="A"), await printer_factory(name="B")
|
||||
await archive_factory(b.id, print_name="Benchy", content_hash="h2") # older, on B
|
||||
mine = await archive_factory(a.id, print_name="Benchy", content_hash="h2")
|
||||
member = await _member(async_client, [a.id])
|
||||
|
||||
listed = {row["id"]: row for row in (await async_client.get("/api/v1/archives/", headers=member)).json()}
|
||||
|
||||
assert listed[mine.id]["duplicate_count"] in (0, None)
|
||||
assert listed[mine.id]["original_archive_id"] is None
|
||||
|
||||
|
||||
async def test_the_no_3mf_warning_ignores_other_printers(async_client: AsyncClient, printer_factory, archive_factory):
|
||||
a, b = await printer_factory(name="A"), await printer_factory(name="B")
|
||||
await archive_factory(b.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"})
|
||||
member = await _member(async_client, [a.id])
|
||||
|
||||
response = await async_client.get("/api/v1/archives/no-3mf-warning", headers=member)
|
||||
|
||||
assert response.json() == {"has_fallback": False, "reason": None}
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Live archive updates reach only sockets that may see the archive's printer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.core import websocket
|
||||
from backend.app.core.printer_scope import PrinterScope
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _socket(printer_ids):
|
||||
scope = PrinterScope() if printer_ids is None else PrinterScope(frozenset(printer_ids))
|
||||
return SimpleNamespace(state=SimpleNamespace(bambuddy_printer_scope=scope), send_text=AsyncMock())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("archive_printer", "expected"),
|
||||
[
|
||||
(1, {"team_a": True, "team_b": False, "everyone": True}),
|
||||
(None, {"team_a": True, "team_b": True, "everyone": True}), # printer-less: in every scope
|
||||
(websocket._UNKNOWN_PRINTER, {"team_a": False, "team_b": False, "everyone": True}),
|
||||
],
|
||||
)
|
||||
async def test_an_update_follows_the_archive_printer(archive_printer, expected):
|
||||
manager = websocket.ConnectionManager()
|
||||
sockets = {"team_a": _socket({1}), "team_b": _socket({2}), "everyone": _socket(None)}
|
||||
manager.active_connections = list(sockets.values())
|
||||
|
||||
with patch.object(websocket, "_archive_printer_id", new=AsyncMock(return_value=archive_printer)):
|
||||
await manager.send_archive_updated({"id": 7, "cost": 1.5})
|
||||
|
||||
assert {name: s.send_text.await_count == 1 for name, s in sockets.items()} == expected
|
||||
|
||||
|
||||
async def test_an_archive_that_cannot_be_looked_up_reaches_only_unrestricted_sockets():
|
||||
with patch("backend.app.core.database.async_session", side_effect=RuntimeError("db down")):
|
||||
assert await websocket._archive_printer_id(7) == websocket._UNKNOWN_PRINTER
|
||||
assert await websocket._archive_printer_id(None) == websocket._UNKNOWN_PRINTER
|
||||
Reference in New Issue
Block a user