mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 08:23:14 +00:00
feat(api): procurement-api read stack + OpenAPI docs (SHOC reconciliation path) (#127)
Some checks are pending
Deploy / deploy (push) Waiting to run
Some checks are pending
Deploy / deploy (push) Waiting to run
* feat(api): add procurement-api stack - read API + OpenAPI docs page Third CDK stack: API Gateway REST API (IAM SigV4) over both pipelines' tables, replacing SHOC's retired SyncController cross-account DynamoDB scan as the reconciliation/backfill path. - lambdas/api/: handler (healthcheck + docs-token gate + router dispatch), router (single route table), pagination (opaque cursor, hostile -> 400), Decimal-safe serialization, wo_repo/po_repo reads. No VendorReplies. - OpenAPI 3.1 spec as source of truth incl. top-level webhooks section documenting the outbound SHOC feed; phase-2 write endpoints x-planned (router answers 501). Self-contained /docs page, no CDN. - Auth: AWS_IAM on data routes + resource policy scoped to exactly arn:aws:iam::396287094661:role/shoc-backend-dev on GET/*; /docs and /openapi.json carve-out is token-gated in the Lambda via shared web_ui_auth (fail-closed, INFRA-74 posture). - KMS: explicit Decrypt/DescribeKey on the DynamoDB CMK from SSM (name-imported table drops the key association - INFRA-104 class). - Alarms: errors/throttles/duration(p99>=22.5s) + gateway 5xx, ALARM-only to site-alerts. No access logging in v1 (docs ?token= shim stays out of logs); cloud_watch_role=False. - Tests: handler auth-seam + routing + Decimal round-trip; moto cursor pagination incl. hostile cursors; spec<->router drift gate; bundle AST pins for the api command; pytest.ini --cov + loader siblings. - Deploy role: third stack DescribeStacks ARN + procurement-api smoke invoke ARN (re-run create-deploy-role.sh before merge). * harden(api): apply sh-security-review findings to procurement-api Fan-out (6 detectors) + review findings resolved: Correctness / DoS: - pagination: require EXACT key-set match (was subset) so a partial/foreign composite cursor can't reach DynamoDB as an inconsistent ExclusiveStartKey -> ValidationException -> 500; comments Query now pins the cursor's work_order_id to the path entity. - handler: map botocore ValidationException to 400 (defense in depth) so a crafted cursor can't drive the zero-threshold 5xx alarm. - web_ui_auth: compare tokens as bytes; a non-ASCII presented token now fails closed (401) instead of crashing hmac.compare_digest into a 500. Resolves the pre-existing xfail(strict) follow-up test; hardens the web UIs too. Docs page: - typeStr() now escapes the one spec-derived string that reached innerHTML. - spec inlined into the docs <script> block escapes "<" -> < (</script> breakout guard); /openapi.json still served byte-faithful. - Cache-Control: no-store + Referrer-Policy: no-referrer on docs responses so the ?token= URL stays out of caches/Referer. - spec-drift test asserts the committed spec carries no "</" / "<!--". IAM / IaC: - resource policy enumerates the 7 data GET resources instead of GET/* so a future GET route can't silently inherit SHOC cross-account reach. - kms:Decrypt grant gains a kms:ViaService=dynamodb condition. - stage throttling (50 rps / 100 burst) bounds the unauthenticated /docs blast radius below the 10k account default. - corrected the PATCH/POST comment (same-account callers aren't blocked by the resource policy; 501 handler + absent write grant are the gate). - documented the RETAIN log-group first-deploy rollback trap and the resource-policy-needs-redeploy gotcha in-stack. Mandatory GPT-4.1 cross-family review of the full policy surface: no BLOCK/FIX. 675 tests pass, ruff clean, cdk synth green.
This commit is contained in:
parent
00d0d32337
commit
f67d8b9907
23 changed files with 2869 additions and 32 deletions
54
README.md
54
README.md
|
|
@ -86,9 +86,27 @@ Amazon APM work order emails (from Hexagon EAM / HxGN SmartCloud) are received a
|
||||||
- `WorkOrders` (PK: `work_order_id`) — `site-code-index` and `status-index` GSIs removed 2026-06-03 (audit M-20)
|
- `WorkOrders` (PK: `work_order_id`) — `site-code-index` and `status-index` GSIs removed 2026-06-03 (audit M-20)
|
||||||
- `WorkOrderComments` (PK: `work_order_id`, SK: `comment_id`) — see the `comment_id` format note below
|
- `WorkOrderComments` (PK: `work_order_id`, SK: `comment_id`) — see the `comment_id` format note below
|
||||||
|
|
||||||
|
### Procurement API (`procurement-api` stack)
|
||||||
|
|
||||||
|
A read-only REST API (API Gateway + the `procurement-api` Lambda, `lambdas/api/`) over both pipelines' tables, plus a token-gated OpenAPI docs page. Primary consumer: the SHOC backend, for which this replaces the retired SyncController cross-account DynamoDB scan as the reconciliation/backfill path (and the initial-history load for the outbound work-order webhook, which starts at LATEST).
|
||||||
|
|
||||||
|
| Method + path | Auth | Backing read |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /work-orders` · `/purchase-orders` · `/verified-sites` | IAM SigV4 | unordered paginated Scan (`limit` 1–500, opaque `cursor` = base64 `LastEvaluatedKey`; malformed cursor → 400) |
|
||||||
|
| `GET /work-orders/{id}` · `/purchase-orders/{id}` · `/verified-sites/{siteCode}` | IAM SigV4 | GetItem (404 on miss) |
|
||||||
|
| `GET /work-orders/{id}/comments` | IAM SigV4 | Query on the partition key, paginated |
|
||||||
|
| `POST /work-orders/{id}/comments`, `PATCH /work-orders/{id}` | — | **phase-2 planned** (`x-planned` in the spec); handler answers 501 |
|
||||||
|
| `GET /docs`, `GET /openapi.json` | shared docs token (`X-Auth-Token` header or `?token=` in a browser) | self-contained HTML reference page / the committed spec |
|
||||||
|
|
||||||
|
- **Spec is source of truth:** `lambdas/api/openapi.json` (OpenAPI 3.1). Its top-level `webhooks` section documents the outbound SHOC work-order feed, so one page describes both directions (call + be-called). `tests/test_api_spec_drift.py` pins the spec's paths to the router table, so spec and implementation cannot drift.
|
||||||
|
- **Auth:** data routes use API Gateway `AWS_IAM` (SigV4) plus a resource policy allowing exactly `arn:aws:iam::396287094661:role/shoc-backend-dev` on `GET/*`; same-account admin callers authorize via identity policy (Postman signs SigV4 natively). Docs routes are auth `NONE` at the gateway (resource-policy carve-out for exactly those two GETs) but the handler fails closed on the shared token (`lambdas/shared/web_ui_auth.py`, secret `procurement-ingest/web-ui-auth-token`) — not an unauthenticated data path (INFRA-74 posture).
|
||||||
|
- **KMS:** `purchase-orders` is CMK-encrypted; the imported-by-name table doesn't carry the key association, so the stack grants `kms:Decrypt`/`DescribeKey` on the CMK from SSM `/seahaven/dynamodb/cmk-arn` explicitly (the INFRA-104 failure class).
|
||||||
|
- **No access logging in v1** (keeps the `?token=` shim out of any log and avoids the account-level API Gateway CloudWatch role); rotate the docs token before ever enabling it. No CORS (server-to-server + Postman callers).
|
||||||
|
- **Alarms:** `procurement-api-errors`/`-throttles`/`-duration` (p99 ≥ 22.5 s) + gateway `procurement-api-5xx`, all ALARM-only → `site-alerts`. No 4XX alarm (401/403/404 are expected traffic).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**IaC:** AWS CDK (Python), two stacks in one app, region `us-east-1`. The `cdk.Environment` is deliberately **account-agnostic** (region-only, no `account=`): the stacks deploy to whichever account the deploy credentials target (`011934824531` seahaven-prod since the 2026-07 account migration; formerly mgmt `328440206208`), and every account-derived template value — bucket names, `Lambda::Permission` source account, the site-alerts SNS action ARN, the Bedrock ARN below — renders as the CloudFormation `AWS::AccountId` pseudo-parameter rather than a literal. Pinning `account=` was evaluated in Phase 4 and rejected: it would resolve those tokens to literals, and against the deployed (account-agnostic) templates CloudFormation flags the `RemovalPolicy.RETAIN` email buckets as requiring replacement — a data-loss risk — for no functional gain.
|
**IaC:** AWS CDK (Python), three stacks in one app, region `us-east-1`. The `cdk.Environment` is deliberately **account-agnostic** (region-only, no `account=`): the stacks deploy to whichever account the deploy credentials target (`011934824531` seahaven-prod since the 2026-07 account migration; formerly mgmt `328440206208`), and every account-derived template value — bucket names, `Lambda::Permission` source account, the site-alerts SNS action ARN, the Bedrock ARN below — renders as the CloudFormation `AWS::AccountId` pseudo-parameter rather than a literal. Pinning `account=` was evaluated in Phase 4 and rejected: it would resolve those tokens to literals, and against the deployed (account-agnostic) templates CloudFormation flags the `RemovalPolicy.RETAIN` email buckets as requiring replacement — a data-loss risk — for no functional gain.
|
||||||
|
|
||||||
All Lambdas: Python 3.12, ARM64, 60-day log retention.
|
All Lambdas: Python 3.12, ARM64, 60-day log retention.
|
||||||
|
|
||||||
|
|
@ -179,7 +197,7 @@ if isinstance(event, dict) and event.get("healthcheck") is True:
|
||||||
|
|
||||||
This placement is deliberate, not incidental: real mail always arrives as an S3 `ObjectCreated` event whose top-level keys (`Records`) AWS controls, so email content can never set a top-level `healthcheck` key — the branch creates no accept path for forged mail. It also emits **no EMF metric and no log line**, so it can never match the `sender_auth_rejected` log-metric-filter pattern that feeds the `<fn>-sender-auth-rejected` alarm (see [CloudWatch alarms](#cloudwatch-alarms)) — that alarm pages at ≥1 match in its window, so repeated healthcheck invokes across deploys (two deploys in ~30 min is routine) must never contribute to it. Unit coverage: `lambdas/po/email_processor/tests/test_po_healthcheck.py` and `lambdas/wo/email_processor/tests/test_healthcheck.py`.
|
This placement is deliberate, not incidental: real mail always arrives as an S3 `ObjectCreated` event whose top-level keys (`Records`) AWS controls, so email content can never set a top-level `healthcheck` key — the branch creates no accept path for forged mail. It also emits **no EMF metric and no log line**, so it can never match the `sender_auth_rejected` log-metric-filter pattern that feeds the `<fn>-sender-auth-rejected` alarm (see [CloudWatch alarms](#cloudwatch-alarms)) — that alarm pages at ≥1 match in its window, so repeated healthcheck invokes across deploys (two deploys in ~30 min is routine) must never contribute to it. Unit coverage: `lambdas/po/email_processor/tests/test_po_healthcheck.py` and `lambdas/wo/email_processor/tests/test_healthcheck.py`.
|
||||||
|
|
||||||
**Post-deploy smoke gate.** `scripts/post-deploy-smoke.sh` is wired into the CD workflow as `cd-cdk.yaml`'s `post-deploy-script` input (see [CI/CD](#cicd)) and runs synchronously after every deploy, before the workflow is considered green. It invokes both `po-email-processor` and `workorder-email-processor` with `aws lambda invoke --invocation-type RequestResponse --payload '{"healthcheck": true}'` (region `us-east-1`) and asserts, per function:
|
**Post-deploy smoke gate.** `scripts/post-deploy-smoke.sh` is wired into the CD workflow as `cd-cdk.yaml`'s `post-deploy-script` input (see [CI/CD](#cicd)) and runs synchronously after every deploy, before the workflow is considered green. It invokes `po-email-processor`, `workorder-email-processor`, and `procurement-api` with `aws lambda invoke --invocation-type RequestResponse --payload '{"healthcheck": true}'` (region `us-east-1`) and asserts, per function:
|
||||||
|
|
||||||
1. The invoke response's **`FunctionError` field is absent** — this is the load-bearing check. A broken bundle (e.g. an `ImportError` at module init from a missing sibling module) still returns HTTP 200 from the Lambda Invoke API with `FunctionError=Unhandled`; a bare exit-code check on `aws lambda invoke` would false-pass on exactly the failure this gate exists to catch.
|
1. The invoke response's **`FunctionError` field is absent** — this is the load-bearing check. A broken bundle (e.g. an `ImportError` at module init from a missing sibling module) still returns HTTP 200 from the Lambda Invoke API with `FunctionError=Unhandled`; a bare exit-code check on `aws lambda invoke` would false-pass on exactly the failure this gate exists to catch.
|
||||||
2. The returned payload is **exactly** `{"healthcheck": "ok"}`.
|
2. The returned payload is **exactly** `{"healthcheck": "ok"}`.
|
||||||
|
|
@ -248,13 +266,13 @@ The `purchase-orders` DynamoDB table is **owned by this repo's `po-ingest` stack
|
||||||
|
|
||||||
**Consumers (read-only):**
|
**Consumers (read-only):**
|
||||||
|
|
||||||
| Repo | How it reads | Purpose |
|
| Consumer | How it reads | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `seahaven-slack-bot` | `po-sync` (DynamoDB Streams + daily scan) and `wo-po-lookup` | Daily KB sync + Bedrock agent PO lookups |
|
| `procurement-api` (this repo) | `Table.from_table_name` + `grant_read_data` | REST reads (`GET /purchase-orders*`) |
|
||||||
|
|
||||||
The consumer imports the table via `Table.fromTableName(...)` and is granted read-only access (`grantReadData`); it does not own or define it.
|
`seahaven-slack-bot`, the former external consumer, was decommissioned 2026-07-23 and its cross-account grants removed. External consumers now read through the `procurement-api` REST contract (`lambdas/api/openapi.json`), never by importing the table directly.
|
||||||
|
|
||||||
**Schema-coordination rule:** Any change to the `purchase-orders` schema (partition key, item shape, attribute names, streams view type) must be coordinated with `seahaven-slack-bot`. The owner here ships the change; the consumer must be updated in lockstep so its readers do not break. Treat schema changes as a cross-repo migration, not a local edit.
|
**Schema-coordination rule:** Any change to the `purchase-orders` schema (partition key, item shape, attribute names, streams view type) must be reflected in the API's OpenAPI schemas in the same PR (the spec-drift test pins paths, not item shapes — the schema fields are maintained by hand). Treat schema changes as a contract migration, not a local edit.
|
||||||
|
|
||||||
**Known exception (INFRA-51):** `amazon-po-parser` currently writes directly to `purchase-orders` outside this stack (backfill/enrichment scripts). This second writer is being folded into the `po-ingest` pipeline so this stack is the sole writer; until INFRA-51 closes, coordinate any schema change with `amazon-po-parser` as well.
|
**Known exception (INFRA-51):** `amazon-po-parser` currently writes directly to `purchase-orders` outside this stack (backfill/enrichment scripts). This second writer is being folded into the `po-ingest` pipeline so this stack is the sole writer; until INFRA-51 closes, coordinate any schema change with `amazon-po-parser` as well.
|
||||||
|
|
||||||
|
|
@ -284,7 +302,7 @@ Both tables are **owned by this repo's `WorkorderIngestStack`** (`cdk/wo_stack.p
|
||||||
|
|
||||||
Both currently use default DynamoDB encryption — they are **not** yet on the shared customer-managed CMK (`alias/seahaven-dynamodb`, INFRA-95 / M-3); that migration is tracked in INFRA-6. The `workorder-email-processor` role no longer holds a pre-emptive encrypt/decrypt grant on that CMK (removed in the 2026-06-17 security sweep — it was unused while the tables are unencrypted and extended the role's decrypt reach to the CMK protecting `purchase-orders`). Re-add the grant as part of the INFRA-6 migration, at which point `grant_read_write_data` on the then-encrypted tables propagates the needed key permissions automatically.
|
Both currently use default DynamoDB encryption — they are **not** yet on the shared customer-managed CMK (`alias/seahaven-dynamodb`, INFRA-95 / M-3); that migration is tracked in INFRA-6. The `workorder-email-processor` role no longer holds a pre-emptive encrypt/decrypt grant on that CMK (removed in the 2026-06-17 security sweep — it was unused while the tables are unencrypted and extended the role's decrypt reach to the CMK protecting `purchase-orders`). Re-add the grant as part of the INFRA-6 migration, at which point `grant_read_write_data` on the then-encrypted tables propagates the needed key permissions automatically.
|
||||||
|
|
||||||
**Consumer (read-only) — data contract:** `seahaven-slack-bot` imports both tables via `Table.fromTableName(...)` (`grantReadData`) and reads them from two Lambdas: `workorder-sync` (daily full-table scan into the Bedrock knowledge base) and `wo-po-lookup` (the Bedrock agent's direct WO lookup action group). The bot depends on the PK/SK schema above and these attributes: on `WorkOrders` — `description`, `wo_status`, `customer`, `site_code`, `building`, `address`, `severity`, `priority`, `assigned_to`, `date_reported`, `scheduled_start`, `due_date`, `updated_at`; on `WorkOrderComments` — `created_at` (used to sort comments), `commenter`, `text`. Any change to table name, key schema, these attribute names, or the encryption configuration (e.g. the INFRA-6 CMK migration) must be coordinated with `seahaven-slack-bot` before it ships, or the Bedrock agent breaks at runtime (not at deploy — the tables are imported by name, so there is no compile-time link).
|
**Consumers (read-only) — data contract:** `seahaven-slack-bot` (the former external reader) was decommissioned 2026-07-23; its grants are gone. Current consumers: the `procurement-api` Lambda (this repo, `Table.from_table_name` + `grant_read_data`, serving `GET /work-orders*`), and — once the SHOC webhook ships — the `workorder-shoc-emitter` stream consumer. External readers (SHOC) consume through the REST contract (`lambdas/api/openapi.json`) and the webhook contract (`docs/shoc-webhook-contract.md`); both documents' field lists mirror `lambdas/wo/email_processor/persistence.py`. Any change to table name, key schema, attribute names, or encryption configuration (e.g. the INFRA-6 CMK migration) must update those two contracts in the same PR — the tables are imported by name, so there is no compile-time link and breakage surfaces at runtime.
|
||||||
|
|
||||||
**Stream field contract (`site_code`).** Three definitions of "is this a valid site code" have existed in this repo at once. The canonical shape is `derived_fields._STRICT_CODE_RE` (`[A-Z][A-Z0-9]{2,4}`, `fullmatch`) plus its skip-list semantics (a code-shaped token is only a real site code if it is *not* skip-listed, e.g. `LLC`/`INC`/`CORP`/`LTD`/`ATTN`), used by `derive_site_code()` (the `enrich_parsed()` classifier, see the **Derived fields** note in the Purchase Orders flow above). Honestly noted, not papered over: `lambdas/po/site_extractor/handler.py`'s own `SITE_CODE_PATTERN` (`[A-Z]{2,4}\d{1,2}`, prefix-anchored `.match`, digit-requiring) still diverges from the canonical shape as of this phase — it rejects valid all-letter codes like `KLAL` (which surface as permanent `pending-site-review` rows) and accepts overlong junk like `DLI6X`/`SNY55` that the canonical `fullmatch` would not. Reconciling `po-ingest-site-extractor`'s direct-field validation onto the canonical `derived_fields` shape is Phase 6 scope, tracked separately — this paragraph will be updated when it lands.
|
**Stream field contract (`site_code`).** Three definitions of "is this a valid site code" have existed in this repo at once. The canonical shape is `derived_fields._STRICT_CODE_RE` (`[A-Z][A-Z0-9]{2,4}`, `fullmatch`) plus its skip-list semantics (a code-shaped token is only a real site code if it is *not* skip-listed, e.g. `LLC`/`INC`/`CORP`/`LTD`/`ATTN`), used by `derive_site_code()` (the `enrich_parsed()` classifier, see the **Derived fields** note in the Purchase Orders flow above). Honestly noted, not papered over: `lambdas/po/site_extractor/handler.py`'s own `SITE_CODE_PATTERN` (`[A-Z]{2,4}\d{1,2}`, prefix-anchored `.match`, digit-requiring) still diverges from the canonical shape as of this phase — it rejects valid all-letter codes like `KLAL` (which surface as permanent `pending-site-review` rows) and accepts overlong junk like `DLI6X`/`SNY55` that the canonical `fullmatch` would not. Reconciling `po-ingest-site-extractor`'s direct-field validation onto the canonical `derived_fields` shape is Phase 6 scope, tracked separately — this paragraph will be updated when it lands.
|
||||||
|
|
||||||
|
|
@ -292,9 +310,7 @@ Both currently use default DynamoDB encryption — they are **not** yet on the s
|
||||||
|
|
||||||
Owned by this repo's `po-ingest` stack (`cdk/po_stack.py`). PK `siteCode` (S); default DynamoDB encryption (NOT the shared CMK).
|
Owned by this repo's `po-ingest` stack (`cdk/po_stack.py`). PK `siteCode` (S); default DynamoDB encryption (NOT the shared CMK).
|
||||||
|
|
||||||
**Consumer (read-only) — data contract:** `seahaven-slack-bot`'s `wo-po-lookup` Lambda imports this table via `Table.fromTableName(...)` for the Bedrock agent's `lookup_site` action. It does point lookups by `siteCode` and reads `address`, `fullAddress`, `city`, `state`, `zip`, `latitude`, `longitude`, `notes`. Coordinate any change to the table name, key schema, or these attribute names with `seahaven-slack-bot`.
|
**Consumer (read-only) — data contract:** `seahaven-slack-bot` (former reader, incl. the INFRA-138/INFRA-180 `by-state` GSI drift saga) was decommissioned 2026-07-23 — the GSI-drift issue died with it. Current consumer: the `procurement-api` Lambda (`GET /verified-sites*`). The API's `VerifiedSite` schema deliberately promises only pipeline-written fields (`siteCode`, `address`, `city`, `state`, `zip`, `fullAddress`, `locationCode`, `poCount`, `sourcePOs`) — `latitude`/`longitude`/`notes` were manually curated legacy attributes the extractor never writes, and are not part of the contract.
|
||||||
|
|
||||||
> **GSI drift (INFRA-138):** the `by-state` GSI was removed here on 2026-06-03 (audit M-20, "0 reads in 30d"), but `seahaven-slack-bot` still queries `IndexName: 'by-state'` for its state-listing path, so that path fails at runtime today. Restoring the GSI or removing the consumer's state path needs to be reconciled cross-repo. This is the kind of silent owner-side lifecycle change this data-contract note exists to prevent.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
@ -306,7 +322,7 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr
|
||||||
|
|
||||||
GitHub Actions with reusable workflows from `Sea-Haven-Industries/.github` (all pinned to a commit SHA of `main`):
|
GitHub Actions with reusable workflows from `Sea-Haven-Industries/.github` (all pinned to a commit SHA of `main`):
|
||||||
- **CI** (`ci.yaml`, PR to `main`): linting + `cdk synth` via `ci-python-sam.yaml`. `cdk synth`'s Docker-bundled asset build for `po-email-processor` and `workorder-email-processor` mounts the widened `../lambdas` asset root (Phase 2, see [Deploy-Pipeline Guards](#deploy-pipeline-guards-phase-0)) as build context, not just each function's own subdirectory — the `exclude` list on both `from_asset` calls strips local-only `__pycache__`/`package/` (and `tests/`) cruft from that wider mount's source fingerprint, so CI's asset hash matches a clean local checkout, and each function's scoped `cp` glob copies only its own pipeline's `*.py` into the zip. (The exclude does not, and under `SOURCE` hashing cannot, keep the *other* pipeline's tracked source out of the fingerprint (see the PO bundling note above on `SOURCE` hashing) — but that source is identical in CI and local, so it does not cause hash divergence.)
|
- **CI** (`ci.yaml`, PR to `main`): linting + `cdk synth` via `ci-python-sam.yaml`. `cdk synth`'s Docker-bundled asset build for `po-email-processor` and `workorder-email-processor` mounts the widened `../lambdas` asset root (Phase 2, see [Deploy-Pipeline Guards](#deploy-pipeline-guards-phase-0)) as build context, not just each function's own subdirectory — the `exclude` list on both `from_asset` calls strips local-only `__pycache__`/`package/` (and `tests/`) cruft from that wider mount's source fingerprint, so CI's asset hash matches a clean local checkout, and each function's scoped `cp` glob copies only its own pipeline's `*.py` into the zip. (The exclude does not, and under `SOURCE` hashing cannot, keep the *other* pipeline's tracked source out of the fingerprint (see the PO bundling note above on `SOURCE` hashing) — but that source is identical in CI and local, so it does not cause hash divergence.)
|
||||||
- **CD** (`deploy.yaml`, push to `main`): CDK deploy via `cd-cdk.yaml` (OIDC auth), followed by the synchronous `post-deploy-script: scripts/post-deploy-smoke.sh` healthcheck gate (see [Deploy-Pipeline Guards](#deploy-pipeline-guards-phase-0)) — `cd-cdk.yaml`'s `stack-name` input only accepts one stack, so the smoke script itself enumerates both `po-email-processor` and `workorder-email-processor`
|
- **CD** (`deploy.yaml`, push to `main`): CDK deploy via `cd-cdk.yaml` (OIDC auth), followed by the synchronous `post-deploy-script: scripts/post-deploy-smoke.sh` healthcheck gate (see [Deploy-Pipeline Guards](#deploy-pipeline-guards-phase-0)) — `cd-cdk.yaml`'s `stack-name` input only accepts one stack, so the smoke script itself enumerates `po-email-processor`, `workorder-email-processor`, and `procurement-api`
|
||||||
- Plus dependency review and PR labeler workflows on every PR
|
- Plus dependency review and PR labeler workflows on every PR
|
||||||
|
|
||||||
Branch protection on `main` — all changes through PR.
|
Branch protection on `main` — all changes through PR.
|
||||||
|
|
@ -392,12 +408,13 @@ python scripts/backfill_sites.py
|
||||||
|
|
||||||
```
|
```
|
||||||
cdk/
|
cdk/
|
||||||
app.py # Two stacks: po-ingest + WorkorderIngestStack (region-only env)
|
app.py # Three stacks: po-ingest + WorkorderIngestStack + procurement-api (region-only env)
|
||||||
common.py # Phase 4: shared plain-function CDK helpers (alarms, Bedrock grant,
|
common.py # Phase 4: shared plain-function CDK helpers (alarms, Bedrock grant,
|
||||||
# email bucket, processor DLQ, fallback-rate alarm) -- called with
|
# email bucket, processor DLQ, fallback-rate alarm) -- called with
|
||||||
# each stack's own scope + literal construct ids, logical-ID-safe
|
# each stack's own scope + literal construct ids, logical-ID-safe
|
||||||
po_stack.py # Purchase order pipeline resources
|
po_stack.py # Purchase order pipeline resources
|
||||||
wo_stack.py # Work order pipeline resources
|
wo_stack.py # Work order pipeline resources
|
||||||
|
procurement_api_stack.py # REST API (IAM SigV4 + resource policy) over both pipelines' tables + token-gated /docs
|
||||||
lambdas/ # Phase 2: shared Code.from_asset("../lambdas") bundling root for
|
lambdas/ # Phase 2: shared Code.from_asset("../lambdas") bundling root for
|
||||||
# BOTH po-email-processor and workorder-email-processor (and, since
|
# BOTH po-email-processor and workorder-email-processor (and, since
|
||||||
# Phase 3, both web_ui functions) -- each email-processor command
|
# Phase 3, both web_ui functions) -- each email-processor command
|
||||||
|
|
@ -408,9 +425,18 @@ lambdas/ # Phase 2: shared Code.from_asset("../lambdas") bundling
|
||||||
shared/ # Phase 3: single-sourced first-party modules, landed FLAT (no
|
shared/ # Phase 3: single-sourced first-party modules, landed FLAT (no
|
||||||
# __init__.py -- bare-name imports) into each bundle by the cp above
|
# __init__.py -- bare-name imports) into each bundle by the cp above
|
||||||
ses_auth.py # fail-closed SES sender-auth (INFRA-107) -- one copy, one fix
|
ses_auth.py # fail-closed SES sender-auth (INFRA-107) -- one copy, one fix
|
||||||
web_ui_auth.py # fail-closed X-Auth-Token gate + token cache (INFRA-74)
|
web_ui_auth.py # fail-closed X-Auth-Token gate + token cache (INFRA-74; also gates the API /docs routes)
|
||||||
email_parsing.py # parse_raw_email (WO superset; returns cc unconditionally)
|
email_parsing.py # parse_raw_email (WO superset; returns cc unconditionally)
|
||||||
emf.py # generic CloudWatch EMF emitter (dimension-sets pinned once)
|
emf.py # generic CloudWatch EMF emitter (dimension-sets pinned once)
|
||||||
|
api/ # procurement-api Lambda (REST reads over both pipelines' tables)
|
||||||
|
handler.py # healthcheck early-return + docs-token gate + route dispatch + error mapping
|
||||||
|
router.py # single route table (spec-drift test pins it to openapi.json)
|
||||||
|
pagination.py # opaque base64(LastEvaluatedKey) cursor encode/validate (hostile cursor -> 400)
|
||||||
|
serialization.py # Decimal-safe JSON responses
|
||||||
|
wo_repo.py # WorkOrders/WorkOrderComments reads (paginated Scan / PK Query)
|
||||||
|
po_repo.py # purchase-orders/verified-sites reads (no VendorReplies -- dead table)
|
||||||
|
openapi.json # OpenAPI 3.1 source of truth (paths + outbound `webhooks` section)
|
||||||
|
docs.html # self-contained reference page (spec inlined at request time; no CDN)
|
||||||
po/ # PO pipeline Lambdas
|
po/ # PO pipeline Lambdas
|
||||||
email_processor/ # Phase 5: God-handler decomposed into flat siblings (bare-name
|
email_processor/ # Phase 5: God-handler decomposed into flat siblings (bare-name
|
||||||
# imports; the Phase 0/2/3 `cp <pipeline>/email_processor/*.py`
|
# imports; the Phase 0/2/3 `cp <pipeline>/email_processor/*.py`
|
||||||
|
|
@ -460,7 +486,7 @@ lambdas/ # Phase 2: shared Code.from_asset("../lambdas") bundling
|
||||||
scripts/
|
scripts/
|
||||||
reprocess.py
|
reprocess.py
|
||||||
backfill_sites.py
|
backfill_sites.py
|
||||||
post-deploy-smoke.sh # CD gate: synchronous healthcheck invoke of both processors, checks FunctionError
|
post-deploy-smoke.sh # CD gate: synchronous healthcheck invoke of the three smoke-gated functions, checks FunctionError
|
||||||
conftest.py # Phase 8: THE repo-root session-invariant conftest. rootdir is pinned by
|
conftest.py # Phase 8: THE repo-root session-invariant conftest. rootdir is pinned by
|
||||||
# pytest.ini at repo root, so this loads for every pytest invocation shape --
|
# pytest.ini at repo root, so this loads for every pytest invocation shape --
|
||||||
# including a standalone `pytest lambdas/po/email_processor/tests` -- before
|
# including a standalone `pytest lambdas/po/email_processor/tests` -- before
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import aws_cdk as cdk
|
import aws_cdk as cdk
|
||||||
from po_stack import PoIngestStack
|
from po_stack import PoIngestStack
|
||||||
|
from procurement_api_stack import ProcurementApiStack
|
||||||
from wo_stack import WorkorderIngestStack
|
from wo_stack import WorkorderIngestStack
|
||||||
|
|
||||||
app = cdk.App()
|
app = cdk.App()
|
||||||
|
|
@ -19,4 +20,11 @@ WorkorderIngestStack(
|
||||||
env=cdk.Environment(region="us-east-1"),
|
env=cdk.Environment(region="us-east-1"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ProcurementApiStack(
|
||||||
|
app,
|
||||||
|
"procurement-api",
|
||||||
|
stack_name="procurement-api",
|
||||||
|
env=cdk.Environment(region="us-east-1"),
|
||||||
|
)
|
||||||
|
|
||||||
app.synth()
|
app.synth()
|
||||||
|
|
|
||||||
361
cdk/procurement_api_stack.py
Normal file
361
cdk/procurement_api_stack.py
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
"""procurement-api stack: read-only REST API over both pipelines' tables.
|
||||||
|
|
||||||
|
Third stack in the app. Serves work orders + comments (WO stack tables) and
|
||||||
|
purchase orders + verified sites (PO stack tables) to SigV4 callers -- the
|
||||||
|
primary consumer is the SHOC backend, for which this API replaces the retired
|
||||||
|
SyncController cross-account DynamoDB scan as the reconciliation/backfill
|
||||||
|
path. Also hosts the token-gated OpenAPI docs page (/docs, /openapi.json).
|
||||||
|
|
||||||
|
Tables are imported by fixed physical name (Table.from_table_name), NOT
|
||||||
|
passed as cross-stack objects: object passing would synthesize CFN Exports
|
||||||
|
from the owning stacks and lock them against future changes to the tables.
|
||||||
|
The one thing name-import does NOT carry is the purchase-orders CMK
|
||||||
|
association -- see the explicit KMS grant below.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import aws_cdk as cdk
|
||||||
|
from aws_cdk import (
|
||||||
|
Duration,
|
||||||
|
Stack,
|
||||||
|
aws_apigateway as apigateway,
|
||||||
|
aws_cloudwatch as cloudwatch,
|
||||||
|
aws_cloudwatch_actions as cw_actions,
|
||||||
|
aws_dynamodb as dynamodb,
|
||||||
|
aws_iam as iam,
|
||||||
|
aws_kms as kms,
|
||||||
|
aws_lambda as lambda_,
|
||||||
|
aws_secretsmanager as secretsmanager,
|
||||||
|
aws_sns as sns,
|
||||||
|
aws_ssm as ssm,
|
||||||
|
)
|
||||||
|
from constructs import Construct
|
||||||
|
|
||||||
|
import common
|
||||||
|
|
||||||
|
# The only cross-account caller. Grants are to this exact role ARN -- future
|
||||||
|
# shoc-backend-staging/-prod roles are each a deliberate, individually
|
||||||
|
# cross-reviewed addition (no wildcard/prefix trust).
|
||||||
|
SHOC_BACKEND_DEV_ROLE_ARN = "arn:aws:iam::396287094661:role/shoc-backend-dev"
|
||||||
|
|
||||||
|
STAGE_NAME = "prod"
|
||||||
|
|
||||||
|
|
||||||
|
class ProcurementApiStack(Stack):
|
||||||
|
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
|
||||||
|
super().__init__(scope, construct_id, **kwargs)
|
||||||
|
|
||||||
|
alarm_topic = sns.Topic.from_topic_arn(
|
||||||
|
self,
|
||||||
|
"SiteAlertsTopic",
|
||||||
|
f"arn:aws:sns:{self.region}:{self.account}:site-alerts",
|
||||||
|
)
|
||||||
|
|
||||||
|
work_orders_table = dynamodb.Table.from_table_name(
|
||||||
|
self, "WorkOrdersTable", "WorkOrders"
|
||||||
|
)
|
||||||
|
comments_table = dynamodb.Table.from_table_name(
|
||||||
|
self, "CommentsTable", "WorkOrderComments"
|
||||||
|
)
|
||||||
|
po_table = dynamodb.Table.from_table_name(
|
||||||
|
self, "PurchaseOrdersTable", "purchase-orders"
|
||||||
|
)
|
||||||
|
verified_sites_table = dynamodb.Table.from_table_name(
|
||||||
|
self, "VerifiedSitesTable", "verified-sites"
|
||||||
|
)
|
||||||
|
|
||||||
|
web_ui_auth_secret = secretsmanager.Secret.from_secret_name_v2(
|
||||||
|
self,
|
||||||
|
"WebUiAuthToken",
|
||||||
|
"procurement-ingest/web-ui-auth-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
# purchase-orders is SSE-KMS encrypted with the org DynamoDB CMK. A
|
||||||
|
# name-imported Table has no encryption-key association, so
|
||||||
|
# grant_read_data alone leaves the reader without kms:Decrypt and every
|
||||||
|
# purchase-orders read AccessDenies at runtime (the INFRA-104 failure
|
||||||
|
# class). Import the key from the same SSM parameter po_stack uses and
|
||||||
|
# grant it explicitly.
|
||||||
|
dynamodb_cmk = kms.Key.from_key_arn(
|
||||||
|
self,
|
||||||
|
"DynamoDbCmk",
|
||||||
|
ssm.StringParameter.value_for_string_parameter(
|
||||||
|
self, "/seahaven/dynamodb/cmk-arn"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# RETAIN log group (INFRA-114). This is the only stateful resource in
|
||||||
|
# this otherwise-stateless stack, so it carries the fresh-deploy
|
||||||
|
# rollback trap: if the FIRST create fails after the group exists,
|
||||||
|
# rollback deletes everything else but retains the group, and the retry
|
||||||
|
# CREATE then collides on "/aws/lambda/procurement-api already exists".
|
||||||
|
# Recovery: delete that log group before re-running a failed first
|
||||||
|
# deploy (same class as the RETAIN-orphan recovery in the deploy-role
|
||||||
|
# runbook / reference_cfn_deploy_role_gotchas).
|
||||||
|
api_log_group = common.make_function_log_group(
|
||||||
|
self, "ProcurementApi", "procurement-api"
|
||||||
|
)
|
||||||
|
api_fn = lambda_.Function(
|
||||||
|
self,
|
||||||
|
"ProcurementApi",
|
||||||
|
function_name="procurement-api",
|
||||||
|
runtime=lambda_.Runtime.PYTHON_3_12,
|
||||||
|
architecture=lambda_.Architecture.ARM_64,
|
||||||
|
handler="handler.handler",
|
||||||
|
code=lambda_.Code.from_asset(
|
||||||
|
"../lambdas",
|
||||||
|
exclude=["**/__pycache__/**"],
|
||||||
|
bundling=cdk.BundlingOptions(
|
||||||
|
image=lambda_.Runtime.PYTHON_3_12.bundling_image,
|
||||||
|
command=[
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
# Non-recursive glob ships every api/ sibling (the
|
||||||
|
# allowlist-omission trap from PR #105/PR #2); the spec
|
||||||
|
# and docs page ride along because the handler serves
|
||||||
|
# them from its own package dir. web_ui_auth.py must
|
||||||
|
# land FLAT beside handler.py for the bare import.
|
||||||
|
"cp api/*.py /asset-output/ && "
|
||||||
|
"cp api/openapi.json /asset-output/ && "
|
||||||
|
"cp api/docs.html /asset-output/ && "
|
||||||
|
"cp shared/web_ui_auth.py /asset-output/ && "
|
||||||
|
"rm -rf /asset-output/__pycache__",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
timeout=Duration.seconds(30),
|
||||||
|
memory_size=256,
|
||||||
|
log_group=api_log_group,
|
||||||
|
environment={
|
||||||
|
"WORK_ORDERS_TABLE": work_orders_table.table_name,
|
||||||
|
"COMMENTS_TABLE": comments_table.table_name,
|
||||||
|
"PO_TABLE": po_table.table_name,
|
||||||
|
"VERIFIED_SITES_TABLE": verified_sites_table.table_name,
|
||||||
|
"WEB_UI_AUTH_TOKEN_SECRET_ARN": web_ui_auth_secret.secret_arn,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
work_orders_table.grant_read_data(api_fn)
|
||||||
|
comments_table.grant_read_data(api_fn)
|
||||||
|
po_table.grant_read_data(api_fn)
|
||||||
|
verified_sites_table.grant_read_data(api_fn)
|
||||||
|
web_ui_auth_secret.grant_read(api_fn)
|
||||||
|
api_fn.add_to_role_policy(
|
||||||
|
iam.PolicyStatement(
|
||||||
|
actions=["kms:Decrypt", "kms:DescribeKey"],
|
||||||
|
resources=[dynamodb_cmk.key_arn],
|
||||||
|
# Scope the grant to the DynamoDB data path only: the role can
|
||||||
|
# decrypt purchase-orders items via DynamoDB, never call
|
||||||
|
# kms:Decrypt directly on arbitrary ciphertext under the shared
|
||||||
|
# org CMK.
|
||||||
|
conditions={
|
||||||
|
"StringEquals": {
|
||||||
|
"kms:ViaService": f"dynamodb.{self.region}.amazonaws.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resource policy: once a REST API has one, anything not explicitly
|
||||||
|
# allowed is denied -- so the docs routes need their own Allow or the
|
||||||
|
# NONE-auth methods would be black-holed. The docs routes are still
|
||||||
|
# token-gated inside the Lambda (fail-closed), so this is not an
|
||||||
|
# unauthenticated data path (INFRA-74 posture).
|
||||||
|
#
|
||||||
|
# The SHOC data grant enumerates the exact GET resources rather than
|
||||||
|
# GET/*: adding a RESOURCE must be as reviewable as adding a PRINCIPAL,
|
||||||
|
# so a future GET route can't silently inherit cross-account reach
|
||||||
|
# without a policy diff. (Note: this resource policy binds CROSS-account
|
||||||
|
# callers only; a same-account principal holding execute-api:Invoke is
|
||||||
|
# authorized by its own identity policy under AWS union semantics -- it
|
||||||
|
# is NOT constrained here, including on the planned PATCH/POST methods,
|
||||||
|
# which is why those also rely on the 501 handler + absent write grant,
|
||||||
|
# not on this policy, until phase 2.)
|
||||||
|
shoc_data_resources = [
|
||||||
|
f"execute-api:/{STAGE_NAME}/GET/work-orders",
|
||||||
|
f"execute-api:/{STAGE_NAME}/GET/work-orders/*",
|
||||||
|
f"execute-api:/{STAGE_NAME}/GET/purchase-orders",
|
||||||
|
f"execute-api:/{STAGE_NAME}/GET/purchase-orders/*",
|
||||||
|
f"execute-api:/{STAGE_NAME}/GET/verified-sites",
|
||||||
|
f"execute-api:/{STAGE_NAME}/GET/verified-sites/*",
|
||||||
|
]
|
||||||
|
api_policy = iam.PolicyDocument(
|
||||||
|
statements=[
|
||||||
|
iam.PolicyStatement(
|
||||||
|
sid="ShocBackendDevDataRead",
|
||||||
|
principals=[iam.ArnPrincipal(SHOC_BACKEND_DEV_ROLE_ARN)],
|
||||||
|
actions=["execute-api:Invoke"],
|
||||||
|
resources=shoc_data_resources,
|
||||||
|
),
|
||||||
|
# SECURITY INVARIANT: this AnyPrincipal allow is safe only
|
||||||
|
# while /docs//openapi.json serve static docs (handler
|
||||||
|
# enforces the shared token, fail-closed). Widening these
|
||||||
|
# routes to dynamic data, or enabling access logging (which
|
||||||
|
# would record the docs ?token= shim), requires a security
|
||||||
|
# re-review + docs-token rotation.
|
||||||
|
iam.PolicyStatement(
|
||||||
|
sid="DocsTokenGatedRoutes",
|
||||||
|
principals=[iam.AnyPrincipal()],
|
||||||
|
actions=["execute-api:Invoke"],
|
||||||
|
resources=[
|
||||||
|
f"execute-api:/{STAGE_NAME}/GET/docs",
|
||||||
|
f"execute-api:/{STAGE_NAME}/GET/openapi.json",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# OPERATIONAL NOTE: API Gateway serves the resource policy from the
|
||||||
|
# deployed stage snapshot, and CDK's Deployment hash is computed from
|
||||||
|
# resources/methods, not the RestApi Policy. A later policy-ONLY change
|
||||||
|
# (e.g. revoking the SHOC role) will UPDATE the RestApi but keep serving
|
||||||
|
# the old policy until a new Deployment is forced (any method/resource
|
||||||
|
# change, or a salted deployment). When tightening this policy, force a
|
||||||
|
# redeploy and verify the effective policy post-deploy.
|
||||||
|
api = apigateway.RestApi(
|
||||||
|
self,
|
||||||
|
"ProcurementRestApi",
|
||||||
|
rest_api_name="procurement-api",
|
||||||
|
description=(
|
||||||
|
"Read API over procurement-ingest work orders + purchase "
|
||||||
|
"orders; token-gated OpenAPI docs at /docs"
|
||||||
|
),
|
||||||
|
endpoint_types=[apigateway.EndpointType.REGIONAL],
|
||||||
|
policy=api_policy,
|
||||||
|
deploy_options=apigateway.StageOptions(
|
||||||
|
stage_name=STAGE_NAME,
|
||||||
|
# Bound the blast radius of the unauthenticated /docs routes
|
||||||
|
# (and the whole API) below the 10k rps account default -- this
|
||||||
|
# is a low-volume reconciliation/backfill API, not a hot path.
|
||||||
|
throttling_rate_limit=50,
|
||||||
|
throttling_burst_limit=100,
|
||||||
|
),
|
||||||
|
# No access/execution logging in v1: avoids the account-level API
|
||||||
|
# Gateway CloudWatch role prerequisite AND keeps the docs ?token=
|
||||||
|
# query shim out of any log. Rotate the docs token before ever
|
||||||
|
# enabling access logging here.
|
||||||
|
cloud_watch_role=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
integration = apigateway.LambdaIntegration(api_fn)
|
||||||
|
iam_auth = {"authorization_type": apigateway.AuthorizationType.IAM}
|
||||||
|
|
||||||
|
work_orders = api.root.add_resource("work-orders")
|
||||||
|
work_orders.add_method("GET", integration, **iam_auth)
|
||||||
|
wo_by_id = work_orders.add_resource("{workOrderId}")
|
||||||
|
wo_by_id.add_method("GET", integration, **iam_auth)
|
||||||
|
# Phase-2 planned write endpoints: deployed but the handler answers 501
|
||||||
|
# and the role holds no DynamoDB write grant. The resource policy denies
|
||||||
|
# these to the cross-account SHOC role (GET-only enumeration above); a
|
||||||
|
# same-account caller is NOT blocked by the resource policy, so the 501
|
||||||
|
# handler + absent write grant are the real gate until phase 2 lands the
|
||||||
|
# deliberate policy + handler + write-grant change with its own review.
|
||||||
|
wo_by_id.add_method("PATCH", integration, **iam_auth)
|
||||||
|
wo_comments = wo_by_id.add_resource("comments")
|
||||||
|
wo_comments.add_method("GET", integration, **iam_auth)
|
||||||
|
wo_comments.add_method("POST", integration, **iam_auth)
|
||||||
|
|
||||||
|
purchase_orders = api.root.add_resource("purchase-orders")
|
||||||
|
purchase_orders.add_method("GET", integration, **iam_auth)
|
||||||
|
purchase_orders.add_resource("{poNumber}").add_method(
|
||||||
|
"GET", integration, **iam_auth
|
||||||
|
)
|
||||||
|
|
||||||
|
verified_sites = api.root.add_resource("verified-sites")
|
||||||
|
verified_sites.add_method("GET", integration, **iam_auth)
|
||||||
|
verified_sites.add_resource("{siteCode}").add_method(
|
||||||
|
"GET", integration, **iam_auth
|
||||||
|
)
|
||||||
|
|
||||||
|
api.root.add_resource("docs").add_method(
|
||||||
|
"GET",
|
||||||
|
integration,
|
||||||
|
authorization_type=apigateway.AuthorizationType.NONE,
|
||||||
|
)
|
||||||
|
api.root.add_resource("openapi.json").add_method(
|
||||||
|
"GET",
|
||||||
|
integration,
|
||||||
|
authorization_type=apigateway.AuthorizationType.NONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Alarms (ALARM-only -> site-alerts, NOT_BREACHING) ---
|
||||||
|
# common.add_standard_lambda_alarms is NOT used here: its duration
|
||||||
|
# threshold is a fixed 45000 ms (75% of the processors' 60 s timeout),
|
||||||
|
# which this function's 30 s timeout can never reach. Same idiom,
|
||||||
|
# right-sized thresholds.
|
||||||
|
api_fn.metric_errors(period=Duration.minutes(5), statistic="Sum").create_alarm(
|
||||||
|
self,
|
||||||
|
"ProcurementApiErrorsAlarm",
|
||||||
|
alarm_name="procurement-api-errors",
|
||||||
|
alarm_description=(
|
||||||
|
"procurement-api Lambda raised (bundle/init failures; the "
|
||||||
|
"handler catches request errors, so any signal here is "
|
||||||
|
"structural)"
|
||||||
|
),
|
||||||
|
threshold=0,
|
||||||
|
evaluation_periods=1,
|
||||||
|
comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
||||||
|
treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
|
||||||
|
).add_alarm_action(cw_actions.SnsAction(alarm_topic))
|
||||||
|
|
||||||
|
api_fn.metric_throttles(
|
||||||
|
period=Duration.minutes(5), statistic="Sum"
|
||||||
|
).create_alarm(
|
||||||
|
self,
|
||||||
|
"ProcurementApiThrottlesAlarm",
|
||||||
|
alarm_name="procurement-api-throttles",
|
||||||
|
alarm_description="procurement-api Lambda throttled",
|
||||||
|
threshold=0,
|
||||||
|
evaluation_periods=1,
|
||||||
|
comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
||||||
|
treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
|
||||||
|
).add_alarm_action(cw_actions.SnsAction(alarm_topic))
|
||||||
|
|
||||||
|
api_fn.metric_duration(
|
||||||
|
period=Duration.minutes(5), statistic="p99"
|
||||||
|
).create_alarm(
|
||||||
|
self,
|
||||||
|
"ProcurementApiDurationAlarm",
|
||||||
|
alarm_name="procurement-api-duration",
|
||||||
|
alarm_description=(
|
||||||
|
"procurement-api p99 duration >= 22.5s (75% of the 30s "
|
||||||
|
"timeout; scans degrading toward timeout)"
|
||||||
|
),
|
||||||
|
threshold=22500,
|
||||||
|
evaluation_periods=3,
|
||||||
|
datapoints_to_alarm=2,
|
||||||
|
comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
||||||
|
treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
|
||||||
|
).add_alarm_action(cw_actions.SnsAction(alarm_topic))
|
||||||
|
|
||||||
|
# Gateway-side 5xx: catches what the Lambda's own Errors metric can't
|
||||||
|
# (the handler returns clean 500s; integration faults surface here).
|
||||||
|
# No 4XX alarm -- 401/403/404 are expected traffic.
|
||||||
|
cloudwatch.Metric(
|
||||||
|
namespace="AWS/ApiGateway",
|
||||||
|
metric_name="5XXError",
|
||||||
|
dimensions_map={"ApiName": "procurement-api", "Stage": STAGE_NAME},
|
||||||
|
period=Duration.minutes(5),
|
||||||
|
statistic="Sum",
|
||||||
|
).create_alarm(
|
||||||
|
self,
|
||||||
|
"ProcurementApi5xxAlarm",
|
||||||
|
alarm_name="procurement-api-5xx",
|
||||||
|
alarm_description="procurement-api gateway 5XX responses",
|
||||||
|
threshold=0,
|
||||||
|
evaluation_periods=1,
|
||||||
|
comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
||||||
|
treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
|
||||||
|
).add_alarm_action(cw_actions.SnsAction(alarm_topic))
|
||||||
|
|
||||||
|
cdk.CfnOutput(
|
||||||
|
self,
|
||||||
|
"ApiEndpointUrl",
|
||||||
|
value=api.url,
|
||||||
|
description="procurement-api invoke URL (stage prod)",
|
||||||
|
)
|
||||||
|
cdk.CfnOutput(
|
||||||
|
self,
|
||||||
|
"ProcurementApiFunctionArn",
|
||||||
|
value=api_fn.function_arn,
|
||||||
|
description="ARN of the procurement-api Lambda",
|
||||||
|
)
|
||||||
|
|
@ -10,19 +10,20 @@ stays untouched until decommission as the emergency mgmt deploy path.
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `trust-policy.json` | OIDC trust: `repo:Sea-Haven-Industries/procurement-ingest:ref:refs/heads/main` only |
|
| `trust-policy.json` | OIDC trust: `repo:Sea-Haven-Industries/procurement-ingest:ref:refs/heads/main` only |
|
||||||
| `permissions-policy.json` | `sts:AssumeRole` on the four `cdk-hnb659fds-*` bootstrap roles, `cloudformation:DescribeStacks` scoped to this repo's stacks + `CDKToolkit` (cd-cdk health check), and `lambda:InvokeFunction` on exactly the two email-processor function ARNs (post-deploy smoke gate) |
|
| `permissions-policy.json` | `sts:AssumeRole` on the four `cdk-hnb659fds-*` bootstrap roles, `cloudformation:DescribeStacks` scoped to this repo's stacks + `CDKToolkit` (cd-cdk health check), and `lambda:InvokeFunction` on exactly the three smoke-gated function ARNs (post-deploy smoke gate) |
|
||||||
| `create-deploy-role.sh` | Idempotent create-or-update from the two JSON files, profile `seahaven-prod` |
|
| `create-deploy-role.sh` | Idempotent create-or-update from the two JSON files, profile `seahaven-prod` |
|
||||||
|
|
||||||
> **Maintenance note:** `DescribeStacks` is scoped to `stack/po-ingest/*`, `stack/WorkorderIngestStack/*`, and `stack/CDKToolkit/*`. If a third stack is ever added to this CDK app, add its ARN pattern here and re-run the review-then-apply flow — otherwise the cd-cdk health check on the new stack will `AccessDenied`.
|
> **Maintenance note:** `DescribeStacks` is scoped to `stack/po-ingest/*`, `stack/WorkorderIngestStack/*`, `stack/procurement-api/*` (added with the procurement-api stack), and `stack/CDKToolkit/*`. If another stack is ever added to this CDK app, add its ARN pattern here and re-run the review-then-apply flow — otherwise the cd-cdk health check on the new stack will `AccessDenied`.
|
||||||
|
|
||||||
## Why the SmokeInvokeLambda statement exists
|
## Why the SmokeInvokeLambda statement exists
|
||||||
|
|
||||||
`deploy.yaml` runs `scripts/post-deploy-smoke.sh` under the deploy role's own
|
`deploy.yaml` runs `scripts/post-deploy-smoke.sh` under the deploy role's own
|
||||||
session, not the assumed `cdk-*` roles. Without `lambda:InvokeFunction` on the
|
session, not the assumed `cdk-*` roles. Without `lambda:InvokeFunction` on the
|
||||||
two function ARNs the smoke gate hits AccessDenied and every deploy fails
|
smoke-gated function ARNs (the two email processors + `procurement-api`) the
|
||||||
closed. The mgmt-era grant was applied out-of-band and undocumented; keeping
|
smoke gate hits AccessDenied and every deploy fails closed. The mgmt-era grant
|
||||||
it in these reviewed artifacts closes that gap. Scope it to exactly the two
|
was applied out-of-band and undocumented; keeping it in these reviewed
|
||||||
ARNs, never `Resource: "*"`.
|
artifacts closes that gap. Scope it to exactly the named ARNs, never
|
||||||
|
`Resource: "*"`.
|
||||||
|
|
||||||
## Change process
|
## Change process
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"Resource": [
|
"Resource": [
|
||||||
"arn:aws:cloudformation:us-east-1:011934824531:stack/po-ingest/*",
|
"arn:aws:cloudformation:us-east-1:011934824531:stack/po-ingest/*",
|
||||||
"arn:aws:cloudformation:us-east-1:011934824531:stack/WorkorderIngestStack/*",
|
"arn:aws:cloudformation:us-east-1:011934824531:stack/WorkorderIngestStack/*",
|
||||||
|
"arn:aws:cloudformation:us-east-1:011934824531:stack/procurement-api/*",
|
||||||
"arn:aws:cloudformation:us-east-1:011934824531:stack/CDKToolkit/*"
|
"arn:aws:cloudformation:us-east-1:011934824531:stack/CDKToolkit/*"
|
||||||
],
|
],
|
||||||
"Condition": {
|
"Condition": {
|
||||||
|
|
@ -33,7 +34,8 @@
|
||||||
"Action": "lambda:InvokeFunction",
|
"Action": "lambda:InvokeFunction",
|
||||||
"Resource": [
|
"Resource": [
|
||||||
"arn:aws:lambda:us-east-1:011934824531:function:po-email-processor",
|
"arn:aws:lambda:us-east-1:011934824531:function:po-email-processor",
|
||||||
"arn:aws:lambda:us-east-1:011934824531:function:workorder-email-processor"
|
"arn:aws:lambda:us-east-1:011934824531:function:workorder-email-processor",
|
||||||
|
"arn:aws:lambda:us-east-1:011934824531:function:procurement-api"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
229
lambdas/api/docs.html
Normal file
229
lambdas/api/docs.html
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Procurement Ingest API</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; line-height: 1.55; }
|
||||||
|
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 20px 80px; }
|
||||||
|
h1 { font-size: 24px; margin-bottom: 4px; }
|
||||||
|
h2 { font-size: 18px; margin: 36px 0 14px; padding-bottom: 6px; border-bottom: 2px solid #e2e8f0; }
|
||||||
|
.muted { color: #64748b; font-size: 14px; }
|
||||||
|
.desc { margin: 14px 0; font-size: 14px; color: #334155; }
|
||||||
|
.desc p { margin-bottom: 8px; }
|
||||||
|
code { background: #e2e8f0; border-radius: 4px; padding: 1px 5px; font-size: 12.5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.card { background: #fff; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 12px; overflow: hidden; }
|
||||||
|
.op-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; cursor: pointer; }
|
||||||
|
.op-head:hover { background: #f8fafc; }
|
||||||
|
.method { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 6px; color: #fff; min-width: 58px; text-align: center; }
|
||||||
|
.m-get { background: #10b981; } .m-post { background: #f59e0b; } .m-patch { background: #8b5cf6; }
|
||||||
|
.m-webhook { background: #3b82f6; }
|
||||||
|
.path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; font-weight: 600; }
|
||||||
|
.summary { color: #64748b; font-size: 13px; flex: 1; }
|
||||||
|
.planned { background: #fef3c7; color: #92400e; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; }
|
||||||
|
.op-body { display: none; padding: 4px 16px 16px; border-top: 1px solid #f1f5f9; }
|
||||||
|
.open .op-body { display: block; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 13px; }
|
||||||
|
th { text-align: left; padding: 6px 8px; color: #64748b; font-size: 11px; text-transform: uppercase; border-bottom: 2px solid #e2e8f0; }
|
||||||
|
td { padding: 6px 8px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
|
||||||
|
.sec-label { font-size: 12px; font-weight: 700; color: #64748b; text-transform: uppercase; margin: 14px 0 4px; }
|
||||||
|
.schema-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
|
||||||
|
.type { color: #7c3aed; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||||
|
.req { color: #dc2626; font-size: 11px; font-weight: 700; }
|
||||||
|
.badge-auth { background: #dbeafe; color: #1d4ed8; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; }
|
||||||
|
a { color: #3b82f6; text-decoration: none; }
|
||||||
|
.enum { color: #0f766e; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap" id="app"><p class="muted">Loading spec…</p></div>
|
||||||
|
<script id="spec" type="application/json">__OPENAPI_SPEC_JSON__</script>
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
const spec = JSON.parse(document.getElementById("spec").textContent);
|
||||||
|
const el = (tag, cls, html) => {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (cls) node.className = cls;
|
||||||
|
if (html !== undefined) node.innerHTML = html;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
const escapeHtml = s => String(s).replace(/[&<>"']/g,
|
||||||
|
c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||||
|
const md = s => escapeHtml(s || "")
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||||
|
.split(/\n\n+/).map(p => `<p>${p.replace(/\n/g, "<br>")}</p>`).join("");
|
||||||
|
|
||||||
|
function deref(node) {
|
||||||
|
if (node && node.$ref) {
|
||||||
|
const parts = node.$ref.replace("#/", "").split("/");
|
||||||
|
let cur = spec;
|
||||||
|
for (const p of parts) cur = cur[p];
|
||||||
|
return { name: node.$ref.split("/").pop(), ...cur };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
const typeStr = schema => {
|
||||||
|
// Returns an HTML-escaped fragment: this is the one spec-derived string
|
||||||
|
// that reaches innerHTML (the type cells), so it must escape like every
|
||||||
|
// other renderer here. The array<...> wrapper composes already-escaped
|
||||||
|
// pieces, so its literal </> are safe.
|
||||||
|
if (!schema) return "";
|
||||||
|
if (schema.$ref) return escapeHtml(schema.$ref.split("/").pop());
|
||||||
|
if (Array.isArray(schema.type) && schema.type.includes("array") && schema.items)
|
||||||
|
return `array<${typeStr(schema.items)}>`;
|
||||||
|
if (schema.type === "array" && schema.items) return `array<${typeStr(schema.items)}>`;
|
||||||
|
const t = Array.isArray(schema.type) ? schema.type.join(" | ") : (schema.type || "");
|
||||||
|
return escapeHtml(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
function schemaTable(schema, depth = 0) {
|
||||||
|
schema = deref(schema);
|
||||||
|
if (schema.allOf) {
|
||||||
|
const merged = { properties: {}, required: [] };
|
||||||
|
for (const part of schema.allOf.map(deref)) {
|
||||||
|
Object.assign(merged.properties, part.properties || {});
|
||||||
|
merged.required.push(...(part.required || []));
|
||||||
|
}
|
||||||
|
schema = merged;
|
||||||
|
}
|
||||||
|
if (!schema.properties) return null;
|
||||||
|
const table = el("table");
|
||||||
|
table.appendChild(el("tr", "", "<th>Field</th><th>Type</th><th>Description</th>"));
|
||||||
|
for (const [name, raw] of Object.entries(schema.properties)) {
|
||||||
|
const prop = raw.$ref ? deref(raw) : raw;
|
||||||
|
const required = (schema.required || []).includes(name);
|
||||||
|
let desc = md(prop.description);
|
||||||
|
if (prop.enum) desc += `<div class="enum">${prop.enum.filter(v => v !== null).map(escapeHtml).join(" | ")}</div>`;
|
||||||
|
const tr = el("tr", "", `
|
||||||
|
<td><span class="schema-name">${escapeHtml(name)}</span>${required ? ' <span class="req">required</span>' : ""}</td>
|
||||||
|
<td><span class="type">${typeStr(raw)}</span></td>
|
||||||
|
<td>${desc}</td>`);
|
||||||
|
table.appendChild(tr);
|
||||||
|
const inner = prop.type === "object" || (Array.isArray(prop.type) && prop.type.includes("object"));
|
||||||
|
if (inner && prop.properties && depth < 2) {
|
||||||
|
const cell = el("td", "", "");
|
||||||
|
cell.colSpan = 3;
|
||||||
|
cell.style.paddingLeft = "24px";
|
||||||
|
cell.appendChild(schemaTable(prop, depth + 1));
|
||||||
|
const row = el("tr");
|
||||||
|
row.appendChild(cell);
|
||||||
|
table.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
function opCard(method, path, op, isWebhook) {
|
||||||
|
const card = el("div", "card");
|
||||||
|
const head = el("div", "op-head");
|
||||||
|
const cls = isWebhook ? "m-webhook" : `m-${method.toLowerCase()}`;
|
||||||
|
head.appendChild(el("span", `method ${cls}`, isWebhook ? "EVENT" : method.toUpperCase()));
|
||||||
|
head.appendChild(el("span", "path", escapeHtml(path)));
|
||||||
|
head.appendChild(el("span", "summary", escapeHtml(op.summary || "")));
|
||||||
|
if (op["x-planned"]) head.appendChild(el("span", "planned", "PLANNED"));
|
||||||
|
const secSchemes = op.security || spec.security || [];
|
||||||
|
const secName = secSchemes.length ? Object.keys(secSchemes[0])[0] : null;
|
||||||
|
if (!isWebhook && secName) head.appendChild(el("span", "badge-auth", escapeHtml(secName)));
|
||||||
|
const body = el("div", "op-body");
|
||||||
|
if (op.description) body.appendChild(el("div", "desc", md(op.description)));
|
||||||
|
|
||||||
|
const params = (op.parameters || []).map(deref);
|
||||||
|
if (params.length) {
|
||||||
|
body.appendChild(el("div", "sec-label", "Parameters"));
|
||||||
|
const table = el("table");
|
||||||
|
table.appendChild(el("tr", "", "<th>Name</th><th>In</th><th>Type</th><th>Description</th>"));
|
||||||
|
for (const p of params) {
|
||||||
|
table.appendChild(el("tr", "", `
|
||||||
|
<td><span class="schema-name">${escapeHtml(p.name)}</span>${p.required ? ' <span class="req">required</span>' : ""}</td>
|
||||||
|
<td>${escapeHtml(p.in)}</td>
|
||||||
|
<td><span class="type">${typeStr(p.schema)}</span></td>
|
||||||
|
<td>${md(p.description)}</td>`));
|
||||||
|
}
|
||||||
|
body.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqSchema = op.requestBody && op.requestBody.content &&
|
||||||
|
op.requestBody.content["application/json"] && op.requestBody.content["application/json"].schema;
|
||||||
|
if (reqSchema) {
|
||||||
|
body.appendChild(el("div", "sec-label", `Payload (${typeStr(reqSchema)})`));
|
||||||
|
const table = schemaTable(reqSchema);
|
||||||
|
if (table) body.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(el("div", "sec-label", "Responses"));
|
||||||
|
const rtable = el("table");
|
||||||
|
rtable.appendChild(el("tr", "", "<th>Status</th><th>Description</th><th>Schema</th>"));
|
||||||
|
for (const [code, raw] of Object.entries(op.responses || {})) {
|
||||||
|
const resp = deref(raw);
|
||||||
|
const schema = resp.content && resp.content["application/json"] && resp.content["application/json"].schema;
|
||||||
|
rtable.appendChild(el("tr", "", `
|
||||||
|
<td><span class="schema-name">${escapeHtml(code)}</span></td>
|
||||||
|
<td>${md(resp.description)}</td>
|
||||||
|
<td><span class="type">${schema ? typeStr(schema) : ""}</span></td>`));
|
||||||
|
if (code.startsWith("2") && schema) {
|
||||||
|
const cell = el("td", "", "");
|
||||||
|
cell.colSpan = 3;
|
||||||
|
cell.style.paddingLeft = "24px";
|
||||||
|
const table = schemaTable(schema);
|
||||||
|
if (table) cell.appendChild(table);
|
||||||
|
const row = el("tr");
|
||||||
|
row.appendChild(cell);
|
||||||
|
rtable.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.appendChild(rtable);
|
||||||
|
head.addEventListener("click", () => card.classList.toggle("open"));
|
||||||
|
card.appendChild(head);
|
||||||
|
card.appendChild(body);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = document.getElementById("app");
|
||||||
|
app.innerHTML = "";
|
||||||
|
app.appendChild(el("h1", "", escapeHtml(spec.info.title)));
|
||||||
|
app.appendChild(el("div", "muted", `v${escapeHtml(spec.info.version)} · ${escapeHtml((spec.servers && spec.servers[0] && spec.servers[0].url) || "")}`));
|
||||||
|
app.appendChild(el("div", "desc", md(spec.info.description)));
|
||||||
|
|
||||||
|
app.appendChild(el("h2", "", "Authentication"));
|
||||||
|
for (const [name, scheme] of Object.entries((spec.components || {}).securitySchemes || {})) {
|
||||||
|
const card = el("div", "card");
|
||||||
|
card.appendChild(el("div", "op-head",
|
||||||
|
`<span class="badge-auth">${escapeHtml(name)}</span>
|
||||||
|
<span class="summary">${escapeHtml(scheme.name)} (${escapeHtml(scheme.in)}) — ${md(scheme.description)}</span>`));
|
||||||
|
app.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.appendChild(el("h2", "", "Endpoints (this API can be called)"));
|
||||||
|
for (const [path, ops] of Object.entries(spec.paths || {})) {
|
||||||
|
for (const [method, op] of Object.entries(ops)) {
|
||||||
|
app.appendChild(opCard(method, path, op, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.appendChild(el("h2", "", "Outbound webhooks (this API calls you)"));
|
||||||
|
for (const [name, ops] of Object.entries(spec.webhooks || {})) {
|
||||||
|
for (const [method, op] of Object.entries(ops)) {
|
||||||
|
app.appendChild(opCard(method, name, op, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.appendChild(el("h2", "", "Schemas"));
|
||||||
|
for (const [name, schema] of Object.entries((spec.components || {}).schemas || {})) {
|
||||||
|
const card = el("div", "card open");
|
||||||
|
const head = el("div", "op-head");
|
||||||
|
head.appendChild(el("span", "schema-name", escapeHtml(name)));
|
||||||
|
head.appendChild(el("span", "summary", escapeHtml(schema.description || "")));
|
||||||
|
card.appendChild(head);
|
||||||
|
const body = el("div", "op-body");
|
||||||
|
const table = schemaTable(schema);
|
||||||
|
if (table) body.appendChild(table);
|
||||||
|
card.appendChild(body);
|
||||||
|
head.addEventListener("click", () => card.classList.toggle("open"));
|
||||||
|
app.appendChild(card);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
193
lambdas/api/handler.py
Normal file
193
lambdas/api/handler.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""Procurement API Lambda (API Gateway REST proxy integration).
|
||||||
|
|
||||||
|
Auth is split by route class and enforced at two layers:
|
||||||
|
- Data routes: AWS_IAM at the gateway (SigV4; cross-account callers allowed by
|
||||||
|
the API resource policy). The handler does NOT re-check a token there --
|
||||||
|
authorization is API Gateway's job on those routes.
|
||||||
|
- Docs routes (/docs, /openapi.json): reachable at the gateway (auth NONE +
|
||||||
|
resource-policy carve-out) but the handler fails closed on the shared
|
||||||
|
header token via web_ui_auth (same secret + constant-time compare as the
|
||||||
|
web UIs), so they are never an unauthenticated data path (INFRA-74).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import po_repo
|
||||||
|
import wo_repo
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from pagination import BadCursor, clamp_limit
|
||||||
|
from router import DATA_ROUTES, DOCS_ROUTES, PLANNED_ROUTES
|
||||||
|
from serialization import error_response, json_response
|
||||||
|
from web_ui_auth import is_authenticated
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
_MODULE_DIR = Path(__file__).resolve().parent
|
||||||
|
_SPEC_PATH = _MODULE_DIR / "openapi.json"
|
||||||
|
_DOCS_PATH = _MODULE_DIR / "docs.html"
|
||||||
|
# The docs page template carries this placeholder where the spec JSON is
|
||||||
|
# inlined, so /docs is a single token-gated request (a browser can't attach
|
||||||
|
# the auth header to a follow-up asset fetch).
|
||||||
|
_SPEC_PLACEHOLDER = "__OPENAPI_SPEC_JSON__"
|
||||||
|
|
||||||
|
_spec_cache = None
|
||||||
|
_docs_cache = None
|
||||||
|
|
||||||
|
_REPO_FUNCS = {
|
||||||
|
"list_work_orders": wo_repo.list_work_orders,
|
||||||
|
"get_work_order": wo_repo.get_work_order,
|
||||||
|
"list_comments": wo_repo.list_comments,
|
||||||
|
"list_purchase_orders": po_repo.list_purchase_orders,
|
||||||
|
"get_purchase_order": po_repo.get_purchase_order,
|
||||||
|
"list_verified_sites": po_repo.list_verified_sites,
|
||||||
|
"get_verified_site": po_repo.get_verified_site,
|
||||||
|
}
|
||||||
|
|
||||||
|
_PATH_PARAM_BY_RESOURCE = {
|
||||||
|
"/work-orders/{workOrderId}": "workOrderId",
|
||||||
|
"/work-orders/{workOrderId}/comments": "workOrderId",
|
||||||
|
"/purchase-orders/{poNumber}": "poNumber",
|
||||||
|
"/verified-sites/{siteCode}": "siteCode",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Headers on the docs responses: the token rides in the ?token= query shim,
|
||||||
|
# so keep the token-keyed URL and page out of shared/browser caches and out of
|
||||||
|
# any Referer sent to a followed link.
|
||||||
|
_DOCS_SECURITY_HEADERS = {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_spec() -> str:
|
||||||
|
"""Raw spec bytes, served verbatim at /openapi.json (byte-faithful JSON)."""
|
||||||
|
global _spec_cache
|
||||||
|
if _spec_cache is None:
|
||||||
|
_spec_cache = _SPEC_PATH.read_text(encoding="utf-8")
|
||||||
|
return _spec_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _load_docs_html() -> str:
|
||||||
|
global _docs_cache
|
||||||
|
if _docs_cache is None:
|
||||||
|
# Neutralize any "</script"/"<!--" a future spec edit could introduce:
|
||||||
|
# the spec is spliced into a <script type="application/json"> block
|
||||||
|
# where the HTML parser ends the element at the first literal "<"
|
||||||
|
# sequence regardless of JSON quoting. "<" is the same JSON
|
||||||
|
# string value ("<") to JSON.parse but can never close the script tag.
|
||||||
|
inlined_spec = _load_spec().replace("<", "\\u003c")
|
||||||
|
_docs_cache = _DOCS_PATH.read_text(encoding="utf-8").replace(
|
||||||
|
_SPEC_PLACEHOLDER, inlined_spec
|
||||||
|
)
|
||||||
|
return _docs_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _with_token_shim(event: dict) -> dict:
|
||||||
|
"""Copy a ?token= query parameter into an x-auth-token header.
|
||||||
|
|
||||||
|
Browsers can't set headers on plain navigation, so /docs accepts the
|
||||||
|
shared token as a query parameter too. The constant-time compare still
|
||||||
|
happens inside web_ui_auth -- this only synthesizes the header on a
|
||||||
|
shallow copy. Acceptable only while API Gateway access logging stays off
|
||||||
|
(nothing at the gateway records the query string); rotate the token if
|
||||||
|
access logging is ever enabled.
|
||||||
|
"""
|
||||||
|
qs = event.get("queryStringParameters") or {}
|
||||||
|
token = qs.get("token")
|
||||||
|
if not token:
|
||||||
|
return event
|
||||||
|
shimmed = dict(event)
|
||||||
|
headers = dict(event.get("headers") or {})
|
||||||
|
headers["x-auth-token"] = token
|
||||||
|
shimmed["headers"] = headers
|
||||||
|
return shimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _docs_response(resource: str, event: dict) -> dict:
|
||||||
|
# SECURITY INVARIANT: these routes serve ONLY the committed spec and the
|
||||||
|
# static docs page -- never table data. The gateway resource policy allows
|
||||||
|
# Principal "*" on exactly these two GETs on the strength of that; serving
|
||||||
|
# anything dynamic here requires a resource-policy + security re-review.
|
||||||
|
if not is_authenticated(_with_token_shim(event)):
|
||||||
|
return error_response(401, "unauthorized")
|
||||||
|
if resource == "/openapi.json":
|
||||||
|
return {
|
||||||
|
"statusCode": 200,
|
||||||
|
"headers": {"Content-Type": "application/json", **_DOCS_SECURITY_HEADERS},
|
||||||
|
"body": _load_spec(),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"statusCode": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
**_DOCS_SECURITY_HEADERS,
|
||||||
|
},
|
||||||
|
"body": _load_docs_html(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _data_response(route_key: tuple, event: dict) -> dict:
|
||||||
|
method, resource = route_key
|
||||||
|
func = _REPO_FUNCS[DATA_ROUTES[route_key]]
|
||||||
|
qs = event.get("queryStringParameters") or {}
|
||||||
|
path_params = event.get("pathParameters") or {}
|
||||||
|
|
||||||
|
if DATA_ROUTES[route_key].startswith("list_"):
|
||||||
|
limit = clamp_limit(qs.get("limit"))
|
||||||
|
cursor = qs.get("cursor")
|
||||||
|
if resource in _PATH_PARAM_BY_RESOURCE:
|
||||||
|
entity_id = path_params.get(_PATH_PARAM_BY_RESOURCE[resource], "")
|
||||||
|
items, next_cursor = func(entity_id, limit, cursor)
|
||||||
|
else:
|
||||||
|
items, next_cursor = func(limit, cursor)
|
||||||
|
return json_response(200, {"items": items, "next_cursor": next_cursor})
|
||||||
|
|
||||||
|
entity_id = path_params.get(_PATH_PARAM_BY_RESOURCE[resource], "")
|
||||||
|
item = func(entity_id)
|
||||||
|
if item is None:
|
||||||
|
return error_response(404, "not found")
|
||||||
|
return json_response(200, item)
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch(route_key: tuple, event: dict) -> dict:
|
||||||
|
if route_key in PLANNED_ROUTES:
|
||||||
|
return error_response(501, "planned endpoint - not implemented (phase 2)")
|
||||||
|
if route_key in DOCS_ROUTES:
|
||||||
|
return _docs_response(route_key[1], event)
|
||||||
|
if route_key in DATA_ROUTES:
|
||||||
|
return _data_response(route_key, event)
|
||||||
|
return error_response(404, "not found")
|
||||||
|
|
||||||
|
|
||||||
|
def handler(event, context):
|
||||||
|
# Deploy-guard healthcheck: a direct-invoke {"healthcheck": true} probe
|
||||||
|
# returns before any routing/auth so the post-deploy smoke gate can verify
|
||||||
|
# the bundle imports and the runtime boots.
|
||||||
|
if isinstance(event, dict) and event.get("healthcheck") is True:
|
||||||
|
return {"healthcheck": "ok"}
|
||||||
|
|
||||||
|
method = (event.get("httpMethod") or "").upper()
|
||||||
|
resource = event.get("resource") or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _dispatch((method, resource), event)
|
||||||
|
except BadCursor as exc:
|
||||||
|
return error_response(400, str(exc))
|
||||||
|
except ClientError as exc:
|
||||||
|
# A client-supplied cursor that survives validation but is still
|
||||||
|
# inconsistent at the data layer makes DynamoDB raise
|
||||||
|
# ValidationException; map it to 400, not 500, so a crafted cursor
|
||||||
|
# can't drive the 5xx alarm. Any other AWS error is a real 500.
|
||||||
|
if exc.response.get("Error", {}).get("Code") == "ValidationException":
|
||||||
|
return error_response(400, "cursor is not valid")
|
||||||
|
logger.exception("AWS error serving %s %s", method, resource)
|
||||||
|
return error_response(500, "internal error")
|
||||||
|
except Exception:
|
||||||
|
# A raised exception would surface as an opaque 502 from the proxy
|
||||||
|
# integration; return a clean 500 instead. The API Gateway 5XX alarm
|
||||||
|
# pages on these; the exception (never the request token) is logged.
|
||||||
|
logger.exception("Unhandled error serving %s %s", method, resource)
|
||||||
|
return error_response(500, "internal error")
|
||||||
1022
lambdas/api/openapi.json
Normal file
1022
lambdas/api/openapi.json
Normal file
File diff suppressed because it is too large
Load diff
62
lambdas/api/pagination.py
Normal file
62
lambdas/api/pagination.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Opaque cursor pagination over DynamoDB ``LastEvaluatedKey``.
|
||||||
|
|
||||||
|
The cursor is base64url(JSON(LastEvaluatedKey)). It is untrusted client input:
|
||||||
|
``decode_cursor`` validates shape strictly (a flat dict whose keys are exactly
|
||||||
|
a subset of the table's key attributes and whose values are non-empty strings)
|
||||||
|
and raises ``BadCursor`` -- mapped to HTTP 400 by the handler -- on anything
|
||||||
|
else, so a malformed or tampered cursor can never reach DynamoDB as an
|
||||||
|
arbitrary ``ExclusiveStartKey`` or surface as a 500.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 100
|
||||||
|
MAX_LIMIT = 500
|
||||||
|
_MAX_CURSOR_CHARS = 2048
|
||||||
|
|
||||||
|
|
||||||
|
class BadCursor(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_limit(raw) -> int:
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return DEFAULT_LIMIT
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise BadCursor("limit must be an integer") from None
|
||||||
|
return max(1, min(MAX_LIMIT, value))
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cursor(last_evaluated_key: dict) -> str:
|
||||||
|
raw = json.dumps(last_evaluated_key, default=str, sort_keys=True)
|
||||||
|
return base64.urlsafe_b64encode(raw.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cursor(cursor: str, key_attrs: frozenset) -> dict:
|
||||||
|
"""Decode a cursor and require it to be EXACTLY the table's key attributes.
|
||||||
|
|
||||||
|
``key_attrs`` is the full key schema of the operation the cursor feeds
|
||||||
|
(e.g. ``{work_order_id, comment_id}`` for the comments Query). An exact
|
||||||
|
match -- not a subset -- is required: a partial composite key or a cursor
|
||||||
|
minted for a different endpoint would otherwise pass a subset check, reach
|
||||||
|
DynamoDB as an incomplete/inconsistent ``ExclusiveStartKey``, and raise a
|
||||||
|
ValidationException that surfaces as a 500 (and pages the 5xx alarm). Here
|
||||||
|
it fails closed as a 400 instead. Callers additionally pin the
|
||||||
|
partition-key value to the request path (see ``wo_repo.list_comments``).
|
||||||
|
"""
|
||||||
|
if len(cursor) > _MAX_CURSOR_CHARS:
|
||||||
|
raise BadCursor("cursor too long")
|
||||||
|
try:
|
||||||
|
decoded = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
||||||
|
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
||||||
|
raise BadCursor("cursor is not valid") from None
|
||||||
|
if not isinstance(decoded, dict) or set(decoded) != key_attrs:
|
||||||
|
raise BadCursor("cursor is not valid")
|
||||||
|
for value in decoded.values():
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise BadCursor("cursor is not valid")
|
||||||
|
return decoded
|
||||||
53
lambdas/api/po_repo.py
Normal file
53
lambdas/api/po_repo.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""Read access to the purchase-order tables for the procurement API.
|
||||||
|
|
||||||
|
Same unordered cursor-paginated Scan pattern as wo_repo (no GSIs; listing
|
||||||
|
serves reconciliation, not ranked queries). VendorReplies is deliberately
|
||||||
|
absent: the table is dead (its writer was deleted) and must not gain new
|
||||||
|
consumers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from pagination import decode_cursor, encode_cursor
|
||||||
|
|
||||||
|
dynamodb = boto3.resource("dynamodb")
|
||||||
|
|
||||||
|
PO_TABLE = os.environ.get("PO_TABLE", "purchase-orders")
|
||||||
|
VERIFIED_SITES_TABLE = os.environ.get("VERIFIED_SITES_TABLE", "verified-sites")
|
||||||
|
|
||||||
|
PO_KEY_ATTRS = frozenset({"po_number"})
|
||||||
|
SITE_KEY_ATTRS = frozenset({"siteCode"})
|
||||||
|
|
||||||
|
|
||||||
|
def _page(response) -> tuple[list, str | None]:
|
||||||
|
items = response.get("Items", [])
|
||||||
|
lek = response.get("LastEvaluatedKey")
|
||||||
|
return items, (encode_cursor(lek) if lek else None)
|
||||||
|
|
||||||
|
|
||||||
|
def list_purchase_orders(limit: int, cursor: str | None) -> tuple[list, str | None]:
|
||||||
|
table = dynamodb.Table(PO_TABLE)
|
||||||
|
kwargs = {"Limit": limit}
|
||||||
|
if cursor:
|
||||||
|
kwargs["ExclusiveStartKey"] = decode_cursor(cursor, PO_KEY_ATTRS)
|
||||||
|
return _page(table.scan(**kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def get_purchase_order(po_number: str) -> dict | None:
|
||||||
|
table = dynamodb.Table(PO_TABLE)
|
||||||
|
return table.get_item(Key={"po_number": po_number}).get("Item")
|
||||||
|
|
||||||
|
|
||||||
|
def list_verified_sites(limit: int, cursor: str | None) -> tuple[list, str | None]:
|
||||||
|
table = dynamodb.Table(VERIFIED_SITES_TABLE)
|
||||||
|
kwargs = {"Limit": limit}
|
||||||
|
if cursor:
|
||||||
|
kwargs["ExclusiveStartKey"] = decode_cursor(cursor, SITE_KEY_ATTRS)
|
||||||
|
return _page(table.scan(**kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def get_verified_site(site_code: str) -> dict | None:
|
||||||
|
table = dynamodb.Table(VERIFIED_SITES_TABLE)
|
||||||
|
return table.get_item(Key={"siteCode": site_code}).get("Item")
|
||||||
1
lambdas/api/requirements.txt
Normal file
1
lambdas/api/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Dependabot anchor only. The procurement-api Lambda is stdlib+boto3 (provided by the runtime); nothing is pip-installed into the bundle.
|
||||||
33
lambdas/api/router.py
Normal file
33
lambdas/api/router.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Route tables for the procurement API.
|
||||||
|
|
||||||
|
Single source of truth for what the API implements: the spec-drift test
|
||||||
|
(tests/test_api_spec_drift.py) asserts these tables match lambdas/api/
|
||||||
|
openapi.json exactly, so an endpoint can't be added, removed, or renamed on
|
||||||
|
one side without failing CI. Keys are (httpMethod, resource) as API Gateway's
|
||||||
|
Lambda-proxy event presents them (resource = the templated path).
|
||||||
|
|
||||||
|
DATA_ROUTES values are handler-module attribute names resolved by handler.py.
|
||||||
|
PLANNED_ROUTES are phase-2 write endpoints: present in the spec (x-planned)
|
||||||
|
and at the gateway, but the handler answers 501 until they ship with their
|
||||||
|
own IAM diff + cross-family review.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DATA_ROUTES = {
|
||||||
|
("GET", "/work-orders"): "list_work_orders",
|
||||||
|
("GET", "/work-orders/{workOrderId}"): "get_work_order",
|
||||||
|
("GET", "/work-orders/{workOrderId}/comments"): "list_comments",
|
||||||
|
("GET", "/purchase-orders"): "list_purchase_orders",
|
||||||
|
("GET", "/purchase-orders/{poNumber}"): "get_purchase_order",
|
||||||
|
("GET", "/verified-sites"): "list_verified_sites",
|
||||||
|
("GET", "/verified-sites/{siteCode}"): "get_verified_site",
|
||||||
|
}
|
||||||
|
|
||||||
|
DOCS_ROUTES = {
|
||||||
|
("GET", "/docs"),
|
||||||
|
("GET", "/openapi.json"),
|
||||||
|
}
|
||||||
|
|
||||||
|
PLANNED_ROUTES = {
|
||||||
|
("POST", "/work-orders/{workOrderId}/comments"),
|
||||||
|
("PATCH", "/work-orders/{workOrderId}"),
|
||||||
|
}
|
||||||
43
lambdas/api/serialization.py
Normal file
43
lambdas/api/serialization.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""JSON response helpers for the procurement API.
|
||||||
|
|
||||||
|
DynamoDB items come back through boto3 with numeric attributes as
|
||||||
|
``decimal.Decimal`` (e.g. purchase-orders ``total_amount``/``line_items``
|
||||||
|
prices), which ``json.dumps`` rejects. The encoder below renders integral
|
||||||
|
Decimals as JSON integers (exact) and non-integral Decimals as floats. For the
|
||||||
|
values this API serves -- currency amounts at PO magnitudes, well under ~15
|
||||||
|
significant digits -- the shortest-float repr round-trips the original digits
|
||||||
|
exactly. A Decimal carrying more precision than a float can hold (DynamoDB
|
||||||
|
Number supports 38 digits) would be truncated silently; the pipeline never
|
||||||
|
writes such values, but if that ever changes, switch non-integral Decimals to
|
||||||
|
``str(o)`` and update the OpenAPI number types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import decimal
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class _DecimalSafeEncoder(json.JSONEncoder):
|
||||||
|
def default(self, o):
|
||||||
|
if isinstance(o, decimal.Decimal):
|
||||||
|
if o == o.to_integral_value():
|
||||||
|
return int(o)
|
||||||
|
return float(o)
|
||||||
|
if isinstance(o, set):
|
||||||
|
return sorted(o)
|
||||||
|
return super().default(o)
|
||||||
|
|
||||||
|
|
||||||
|
def dumps(payload) -> str:
|
||||||
|
return json.dumps(payload, cls=_DecimalSafeEncoder)
|
||||||
|
|
||||||
|
|
||||||
|
def json_response(status_code: int, payload) -> dict:
|
||||||
|
return {
|
||||||
|
"statusCode": status_code,
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": dumps(payload),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(status_code: int, message: str) -> dict:
|
||||||
|
return json_response(status_code, {"error": message})
|
||||||
62
lambdas/api/wo_repo.py
Normal file
62
lambdas/api/wo_repo.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Read access to the work-order tables for the procurement API.
|
||||||
|
|
||||||
|
Listing is a cursor-paginated Scan -- deliberately: the WO tables carry no
|
||||||
|
GSIs (removed 2026-06-03 for zero reads), and the API's list consumers
|
||||||
|
(SHOC reconciliation/backfill) walk the full table anyway, which is exactly
|
||||||
|
the access pattern SHOC's retired SyncController used. Listings are therefore
|
||||||
|
UNORDERED across pages; per-work-order comment listing is a cheap Query on
|
||||||
|
the partition key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from boto3.dynamodb.conditions import Key
|
||||||
|
|
||||||
|
from pagination import BadCursor, decode_cursor, encode_cursor
|
||||||
|
|
||||||
|
dynamodb = boto3.resource("dynamodb")
|
||||||
|
|
||||||
|
WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders")
|
||||||
|
COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments")
|
||||||
|
|
||||||
|
WO_KEY_ATTRS = frozenset({"work_order_id"})
|
||||||
|
COMMENT_KEY_ATTRS = frozenset({"work_order_id", "comment_id"})
|
||||||
|
|
||||||
|
|
||||||
|
def _page(response) -> tuple[list, str | None]:
|
||||||
|
items = response.get("Items", [])
|
||||||
|
lek = response.get("LastEvaluatedKey")
|
||||||
|
return items, (encode_cursor(lek) if lek else None)
|
||||||
|
|
||||||
|
|
||||||
|
def list_work_orders(limit: int, cursor: str | None) -> tuple[list, str | None]:
|
||||||
|
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
||||||
|
kwargs = {"Limit": limit}
|
||||||
|
if cursor:
|
||||||
|
kwargs["ExclusiveStartKey"] = decode_cursor(cursor, WO_KEY_ATTRS)
|
||||||
|
return _page(table.scan(**kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def get_work_order(work_order_id: str) -> dict | None:
|
||||||
|
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
||||||
|
return table.get_item(Key={"work_order_id": work_order_id}).get("Item")
|
||||||
|
|
||||||
|
|
||||||
|
def list_comments(
|
||||||
|
work_order_id: str, limit: int, cursor: str | None
|
||||||
|
) -> tuple[list, str | None]:
|
||||||
|
table = dynamodb.Table(COMMENTS_TABLE)
|
||||||
|
kwargs = {
|
||||||
|
"KeyConditionExpression": Key("work_order_id").eq(work_order_id),
|
||||||
|
"Limit": limit,
|
||||||
|
}
|
||||||
|
if cursor:
|
||||||
|
start_key = decode_cursor(cursor, COMMENT_KEY_ATTRS)
|
||||||
|
# Pin the cursor's partition to the path entity: a cursor minted for
|
||||||
|
# WO A must never resume WO B's Query (DynamoDB would reject the
|
||||||
|
# partition mismatch as a 500; reject it as a 400 here instead).
|
||||||
|
if start_key["work_order_id"] != work_order_id:
|
||||||
|
raise BadCursor("cursor is not valid")
|
||||||
|
kwargs["ExclusiveStartKey"] = start_key
|
||||||
|
return _page(table.query(**kwargs))
|
||||||
|
|
@ -74,4 +74,8 @@ def is_authenticated(event: dict) -> bool:
|
||||||
presented = auth[7:].strip()
|
presented = auth[7:].strip()
|
||||||
if not presented:
|
if not presented:
|
||||||
return False
|
return False
|
||||||
return hmac.compare_digest(presented, token)
|
# Compare as bytes: hmac.compare_digest raises TypeError on non-ASCII str
|
||||||
|
# operands, which a crafted token (?token=%C3%A9 or a non-ASCII header)
|
||||||
|
# would otherwise turn into an uncaught 500. Bytes always compare in
|
||||||
|
# constant time, so a non-matching token fails closed (401) instead.
|
||||||
|
return hmac.compare_digest(presented.encode("utf-8"), token.encode("utf-8"))
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ addopts =
|
||||||
--cov=lambdas/po/web_ui
|
--cov=lambdas/po/web_ui
|
||||||
--cov=lambdas/wo/web_ui
|
--cov=lambdas/wo/web_ui
|
||||||
--cov=lambdas/po/site_extractor
|
--cov=lambdas/po/site_extractor
|
||||||
|
--cov=lambdas/api
|
||||||
--cov=lambdas/shared
|
--cov=lambdas/shared
|
||||||
--cov-report=term-missing
|
--cov-report=term-missing
|
||||||
--cov-fail-under=80
|
--cov-fail-under=80
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REGION="us-east-1"
|
REGION="us-east-1"
|
||||||
FUNCTION_NAMES=("po-email-processor" "workorder-email-processor")
|
FUNCTION_NAMES=("po-email-processor" "workorder-email-processor" "procurement-api")
|
||||||
HEALTHCHECK_PAYLOAD='{"healthcheck": true}'
|
HEALTHCHECK_PAYLOAD='{"healthcheck": true}'
|
||||||
|
|
||||||
work_dir="$(mktemp -d)"
|
work_dir="$(mktemp -d)"
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,15 @@ _SIBLING_MODULES = (
|
||||||
"email_parsing",
|
"email_parsing",
|
||||||
"emf",
|
"emf",
|
||||||
"web_ui_auth",
|
"web_ui_auth",
|
||||||
|
# procurement-api siblings (lambdas/api/): pagination/serialization/router
|
||||||
|
# have no sibling deps; wo_repo/po_repo import pagination, so they follow
|
||||||
|
# it. These names exist only under lambdas/api/, so the po/wo handler
|
||||||
|
# loads skip them via the exists() check.
|
||||||
|
"pagination",
|
||||||
|
"serialization",
|
||||||
|
"router",
|
||||||
|
"wo_repo",
|
||||||
|
"po_repo",
|
||||||
"prompts",
|
"prompts",
|
||||||
"template_parser",
|
"template_parser",
|
||||||
"derived_fields",
|
"derived_fields",
|
||||||
|
|
|
||||||
271
tests/test_api_handlers.py
Normal file
271
tests/test_api_handlers.py
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
"""procurement-api handler tests (offline, fake Dynamo).
|
||||||
|
|
||||||
|
Pins the auth seam both ways: docs routes fail closed on the shared token
|
||||||
|
(including the browser ?token= shim), while data routes must NOT consult the
|
||||||
|
token at all -- SigV4 authorization is API Gateway's job upstream, and a
|
||||||
|
handler-side token check there would break every legitimate SigV4 caller.
|
||||||
|
Also pins routing, 404/501/400 mapping, Decimal-safe serialization, and limit
|
||||||
|
clamping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.support import load_lambda_module
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTable:
|
||||||
|
def __init__(self, items):
|
||||||
|
self._items = items
|
||||||
|
self.last_kwargs = None
|
||||||
|
|
||||||
|
def scan(self, **kwargs):
|
||||||
|
self.last_kwargs = kwargs
|
||||||
|
return {"Items": list(self._items)}
|
||||||
|
|
||||||
|
def query(self, **kwargs):
|
||||||
|
self.last_kwargs = kwargs
|
||||||
|
return {"Items": list(self._items)}
|
||||||
|
|
||||||
|
def get_item(self, Key): # noqa: N803 (boto3 kwarg name)
|
||||||
|
pk_attr, pk_val = next(iter(Key.items()))
|
||||||
|
for item in self._items:
|
||||||
|
if item.get(pk_attr) == pk_val:
|
||||||
|
return {"Item": item}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDynamo:
|
||||||
|
def __init__(self, tables):
|
||||||
|
self.tables = tables
|
||||||
|
|
||||||
|
def Table(self, name): # noqa: N802 (boto3 method name)
|
||||||
|
return self.tables[name]
|
||||||
|
|
||||||
|
|
||||||
|
def _event(method, resource, path_params=None, qs=None, headers=None):
|
||||||
|
return {
|
||||||
|
"httpMethod": method,
|
||||||
|
"resource": resource,
|
||||||
|
"pathParameters": path_params or {},
|
||||||
|
"queryStringParameters": qs or {},
|
||||||
|
"headers": headers or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def api(monkeypatch):
|
||||||
|
mod = load_lambda_module("api", "handler")
|
||||||
|
wo_items = [
|
||||||
|
{
|
||||||
|
"work_order_id": "11144580730",
|
||||||
|
"wo_status": "assigned",
|
||||||
|
"description": "Dock door 14",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
comment_items = [
|
||||||
|
{
|
||||||
|
"work_order_id": "11144580730",
|
||||||
|
"comment_id": "11144580730#2026-01-01T00:00:00#abc123def456",
|
||||||
|
"text": "Vendor dispatched",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
po_items = [
|
||||||
|
{
|
||||||
|
"po_number": "2D-22030794",
|
||||||
|
"total_amount": Decimal("123.45"),
|
||||||
|
"line_items": [{"quantity": Decimal("5"), "price": Decimal("24.69")}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
site_items = [{"siteCode": "JFK8", "state": "NY", "poCount": Decimal("7")}]
|
||||||
|
|
||||||
|
tables = {
|
||||||
|
mod.wo_repo.WORK_ORDERS_TABLE: _FakeTable(wo_items),
|
||||||
|
mod.wo_repo.COMMENTS_TABLE: _FakeTable(comment_items),
|
||||||
|
mod.po_repo.PO_TABLE: _FakeTable(po_items),
|
||||||
|
mod.po_repo.VERIFIED_SITES_TABLE: _FakeTable(site_items),
|
||||||
|
}
|
||||||
|
fake = _FakeDynamo(tables)
|
||||||
|
monkeypatch.setattr(mod.wo_repo, "dynamodb", fake)
|
||||||
|
monkeypatch.setattr(mod.po_repo, "dynamodb", fake)
|
||||||
|
return mod, fake
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthcheck_short_circuits(api):
|
||||||
|
mod, _ = api
|
||||||
|
assert mod.handler({"healthcheck": True}, None) == {"healthcheck": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_fail_closed_without_token(api):
|
||||||
|
# No WEB_UI_AUTH_TOKEN_SECRET_ARN configured -> web_ui_auth returns None
|
||||||
|
# -> the docs routes must 401, not serve the spec.
|
||||||
|
mod, _ = api
|
||||||
|
for resource in ("/docs", "/openapi.json"):
|
||||||
|
response = mod.handler(_event("GET", resource), None)
|
||||||
|
assert response["statusCode"] == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_served_when_authenticated(api, monkeypatch):
|
||||||
|
mod, _ = api
|
||||||
|
monkeypatch.setattr(mod, "is_authenticated", lambda event: True)
|
||||||
|
docs = mod.handler(_event("GET", "/docs"), None)
|
||||||
|
assert docs["statusCode"] == 200
|
||||||
|
assert docs["headers"]["Content-Type"].startswith("text/html")
|
||||||
|
assert "Procurement Ingest API" in docs["body"]
|
||||||
|
assert mod._SPEC_PLACEHOLDER not in docs["body"]
|
||||||
|
|
||||||
|
spec = mod.handler(_event("GET", "/openapi.json"), None)
|
||||||
|
assert spec["statusCode"] == 200
|
||||||
|
assert json.loads(spec["body"])["openapi"] == "3.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_query_shim_synthesizes_header(api, monkeypatch):
|
||||||
|
mod, _ = api
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def capture(event):
|
||||||
|
seen.update(event.get("headers") or {})
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "is_authenticated", capture)
|
||||||
|
original = _event("GET", "/docs", qs={"token": "sekrit"})
|
||||||
|
mod.handler(original, None)
|
||||||
|
assert seen.get("x-auth-token") == "sekrit"
|
||||||
|
# The shim must not mutate the caller's event in place.
|
||||||
|
assert "x-auth-token" not in original["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_ascii_token_fails_closed_not_500(api):
|
||||||
|
# A non-ASCII presented token must 401 (fail closed), not crash
|
||||||
|
# hmac.compare_digest into a 500 that pages the 5xx alarm. Reach the
|
||||||
|
# web_ui_auth module namespace through the imported is_authenticated's
|
||||||
|
# globals and seed a valid cached token.
|
||||||
|
mod, _ = api
|
||||||
|
auth_globals = mod.is_authenticated.__globals__
|
||||||
|
auth_globals["_auth_token_cache"] = "correct-token"
|
||||||
|
auth_globals["_auth_token_cached_at"] = float("inf")
|
||||||
|
try:
|
||||||
|
response = mod.handler(
|
||||||
|
_event("GET", "/docs", headers={"x-auth-token": "é"}), None
|
||||||
|
)
|
||||||
|
assert response["statusCode"] == 401
|
||||||
|
finally:
|
||||||
|
auth_globals["_auth_token_cache"] = None
|
||||||
|
auth_globals["_auth_token_cached_at"] = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_routes_never_consult_docs_token(api, monkeypatch):
|
||||||
|
mod, _ = api
|
||||||
|
|
||||||
|
def explode(event):
|
||||||
|
raise AssertionError("data routes must not consult the docs token")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "is_authenticated", explode)
|
||||||
|
response = mod.handler(_event("GET", "/work-orders"), None)
|
||||||
|
assert response["statusCode"] == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_and_get_routing(api):
|
||||||
|
mod, _ = api
|
||||||
|
listing = mod.handler(_event("GET", "/work-orders"), None)
|
||||||
|
body = json.loads(listing["body"])
|
||||||
|
assert body["items"][0]["work_order_id"] == "11144580730"
|
||||||
|
assert body["next_cursor"] is None
|
||||||
|
|
||||||
|
hit = mod.handler(
|
||||||
|
_event(
|
||||||
|
"GET",
|
||||||
|
"/work-orders/{workOrderId}",
|
||||||
|
path_params={"workOrderId": "11144580730"},
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert json.loads(hit["body"])["wo_status"] == "assigned"
|
||||||
|
|
||||||
|
miss = mod.handler(
|
||||||
|
_event(
|
||||||
|
"GET",
|
||||||
|
"/work-orders/{workOrderId}",
|
||||||
|
path_params={"workOrderId": "999"},
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert miss["statusCode"] == 404
|
||||||
|
|
||||||
|
comments = mod.handler(
|
||||||
|
_event(
|
||||||
|
"GET",
|
||||||
|
"/work-orders/{workOrderId}/comments",
|
||||||
|
path_params={"workOrderId": "11144580730"},
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert json.loads(comments["body"])["items"][0]["text"] == "Vendor dispatched"
|
||||||
|
|
||||||
|
sites = mod.handler(
|
||||||
|
_event(
|
||||||
|
"GET",
|
||||||
|
"/verified-sites/{siteCode}",
|
||||||
|
path_params={"siteCode": "JFK8"},
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert json.loads(sites["body"])["state"] == "NY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decimal_serialization_round_trips(api):
|
||||||
|
mod, _ = api
|
||||||
|
response = mod.handler(
|
||||||
|
_event(
|
||||||
|
"GET",
|
||||||
|
"/purchase-orders/{poNumber}",
|
||||||
|
path_params={"poNumber": "2D-22030794"},
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
body = json.loads(response["body"])
|
||||||
|
assert body["total_amount"] == 123.45
|
||||||
|
assert body["line_items"][0]["quantity"] == 5
|
||||||
|
assert isinstance(body["line_items"][0]["quantity"], int)
|
||||||
|
assert "Decimal" not in response["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_clamped_to_bounds(api):
|
||||||
|
mod, fake = api
|
||||||
|
mod.handler(_event("GET", "/work-orders", qs={"limit": "9999"}), None)
|
||||||
|
assert fake.tables[mod.wo_repo.WORK_ORDERS_TABLE].last_kwargs["Limit"] == 500
|
||||||
|
mod.handler(_event("GET", "/work-orders", qs={"limit": "0"}), None)
|
||||||
|
assert fake.tables[mod.wo_repo.WORK_ORDERS_TABLE].last_kwargs["Limit"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_integer_limit_is_400(api):
|
||||||
|
mod, _ = api
|
||||||
|
response = mod.handler(_event("GET", "/work-orders", qs={"limit": "abc"}), None)
|
||||||
|
assert response["statusCode"] == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_planned_routes_return_501(api):
|
||||||
|
mod, _ = api
|
||||||
|
for method, resource in mod.PLANNED_ROUTES:
|
||||||
|
response = mod.handler(_event(method, resource), None)
|
||||||
|
assert response["statusCode"] == 501, (method, resource)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_route_is_404(api):
|
||||||
|
mod, _ = api
|
||||||
|
response = mod.handler(_event("GET", "/nope"), None)
|
||||||
|
assert response["statusCode"] == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_error_maps_to_clean_500(api, monkeypatch):
|
||||||
|
mod, fake = api
|
||||||
|
|
||||||
|
def boom(**kwargs):
|
||||||
|
raise RuntimeError("dynamo fell over")
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake.tables[mod.wo_repo.WORK_ORDERS_TABLE], "scan", boom)
|
||||||
|
response = mod.handler(_event("GET", "/work-orders"), None)
|
||||||
|
assert response["statusCode"] == 500
|
||||||
|
assert json.loads(response["body"]) == {"error": "internal error"}
|
||||||
232
tests/test_api_pagination_moto.py
Normal file
232
tests/test_api_pagination_moto.py
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
"""Cursor pagination against real DynamoDB semantics (moto).
|
||||||
|
|
||||||
|
The offline handler tests fake Dynamo, which can't exercise real
|
||||||
|
LastEvaluatedKey behavior. Here moto-backed tables with the production key
|
||||||
|
schemas prove: pages are disjoint and complete, the final page's next_cursor
|
||||||
|
is null, comment Query pagination respects the composite key, and hostile
|
||||||
|
cursors (garbage, wrong key attrs, huge) map to 400 -- never 500, never a raw
|
||||||
|
ExclusiveStartKey error.
|
||||||
|
|
||||||
|
The repo modules build their boto3 resource at import time; a resource created
|
||||||
|
before mock_aws starts is NOT intercepted, so each test rebinds the modules'
|
||||||
|
``dynamodb`` to a fresh resource inside the mock (same trap as test_po_merge's
|
||||||
|
moto-before-handler ordering).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import pytest
|
||||||
|
from moto import mock_aws
|
||||||
|
|
||||||
|
from tests.support import load_lambda_module
|
||||||
|
|
||||||
|
|
||||||
|
def _event(method, resource, path_params=None, qs=None):
|
||||||
|
return {
|
||||||
|
"httpMethod": method,
|
||||||
|
"resource": resource,
|
||||||
|
"pathParameters": path_params or {},
|
||||||
|
"queryStringParameters": qs or {},
|
||||||
|
"headers": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def api(monkeypatch):
|
||||||
|
with mock_aws():
|
||||||
|
mod = load_lambda_module("api", "handler")
|
||||||
|
resource = boto3.resource("dynamodb", region_name="us-east-1")
|
||||||
|
monkeypatch.setattr(mod.wo_repo, "dynamodb", resource)
|
||||||
|
monkeypatch.setattr(mod.po_repo, "dynamodb", resource)
|
||||||
|
|
||||||
|
resource.create_table(
|
||||||
|
TableName=mod.wo_repo.WORK_ORDERS_TABLE,
|
||||||
|
KeySchema=[{"AttributeName": "work_order_id", "KeyType": "HASH"}],
|
||||||
|
AttributeDefinitions=[
|
||||||
|
{"AttributeName": "work_order_id", "AttributeType": "S"}
|
||||||
|
],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
resource.create_table(
|
||||||
|
TableName=mod.wo_repo.COMMENTS_TABLE,
|
||||||
|
KeySchema=[
|
||||||
|
{"AttributeName": "work_order_id", "KeyType": "HASH"},
|
||||||
|
{"AttributeName": "comment_id", "KeyType": "RANGE"},
|
||||||
|
],
|
||||||
|
AttributeDefinitions=[
|
||||||
|
{"AttributeName": "work_order_id", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "comment_id", "AttributeType": "S"},
|
||||||
|
],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
resource.create_table(
|
||||||
|
TableName=mod.po_repo.PO_TABLE,
|
||||||
|
KeySchema=[{"AttributeName": "po_number", "KeyType": "HASH"}],
|
||||||
|
AttributeDefinitions=[{"AttributeName": "po_number", "AttributeType": "S"}],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
resource.create_table(
|
||||||
|
TableName=mod.po_repo.VERIFIED_SITES_TABLE,
|
||||||
|
KeySchema=[{"AttributeName": "siteCode", "KeyType": "HASH"}],
|
||||||
|
AttributeDefinitions=[{"AttributeName": "siteCode", "AttributeType": "S"}],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
yield mod, resource
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_pages(mod, resource_path, qs_extra=None, path_params=None, limit=5):
|
||||||
|
seen, pages = [], 0
|
||||||
|
cursor = None
|
||||||
|
while True:
|
||||||
|
qs = {"limit": str(limit), **(qs_extra or {})}
|
||||||
|
if cursor:
|
||||||
|
qs["cursor"] = cursor
|
||||||
|
response = mod.handler(
|
||||||
|
_event("GET", resource_path, path_params=path_params, qs=qs), None
|
||||||
|
)
|
||||||
|
assert response["statusCode"] == 200
|
||||||
|
body = json.loads(response["body"])
|
||||||
|
seen.extend(body["items"])
|
||||||
|
pages += 1
|
||||||
|
cursor = body["next_cursor"]
|
||||||
|
assert pages < 50, "pagination failed to terminate"
|
||||||
|
if cursor is None:
|
||||||
|
return seen, pages
|
||||||
|
|
||||||
|
|
||||||
|
def test_work_order_scan_pages_are_disjoint_and_complete(api):
|
||||||
|
mod, resource = api
|
||||||
|
table = resource.Table(mod.wo_repo.WORK_ORDERS_TABLE)
|
||||||
|
for i in range(12):
|
||||||
|
table.put_item(Item={"work_order_id": str(10000 + i), "wo_status": "new"})
|
||||||
|
|
||||||
|
items, pages = _walk_pages(mod, "/work-orders", limit=5)
|
||||||
|
ids = [item["work_order_id"] for item in items]
|
||||||
|
assert len(ids) == 12
|
||||||
|
assert len(set(ids)) == 12
|
||||||
|
assert pages >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_query_pagination_composite_key(api):
|
||||||
|
mod, resource = api
|
||||||
|
table = resource.Table(mod.wo_repo.COMMENTS_TABLE)
|
||||||
|
for i in range(7):
|
||||||
|
table.put_item(
|
||||||
|
Item={
|
||||||
|
"work_order_id": "555",
|
||||||
|
"comment_id": f"555#2026-01-0{i + 1}T00:00:00#{i:012d}",
|
||||||
|
"text": f"comment {i}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# A second work order's comments must never bleed into the page.
|
||||||
|
table.put_item(
|
||||||
|
Item={"work_order_id": "666", "comment_id": "666#x#0", "text": "other"}
|
||||||
|
)
|
||||||
|
|
||||||
|
items, _ = _walk_pages(
|
||||||
|
mod,
|
||||||
|
"/work-orders/{workOrderId}/comments",
|
||||||
|
path_params={"workOrderId": "555"},
|
||||||
|
limit=3,
|
||||||
|
)
|
||||||
|
assert len(items) == 7
|
||||||
|
assert {item["work_order_id"] for item in items} == {"555"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_purchase_order_and_site_pagination(api):
|
||||||
|
mod, resource = api
|
||||||
|
po_table = resource.Table(mod.po_repo.PO_TABLE)
|
||||||
|
for i in range(6):
|
||||||
|
po_table.put_item(Item={"po_number": f"2D-{i:08d}"})
|
||||||
|
sites_table = resource.Table(mod.po_repo.VERIFIED_SITES_TABLE)
|
||||||
|
for code in ("JFK8", "WNY2", "UVA5"):
|
||||||
|
sites_table.put_item(Item={"siteCode": code})
|
||||||
|
|
||||||
|
po_items, _ = _walk_pages(mod, "/purchase-orders", limit=4)
|
||||||
|
assert len(po_items) == 6
|
||||||
|
site_items, _ = _walk_pages(mod, "/verified-sites", limit=2)
|
||||||
|
assert {item["siteCode"] for item in site_items} == {"JFK8", "WNY2", "UVA5"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cursor",
|
||||||
|
[
|
||||||
|
"not-base64!!!",
|
||||||
|
"aGVsbG8=", # base64("hello") -- not JSON-dict
|
||||||
|
"e30=", # base64("{}") -- empty dict
|
||||||
|
# base64 of {"evil_attr": "x"} -- key attr not allowed for this table
|
||||||
|
"eyJldmlsX2F0dHIiOiAieCJ9",
|
||||||
|
"A" * 3000, # oversized
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_hostile_cursor_is_400_not_500(api, cursor):
|
||||||
|
mod, resource = api
|
||||||
|
resource.Table(mod.wo_repo.WORK_ORDERS_TABLE).put_item(Item={"work_order_id": "1"})
|
||||||
|
response = mod.handler(_event("GET", "/work-orders", qs={"cursor": cursor}), None)
|
||||||
|
assert response["statusCode"] == 400
|
||||||
|
assert "error" in json.loads(response["body"])
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor(payload):
|
||||||
|
return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_composite_cursor_is_400_not_500(api):
|
||||||
|
# A work-orders-shaped cursor {work_order_id} is a partial key for the
|
||||||
|
# comments Query (needs work_order_id+comment_id). Exact-key match rejects
|
||||||
|
# it as 400 rather than letting DynamoDB ValidationException -> 500.
|
||||||
|
mod, resource = api
|
||||||
|
resource.Table(mod.wo_repo.COMMENTS_TABLE).put_item(
|
||||||
|
Item={"work_order_id": "555", "comment_id": "555#x#0"}
|
||||||
|
)
|
||||||
|
response = mod.handler(
|
||||||
|
_event(
|
||||||
|
"GET",
|
||||||
|
"/work-orders/{workOrderId}/comments",
|
||||||
|
path_params={"workOrderId": "555"},
|
||||||
|
qs={"cursor": _cursor({"work_order_id": "555"})},
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert response["statusCode"] == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_work_order_comment_cursor_is_400(api):
|
||||||
|
# A full comments cursor minted for WO A must not resume WO B's Query.
|
||||||
|
mod, resource = api
|
||||||
|
response = mod.handler(
|
||||||
|
_event(
|
||||||
|
"GET",
|
||||||
|
"/work-orders/{workOrderId}/comments",
|
||||||
|
path_params={"workOrderId": "B"},
|
||||||
|
qs={"cursor": _cursor({"work_order_id": "A", "comment_id": "A#x#0"})},
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert response["statusCode"] == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamodb_validation_error_maps_to_400(api, monkeypatch):
|
||||||
|
# Defense in depth: even if some crafted-but-valid cursor reached DynamoDB
|
||||||
|
# and raised ValidationException, the handler maps it to 400, not a 500
|
||||||
|
# that would page the zero-threshold 5xx alarm.
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
mod, _ = api
|
||||||
|
|
||||||
|
class _RaisingTable:
|
||||||
|
def scan(self, **kwargs):
|
||||||
|
raise ClientError(
|
||||||
|
{"Error": {"Code": "ValidationException", "Message": "bad key"}},
|
||||||
|
"Scan",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mod.wo_repo,
|
||||||
|
"dynamodb",
|
||||||
|
type("D", (), {"Table": lambda self, n: _RaisingTable()})(),
|
||||||
|
)
|
||||||
|
response = mod.handler(_event("GET", "/work-orders"), None)
|
||||||
|
assert response["statusCode"] == 400
|
||||||
114
tests/test_api_spec_drift.py
Normal file
114
tests/test_api_spec_drift.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Spec <-> implementation drift gate.
|
||||||
|
|
||||||
|
lambdas/api/openapi.json is the published contract; lambdas/api/router.py is
|
||||||
|
what the Lambda actually serves. This test makes them the SAME set: an
|
||||||
|
endpoint added/removed/renamed on one side without the other fails CI here,
|
||||||
|
so the docs page can never silently lie. Also pins the phase-2 x-planned
|
||||||
|
markers, the four outbound webhook events, and the enum values the spec
|
||||||
|
promises against the WO extraction contract (prompts.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tests.support import REPO_ROOT, load_lambda_module
|
||||||
|
|
||||||
|
_SPEC_PATH = Path(REPO_ROOT) / "lambdas" / "api" / "openapi.json"
|
||||||
|
|
||||||
|
_HTTP_METHODS = {"get", "put", "post", "patch", "delete", "head", "options"}
|
||||||
|
|
||||||
|
|
||||||
|
def _spec():
|
||||||
|
return json.loads(_SPEC_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_routes(spec):
|
||||||
|
implemented, planned = set(), set()
|
||||||
|
for path, ops in spec["paths"].items():
|
||||||
|
for method, op in ops.items():
|
||||||
|
if method not in _HTTP_METHODS:
|
||||||
|
continue
|
||||||
|
key = (method.upper(), path)
|
||||||
|
if op.get("x-planned"):
|
||||||
|
planned.add(key)
|
||||||
|
else:
|
||||||
|
implemented.add(key)
|
||||||
|
return implemented, planned
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_is_openapi_31():
|
||||||
|
assert _spec()["openapi"] == "3.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_implemented_routes_match_router_exactly():
|
||||||
|
router = load_lambda_module("api", "router")
|
||||||
|
implemented, planned = _spec_routes(_spec())
|
||||||
|
assert implemented == set(router.DATA_ROUTES) | set(router.DOCS_ROUTES)
|
||||||
|
assert planned == set(router.PLANNED_ROUTES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_data_route_has_a_repo_function():
|
||||||
|
router = load_lambda_module("api", "router")
|
||||||
|
handler = load_lambda_module("api", "handler")
|
||||||
|
assert set(router.DATA_ROUTES.values()) == set(handler._REPO_FUNCS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhooks_section_documents_all_four_events():
|
||||||
|
spec = _spec()
|
||||||
|
assert set(spec["webhooks"]) == {
|
||||||
|
"work_order.created",
|
||||||
|
"work_order.updated",
|
||||||
|
"work_order.cancelled",
|
||||||
|
"work_order.comment_added",
|
||||||
|
}
|
||||||
|
for ops in spec["webhooks"].values():
|
||||||
|
assert "post" in ops
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_enums_match_wo_extraction_contract():
|
||||||
|
# The WO pipeline's prompts.py is the enum source of truth (the webhook
|
||||||
|
# contract pins it too). If the pipeline ever widens wo_status or
|
||||||
|
# record_type, the published spec must move in the same PR.
|
||||||
|
prompts = load_lambda_module("wo", "email_processor/prompts")
|
||||||
|
prompt_text = prompts.EXTRACTION_PROMPT
|
||||||
|
spec = _spec()
|
||||||
|
schemas = spec["components"]["schemas"]
|
||||||
|
|
||||||
|
wo_status_enum = {
|
||||||
|
value
|
||||||
|
for value in schemas["WorkOrder"]["properties"]["wo_status"]["enum"]
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
record_type_enum = {
|
||||||
|
value
|
||||||
|
for value in schemas["WorkOrder"]["properties"]["record_type"]["enum"]
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
for value in wo_status_enum - {"unknown"}:
|
||||||
|
assert value in prompt_text, f"wo_status {value!r} not in extraction prompt"
|
||||||
|
for value in record_type_enum:
|
||||||
|
assert value in prompt_text, f"record_type {value!r} not in extraction prompt"
|
||||||
|
|
||||||
|
event_data = schemas["WorkOrderEventData"]["properties"]
|
||||||
|
assert set(event_data["wo_status"]["enum"]) == set(
|
||||||
|
schemas["WorkOrder"]["properties"]["wo_status"]["enum"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_has_no_script_breakout_sequence():
|
||||||
|
# The spec is inlined into a <script type="application/json"> block on the
|
||||||
|
# docs page. The handler escapes "<" -> < defensively, but keep the
|
||||||
|
# committed spec itself clean so the raw /openapi.json is also breakout-safe
|
||||||
|
# and a reviewer sees the invariant here.
|
||||||
|
raw = _SPEC_PATH.read_text(encoding="utf-8")
|
||||||
|
assert "</" not in raw and "<!--" not in raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_and_spec_files_ship_with_the_handler():
|
||||||
|
# The handler serves these from its own package dir; if either file is
|
||||||
|
# missing from lambdas/api/ the bundle would 500 at runtime.
|
||||||
|
api_dir = Path(REPO_ROOT) / "lambdas" / "api"
|
||||||
|
assert (api_dir / "openapi.json").is_file()
|
||||||
|
assert (api_dir / "docs.html").is_file()
|
||||||
|
docs = (api_dir / "docs.html").read_text(encoding="utf-8")
|
||||||
|
assert "__OPENAPI_SPEC_JSON__" in docs
|
||||||
|
|
@ -21,8 +21,10 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
PO_HANDLER = REPO_ROOT / "lambdas" / "po" / "email_processor" / "handler.py"
|
PO_HANDLER = REPO_ROOT / "lambdas" / "po" / "email_processor" / "handler.py"
|
||||||
WO_HANDLER = REPO_ROOT / "lambdas" / "wo" / "email_processor" / "handler.py"
|
WO_HANDLER = REPO_ROOT / "lambdas" / "wo" / "email_processor" / "handler.py"
|
||||||
|
API_HANDLER = REPO_ROOT / "lambdas" / "api" / "handler.py"
|
||||||
PO_STACK = REPO_ROOT / "cdk" / "po_stack.py"
|
PO_STACK = REPO_ROOT / "cdk" / "po_stack.py"
|
||||||
WO_STACK = REPO_ROOT / "cdk" / "wo_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
|
# 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`.
|
# shipped into the email-processor bundles via `cp shared/*.py`.
|
||||||
SHARED_DIR = REPO_ROOT / "lambdas" / "shared"
|
SHARED_DIR = REPO_ROOT / "lambdas" / "shared"
|
||||||
|
|
@ -90,6 +92,28 @@ WO_PIPELINE_MODULES = frozenset(
|
||||||
PO_EXPECTED_TOP_LEVEL_MODULES = PO_PIPELINE_MODULES | SHARED_MODULES
|
PO_EXPECTED_TOP_LEVEL_MODULES = PO_PIPELINE_MODULES | SHARED_MODULES
|
||||||
WO_EXPECTED_TOP_LEVEL_MODULES = WO_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 and docs page from its own package dir, so dropping either 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|$)",
|
||||||
|
)
|
||||||
|
|
||||||
# Pipeline-scoped glob shapes the per-stack ships-all pins accept. Phase 2
|
# 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
|
# 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
|
# glob carries its own pipeline's path prefix (`po/email_processor/*.py`); a
|
||||||
|
|
@ -652,3 +676,93 @@ def test_detection_logic_rejects_commented_out_web_ui_auth_cp():
|
||||||
)
|
)
|
||||||
executed = _executed_cp_commands(commented_out_command)
|
executed = _executed_cp_commands(commented_out_command)
|
||||||
assert not any(re.search(WEB_UI_AUTH_CP_RE, cp) for cp in executed)
|
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}"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -105,16 +105,12 @@ def test_header_matrix(load_auth, monkeypatch, headers, expected):
|
||||||
assert mod.is_authenticated(_evt(**headers)) is expected
|
assert mod.is_authenticated(_evt(**headers)) is expected
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(
|
|
||||||
strict=True,
|
|
||||||
reason="web_ui_auth is frozen this phase: is_authenticated raises TypeError on "
|
|
||||||
"a non-ASCII presented token (hmac.compare_digest is ASCII-only) and surfaces "
|
|
||||||
"as a 500 instead of failing closed to False. Asserting the DESIRED behavior "
|
|
||||||
"under xfail(strict) documents the intended fix and auto-fails (xpass) the moment "
|
|
||||||
"the module is corrected, forcing the marker's removal. Tracked as a follow-up.",
|
|
||||||
raises=TypeError,
|
|
||||||
)
|
|
||||||
def test_non_ascii_token_should_fail_closed(load_auth, monkeypatch):
|
def test_non_ascii_token_should_fail_closed(load_auth, monkeypatch):
|
||||||
|
# Fixed 2026-07-23 (procurement-api security review): is_authenticated now
|
||||||
|
# compares tokens as bytes, so a non-ASCII presented token fails closed to
|
||||||
|
# False instead of raising TypeError (which surfaced as a 500 that would
|
||||||
|
# page the API's zero-threshold 5xx alarm). Was xfail(strict) while
|
||||||
|
# web_ui_auth was frozen; the fix landed with the shared bytes-compare.
|
||||||
mod = load_auth()
|
mod = load_auth()
|
||||||
monkeypatch.setattr(mod, "_get_auth_token", lambda: "SECRET")
|
monkeypatch.setattr(mod, "_get_auth_token", lambda: "SECRET")
|
||||||
assert mod.is_authenticated(_evt(**{"x-auth-token": "SÉCRET"})) is False
|
assert mod.is_authenticated(_evt(**{"x-auth-token": "SÉCRET"})) is False
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue