mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 22:23:14 +00:00
* docs(webhook): revise SHOC webhook contract and plan for post-migration reality Branch re-cut on main 2026-07-23 (old base carried stale PR #99 commits). Contract Rev 2026-07-23: - Producer account corrected: seahaven-prod (011934824531); mgmt frozen - Reconciliation backstop is the new procurement read API, not SyncController - wo_status "unknown" is real; SHOC must map it (checklist item added) - write_origin forward-compat note for phase-2 write-back echo suppression - SyncVendorReplies retirement flagged (dead table, no vendor_reply event) Plan updates: - Account gate: seahaven-prod only; never enable streams on mgmt tables - Emitter ships DARK (ESMs enabled=False); activation is a deliberate flip after the SHOC receiver passes shared HMAC vectors - Post-refactor conventions: common.py helpers, bundle-consistency AST pins, pytest.ini --cov additions, consolidated test roots - Dedicated-CMK rationale, secret-ARN handooff step, consumer audit refreshed (slack-bot decommissioned), enum golden test, write_origin skip-branch test * feat(webhook): SHOC WO webhook emitter — dark-ship streams, HMAC secret + rotation 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. * fix(webhook): kms:ViaService pins, https-only delivery, cross-account principal CI pin GPT-4.1 cross-family review of the policy surface (no BLOCK): FIX applied to the cross-account shoc-backend-dev Decrypt statement and both Lambda role KMS grants (the key is only ever used via Secrets Manager); its invariant-enforcement QUESTION answered durably with tests/test_cross_account_principal_pin.py (any new foreign IAM principal in cdk/ fails CI). Scanner mediums fixed: delivery.py and the replay script now refuse non-https URLs (urllib follows file:// and http://). SQS metadata-action and dynamodb:ListStreams NITs skipped: standard CDK grant shapes; ListStreams has no resource-level scoping. The 4 gitleaks HIGHs on docs/shoc-webhook-test-vectors.json are deliberate non-secrets (shared receiver-verification vectors) suppressed machine-level with justification. * harden(webhook): resolve /sh-security-review findings (1 confirmed medium + cheap fixes) High-recall detector fan-out (injection/authz/secrets-crypto/iac-iam/logic) + proof-or-kill verifier. Gate PASSES: 1 confirmed medium, 0 confirmed critical/high. Confirmed finding fixed; several unverified-but-cheap hardenings applied since the emitter ships dark and activation is weeks out. - CONFIRMED medium (confused deputy): the rotation Lambda's generated invoke permission for secretsmanager.amazonaws.com carried no SourceAccount/SourceArn, so any account's Secrets Manager could invoke the rotator. Patched the generated CfnPermission in place (a second permission would be additive, not restrictive) to pin account + this secret ARN. - delivery + replay: refuse to follow receiver 3xx redirects (no-redirect opener) so live X-SH-* auth headers can't be forwarded to a receiver-chosen Location and an http:// Location can't slip past the https guard. Fixed the "unfollowed 3xx" comment that was factually wrong. - delivery: classify 401/403 as retryable (invalidate key cache + retry in order) instead of parking -- transient auth failures (rotation outran the TTL cache, clock skew) are availability events, not contract bugs. - envelope: build_event now genuinely total (guarded eventID / ApproximateCreationDateTime subscripts) per its own never-raise contract. - handler: catch-all so an unexpected per-record error (e.g. SQS park failure) reports only that record instead of failing the whole batch (which would re-deliver every earlier success for 24h); per-invocation emit/skip batch summary so a systemic silent drop is queryable/alarmable. - rotator: narrow the AWSCURRENT-read except to ResourceNotFound/JSONDecode (transient SM/KMS errors re-raise so the overlap key isn't silently dropped); kid uniqueness checked against ALL retained kids with a random suffix on collision (never reissue a kid for a different secret). - contract: skeleton-upsert required on ANY unknown work_order_id (not just comment-before-create) + monotonicity guard (ignore older updated_at), so a parked created or an out-of-order replay can't corrupt receiver state. Unverified/refuted findings left as-is with rationale: the two "high" logic claims (whole-batch crash triggers, ordering violation) were refuted on reachability (real stream records carry required fields; persistence writes strings only; full-state idempotent upsert absorbs the ordering gap). Signed kid/version binding (AUTHZ-002) declined: coordinated contract change, not cheap, no exploit with one algorithm/key. * fix(webhook): drop kid from rotator test_ok log (CodeQL clear-text-logging FP) GHAS CodeQL flagged py/clear-text-logging-sensitive-data (high) at _test_secret's success log because head["kid"] is subscripted from the same parsed-secret dict that holds head["secret"] — the taint tracker can't tell the non-secret key id from the secret. The secret value is never logged. Rather than dismiss the alert (fragile; re-alerts on line moves), remove the flow: kid is already logged at stage time in _create_secret and version_id correlates the steps, so the test_ok log keeps only event + version_id. Also hardens against a future edit that swaps the logged field.
936 lines
46 KiB
Python
936 lines
46 KiB
Python
"""AST-based bundle-consistency test for the email-processor Lambdas.
|
|
|
|
Verifies that each pipeline's CDK bundling `command` actually ships every
|
|
first-party sibling module handler.py imports into /asset-output. This
|
|
guards against a regression where the bundling `cp` step (whether an
|
|
explicit filename allowlist or a glob) silently drops a module the handler
|
|
depends on -- history: PR #105 shipped without template_parser.py, and PR #2
|
|
nearly shipped without derived_fields.py, both allowlist-maintenance misses
|
|
that would ImportError at runtime.
|
|
|
|
Pure ast + file reads -- no AWS/boto3/CDK synth, no handler import, no moto.
|
|
Fast and has no dependency on the moto-before-handler import-order invariant
|
|
that the rest of the suite relies on.
|
|
"""
|
|
|
|
import ast
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
PO_HANDLER = REPO_ROOT / "lambdas" / "po" / "email_processor" / "handler.py"
|
|
WO_HANDLER = REPO_ROOT / "lambdas" / "wo" / "email_processor" / "handler.py"
|
|
API_HANDLER = REPO_ROOT / "lambdas" / "api" / "handler.py"
|
|
SHOC_EMITTER_HANDLER = REPO_ROOT / "lambdas" / "wo" / "shoc_emitter" / "handler.py"
|
|
SHOC_ROTATOR_HANDLER = REPO_ROOT / "lambdas" / "wo" / "shoc_hmac_rotator" / "handler.py"
|
|
PO_STACK = REPO_ROOT / "cdk" / "po_stack.py"
|
|
WO_STACK = REPO_ROOT / "cdk" / "wo_stack.py"
|
|
API_STACK = REPO_ROOT / "cdk" / "procurement_api_stack.py"
|
|
# Phase 3: ses_auth/web_ui_auth/email_parsing/emf are single-sourced here and
|
|
# shipped into the email-processor bundles via `cp shared/*.py`.
|
|
SHARED_DIR = REPO_ROOT / "lambdas" / "shared"
|
|
|
|
# The names Phase 3 single-sourced under lambdas/shared/. After the move NONE
|
|
# of these may reappear as a top-level .py in either email-processor pipeline
|
|
# dir: every handler loader resolves a pipeline-local copy FIRST
|
|
# (tests/conftest.py load_handler checks path.parent before _SHARED_DIR;
|
|
# lambdas/wo/email_processor/tests/_wo_parser_support.py inserts the pipeline
|
|
# dir ahead of shared/ on sys.path), so a stray reappearance would silently
|
|
# SHADOW the single shared source in every handler-loaded test while
|
|
# test_ses_auth / test_parse_raw_email keep exercising shared/ -- divergence
|
|
# undetected. This is exactly the future-drift invariant the retired
|
|
# byte-identity ses_auth fixture used to guard; it is now enforced by policing
|
|
# the pipeline dirs and shared/ SEPARATELY (never as a union, which would let
|
|
# the same name already expected in shared/ mask a pipeline-dir stray) --
|
|
# see test_no_shared_module_shadow_in_pipeline_dirs and the per-dir exact-set
|
|
# pins below.
|
|
SHARED_MODULES = frozenset({"ses_auth", "email_parsing", "emf", "web_ui_auth"})
|
|
|
|
# Exact top-level .py stems each dir must hold. Pinned as exact sets (both
|
|
# bounds): the bundling globs (`cp <pipeline>/email_processor/*.py` +
|
|
# `cp shared/*.py`) ship every top-level .py in these dirs, and
|
|
# `Code.from_asset` stages untracked files too (it does not honor .gitignore),
|
|
# so a stray scratch/secrets .py -- or a shadow copy of a shared module -- would
|
|
# silently ship into the production zip on a local deploy. Any new top-level
|
|
# module must be added here deliberately, the moment to decide whether it SHOULD
|
|
# ship (a real module) or must be excluded.
|
|
# Phase 5 decomposed each God-handler into flat siblings (constraint 6): the
|
|
# non-recursive `cp <pipeline>/email_processor/*.py` glob auto-ships them, but
|
|
# the exact-set pin must list every new sibling or the ships-no-unexpected test
|
|
# fails -- keeping the teeth (a sibling not added here, or a commented-out cp,
|
|
# still fails). PO gained prompts/extraction/enrichment/telemetry/persistence;
|
|
# WO the same minus enrichment (it has no enrichment stage).
|
|
PO_PIPELINE_MODULES = frozenset(
|
|
{
|
|
"handler",
|
|
"template_parser",
|
|
"derived_fields",
|
|
"extraction",
|
|
"enrichment",
|
|
"telemetry",
|
|
"persistence",
|
|
"prompts",
|
|
}
|
|
)
|
|
WO_PIPELINE_MODULES = frozenset(
|
|
{
|
|
"__init__",
|
|
"handler",
|
|
"template_parser",
|
|
"extraction",
|
|
"telemetry",
|
|
"persistence",
|
|
"prompts",
|
|
}
|
|
)
|
|
|
|
# Full shipped set of each email-processor bundle (pipeline glob + shared glob).
|
|
# Derived from the per-dir pins above so it stays consistent with them; policed
|
|
# per-dir (NOT via this union) so a shared-name shadow in a pipeline dir cannot
|
|
# be masked. web_ui_auth is a deliberate, harmless ride-along of the pinned
|
|
# `cp shared/*.py` (constraint #8) -- never imported by the email handlers, but
|
|
# it ships, so it is part of the shipped set.
|
|
PO_EXPECTED_TOP_LEVEL_MODULES = PO_PIPELINE_MODULES | SHARED_MODULES
|
|
WO_EXPECTED_TOP_LEVEL_MODULES = WO_PIPELINE_MODULES | SHARED_MODULES
|
|
|
|
# procurement-api (lambdas/api/): a fourth bundled function, in its own stack.
|
|
# Same exact-set discipline as the pipeline dirs -- `cp api/*.py` ships every
|
|
# top-level .py here (untracked strays included), so any new module is a
|
|
# deliberate addition to this pin.
|
|
API_PIPELINE_MODULES = frozenset(
|
|
{
|
|
"handler",
|
|
"router",
|
|
"pagination",
|
|
"serialization",
|
|
"wo_repo",
|
|
"po_repo",
|
|
}
|
|
)
|
|
# Non-.py files the api bundle must also EXECUTE cps for: the handler serves
|
|
# the spec, docs page, and the vendored Redoc bundle from its own package
|
|
# dir, so dropping any cp 500s /docs at runtime with green CI.
|
|
API_DATA_FILES_CP_RES = (
|
|
r"(?:^|\s)api/openapi\.json(?:\s|$)",
|
|
r"(?:^|\s)api/docs\.html(?:\s|$)",
|
|
r"(?:^|\s)api/redoc\.standalone\.js(?:\s|$)",
|
|
r"(?:^|\s)api/fonts\.css(?:\s|$)",
|
|
)
|
|
|
|
# SHOC webhook emitter + HMAC rotator (lambdas/wo/shoc_emitter|shoc_hmac_rotator,
|
|
# bundled by cdk/wo_stack.py's _add_shoc_webhook_emitter). Same exact-set
|
|
# discipline as the other bundles: `cp wo/shoc_emitter/*.py` /
|
|
# `cp wo/shoc_hmac_rotator/*.py` ship every top-level .py in those dirs
|
|
# (untracked strays included -- Code.from_asset does not honor .gitignore), so
|
|
# any new module is a deliberate addition to these pins.
|
|
SHOC_EMITTER_MODULES = frozenset({"__init__", "handler", "envelope", "delivery"})
|
|
SHOC_ROTATOR_MODULES = frozenset({"__init__", "handler"})
|
|
|
|
# Pipeline-scoped glob shapes the per-stack ships-all pins accept. Phase 2
|
|
# widened the bundling cwd to the shared ../lambdas asset root, so the executed
|
|
# glob carries its own pipeline's path prefix (`po/email_processor/*.py`); a
|
|
# bare `*.py`/`./*.py` prefix is still accepted for the legacy in-dir cwd. A
|
|
# WRONG-pipeline prefix (`wo/email_processor/*.py` in po_stack) or an arbitrary
|
|
# directory (`venv/lib/*.py`) is deliberately NOT accepted: it would false-pass
|
|
# the ships-all shape check while staging a bundle missing a required sibling
|
|
# (e.g. derived_fields.py, which lives only under po/) -- a runtime ImportError
|
|
# that green CI must never wave through. Do NOT relax these to an any-prefix
|
|
# pattern: that reintroduces exactly the wrong-pipeline false-pass this pins out.
|
|
PO_SCOPED_GLOB_RE = r"(?:^|\s)(?:\./|po/email_processor/)?\*\.py(?:\s|$)"
|
|
WO_SCOPED_GLOB_RE = r"(?:^|\s)(?:\./|wo/email_processor/)?\*\.py(?:\s|$)"
|
|
|
|
# Phase 3: both email-processor bundles gain a second glob copying the
|
|
# single-sourced shared modules flat into the zip. This must be the EXECUTED
|
|
# form (`_executed_cp_commands` strips comments), so a commented-out shared cp
|
|
# cannot false-pass the pin.
|
|
SHARED_CP_RE = r"(?:^|\s)shared/\*\.py(?:\s|$)"
|
|
|
|
# Phase 3: each stack's web_ui function stages ONLY shared/web_ui_auth.py flat
|
|
# beside its handler (NOT all of shared/ -- the email-only modules must not
|
|
# bloat the web UI zip). The auth-gated web_ui handlers do a module-top-level
|
|
# `from web_ui_auth import is_authenticated`, so dropping/commenting this cp
|
|
# ImportErrors the Lambda at cold start with green CI -- the PR #105 ImportError
|
|
# class the AST test exists to prevent, but for a surface (web_ui) the
|
|
# email-processor selector deliberately excludes. Checked as the EXECUTED form
|
|
# so a commented-out cp cannot false-pass.
|
|
WEB_UI_AUTH_CP_RE = r"(?:^|\s)shared/web_ui_auth\.py(?:\s|$)"
|
|
|
|
|
|
def _first_party_sibling_imports(handler_path: Path) -> set[str]:
|
|
"""Top-level module names handler.py imports that are first-party siblings.
|
|
|
|
Parses only top-level (module-body) `import X` / `from X import ...`
|
|
statements -- not imports nested in functions -- and keeps a name only
|
|
if `<sibling_dir>/X.py` exists, which filters out stdlib/third-party
|
|
imports (json, os, boto3, ...) and keeps exactly the modules the
|
|
bundling step is obligated to ship.
|
|
"""
|
|
tree = ast.parse(handler_path.read_text())
|
|
names: set[str] = set()
|
|
for node in tree.body:
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
names.add(alias.name.split(".")[0])
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if node.module:
|
|
names.add(node.module.split(".")[0])
|
|
sibling_dir = handler_path.parent
|
|
# A first-party sibling resolves either next to the handler (per-pipeline:
|
|
# template_parser/derived_fields) or under lambdas/shared/ (single-sourced:
|
|
# ses_auth/email_parsing/emf, Phase 3). Both must count, or the ships-all
|
|
# pin would silently drop the shared siblings (ses_auth was silently
|
|
# dropped, email_parsing/emf never seen, before this resolved shared/).
|
|
return {
|
|
name
|
|
for name in names
|
|
if (sibling_dir / f"{name}.py").exists() or (SHARED_DIR / f"{name}.py").exists()
|
|
}
|
|
|
|
|
|
def _extract_bundling_command(stack_path: Path) -> str:
|
|
"""Extract the bash -c bundling command string from a CDK stack file.
|
|
|
|
Locates the `command=[...]` keyword argument to BundlingOptions and
|
|
returns its final list element's string value. Adjacent string-literal
|
|
concatenation (used in both stacks to keep the command readable across
|
|
lines, with `#` comments interspersed) is folded by the parser itself,
|
|
so this returns the exact single command string CDK will hand to bash.
|
|
|
|
Since Phase 3 each stack has TWO bundled functions -- the email processor
|
|
and the web_ui function (which now also stages a shared module). "The"
|
|
bundling command this test cares about is the EMAIL-processor one, so we
|
|
select the command whose text mentions ``email_processor`` (its first cp
|
|
is ``cp <pipeline>/email_processor/*.py``) and demand exactly one match --
|
|
the web_ui command (``cp -r <pipeline>/web_ui/.``) and site_extractor do
|
|
not match.
|
|
"""
|
|
tree = ast.parse(stack_path.read_text())
|
|
commands: list[str] = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.keyword) and node.arg == "command":
|
|
list_node = node.value
|
|
if isinstance(list_node, ast.List) and list_node.elts:
|
|
last = list_node.elts[-1]
|
|
if isinstance(last, ast.Constant) and isinstance(last.value, str):
|
|
commands.append(last.value)
|
|
email_cmds = [c for c in commands if "email_processor" in c]
|
|
if len(email_cmds) != 1:
|
|
raise AssertionError(
|
|
f"expected exactly one email-processor bundling command=[...] list in "
|
|
f"{stack_path}, found {len(email_cmds)} (of {len(commands)} total "
|
|
"command lists) — update this test to select the intended bundling "
|
|
"command explicitly"
|
|
)
|
|
return email_cmds[0]
|
|
|
|
|
|
def _extract_web_ui_command(stack_path: Path) -> str:
|
|
"""Extract the web_ui function's bash -c bundling command string.
|
|
|
|
Mirrors _extract_bundling_command but selects the command whose text
|
|
mentions ``web_ui`` (its first cp is ``cp -r <pipeline>/web_ui/.``). The
|
|
email-processor command (``cp <pipeline>/email_processor/*.py``, plus
|
|
``cp shared/*.py``) and site_extractor do not contain ``web_ui`` in the
|
|
folded command STRING (the explanatory ``# ... web_ui_auth ...`` comments
|
|
around the email command are Python comments, not string content, so they
|
|
are not part of the folded literal). Demands exactly one match so an
|
|
ambiguity surfaces loudly instead of silently checking the wrong command.
|
|
"""
|
|
tree = ast.parse(stack_path.read_text())
|
|
commands: list[str] = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.keyword) and node.arg == "command":
|
|
list_node = node.value
|
|
if isinstance(list_node, ast.List) and list_node.elts:
|
|
last = list_node.elts[-1]
|
|
if isinstance(last, ast.Constant) and isinstance(last.value, str):
|
|
commands.append(last.value)
|
|
web_ui_cmds = [c for c in commands if "web_ui" in c]
|
|
if len(web_ui_cmds) != 1:
|
|
raise AssertionError(
|
|
f"expected exactly one web_ui bundling command=[...] list in "
|
|
f"{stack_path}, found {len(web_ui_cmds)} (of {len(commands)} total "
|
|
"command lists) — update this test to select the intended bundling "
|
|
"command explicitly"
|
|
)
|
|
return web_ui_cmds[0]
|
|
|
|
|
|
def _executed_cp_commands(command: str) -> list[str]:
|
|
"""The `cp ...` invocations bash would actually execute, comment-stripped.
|
|
|
|
Splits the bundling command on shell separators (`&&`, `;`, `|`, newline),
|
|
drops any trailing `#` comment from each segment, and returns the segments
|
|
whose command word is `cp`. This is deliberately stricter than a substring
|
|
match: a glob or `cp -r` string that survives only inside a comment
|
|
(e.g. `cp handler.py /asset-output/ # was: cp ./*.py /asset-output/`) is
|
|
NOT returned, because bash would not execute it -- so a revert that strips
|
|
the real copy but leaves the old text commented cannot false-pass.
|
|
"""
|
|
segments = re.split(r"&&|\|\||;|\||\n", command)
|
|
cp_cmds: list[str] = []
|
|
for seg in segments:
|
|
seg = seg.split("#", 1)[0].strip()
|
|
if re.match(r"cp\b", seg):
|
|
cp_cmds.append(seg)
|
|
return cp_cmds
|
|
|
|
|
|
def _bundling_ships_all(command: str, sibling_names: set[str]) -> bool:
|
|
"""True if `command` is guaranteed to ship every name in `sibling_names`.
|
|
|
|
Inspects only the `cp` commands bash actually executes (comments stripped
|
|
via `_executed_cp_commands`) and ACCUMULATES the set of shipped module
|
|
stems across ALL of them -- because since Phase 3 the required siblings
|
|
ship via TWO globs, not one: `cp <pipeline>/email_processor/*.py` covers
|
|
the per-pipeline siblings and `cp shared/*.py` covers the single-sourced
|
|
ones (ses_auth/email_parsing/emf). A single-cp "one glob ships everything"
|
|
check would wrongly report False now that coverage is split.
|
|
|
|
Each executed cp contributes to the shipped set as follows:
|
|
- a non-recursive `*.py` glob ships every top-level .py in the globbed
|
|
directory -- but ONLY that directory. Its (optional) path prefix is
|
|
resolved against the ../lambdas asset root (the Phase 2 bundling cwd)
|
|
and every `.py` stem actually present there is added. A wrong-pipeline
|
|
or arbitrary-directory glob (`cp wo/email_processor/*.py` in po_stack,
|
|
`cp venv/lib/*.py`) therefore contributes only what that dir really
|
|
holds (or nothing, if it does not exist) and can never cover a sibling
|
|
that lives elsewhere;
|
|
- a recursive copy of the WHOLE source dir, e.g. `cp -r . /asset-output/`
|
|
(`.`/`./` source only), ships everything -> short-circuit True;
|
|
- any other executed `cp` is an explicit filename allowlist (the legacy
|
|
PO form): each copied `<name>.py` stem is added, so a reverted
|
|
allowlist missing a sibling leaves that sibling out of the set.
|
|
Returns True only if every required sibling ended up in the accumulated set.
|
|
"""
|
|
cp_cmds = _executed_cp_commands(command)
|
|
if not cp_cmds:
|
|
return False
|
|
shipped: set[str] = set()
|
|
for cp in cp_cmds:
|
|
glob_match = re.search(r"(?:^|\s)((?:[\w./-]+/)?)\*\.py(?:\s|$)", cp)
|
|
if glob_match:
|
|
glob_dir = (REPO_ROOT / "lambdas" / glob_match.group(1)).resolve()
|
|
shipped.update(p.stem for p in glob_dir.glob("*.py"))
|
|
continue
|
|
if re.search(r"cp\s+-r\s+\.\/?\s+/asset-output", cp):
|
|
return True
|
|
# Explicit-allowlist shape: collect the copied source filenames,
|
|
# ignoring any cp flags and the trailing /asset-output destination.
|
|
match = re.search(
|
|
r"cp\s+(?:-\S+\s+)*(.*?)\s*/asset-output/?\"?\s*$", cp.strip()
|
|
)
|
|
if match:
|
|
shipped.update(Path(f).stem for f in match.group(1).split())
|
|
return all(name in shipped for name in sibling_names)
|
|
|
|
|
|
def test_po_bundling_ships_all_first_party_siblings():
|
|
siblings = _first_party_sibling_imports(PO_HANDLER)
|
|
# Sanity: PO handler.py is known to import ses_auth, template_parser,
|
|
# and derived_fields as bare-name siblings. If this ever collapses to
|
|
# an empty set, the test below would vacuously pass -- guard against that.
|
|
assert siblings, "expected first-party sibling imports in PO handler.py"
|
|
|
|
command = _extract_bundling_command(PO_STACK)
|
|
|
|
# Pinned per the Phase 0 healthcheck/bundling contract: PO's bundling
|
|
# command must EXECUTE the non-recursive glob, not a hand-maintained
|
|
# allowlist. Checked against the executed cp (comments stripped) so the
|
|
# old glob text surviving only in a comment cannot satisfy this.
|
|
executed_cps = _executed_cp_commands(command)
|
|
assert any(re.search(PO_SCOPED_GLOB_RE, cp) for cp in executed_cps), (
|
|
"cdk/po_stack.py bundling command must EXECUTE the PO-scoped non-recursive "
|
|
"glob 'cp po/email_processor/*.py /asset-output/' so every first-party "
|
|
"sibling handler.py imports ships automatically. A wrong-pipeline or "
|
|
"arbitrary-directory glob is rejected here on purpose. "
|
|
f"Executed cp commands: {executed_cps}"
|
|
)
|
|
# Phase 3: the shared siblings (ses_auth/email_parsing/emf) ship via a
|
|
# SECOND executed glob, `cp shared/*.py /asset-output/`. Require it to be
|
|
# actually executed (comment-stripped), so commenting it out fails here;
|
|
# combined with ships-all below (those siblings resolve only under shared/)
|
|
# a removed/commented shared cp fails BOTH assertions.
|
|
assert any(re.search(SHARED_CP_RE, cp) for cp in executed_cps), (
|
|
"cdk/po_stack.py email-processor bundling command must EXECUTE "
|
|
"'cp shared/*.py /asset-output/' so the single-sourced shared modules "
|
|
f"ship flat beside handler.py. Executed cp commands: {executed_cps}"
|
|
)
|
|
assert _bundling_ships_all(command, siblings), (
|
|
f"cdk/po_stack.py bundling command does not ship all of {sorted(siblings)}: "
|
|
f"{command!r}"
|
|
)
|
|
|
|
|
|
def test_wo_bundling_ships_all_first_party_siblings():
|
|
siblings = _first_party_sibling_imports(WO_HANDLER)
|
|
assert siblings, "expected first-party sibling imports in WO handler.py"
|
|
|
|
command = _extract_bundling_command(WO_STACK)
|
|
|
|
# Phase 2: WO now ships via the same scoped non-recursive glob as PO,
|
|
# copying only wo/email_processor/*.py from the widened ../lambdas asset
|
|
# root. This deliberately drops tests/, __pycache__, and requirements.txt
|
|
# from the production zip (docs/refactor-evaluation.md Phase 2). Checked
|
|
# against the comment-stripped executed cp so an old `cp -r .` surviving
|
|
# only in a comment cannot satisfy this, and a revert to the whole-dir
|
|
# copy (which would re-ship tests/) fails loudly.
|
|
executed_cps = _executed_cp_commands(command)
|
|
assert any(re.search(WO_SCOPED_GLOB_RE, cp) for cp in executed_cps), (
|
|
"cdk/wo_stack.py bundling command must EXECUTE the WO-scoped non-recursive "
|
|
"glob 'cp wo/email_processor/*.py /asset-output/' so every first-party "
|
|
"sibling ships while tests/ and requirements.txt are excluded. A "
|
|
"wrong-pipeline or arbitrary-directory glob is rejected here on purpose. "
|
|
f"Executed cp commands: {executed_cps}"
|
|
)
|
|
# Phase 3: shared siblings ship via the second executed glob `cp shared/*.py`.
|
|
assert any(re.search(SHARED_CP_RE, cp) for cp in executed_cps), (
|
|
"cdk/wo_stack.py email-processor bundling command must EXECUTE "
|
|
"'cp shared/*.py /asset-output/' so the single-sourced shared modules "
|
|
f"ship flat beside handler.py. Executed cp commands: {executed_cps}"
|
|
)
|
|
assert _bundling_ships_all(command, siblings), (
|
|
f"cdk/wo_stack.py bundling command does not ship all of {sorted(siblings)}: "
|
|
f"{command!r}"
|
|
)
|
|
|
|
|
|
def test_po_email_processor_dir_ships_no_unexpected_top_level_modules():
|
|
"""Upper bound on the PO pipeline dir alone (NOT unioned with shared/).
|
|
|
|
The sibling-import tests above prove the glob ships every module the
|
|
handler needs (shipped >= required). This proves the other direction for
|
|
the pipeline dir (shipped <= expected): because `cp po/email_processor/*.py`
|
|
copies every top-level .py in that dir -- and `Code.from_asset` stages
|
|
untracked files too (it does not honor .gitignore) -- a stray scratch or
|
|
secrets .py, OR a shadow copy of a moved shared module, would silently ship
|
|
into the zip on a local deploy. Policing this dir SEPARATELY from shared/
|
|
(rather than as a union with it) is what makes a strayed-back ses_auth.py /
|
|
email_parsing.py / emf.py FAIL here instead of being masked by the same name
|
|
already being expected in shared/.
|
|
"""
|
|
top_level = {p.stem for p in PO_HANDLER.parent.glob("*.py")}
|
|
assert top_level == set(PO_PIPELINE_MODULES), (
|
|
"unexpected top-level .py set in lambdas/po/email_processor -- the "
|
|
"'cp po/email_processor/*.py' glob would ship exactly these into the "
|
|
f"Lambda zip. Found {sorted(top_level)}, expected "
|
|
f"{sorted(PO_PIPELINE_MODULES)}. A moved shared module "
|
|
f"({sorted(SHARED_MODULES)}) reappearing here would SHADOW the single "
|
|
"shared source in every handler-loaded test -- remove it. If a new "
|
|
"per-pipeline module is intended, add it to PO_PIPELINE_MODULES."
|
|
)
|
|
|
|
|
|
def test_wo_email_processor_dir_ships_no_unexpected_top_level_modules():
|
|
"""Upper bound on the WO pipeline dir alone (NOT unioned with shared/).
|
|
|
|
The WO equivalent of the PO exact-set pin -- previously MISSING entirely,
|
|
so a stray .py (or a shadow copy of a moved shared module) in
|
|
lambdas/wo/email_processor was policed by nothing. Same rationale as the PO
|
|
pin: `cp wo/email_processor/*.py` ships every top-level .py in this dir, and
|
|
a strayed-back ses_auth/email_parsing/emf would shadow the shared source in
|
|
the WO handler-loaded tests.
|
|
"""
|
|
top_level = {p.stem for p in WO_HANDLER.parent.glob("*.py")}
|
|
assert top_level == set(WO_PIPELINE_MODULES), (
|
|
"unexpected top-level .py set in lambdas/wo/email_processor -- the "
|
|
"'cp wo/email_processor/*.py' glob would ship exactly these into the "
|
|
f"Lambda zip. Found {sorted(top_level)}, expected "
|
|
f"{sorted(WO_PIPELINE_MODULES)}. A moved shared module "
|
|
f"({sorted(SHARED_MODULES)}) reappearing here would SHADOW the single "
|
|
"shared source in every handler-loaded test -- remove it. If a new "
|
|
"per-pipeline module is intended, add it to WO_PIPELINE_MODULES."
|
|
)
|
|
|
|
|
|
def test_shared_dir_ships_no_unexpected_top_level_modules():
|
|
"""Upper bound on lambdas/shared/ alone (NOT unioned with a pipeline dir).
|
|
|
|
`cp shared/*.py` ships every top-level .py under lambdas/shared/ into BOTH
|
|
email-processor bundles, so a stray scratch/secrets .py here would leak into
|
|
both production zips. Pinned to exactly the four single-sourced modules.
|
|
"""
|
|
top_level = {p.stem for p in SHARED_DIR.glob("*.py")}
|
|
assert top_level == set(SHARED_MODULES), (
|
|
"unexpected top-level .py set in lambdas/shared -- the 'cp shared/*.py' "
|
|
"glob would ship exactly these into BOTH email-processor Lambda zips. "
|
|
f"Found {sorted(top_level)}, expected {sorted(SHARED_MODULES)}. If a new "
|
|
"shared module is intended, add it to SHARED_MODULES; if it is a scratch "
|
|
"or secrets file, remove it before deploy."
|
|
)
|
|
|
|
|
|
def test_no_shared_module_shadow_in_pipeline_dirs():
|
|
"""The moved shared names must live ONLY under lambdas/shared/.
|
|
|
|
Replaces the future-drift guard the retired byte-identity ses_auth fixture
|
|
used to provide. A stray reappearance of a moved shared module in either
|
|
email-processor pipeline dir would be resolved FIRST by every handler loader
|
|
-- tests/conftest.py load_handler checks `path.parent / f"{sibling}.py"`
|
|
before the _SHARED_DIR fallback, and
|
|
lambdas/wo/email_processor/tests/_wo_parser_support.py inserts the pipeline
|
|
dir ahead of shared/ on sys.path -- silently SHADOWING the single shared
|
|
source in every handler-loaded test, while test_ses_auth / test_parse_raw_email
|
|
keep exercising shared/. Divergence would go undetected. Police the pipeline
|
|
dirs and shared/ SEPARATELY so no union can mask the shadow.
|
|
"""
|
|
for name in sorted(SHARED_MODULES):
|
|
assert (SHARED_DIR / f"{name}.py").exists(), (
|
|
f"{name}.py must exist under lambdas/shared/ (single source of truth)"
|
|
)
|
|
assert not (PO_HANDLER.parent / f"{name}.py").exists(), (
|
|
f"{name}.py reappeared in lambdas/po/email_processor -- it would "
|
|
"SHADOW lambdas/shared/{name}.py in every PO handler-loaded test "
|
|
"(the loader resolves the pipeline-local copy first). Delete it; "
|
|
"the single source lives under lambdas/shared/."
|
|
)
|
|
assert not (WO_HANDLER.parent / f"{name}.py").exists(), (
|
|
f"{name}.py reappeared in lambdas/wo/email_processor -- it would "
|
|
"SHADOW lambdas/shared/{name}.py in every WO handler-loaded test "
|
|
"(_wo_parser_support inserts the pipeline dir ahead of shared/ on "
|
|
"sys.path). Delete it; the single source lives under lambdas/shared/."
|
|
)
|
|
|
|
|
|
def test_detection_logic_catches_allowlist_missing_a_sibling():
|
|
"""Unit-level check on `_bundling_ships_all` itself.
|
|
|
|
Simulates the historical regression shape directly: someone reverts the
|
|
PO glob back to an explicit filename allowlist that omits a required sibling.
|
|
Phase 5 note: the PR #2 near-miss module (derived_fields) is now a TRANSITIVE
|
|
import via enrichment.py, so it is no longer among handler.py's DIRECT
|
|
first-party imports; the representative near-miss here is enrichment (PO-only,
|
|
a direct handler import). `_bundling_ships_all` must detect the gap so that,
|
|
combined with the "cp ./*.py" pin above,
|
|
test_po_bundling_ships_all_first_party_siblings fails loudly on any such
|
|
revert rather than silently passing.
|
|
"""
|
|
siblings = _first_party_sibling_imports(PO_HANDLER)
|
|
assert "enrichment" in siblings # sanity: a PO-only direct flat-sibling import
|
|
|
|
reverted_allowlist_command = (
|
|
"pip install --platform manylinux2014_aarch64 --only-binary=:all: "
|
|
"-r requirements.txt -t /asset-output && "
|
|
"cp handler.py ses_auth.py template_parser.py /asset-output/"
|
|
)
|
|
|
|
assert not _bundling_ships_all(reverted_allowlist_command, siblings)
|
|
|
|
# Control: the same allowlist shape listing ALL required direct siblings
|
|
# (the Phase 3 shared ses_auth/email_parsing + the Phase 5 flat siblings
|
|
# prompts/telemetry/extraction/enrichment/persistence) is correctly
|
|
# recognized as complete under the accumulate model, proving the failure
|
|
# above is about the missing files and not a regex artifact.
|
|
complete_allowlist_command = (
|
|
"pip install --platform manylinux2014_aarch64 --only-binary=:all: "
|
|
"-r requirements.txt -t /asset-output && "
|
|
"cp handler.py ses_auth.py template_parser.py email_parsing.py "
|
|
"prompts.py telemetry.py extraction.py enrichment.py persistence.py "
|
|
"/asset-output/"
|
|
)
|
|
assert _bundling_ships_all(complete_allowlist_command, siblings)
|
|
|
|
|
|
def test_detection_logic_rejects_narrowed_scoped_glob():
|
|
"""A narrowed single-file cp (no `*`) must not satisfy the scoped-glob pin.
|
|
|
|
Phase 2 widened the glob's source prefix to `po/email_processor/*.py`
|
|
(resp. `wo/email_processor/*.py`) against the ../lambdas asset root.
|
|
Guard against a narrowing regression -- e.g. a bad find/replace that
|
|
keeps the path prefix but drops the `*` and copies only handler.py --
|
|
from silently passing as ships-all. `cp po/email_processor/handler.py`
|
|
has a path prefix but no glob star, so the scoped-glob regex must not
|
|
match it, and it also fails the explicit-allowlist fallback because it
|
|
omits ses_auth/template_parser/derived_fields.
|
|
"""
|
|
siblings = _first_party_sibling_imports(PO_HANDLER)
|
|
|
|
narrowed_command = (
|
|
"pip install --platform manylinux2014_aarch64 --only-binary=:all: "
|
|
"-r po/email_processor/requirements.txt -t /asset-output && "
|
|
"cp po/email_processor/handler.py /asset-output/"
|
|
)
|
|
assert not _bundling_ships_all(narrowed_command, siblings)
|
|
|
|
|
|
def test_detection_logic_rejects_commented_out_scoped_glob():
|
|
"""A scoped glob surviving only in a `#` comment must not pass.
|
|
|
|
Simulates a revert that narrows the executed `cp` to a single file but
|
|
leaves the old scoped-glob text trailing as a comment (e.g. a
|
|
half-finished revert). `_executed_cp_commands` strips `#` comments
|
|
before any regex sees the segment, so the glob text alone -- never
|
|
actually executed by bash -- cannot false-pass the pin.
|
|
"""
|
|
siblings = _first_party_sibling_imports(WO_HANDLER)
|
|
|
|
commented_out_command = (
|
|
"pip install --platform manylinux2014_aarch64 --only-binary=:all: "
|
|
"-r wo/email_processor/requirements.txt -t /asset-output && "
|
|
"cp wo/email_processor/handler.py /asset-output/ "
|
|
"# was: cp wo/email_processor/*.py /asset-output/"
|
|
)
|
|
assert not _bundling_ships_all(commented_out_command, siblings)
|
|
|
|
|
|
def test_detection_logic_rejects_commented_out_shared_cp():
|
|
"""A `cp shared/*.py` surviving only in a `#` comment must not count as run.
|
|
|
|
Simulates a half-finished revert that drops the executed shared cp but
|
|
leaves its text trailing as a comment. `_executed_cp_commands` strips `#`
|
|
comments before any regex sees the segment, so the shared-cp text alone --
|
|
never executed by bash -- is not among the executed cp's. Combined with the
|
|
fixed ships-all (ses_auth/email_parsing/emf resolve ONLY under shared/), a
|
|
removed or commented shared cp fails both the SHARED_CP_RE pin and ships-all.
|
|
"""
|
|
commented_out_command = (
|
|
"cp po/email_processor/*.py /asset-output/ # cp shared/*.py /asset-output/"
|
|
)
|
|
executed = _executed_cp_commands(commented_out_command)
|
|
assert not any(re.search(SHARED_CP_RE, cp) for cp in executed)
|
|
# And ships-all is False: the shared siblings never got shipped.
|
|
po_siblings = _first_party_sibling_imports(PO_HANDLER)
|
|
assert not _bundling_ships_all(commented_out_command, po_siblings)
|
|
|
|
|
|
def test_detection_logic_rejects_wrong_pipeline_glob():
|
|
"""A glob pointing at the WRONG pipeline (or any other dir) must not pass.
|
|
|
|
Phase 2 widened the bundling cwd to the shared ../lambdas asset root, so a
|
|
copy-paste slip that leaves po_stack copying `wo/email_processor/*.py`
|
|
would stage a bundle missing derived_fields.py (which lives only under
|
|
po/email_processor) -- a runtime ImportError. `_bundling_ships_all`
|
|
resolves the globbed directory against the lambdas root and confirms it
|
|
actually holds every required sibling, so a wrong-pipeline or
|
|
arbitrary-directory glob is rejected rather than short-circuiting to True
|
|
on the mere presence of a `*.py` token. This is the missing-sibling class
|
|
(PR #105 / PR #2) the AST test exists to catch at CI time.
|
|
"""
|
|
po_siblings = _first_party_sibling_imports(PO_HANDLER)
|
|
# enrichment is a direct PO handler import that lives ONLY under
|
|
# po/email_processor (WO has no enrichment stage) -- Phase 5's clean
|
|
# stand-in for derived_fields (now only a transitive import) as the
|
|
# sibling a wrong-pipeline `wo/email_processor/*.py` glob would miss.
|
|
assert "enrichment" in po_siblings # lives only under po/email_processor
|
|
|
|
wrong_pipeline_command = (
|
|
"pip install --platform manylinux2014_aarch64 --only-binary=:all: "
|
|
"-r po/email_processor/requirements.txt -t /asset-output && "
|
|
"cp wo/email_processor/*.py /asset-output/"
|
|
)
|
|
assert not _bundling_ships_all(wrong_pipeline_command, po_siblings)
|
|
|
|
arbitrary_dir_command = (
|
|
"pip install --platform manylinux2014_aarch64 --only-binary=:all: "
|
|
"-r po/email_processor/requirements.txt -t /asset-output && "
|
|
"cp venv/lib/*.py /asset-output/"
|
|
)
|
|
assert not _bundling_ships_all(arbitrary_dir_command, po_siblings)
|
|
|
|
# The per-stack shape-check pins reject the wrong prefix too: the PO pin
|
|
# matches its own scoped glob but not the WO one, and vice versa.
|
|
assert re.search(PO_SCOPED_GLOB_RE, "cp po/email_processor/*.py /asset-output/")
|
|
assert not re.search(PO_SCOPED_GLOB_RE, "cp wo/email_processor/*.py /asset-output/")
|
|
assert re.search(WO_SCOPED_GLOB_RE, "cp wo/email_processor/*.py /asset-output/")
|
|
assert not re.search(WO_SCOPED_GLOB_RE, "cp po/email_processor/*.py /asset-output/")
|
|
|
|
|
|
def test_po_web_ui_bundle_stages_shared_auth_module():
|
|
"""The PO web_ui bundle must EXECUTE `cp shared/web_ui_auth.py`.
|
|
|
|
Phase 3 removed the inline auth block from lambdas/po/web_ui/handler.py,
|
|
which now does a module-top-level `from web_ui_auth import is_authenticated`;
|
|
web_ui_auth.py lives ONLY under lambdas/shared/. No test imports the web_ui
|
|
handler, and the email-processor AST pins deliberately exclude the web_ui
|
|
command -- so without this pin, dropping/commenting the web_ui cp would
|
|
ImportError the auth-gated Lambda at cold start with green CI. Checked
|
|
against the comment-stripped executed cp so the old text surviving only in a
|
|
comment cannot satisfy it.
|
|
"""
|
|
command = _extract_web_ui_command(PO_STACK)
|
|
executed_cps = _executed_cp_commands(command)
|
|
assert any(re.search(WEB_UI_AUTH_CP_RE, cp) for cp in executed_cps), (
|
|
"cdk/po_stack.py web_ui bundling command must EXECUTE "
|
|
"'cp shared/web_ui_auth.py /asset-output/' so the shared auth module "
|
|
"ships flat beside handler.py and `from web_ui_auth import "
|
|
f"is_authenticated` resolves at cold start. Executed cp commands: "
|
|
f"{executed_cps}"
|
|
)
|
|
|
|
|
|
def test_wo_web_ui_bundle_stages_shared_auth_module():
|
|
"""The WO web_ui bundle must EXECUTE `cp shared/web_ui_auth.py`.
|
|
|
|
WO equivalent of test_po_web_ui_bundle_stages_shared_auth_module -- same
|
|
ImportError-at-cold-start hazard for lambdas/wo/web_ui/handler.py.
|
|
"""
|
|
command = _extract_web_ui_command(WO_STACK)
|
|
executed_cps = _executed_cp_commands(command)
|
|
assert any(re.search(WEB_UI_AUTH_CP_RE, cp) for cp in executed_cps), (
|
|
"cdk/wo_stack.py web_ui bundling command must EXECUTE "
|
|
"'cp shared/web_ui_auth.py /asset-output/' so the shared auth module "
|
|
"ships flat beside handler.py and `from web_ui_auth import "
|
|
f"is_authenticated` resolves at cold start. Executed cp commands: "
|
|
f"{executed_cps}"
|
|
)
|
|
|
|
|
|
def test_detection_logic_rejects_commented_out_web_ui_auth_cp():
|
|
"""A `cp shared/web_ui_auth.py` surviving only in a `#` comment must not pass.
|
|
|
|
Mirror of test_detection_logic_rejects_commented_out_shared_cp for the
|
|
web_ui staging: a half-finished revert that drops the executed cp but leaves
|
|
its text trailing as a comment must NOT register as executed, since bash
|
|
would never run it.
|
|
"""
|
|
commented_out_command = (
|
|
"cp -r po/web_ui/. /asset-output/ # cp shared/web_ui_auth.py /asset-output/"
|
|
)
|
|
executed = _executed_cp_commands(commented_out_command)
|
|
assert not any(re.search(WEB_UI_AUTH_CP_RE, cp) for cp in executed)
|
|
|
|
|
|
def _extract_api_command(stack_path: Path) -> str:
|
|
"""Extract the procurement-api function's bash -c bundling command string.
|
|
|
|
Mirrors _extract_bundling_command but selects the command whose text
|
|
mentions ``api/`` (its first cp is ``cp api/*.py``). procurement_api_stack
|
|
has exactly one bundled function today; the exactly-one demand makes any
|
|
future second bundle in that stack surface loudly instead of silently
|
|
checking the wrong command.
|
|
"""
|
|
tree = ast.parse(stack_path.read_text())
|
|
commands: list[str] = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.keyword) and node.arg == "command":
|
|
list_node = node.value
|
|
if isinstance(list_node, ast.List) and list_node.elts:
|
|
last = list_node.elts[-1]
|
|
if isinstance(last, ast.Constant) and isinstance(last.value, str):
|
|
commands.append(last.value)
|
|
api_cmds = [c for c in commands if "api/" in c]
|
|
if len(api_cmds) != 1:
|
|
raise AssertionError(
|
|
f"expected exactly one api bundling command=[...] list in "
|
|
f"{stack_path}, found {len(api_cmds)} (of {len(commands)} total "
|
|
"command lists) — update this test to select the intended bundling "
|
|
"command explicitly"
|
|
)
|
|
return api_cmds[0]
|
|
|
|
|
|
def test_api_bundling_ships_all_first_party_siblings():
|
|
"""The procurement-api bundle must ship every sibling handler.py imports.
|
|
|
|
Same PR #105 ImportError class as the email processors: the api handler
|
|
imports router/pagination/serialization/wo_repo/po_repo (next to it) and
|
|
web_ui_auth (lambdas/shared/); a narrowed glob or dropped shared cp would
|
|
cold-start ImportError with green CI.
|
|
"""
|
|
required = _first_party_sibling_imports(API_HANDLER)
|
|
assert required, "expected api/handler.py to import first-party siblings"
|
|
command = _extract_api_command(API_STACK)
|
|
assert _bundling_ships_all(command, required), (
|
|
f"cdk/procurement_api_stack.py bundling does not ship all first-party "
|
|
f"siblings {sorted(required)}. Executed cp commands: "
|
|
f"{_executed_cp_commands(command)}"
|
|
)
|
|
|
|
|
|
def test_api_dir_ships_no_unexpected_top_level_modules():
|
|
"""Exact-set pin on lambdas/api/*.py -- both bounds.
|
|
|
|
`cp api/*.py` ships every top-level .py in the dir (untracked strays
|
|
included, since Code.from_asset does not honor .gitignore), so a stray
|
|
scratch/secrets module would silently ship into the production zip. Any
|
|
new module must be deliberately added to API_PIPELINE_MODULES.
|
|
"""
|
|
api_dir = REPO_ROOT / "lambdas" / "api"
|
|
top_level = {p.stem for p in api_dir.glob("*.py")}
|
|
assert top_level == set(API_PIPELINE_MODULES), (
|
|
f"lambdas/api/ top-level modules {sorted(top_level)} != pinned "
|
|
f"{sorted(API_PIPELINE_MODULES)}. If a new module is intended, add it "
|
|
"to API_PIPELINE_MODULES."
|
|
)
|
|
|
|
|
|
def test_api_bundle_stages_shared_auth_module():
|
|
"""The api bundle must EXECUTE `cp shared/web_ui_auth.py` (docs-token gate)."""
|
|
command = _extract_api_command(API_STACK)
|
|
executed_cps = _executed_cp_commands(command)
|
|
assert any(re.search(WEB_UI_AUTH_CP_RE, cp) for cp in executed_cps), (
|
|
"cdk/procurement_api_stack.py bundling command must EXECUTE "
|
|
"'cp shared/web_ui_auth.py /asset-output/' so the docs-token gate "
|
|
f"resolves at cold start. Executed cp commands: {executed_cps}"
|
|
)
|
|
|
|
|
|
def test_api_bundle_stages_spec_and_docs_page():
|
|
"""The api bundle must EXECUTE cps for openapi.json and docs.html.
|
|
|
|
The handler serves both from its package dir; the .py glob does not cover
|
|
them, so each needs its own executed cp or /docs 500s at runtime.
|
|
"""
|
|
command = _extract_api_command(API_STACK)
|
|
executed_cps = _executed_cp_commands(command)
|
|
for pattern in API_DATA_FILES_CP_RES:
|
|
assert any(re.search(pattern, cp) for cp in executed_cps), (
|
|
f"cdk/procurement_api_stack.py bundling command must EXECUTE a cp "
|
|
f"matching {pattern!r}. Executed cp commands: {executed_cps}"
|
|
)
|
|
|
|
|
|
def _extract_shoc_emitter_command(stack_path: Path) -> str:
|
|
"""Extract the SHOC emitter's bash -c bundling command string.
|
|
|
|
Mirrors _extract_api_command but selects the command whose text mentions
|
|
``shoc_emitter`` (its cp is ``cp wo/shoc_emitter/*.py``). The plan's CI
|
|
note (docs/shoc-webhook-plan.md Phase 3) is explicit that an unrecognized
|
|
bundle ships unchecked -- the email/web_ui selectors deliberately do not
|
|
match the emitter command, so it needs its own selector. Demands exactly
|
|
one match so an ambiguity surfaces loudly instead of silently checking the
|
|
wrong command.
|
|
"""
|
|
tree = ast.parse(stack_path.read_text())
|
|
commands: list[str] = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.keyword) and node.arg == "command":
|
|
list_node = node.value
|
|
if isinstance(list_node, ast.List) and list_node.elts:
|
|
last = list_node.elts[-1]
|
|
if isinstance(last, ast.Constant) and isinstance(last.value, str):
|
|
commands.append(last.value)
|
|
shoc_cmds = [c for c in commands if "shoc_emitter" in c]
|
|
if len(shoc_cmds) != 1:
|
|
raise AssertionError(
|
|
f"expected exactly one shoc_emitter bundling command=[...] list in "
|
|
f"{stack_path}, found {len(shoc_cmds)} (of {len(commands)} total "
|
|
"command lists) — update this test to select the intended bundling "
|
|
"command explicitly"
|
|
)
|
|
return shoc_cmds[0]
|
|
|
|
|
|
def _extract_shoc_rotator_command(stack_path: Path) -> str:
|
|
"""Extract the SHOC HMAC rotator's bash -c bundling command string.
|
|
|
|
Mirrors _extract_shoc_emitter_command with the ``shoc_hmac_rotator``
|
|
selector (its cp is ``cp wo/shoc_hmac_rotator/*.py``); the emitter command
|
|
does not contain that substring, so exactly-one holds.
|
|
"""
|
|
tree = ast.parse(stack_path.read_text())
|
|
commands: list[str] = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.keyword) and node.arg == "command":
|
|
list_node = node.value
|
|
if isinstance(list_node, ast.List) and list_node.elts:
|
|
last = list_node.elts[-1]
|
|
if isinstance(last, ast.Constant) and isinstance(last.value, str):
|
|
commands.append(last.value)
|
|
rotator_cmds = [c for c in commands if "shoc_hmac_rotator" in c]
|
|
if len(rotator_cmds) != 1:
|
|
raise AssertionError(
|
|
f"expected exactly one shoc_hmac_rotator bundling command=[...] list "
|
|
f"in {stack_path}, found {len(rotator_cmds)} (of {len(commands)} "
|
|
"total command lists) — update this test to select the intended "
|
|
"bundling command explicitly"
|
|
)
|
|
return rotator_cmds[0]
|
|
|
|
|
|
def test_shoc_emitter_bundling_ships_all_first_party_siblings():
|
|
"""The SHOC emitter bundle must ship every sibling handler.py imports.
|
|
|
|
Same PR #105 ImportError class as the other bundles: the emitter handler
|
|
imports envelope and delivery as bare-name flat siblings
|
|
(lambdas/wo/shoc_emitter/); a narrowed cp would cold-start ImportError on
|
|
the first stream invocation with green CI.
|
|
"""
|
|
required = _first_party_sibling_imports(SHOC_EMITTER_HANDLER)
|
|
# Sanity: the emitter handler is known to import envelope + delivery. If
|
|
# this ever collapses to empty, ships-all would vacuously pass.
|
|
assert required == {"envelope", "delivery"}, (
|
|
f"expected the emitter handler's first-party siblings to be envelope + "
|
|
f"delivery, found {sorted(required)} — update this pin deliberately"
|
|
)
|
|
command = _extract_shoc_emitter_command(WO_STACK)
|
|
assert _bundling_ships_all(command, required), (
|
|
f"cdk/wo_stack.py shoc_emitter bundling does not ship all first-party "
|
|
f"siblings {sorted(required)}. Executed cp commands: "
|
|
f"{_executed_cp_commands(command)}"
|
|
)
|
|
|
|
|
|
def test_shoc_rotator_bundling_ships_handler():
|
|
"""The rotator bundle must ship handler.py (it has no first-party siblings).
|
|
|
|
The rotator is stdlib + boto3-from-runtime only, so its required sibling
|
|
set is empty -- pin that fact (a future sibling import must extend this
|
|
test), then prove the executed glob actually ships handler.py itself.
|
|
"""
|
|
required = _first_party_sibling_imports(SHOC_ROTATOR_HANDLER)
|
|
assert required == set(), (
|
|
f"the rotator handler grew first-party sibling imports "
|
|
f"{sorted(required)} — extend this ships-all test to require them"
|
|
)
|
|
command = _extract_shoc_rotator_command(WO_STACK)
|
|
assert _bundling_ships_all(command, {"handler"}), (
|
|
f"cdk/wo_stack.py shoc_hmac_rotator bundling does not ship handler.py. "
|
|
f"Executed cp commands: {_executed_cp_commands(command)}"
|
|
)
|
|
|
|
|
|
def test_shoc_emitter_dir_ships_no_unexpected_top_level_modules():
|
|
"""Exact-set pin on lambdas/wo/shoc_emitter/*.py -- both bounds.
|
|
|
|
`cp wo/shoc_emitter/*.py` ships every top-level .py in the dir (untracked
|
|
strays included, since Code.from_asset does not honor .gitignore), so a
|
|
stray scratch/secrets module would silently ship into the production zip.
|
|
Any new module must be deliberately added to SHOC_EMITTER_MODULES.
|
|
"""
|
|
top_level = {p.stem for p in SHOC_EMITTER_HANDLER.parent.glob("*.py")}
|
|
assert top_level == set(SHOC_EMITTER_MODULES), (
|
|
f"lambdas/wo/shoc_emitter/ top-level modules {sorted(top_level)} != "
|
|
f"pinned {sorted(SHOC_EMITTER_MODULES)}. If a new module is intended, "
|
|
"add it to SHOC_EMITTER_MODULES."
|
|
)
|
|
|
|
|
|
def test_shoc_rotator_dir_ships_no_unexpected_top_level_modules():
|
|
"""Exact-set pin on lambdas/wo/shoc_hmac_rotator/*.py -- both bounds.
|
|
|
|
Same rationale as the emitter pin: `cp wo/shoc_hmac_rotator/*.py` ships
|
|
every top-level .py in the dir, strays included.
|
|
"""
|
|
top_level = {p.stem for p in SHOC_ROTATOR_HANDLER.parent.glob("*.py")}
|
|
assert top_level == set(SHOC_ROTATOR_MODULES), (
|
|
f"lambdas/wo/shoc_hmac_rotator/ top-level modules {sorted(top_level)} "
|
|
f"!= pinned {sorted(SHOC_ROTATOR_MODULES)}. If a new module is "
|
|
"intended, add it to SHOC_ROTATOR_MODULES."
|
|
)
|
|
|
|
|
|
def test_shoc_commands_do_not_collide_with_existing_selectors():
|
|
"""The SHOC bundling commands must not trip the other exactly-one selectors.
|
|
|
|
_extract_bundling_command selects on the substring ``email_processor`` and
|
|
_extract_web_ui_command on ``web_ui``, each demanding exactly one match in
|
|
wo_stack.py. If either SHOC command ever grew one of those substrings
|
|
(e.g. a copy-pasted comment folded into the command string), those
|
|
selectors would find two commands and every email/web_ui pin would error.
|
|
Assert the invariant here so the failure names the actual cause.
|
|
"""
|
|
for command in (
|
|
_extract_shoc_emitter_command(WO_STACK),
|
|
_extract_shoc_rotator_command(WO_STACK),
|
|
):
|
|
assert "email_processor" not in command, (
|
|
f"SHOC bundling command contains 'email_processor', which would "
|
|
f"break _extract_bundling_command's exactly-one selection: "
|
|
f"{command!r}"
|
|
)
|
|
assert "web_ui" not in command, (
|
|
f"SHOC bundling command contains 'web_ui', which would break "
|
|
f"_extract_web_ui_command's exactly-one selection: {command!r}"
|
|
)
|