Add authenticated case intake and completed-case return API

This commit is contained in:
Nico committed 2026-10-21 14:36:26 +02:00
1 parent 830dabe870
commit 27feda6d7f
14 files changed
+1098 -1

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
import json
from dataclasses import replace
import pytest
pytest.importorskip('starlette')
pytest.importorskip('httpx')
pytest.importorskip('multipart')
from starlette.testclient import TestClient
from viano_bug_analysis.api import APIConfig, Client, Project, IntakeLimits
from viano_bug_analysis.api.app import create_app
from viano_bug_analysis.api.config import APIError
from viano_bug_analysis.lifecycle.checkpoints import CheckpointStore
from viano_bug_analysis.lifecycle.storage import Workspace
from viano_bug_analysis.package import PackageReader
TOKEN = 'test_token_' + 'x' * 40
@pytest.fixture
def api(tmp_path, monkeypatch):
monkeypatch.setenv('TEST_INTAKE_TOKEN', TOKEN)
config = APIConfig(tmp_path / 'service', (Project('core', 'wow', 'vcore'),),
(Client('capture', 'TEST_INTAKE_TOKEN', ('core',)),), limits=IntakeLimits(min_free_bytes=1))
app = create_app(config)
return TestClient(app), app.state.intake, config
def data(kind='new_case'):
result = {'kind': kind, 'project_id': 'core', 'profile_id': 'vcore', 'case_id': 'BUG-000020',
'report': {'observations': ['The observed bug persists.']}}
if kind == 'case_return':
result['feedback'] = {'outcome': 'patch_failed', 'summary': 'Still broken',
'observations': ['The tested patch did not fix it.']}
return result
def post(client, source, *, metadata=None, key='first', role='wowdiag', token=TOKEN):
return client.post('/v1/submissions', headers={'Authorization': 'Bearer ' + token,
'Idempotency-Key': key}, data={'metadata': json.dumps(metadata or data())},
files={role: ('../untrusted.bin', source.read_bytes(), 'application/octet-stream')})
def test_create_replay_conflict_and_private_status(api, source_pair):
client, service, _ = api
source = source_pair[0]
response = post(client, source)
assert response.status_code == 201, response.text
receipt = response.json()
assert receipt['status'] == 'QUEUED'
assert post(client, source).status_code == 200
assert len(list(service.workspace.inbox.glob('*.zip'))) == 1
changed = data() | {'report': {'title': 'Changed'}}
assert post(client, source, metadata=changed).status_code == 409
assert post(client, source, key='different').status_code == 409
assert post(client, source, token='bad').status_code == 401
assert client.get('/v1/submissions/' + receipt['submission_id']).status_code == 401
with PackageReader(next(service.workspace.inbox.glob('*.zip'))) as reader:
assert reader.manifest['project']['id'] == 'core'
assert reader.manifest['state'] == 'NEW'
assert b'The observed bug persists.' in reader.read_bytes(
'intake/submissions/' + receipt['submission_id'] + '/report.json')
def test_return_preserves_history_and_increments_revision_once(api, source_pair, tmp_path):
client, service, _ = api
first = post(client, source_pair[0]).json()
inbox = next(service.workspace.inbox.glob('*.zip'))
# Standalone terminal case simulates transfer back from a different deployment.
from viano_bug_analysis.package import PackageWriter
terminal = tmp_path / 'terminal.zip'
PackageWriter().write(inbox, terminal, manifest_updates={'state': 'OUTPUT_READY',
'analysis_revision': 1, 'history': {'analysis_revisions': 1},
'routing': {'destination': 'outbox'}})
inbox.unlink()
response = post(client, terminal, metadata=data('case_return'), key='return', role='case_package')
assert response.status_code == 201, response.text
returned = next(service.workspace.inbox.glob('*.zip'))
with PackageReader(terminal) as old, PackageReader(returned) as new:
assert new.manifest['state'] == 'REOPENED'
assert new.manifest['analysis_revision'] == 2
assert new.manifest['history']['feedback'] == 1
assert new.manifest['package_id'] == first['package_id']
for ref in old.files:
if ref != 'manifest.json':
assert old.read_bytes(ref) == new.read_bytes(ref)
index = json.loads(new.read_bytes(new.manifest['artifacts']['case_inputs']))
assert any(b'The tested patch did not fix it.' in new.read_bytes(ref) for ref in index['reports'])
assert post(client, terminal, metadata=data('case_return'), key='return', role='case_package').status_code == 200
assert post(client, terminal, metadata=data('case_return'), key='return2', role='case_package').status_code == 409
from viano_bug_analysis.case import CaseBuilder
isolated = CheckpointStore(Workspace(tmp_path / 'analysis'))
isolated.intake(returned)
built = CaseBuilder(isolated).build('BUG-000020', first['package_id'])
with PackageReader(built.package) as reader:
case = json.loads(reader.read_bytes(built.case_ref))
assert any(o['text'] == 'The tested patch did not fix it.'
for o in case['human_observations']['observations'])
def test_active_case_cannot_be_returned(api, source_pair):
client, service, _ = api
post(client, source_pair[0])
source = next(service.workspace.inbox.glob('*.zip'))
assert post(client, source, metadata=data('case_return'), key='return', role='case_package').status_code == 409
def test_limits_and_contract(api, source_pair):
client, _, config = api
assert client.get('/healthz').json() == {'status': 'OK'}
assert client.get('/v1/schema', headers={'Authorization': 'Bearer ' + TOKEN}).status_code == 200
assert post(client, source_pair[0], metadata=data() | {'budget': 99}).status_code == 422
assert post(client, source_pair[0], metadata=data() | {'project_id': 'elsewhere'}).status_code == 403
tiny = replace(config, workspace=config.workspace.parent / 'tiny',
limits=replace(config.limits, max_body_bytes=100))
limited = TestClient(create_app(tiny))
assert post(limited, source_pair[0]).status_code == 413
def test_recover_prepared_after_publication_interruption(api, source_pair, monkeypatch):
client, service, _ = api
publish = service._publish
def interrupted(receipt):
raise OSError('simulated crash')
monkeypatch.setattr(service, '_publish', interrupted)
with pytest.raises(OSError):
service.submit(service.config.clients[0], 'crash', json.dumps(data()).encode(), [('wowdiag', source_pair[0])])
monkeypatch.setattr(service, '_publish', publish)
service.recover()
assert len(list(service.workspace.inbox.glob('*.zip'))) == 1
result, replay = service.submit(service.config.clients[0], 'crash', json.dumps(data()).encode(), [('wowdiag', source_pair[0])])
assert replay and result['status'] == 'QUEUED'
def test_published_receipt_crash_replay_after_worker_consumes(api, source_pair, monkeypatch):
import viano_bug_analysis.api.service as module
client, service, _ = api
original = module.atomic_json
def interrupted(path, value):
if value.get('status') == 'QUEUED':
raise OSError('crash after publication')
original(path, value)
monkeypatch.setattr(module, 'atomic_json', interrupted)
with pytest.raises(OSError):
service.submit(service.config.clients[0], 'crash2', json.dumps(data()).encode(), [('wowdiag', source_pair[0])])
inbox = next(service.workspace.inbox.glob('*.zip'))
service.store.intake(inbox)
inbox.unlink()
monkeypatch.setattr(module, 'atomic_json', original)
service.recover()
assert not list(service.workspace.inbox.glob('*.zip'))
result, replay = service.submit(service.config.clients[0], 'crash2', json.dumps(data()).encode(), [('wowdiag', source_pair[0])])
assert replay and result['status'] == 'VALIDATED'
def test_file_limit_and_partial_upload_cleanup(api, source_pair):
_, _, config = api
tiny = replace(config, workspace=config.workspace.parent / 'files',
limits=replace(config.limits, max_file_bytes=5))
app = create_app(tiny)
response = post(TestClient(app), source_pair[0])
assert response.status_code == 413
assert not list(app.state.intake.staging.iterdir())
def test_return_with_stale_managed_head_is_rejected(api, source_pair, tmp_path):
client, service, _ = api
first = post(client, source_pair[0]).json()
inbox = next(service.workspace.inbox.glob('*.zip'))
from viano_bug_analysis.package import PackageWriter
terminal = tmp_path / 'terminal.zip'
PackageWriter().write(inbox, terminal, manifest_updates={'state': 'OUTPUT_READY',
'analysis_revision': 1, 'history': {'analysis_revisions': 1}, 'routing': {'destination': 'outbox'}})
service.store.intake(terminal)
inbox.unlink()
response = post(client, terminal, metadata=data('case_return'), key='stale', role='case_package')
assert response.status_code == 409
head = service.store.latest('BUG-000020', first['package_id'])
response = post(client, head, metadata=data('case_return'), key='fresh', role='case_package')
assert response.status_code == 201, response.text
assert response.json()['status'] == 'QUEUED' # not the previous OUTPUT_READY result
auth = {'Authorization': 'Bearer ' + TOKEN}
assert client.get('/v1/submissions/' + response.json()['submission_id'] + '/package', headers=auth).status_code == 409
def test_scope_invalid_json_and_mismatched_source(api, source_pair):
client, service, _ = api
response = post(client, source_pair[0], metadata=data() | {'case_id': 'BUG-000021'})
assert response.status_code == 422
assert not list(service.workspace.inbox.glob('*.zip'))
raw = client.post('/v1/submissions', headers={'Authorization': 'Bearer ' + TOKEN,
'Idempotency-Key': 'duplicate-json'}, data={'metadata': '{"kind":"new_case","kind":"case_return"}'},
files={'wowdiag': ('source', source_pair[0].read_bytes())})
assert raw.status_code == 422
first = post(client, source_pair[0]).json()
stranger = Client('stranger', 'OTHER', ('other-project',))
with pytest.raises(APIError) as exc:
service.status(first['submission_id'], stranger)
assert exc.value.status == 404
def test_pair_and_attachments_are_retained(api, source_pair):
client, service, _ = api
response = client.post('/v1/submissions', headers={'Authorization': 'Bearer ' + TOKEN,
'Idempotency-Key': 'paired'}, data={'metadata': json.dumps(data())}, files=[
('wowdiag', ('diag', source_pair[0].read_bytes())),
('wowsrc', ('src', source_pair[1].read_bytes())),
('attachments', ('a', b'opaque-human-notes'))])
assert response.status_code == 201, response.text
with PackageReader(next(service.workspace.inbox.glob('*.zip'))) as reader:
index = json.loads(reader.read_bytes(reader.manifest['artifacts']['case_inputs']))
assert any(reader.read_bytes(ref) == b'opaque-human-notes' for ref in index['attachments'])