diff --git a/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-3-report.md b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-3-report.md index 2d68ef0..4f7502d 100644 --- a/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-3-report.md +++ b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-3-report.md @@ -129,3 +129,44 @@ Result: ``` The existing `pytest_asyncio` configuration and `openpyxl` print-area warnings remain unchanged. + +## UTF-8 Byte-Length Re-Review Fix + +### RED + +Added `test_resolution_input_rejects_multibyte_utf8_at_ascii_character_limits` before changing production code. It submits `é` repeated 512 times as `value` and 2000 times as `reason`; both are within the existing character counts but exceed the corresponding UTF-8 byte budgets. + +Focused command: + +```text +python -m pytest central/tests/test_blocker_resolution_panel.py -q +``` + +RED result: + +```text +1 failed, 9 passed +``` + +The failure confirmed character-count validation accepted the multibyte inputs. + +### GREEN + +- `value` and `reason` limits now use `len(value.encode("utf-8"))` and `len(reason.encode("utf-8"))`. +- Existing ASCII boundary inputs remain accepted. +- Multibyte inputs fail closed with explicit UTF-8 byte-limit validation errors, before persistence or audit sanitization. +- Redaction and remote-write boundaries were not changed. + +Focused result: + +```text +10 passed, 1 warning +``` + +Full central result: + +```text +266 passed, 1 warning +``` + +The existing `pytest_asyncio` configuration and `openpyxl` print-area warnings remain unchanged. diff --git a/central/app/blocker_resolution.py b/central/app/blocker_resolution.py index 8403642..d2aa7a2 100644 --- a/central/app/blocker_resolution.py +++ b/central/app/blocker_resolution.py @@ -28,10 +28,10 @@ def validate_resolution_input(payload): return None, "value must be a string" if not isinstance(payload.get("reason"), str): return None, "reason must be a string" - if len(payload["value"]) > MAX_VALUE_LENGTH: - return None, f"value must be at most {MAX_VALUE_LENGTH} characters" - if len(payload["reason"]) > MAX_REASON_LENGTH: - return None, f"reason must be at most {MAX_REASON_LENGTH} characters" + if len(payload["value"].encode("utf-8")) > MAX_VALUE_LENGTH: + return None, f"value must be at most {MAX_VALUE_LENGTH} UTF-8 bytes" + if len(payload["reason"].encode("utf-8")) > MAX_REASON_LENGTH: + return None, f"reason must be at most {MAX_REASON_LENGTH} UTF-8 bytes" normalized, error = validate_entry(payload) if error: diff --git a/central/tests/test_blocker_resolution_panel.py b/central/tests/test_blocker_resolution_panel.py index 241747e..9f1f988 100644 --- a/central/tests/test_blocker_resolution_panel.py +++ b/central/tests/test_blocker_resolution_panel.py @@ -161,6 +161,14 @@ def test_resolution_input_rejects_oversized_and_non_string_fields_cleanly(): assert error +def test_resolution_input_rejects_multibyte_utf8_at_ascii_character_limits(): + base = {"scope": "PRODUCT", "scope_key": "P-001", "key": "service_cfop", "value": "5102", "reason": "reviewed"} + for field, value in (("value", "é" * 512), ("reason", "é" * 2000)): + normalized, error = validate_resolution_input({**base, field: value}) + assert normalized is None + assert error and "bytes" in error + + def test_boundary_length_audit_keeps_required_fields_instead_of_truncating_context(): session_factory = setup_db() blocker_id, admin_id, _ = _seed_blocker(session_factory)