From a2e69bbf446ed1cad2892f86a85f49c604fc8af4 Mon Sep 17 00:00:00 2001 From: Thanasis Daglis Date: Fri, 11 Sep 2026 20:12:05 +0300 Subject: [PATCH] fix: enforce supported delivery string limits --- README.md | 7 ++ contract-version.json | 4 +- .../contract/schemas/ticket-1.0.schema.json | 14 ++-- src/intryc_delivery/contract/semantic.py | 32 +++++---- tests/test_string_limits.py | 66 +++++++++++++++++++ tests/test_tag_validation.py | 33 ++++++++++ 6 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 tests/test_string_limits.py create mode 100644 tests/test_tag_validation.py diff --git a/README.md b/README.md index ff7c340..128d7ec 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,13 @@ assignee email with an active, eligible user in your Intryc workspace. Include all referenced users in each ticket's `users` list. The CLI does not create Intryc accounts or verify workspace eligibility. +Tags must be non-empty strings of at most 255 characters. Ticket tags cannot +contain duplicates. The same length limit applies to each tag in tag-event +`value` and `previous_value`. User email strings may contain at most 254 +characters, including surrounding whitespace. Ticket and user custom-field keys +are limited to 512 characters. In field events named `status`, string `value` +and `previous_value` are also limited to 512 characters. + [Complete examples](examples/delivery) show a written ticket, a call-only ticket and a ticket with an attachment. Their data is synthetic; the WAV contains one second of silence. Use a real recording when testing transcription. diff --git a/contract-version.json b/contract-version.json index 80a06a5..f2599b6 100644 --- a/contract-version.json +++ b/contract-version.json @@ -6,7 +6,7 @@ "files": [ { "path": "src/intryc_delivery/contract/semantic.py", - "sha256": "382be6f785a1aaf29878901ebd07896271e2d63245739052136a0957fa077b47" + "sha256": "815b03aa0c8301fa909ea6ffb9c3284a1c09903550251ddafe0d9b05f753b485" }, { "path": "src/intryc_delivery/contract/json_payload.py", @@ -14,7 +14,7 @@ }, { "path": "src/intryc_delivery/contract/schemas/ticket-1.0.schema.json", - "sha256": "3fa1ebcffbaac6e372182083a31c3a131d8036d3715954e092f0b5b288092a0f" + "sha256": "fa898d9648df9054d0a565ea4ffa71aa9eafdaf2a11b2c7657964cb6d20da0eb" }, { "path": "src/intryc_delivery/contract/schemas/ticket-delivery-1.0.schema.json", diff --git a/src/intryc_delivery/contract/schemas/ticket-1.0.schema.json b/src/intryc_delivery/contract/schemas/ticket-1.0.schema.json index 5d3fe93..ea06c99 100644 --- a/src/intryc_delivery/contract/schemas/ticket-1.0.schema.json +++ b/src/intryc_delivery/contract/schemas/ticket-1.0.schema.json @@ -19,7 +19,7 @@ "tags": { "type": "array", "uniqueItems": true, - "items": {"type": "string", "minLength": 1, "pattern": "\\S"} + "items": {"type": "string", "minLength": 1, "maxLength": 255, "pattern": "\\S"} }, "custom_fields": {"$ref": "#/$defs/scalarMap"}, "users": { @@ -55,7 +55,7 @@ "scalar": {"type": ["string", "number", "boolean", "null"]}, "scalarMap": { "type": "object", - "propertyNames": {"type": "string", "minLength": 1, "pattern": "\\S"}, + "propertyNames": {"type": "string", "minLength": 1, "maxLength": 512, "pattern": "\\S"}, "additionalProperties": {"$ref": "#/$defs/scalar"} }, "flexibleMap": {"type": "object", "additionalProperties": true}, @@ -68,7 +68,7 @@ "properties": { "source_user_id": {"$ref": "#/$defs/userReference"}, "name": {"type": "string", "minLength": 1, "pattern": "\\S", "maxLength": 1024}, - "email": {"type": "string", "format": "email", "pattern": "^\\s*[^@\\s]+@[^@\\s]+\\.[^@\\s]+\\s*$", "description": "Use a basic name@domain.tld email address. Surrounding whitespace is trimmed and letters are lowercased during import."}, + "email": {"type": "string", "maxLength": 254, "format": "email", "pattern": "^\\s*[^@\\s]+@[^@\\s]+\\.[^@\\s]+\\s*$", "description": "Use a basic name@domain.tld email address. Surrounding whitespace is trimmed and letters are lowercased during import."}, "role": {"$ref": "#/$defs/role"}, "active": {"type": "boolean"}, "created_at": {"type": "string", "format": "date-time"}, @@ -233,6 +233,10 @@ }, "fieldEvent": { "allOf": [ + { + "if": {"properties": {"field_name": {"const": "status"}}, "required": ["field_name"]}, + "then": {"properties": {"value": {"maxLength": 512}, "previous_value": {"maxLength": 512}}} + }, {"$ref": "#/$defs/eventBase"}, { "type": "object", @@ -261,8 +265,8 @@ "created_at": true, "actor_source_user_id": true, "type": {"enum": ["change", "update", "add", "remove"]}, - "previous_value": {"type": "array", "items": {"type": "string", "minLength": 1, "pattern": "\\S"}}, - "value": {"type": "array", "items": {"type": "string", "minLength": 1, "pattern": "\\S"}} + "previous_value": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 255, "pattern": "\\S"}}, + "value": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 255, "pattern": "\\S"}} } } ] diff --git a/src/intryc_delivery/contract/semantic.py b/src/intryc_delivery/contract/semantic.py index 5e11703..b47b4ee 100644 --- a/src/intryc_delivery/contract/semantic.py +++ b/src/intryc_delivery/contract/semantic.py @@ -941,13 +941,13 @@ def _validate_user( ) ) if email is not None and ( - not isinstance(email, str) or not EMAIL_PATTERN.fullmatch(email.strip()) + not isinstance(email, str) or len(email) > 254 or not EMAIL_PATTERN.fullmatch(email.strip()) ): issues.append( _issue( ErrorScope.TICKET, "INVALID_USER_EMAIL", - "email must be valid.", + "email must be valid and at most 254 characters.", pointer=f"{pointer}/email", ticket_id=ticket_id, ) @@ -1034,13 +1034,13 @@ def _validate_user( ) custom_fields = value.get("custom_fields", {}) if not isinstance(custom_fields, dict) or any( - not isinstance(key, str) or not key.strip() for key in custom_fields + not isinstance(key, str) or not key.strip() or len(key) > 512 for key in custom_fields ): issues.append( _issue( ErrorScope.TICKET, "INVALID_USER_CUSTOM_FIELDS", - "custom_fields must be an object with non-empty string keys.", + "custom_fields must be an object with non-empty string keys of at most 512 characters.", pointer=f"{pointer}/custom_fields", ticket_id=ticket_id, ) @@ -1384,13 +1384,13 @@ def parse_ticket( custom_fields = payload.get("custom_fields", {}) if not isinstance(custom_fields, dict) or any( - not isinstance(key, str) or not key.strip() for key in custom_fields + not isinstance(key, str) or not key.strip() or len(key) > 512 for key in custom_fields ): issues.append( _issue( ErrorScope.TICKET, "INVALID_CUSTOM_FIELDS", - "custom_fields must be an object with non-empty string keys.", + "custom_fields must be an object with non-empty string keys of at most 512 characters.", pointer="/custom_fields", ticket_id=expected_source_ticket_id, ) @@ -1415,13 +1415,13 @@ def parse_ticket( tags = payload.get("tags", []) if not isinstance(tags, list) or any( - not isinstance(tag, str) or not tag.strip() for tag in tags + not isinstance(tag, str) or not tag.strip() or len(tag) > 255 for tag in tags ): issues.append( _issue( ErrorScope.TICKET, "INVALID_TAGS", - "tags must contain non-empty strings.", + "tags must contain non-empty strings of at most 255 characters.", pointer="/tags", ticket_id=expected_source_ticket_id, ) @@ -1893,7 +1893,7 @@ def _validate_events( if ( not isinstance(field_name, str) or not field_name.strip() - or len(field_name.strip()) > 512 + or len(field_name) > 512 ): issues.append( _issue( @@ -1916,17 +1916,27 @@ def _validate_events( ticket_id=ticket_id, ) ) + if field_name == "status" and isinstance(field_value, str) and len(field_value) > 512: + issues.append( + _issue( + ErrorScope.TICKET, + "INVALID_FIELD_EVENT_VALUE", + f"{key} for status must be at most 512 characters.", + pointer=f"{item_pointer}/{key}", + ticket_id=ticket_id, + ) + ) if tag_values: for key in ("value", "previous_value"): tag_list = event.get(key, []) if not isinstance(tag_list, list) or any( - not isinstance(tag, str) or not tag.strip() for tag in tag_list + not isinstance(tag, str) or not tag.strip() or len(tag) > 255 for tag in tag_list ): issues.append( _issue( ErrorScope.TICKET, "INVALID_TAG_EVENT_VALUE", - f"{key} must be an array of tag strings.", + f"{key} must be an array of non-empty tag strings of at most 255 characters.", pointer=f"{item_pointer}/{key}", ticket_id=ticket_id, ) diff --git a/tests/test_string_limits.py b/tests/test_string_limits.py new file mode 100644 index 0000000..4458679 --- /dev/null +++ b/tests/test_string_limits.py @@ -0,0 +1,66 @@ +import json + +import pytest + +from intryc_delivery.contract.semantic import ContractValidationError, parse_ticket +from intryc_delivery.validation import schema_validators + + +@pytest.mark.parametrize( + "field,limit,code", + [ + ("key", 512, "INVALID_CUSTOM_FIELDS"), + ("user_key", 512, "INVALID_USER_CUSTOM_FIELDS"), + ("email", 254, "INVALID_USER_EMAIL"), + ("field_name", 512, "FIELD_NAME_REQUIRED"), + ("value", 512, "INVALID_FIELD_EVENT_VALUE"), + ("previous_value", 512, "INVALID_FIELD_EVENT_VALUE"), + ], +) +@pytest.mark.parametrize("extra", [0, 1]) +def test_string_limits_match_schema(root, field, limit, code, extra): + ticket = json.loads((root / "ticket_details/written.json").read_bytes()) + ticket["field_events"] = [ + { + "source_event_id": "event-1", + "created_at": ticket["created_at"], + "type": "change", + "field_name": "status", + } + ] + value = "🙂" * (limit + extra) + if field == "key": + ticket["custom_fields"] = {value: "sample"} + elif field == "user_key": + ticket["users"][0]["custom_fields"] = {value: "sample"} + elif field == "email": + ticket["users"][0]["email"] = "🙂" * (limit + extra - len("@example.com")) + "@example.com" + else: + ticket["field_events"][0]["field_name"] = "status" + ticket["field_events"][0][field] = value + assert schema_validators()["ticket-1.0.schema.json"].is_valid(ticket) is (extra == 0) + if extra == 0: + parse_ticket(ticket, expected_source_ticket_id=ticket["source_ticket_id"]) + else: + with pytest.raises(ContractValidationError) as error: + parse_ticket(ticket, expected_source_ticket_id=ticket["source_ticket_id"]) + assert code in {issue.code for issue in error.value.issues} + + +@pytest.mark.parametrize( + "field_name,value", + [("status", None), ("status", True), ("status", 42), ("description", "x" * 4096)], +) +def test_status_bound_keeps_other_scalar_values_supported(root, field_name, value): + ticket = json.loads((root / "ticket_details/written.json").read_bytes()) + ticket["field_events"] = [ + { + "source_event_id": "event-1", + "created_at": ticket["created_at"], + "type": "change", + "field_name": "status", + } + ] + ticket["field_events"][0].update(field_name=field_name, value=value, previous_value=value) + assert schema_validators()["ticket-1.0.schema.json"].is_valid(ticket) + parse_ticket(ticket, expected_source_ticket_id=ticket["source_ticket_id"]) diff --git a/tests/test_tag_validation.py b/tests/test_tag_validation.py new file mode 100644 index 0000000..4b20324 --- /dev/null +++ b/tests/test_tag_validation.py @@ -0,0 +1,33 @@ +import json + +import pytest + +from intryc_delivery.contract.semantic import ContractValidationError, parse_ticket +from intryc_delivery.validation import schema_validators + + +@pytest.mark.parametrize("length,accepted", [(255, True), (256, False), (4096, False)]) +@pytest.mark.parametrize("character", ["a", "😀"]) +@pytest.mark.parametrize("field", ["tags", "value", "previous_value"]) +def test_tag_length_matches_schema(root, length, accepted, character, field): + ticket = json.loads((root / "ticket_details/written.json").read_bytes()) + if field == "tags": + ticket["tags"] = [character * length] + else: + ticket["tag_events"] = [ + { + "source_event_id": "event-1", + "created_at": ticket["created_at"], + "type": "add", + field: [character * length], + } + ] + assert schema_validators()["ticket-1.0.schema.json"].is_valid(ticket) is accepted + if accepted: + parse_ticket(ticket, expected_source_ticket_id=ticket["source_ticket_id"]) + else: + with pytest.raises(ContractValidationError) as error: + parse_ticket(ticket, expected_source_ticket_id=ticket["source_ticket_id"]) + assert ("INVALID_TAGS" if field == "tags" else "INVALID_TAG_EVENT_VALUE") in { + issue.code for issue in error.value.issues + }