Skip to content

Commit 37cf318

Browse files
feat: runtime SessionDict validation at JSONL parse boundary
Replace cast(SessionDict, ...) in parse_session() with validate_session_dict() so untrusted .jsonl files are checked at ingress. Add SessionValidationError with path/detail fields, utils/validation.py, and tests for five malformed shapes plus session_minimal.jsonl regression.
1 parent 809be1b commit 37cf318

4 files changed

Lines changed: 163 additions & 5 deletions

File tree

models/errors.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
"""HTTP error response shapes."""
1+
"""HTTP error response shapes and domain validation errors."""
22

33
from typing import TypedDict
44

55

66
class ErrorResponse(TypedDict):
77
error: str
8+
9+
10+
class SessionValidationError(ValueError):
11+
"""Raised when parsed JSONL output does not match SessionDict contract."""
12+
13+
def __init__(self, path: str, detail: str) -> None:
14+
self.path = path
15+
super().__init__(f"Session validation failed at {path}: {detail}")

tests/test_jsonl_validation.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Runtime validation at the JSONL → SessionDict boundary."""
2+
3+
import os
4+
import sys
5+
6+
import pytest
7+
8+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
9+
10+
from models.errors import SessionValidationError # noqa: E402
11+
from utils.jsonl_parser import parse_session # noqa: E402
12+
from utils.validation import validate_session_dict # noqa: E402
13+
14+
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
15+
16+
17+
def _valid_payload(**overrides: object) -> dict:
18+
base = {
19+
"session_id": "abc123",
20+
"title": "Test Session",
21+
"messages": [{"role": "user", "text": "hello"}],
22+
"metadata": {"session_id": "abc123"},
23+
}
24+
base.update(overrides)
25+
return base
26+
27+
28+
class TestValidateSessionDict:
29+
def test_missing_session_id(self):
30+
payload = _valid_payload()
31+
del payload["session_id"]
32+
with pytest.raises(SessionValidationError) as exc_info:
33+
validate_session_dict(payload)
34+
assert exc_info.value.path == "session_id"
35+
36+
def test_wrong_type_session_id(self):
37+
with pytest.raises(SessionValidationError) as exc_info:
38+
validate_session_dict(_valid_payload(session_id=123))
39+
assert exc_info.value.path == "session_id"
40+
41+
def test_null_role_in_message(self):
42+
with pytest.raises(SessionValidationError) as exc_info:
43+
validate_session_dict(
44+
_valid_payload(messages=[{"role": None, "text": "x"}])
45+
)
46+
assert exc_info.value.path == "messages[0].role"
47+
48+
def test_metadata_not_dict(self):
49+
with pytest.raises(SessionValidationError) as exc_info:
50+
validate_session_dict(_valid_payload(metadata="not-a-dict"))
51+
assert exc_info.value.path == "metadata"
52+
53+
def test_message_not_dict(self):
54+
with pytest.raises(SessionValidationError) as exc_info:
55+
validate_session_dict(_valid_payload(messages=["not-a-dict"]))
56+
assert exc_info.value.path == "messages[0]"
57+
58+
def test_valid_payload_returns_session_dict(self):
59+
result = validate_session_dict(_valid_payload())
60+
assert result["session_id"] == "abc123"
61+
assert result["messages"][0]["role"] == "user"
62+
63+
64+
class TestParseSessionValidationRegression:
65+
def test_session_minimal_fixture_unchanged(self):
66+
path = os.path.join(FIXTURES, "session_minimal.jsonl")
67+
session = parse_session(path)
68+
assert session["session_id"] == "session_minimal"
69+
assert session["title"] == "Hello from integration fixture"
70+
assert len(session["messages"]) == 2
71+
assert session["messages"][0]["role"] == "user"
72+
assert session["messages"][1]["role"] == "assistant"

utils/jsonl_parser.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import json
55
import os
66
from datetime import datetime
7-
from typing import Any, cast
7+
from typing import Any
88

99
from models.session import MessageDict, QuickSessionInfoDict, SessionDict
10+
from utils.validation import validate_session_dict
1011

1112

1213
def parse_session(filepath: str) -> SessionDict:
@@ -120,14 +121,13 @@ def parse_session(filepath: str) -> SessionDict:
120121

121122
title = _infer_title(messages)
122123

123-
return cast(
124-
SessionDict,
124+
return validate_session_dict(
125125
{
126126
"session_id": session_id,
127127
"title": title,
128128
"messages": messages,
129129
"metadata": metadata,
130-
},
130+
}
131131
)
132132

133133

utils/validation.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Runtime validation for TypedDict shapes at untrusted-data boundaries."""
2+
3+
from typing import Any, cast
4+
5+
from models.errors import SessionValidationError
6+
from models.session import MessageDict, SessionDict
7+
8+
_REQUIRED_SESSION_KEYS = ("session_id", "title", "messages", "metadata")
9+
10+
11+
def validate_session_dict(data: dict[str, Any]) -> SessionDict:
12+
"""Validate a plain dict matches SessionDict before returning it."""
13+
if not isinstance(data, dict):
14+
raise SessionValidationError("$", "expected dict")
15+
16+
for key in _REQUIRED_SESSION_KEYS:
17+
if key not in data:
18+
raise SessionValidationError(key, "missing required field")
19+
20+
session_id = data["session_id"]
21+
if session_id is None:
22+
raise SessionValidationError("session_id", "must not be null")
23+
if not isinstance(session_id, str):
24+
raise SessionValidationError(
25+
"session_id", f"expected str, got {type(session_id).__name__}"
26+
)
27+
28+
title = data["title"]
29+
if title is None:
30+
raise SessionValidationError("title", "must not be null")
31+
if not isinstance(title, str):
32+
raise SessionValidationError(
33+
"title", f"expected str, got {type(title).__name__}"
34+
)
35+
36+
messages = data["messages"]
37+
if messages is None:
38+
raise SessionValidationError("messages", "must not be null")
39+
if not isinstance(messages, list):
40+
raise SessionValidationError(
41+
"messages", f"expected list, got {type(messages).__name__}"
42+
)
43+
44+
for index, message in enumerate(messages):
45+
path = f"messages[{index}]"
46+
if message is None:
47+
raise SessionValidationError(path, "must not be null")
48+
if not isinstance(message, dict):
49+
raise SessionValidationError(
50+
path, f"expected dict, got {type(message).__name__}"
51+
)
52+
if "role" not in message:
53+
raise SessionValidationError(f"{path}.role", "missing required field")
54+
role = message["role"]
55+
if role is None:
56+
raise SessionValidationError(f"{path}.role", "must not be null")
57+
if not isinstance(role, str):
58+
raise SessionValidationError(
59+
f"{path}.role", f"expected str, got {type(role).__name__}"
60+
)
61+
62+
metadata = data["metadata"]
63+
if metadata is None:
64+
raise SessionValidationError("metadata", "must not be null")
65+
if not isinstance(metadata, dict):
66+
raise SessionValidationError(
67+
"metadata", f"expected dict, got {type(metadata).__name__}"
68+
)
69+
70+
return cast(
71+
SessionDict,
72+
{
73+
"session_id": session_id,
74+
"title": title,
75+
"messages": cast(list[MessageDict], messages),
76+
"metadata": metadata,
77+
},
78+
)

0 commit comments

Comments
 (0)