166 خطوط
7.5 KiB
Python
166 خطوط
7.5 KiB
Python
import json
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select, update
|
|
from app.config import settings
|
|
from app.db import Job, claim, engine, sessions
|
|
from app.main import app
|
|
from app.storage import storage
|
|
from app.worker import process
|
|
|
|
KEY = 'test-api-key-' + 'x'*40
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
monkeypatch.setenv('STORAGE_DIR', str(tmp_path))
|
|
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path}/test.db')
|
|
monkeypatch.setenv('API_KEY', KEY)
|
|
monkeypatch.setenv('PUBLIC_BASE_URL', 'http://testserver')
|
|
monkeypatch.setenv('MAX_UPLOAD_BYTES', '1048576')
|
|
settings.cache_clear(); storage.cache_clear(); sessions.cache_clear(); engine.cache_clear()
|
|
with TestClient(app) as client: yield client
|
|
engine().dispose()
|
|
settings.cache_clear(); storage.cache_clear(); sessions.cache_clear(); engine.cache_clear()
|
|
|
|
|
|
def upload(client, data, **fields):
|
|
return client.post('/api/v1/schematics/render', files={'file': ('sample.SchDoc', data, 'application/octet-stream')},
|
|
data=fields, headers={'X-API-Key': KEY})
|
|
|
|
|
|
def test_private_upload_worker_viewer_and_expiry(client, ascii_schematic):
|
|
response = upload(client, ascii_schematic)
|
|
assert response.status_code == 202, response.text
|
|
body = response.json(); id = body['id']; token = {'X-Viewer-Token': body['viewer_token']}
|
|
assert len(id) == 48 and '#token=' in body['viewer_url']
|
|
base = f'/api/v1/schematics/{id}'
|
|
assert client.get(base+'/status').status_code == 404
|
|
assert client.get(base+'/status',headers=token).json()['status'] == 'queued'
|
|
assert client.get(base+'/data',headers=token).status_code == 409
|
|
job = claim(); assert job['id'] == id
|
|
process(job)
|
|
response = client.get(base+'/status',headers=token)
|
|
assert response.json()['status'] == 'completed', response.text
|
|
assert response.json()['compatibility'] == 'partial'
|
|
data = client.get(base+'/data',headers=token).json()
|
|
assert data['components'][0]['designator'] == 'R1'
|
|
drawing = client.get(base+'/svg',headers=token)
|
|
assert drawing.status_code == 200 and '<svg' in drawing.text and 'R1' in drawing.text
|
|
view = client.get('/view/'+id)
|
|
assert view.status_code == 200 and 'viewer.js' in view.text
|
|
assert 'no-referrer' == view.headers['referrer-policy']
|
|
with sessions().begin() as db: db.execute(update(Job).where(Job.id==id).values(expires_at=time.time()-1))
|
|
assert client.get(base+'/data',headers=token).status_code == 410
|
|
assert client.get(base+'/status').status_code == 404
|
|
|
|
|
|
def test_unlisted_and_api_key(client, ascii_schematic):
|
|
assert client.post('/api/v1/schematics/render').status_code == 401
|
|
assert client.post('/api/v1/schematics/render',headers={'X-API-Key':'bad'}).status_code == 401
|
|
body = upload(client,ascii_schematic,access='unlisted').json()
|
|
assert 'viewer_token' not in body
|
|
assert client.get(f"/api/v1/schematics/{body['id']}/status").status_code == 200
|
|
|
|
|
|
def test_upload_validation(client):
|
|
assert upload(client,b'not a schematic').status_code == 422
|
|
response=client.post('/api/v1/schematics/render',files={'file':('bad.PcbDoc',b'a')},headers={'X-API-Key':KEY})
|
|
assert response.status_code == 415
|
|
assert upload(client,b'x'*(1048576+1)).status_code == 413
|
|
assert upload(client,b'|HEADER=x',expires_in=999999999).status_code == 422
|
|
assert upload(client,b'|HEADER=x',access='public').status_code == 422
|
|
|
|
|
|
def test_multipart_limit_before_spooling(client):
|
|
r = client.post('/api/v1/schematics/render',content=b'x'*(3*1048576),
|
|
headers={'X-API-Key':KEY,'Content-Type':'multipart/form-data; boundary=foo'})
|
|
assert r.status_code == 413, r.text
|
|
|
|
|
|
def test_corruption_is_durable_failure(client):
|
|
body = upload(client,b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'+b'\0'*504).json()
|
|
process(claim())
|
|
r = client.get(f"/api/v1/schematics/{body['id']}/status",headers={'X-API-Key':KEY})
|
|
assert r.json()['status'] == 'failed'
|
|
assert r.json()['error']['code'] == 'invalid_schematic'
|
|
|
|
|
|
def test_queue_capacity(client, ascii_schematic, monkeypatch):
|
|
monkeypatch.setenv('MAX_PENDING_JOBS','1'); settings.cache_clear()
|
|
assert upload(client,ascii_schematic).status_code == 202
|
|
assert upload(client,ascii_schematic).status_code == 503
|
|
|
|
|
|
def test_atomic_claim_and_stale_worker_fencing(client, ascii_schematic):
|
|
body=upload(client,ascii_schematic).json()
|
|
with ThreadPoolExecutor(max_workers=4) as pool: jobs=list(pool.map(lambda _:claim(),range(4)))
|
|
found=[job for job in jobs if job]
|
|
assert len(found)==1
|
|
old=found[0]
|
|
with sessions().begin() as db: db.execute(update(Job).where(Job.id==body['id']).values(lease_until=time.time()-1))
|
|
new=claim(); assert new['attempt']==2
|
|
process(new); process(old)
|
|
with sessions()() as db:
|
|
job=db.get(Job,body['id'])
|
|
assert job.status=='completed' and job.output_prefix.endswith('attempt-2')
|
|
|
|
|
|
def test_storage_path_traversal(client):
|
|
for key in ['../etc/passwd','a'*48+'/../input.SchDoc','/tmp/evil','a'*48+'/input.SchDoc/evil']:
|
|
with pytest.raises(ValueError): storage().get(key)
|
|
|
|
|
|
def test_api_docs_and_static(client):
|
|
assert client.get('/openapi.json').json()['info']['version']=='1.0.0'
|
|
assert client.get('/').status_code==200
|
|
assert client.get('/assets/viewer.js').status_code==200
|
|
assert client.get('/assets/not-allowed.js').status_code==404
|
|
assert client.get('/view/nope').status_code==404
|
|
|
|
|
|
def test_production_fails_closed(client, monkeypatch):
|
|
monkeypatch.setenv('APP_ENV','production'); monkeypatch.setenv('API_KEY',''); settings.cache_clear()
|
|
with pytest.raises(RuntimeError,match='API_KEY'): settings()
|
|
monkeypatch.setenv('API_KEY',KEY); settings.cache_clear()
|
|
with pytest.raises(RuntimeError,match='HTTPS'): settings()
|
|
|
|
|
|
def test_expired_storage_cleanup(client, ascii_schematic):
|
|
from app.cleanup import cleanup
|
|
body=upload(client,ascii_schematic).json(); process(claim())
|
|
identifier=body['id']
|
|
with sessions().begin() as db: db.execute(update(Job).where(Job.id==identifier).values(expires_at=time.time()-1))
|
|
assert cleanup()==1
|
|
with sessions()() as db: assert db.get(Job,identifier) is None
|
|
with pytest.raises(FileNotFoundError): storage().get(identifier+'/input.SchDoc')
|
|
|
|
|
|
def test_expired_worker_is_not_reclaimed(client, ascii_schematic):
|
|
body=upload(client,ascii_schematic).json()
|
|
with sessions().begin() as db: db.execute(update(Job).where(Job.id==body['id']).values(expires_at=time.time()-1))
|
|
assert claim() is None
|
|
|
|
|
|
def test_repeated_worker_loss_fails_job(client, ascii_schematic):
|
|
body=upload(client,ascii_schematic).json(); assert claim()['attempt']==1
|
|
for attempt in (2,3):
|
|
with sessions().begin() as db: db.execute(update(Job).where(Job.id==body['id']).values(lease_until=time.time()-1))
|
|
result=claim()
|
|
if attempt==2: assert result['attempt']==2
|
|
else: assert result is None
|
|
with sessions()() as db: assert db.get(Job,body['id']).error_code=='worker_lost'
|
|
|
|
|
|
def test_parser_wall_timeout_is_reported(client,ascii_schematic,monkeypatch):
|
|
import subprocess
|
|
from app import worker
|
|
body=upload(client,ascii_schematic).json(); job=claim()
|
|
def timeout(*args,**kw): raise subprocess.TimeoutExpired('parser',60)
|
|
monkeypatch.setattr(worker.subprocess,'run',timeout)
|
|
process(job)
|
|
status=client.get(f"/api/v1/schematics/{body['id']}/status",headers={'X-API-Key':KEY}).json()
|
|
assert status['status']=='failed' and status['error']['code']=='parse_timeout'
|