From 4329786fc314dd5601edc2ccfd85c7cb4b986e80 Mon Sep 17 00:00:00 2001 From: dev-belly Date: Fri, 23 Oct 2026 22:05:12 +0800 Subject: [PATCH] fix: sort audit evidence timelines by UTC instant --- src/controltrace/rules.py | 4 ++-- tests/test_rules.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/controltrace/rules.py b/src/controltrace/rules.py index 4f4135a..7873ad9 100644 --- a/src/controltrace/rules.py +++ b/src/controltrace/rules.py @@ -136,13 +136,13 @@ def _finding( for item in related: record = item["record"] for field, label, kind in _TIMELINE_FIELDS.get(item["table"], []): - if record.get(field): + if parse_utc(record.get(field)) is not None: timeline.append({ "at": record[field], "event": label, "kind": kind, "field": field, "evidence_id": item["id"], "source_system": record["source_system"], }) - timeline.sort(key=lambda item: (item["at"], item["evidence_id"], item["field"])) + timeline.sort(key=lambda item: (parse_utc(item["at"]), item["evidence_id"], item["field"])) digest = hashlib.sha256(f"{control_id}|{issue_code}|{entity_id}".encode()).hexdigest()[:12] return { "finding_id": f"F-{digest.upper()}", diff --git a/tests/test_rules.py b/tests/test_rules.py index c9a5fb9..e3229af 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -43,6 +43,29 @@ def test_fixture_manifest_matches_actual_rule_outcomes(): assert finding["rule"]["control_goal"] +@pytest.mark.parametrize( + ("approved_at", "tested_at", "deployed_at"), + [ + ("2025-06-10T10:00:00Z", "2025-06-10T10:00:00.050000Z", "2025-06-10T10:00:00.100000Z"), + ("2025-06-10T11:00:00+01:00", "2025-06-10T10:00:01Z", "2025-06-10T06:00:02-04:00"), + ], +) +def test_evidence_timeline_orders_actual_instants_and_preserves_source_times( + approved_at, tested_at, deployed_at +): + data = deepcopy(generate_demo_data()) + deployment = _record(data, "deployments", "deployment_id", "D002") + deployment["deployed_at"] = deployed_at + ticket = _record(data, "change_tickets", "ticket_id", deployment["ticket_id"]) + ticket.update(approved_at=approved_at, tested_at=tested_at) + finding = next(item for item in evaluate(data) if item["entity_id"] == "D002") + + timeline = finding["timeline"] + instants = [parse_utc(item["at"]) for item in timeline] + assert instants == sorted(instants) + assert [item["at"] for item in timeline[:3]] == [approved_at, tested_at, deployed_at] + + def test_exact_deadline_is_on_time_for_each_time_based_control(): data = deepcopy(generate_demo_data()) _record(data, "accounts", "account_id", "A001")["disabled_at"] = "2025-06-12T09:00:00Z"