feat: durcit l'exécution SQL IA, borne les uploads et ajoute des tests

- sql_executor: remplace le filtre regex fragile par une autorisation SQLite
  (set_authorizer) en complément de mode=ro ; rejette les instructions multiples
- uploads: lecture bornée des PDF (helper read_upload_limited, limite 20 Mo,
  HTTP 413) branchée sur /extract et /save-with-pdf
- tests: suite pytest (54 tests) couvrant amounts, dates, storage, sql_executor,
  uploads et DatabaseService.save_document ; pytest ajouté en dépendance dev

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:11:07 +02:00
parent c15b65f0d9
commit 635a094591
13 changed files with 646 additions and 22 deletions

View File

@@ -0,0 +1,94 @@
"""Tests de l'exécution SQL read-only de l'assistant IA."""
import sqlite3
import pytest
from plesna_gerance.services.sql_executor import (
execute_readonly_sql,
_validate_sql,
_ensure_limit,
_authorizer,
_SQLITE_OK,
_SQLITE_DENY,
_SQLITE_SELECT,
_SQLITE_READ,
_SQLITE_PRAGMA,
MAX_ROWS,
)
# --- Garde lexicale -------------------------------------------------------
def test_validate_rejects_non_select():
with pytest.raises(ValueError):
_validate_sql("DELETE FROM tags")
def test_validate_rejects_stacked_statements():
with pytest.raises(ValueError):
_validate_sql("SELECT 1; DROP TABLE tags")
def test_validate_rejects_unknown_pragma():
with pytest.raises(ValueError):
_validate_sql("PRAGMA writable_schema = ON")
def test_validate_allows_select_and_with():
_validate_sql("SELECT * FROM tags")
_validate_sql("WITH t AS (SELECT 1) SELECT * FROM t")
_validate_sql("PRAGMA table_info(tags)")
def test_ensure_limit_adds_limit():
assert _ensure_limit("SELECT * FROM tags").endswith(f"LIMIT {MAX_ROWS}")
def test_ensure_limit_preserves_existing():
q = "SELECT * FROM tags LIMIT 5"
assert _ensure_limit(q) == q
# --- Autorisation SQLite (unitaire) ---------------------------------------
def test_authorizer_allows_reads():
assert _authorizer(_SQLITE_SELECT, None, None, None, None) == _SQLITE_OK
assert _authorizer(_SQLITE_READ, "tags", "nom", "main", None) == _SQLITE_OK
def test_authorizer_allows_whitelisted_pragma():
assert _authorizer(_SQLITE_PRAGMA, "table_info", "tags", None, None) == _SQLITE_OK
def test_authorizer_denies_unknown_pragma():
assert _authorizer(_SQLITE_PRAGMA, "writable_schema", "ON", None, None) == _SQLITE_DENY
def test_authorizer_denies_unknown_action():
# 9 = SQLITE_DELETE, doit être refusé
assert _authorizer(9, "tags", None, "main", None) == _SQLITE_DENY
# --- Exécution réelle (nécessite une base) --------------------------------
def test_execute_select_returns_rows(db_session):
# init_db seed des tags prédéfinis
result = execute_readonly_sql("SELECT nom FROM tags ORDER BY nom")
assert "nom" in result["columns"]
assert result["row_count"] >= 1
def test_execute_write_blocked(db_session):
# Bloqué par la garde lexicale (ValueError) ou, à défaut, par mode=ro /
# l'autorisation au niveau SQLite (DatabaseError). Dans tous les cas : refusé.
with pytest.raises((ValueError, sqlite3.DatabaseError)):
execute_readonly_sql("DELETE FROM tags")
def test_execute_attach_blocked(db_session):
with pytest.raises((ValueError, sqlite3.DatabaseError)):
execute_readonly_sql("ATTACH DATABASE 'x.db' AS x")