changement vers google drive storage pour les contrats.
CI - Security, Lint & Tests / validate (push) Failing after 1m15s

Tentative d'obtenir un token de connection échoue
This commit is contained in:
cedrick2711
2026-08-26 20:52:40 -04:00
parent 38defc53a5
commit 820cccb25e
20 changed files with 476 additions and 2746 deletions
+40
View File
@@ -0,0 +1,40 @@
"""Add Drive signed upload test."""
import pathlib
p = pathlib.Path('tests/test_contract_uploads.py')
t = p.read_bytes()
drive_test = (
b"\n def test_signed_upload_with_google_drive(\n"
b" self, app, client, as_role, make_user, contract_for, monkeypatch\n"
b" ):\n"
b' """Signing with Drive backend stores gdrive://<id> in signed_file_path."""\n'
b" monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')\n"
b" monkeypatch.setattr(\n"
b" 'app.storage.upload_google_drive_file',\n"
b" lambda **_kwargs: 'drive-signed-456',\n"
b" )\n"
b" player_id = as_role('player')\n"
b" admin_id = make_user('admin')\n"
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
b" client.post(\n"
b" f'/users/contracts/{contract_id}/upload_signed',\n"
b" data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},\n"
b" content_type='multipart/form-data',\n"
b" follow_redirects=True,\n"
b" )\n\n"
b" with app.app_context():\n"
b" contract = db.session.get(Contract, contract_id)\n"
b" assert contract.status == 'signed'\n"
b" assert contract.signed_file_path == 'gdrive://drive-signed-456'\n\n"
)
# Add before 'class TestTheHelper:'
marker = b'\nclass TestTheHelper:'
pos = t.find(marker)
if pos >= 0:
t = t[:pos] + drive_test + t[pos:]
p.write_bytes(t)
print('Added Drive signed upload test')
else:
print('Could not find marker')
+48
View File
@@ -0,0 +1,48 @@
"""Add new tests to test_contract_uploads.py."""
import pathlib
p = pathlib.Path('tests/test_contract_uploads.py')
t = p.read_bytes()
# 1. Header-only test in TestSignedUpload (before 'class TestTheHelper:')
marker = b'\n\nclass TestTheHelper:'
test = (
b"\n def test_a_header_only_pdf_is_refused(\n"
b" self, app, client, as_role, make_user, contract_for\n"
b" ):\n"
b' """A file with a %PDF- header but no real PDF structure is rejected."""\n'
b" player_id = as_role('player')\n"
b" admin_id = make_user('admin')\n"
b" contract_id, directory = contract_for(player_id, admin_id)\n\n"
b" client.post(\n"
b" f'/users/contracts/{contract_id}/upload_signed',\n"
b" data={'signed_file': (io.BytesIO(PDF_HEADER_ONLY_BYTES), 'signed.pdf')},\n"
b" content_type='multipart/form-data',\n"
b" follow_redirects=True,\n"
b" )\n\n"
b" with app.app_context():\n"
b" contract = db.session.get(Contract, contract_id)\n"
b" assert contract.status != 'signed'\n"
b" assert contract.signed_file_path is None\n\n"
)
pos = t.find(marker)
if pos >= 0:
t = t[:pos] + test + t[pos:]
# 2. Header-only helper test at end of TestTheHelper
trailing = t.rstrip(b'\n')
idx = t.find(trailing) + len(trailing)
helper = (
b"\n def test_a_header_only_file_is_refused(self, app):\n"
b' """A stream starting with %PDF- but holding non-PDF data is\n'
b" rejected (the header check is not enough).\"\"\"\n"
b" from werkzeug.datastructures import FileStorage\n\n"
b" from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error\n\n"
b" upload = FileStorage(stream=io.BytesIO(PDF_HEADER_ONLY_BYTES), filename='clever.pdf')\n"
b" with app.test_request_context('/'):\n"
b" assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)\n"
)
t = t[:idx] + helper + t[idx:]
p.write_bytes(t)
print('Added new tests')
+40 -1
View File
@@ -22,7 +22,9 @@ class GoogleDriveStorageError(RuntimeError):
def _setting(name): def _setting(name):
value = os.getenv(name) value = os.getenv(name)
if not value: if not value:
raise GoogleDriveStorageError(f'{name} must be configured for Google Drive document storage.') raise GoogleDriveStorageError(
f'{name} must be configured for Google Drive document storage.'
)
return value return value
@@ -47,6 +49,43 @@ def _drive_service():
return build('drive', 'v3', credentials=credentials, cache_discovery=False) return build('drive', 'v3', credentials=credentials, cache_discovery=False)
def list_folder_files():
"""Return (file_id, name) tuples for every file in the configured folder.
Used by the backup script to enumerate Drive-stored contracts. Returns
only files directly in the folder (not nested inside sub-folders).
Returns:
list[tuple[str, str]]: (file_id, name) for each file found.
"""
try:
folder_id = _setting(GOOGLE_DRIVE_FOLDER_ID_ENV)
page_token = None
results = []
while True:
response = (
_drive_service()
.files()
.list(
q=f"'{folder_id}' in parents and trashed=false",
fields='nextPageToken, files(id, name)',
pageToken=page_token,
pageSize=1000,
)
.execute()
)
for f in response.get('files', []):
results.append((f['id'], f['name']))
page_token = response.get('nextPageToken')
if not page_token:
break
return results
except GoogleDriveStorageError:
raise
except Exception as exc:
raise GoogleDriveStorageError('Google Drive could not list folder contents.') from exc
def upload_file(*, stream, filename, mimetype): def upload_file(*, stream, filename, mimetype):
"""Upload a PDF to the configured owner folder and return its Drive ID.""" """Upload a PDF to the configured owner folder and return its Drive ID."""
try: try:
-1312
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -39,6 +39,10 @@ def pdf_upload_error(file, allowed_extensions):
could put an arbitrary file on the server under a name the application could put an arbitrary file on the server under a name the application
later hands back for download (SEC-021). later hands back for download (SEC-021).
The check now goes beyond the header: it parses the upload with pypdf
and requires at least one parseable page, so a file with ``%PDF-``
prepended or a renamed non-PDF is rejected.
Args: Args:
file: The uploaded FileStorage, or None. file: The uploaded FileStorage, or None.
allowed_extensions: Extensions to accept, lowercase and without dot. allowed_extensions: Extensions to accept, lowercase and without dot.
@@ -53,11 +57,27 @@ def pdf_upload_error(file, allowed_extensions):
if not (stem and dot) or extension.lower() not in allowed_extensions: if not (stem and dot) or extension.lower() not in allowed_extensions:
return _('Only PDF files are allowed for contracts.') return _('Only PDF files are allowed for contracts.')
# Fast-fail: the PDF signature must appear at the start of the stream.
head = file.stream.read(len(PDF_SIGNATURE)) head = file.stream.read(len(PDF_SIGNATURE))
file.stream.seek(0) file.stream.seek(0)
if head != PDF_SIGNATURE: if head != PDF_SIGNATURE:
return _('That file is not a PDF, whatever its name says.') return _('That file is not a PDF, whatever its name says.')
# Full-content validation: parse with pypdf and require at least one
# page. A file with a forged header but no real PDF structure (e.g. a
# renamed executable with ``%PDF-`` prepended) will fail here.
try:
from pypdf import PdfReader
from pypdf.errors import PyPdfError
reader = PdfReader(file.stream, strict=False)
if len(reader.pages) == 0:
return _('That file is not a PDF, whatever its name says.')
except (PyPdfError, ValueError, TypeError):
return _('That file is not a PDF, whatever its name says.')
finally:
file.stream.seek(0)
return None return None
+3 -1
View File
@@ -135,7 +135,9 @@ def upload_contract():
try: try:
stored_path = store_uploaded_document(file, relative_path) stored_path = store_uploaded_document(file, relative_path)
except GoogleDriveStorageError: except GoogleDriveStorageError:
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger') flash(
_('Contract storage is temporarily unavailable. Please try again later.'), 'danger'
)
return redirect(url_for('users.upload_contract')) return redirect(url_for('users.upload_contract'))
contract = Contract( contract = Contract(
+7 -1
View File
@@ -34,8 +34,14 @@ import os
from app.google_drive import ( from app.google_drive import (
GoogleDriveStorageError, GoogleDriveStorageError,
)
from app.google_drive import (
delete_file as delete_google_drive_file, delete_file as delete_google_drive_file,
)
from app.google_drive import (
download_file as download_google_drive_file, download_file as download_google_drive_file,
)
from app.google_drive import (
upload_file as upload_google_drive_file, upload_file as upload_google_drive_file,
) )
@@ -112,7 +118,7 @@ def _rooted(env_name, default_name):
def document_storage_backend(): def document_storage_backend():
"""Return the configured backend for new document uploads.""" """Return the configured backend for new document uploads."""
backend = os.getenv(DOCUMENT_STORAGE_BACKEND_ENV, 'google_drive').strip().lower() backend = os.getenv(DOCUMENT_STORAGE_BACKEND_ENV, 'local').strip().lower()
if backend not in {'google_drive', 'local'}: if backend not in {'google_drive', 'local'}:
raise ValueError( raise ValueError(
f'{DOCUMENT_STORAGE_BACKEND_ENV} must be "google_drive" or "local", not {backend!r}' f'{DOCUMENT_STORAGE_BACKEND_ENV} must be "google_drive" or "local", not {backend!r}'
+81 -2
View File
@@ -42,7 +42,11 @@ from urllib.parse import unquote, urlparse
# to be — see DOCUMENTS_DIR below. # to be — see DOCUMENTS_DIR below.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from app.storage import backups_root, documents_root # noqa: E402 — needs the path above from app.storage import ( # noqa: E402 — needs the path above
backups_root,
document_storage_backend,
documents_root,
)
# Configuration # Configuration
BACKUP_DIR = backups_root() BACKUP_DIR = backups_root()
@@ -292,6 +296,75 @@ def backup_documents():
return zip_path return zip_path
def backup_drive_documents():
"""Download every file from the configured Google Drive folder and archive it.
Called **in addition to** ``backup_documents()`` when the storage backend is
``google_drive``. When the backend is ``local`` this function silently
skips — local backups already handled by ``backup_documents()``.
Returns:
str | None: Path to the created archive, or None when skipped.
Raises:
BackupError: If Drive is configured but listing or downloading fails.
"""
if document_storage_backend() != 'google_drive':
print('[SKIP] Google Drive is not configured; local documents only.')
return None
try:
from app.google_drive import (
GoogleDriveStorageError,
list_folder_files,
)
from app.google_drive import (
download_file as download_drive_file,
)
except ImportError:
raise BackupError(
'Google Drive dependencies are not installed. Install requirements.txt again.'
) from None
print('[INFO] Listing Google Drive contract folder...')
try:
files = list_folder_files()
except (GoogleDriveStorageError, Exception) as exc: # noqa: BLE001 — boundary
raise BackupError(f'Google Drive listing failed: {exc}') from exc
if not files:
print('[INFO] Google Drive folder is empty; no Drive documents to back up.')
return None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_drive_{timestamp}')
download = {}
for file_id, name in files:
print(f' Downloading {name} ({file_id})...')
try:
download[name] = download_drive_file(file_id)
except (GoogleDriveStorageError, Exception) as exc: # noqa: BLE001 — boundary
raise BackupError(f'Google Drive download failed for {name}: {exc}') from exc
try:
import zipfile
zip_path = f'{archive_basename}.zip'
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for name, data in download.items():
zf.writestr(name, data)
except Exception as exc: # noqa: BLE001 — boundary
raise BackupError(f'Drive document archive failed: {exc}') from exc
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
raise BackupError('Drive document archiver reported success but produced an empty file.')
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
print(f'[OK] Drive documents backed up to: {zip_path} ({size_mb:.1f} MB)')
return zip_path
def cleanup_old_backups(): def cleanup_old_backups():
"""Remove backup files older than BACKUP_RETENTION_DAYS.""" """Remove backup files older than BACKUP_RETENTION_DAYS."""
if not os.path.exists(BACKUP_DIR): if not os.path.exists(BACKUP_DIR):
@@ -365,10 +438,16 @@ def main(argv=None):
except BackupError as exc: except BackupError as exc:
documents_ok = False documents_ok = False
print(f'[ERROR] {exc}') print(f'[ERROR] {exc}')
drive_ok = True
try:
backup_drive_documents()
except BackupError as exc:
drive_ok = False
print(f'[ERROR] {exc}')
cleanup_old_backups() cleanup_old_backups()
print() print()
if verified and documents_ok: if verified and documents_ok and drive_ok:
print('=== Backup completed successfully ===') print('=== Backup completed successfully ===')
return 0 return 0
+121
View File
@@ -0,0 +1,121 @@
"""Obtain a Google Drive refresh token for the application.
Run this once from a development machine that has a browser. It opens a local
OAuth redirect server, asks you to log in, and prints the refresh token.
Usage:
python app/supporting_scripts/get_refresh_token.py
Requires:
pip install google-auth-oauthlib
Before running:
1. Create a Google Cloud project and enable the Drive API.
2. Create a **Desktop** OAuth 2.0 client ID.
3. Add your email as a test user in the OAuth consent screen.
4. Run this script and paste the values when prompted.
"""
import os
import sys
def _prompt(label, env_name, default=""):
"""Ask the user for a value, accepting an env-var fallback or a default."""
candidate = os.getenv(env_name) or default
hint = f" [{candidate}]" if candidate else ""
raw = input(f"{label}{hint}: ").strip()
if raw:
return raw
if candidate:
return candidate
return default
def _validate_client_id(client_id):
"""Warn if the client ID doesn't look like a real OAuth client ID."""
if client_id == "YOUR_CLIENT_ID.apps.googleusercontent.com":
print("[ERROR] You must provide a real client ID from the Google Cloud console.")
return False
if not client_id.endswith(".apps.googleusercontent.com"):
print(
f"[WARNING] Client ID should end with '.apps.googleusercontent.com'.\n"
f" Got: {client_id!r}\n"
f" If this is a placeholder, the OAuth request will fail.\n"
)
return True
def _validate_client_secret(secret):
if secret == "YOUR_CLIENT_SECRET":
print("[ERROR] You must provide a real client secret from the Google Cloud console.")
return False
# The GOCSPX- prefix is now used by every client type, so it no longer
# tells us anything about whether the client was created as a Desktop app.
return True
def main():
print("=" * 60)
print("Google Drive Refresh Token — Setup")
print("=" * 60)
print()
# When both env vars are set, skip prompts entirely for headless runs.
env_id = os.getenv("GOOGLE_DRIVE_CLIENT_ID")
env_secret = os.getenv("GOOGLE_DRIVE_CLIENT_SECRET")
if env_id and env_secret:
client_id = env_id
client_secret = env_secret
print(f"Using Client ID from environment: {client_id[:30]}...")
else:
print("Enter your Google Cloud OAuth credentials.")
print("You can also set GOOGLE_DRIVE_CLIENT_ID and GOOGLE_DRIVE_CLIENT_SECRET")
print("environment variables and re-run to skip the prompts.")
print()
client_id = _prompt("Client ID", "GOOGLE_DRIVE_CLIENT_ID")
client_secret = _prompt("Client Secret", "GOOGLE_DRIVE_CLIENT_SECRET")
if not _validate_client_id(client_id) or not _validate_client_secret(client_secret):
sys.exit(1)
print()
print("Opening browser to authenticate with Google...")
print("(If it doesn't open, copy the URL below into your browser.)")
print()
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPE = "https://www.googleapis.com/auth/drive.file"
client_config = {
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uris": ["http://localhost"],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
}
flow = InstalledAppFlow.from_client_config(client_config, scopes=[SCOPE])
credentials = flow.run_local_server(host="localhost", port=8080, open_browser=True)
print()
print("=" * 60)
print("SUCCESS — copy these values into your .env file:")
print("=" * 60)
print()
print("DOCUMENT_STORAGE_BACKEND=google_drive")
print(f"GOOGLE_DRIVE_CLIENT_ID={credentials.client_id}")
print(f"GOOGLE_DRIVE_CLIENT_SECRET={client_secret}")
print(f"GOOGLE_DRIVE_REFRESH_TOKEN={credentials.refresh_token}")
print("GOOGLE_DRIVE_FOLDER_ID=<get this from the Drive folder URL>")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -94,8 +94,8 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
<label for="signed_file">{{ _('Select Signed Contract File') }}</label> <label for="signed_file">{{ _('Select Signed Contract File') }}</label>
<input type="file" id="signed_file" name="signed_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required> <input type="file" id="signed_file" name="signed_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small> <small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button> <button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button>
+2 -2
View File
@@ -24,8 +24,8 @@
<div class="form-group"> <div class="form-group">
<label for="contract_file">{{ _('Contract File') }}</label> <label for="contract_file">{{ _('Contract File') }}</label>
<input type="file" id="contract_file" name="contract_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required> <input type="file" id="contract_file" name="contract_file" accept=".pdf" required>
<small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small> <small class="form-text text-muted">{{ _('Accepted format: PDF') }}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -15,6 +15,7 @@ incident.
|---|---|---| |---|---|---|
| `db_backup_<timestamp>.dump` | Full PostgreSQL dump, custom format | Every account, tryout, evaluation, note and contract record | | `db_backup_<timestamp>.dump` | Full PostgreSQL dump, custom format | Every account, tryout, evaluation, note and contract record |
| `documents_backup_<timestamp>.zip` | `documents/` directory | The contract **files** themselves — they exist only on disk, the database stores paths | | `documents_backup_<timestamp>.zip` | `documents/` directory | The contract **files** themselves — they exist only on disk, the database stores paths |
| `documents_backup_drive_<timestamp>.zip` | Google Drive contract folder | Drive-stored contracts downloaded and archived when `DOCUMENT_STORAGE_BACKEND=google_drive` |
Losing either one alone loses data. A database restore without the document Losing either one alone loses data. A database restore without the document
archive leaves contract rows pointing at files that no longer exist. archive leaves contract rows pointing at files that no longer exist.
+2 -1
View File
@@ -82,8 +82,9 @@ Run through this checklist before deploying to production.
- [ ] Upload size limited to 16MB (`MAX_CONTENT_LENGTH`) - [ ] Upload size limited to 16MB (`MAX_CONTENT_LENGTH`)
- [ ] File extensions restricted to PDF only - [ ] File extensions restricted to PDF only
- [ ] File content validated by parsing with pypdf (`PdfReader`) — header (`%PDF-`) + at least one parseable page
- [ ] Files stored with UUID filenames (not original names) - [ ] Files stored with UUID filenames (not original names)
- [ ] Upload directory outside web root - [ ] Upload directory outside web root (or Google Drive in production)
## Phase 13 — Logging & Monitoring ## Phase 13 — Logging & Monitoring
+1
View File
@@ -26,6 +26,7 @@ frozenlist==1.8.0
greenlet==3.5.4 greenlet==3.5.4
google-api-python-client==2.198.0 google-api-python-client==2.198.0
google-auth==2.56.3 google-auth==2.56.3
pypdf==6.16.2
idna==3.18 idna==3.18
itsdangerous==2.2.0 itsdangerous==2.2.0
Jinja2==3.1.6 Jinja2==3.1.6
+55 -7
View File
@@ -14,13 +14,25 @@ import io
import os import os
import pytest import pytest
from pypdf import PdfWriter as _Pw
from app.extensions import db from app.extensions import db
from app.models import Contract from app.models import Contract
PDF_BYTES = b'%PDF-1.7\n1 0 obj\n<<>>\nendobj\ntrailer\n%%EOF\n'
def _valid_pdf_bytes():
w = _Pw()
w.add_blank_page(612, 792)
buf = io.BytesIO()
w.write(buf)
return buf.getvalue()
_PDF_BYTES = _valid_pdf_bytes()
NOT_PDF_BYTES = b'MZ\x90\x00\x03\x00\x00\x00' # a Windows executable header NOT_PDF_BYTES = b'MZ\x90\x00\x03\x00\x00\x00' # a Windows executable header
PDF_HEADER_ONLY_BYTES = b'%PDF-1.7\n' + b'MZ' * 100 # PDF header over non-PDF content
@pytest.fixture @pytest.fixture
def contract_for(app, tmp_path): def contract_for(app, tmp_path):
@@ -29,7 +41,7 @@ def contract_for(app, tmp_path):
def _make(player_id, uploader_id): def _make(player_id, uploader_id):
stored = 'deadbeef.pdf' stored = 'deadbeef.pdf'
path = tmp_path / stored path = tmp_path / stored
path.write_bytes(PDF_BYTES) path.write_bytes(_PDF_BYTES)
with app.app_context(): with app.app_context():
contract = Contract( contract = Contract(
player_id=player_id, player_id=player_id,
@@ -89,7 +101,7 @@ class TestSignedUpload:
client.post( client.post(
f'/users/contracts/{contract_id}/upload_signed', f'/users/contracts/{contract_id}/upload_signed',
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},
content_type='multipart/form-data', content_type='multipart/form-data',
follow_redirects=True, follow_redirects=True,
) )
@@ -110,7 +122,7 @@ class TestSignedUpload:
client.post( client.post(
f'/users/contracts/{contract_id}/upload_signed', f'/users/contracts/{contract_id}/upload_signed',
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, data={'signed_file': (io.BytesIO(_PDF_BYTES), 'signed.pdf')},
content_type='multipart/form-data', content_type='multipart/form-data',
follow_redirects=True, follow_redirects=True,
) )
@@ -118,6 +130,31 @@ class TestSignedUpload:
with app.app_context(): with app.app_context():
assert db.session.get(Contract, contract_id).status != 'signed' assert db.session.get(Contract, contract_id).status != 'signed'
def test_signed_upload_with_google_drive(
self, app, client, as_role, make_user, contract_for, monkeypatch
):
"""Signing with Drive backend stores gdrive://<id> in signed_file_path."""
monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')
monkeypatch.setattr(
'app.storage.upload_google_drive_file',
lambda **_kwargs: 'drive-signed-456',
)
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_file_path == 'gdrive://drive-signed-456'
class TestTheHelper: class TestTheHelper:
def test_it_reports_a_missing_file(self, app): def test_it_reports_a_missing_file(self, app):
@@ -133,16 +170,27 @@ class TestTheHelper:
from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
upload = FileStorage(stream=io.BytesIO(PDF_BYTES), filename='c.pdf') upload = FileStorage(stream=io.BytesIO(_PDF_BYTES), filename='c.pdf')
with app.test_request_context('/'): with app.test_request_context('/'):
assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS) is None assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS) is None
assert upload.stream.read() == PDF_BYTES assert upload.stream.read() == _PDF_BYTES
def test_a_name_without_a_dot_is_refused(self, app): def test_a_name_without_a_dot_is_refused(self, app):
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
upload = FileStorage(stream=io.BytesIO(PDF_BYTES), filename='pdf') upload = FileStorage(stream=io.BytesIO(_PDF_BYTES), filename='pdf')
with app.test_request_context('/'):
assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)
def test_a_header_only_file_is_refused(self, app):
"""A stream starting with %PDF- but holding non-PDF data is
rejected (the header check is not enough)."""
from werkzeug.datastructures import FileStorage
from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error
upload = FileStorage(stream=io.BytesIO(PDF_HEADER_ONLY_BYTES), filename='clever.pdf')
with app.test_request_context('/'): with app.test_request_context('/'):
assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS) assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)
+16 -4
View File
@@ -28,6 +28,20 @@ from app.storage import (
) )
def _valid_pdf():
"""A real one-page PDF that passes pypdf content validation."""
import io
from pypdf import PdfWriter
w = PdfWriter()
w.add_blank_page(612, 792)
buf = io.BytesIO()
w.write(buf)
buf.seek(0)
return buf
class TestDocumentsRoot: class TestDocumentsRoot:
def test_the_environment_wins(self, tmp_path, monkeypatch): def test_the_environment_wins(self, tmp_path, monkeypatch):
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'elsewhere')) monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'elsewhere'))
@@ -165,10 +179,8 @@ class TestThroughTheUploadRoute:
def _pdf(): def _pdf():
"""The smallest thing pdf_upload_error accepts.""" """The smallest valid one-page PDF that passes pypdf content validation."""
import io return _valid_pdf()
return io.BytesIO(b'%PDF-1.4\n%%EOF\n')
class TestDeletingAnAccountTakesItsDocuments: class TestDeletingAnAccountTakesItsDocuments: