mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-01 18:13:13 +00:00
Implements docs/shoc-webhook-plan.md Phases 1-5 (PR-2 of the SHOC call-and-be-called effort). Everything ships DARK: both DynamoDB event source mappings deploy enabled=False; activation is a deliberate one-line follow-up PR gated on the SHOC receiver passing the shared HMAC test vectors. - Streams: NEW_AND_OLD_IMAGES on WorkOrders + WorkOrderComments (in-place update, RETAIN + logical IDs untouched; no existing consumers — verified live, neither table had a stream). - workorder-shoc-emitter (Py3.12/ARM64): stream -> envelope -> HMAC-signed POST per docs/shoc-webhook-contract.md; strict per-shard ordering (parallelization 1, bisect off, retry until 24h age, ReportBatchItemFailures); 429/5xx/timeout block the shard in order, other 4xx park to workorder-shoc-emitter-rejected; ESM failures -> workorder-shoc-emitter-failures (metadata; replay rebuilds from DynamoDB). Echo guard skips write_origin=shoc-write-api. - Secret workorder-ingest/shoc-webhook-hmac on a dedicated CMK (alias workorder-ingest-shoc-webhook-kms); cross-account GetSecretValue/DescribeSecret + kms:Decrypt granted to exactly arn:aws:iam::396287094661:role/shoc-backend-dev. RemovalPolicy DESTROY deliberately (machine-generated material; avoids the fixed-name RETAIN-orphan deadlock). - workorder-shoc-hmac-rotator: 30-day rotation, dual-key overlap, 64-hex keys, kid = UTC %Y-%m-%dT%H. - Alarms (ALARM-only -> site-alerts): emitter errors/throttles/ duration + iterator-age (>=10 min) + failures/rejected queue depth; rotator standard trio. - scripts/replay_shoc_webhooks.py: dry-run-default operator replay (rebuilds from tables, replay:true envelopes). - Tests: 742 passing, 85.56% aggregate; golden HMAC vectors shared with SHOC in docs/shoc-webhook-test-vectors.json (emitter + replay signing pinned to identical vectors); bundle-consistency AST pins for both new bundles. - README: WO stack + webhook feed section, alarm table, runbooks; removed stale seahaven-slack-bot consumer references.
289 lines
9.7 KiB
Python
289 lines
9.7 KiB
Python
"""Signing + delivery tests for the SHOC webhook emitter (plan Phase 5).
|
|
|
|
Golden vectors: docs/shoc-webhook-test-vectors.json is the shared handoff
|
|
artifact -- Luby's receiver verifies against the same vectors -- and BOTH
|
|
producer-side sign_body implementations (the emitter's delivery module and
|
|
the replay script) are pinned here against every vector, so they can never
|
|
drift from each other or from the published vectors.
|
|
|
|
Also covers the contract section 7 response-classification matrix (2xx /
|
|
429+5xx / timeout+connection error / other 4xx) with a monkeypatched urllib
|
|
opener, and the Secrets Manager key cache: 5-minute TTL via time.monotonic,
|
|
keys[0] selection, empty-keys (bootstrap) -> retryable.
|
|
"""
|
|
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import urllib.error
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.support import REPO_ROOT, load_lambda_module
|
|
|
|
_VECTORS_PATH = Path(REPO_ROOT) / "docs" / "shoc-webhook-test-vectors.json"
|
|
VECTORS = json.loads(_VECTORS_PATH.read_text(encoding="utf-8"))["vectors"]
|
|
|
|
TEST_KEYS = [
|
|
{"kid": "2026-07-20T00", "secret": "ab" * 32},
|
|
{"kid": "2026-06-20T00", "secret": "cd" * 32},
|
|
]
|
|
|
|
ENVELOPE = {
|
|
"schema_version": 1,
|
|
"delivery_id": "evt-1",
|
|
"event_type": "work_order.created",
|
|
"occurred_at": "2026-07-16T14:03:22.114208+00:00",
|
|
"source": "procurement-ingest/workorder-shoc-emitter",
|
|
"replay": False,
|
|
"data": {"work_order_id": "11144580730"},
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def delivery():
|
|
return load_lambda_module("wo", "shoc_emitter/delivery")
|
|
|
|
|
|
def _load_replay_script():
|
|
# scripts/ is not a package and not on sys.path; load by file path
|
|
# (mirrors tests/test_reprocess_contract.py). Import is side-effect-free:
|
|
# the script builds its boto3 session inside main().
|
|
path = Path(REPO_ROOT) / "scripts" / "replay_shoc_webhooks.py"
|
|
spec = importlib.util.spec_from_file_location(
|
|
"replay_shoc_webhooks_for_vectors", path
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
# --- Golden vectors ----------------------------------------------------------
|
|
|
|
|
|
def test_vector_file_covers_required_shapes():
|
|
# The handoff artifact itself must keep its coverage promises: at least
|
|
# one non-ASCII UTF-8 body (multi-byte signing) and one empty-object body.
|
|
assert len(VECTORS) >= 4
|
|
assert any(any(ord(ch) > 127 for ch in v["body"]) for v in VECTORS)
|
|
assert any(v["body"] == "{}" for v in VECTORS)
|
|
for vector in VECTORS:
|
|
assert set(vector) == {
|
|
"kid",
|
|
"secret_hex",
|
|
"timestamp",
|
|
"body",
|
|
"expected_signature",
|
|
}
|
|
|
|
|
|
def test_delivery_sign_body_matches_golden_vectors(delivery):
|
|
for vector in VECTORS:
|
|
signature = delivery.sign_body(
|
|
vector["secret_hex"],
|
|
vector["timestamp"],
|
|
vector["body"].encode("utf-8"),
|
|
)
|
|
assert signature == vector["expected_signature"], (
|
|
f"delivery.sign_body drifted from golden vector kid="
|
|
f"{vector['kid']} ts={vector['timestamp']}"
|
|
)
|
|
|
|
|
|
def test_replay_sign_body_matches_golden_vectors():
|
|
replay = _load_replay_script()
|
|
for vector in VECTORS:
|
|
signature = replay.sign_body(
|
|
vector["secret_hex"],
|
|
vector["timestamp"],
|
|
vector["body"].encode("utf-8"),
|
|
)
|
|
assert signature == vector["expected_signature"], (
|
|
f"replay sign_body drifted from golden vector kid="
|
|
f"{vector['kid']} ts={vector['timestamp']}"
|
|
)
|
|
|
|
|
|
# --- Response classification matrix (contract section 7) ---------------------
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status):
|
|
self.status = status
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc_info):
|
|
return False
|
|
|
|
|
|
@pytest.fixture
|
|
def signing_keys(monkeypatch, delivery):
|
|
monkeypatch.setattr(delivery, "_get_hmac_keys", lambda: TEST_KEYS)
|
|
|
|
|
|
def _patch_urlopen(monkeypatch, delivery, fn):
|
|
monkeypatch.setattr(delivery.urllib.request, "urlopen", fn)
|
|
|
|
|
|
@pytest.mark.parametrize("status", [200, 204])
|
|
def test_2xx_is_delivered(monkeypatch, delivery, signing_keys, status):
|
|
_patch_urlopen(
|
|
monkeypatch, delivery, lambda request, timeout: _FakeResponse(status)
|
|
)
|
|
assert delivery.deliver(ENVELOPE) == ("delivered", status)
|
|
|
|
|
|
@pytest.mark.parametrize("status", [429, 500, 503])
|
|
def test_429_and_5xx_raise_retryable(monkeypatch, delivery, signing_keys, status):
|
|
def _raise(request, timeout):
|
|
raise urllib.error.HTTPError(
|
|
delivery.SHOC_WEBHOOK_URL, status, "boom", None, io.BytesIO(b"")
|
|
)
|
|
|
|
_patch_urlopen(monkeypatch, delivery, _raise)
|
|
with pytest.raises(delivery.RetryableDeliveryError) as exc:
|
|
delivery.deliver(ENVELOPE)
|
|
assert exc.value.status_code == status
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[urllib.error.URLError(OSError("connection refused")), TimeoutError()],
|
|
ids=["connection-error", "timeout"],
|
|
)
|
|
def test_connection_failures_raise_retryable(
|
|
monkeypatch, delivery, signing_keys, error
|
|
):
|
|
def _raise(request, timeout):
|
|
raise error
|
|
|
|
_patch_urlopen(monkeypatch, delivery, _raise)
|
|
with pytest.raises(delivery.RetryableDeliveryError) as exc:
|
|
delivery.deliver(ENVELOPE)
|
|
assert exc.value.status_code is None
|
|
|
|
|
|
@pytest.mark.parametrize("status", [400, 404, 422])
|
|
def test_other_4xx_is_rejected_not_raised(monkeypatch, delivery, signing_keys, status):
|
|
def _raise(request, timeout):
|
|
raise urllib.error.HTTPError(
|
|
delivery.SHOC_WEBHOOK_URL, status, "bad", None, io.BytesIO(b"")
|
|
)
|
|
|
|
_patch_urlopen(monkeypatch, delivery, _raise)
|
|
assert delivery.deliver(ENVELOPE) == ("rejected", status)
|
|
|
|
|
|
def test_request_signed_with_keys0_and_contract_headers(
|
|
monkeypatch, delivery, signing_keys
|
|
):
|
|
captured = {}
|
|
|
|
def _capture(request, timeout):
|
|
captured["request"] = request
|
|
captured["timeout"] = timeout
|
|
return _FakeResponse(200)
|
|
|
|
_patch_urlopen(monkeypatch, delivery, _capture)
|
|
assert delivery.deliver(ENVELOPE) == ("delivered", 200)
|
|
|
|
request = captured["request"]
|
|
assert captured["timeout"] == delivery.POST_TIMEOUT_SECONDS
|
|
assert request.get_method() == "POST"
|
|
assert request.data == json.dumps(ENVELOPE).encode("utf-8")
|
|
assert request.get_header("Content-type") == "application/json; charset=utf-8"
|
|
assert request.get_header("User-agent") == "workorder-shoc-emitter/1"
|
|
# The producer always signs with keys[0] (contract section 6.1).
|
|
assert request.get_header("X-sh-key-id") == TEST_KEYS[0]["kid"]
|
|
timestamp = request.get_header("X-sh-timestamp")
|
|
assert timestamp.isdigit()
|
|
expected = delivery.sign_body(TEST_KEYS[0]["secret"], int(timestamp), request.data)
|
|
assert request.get_header("X-sh-signature") == f"v1={expected}"
|
|
|
|
|
|
# --- Secret cache ------------------------------------------------------------
|
|
|
|
|
|
class _FakeSecretsManager:
|
|
"""Returns payloads in sequence (last one repeats); counts fetches."""
|
|
|
|
def __init__(self, payloads):
|
|
self.payloads = list(payloads)
|
|
self.calls = 0
|
|
self.secret_ids = []
|
|
|
|
def get_secret_value(self, SecretId): # noqa: N803 (boto3 kwarg name)
|
|
self.calls += 1
|
|
self.secret_ids.append(SecretId)
|
|
payload = self.payloads.pop(0) if len(self.payloads) > 1 else self.payloads[0]
|
|
return {"SecretString": json.dumps(payload)}
|
|
|
|
|
|
@pytest.fixture
|
|
def cache_reset(monkeypatch, delivery):
|
|
monkeypatch.setattr(delivery, "_hmac_keys_cache", None)
|
|
monkeypatch.setattr(delivery, "_hmac_keys_cached_at", 0.0)
|
|
monkeypatch.setattr(
|
|
delivery,
|
|
"HMAC_SECRET_ARN",
|
|
"arn:aws:secretsmanager:us-east-1:011934824531:secret:"
|
|
"workorder-ingest/shoc-webhook-hmac-AbCdEf",
|
|
)
|
|
|
|
|
|
def _wire_cache(monkeypatch, delivery, fake, clock):
|
|
monkeypatch.setattr(delivery.boto3, "client", lambda service: fake)
|
|
monkeypatch.setattr(delivery.time, "monotonic", lambda: clock["t"])
|
|
|
|
|
|
def test_cache_honors_ttl_and_refreshes_after_expiry(
|
|
monkeypatch, delivery, cache_reset
|
|
):
|
|
rotated = [
|
|
{"keys": [{"kid": "2026-08-20T00", "secret": "ef" * 32}] + TEST_KEYS[:1]}
|
|
]
|
|
fake = _FakeSecretsManager([{"keys": TEST_KEYS}] + rotated)
|
|
clock = {"t": 1000.0}
|
|
_wire_cache(monkeypatch, delivery, fake, clock)
|
|
|
|
assert delivery._get_hmac_keys()[0]["kid"] == "2026-07-20T00"
|
|
assert fake.calls == 1
|
|
|
|
# Inside the 300s TTL: served from cache, no refetch.
|
|
clock["t"] = 1000.0 + 299.0
|
|
assert delivery._get_hmac_keys()[0]["kid"] == "2026-07-20T00"
|
|
assert fake.calls == 1
|
|
|
|
# TTL expired: refetch picks up the rotated keys[0] (cache invalidation
|
|
# is what makes 30-day rotation propagate within 5 minutes).
|
|
clock["t"] = 1000.0 + 300.5
|
|
assert delivery._get_hmac_keys()[0]["kid"] == "2026-08-20T00"
|
|
assert fake.calls == 2
|
|
assert fake.secret_ids[0] == delivery.HMAC_SECRET_ARN
|
|
|
|
|
|
def test_empty_or_missing_keys_is_retryable_bootstrap_state(
|
|
monkeypatch, delivery, cache_reset
|
|
):
|
|
clock = {"t": 5000.0}
|
|
for payload in ({"keys": []}, {"keys": [], "bootstrap_entropy": "seed"}, {}):
|
|
monkeypatch.setattr(delivery, "_hmac_keys_cache", None)
|
|
monkeypatch.setattr(delivery, "_hmac_keys_cached_at", 0.0)
|
|
fake = _FakeSecretsManager([payload])
|
|
_wire_cache(monkeypatch, delivery, fake, clock)
|
|
with pytest.raises(delivery.RetryableDeliveryError):
|
|
delivery._get_hmac_keys()
|
|
|
|
|
|
def test_secret_fetch_failure_is_retryable(monkeypatch, delivery, cache_reset):
|
|
class _Boom:
|
|
def get_secret_value(self, SecretId): # noqa: N803 (boto3 kwarg name)
|
|
raise RuntimeError("throttled")
|
|
|
|
clock = {"t": 9000.0}
|
|
_wire_cache(monkeypatch, delivery, _Boom(), clock)
|
|
with pytest.raises(delivery.RetryableDeliveryError):
|
|
delivery._get_hmac_keys()
|