procurement-ingest/tests/test_resource_policy_helper.py
Adam Moussa c50ce7a3b3
feat(migration): prepare stacks and tooling for the seahaven-prod account move
Phase 1 of the mgmt (328440206208) -> seahaven-prod (011934824531)
migration. No behavior change in-account; everything here is additive or
account-portability hygiene:

- infra/deploy-role/: reviewed OIDC deploy-role artifacts for prod
  (trust main-only, cdk-hnb659fds-* AssumeRole, smoke-invoke-lambda scoped
  to exactly the two email-processor fn ARNs). Codifies the previously
  out-of-band smoke-invoke grant.
- Table resource policies: make_slack_bot_read_policy in cdk/common.py,
  applied to purchase-orders, verified-sites, WorkOrders,
  WorkOrderComments (NOT pending-site-review; no bot consumer). Grants the
  mgmt-resident seahaven-slack-bot roles read-only cross-account access
  post-move (bot-side identity grants land in the slack-bot repo).
- scripts/migrate_tables.py: dry-run-default backfill tool implementing
  the plan's per-table semantics (superset overwrite, ingested_at cutoff
  for WorkOrderComments, backup-gated truncate-and-load for the two
  site tables) plus a verify subcommand (count parity, spot checks,
  sticky-Cancelled drift check).
- tests/test_resource_policy_helper.py: statement-shape unit tests +
  static pins that exactly the four bot-read tables carry the policy.
- Account-literal fixes: account-agnostic fixture bucket in
  test_reprocess_contract; runbook/README/po-template-parser account
  references updated to prod with historical mgmt notes; README gains the
  account-prerequisites list (imported-by-name dependencies).

deploy.yaml is deliberately unchanged (push-to-main auto-deploy kept).
Merge is held until migration Phase 0 completes; flipping the
AWS_DEPLOY_ROLE_ARN repo secret and merging this PR IS the first prod
deploy.
2026-07-23 14:40:09 -04:00

146 lines
4.6 KiB
Python

"""Unit tests for common.make_slack_bot_read_policy (prod-migration PR).
Two layers, no full-app synth (PoIngestStack synth would trigger Docker
bundling in CI):
1. Synthesizes a minimal throwaway stack with one table using the helper and
asserts the exact ResourcePolicy statement shape (sid, read-only actions,
mgmt-account principal, PrincipalArn prefix condition, per-table ARN).
2. Static pins that the four bot-read tables in the real stack files pass
resource_policy= and pending-site-review does NOT (no bot consumer).
"""
import importlib.util
import re
import sys
from pathlib import Path
import aws_cdk as cdk
import pytest
from aws_cdk import assertions
from aws_cdk import aws_dynamodb as dynamodb
REPO_ROOT = Path(__file__).resolve().parents[1]
_spec = importlib.util.spec_from_file_location(
"cdk_common", REPO_ROOT / "cdk" / "common.py"
)
common = importlib.util.module_from_spec(_spec)
sys.modules["cdk_common"] = common
_spec.loader.exec_module(common)
MGMT_ACCOUNT = "328440206208"
PROD_ACCOUNT = "011934824531"
def _synth_policy(table_name):
app = cdk.App()
stack = cdk.Stack(
app,
"T",
env=cdk.Environment(account=PROD_ACCOUNT, region="us-east-1"),
)
dynamodb.Table(
stack,
"Tbl",
table_name=table_name,
partition_key=dynamodb.Attribute(name="pk", type=dynamodb.AttributeType.STRING),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
resource_policy=common.make_slack_bot_read_policy(stack, table_name),
)
template = assertions.Template.from_stack(stack)
tables = template.find_resources("AWS::DynamoDB::Table")
(props,) = [r["Properties"] for r in tables.values()]
statements = props["ResourcePolicy"]["PolicyDocument"]["Statement"]
assert len(statements) == 1
return statements[0]
def test_statement_shape_and_actions():
s = _synth_policy("purchase-orders")
assert s["Sid"] == "AllowMgmtSlackBotRead"
assert s["Effect"] == "Allow"
assert sorted(s["Action"]) == [
"dynamodb:BatchGetItem",
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:Query",
"dynamodb:Scan",
]
def test_no_write_actions():
s = _synth_policy("purchase-orders")
writes = {
a
for a in s["Action"]
if re.search(r"Put|Update|Delete|Write|Create|Restore|Export|Import", a)
}
assert not writes
def test_principal_is_mgmt_account_root():
s = _synth_policy("WorkOrders")
principal = s["Principal"]["AWS"]
# Token-joined root-principal ARN: {"Fn::Join": ["", ["arn:", {...}, ":iam::328440206208:root"]]}
assert MGMT_ACCOUNT in str(principal)
assert ":root" in str(principal)
def test_principal_arn_condition_pins_bot_stack_prefix():
s = _synth_policy("WorkOrders")
assert s["Condition"]["ArnLike"]["aws:PrincipalArn"] == (
f"arn:aws:iam::{MGMT_ACCOUNT}:role/seahaven-slack-bot-*"
)
@pytest.mark.parametrize("name", ["purchase-orders", "WorkOrderComments"])
def test_resource_is_exactly_the_named_table_arn(name):
import json
s = _synth_policy(name)
# format_arn leaves the partition as {"Ref": "AWS::Partition"}, so the
# resource is an Fn::Join; assert on the deterministic joined tail.
dumped = json.dumps(s["Resource"])
assert f"dynamodb:us-east-1:{PROD_ACCOUNT}:table/{name}" in dumped
# Exactly one resource entry (no index wildcard, no extra ARNs).
assert not isinstance(s["Resource"], list) or len(s["Resource"]) == 1
# --- static pins against the real stack files ---
_PO_STACK = (REPO_ROOT / "cdk" / "po_stack.py").read_text()
_WO_STACK = (REPO_ROOT / "cdk" / "wo_stack.py").read_text()
def _table_blocks(source):
"""Map table_name -> the dynamodb.Table(...) call block containing it."""
blocks = {}
for m in re.finditer(r"dynamodb\.Table\(", source):
depth, i = 0, m.end() - 1
while i < len(source):
depth += {"(": 1, ")": -1}.get(source[i], 0)
if depth == 0:
break
i += 1
block = source[m.start() : i + 1]
nm = re.search(r'table_name="([^"]+)"', block)
if nm:
blocks[nm.group(1)] = block
return blocks
def test_bot_read_tables_carry_resource_policy():
blocks = {**_table_blocks(_PO_STACK), **_table_blocks(_WO_STACK)}
for name in [
"purchase-orders",
"verified-sites",
"WorkOrders",
"WorkOrderComments",
]:
assert "make_slack_bot_read_policy" in blocks[name], name
def test_pending_site_review_has_no_resource_policy():
blocks = _table_blocks(_PO_STACK)
assert "resource_policy" not in blocks["pending-site-review"]