Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions contract-version.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
"files": [
{
"path": "src/intryc_delivery/contract/semantic.py",
"sha256": "382be6f785a1aaf29878901ebd07896271e2d63245739052136a0957fa077b47"
"sha256": "815b03aa0c8301fa909ea6ffb9c3284a1c09903550251ddafe0d9b05f753b485"
},
{
"path": "src/intryc_delivery/contract/json_payload.py",
"sha256": "f56fa021bbc925e282fb82dfd9fc46ff212d93c967153786cf2cf50179b7bae6"
},
{
"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",
Expand Down
14 changes: 9 additions & 5 deletions src/intryc_delivery/contract/schemas/ticket-1.0.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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},
Expand All @@ -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"},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"}}
}
}
]
Expand Down
32 changes: 21 additions & 11 deletions src/intryc_delivery/contract/semantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand All @@ -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,
)
Expand Down Expand Up @@ -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(
Expand All @@ -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,
)
Expand Down
66 changes: 66 additions & 0 deletions tests/test_string_limits.py
Original file line number Diff line number Diff line change
@@ -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"])
33 changes: 33 additions & 0 deletions tests/test_tag_validation.py
Original file line number Diff line number Diff line change
@@ -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
}