From 70ac8f4b0bfad6353fbeb01f5180d3eff6d133af Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 16:22:43 -0400 Subject: [PATCH 1/6] fix(lawful-basis): stringify attachment body and default party/dialog to 0 vcon-lib 0.9.6's add_lawful_basis_attachment() emits `body` as the attachment dict itself, not the JSON string `encoding: "json"` implies and the vCon schema requires (`body` is `type: string`). Stringify the attachment vcon-lib just appended rather than hand-rolling the shape. Also default party_index/dialog_index to 0 instead of leaving them unset: every adapter builder here produces exactly one dialog and treats party 0 as the recording's subject, and the vCon schema requires both fields on every attachment. Updates the existing lawful-basis tests to decode the body before asserting on its contents. Co-Authored-By: Claude Opus 5.5 --- core/lawful_basis.py | 17 +++++++++++++++-- tests/adapters/telnyx/test_byok_e2e.py | 3 ++- tests/test_lawful_basis.py | 25 +++++++++++++++++++++---- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/core/lawful_basis.py b/core/lawful_basis.py index 8995797..d6b4613 100644 --- a/core/lawful_basis.py +++ b/core/lawful_basis.py @@ -30,6 +30,7 @@ from __future__ import annotations +import json import logging from collections.abc import Iterable from typing import TYPE_CHECKING, Any @@ -90,11 +91,17 @@ 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, then fixes the one thing it gets wrong: vcon-lib 0.9.6's + `add_lawful_basis_attachment` emits `body` as the attachment dict + itself (an object), not the JSON string `encoding: "json"` implies and + the vCon schema requires (`body` is `type: string` there). 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. """ if not self.enabled: return False @@ -115,6 +122,12 @@ 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] + if not isinstance(attachment.get("body"), str): + attachment["body"] = json.dumps(attachment["body"]) + return True diff --git a/tests/adapters/telnyx/test_byok_e2e.py b/tests/adapters/telnyx/test_byok_e2e.py index 1f1c38f..544ead1 100644 --- a/tests/adapters/telnyx/test_byok_e2e.py +++ b/tests/adapters/telnyx/test_byok_e2e.py @@ -175,7 +175,8 @@ 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] - body = found[0]["body"] + assert isinstance(found[0]["body"], str), "body must be a JSON string, not an object" + body = json.loads(found[0]["body"]) assert body["lawful_basis"] == "legitimate_interests" assert [g["purpose"] for g in body["purpose_grants"]] == [ "recording", diff --git a/tests/test_lawful_basis.py b/tests/test_lawful_basis.py index b105091..3e0703b 100644 --- a/tests/test_lawful_basis.py +++ b/tests/test_lawful_basis.py @@ -4,6 +4,8 @@ in this monorepo produced asserted a legal basis for its own existence. """ +import json + import pytest from vcon import Vcon @@ -15,6 +17,18 @@ def fresh_vcon(): return Vcon.build_new() +def body_of(attachment: dict) -> dict: + """Decode an attachment's body. + + vcon-lib 0.9.6's `add_lawful_basis_attachment` emits `body` as an object; + `LawfulBasisConfig.apply` stringifies it to match the schema (`body` is + `type: string`) and `encoding: "json"`. Tests decode it back to assert on + its contents. + """ + assert isinstance(attachment["body"], str), "body must be a JSON string, not an object" + return json.loads(attachment["body"]) + + # -- the refusal to invent ------------------------------------------------- @@ -59,7 +73,10 @@ 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" def test_emitted_attachment_keeps_the_vcon_valid(): @@ -104,7 +121,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 +134,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 +142,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 --------------------------------------------------------- From f67acc9f4c86a3655f1566e9355d219e011eaee0 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 16:22:49 -0400 Subject: [PATCH 2/6] feat(lawful-basis): wire lawful basis and external media into all webhooks Asterisk, Bandwidth, FreeSWITCH and Twilio built their vCon builders without lawful_basis or publisher, so only Telnyx ever emitted a lawful_basis attachment or re-hosted media instead of embedding it inline. The per-platform config classes already had build_lawful_basis()/build_publisher() via BaseConfig, and the builders already accepted both kwargs; only the webhook factories were missing the wiring, so this follows the same pattern already used in adapters/telnyx/webhook.py. Unset LAWFUL_BASIS keeps prior behaviour: no attachment, a startup warning. MEDIA_BACKEND=embed (the default) keeps prior behaviour too. Co-Authored-By: Claude Opus 5.5 --- adapters/asterisk/webhook.py | 17 +++++++++++++++++ adapters/bandwidth/webhook.py | 17 +++++++++++++++++ adapters/freeswitch/webhook.py | 17 +++++++++++++++++ adapters/twilio/webhook.py | 17 +++++++++++++++++ 4 files changed, 68 insertions(+) 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) From 27b5ed161b396afb53c60ad9767ea65f7a4245dc Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 16:22:55 -0400 Subject: [PATCH 3/6] test(lawful-basis): cover lawful basis and external media per platform Asterisk, Bandwidth, FreeSWITCH and Twilio each get the same coverage Telnyx already had: a configured LAWFUL_BASIS produces a correctly shaped attachment (string body, purpose/party/dialog/encoding, extensions), an unset one produces neither an attachment nor a warning-free log, and MEDIA_BACKEND=filesystem produces a dialog with url + content_hash and no inline body. Twilio's file lives at the tests/ top level rather than under tests/adapters/, matching how its other tests are already organized in this repo. Co-Authored-By: Claude Opus 5.5 --- .../asterisk/test_lawful_basis_and_media.py | 95 ++++++++++++++++++ .../bandwidth/test_lawful_basis_and_media.py | 93 ++++++++++++++++++ .../freeswitch/test_lawful_basis_and_media.py | 93 ++++++++++++++++++ tests/test_lawful_basis_and_media_twilio.py | 96 +++++++++++++++++++ 4 files changed, 377 insertions(+) create mode 100644 tests/adapters/asterisk/test_lawful_basis_and_media.py create mode 100644 tests/adapters/bandwidth/test_lawful_basis_and_media.py create mode 100644 tests/adapters/freeswitch/test_lawful_basis_and_media.py create mode 100644 tests/test_lawful_basis_and_media_twilio.py 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..5cca830 --- /dev/null +++ b/tests/adapters/asterisk/test_lawful_basis_and_media.py @@ -0,0 +1,95 @@ +"""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 json +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 isinstance(attachment["body"], str), "body must be a JSON string, not an object" + + body = json.loads(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..a646dc6 --- /dev/null +++ b/tests/adapters/bandwidth/test_lawful_basis_and_media.py @@ -0,0 +1,93 @@ +"""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 json +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 isinstance(attachment["body"], str), "body must be a JSON string, not an object" + + body = json.loads(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..cc48e34 --- /dev/null +++ b/tests/adapters/freeswitch/test_lawful_basis_and_media.py @@ -0,0 +1,93 @@ +"""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 json +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 isinstance(attachment["body"], str), "body must be a JSON string, not an object" + + body = json.loads(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/test_lawful_basis_and_media_twilio.py b/tests/test_lawful_basis_and_media_twilio.py new file mode 100644 index 0000000..1e98834 --- /dev/null +++ b/tests/test_lawful_basis_and_media_twilio.py @@ -0,0 +1,96 @@ +"""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 json +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 isinstance(attachment["body"], str), "body must be a JSON string, not an object" + + body = json.loads(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 From 135525bcdae8f098090706c31e9fe0a7d6eed43d Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 16:22:59 -0400 Subject: [PATCH 4/6] docs(readme): list LAWFUL_BASIS* and MEDIA_* in common configuration Both apply to every adapter now, not just Telnyx. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 949d519..e208408 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,17 @@ 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 | ## Webhook authentication From 831f5556ce66755687cd311dd172504587053428 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 16:27:15 -0400 Subject: [PATCH 5/6] fix(attachments): backfill start/party/dialog on every emitted attachment The official schema (draft-ietf-vcon-vcon-core, Attachment Object) lists start, party, and dialog as required on every attachment, not optional. vcon-lib's own add_tag() already sets party/dialog on the tags attachment it creates but never start, and its own validator does not check for any of the three, so every vCon this monorepo produced was schema-invalid on that attachment regardless of lawful basis. BaseVconBuilder.build() now backfills all three on every attachment it emits, once, after tags and lawful_basis are both added: start from the vCon's own created_at (the attachment is produced at conversion time, which is what "sent/exchanged" means here, in the same ISO 8601 + timezone format created_at already uses), party/dialog defaulting to 0. Uses setdefault, so an attachment that already carries a value (the lawful_basis attachment already sets its own party/dialog) is never overwritten. Regenerating sample vCons per platform against the official schema (vcon_json_schema.json) confirms the start/party/dialog gap is closed, but surfaces two further, distinct, pre-existing schema mismatches this fix does not touch: - The embedded-audio path sets dialog.encoding = "base64", which is not in the schema's enum (base64url | json | none). - The tags attachment's body is a JSON array (vcon-lib's own add_tag() shape), not the string the schema requires for `body`. Stringifying it here would silently break vcon-lib's own Vcon.get_tag() (which iterates body expecting list items) and any later Vcon.add_tag() call on the same vCon (which appends to body expecting a list), so it is left alone rather than worked around. Both are unrelated to lawful basis and out of scope for this card. Co-Authored-By: Claude Opus 5.5 --- core/base_builder.py | 15 +++++ tests/test_attachment_required_fields.py | 72 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 tests/test_attachment_required_fields.py diff --git a/core/base_builder.py b/core/base_builder.py index 1709f1c..27356c6 100644 --- a/core/base_builder.py +++ b/core/base_builder.py @@ -295,6 +295,21 @@ def build(self, recording_data: BaseRecordingData) -> Vcon | None: vcon.uuid, ) + # The official schema (draft-ietf-vcon-vcon-core, Attachment Object) + # requires `start`, `party`, and `dialog` on every attachment. + # Neither vcon-lib's own `add_tag()` (the tags attachment above) + # nor its own validator enforces 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. Existing values are + # never overwritten (the lawful_basis attachment already sets its + # own party/dialog). + for attachment in vcon.vcon_dict.get("attachments", []): + attachment.setdefault("start", vcon.created_at) + attachment.setdefault("party", 0) + attachment.setdefault("dialog", 0) + 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/tests/test_attachment_required_fields.py b/tests/test_attachment_required_fields.py new file mode 100644 index 0000000..6e436b2 --- /dev/null +++ b/tests/test_attachment_required_fields.py @@ -0,0 +1,72 @@ +"""CON-1083 follow-up: every attachment carries start/party/dialog. + +The official schema (draft-ietf-vcon-vcon-core, Attachment Object) requires +`start`, `party`, and `dialog` on every attachment. `Vcon.add_tag()` already +sets `party`/`dialog` (but not `start`) on the tags attachment it creates; +`LawfulBasisConfig.apply()` sets all three itself. Nothing backfilled +`start` for the tags attachment 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_and_dialog(): + """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" + + +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 From 10e66211d221d721ef7298d83d8533e788162400 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 17:27:32 -0400 Subject: [PATCH 6/6] fix(lawful-basis): retarget attachment shape to draft-ietf-vcon-vcon-core-04 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the earlier json.dumps stringification of the lawful_basis attachment body: -04 §2.3.2 (CDDL body: any) makes body the raw JSON value for encoding: "json", not a string. That fix was written against a stale schema fork that typed body as a string; the working group's current schema (vendored at vcon-adapter-template's tests/schema) does not. vcon-lib 0.9.6's object body was already correct and is left untouched. mediatype is required whenever body is present (-04's Attachment Object). vcon-lib sets it on neither the lawful_basis attachment nor the tags attachment from add_tag(), so LawfulBasisConfig.apply() and the attachment backfill loop in BaseVconBuilder.build() both now setdefault mediatype to "application/json" (every attachment either of them touches is encoding: "json"). Updates every test that asserted body was a JSON string to assert it is the JSON object/array instead, and adds mediatype coverage alongside the existing start/party/dialog checks. README: documents that JSON-encoded attachment bodies are raw values under -04, not json.dumps strings. Regenerated sample vCons (asterisk, bandwidth, freeswitch, twilio, telnyx) with LAWFUL_BASIS set and MEDIA_BACKEND=filesystem, validated against the vendored working-group schema (main @ fdcf2f5): all five VALID. (The embedded-audio dialog.encoding="base64" mismatch is unrelated and tracked separately as CON-1100.) Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++++ core/base_builder.py | 19 +++++++++------ core/lawful_basis.py | 23 +++++++++++-------- .../asterisk/test_lawful_basis_and_media.py | 9 +++++--- .../bandwidth/test_lawful_basis_and_media.py | 9 +++++--- .../freeswitch/test_lawful_basis_and_media.py | 9 +++++--- tests/adapters/telnyx/test_byok_e2e.py | 8 +++++-- tests/test_attachment_required_fields.py | 22 +++++++++++------- tests/test_lawful_basis.py | 16 ++++++------- tests/test_lawful_basis_and_media_twilio.py | 9 +++++--- 10 files changed, 83 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index e208408..32e4d91 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,13 @@ All adapters share these common configuration options: | `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 Every adapter validates its incoming webhooks by default (`VALIDATE_*_WEBHOOK=true`): diff --git a/core/base_builder.py b/core/base_builder.py index 27356c6..39b089d 100644 --- a/core/base_builder.py +++ b/core/base_builder.py @@ -295,20 +295,25 @@ def build(self, recording_data: BaseRecordingData) -> Vcon | None: vcon.uuid, ) - # The official schema (draft-ietf-vcon-vcon-core, Attachment Object) - # requires `start`, `party`, and `dialog` on every attachment. - # Neither vcon-lib's own `add_tag()` (the tags attachment above) - # nor its own validator enforces that, so every attachment this + # 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. Existing values are - # never overwritten (the lawful_basis attachment already sets its - # own party/dialog). + # 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} " diff --git a/core/lawful_basis.py b/core/lawful_basis.py index d6b4613..5d96949 100644 --- a/core/lawful_basis.py +++ b/core/lawful_basis.py @@ -30,7 +30,6 @@ from __future__ import annotations -import json import logging from collections.abc import Iterable from typing import TYPE_CHECKING, Any @@ -95,13 +94,18 @@ 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, then fixes the one thing it gets wrong: vcon-lib 0.9.6's - `add_lawful_basis_attachment` emits `body` as the attachment dict - itself (an object), not the JSON string `encoding: "json"` implies and - the vCon schema requires (`body` is `type: string` there). 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. + 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 @@ -127,7 +131,6 @@ def apply(self, vcon: Vcon, party_index: int = 0, dialog_index: int = 0) -> bool ) attachment = vcon.vcon_dict["attachments"][-1] - if not isinstance(attachment.get("body"), str): - attachment["body"] = json.dumps(attachment["body"]) + 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 index 5cca830..4d3dbdb 100644 --- a/tests/adapters/asterisk/test_lawful_basis_and_media.py +++ b/tests/adapters/asterisk/test_lawful_basis_and_media.py @@ -9,7 +9,6 @@ import base64 import hashlib -import json import logging import pytest @@ -59,9 +58,13 @@ def test_lawful_basis_emitted_when_configured(build): assert attachment["party"] == 0 assert attachment["dialog"] == 0 assert attachment["encoding"] == "json" - assert isinstance(attachment["body"], str), "body must be a JSON string, not an object" + 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 = json.loads(attachment["body"]) + body = attachment["body"] assert body["lawful_basis"] == "consent" assert "lawful_basis" in vcon.to_dict()["extensions"] diff --git a/tests/adapters/bandwidth/test_lawful_basis_and_media.py b/tests/adapters/bandwidth/test_lawful_basis_and_media.py index a646dc6..f7bd2fc 100644 --- a/tests/adapters/bandwidth/test_lawful_basis_and_media.py +++ b/tests/adapters/bandwidth/test_lawful_basis_and_media.py @@ -7,7 +7,6 @@ import base64 import hashlib -import json import logging import pytest @@ -57,9 +56,13 @@ def test_lawful_basis_emitted_when_configured(build): assert attachment["party"] == 0 assert attachment["dialog"] == 0 assert attachment["encoding"] == "json" - assert isinstance(attachment["body"], str), "body must be a JSON string, not an object" + 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 = json.loads(attachment["body"]) + body = attachment["body"] assert body["lawful_basis"] == "consent" assert "lawful_basis" in vcon.to_dict()["extensions"] diff --git a/tests/adapters/freeswitch/test_lawful_basis_and_media.py b/tests/adapters/freeswitch/test_lawful_basis_and_media.py index cc48e34..8f354e1 100644 --- a/tests/adapters/freeswitch/test_lawful_basis_and_media.py +++ b/tests/adapters/freeswitch/test_lawful_basis_and_media.py @@ -7,7 +7,6 @@ import base64 import hashlib -import json import logging import pytest @@ -57,9 +56,13 @@ def test_lawful_basis_emitted_when_configured(build): assert attachment["party"] == 0 assert attachment["dialog"] == 0 assert attachment["encoding"] == "json" - assert isinstance(attachment["body"], str), "body must be a JSON string, not an object" + 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 = json.loads(attachment["body"]) + body = attachment["body"] assert body["lawful_basis"] == "consent" assert "lawful_basis" in vcon.to_dict()["extensions"] diff --git a/tests/adapters/telnyx/test_byok_e2e.py b/tests/adapters/telnyx/test_byok_e2e.py index 544ead1..c8f6968 100644 --- a/tests/adapters/telnyx/test_byok_e2e.py +++ b/tests/adapters/telnyx/test_byok_e2e.py @@ -175,8 +175,12 @@ 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] - assert isinstance(found[0]["body"], str), "body must be a JSON string, not an object" - body = json.loads(found[0]["body"]) + # 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"]] == [ "recording", diff --git a/tests/test_attachment_required_fields.py b/tests/test_attachment_required_fields.py index 6e436b2..dd67383 100644 --- a/tests/test_attachment_required_fields.py +++ b/tests/test_attachment_required_fields.py @@ -1,10 +1,11 @@ -"""CON-1083 follow-up: every attachment carries start/party/dialog. - -The official schema (draft-ietf-vcon-vcon-core, Attachment Object) requires -`start`, `party`, and `dialog` on every attachment. `Vcon.add_tag()` already -sets `party`/`dialog` (but not `start`) on the tags attachment it creates; -`LawfulBasisConfig.apply()` sets all three itself. Nothing backfilled -`start` for the tags attachment until `BaseVconBuilder.build()` started +"""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. """ @@ -34,7 +35,7 @@ def _built_vcon(**builder_kwargs): return vcon -def test_every_attachment_carries_start_party_and_dialog(): +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"]) @@ -47,6 +48,10 @@ def test_every_attachment_carries_start_party_and_dialog(): 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(): @@ -70,3 +75,4 @@ def test_lawful_basis_attachment_keeps_its_own_party_and_dialog(): 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 3e0703b..41460d4 100644 --- a/tests/test_lawful_basis.py +++ b/tests/test_lawful_basis.py @@ -4,8 +4,6 @@ in this monorepo produced asserted a legal basis for its own existence. """ -import json - import pytest from vcon import Vcon @@ -18,15 +16,14 @@ def fresh_vcon(): def body_of(attachment: dict) -> dict: - """Decode an attachment's body. + """An attachment's body. - vcon-lib 0.9.6's `add_lawful_basis_attachment` emits `body` as an object; - `LawfulBasisConfig.apply` stringifies it to match the schema (`body` is - `type: string`) and `encoding: "json"`. Tests decode it back to assert on - its contents. + 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"], str), "body must be a JSON string, not an object" - return json.loads(attachment["body"]) + assert isinstance(attachment["body"], dict), "body must be the JSON object, not a string" + return attachment["body"] # -- the refusal to invent ------------------------------------------------- @@ -77,6 +74,7 @@ def test_emitted_attachment_is_found_by_the_library(): 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(): diff --git a/tests/test_lawful_basis_and_media_twilio.py b/tests/test_lawful_basis_and_media_twilio.py index 1e98834..9a32c59 100644 --- a/tests/test_lawful_basis_and_media_twilio.py +++ b/tests/test_lawful_basis_and_media_twilio.py @@ -10,7 +10,6 @@ import base64 import hashlib -import json import logging import pytest @@ -60,9 +59,13 @@ def test_lawful_basis_emitted_when_configured(build): assert attachment["party"] == 0 assert attachment["dialog"] == 0 assert attachment["encoding"] == "json" - assert isinstance(attachment["body"], str), "body must be a JSON string, not an object" + 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 = json.loads(attachment["body"]) + body = attachment["body"] assert body["lawful_basis"] == "consent" assert "lawful_basis" in vcon.to_dict()["extensions"]