401 lines
14 KiB
Python
401 lines
14 KiB
Python
from pathlib import Path
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
import services.recording_service.app as recording_module
|
|
from services.recording_service.app import app as recording_app
|
|
from services.shared.db import get_session
|
|
from services.shared.security import issue_app_token
|
|
from services.shared.sql_models import CallRecordingRow
|
|
from services.voice_adapter_service.app import app as voice_app
|
|
|
|
|
|
def _write_audio_fixture(path: Path, size: int = 32) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(b"RIFF" + b"\x00" * size)
|
|
return path
|
|
|
|
|
|
def _supervisor_headers() -> dict[str, str]:
|
|
return {"X-User": "supervisor", "X-Role": "supervisor"}
|
|
|
|
|
|
def _admin_headers() -> dict[str, str]:
|
|
return {"X-User": "admin", "X-Role": "admin"}
|
|
|
|
|
|
def _operator_headers() -> dict[str, str]:
|
|
return {"X-User": "operator", "X-Role": "operator"}
|
|
|
|
|
|
def test_import_from_voice_event_creates_recording_and_is_idempotent(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "recordings"))
|
|
voice_client = TestClient(voice_app)
|
|
recording_client = TestClient(recording_app)
|
|
source_path = _write_audio_fixture(tmp_path / "fixtures" / "call.wav")
|
|
|
|
event = voice_client.post(
|
|
"/integrations/voice/events",
|
|
headers=_admin_headers(),
|
|
json={
|
|
"event_type": "recording.ready",
|
|
"call_id": f"call_{tmp_path.name}",
|
|
"interaction_id": f"int_{tmp_path.name}",
|
|
"payload": {
|
|
"source_path": str(source_path),
|
|
"file_name": "call.wav",
|
|
"mime_type": "audio/wav",
|
|
"duration_seconds": 1,
|
|
},
|
|
},
|
|
)
|
|
assert event.status_code == 200
|
|
event_id = event.json()["event_id"]
|
|
|
|
created = recording_client.post(
|
|
f"/recordings/import-from-voice-event/{event_id}",
|
|
headers=_supervisor_headers(),
|
|
)
|
|
assert created.status_code == 200
|
|
payload = created.json()
|
|
assert payload["recording_id"].startswith("rec_")
|
|
assert payload["status"] == "ready"
|
|
assert payload["mime_type"] == "audio/wav"
|
|
|
|
duplicated = recording_client.post(
|
|
f"/recordings/import-from-voice-event/{event_id}",
|
|
headers=_supervisor_headers(),
|
|
)
|
|
assert duplicated.status_code == 200
|
|
assert duplicated.json()["recording_id"] == payload["recording_id"]
|
|
|
|
|
|
def test_register_recording_lists_filters_and_archives(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
|
|
recording_client = TestClient(recording_app)
|
|
source_path = _write_audio_fixture(tmp_path / "fixtures" / "register.wav")
|
|
call_id = f"call_{tmp_path.name}"
|
|
interaction_id = f"int_{tmp_path.name}"
|
|
|
|
created = recording_client.post(
|
|
"/recordings/register",
|
|
headers=_supervisor_headers(),
|
|
json={
|
|
"call_id": call_id,
|
|
"interaction_id": interaction_id,
|
|
"source_path": str(source_path),
|
|
"file_name": "register.wav",
|
|
"mime_type": "audio/wav",
|
|
"duration_seconds": 2,
|
|
},
|
|
)
|
|
assert created.status_code == 200
|
|
recording_id = created.json()["recording_id"]
|
|
|
|
listed = recording_client.get(f"/recordings?call_id={call_id}", headers=_supervisor_headers())
|
|
assert listed.status_code == 200
|
|
assert any(item["recording_id"] == recording_id for item in listed.json())
|
|
|
|
filtered = recording_client.get(
|
|
f"/recordings?interaction_id={interaction_id}",
|
|
headers=_supervisor_headers(),
|
|
)
|
|
assert filtered.status_code == 200
|
|
assert any(item["recording_id"] == recording_id for item in filtered.json())
|
|
|
|
detail = recording_client.get(f"/recordings/{recording_id}", headers=_supervisor_headers())
|
|
assert detail.status_code == 200
|
|
assert detail.json()["call_id"] == call_id
|
|
|
|
archived = recording_client.post(
|
|
f"/recordings/{recording_id}/archive",
|
|
headers=_supervisor_headers(),
|
|
)
|
|
assert archived.status_code == 200
|
|
assert archived.json()["status"] == "archived"
|
|
|
|
|
|
def test_recording_content_supports_inline_and_attachment(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
|
|
recording_client = TestClient(recording_app)
|
|
source_path = _write_audio_fixture(tmp_path / "fixtures" / "preview.wav")
|
|
|
|
created = recording_client.post(
|
|
"/recordings/register",
|
|
headers=_supervisor_headers(),
|
|
json={
|
|
"call_id": f"call_{tmp_path.name}",
|
|
"source_path": str(source_path),
|
|
"file_name": "preview.wav",
|
|
},
|
|
)
|
|
assert created.status_code == 200
|
|
recording_id = created.json()["recording_id"]
|
|
|
|
inline = recording_client.get(
|
|
f"/recordings/{recording_id}/content?disposition=inline",
|
|
headers=_supervisor_headers(),
|
|
)
|
|
assert inline.status_code == 200
|
|
assert inline.content.startswith(b"RIFF")
|
|
assert inline.headers["content-type"].startswith("audio/wav")
|
|
|
|
attachment = recording_client.get(
|
|
f"/recordings/{recording_id}/content?disposition=attachment",
|
|
headers=_supervisor_headers(),
|
|
)
|
|
assert attachment.status_code == 200
|
|
assert "attachment" in attachment.headers["content-disposition"]
|
|
|
|
|
|
def test_missing_file_marks_recording_missing(tmp_path, monkeypatch):
|
|
storage_root = tmp_path / "managed"
|
|
monkeypatch.setenv("CC_RECORDINGS_DIR", str(storage_root))
|
|
recording_client = TestClient(recording_app)
|
|
source_path = _write_audio_fixture(tmp_path / "fixtures" / "missing.wav")
|
|
|
|
created = recording_client.post(
|
|
"/recordings/register",
|
|
headers=_supervisor_headers(),
|
|
json={
|
|
"call_id": f"call_{tmp_path.name}",
|
|
"source_path": str(source_path),
|
|
"file_name": "missing.wav",
|
|
},
|
|
)
|
|
assert created.status_code == 200
|
|
recording_id = created.json()["recording_id"]
|
|
|
|
stored_files = list(storage_root.rglob("*.wav"))
|
|
assert stored_files
|
|
stored_files[0].unlink()
|
|
|
|
missing = recording_client.get(
|
|
f"/recordings/{recording_id}/content",
|
|
headers=_supervisor_headers(),
|
|
)
|
|
assert missing.status_code == 410
|
|
|
|
detail = recording_client.get(f"/recordings/{recording_id}", headers=_supervisor_headers())
|
|
assert detail.status_code == 200
|
|
assert detail.json()["status"] == "missing"
|
|
|
|
|
|
def test_recording_import_upload_accepts_multipart(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
|
|
recording_client = TestClient(recording_app)
|
|
fixture = _write_audio_fixture(tmp_path / "fixtures" / "upload.wav")
|
|
|
|
with fixture.open("rb") as handle:
|
|
created = recording_client.post(
|
|
"/recordings/import-upload",
|
|
headers=_admin_headers(),
|
|
data={
|
|
"call_id": f"call_upload_{tmp_path.name}",
|
|
"interaction_id": f"int_upload_{tmp_path.name}",
|
|
"file_name": "upload.wav",
|
|
"mime_type": "audio/wav",
|
|
"duration_seconds": "4",
|
|
},
|
|
files={"file": ("upload.wav", handle, "audio/wav")},
|
|
)
|
|
|
|
assert created.status_code == 200
|
|
payload = created.json()
|
|
assert payload["recording_id"].startswith("rec_")
|
|
assert payload["call_id"].startswith("call_upload_")
|
|
assert payload["status"] == "ready"
|
|
assert payload["mime_type"] == "audio/wav"
|
|
|
|
|
|
def test_recording_import_upload_is_idempotent_by_source_event_id(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
|
|
recording_client = TestClient(recording_app)
|
|
fixture = _write_audio_fixture(tmp_path / "fixtures" / "upload-idempotent.wav")
|
|
source_event_id = f"bridge_upload_{tmp_path.name}"
|
|
form_data = {
|
|
"call_id": f"call_upload_idempotent_{tmp_path.name}",
|
|
"interaction_id": f"int_upload_idempotent_{tmp_path.name}",
|
|
"source_event_id": source_event_id,
|
|
"file_name": "upload-idempotent.wav",
|
|
"mime_type": "audio/wav",
|
|
"duration_seconds": "4",
|
|
}
|
|
|
|
with fixture.open("rb") as handle:
|
|
first = recording_client.post(
|
|
"/recordings/import-upload",
|
|
headers=_admin_headers(),
|
|
data=form_data,
|
|
files={"file": ("upload-idempotent.wav", handle, "audio/wav")},
|
|
)
|
|
with fixture.open("rb") as handle:
|
|
second = recording_client.post(
|
|
"/recordings/import-upload",
|
|
headers=_admin_headers(),
|
|
data=form_data,
|
|
files={"file": ("upload-idempotent.wav", handle, "audio/wav")},
|
|
)
|
|
|
|
assert first.status_code == 200
|
|
assert second.status_code == 200
|
|
assert first.json()["recording_id"] == second.json()["recording_id"]
|
|
assert first.json()["source_event_id"] == source_event_id
|
|
|
|
session = get_session()
|
|
try:
|
|
rows = session.execute(
|
|
select(CallRecordingRow).where(CallRecordingRow.source_event_id == source_event_id)
|
|
).scalars().all()
|
|
assert len(rows) == 1
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_register_recording_recovers_from_source_event_id_conflict(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
|
|
source_path = _write_audio_fixture(tmp_path / "fixtures" / "race.wav")
|
|
source_event_id = f"src_race_{tmp_path.name}"
|
|
existing_call_id = f"call_race_{tmp_path.name}"
|
|
|
|
session = get_session()
|
|
original_commit = session.commit
|
|
commit_attempts = {"count": 0}
|
|
|
|
def _conflicting_commit():
|
|
if commit_attempts["count"] == 0:
|
|
commit_attempts["count"] += 1
|
|
other = get_session()
|
|
try:
|
|
existing = recording_module._register_recording(
|
|
other,
|
|
call_id=existing_call_id,
|
|
interaction_id=f"int_race_{tmp_path.name}",
|
|
source_path=source_path.resolve(),
|
|
file_name="race.wav",
|
|
mime_type="audio/wav",
|
|
duration_seconds=3,
|
|
recorded_at=None,
|
|
source_event_id=source_event_id,
|
|
)
|
|
assert existing.source_event_id == source_event_id
|
|
finally:
|
|
other.close()
|
|
raise IntegrityError("duplicate source_event_id", params=None, orig=Exception("duplicate"))
|
|
return original_commit()
|
|
|
|
try:
|
|
monkeypatch.setattr(session, "commit", _conflicting_commit)
|
|
row = recording_module._register_recording(
|
|
session,
|
|
call_id=existing_call_id,
|
|
interaction_id=f"int_race_{tmp_path.name}",
|
|
source_path=source_path.resolve(),
|
|
file_name="race.wav",
|
|
mime_type="audio/wav",
|
|
duration_seconds=3,
|
|
recorded_at=None,
|
|
source_event_id=source_event_id,
|
|
)
|
|
assert row.source_event_id == source_event_id
|
|
|
|
verify = get_session()
|
|
try:
|
|
rows = verify.execute(
|
|
select(CallRecordingRow).where(CallRecordingRow.source_event_id == source_event_id)
|
|
).scalars().all()
|
|
assert len(rows) == 1
|
|
assert rows[0].recording_id == row.recording_id
|
|
finally:
|
|
verify.close()
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_recording_rejects_unsupported_or_oversized_files_and_operator_access(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
|
|
monkeypatch.setenv("RECORDING_MAX_BYTES", "1024")
|
|
recording_client = TestClient(recording_app)
|
|
|
|
unsupported_path = (tmp_path / "fixtures" / "bad.txt")
|
|
unsupported_path.parent.mkdir(parents=True, exist_ok=True)
|
|
unsupported_path.write_text("not audio", encoding="utf-8")
|
|
|
|
unsupported = recording_client.post(
|
|
"/recordings/register",
|
|
headers=_supervisor_headers(),
|
|
json={
|
|
"call_id": f"call_bad_{tmp_path.name}",
|
|
"source_path": str(unsupported_path),
|
|
"file_name": "bad.txt",
|
|
},
|
|
)
|
|
assert unsupported.status_code == 415
|
|
|
|
oversized_path = _write_audio_fixture(tmp_path / "fixtures" / "big.wav", size=4096)
|
|
oversized = recording_client.post(
|
|
"/recordings/register",
|
|
headers=_supervisor_headers(),
|
|
json={
|
|
"call_id": f"call_big_{tmp_path.name}",
|
|
"source_path": str(oversized_path),
|
|
"file_name": "big.wav",
|
|
},
|
|
)
|
|
assert oversized.status_code == 413
|
|
|
|
denied = recording_client.get("/recordings", headers=_operator_headers())
|
|
assert denied.status_code == 403
|
|
|
|
|
|
def test_recording_import_upload_requires_trusted_service_when_admin_fallback_disabled(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
|
|
monkeypatch.setenv("RECORDING_IMPORT_ALLOW_ADMIN", "0")
|
|
monkeypatch.setenv("RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge")
|
|
monkeypatch.setenv("ALLOW_LEGACY_HEADER_AUTH", "0")
|
|
monkeypatch.setenv("APP_TOKEN_SECRET", "track9-service-token-test")
|
|
recording_client = TestClient(recording_app)
|
|
fixture = _write_audio_fixture(tmp_path / "fixtures" / "svc-upload.wav")
|
|
|
|
with fixture.open("rb") as handle:
|
|
denied = recording_client.post(
|
|
"/recordings/import-upload",
|
|
headers=_admin_headers(),
|
|
data={
|
|
"call_id": f"call_upload_denied_{tmp_path.name}",
|
|
"interaction_id": f"int_upload_denied_{tmp_path.name}",
|
|
"file_name": "svc-upload.wav",
|
|
"mime_type": "audio/wav",
|
|
"duration_seconds": "4",
|
|
},
|
|
files={"file": ("svc-upload.wav", handle, "audio/wav")},
|
|
)
|
|
assert denied.status_code == 403
|
|
|
|
token = issue_app_token(
|
|
subject="svc:asterisk-bridge",
|
|
username="asterisk-bridge",
|
|
role="admin",
|
|
auth_source="service",
|
|
provider="track9-test",
|
|
ttl_seconds=300,
|
|
)
|
|
with fixture.open("rb") as handle:
|
|
allowed = recording_client.post(
|
|
"/recordings/import-upload",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
data={
|
|
"call_id": f"call_upload_allowed_{tmp_path.name}",
|
|
"interaction_id": f"int_upload_allowed_{tmp_path.name}",
|
|
"file_name": "svc-upload.wav",
|
|
"mime_type": "audio/wav",
|
|
"duration_seconds": "4",
|
|
},
|
|
files={"file": ("svc-upload.wav", handle, "audio/wav")},
|
|
)
|
|
|
|
assert allowed.status_code == 200
|
|
assert allowed.json()["recording_id"].startswith("rec_")
|