diff --git a/README.md b/README.md index 949d519..32e4d91 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,24 @@ All adapters share these common configuration options: | `STATE_FILE` | No | `.{adapter}_state.json` | State tracking file | | `LOG_LEVEL` | No | `INFO` | Logging level | | `ALLOW_UNSIGNED_WEBHOOKS` | No | `false` | See [Webhook authentication](#webhook-authentication) | +| `MEDIA_BACKEND` | No | `embed` | Where recording audio goes: `embed` (inline base64), `filesystem`, or `s3` | +| `MEDIA_BASE_URL` | No | - | Public base URL prepended to re-hosted media (`filesystem`/`s3`) | +| `MEDIA_FILESYSTEM_PATH` | Only if `MEDIA_BACKEND=filesystem` | - | Directory to write published recordings to | +| `MEDIA_S3_BUCKET` | Only if `MEDIA_BACKEND=s3` | - | S3 (or S3-compatible) bucket for published recordings. Needs the `s3` extra: `pip install ".[s3]"` | +| `MEDIA_S3_REGION` | No | - | S3 region | +| `MEDIA_S3_PREFIX` | No | - | Key prefix for published recordings in the bucket | +| `MEDIA_S3_ENDPOINT_URL` | No | - | Endpoint URL for an S3-compatible store (DigitalOcean Spaces, MinIO, Telnyx Cloud Storage); leave unset for AWS | +| `LAWFUL_BASIS` | No | - | Why this deployment may hold the recording: `consent`, `contract`, `legal_obligation`, `vital_interests`, `public_task`, or `legitimate_interests`. Unset means no `lawful_basis` attachment is emitted; never defaulted | +| `LAWFUL_BASIS_PURPOSES` | No | `recording` | Comma-separated purposes granted under the basis | +| `LAWFUL_BASIS_EXPIRATION` | No | - | ISO 8601 timestamp when the lawful basis expires | +| `LAWFUL_BASIS_JUSTIFICATION` | No | - | Free-text justification, stored in the attachment's metadata | + +The `lawful_basis` attachment (and every other JSON-encoded attachment these +adapters emit, such as the platform tags attachment) follows +draft-ietf-vcon-vcon-core-04 §2.3.2: for `encoding: "json"`, `body` is the +raw JSON value itself (an object or array), not a `json.dumps` string. +`mediatype: "application/json"` is set alongside it, and `start`/`party`/ +`dialog` are always present, as the Attachment Object requires. ## Webhook authentication diff --git a/adapters/asterisk/webhook.py b/adapters/asterisk/webhook.py index 65cf799..e380580 100644 --- a/adapters/asterisk/webhook.py +++ b/adapters/asterisk/webhook.py @@ -32,12 +32,29 @@ def create_app(config: AsteriskConfig) -> FastAPI: ) # Initialize components + publisher = config.build_publisher() + if publisher is None: + logger.warning( + "MEDIA_BACKEND=embed: audio will be inlined as base64, making each " + "vCon roughly 1.3x the size of the recording. Set MEDIA_BACKEND=s3 " + "for anything beyond a lab." + ) + lawful_basis = config.build_lawful_basis() + if not lawful_basis.enabled: + logger.warning( + "LAWFUL_BASIS is unset, so vCons will carry no record of why this " + "deployment may hold the recording. Fine for a lab; not for real " + "conversations." + ) + builder = AsteriskVconBuilder( download_recordings=config.download_recordings, recording_format=config.recording_format, recordings_path=config.recordings_path, ari_url=config.asterisk_ari_url, ari_auth=config.get_ari_auth(), + publisher=publisher, + lawful_basis=lawful_basis, ) poster = HttpPoster(config.conserver_url, config.get_headers(), config.ingress_lists) tracker = StateTracker(config.state_file) diff --git a/adapters/bandwidth/webhook.py b/adapters/bandwidth/webhook.py index 60dddd7..c0c50ec 100644 --- a/adapters/bandwidth/webhook.py +++ b/adapters/bandwidth/webhook.py @@ -32,10 +32,27 @@ def create_app(config: BandwidthConfig) -> FastAPI: ) # Initialize components + publisher = config.build_publisher() + if publisher is None: + logger.warning( + "MEDIA_BACKEND=embed: audio will be inlined as base64, making each " + "vCon roughly 1.3x the size of the recording. Set MEDIA_BACKEND=s3 " + "for anything beyond a lab." + ) + lawful_basis = config.build_lawful_basis() + if not lawful_basis.enabled: + logger.warning( + "LAWFUL_BASIS is unset, so vCons will carry no record of why this " + "deployment may hold the recording. Fine for a lab; not for real " + "conversations." + ) + builder = BandwidthVconBuilder( download_recordings=config.download_recordings, recording_format=config.recording_format, api_auth=config.get_api_auth(), + publisher=publisher, + lawful_basis=lawful_basis, ) poster = HttpPoster(config.conserver_url, config.get_headers(), config.ingress_lists) tracker = StateTracker(config.state_file) diff --git a/adapters/freeswitch/webhook.py b/adapters/freeswitch/webhook.py index a768adb..e3c6f11 100644 --- a/adapters/freeswitch/webhook.py +++ b/adapters/freeswitch/webhook.py @@ -32,11 +32,28 @@ def create_app(config: FreeSwitchConfig) -> FastAPI: ) # Initialize components + publisher = config.build_publisher() + if publisher is None: + logger.warning( + "MEDIA_BACKEND=embed: audio will be inlined as base64, making each " + "vCon roughly 1.3x the size of the recording. Set MEDIA_BACKEND=s3 " + "for anything beyond a lab." + ) + lawful_basis = config.build_lawful_basis() + if not lawful_basis.enabled: + logger.warning( + "LAWFUL_BASIS is unset, so vCons will carry no record of why this " + "deployment may hold the recording. Fine for a lab; not for real " + "conversations." + ) + builder = FreeSwitchVconBuilder( download_recordings=config.download_recordings, recording_format=config.recording_format, recordings_path=config.recordings_path, recordings_url_base=config.recordings_url_base, + publisher=publisher, + lawful_basis=lawful_basis, ) poster = HttpPoster(config.conserver_url, config.get_headers(), config.ingress_lists) tracker = StateTracker(config.state_file) diff --git a/adapters/twilio/webhook.py b/adapters/twilio/webhook.py index ddde9d6..b303e67 100644 --- a/adapters/twilio/webhook.py +++ b/adapters/twilio/webhook.py @@ -31,10 +31,27 @@ def create_app(config: TwilioConfig) -> FastAPI: ) # Initialize components + publisher = config.build_publisher() + if publisher is None: + logger.warning( + "MEDIA_BACKEND=embed: audio will be inlined as base64, making each " + "vCon roughly 1.3x the size of the recording. Set MEDIA_BACKEND=s3 " + "for anything beyond a lab." + ) + lawful_basis = config.build_lawful_basis() + if not lawful_basis.enabled: + logger.warning( + "LAWFUL_BASIS is unset, so vCons will carry no record of why this " + "deployment may hold the recording. Fine for a lab; not for real " + "conversations." + ) + builder = TwilioVconBuilder( download_recordings=config.download_recordings, recording_format=config.recording_format, twilio_auth=config.get_twilio_auth(), + publisher=publisher, + lawful_basis=lawful_basis, ) poster = HttpPoster(config.conserver_url, config.get_headers(), config.ingress_lists) tracker = StateTracker(config.state_file) diff --git a/core/base_builder.py b/core/base_builder.py index 1709f1c..39b089d 100644 --- a/core/base_builder.py +++ b/core/base_builder.py @@ -295,6 +295,26 @@ def build(self, recording_data: BaseRecordingData) -> Vcon | None: vcon.uuid, ) + # The official schema (draft-ietf-vcon-vcon-core-04, Attachment + # Object) requires `start`, `party`, and `dialog` on every + # attachment, and `mediatype` whenever `body` is present. Neither + # vcon-lib's own `add_tag()` (the tags attachment above) nor its + # own validator enforces any of that, so every attachment this + # builder produces gets backfilled here rather than leaving a + # schema-invalid vCon. `created_at` is when this vCon, and every + # attachment on it, was produced, which is what "sent/exchanged" + # means for a tag or a lawful basis record; every attachment here + # is `encoding: "json"`, so `application/json` is always the + # right mediatype. Existing values are never overwritten (the + # lawful_basis attachment already sets its own party/dialog and + # mediatype). + for attachment in vcon.vcon_dict.get("attachments", []): + attachment.setdefault("start", vcon.created_at) + attachment.setdefault("party", 0) + attachment.setdefault("dialog", 0) + if attachment.get("body") is not None: + attachment.setdefault("mediatype", "application/json") + logger.info( f"Created vCon {vcon.uuid} from recording {recording_data.recording_id} " f"(from: {recording_data.from_number}, to: {recording_data.to_number})" diff --git a/core/lawful_basis.py b/core/lawful_basis.py index 8995797..5d96949 100644 --- a/core/lawful_basis.py +++ b/core/lawful_basis.py @@ -90,11 +90,22 @@ def purpose_grants(self, granted_at: str) -> list[dict[str, Any]]: for purpose in self.purposes ] - def apply(self, vcon: Vcon, party_index: int | None = None) -> bool: + def apply(self, vcon: Vcon, party_index: int = 0, dialog_index: int = 0) -> bool: """Attach the configured basis. Returns False when none is configured. Delegates to `vcon-lib`, which owns the shape its own validator and - finder expect. + finder expect. Under draft-ietf-vcon-vcon-core-04 §2.3.2 (CDDL + `body: any`), `body` for `encoding: "json"` is the JSON value itself, + not a `json.dumps` string — so vcon-lib 0.9.6's object body is + correct as-is and is left untouched. (An earlier revision of this + method stringified it against a stale schema fork that typed `body` + as a string; that was wrong for -04 and has been reverted.) + + Every adapter here produces exactly one dialog and references party 0 + as the recording's subject, so `party`/`dialog` default to 0 rather + than being left off. `mediatype` is required whenever `body` is + present (-04's Attachment Object); vcon-lib does not set it, so it is + added here. """ if not self.enabled: return False @@ -115,6 +126,11 @@ def apply(self, vcon: Vcon, party_index: int | None = None) -> bool: expiration=self.expiration, purpose_grants=self.purpose_grants(granted_at), party_index=party_index, + dialog_index=dialog_index, **extra, ) + + attachment = vcon.vcon_dict["attachments"][-1] + attachment.setdefault("mediatype", "application/json") + return True diff --git a/tests/adapters/asterisk/test_lawful_basis_and_media.py b/tests/adapters/asterisk/test_lawful_basis_and_media.py new file mode 100644 index 0000000..4d3dbdb --- /dev/null +++ b/tests/adapters/asterisk/test_lawful_basis_and_media.py @@ -0,0 +1,98 @@ +"""CON-1083: lawful basis and external media, wired the same way as Telnyx. + +`core/lawful_basis.py` and `core/media_publisher.py` were already exercised +through the Telnyx adapter (CON-814/CON-844); this closes the gap for +Asterisk, whose webhook factory previously built `AsteriskVconBuilder` +without either `lawful_basis` or `publisher`, so every vCon it produced +carried no basis and always embedded audio inline. +""" + +import base64 +import hashlib +import logging + +import pytest + +from adapters.asterisk.builder import AsteriskRecordingData, AsteriskVconBuilder +from core.lawful_basis import LawfulBasisConfig +from core.media_publisher import FilesystemPublisher + +AUDIO = b"RIFF" + b"\x00" * 4 + b"WAVEfmt not real audio, just deterministic bytes" + + +def _event(**overrides): + base = { + "recording_name": "rec-1", + "caller_id_num": "+15551234567", + "connected_line_num": "+15559876543", + "direction": "inbound", + "target_uri": "file:/var/spool/asterisk/recording/rec-1.wav", + } + base.update(overrides) + return base + + +@pytest.fixture +def build(monkeypatch): + """Build a vCon with the real builder, audio download stubbed out.""" + monkeypatch.setattr(AsteriskVconBuilder, "_download_recording", lambda self, rd: AUDIO) + + def _build(**builder_kwargs): + builder = AsteriskVconBuilder(recording_format="wav", **builder_kwargs) + vcon = builder.build(AsteriskRecordingData(_event())) + assert vcon is not None, "builder returned None" + return vcon + + return _build + + +def test_lawful_basis_emitted_when_configured(build): + vcon = build(lawful_basis=LawfulBasisConfig(lawful_basis="consent", purposes=["recording"])) + + found = vcon.find_lawful_basis_attachments() + assert len(found) == 1 + attachment = found[0] + + assert attachment["purpose"] == "lawful_basis" + assert "type" not in attachment + assert attachment["party"] == 0 + assert attachment["dialog"] == 0 + assert attachment["encoding"] == "json" + assert attachment["mediatype"] == "application/json" + # draft-ietf-vcon-vcon-core-04 §2.3.2 (CDDL `body: any`): for + # `encoding: "json"`, body is the JSON value itself, not a `json.dumps` + # string. + assert isinstance(attachment["body"], dict), "body must be the JSON object, not a string" + + body = attachment["body"] + assert body["lawful_basis"] == "consent" + + assert "lawful_basis" in vcon.to_dict()["extensions"] + valid, errors = vcon.is_valid() + assert valid, errors + + +def test_lawful_basis_absent_and_warned_when_unset(build, caplog): + with caplog.at_level(logging.WARNING, logger="core.base_builder"): + vcon = build(lawful_basis=None) + + assert vcon.find_lawful_basis_attachments() == [] + assert "lawful_basis" not in (vcon.to_dict().get("extensions") or []) + assert any("no lawful_basis attachment" in r.getMessage() for r in caplog.records) + + +def test_filesystem_media_backend_produces_url_and_hash_not_inline_body(build, tmp_path): + publisher = FilesystemPublisher(destination=tmp_path, base_url="https://media.test/rec") + vcon = build(publisher=publisher) + dialog = vcon.to_dict()["dialog"][0] + + assert dialog["url"].startswith("https://media.test/rec/") + expected_hash = "sha512-" + base64.urlsafe_b64encode( + hashlib.sha512(AUDIO).digest() + ).decode().rstrip("=") + assert dialog["content_hash"] == expected_hash + assert "body" not in dialog, "audio must not be inlined when publishing" + assert "encoding" not in dialog + + valid, errors = vcon.is_valid() + assert valid, errors diff --git a/tests/adapters/bandwidth/test_lawful_basis_and_media.py b/tests/adapters/bandwidth/test_lawful_basis_and_media.py new file mode 100644 index 0000000..f7bd2fc --- /dev/null +++ b/tests/adapters/bandwidth/test_lawful_basis_and_media.py @@ -0,0 +1,96 @@ +"""CON-1083: lawful basis and external media, wired the same way as Telnyx. + +Bandwidth's webhook factory previously built `BandwidthVconBuilder` without +either `lawful_basis` or `publisher`, so every vCon it produced carried no +basis and always embedded audio inline. +""" + +import base64 +import hashlib +import logging + +import pytest + +from adapters.bandwidth.builder import BandwidthRecordingData, BandwidthVconBuilder +from core.lawful_basis import LawfulBasisConfig +from core.media_publisher import FilesystemPublisher + +AUDIO = b"RIFF" + b"\x00" * 4 + b"WAVEfmt not real audio, just deterministic bytes" + + +def _event(**overrides): + base = { + "recordingId": "rec-1", + "from": "+15551234567", + "to": "+15559876543", + "direction": "inbound", + "mediaUrl": "https://bandwidth.test/media/rec-1.wav", + } + base.update(overrides) + return base + + +@pytest.fixture +def build(monkeypatch): + """Build a vCon with the real builder, audio download stubbed out.""" + monkeypatch.setattr(BandwidthVconBuilder, "_download_recording", lambda self, rd: AUDIO) + + def _build(**builder_kwargs): + builder = BandwidthVconBuilder(recording_format="wav", **builder_kwargs) + vcon = builder.build(BandwidthRecordingData(_event())) + assert vcon is not None, "builder returned None" + return vcon + + return _build + + +def test_lawful_basis_emitted_when_configured(build): + vcon = build(lawful_basis=LawfulBasisConfig(lawful_basis="consent", purposes=["recording"])) + + found = vcon.find_lawful_basis_attachments() + assert len(found) == 1 + attachment = found[0] + + assert attachment["purpose"] == "lawful_basis" + assert "type" not in attachment + assert attachment["party"] == 0 + assert attachment["dialog"] == 0 + assert attachment["encoding"] == "json" + assert attachment["mediatype"] == "application/json" + # draft-ietf-vcon-vcon-core-04 §2.3.2 (CDDL `body: any`): for + # `encoding: "json"`, body is the JSON value itself, not a `json.dumps` + # string. + assert isinstance(attachment["body"], dict), "body must be the JSON object, not a string" + + body = attachment["body"] + assert body["lawful_basis"] == "consent" + + assert "lawful_basis" in vcon.to_dict()["extensions"] + valid, errors = vcon.is_valid() + assert valid, errors + + +def test_lawful_basis_absent_and_warned_when_unset(build, caplog): + with caplog.at_level(logging.WARNING, logger="core.base_builder"): + vcon = build(lawful_basis=None) + + assert vcon.find_lawful_basis_attachments() == [] + assert "lawful_basis" not in (vcon.to_dict().get("extensions") or []) + assert any("no lawful_basis attachment" in r.getMessage() for r in caplog.records) + + +def test_filesystem_media_backend_produces_url_and_hash_not_inline_body(build, tmp_path): + publisher = FilesystemPublisher(destination=tmp_path, base_url="https://media.test/rec") + vcon = build(publisher=publisher) + dialog = vcon.to_dict()["dialog"][0] + + assert dialog["url"].startswith("https://media.test/rec/") + expected_hash = "sha512-" + base64.urlsafe_b64encode( + hashlib.sha512(AUDIO).digest() + ).decode().rstrip("=") + assert dialog["content_hash"] == expected_hash + assert "body" not in dialog, "audio must not be inlined when publishing" + assert "encoding" not in dialog + + valid, errors = vcon.is_valid() + assert valid, errors diff --git a/tests/adapters/freeswitch/test_lawful_basis_and_media.py b/tests/adapters/freeswitch/test_lawful_basis_and_media.py new file mode 100644 index 0000000..8f354e1 --- /dev/null +++ b/tests/adapters/freeswitch/test_lawful_basis_and_media.py @@ -0,0 +1,96 @@ +"""CON-1083: lawful basis and external media, wired the same way as Telnyx. + +FreeSWITCH's webhook factory previously built `FreeSwitchVconBuilder` +without either `lawful_basis` or `publisher`, so every vCon it produced +carried no basis and always embedded audio inline. +""" + +import base64 +import hashlib +import logging + +import pytest + +from adapters.freeswitch.builder import FreeSwitchRecordingData, FreeSwitchVconBuilder +from core.lawful_basis import LawfulBasisConfig +from core.media_publisher import FilesystemPublisher + +AUDIO = b"RIFF" + b"\x00" * 4 + b"WAVEfmt not real audio, just deterministic bytes" + + +def _event(**overrides): + base = { + "uuid": "rec-1", + "caller_id_number": "+15551234567", + "destination_number": "+15559876543", + "direction": "inbound", + "recording_url": "https://freeswitch.test/recordings/rec-1.wav", + } + base.update(overrides) + return base + + +@pytest.fixture +def build(monkeypatch): + """Build a vCon with the real builder, audio download stubbed out.""" + monkeypatch.setattr(FreeSwitchVconBuilder, "_download_recording", lambda self, rd: AUDIO) + + def _build(**builder_kwargs): + builder = FreeSwitchVconBuilder(recording_format="wav", **builder_kwargs) + vcon = builder.build(FreeSwitchRecordingData(_event())) + assert vcon is not None, "builder returned None" + return vcon + + return _build + + +def test_lawful_basis_emitted_when_configured(build): + vcon = build(lawful_basis=LawfulBasisConfig(lawful_basis="consent", purposes=["recording"])) + + found = vcon.find_lawful_basis_attachments() + assert len(found) == 1 + attachment = found[0] + + assert attachment["purpose"] == "lawful_basis" + assert "type" not in attachment + assert attachment["party"] == 0 + assert attachment["dialog"] == 0 + assert attachment["encoding"] == "json" + assert attachment["mediatype"] == "application/json" + # draft-ietf-vcon-vcon-core-04 §2.3.2 (CDDL `body: any`): for + # `encoding: "json"`, body is the JSON value itself, not a `json.dumps` + # string. + assert isinstance(attachment["body"], dict), "body must be the JSON object, not a string" + + body = attachment["body"] + assert body["lawful_basis"] == "consent" + + assert "lawful_basis" in vcon.to_dict()["extensions"] + valid, errors = vcon.is_valid() + assert valid, errors + + +def test_lawful_basis_absent_and_warned_when_unset(build, caplog): + with caplog.at_level(logging.WARNING, logger="core.base_builder"): + vcon = build(lawful_basis=None) + + assert vcon.find_lawful_basis_attachments() == [] + assert "lawful_basis" not in (vcon.to_dict().get("extensions") or []) + assert any("no lawful_basis attachment" in r.getMessage() for r in caplog.records) + + +def test_filesystem_media_backend_produces_url_and_hash_not_inline_body(build, tmp_path): + publisher = FilesystemPublisher(destination=tmp_path, base_url="https://media.test/rec") + vcon = build(publisher=publisher) + dialog = vcon.to_dict()["dialog"][0] + + assert dialog["url"].startswith("https://media.test/rec/") + expected_hash = "sha512-" + base64.urlsafe_b64encode( + hashlib.sha512(AUDIO).digest() + ).decode().rstrip("=") + assert dialog["content_hash"] == expected_hash + assert "body" not in dialog, "audio must not be inlined when publishing" + assert "encoding" not in dialog + + valid, errors = vcon.is_valid() + assert valid, errors diff --git a/tests/adapters/telnyx/test_byok_e2e.py b/tests/adapters/telnyx/test_byok_e2e.py index 1f1c38f..c8f6968 100644 --- a/tests/adapters/telnyx/test_byok_e2e.py +++ b/tests/adapters/telnyx/test_byok_e2e.py @@ -175,6 +175,11 @@ def test_configured_lawful_basis_is_emitted_and_valid(real_event, dual_channel_w assert "lawful_basis" in vcon.to_dict()["extensions"] assert vcon.is_valid()[0] + # draft-ietf-vcon-vcon-core-04 §2.3.2 (CDDL `body: any`): for + # `encoding: "json"`, body is the JSON value itself, not a `json.dumps` + # string. + assert isinstance(found[0]["body"], dict), "body must be the JSON object, not a string" + assert found[0]["mediatype"] == "application/json" body = found[0]["body"] assert body["lawful_basis"] == "legitimate_interests" assert [g["purpose"] for g in body["purpose_grants"]] == [ diff --git a/tests/test_attachment_required_fields.py b/tests/test_attachment_required_fields.py new file mode 100644 index 0000000..dd67383 --- /dev/null +++ b/tests/test_attachment_required_fields.py @@ -0,0 +1,78 @@ +"""CON-1083 follow-up: every attachment carries start/party/dialog/mediatype. + +The official schema (draft-ietf-vcon-vcon-core-04, Attachment Object) +requires `start`, `party`, and `dialog` on every attachment, and `mediatype` +whenever `body` is present. `Vcon.add_tag()` already sets `party`/`dialog` +(but not `start` or `mediatype`) on the tags attachment it creates; +`LawfulBasisConfig.apply()` sets `party`/`dialog`/`mediatype` itself but not +`start`. Nothing backfilled the rest until `BaseVconBuilder.build()` started +doing it for every attachment it emits, regardless of purpose. +""" + +from unittest.mock import patch + +from adapters.twilio.builder import TwilioRecordingData, TwilioVconBuilder +from core.lawful_basis import LawfulBasisConfig + +AUDIO = b"RIFF" + b"\x00" * 4 + b"WAVEfmt not real audio, just deterministic bytes" + + +def _built_vcon(**builder_kwargs): + with patch.object(TwilioVconBuilder, "_download_recording", return_value=AUDIO): + builder = TwilioVconBuilder(recording_format="wav", **builder_kwargs) + vcon = builder.build( + TwilioRecordingData( + { + "RecordingSid": "RE1", + "From": "+15551234567", + "To": "+15559876543", + "Direction": "inbound", + "RecordingUrl": "https://api.twilio.com/recordings/RE1", + } + ) + ) + assert vcon is not None, "builder returned None" + return vcon + + +def test_every_attachment_carries_start_party_dialog_and_mediatype(): + """Both the tags attachment and the lawful_basis attachment qualify.""" + vcon = _built_vcon( + lawful_basis=LawfulBasisConfig(lawful_basis="consent", purposes=["recording"]) + ) + + attachments = vcon.to_dict()["attachments"] + assert len(attachments) == 2, "expected a tags attachment and a lawful_basis attachment" + + for attachment in attachments: + assert "start" in attachment, f"{attachment.get('purpose')} attachment has no start" + assert "party" in attachment, f"{attachment.get('purpose')} attachment has no party" + assert "dialog" in attachment, f"{attachment.get('purpose')} attachment has no dialog" + assert attachment.get("body") is not None, f"{attachment.get('purpose')} has no body" + assert ( + attachment.get("mediatype") == "application/json" + ), f"{attachment.get('purpose')} attachment has no mediatype" + + +def test_backfilled_start_matches_created_at(): + vcon = _built_vcon() + + tags_attachment = next(a for a in vcon.to_dict()["attachments"] if a["purpose"] == "tags") + assert tags_attachment["start"] == vcon.created_at + + +def test_lawful_basis_attachment_keeps_its_own_party_and_dialog(): + """LawfulBasisConfig.apply() already sets party/dialog to 0 itself; the + backfill in BaseVconBuilder.build() must not need to (and, via + setdefault, does not) touch them.""" + vcon = _built_vcon( + lawful_basis=LawfulBasisConfig(lawful_basis="consent", purposes=["recording"]) + ) + + lawful_basis_attachment = next( + a for a in vcon.to_dict()["attachments"] if a["purpose"] == "lawful_basis" + ) + assert lawful_basis_attachment["party"] == 0 + assert lawful_basis_attachment["dialog"] == 0 + assert lawful_basis_attachment["start"] == vcon.created_at + assert lawful_basis_attachment["mediatype"] == "application/json" diff --git a/tests/test_lawful_basis.py b/tests/test_lawful_basis.py index b105091..41460d4 100644 --- a/tests/test_lawful_basis.py +++ b/tests/test_lawful_basis.py @@ -15,6 +15,17 @@ def fresh_vcon(): return Vcon.build_new() +def body_of(attachment: dict) -> dict: + """An attachment's body. + + Under draft-ietf-vcon-vcon-core-04 §2.3.2 (CDDL `body: any`), `body` for + `encoding: "json"` is the JSON value itself, not a `json.dumps` string. + vcon-lib 0.9.6's `add_lawful_basis_attachment` already emits it that way. + """ + assert isinstance(attachment["body"], dict), "body must be the JSON object, not a string" + return attachment["body"] + + # -- the refusal to invent ------------------------------------------------- @@ -59,7 +70,11 @@ def test_emitted_attachment_is_found_by_the_library(): found = vcon.find_lawful_basis_attachments() assert len(found) == 1 - assert found[0]["body"]["lawful_basis"] == "consent" + assert body_of(found[0])["lawful_basis"] == "consent" + assert found[0]["party"] == 0 + assert found[0]["dialog"] == 0 + assert found[0]["encoding"] == "json" + assert found[0]["mediatype"] == "application/json" def test_emitted_attachment_keeps_the_vcon_valid(): @@ -104,7 +119,7 @@ def test_all_purposes_are_granted(): purposes=["recording", "transcription", "analysis"], ).apply(vcon) - grants = vcon.find_lawful_basis_attachments()[0]["body"]["purpose_grants"] + grants = body_of(vcon.find_lawful_basis_attachments()[0])["purpose_grants"] assert [g["purpose"] for g in grants] == ["recording", "transcription", "analysis"] assert all(g["granted"] and g["granted_at"] for g in grants) @@ -117,7 +132,7 @@ def test_justification_survives_in_metadata(): justification="Carrier-side recording; controller manages consent.", ).apply(vcon) - body = vcon.find_lawful_basis_attachments()[0]["body"] + body = body_of(vcon.find_lawful_basis_attachments()[0]) assert "controller manages consent" in body["metadata"]["justification"] @@ -125,7 +140,7 @@ def test_expiration_is_carried_when_set(): vcon = fresh_vcon() LawfulBasisConfig(lawful_basis="consent", expiration="2027-01-01T00:00:00+00:00").apply(vcon) - assert vcon.find_lawful_basis_attachments()[0]["body"]["expiration"] is not None + assert body_of(vcon.find_lawful_basis_attachments()[0])["expiration"] is not None # -- configuration --------------------------------------------------------- diff --git a/tests/test_lawful_basis_and_media_twilio.py b/tests/test_lawful_basis_and_media_twilio.py new file mode 100644 index 0000000..9a32c59 --- /dev/null +++ b/tests/test_lawful_basis_and_media_twilio.py @@ -0,0 +1,99 @@ +"""CON-1083: lawful basis and external media, wired the same way as Telnyx. + +Twilio's webhook factory previously built `TwilioVconBuilder` without either +`lawful_basis` or `publisher`, so every vCon it produced carried no basis and +always embedded audio inline. Twilio-specific tests otherwise live at the top +level of `tests/` (see `test_builder.py`, `test_webhook.py`), not under +`tests/adapters/`, so this follows that convention rather than introducing a +new `tests/adapters/twilio/` directory. +""" + +import base64 +import hashlib +import logging + +import pytest + +from adapters.twilio.builder import TwilioRecordingData, TwilioVconBuilder +from core.lawful_basis import LawfulBasisConfig +from core.media_publisher import FilesystemPublisher + +AUDIO = b"RIFF" + b"\x00" * 4 + b"WAVEfmt not real audio, just deterministic bytes" + + +def _webhook_data(**overrides): + base = { + "RecordingSid": "RE1", + "From": "+15551234567", + "To": "+15559876543", + "Direction": "inbound", + "RecordingUrl": "https://api.twilio.com/recordings/RE1", + } + base.update(overrides) + return base + + +@pytest.fixture +def build(monkeypatch): + """Build a vCon with the real builder, audio download stubbed out.""" + monkeypatch.setattr(TwilioVconBuilder, "_download_recording", lambda self, rd: AUDIO) + + def _build(**builder_kwargs): + builder = TwilioVconBuilder(recording_format="wav", **builder_kwargs) + vcon = builder.build(TwilioRecordingData(_webhook_data())) + assert vcon is not None, "builder returned None" + return vcon + + return _build + + +def test_lawful_basis_emitted_when_configured(build): + vcon = build(lawful_basis=LawfulBasisConfig(lawful_basis="consent", purposes=["recording"])) + + found = vcon.find_lawful_basis_attachments() + assert len(found) == 1 + attachment = found[0] + + assert attachment["purpose"] == "lawful_basis" + assert "type" not in attachment + assert attachment["party"] == 0 + assert attachment["dialog"] == 0 + assert attachment["encoding"] == "json" + assert attachment["mediatype"] == "application/json" + # draft-ietf-vcon-vcon-core-04 §2.3.2 (CDDL `body: any`): for + # `encoding: "json"`, body is the JSON value itself, not a `json.dumps` + # string. + assert isinstance(attachment["body"], dict), "body must be the JSON object, not a string" + + body = attachment["body"] + assert body["lawful_basis"] == "consent" + + assert "lawful_basis" in vcon.to_dict()["extensions"] + valid, errors = vcon.is_valid() + assert valid, errors + + +def test_lawful_basis_absent_and_warned_when_unset(build, caplog): + with caplog.at_level(logging.WARNING, logger="core.base_builder"): + vcon = build(lawful_basis=None) + + assert vcon.find_lawful_basis_attachments() == [] + assert "lawful_basis" not in (vcon.to_dict().get("extensions") or []) + assert any("no lawful_basis attachment" in r.getMessage() for r in caplog.records) + + +def test_filesystem_media_backend_produces_url_and_hash_not_inline_body(build, tmp_path): + publisher = FilesystemPublisher(destination=tmp_path, base_url="https://media.test/rec") + vcon = build(publisher=publisher) + dialog = vcon.to_dict()["dialog"][0] + + assert dialog["url"].startswith("https://media.test/rec/") + expected_hash = "sha512-" + base64.urlsafe_b64encode( + hashlib.sha512(AUDIO).digest() + ).decode().rstrip("=") + assert dialog["content_hash"] == expected_hash + assert "body" not in dialog, "audio must not be inlined when publishing" + assert "encoding" not in dialog + + valid, errors = vcon.is_valid() + assert valid, errors