@@ -0,0 +1,165 @@
|
||||
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'
|
||||
مرجع در شماره جدید
مسدود کردن کاربر