fix(analyses): filter list endpoint by documentId — prevent cross-doc bbox leak

GET /api/analyses?documentId=X retournait toutes les analyses (le query
param était ignoré). Le frontend prenait alors la première COMPLETED de
la réponse, qui pouvait être celle d'un autre doc → bboxes d'un autre
doc projetées sur l'image en cours.

Backend
- analysis_repo: nouvelle méthode find_by_document(document_id, limit, offset)
- analysis_service: expose find_by_document
- api/analyses: GET /api/analyses accepte ?documentId=... (alias Pydantic
  pour respecter la règle ruff N803). Si présent, filtre via la nouvelle
  méthode; sinon comportement inchangé.
- test: test_list_analyses_filtered_by_document vérifie le routing vers
  find_by_document quand le query param est fourni.

Frontend (defensive)
- DocInspectTab / DocAskTab: filtre client-side
  analyses.find(a => a.documentId === requestedId && a.status === 'COMPLETED')
  pour rester safe même si un backend antérieur ignore le param.
This commit is contained in:
Pier-Jean Malandrino 2026-04-30 15:49:25 +02:00
parent 8ac81b9461
commit aa1a195a61
6 changed files with 51 additions and 6 deletions

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from api.schemas import (
AnalysisResponse,
@ -75,9 +75,15 @@ async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep) -> A
@router.get("", response_model=list[AnalysisResponse])
async def list_analyses(service: ServiceDep) -> list[AnalysisResponse]:
"""List all analysis jobs."""
jobs = await service.find_all()
async def list_analyses(
service: ServiceDep,
document_id: str | None = Query(default=None, alias="documentId"),
) -> list[AnalysisResponse]:
"""List analysis jobs, optionally filtered by documentId."""
if document_id:
jobs = await service.find_by_document(document_id)
else:
jobs = await service.find_all()
return [_to_response(j) for j in jobs]

View file

@ -66,6 +66,19 @@ class SqliteAnalysisRepository:
rows = await cursor.fetchall()
return [_row_to_job(r) for r in rows]
async def find_by_document(
self, document_id: str, *, limit: int = 200, offset: int = 0
) -> list[AnalysisJob]:
"""Return analysis jobs for a given document, newest first."""
async with get_connection() as db:
cursor = await db.execute(
f"{_SELECT_WITH_DOC} WHERE aj.document_id = ? "
"ORDER BY aj.created_at DESC LIMIT ? OFFSET ?",
(document_id, limit, offset),
)
rows = await cursor.fetchall()
return [_row_to_job(r) for r in rows]
async def find_by_id(self, job_id: str) -> AnalysisJob | None:
"""Find an analysis job by ID (with document filename), or return None."""
async with get_connection() as db:

View file

@ -149,6 +149,10 @@ class AnalysisService:
"""Return all analysis jobs, newest first."""
return await self._analysis_repo.find_all()
async def find_by_document(self, document_id: str) -> list[AnalysisJob]:
"""Return analysis jobs for a given document, newest first."""
return await self._analysis_repo.find_by_document(document_id)
async def find_by_id(self, job_id: str) -> AnalysisJob | None:
"""Find an analysis job by ID, or return None."""
return await self._analysis_repo.find_by_id(job_id)

View file

@ -199,6 +199,22 @@ class TestAnalysisEndpoints:
assert data[0]["documentFilename"] == "test.pdf"
assert data[0]["status"] == "PENDING"
def test_list_analyses_filtered_by_document(self, client, mock_analysis_service):
mock_analysis_service.find_all = AsyncMock(return_value=[])
mock_analysis_service.find_by_document = AsyncMock(
return_value=[
AnalysisJob(id="j2", document_id="d42", document_filename="paper.pdf"),
]
)
resp = client.get("/api/analyses?documentId=d42")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["documentId"] == "d42"
mock_analysis_service.find_by_document.assert_awaited_once_with("d42")
mock_analysis_service.find_all.assert_not_awaited()
def test_get_analysis(self, client, mock_analysis_service):
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
job.mark_running()

View file

@ -79,7 +79,10 @@ async function loadAnalysis(): Promise<void> {
try {
const analyses = await fetchDocumentAnalyses(requestedId)
if (requestedId !== props.docId) return
analysis.value = analyses.find((a) => a.status === 'COMPLETED') ?? null
// Defensive: filter by documentId because the backend currently ignores
// the ?documentId= query param and returns all analyses.
analysis.value =
analyses.find((a) => a.documentId === requestedId && a.status === 'COMPLETED') ?? null
} catch (e) {
if (requestedId !== props.docId) return
error.value = (e as Error).message || 'Failed to load analysis'

View file

@ -50,7 +50,10 @@ async function load(): Promise<void> {
try {
const analyses = await fetchDocumentAnalyses(requestedId)
if (requestedId !== props.docId) return
analysis.value = analyses.find((a) => a.status === 'COMPLETED') ?? null
// Defensive: filter by documentId because the backend currently ignores
// the ?documentId= query param and returns all analyses.
analysis.value =
analyses.find((a) => a.documentId === requestedId && a.status === 'COMPLETED') ?? null
} catch (e) {
if (requestedId !== props.docId) return
error.value = (e as Error).message || 'Failed to load analysis'