QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun changement de comportement, aucune ligne de logique touchee. 72 fichiers, 4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur les commits voisins reste lisible. `quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui evite le brassage guillemets simples / doubles : le diff porte sur les retours a la ligne, l indentation des appels longs et les virgules finales, pas sur le style de chaine. Verification : 263 tests passent avant et apres, ruff check propre. L activation en CI arrive dans le commit suivant, separement, pour que ce diff-ci ne contienne rien d autre. Co-Authored-By: Claude Opus 5 <[email protected]>
149 lines
5.4 KiB
Python
149 lines
5.4 KiB
Python
"""What may be written to the contracts directory — SEC-021.
|
|
|
|
upload_signed_contract accepted any file with a non-empty name.
|
|
ALLOWED_SIGNED_EXTENSIONS was declared next to it and never read. The file
|
|
landed on disk under a name the application later hands back through
|
|
download_signed_contract, so whatever a player uploaded is what a manager
|
|
opens.
|
|
|
|
The name alone was never enough either: `payload.pdf` says nothing about
|
|
the bytes. Both routes now check the signature as well.
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from app.extensions import db
|
|
from app.models import Contract
|
|
|
|
PDF_BYTES = b'%PDF-1.7\n1 0 obj\n<<>>\nendobj\ntrailer\n%%EOF\n'
|
|
NOT_PDF_BYTES = b'MZ\x90\x00\x03\x00\x00\x00' # a Windows executable header
|
|
|
|
|
|
@pytest.fixture
|
|
def contract_for(app, tmp_path):
|
|
"""A contract row whose file lives in a throwaway directory."""
|
|
|
|
def _make(player_id, uploader_id):
|
|
stored = 'deadbeef.pdf'
|
|
path = tmp_path / stored
|
|
path.write_bytes(PDF_BYTES)
|
|
with app.app_context():
|
|
contract = Contract(
|
|
player_id=player_id,
|
|
uploaded_by_id=uploader_id,
|
|
original_filename='contract.pdf',
|
|
stored_filename=stored,
|
|
file_path=str(path),
|
|
)
|
|
db.session.add(contract)
|
|
db.session.commit()
|
|
return contract.id, tmp_path
|
|
|
|
return _make
|
|
|
|
|
|
class TestSignedUpload:
|
|
def test_an_executable_named_pdf_is_refused(
|
|
self, app, client, as_role, make_user, contract_for
|
|
):
|
|
player_id = as_role('player')
|
|
admin_id = make_user('admin')
|
|
contract_id, directory = contract_for(player_id, admin_id)
|
|
|
|
client.post(
|
|
f'/users/contracts/{contract_id}/upload_signed',
|
|
data={'signed_file': (io.BytesIO(NOT_PDF_BYTES), 'signed.pdf')},
|
|
content_type='multipart/form-data',
|
|
follow_redirects=True,
|
|
)
|
|
|
|
with app.app_context():
|
|
contract = db.session.get(Contract, contract_id)
|
|
assert contract.status != 'signed'
|
|
assert contract.signed_file_path is None
|
|
assert not os.path.exists(directory / 'signed_deadbeef.pdf')
|
|
|
|
def test_a_foreign_extension_is_refused(self, app, client, as_role, make_user, contract_for):
|
|
player_id = as_role('player')
|
|
admin_id = make_user('admin')
|
|
contract_id, _directory = contract_for(player_id, admin_id)
|
|
|
|
client.post(
|
|
f'/users/contracts/{contract_id}/upload_signed',
|
|
data={'signed_file': (io.BytesIO(b'<?php system($_GET[0]); ?>'), 'shell.php')},
|
|
content_type='multipart/form-data',
|
|
follow_redirects=True,
|
|
)
|
|
|
|
with app.app_context():
|
|
assert db.session.get(Contract, contract_id).status != 'signed'
|
|
|
|
def test_a_real_pdf_still_goes_through(self, app, client, as_role, make_user, contract_for):
|
|
"""Guard against over-correcting: signing a contract is the point."""
|
|
player_id = as_role('player')
|
|
admin_id = make_user('admin')
|
|
contract_id, directory = contract_for(player_id, admin_id)
|
|
|
|
client.post(
|
|
f'/users/contracts/{contract_id}/upload_signed',
|
|
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')},
|
|
content_type='multipart/form-data',
|
|
follow_redirects=True,
|
|
)
|
|
|
|
with app.app_context():
|
|
contract = db.session.get(Contract, contract_id)
|
|
assert contract.status == 'signed'
|
|
assert contract.signed_at is not None
|
|
assert os.path.exists(directory / 'signed_deadbeef.pdf')
|
|
|
|
def test_another_player_still_cannot_sign_it(
|
|
self, app, client, as_role, make_user, contract_for
|
|
):
|
|
owner_id = make_user('player')
|
|
admin_id = make_user('admin')
|
|
contract_id, _directory = contract_for(owner_id, admin_id)
|
|
as_role('player')
|
|
|
|
client.post(
|
|
f'/users/contracts/{contract_id}/upload_signed',
|
|
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')},
|
|
content_type='multipart/form-data',
|
|
follow_redirects=True,
|
|
)
|
|
|
|
with app.app_context():
|
|
assert db.session.get(Contract, contract_id).status != 'signed'
|
|
|
|
|
|
class TestTheHelper:
|
|
def test_it_reports_a_missing_file(self, app):
|
|
from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
|
|
|
|
with app.test_request_context('/'):
|
|
assert pdf_upload_error(None, ALLOWED_CONTRACT_EXTENSIONS)
|
|
|
|
def test_it_leaves_the_stream_readable(self, app):
|
|
"""The signature check consumes bytes; the caller still has to save
|
|
the whole file afterwards."""
|
|
from werkzeug.datastructures import FileStorage
|
|
|
|
from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
|
|
|
|
upload = FileStorage(stream=io.BytesIO(PDF_BYTES), filename='c.pdf')
|
|
with app.test_request_context('/'):
|
|
assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS) is None
|
|
assert upload.stream.read() == PDF_BYTES
|
|
|
|
def test_a_name_without_a_dot_is_refused(self, app):
|
|
from werkzeug.datastructures import FileStorage
|
|
|
|
from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
|
|
|
|
upload = FileStorage(stream=io.BytesIO(PDF_BYTES), filename='pdf')
|
|
with app.test_request_context('/'):
|
|
assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)
|