Files
ARR-2.0-0918/integrations/ohip/audit_arr_day.py
T

168 lines
8.4 KiB
Python

"""Offline, manifest-pinned replay of the three-operation ARR v2 capture."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import urllib.error
from . import audit_arr_capture as audit
from . import collect_arr_day as day
from . import collect_arr_source as source
from . import rate_info
require = source.require
def _same_json(left, right):
# Python equality aliases true/1 and false/0, including inside containers.
# Canonical JSON keeps those types distinct while ignoring object key order.
return (json.dumps(left, sort_keys=True, allow_nan=False, separators=(",", ":"))
== json.dumps(right, sort_keys=True, allow_nan=False, separators=(",", ":")))
class VerifiedArchive(audit.VerifiedArchive):
version = day.VERSION
file_pattern = re.compile(r"(?:capture\.json|rate-assessments\.json|(?:request|rate)-[0-9]{6}\.(?:json|meta\.json|response\.bin))")
operations = day.OPERATIONS
options_type = day.Options
max_inventory_files = 182000
def __init__(self, directory: Path, expected_manifest_sha256: str):
super().__init__(directory, expected_manifest_sha256)
for document in (self.capture, self.result):
require(all(document.get(key) == getattr(self.options, key)
for key in ("from_date", "to_date", "rate_date")), "capture_date_mismatch")
class MemorySink(audit.MemorySink):
def __init__(self):
super().__init__()
self.assessments = None
def write(self, name, value):
if re.fullmatch(r"(?:request|rate|profile)-[0-9]{6}\.json", name):
self.latest_request = value
if name == "rate-assessments.json":
self.assessments = value
super().write(name, value)
def replay(archive: VerifiedArchive) -> tuple[list[dict], list[dict], dict]:
"""Rebuild all requests and join decisions without credentials or networking."""
require(archive.version == day.VERSION, "unsupported_day_archive_version")
return _replay(archive)
def _replay(archive, *, named=False):
sink = MemorySink()
counters = {"request": 0, "rate": 0}
if named:
from . import collect_arr_named_day as named_day, profile_reader, profile_summary
counters["profile"] = 0
consumed = {"capture.json", "rate-assessments.json"}
rows, details = [], []
initial_search_done = False
def transport(method, path, body):
nonlocal initial_search_done
operation = sink.latest_request["operation_id"]
prefix = ("profile" if named and operation == profile_summary.OPERATION else
"rate" if operation == rate_info.POST else "request")
counters[prefix] += 1
label = f"{prefix}-{counters[prefix]:06d}"
request_name, meta_name, response_name = label + ".json", label + ".meta.json", label + ".response.bin"
request, meta = archive.document(request_name), archive.document(meta_name)
consumed.update((request_name, meta_name))
require(request.get("operation_id") == operation and request.get("method") == method
and request.get("path") == path
and _same_json(request.get("body"), source.strict_json(body) if body is not None else None),
"archived_request_mismatch")
require(type(request.get("attempt")) is int and request["attempt"] == sink.latest_request["attempt"],
"archived_attempt_mismatch")
if meta.get("error") == "transport_failure":
require(response_name not in archive.inventory, "unexpected_transport_response")
raise urllib.error.URLError("archived_transport_failure")
raw = archive.read(response_name)
consumed.add(response_name)
require(type(meta.get("http_status")) is int and meta.get("oversized") is False, "invalid_response_metadata")
if meta["http_status"] == 200:
page = source.strict_json(raw).get("data", {}).get("reservations", {})
if operation == source.SEARCH and not initial_search_done:
rows.extend(page.get("reservationInfo", []))
if page.get("hasMore", False) is False:
initial_search_done = True
elif operation == source.DETAIL:
details.extend(page.get("reservation", []))
# Historical Retry-After can be an HTTP date now in the past. Replay
# validates response/request sequence; it does not claim timing replay.
headers = {}
if prefix == "profile" and meta.get("retry_after") is not None:
require(type(meta["retry_after"]) is str, "invalid_archived_retry_after")
headers["retry-after"] = meta["retry_after"]
return meta["http_status"], headers, raw
clients = (source.Reader(sink, archive.options.hotel_id, transport, sleep=lambda _: None),
rate_info.RateInfoReader(sink, archive.options.hotel_id, transport, sleep=lambda _: None))
if named:
result = named_day.collect(archive.options, sink, *clients,
profile_reader.ProfileSummaryReader(sink, archive.options.hotel_id, transport,
max_profiles=archive.options.max_profiles, sleep=lambda _: None))
else:
result = day.collect(archive.options, sink, *clients)
require(result.get("candidate_capture_complete") is True, "capture_protocol_replay_failed")
require(consumed == set(archive.inventory), "unconsumed_archive_files")
require([item["name"] for item in sink.files[:-1]] == list(archive.inventory), "archive_sequence_mismatch")
for key, value in result.items():
if key not in {"completed_at", "manifest_sha256"}:
require(_same_json(value, archive.result.get(key)),
"capture_summary_mismatch")
require(_same_json(sink.assessments, archive.document("rate-assessments.json")), "rate_assessment_mismatch")
require(len(rows) == len(details) == len(sink.assessments["records"]) == result["search_records"],
"capture_pair_count_mismatch")
return rows, details, sink.assessments
def analyze_fields(rows: list[dict], details: list[dict], assessments: dict) -> dict:
"""Reuse the source-field observations, with v2's validated rate candidate."""
records = assessments.get("records")
require(isinstance(records, list) and len(records) == len(details), "rate_assessment_count_mismatch")
require(all(record.get("reservation_id") == source.reservation_id(detail)
for record, detail in zip(records, details)), "rate_assessment_identity_mismatch")
analysis = audit.analyze_fields(rows, details)
valid = sum("effective_rate" in record and "error" not in record for record in records)
field = analysis["fields"]["EFFECTIVE_RATE_AMOUNT"]
field.update(records_with_candidate=valid, records_without_candidate=len(records) - valid,
records_with_distinct_candidates=0)
analysis["field_notes"]["EFFECTIVE_RATE_AMOUNT"] = (
"searchRateInfo detail.totalRateAmount, exact reservation/day request binding; "
"finite nonnegative amount and currency checked against detail rate segments; "
"no response identity/date echo, arithmetic fallback or report-equivalence claim")
analysis["field_notes"]["candidate_counts"] += "; v2 effective-price counts require amount/currency validity"
analysis["blocked_business_decisions"][0] = "same_source_effective_rate_equivalence"
return analysis
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--capture-dir", required=True, type=Path)
parser.add_argument("--manifest-sha256", required=True)
args = parser.parse_args(argv)
try:
archive = VerifiedArchive(args.capture_dir, args.manifest_sha256)
rows, details, assessments = replay(archive)
output = {"integrity_verified": True, "protocol_replayed": True, "finance_ready": False,
"records": len(rows), "valid_rate_candidates": archive.result["valid_rate_candidates"],
"rate_issues": archive.result["rate_issues"], "analysis": analyze_fields(rows, details, assessments)}
except source.CollectionError as error:
output = {"integrity_verified": False, "error": str(error), "finance_ready": False}
except Exception:
output = {"integrity_verified": False, "error": "archive_audit_failed", "finance_ready": False}
print(json.dumps(output, ensure_ascii=False))
return 0 if output["integrity_verified"] else 1
if __name__ == "__main__":
raise SystemExit(main())