Compare commits

...

25 commits
v1.0.0 ... main

Author SHA1 Message Date
renovate[bot]
b39c8b27ea
chore(deps): update terraform aws to v6.67.0 (#228)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 16:22:40 +00:00
renovate[bot]
f2ef1df881
chore(deps): update npm minor and patch (#227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 16:22:21 +00:00
renovate[bot]
2fe7133411
chore(deps): update pip minor and patch (#226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 16:21:26 +00:00
dependabot[bot]
cfa14c4240
chore(deps-dev): bump brace-expansion from 5.0.9 to 5.0.12 in the npm_and_yarn group across 1 directory (#225)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
* chore(deps-dev): bump brace-expansion

Bumps the npm_and_yarn group with 1 update in the / directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `brace-expansion` from 5.0.9 to 5.0.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.9...v5.0.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: update org workflow SHA pins to latest version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-10-02 22:06:10 +00:00
renovate[bot]
3bd98aad8d
chore(deps): update dependency pyjwt to v2.15.0 [security] (#224)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-01 16:30:27 +00:00
renovate[bot]
7943207e97
chore(deps): update dependency prettier to v3.9.9 (#222)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-28 16:05:21 +00:00
renovate[bot]
c15ea8cee1
chore(deps): update sea-haven-industries/.github action to v1.0.19 (#223)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-28 16:05:18 +00:00
Adam Moussa
69359df69d
docs(agents): drop security review gates (#221)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
Agents no longer treat a security review or a cross-family review as a merge gate.
2026-09-26 17:17:35 -04:00
Adam Moussa
bf1f3ea182
feat(cart): add a local Redefine cart filler (#220)
* feat(cart): add a local Redefine cart filler

Match the admin order-list CSV to the live menu and add each meal to a guest cart so the weekly bulk order does not have to be typed in by hand.

* fix(cart): report cart failures without a traceback

A closed window or a failed cart request during the fill now prints a short failure and the Added/Failed/Skipped summary instead of crashing.

* fix(cart): fail cleanly on a bad CSV and a closed browser

A non-UTF-8 order list and a Playwright error while opening the page now print a short message and exit 1 instead of a traceback.

* fix(cart): account for a closed window and an empty menu

A closed page during add marks the remaining meals as not attempted, and an empty menu catalog raises the same error as the menu parser.
2026-09-26 20:36:38 +00:00
Adam Moussa
cc506f3c1f
feat(menu): publish the weekly menu from the job worker (PLAT-229) (#219)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
* feat(menu): publish the weekly menu from the job worker

Monday publish parses the catalog embedded in the Redefine menu page and runs on the Fargate worker, so the GitHub Actions scrape cron can go away.

* fix(menu): address review feedback

Use the form deadline in the Monday Slack post, and compare that message exactly so CodeQL does not treat the test as URL sanitization.
2026-09-25 22:10:24 +00:00
renovate[bot]
517e404e75
chore(deps): update dependency gunicorn to v26 (#212)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-25 20:25:51 +00:00
renovate[bot]
aa15277112
chore(deps): update npm minor and patch (#210)
* chore(deps): update npm minor and patch

* test(openapi): stop pinning the Redocly patch version

Renovate minor bumps fail CI when the contract test hardcodes @redocly/cli.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-09-25 20:20:05 +00:00
Adam Moussa
4d1c79b110
ci: pin org reusable workflows to v1.0.16 (#218)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
Pick up the autofix commit that links formatter commits to the bot account.
2026-09-24 15:46:19 +00:00
Adam Moussa
fdf595ea19
ci(workflows): use autofix formatter presets (PLAT-222) (#216)
* ci(workflows): use autofix formatter presets (PLAT-222)

Point autofix at the presets commit so ruff and Terraform fmt use the
org presets, and write the template Prettier and ESLint fixes CI checks.

* style: add an unused import for autofix

Deliberate F401 in week_keys so the ruff preset can remove it.

* ci(workflows): pin autofix to v1.0.15 (PLAT-222)

The previous pin was the pre-squash commit, which became unreachable
when that branch was deleted, so CI failed before autofix started.

* style: apply formatter

---------

Co-authored-by: sea-haven-auto-fix[bot] <5037331+sea-haven-auto-fix[bot]@users.noreply.github.com>
2026-09-24 00:05:36 +00:00
Adam Moussa
f7957436bd
fix(iam): drop githubdeploy workflow_ref OIDC condition (PLAT-222) (#215)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
* fix(iam): drop githubdeploy workflow_ref OIDC condition

AWS STS does not evaluate GitHub workflow_ref, so that trust condition fail-closes AssumeRoleWithWebIdentity.

* chore(security): retarget githubdeploy Checkov suppression

Dropping the workflow_ref trust condition shifted CKV_AWS_111 from line 49 to 42. Permissions are unchanged.

* style: apply formatter

---------

Co-authored-by: sea-haven-auto-fix[bot] <5037331+sea-haven-auto-fix[bot]@users.noreply.github.com>
2026-09-22 21:30:25 +00:00
Adam Moussa
f632020b20
ci: convert onto org HCP reusables (PLAT-222) (#214)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
* ci: convert onto org HCP reusables

Switch Fargate CD and CI to the v1.0.13 org workflows, emit ci-complete, and retarget githubdeploy OIDC to the reusable plus the thin caller.

* chore(security): retarget githubdeploy Checkov suppression

The OIDC dual-claim edit shifted CKV_AWS_111 from line 40 to 49. Permissions are unchanged.

* style: apply formatter

* ci: pin org reusables to v1.0.14

Drop collect-only and requirements from the python lint caller now that ci-python-app is lint-only.

* test(ci): probe autofix with a ruff format violation

* style: apply formatter

---------

Co-authored-by: sea-haven-auto-fix[bot] <5037331+sea-haven-auto-fix[bot]@users.noreply.github.com>
2026-09-22 20:01:17 +00:00
Adam Moussa
7574fc471a
chore(ci): remove unused Mergify stub (#213)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
2026-09-22 18:41:55 +00:00
renovate[bot]
63b31fcdc3
chore(deps): update terraform random to v3.9.1 (#211)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-22 14:33:14 +00:00
renovate[bot]
449cc10b9f
chore(deps): update dependency boto3 to v1.43.98 (#208)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-22 14:31:22 +00:00
renovate[bot]
78f6d1dbe4
chore(deps): update aws-actions/configure-aws-credentials action to v6.3.0 (#209)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-22 14:31:10 +00:00
renovate[bot]
77780899c2
chore(deps): pin python docker tag to 2f17fc0 (#207)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-22 14:24:50 +00:00
Adam Moussa
d7ad49d00f
feat(api): add OpenAPI Redocly contract and VPC outputs (DEV-289) (#206)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
* feat(infra): export attached VPC ids and lock prod to afterhours (DEV-289)

Prod must keep existing_vpc_id pointed at the afterhours VPC. Outputs
expose the resolved vpc_id and public subnet IDs.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* feat(api): add OpenAPI 3.1 and Redocly lint in CI (DEV-289)

Same extends: recommended ruleset and @redocly/cli 2.52.1 as
internal-portal. Documents current { error: string } JSON errors.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(api): document 4xx and reject invalid form-status weeks (DEV-289)

Health, form-status, and roster document 400. form-status now maps
current and returns 400 for a week that is not current or YYYY-WNN.
Redocly treats 302 as a success response, matching the portal.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* style(test): format VPC contract assertions for ruff (DEV-289)

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(api): fail Redocly on missing 4xx and 2xx/3xx (DEV-289)

Promote operation-4xx-response and the 2xx-or-3xx success rule to error.
Replace unused health and roster 400s with 403, matching portal health.
Form-status keeps its real 400 for invalid week.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(api): split week params and allow live menu nulls (DEV-289)

Menu and form-status take current or YYYY-WNN. Orders take YYYY-WNN or a
calendar date and reject current. Menu payloads may emit null menu_url,
calories, protein, and image_url.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(infra): fail prod apply without the afterhours VPC (DEV-289)

Prod never creates the 10.60 fallback VPC. A terraform_data precondition
fails plan and apply when existing_vpc_id is empty, instead of a check
block that only warns.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-09-22 00:33:48 +00:00
Adam Moussa
fb3a181e0c
feat(api): add flask sentry sdk (#205)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
Initialize the SDK on gunicorn and the SQS worker with afterhours-style scrubbing. Store the DSN in SSM and inject only the parameter name onto the live task.
2026-09-21 23:48:09 +00:00
Adam Moussa
596e949eef
fix(form): keep Google sign-in across page refresh (#204)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
2026-09-21 22:50:07 +00:00
Adam Moussa
3efff5e784
fix(infra): share the afterhours VPC in prod (PLAT-215) (#203)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
* fix(infra): share the afterhours VPC when existing_vpc_id is set

Prod is at the account VPC quota, so the v1.0.0 apply destroyed Lambda/API Gateway then failed on CreateVpc. Skip creating a sixth VPC when the workspace supplies afterhours subnets.

* style(test): format the VPC terraform assertion for ruff
2026-09-21 21:15:47 +00:00
59 changed files with 3235 additions and 967 deletions

View file

@ -1,32 +0,0 @@
name: Terraform CI
on:
pull_request:
branches: [main]
paths:
- "terraform/**"
- ".github/workflows/ci-terraform.yaml"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.9.8"
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate

View file

@ -1,21 +1,38 @@
name: CI
on:
pull_request:
branches: [main]
branches: [main, hotfix/**, release/**]
merge_group:
push:
branches: [hotfix/**, release/**]
permissions:
contents: read
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
autofix:
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
permissions:
contents: write
secrets: inherit
with:
presets: ruff,terraform
terraform-version: "1.9.8"
node-version: "24.19.0"
format-command: npm run format:templates:write
lint-fix-command: npx eslint "src/server/templates/*.js" --fix
lint:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
with:
python-version: "3.12.14"
requirements: "requirements-api.txt"
collect-only: false
template-js:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@ -34,7 +51,12 @@ jobs:
- name: Check template JavaScript
run: npm run check:templates
- name: Lint OpenAPI
run: npm run openapi:lint
test:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@ -60,3 +82,30 @@ jobs:
- name: Run tests
run: pytest
terraform:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
with:
terraform-version: "1.9.8"
ci-complete:
name: ci-complete
needs: [autofix, lint, template-js, test, terraform]
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require portions
env:
LINT: ${{ needs.lint.result }}
TEMPLATE_JS: ${{ needs.template-js.result }}
TEST: ${{ needs.test.result }}
TERRAFORM: ${{ needs.terraform.result }}
run: |
set -euo pipefail
test "${LINT}" = success
test "${TEMPLATE_JS}" = success
test "${TEST}" = success
test "${TERRAFORM}" = success

View file

@ -7,4 +7,4 @@ permissions:
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21

View file

@ -1,8 +1,8 @@
name: Deploy API
# Fargate image CD (PLAT-215). GitHub Actions builds the Flask image, pushes
# to ECR, and registers a new task definition. Terraform owns the cluster,
# service, ALB, and ignores container_definitions / task_definition.
# Fargate image CD. GitHub Actions builds the Flask image, pushes to ECR,
# and registers a new task definition. Terraform owns the cluster, service,
# ALB, and ignores container_definitions / task_definition.
#
# push to main -> dev, at github.sha
# release: published -> prod, at the release tag
@ -18,9 +18,7 @@ on:
- "terraform/**"
- "docs/**"
- "*.md"
- ".github/workflows/weekly-menu.yml"
- ".github/workflows/ci.yml"
- ".github/workflows/ci-terraform.yaml"
release:
types: [published]
workflow_dispatch:
@ -40,198 +38,33 @@ permissions:
contents: read
jobs:
target:
name: Resolve target
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
environment: ${{ steps.resolve.outputs.environment }}
ref: ${{ steps.resolve.outputs.ref }}
steps:
- id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
GITHUB_REF_NAME_IN: ${{ github.ref }}
GITHUB_SHA_IN: ${{ github.sha }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
INPUT_ENVIRONMENT: ${{ inputs.environment }}
INPUT_REF: ${{ inputs.ref }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
push)
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
echo "push deploys only run from main" >&2
exit 1
fi
environment=dev
ref="${GITHUB_SHA_IN}"
;;
release)
environment=prod
ref="${RELEASE_TAG}"
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
exit 1
fi
;;
workflow_dispatch)
environment="${INPUT_ENVIRONMENT}"
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
;;
*)
echo "unsupported event ${EVENT_NAME}" >&2
exit 1
;;
esac
{
echo "environment=${environment}"
echo "ref=${ref}"
} >> "${GITHUB_OUTPUT}"
echo "Deploying ${ref} to ${environment}"
deploy:
name: Deploy API to ${{ needs.target.outputs.environment }}
needs: target
runs-on: ubuntu-latest
timeout-minutes: 30
environment: ${{ needs.target.outputs.environment }}
concurrency:
group: deploy-api-${{ needs.target.outputs.environment }}
cancel-in-progress: false
deploy-dev:
name: Deploy API to dev
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
permissions:
contents: read
id-token: write
env:
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.target.outputs.ref }}
persist-credentials: false
secrets: inherit
with:
environment: dev
ref: ${{ inputs.ref }}
ssm-prefix: /meal-order-manager/deploy
docker-platform: linux/amd64
extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}'
- name: Resolve commit
id: commit
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "Building ${sha}"
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Get deploy parameters
id: deploy
run: |
set -euo pipefail
get_param() {
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
}
CLUSTER=$(get_param /meal-order-manager/deploy/cluster)
SERVICE=$(get_param /meal-order-manager/deploy/service)
FAMILY=$(get_param /meal-order-manager/deploy/task-family)
ECR=$(get_param /meal-order-manager/deploy/ecr-repository)
CONTAINER=$(get_param /meal-order-manager/deploy/container-name)
API_URL=$(get_param /meal-order-manager/deploy/api-url)
{
echo "cluster=${CLUSTER}"
echo "service=${SERVICE}"
echo "family=${FAMILY}"
echo "ecr=${ECR}"
echo "container=${CONTAINER}"
echo "api_url=${API_URL}"
} >> "${GITHUB_OUTPUT}"
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
- name: Build and push image
env:
ECR: ${{ steps.deploy.outputs.ecr }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
ENVIRONMENT: ${{ needs.target.outputs.environment }}
run: |
set -euo pipefail
docker build \
--build-arg "GIT_SHA=${GIT_SHA}" \
-t "${ECR}:${GIT_SHA}" \
-t "${ECR}:${ENVIRONMENT}" \
.
docker push "${ECR}:${GIT_SHA}"
docker push "${ECR}:${ENVIRONMENT}"
- name: Register task definition and update service
env:
CLUSTER: ${{ steps.deploy.outputs.cluster }}
SERVICE: ${{ steps.deploy.outputs.service }}
FAMILY: ${{ steps.deploy.outputs.family }}
CONTAINER: ${{ steps.deploy.outputs.container }}
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
aws ecs describe-task-definition \
--task-definition "${FAMILY}" \
--query taskDefinition \
--output json \
| python3 -c '
import json, os, sys
td = json.load(sys.stdin)
for key in (
"taskDefinitionArn",
"revision",
"status",
"requiresAttributes",
"compatibilities",
"registeredAt",
"registeredBy",
"deregisteredAt",
):
td.pop(key, None)
image = os.environ["IMAGE"]
sha = os.environ["GIT_SHA"]
name = os.environ["CONTAINER"]
for container in td["containerDefinitions"]:
if container["name"] != name:
continue
container["image"] = image
env = {item["name"]: item["value"] for item in container.get("environment", [])}
env["GIT_SHA"] = sha
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
container.pop("command", None)
json.dump(td, sys.stdout)
' > /tmp/task-def.json
REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
aws ecs update-service \
--cluster "${CLUSTER}" \
--service "${SERVICE}" \
--task-definition "${FAMILY}:${REV}" \
--force-new-deployment \
>/dev/null
aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
- name: Verify health SHA
env:
API_URL: ${{ steps.deploy.outputs.api_url }}
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
for _ in 1 2 3 4 5 6; do
BODY="$(curl -fsS "${API_URL}/api/health" || true)"
echo "${BODY}"
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
exit 0
fi
sleep 10
done
echo "health SHA did not match ${EXPECTED_SHA}" >&2
exit 1
deploy-prod:
name: Deploy API to prod
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
permissions:
contents: read
id-token: write
secrets: inherit
with:
environment: prod
ref: ${{ github.event.release.tag_name || inputs.ref }}
ssm-prefix: /meal-order-manager/deploy
docker-platform: linux/amd64
ship-gate: true
extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}'

View file

@ -10,4 +10,4 @@ permissions:
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21

View file

@ -1,179 +0,0 @@
name: Weekly Menu Scrape & Publish
on:
schedule:
# Monday 7:30am EST = 12:30 UTC
- cron: '30 12 * * 1'
# Monday 7:30am EDT = 11:30 UTC
- cron: '30 11 * * 1'
workflow_dispatch:
permissions:
id-token: write
contents: read
concurrency:
group: weekly-menu
cancel-in-progress: false
jobs:
scrape-and-publish:
runs-on: ubuntu-latest
# A hung Playwright scrape would otherwise hold the weekly-menu concurrency
# group for the 360-minute default.
timeout-minutes: 30
env:
AWS_REGION: us-east-1
steps:
- name: Timezone guard
if: github.event_name == 'schedule'
env:
CRON: ${{ github.event.schedule }}
run: |
OFFSET=$(TZ='America/New_York' date +%z)
echo "Cron: $CRON | Eastern offset: $OFFSET"
if { [ "$OFFSET" = "-0400" ] && [ "$CRON" = "30 12 * * 1" ]; } || \
{ [ "$OFFSET" = "-0500" ] && [ "$CRON" = "30 11 * * 1" ]; }; then
echo "Wrong-timezone cron fired — skipping"
echo "SKIP_RUN=true" >> "$GITHUB_ENV"
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: env.SKIP_RUN != 'true'
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
if: env.SKIP_RUN != 'true'
with:
python-version: '3.12.14'
- name: Install dependencies
if: env.SKIP_RUN != 'true'
run: |
pip install -r requirements.txt
playwright install chromium --with-deps
- name: Configure AWS credentials
if: env.SKIP_RUN != 'true'
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: ${{ secrets.AWS_WEEKLY_MENU_ROLE_ARN }}
aws-region: us-east-1
- name: Scrape menu
if: env.SKIP_RUN != 'true'
run: python3 src/scraper/scrape_menu.py
# Deploy targets come from Parameter Store, written by Terraform
# (terraform/ssm.tf). They replace the CloudFormation stack outputs this
# job used to read; there is no CloudFormation stack any more.
- name: Get deploy parameters
if: env.SKIP_RUN != 'true'
id: stack
run: |
set -euo pipefail
get_param() {
aws ssm get-parameter --name "$1" --query 'Parameter.Value' --output text
}
API_URL=$(get_param /meal-order-manager/deploy/api-url)
FORM_BUCKET=$(get_param /meal-order-manager/deploy/form-bucket)
DIST_ID=$(get_param /meal-order-manager/deploy/distribution-id)
FORM_URL=$(get_param /meal-order-manager/deploy/form-url)
for v in "$API_URL" "$FORM_BUCKET" "$DIST_ID" "$FORM_URL"; do
if [ -z "$v" ] || [ "$v" = "None" ]; then
echo "A /meal-order-manager/deploy/* parameter is missing; has Terraform been applied?" >&2
exit 1
fi
done
echo "api_url=$API_URL" >> "$GITHUB_OUTPUT"
echo "form_bucket=$FORM_BUCKET" >> "$GITHUB_OUTPUT"
echo "dist_id=$DIST_ID" >> "$GITHUB_OUTPUT"
echo "form_url=$FORM_URL" >> "$GITHUB_OUTPUT"
- name: Get discount settings
if: env.SKIP_RUN != 'true'
id: discount
env:
API_URL: ${{ steps.stack.outputs.api_url }}
run: |
set -euo pipefail
MEALS_PUBLISH_KEY=$(aws ssm get-parameter \
--name /meal-order-manager/publish-key \
--with-decryption \
--query 'Parameter.Value' \
--output text)
SETTINGS=$(MEALS_PUBLISH_KEY="$MEALS_PUBLISH_KEY" python3 scripts/upload_menu.py settings --api-url "$API_URL")
BULK=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["bulk_discount_percent"])' "$SETTINGS")
SUBSIDY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["company_subsidy_percent"])' "$SETTINGS")
echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT"
echo "company_subsidy=$SUBSIDY" >> "$GITHUB_OUTPUT"
- name: Get Google Client ID
if: env.SKIP_RUN != 'true'
id: google
run: |
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
--name /meal-order-manager/google-client-id \
--query 'Parameter.Value' \
--output text)
if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then
echo "Google client ID is required for cloud form generation" >&2
exit 1
fi
echo "client_id=$GOOGLE_CLIENT_ID" >> "$GITHUB_OUTPUT"
- name: Generate order form
if: env.SKIP_RUN != 'true'
env:
BULK_DISCOUNT: ${{ steps.discount.outputs.bulk_discount }}
COMPANY_SUBSIDY: ${{ steps.discount.outputs.company_subsidy }}
GOOGLE_CLIENT_ID: ${{ steps.google.outputs.client_id }}
run: |
# Relative /api paths so the form stays same-origin on CloudFront
# after the ALB origin swap. Do not bake the ALB DNS into HTML.
python3 src/server/generate_form.py \
--bulk-discount "$BULK_DISCOUNT" \
--company-subsidy "$COMPANY_SUBSIDY" \
--google-client-id "$GOOGLE_CLIENT_ID"
- name: Publish menu through API
if: env.SKIP_RUN != 'true'
env:
API_URL: ${{ steps.stack.outputs.api_url }}
run: |
set -euo pipefail
MEALS_PUBLISH_KEY=$(aws ssm get-parameter \
--name /meal-order-manager/publish-key \
--with-decryption \
--query 'Parameter.Value' \
--output text)
MEALS_PUBLISH_KEY="$MEALS_PUBLISH_KEY" python3 scripts/upload_menu.py publish --api-url "$API_URL"
- name: Upload form to S3
if: env.SKIP_RUN != 'true'
env:
FORM_BUCKET: ${{ steps.stack.outputs.form_bucket }}
run: |
WEEK=$(date +%Y-W%U)
aws s3 cp "output/order-form-$WEEK.html" \
"s3://${FORM_BUCKET}/index.html" \
--content-type "text/html" \
--cache-control "no-cache"
aws s3 cp "output/order-form-$WEEK.html" \
"s3://${FORM_BUCKET}/archive/$WEEK.html" \
--content-type "text/html"
- name: Invalidate CloudFront cache
if: env.SKIP_RUN != 'true'
env:
DIST_ID: ${{ steps.stack.outputs.dist_id }}
run: |
aws cloudfront create-invalidation \
--distribution-id "$DIST_ID" \
--paths "/index.html"
- name: Notify Slack
if: env.SKIP_RUN != 'true'
env:
FORM_URL: ${{ steps.stack.outputs.form_url }}
run: python3 scripts/notify_slack.py "$FORM_URL"

View file

@ -1 +0,0 @@
extends: .github

32
.redocly.yaml Normal file
View file

@ -0,0 +1,32 @@
# Same Redocly recommended ruleset as internal-portal (DEV-223 / DEV-289).
# Recommended operation-2xx-response does not count 302. Login-style redirects
# succeed with 302, so that rule is replaced by operation-2xx-or-3xx-response
# at error. operation-4xx-response is promoted to error so missing 4xx fails CI.
extends:
- recommended
rules:
operation-2xx-response: off
operation-4xx-response: error
rule/operation-2xx-or-3xx-response:
subject:
type: Responses
message: Operation must define a 2XX or 3XX response.
severity: error
assertions:
requireAny:
- "200"
- "201"
- "202"
- "204"
- "301"
- "302"
- "303"
- "307"
- "308"
- "2XX"
- "3XX"
apis:
meals@v1:
root: openapi.yaml

View file

@ -9,8 +9,8 @@
"justification": "The meals API ALB is the CloudFront HTTP origin for orders.seahaven.com. TLS and WAF terminate at CloudFront. Restricting the security group to the CloudFront managed prefix list would block GitHub-hosted weekly-menu HMAC publish, which must call the origin with X-Meals-Publish-Key. Application gates are HMAC on /api/publish, Cognito or Google Bearer on admin, and public submit only. Accepted as the HTTP-origin design for PLAT-215; TLS on the ALB is a follow-up."
},
{
"id": "checkov-CKV_AWS_111-40",
"justification": "githubdeploy-meal-order-manager needs ecr:GetAuthorizationToken and ecs:RegisterTaskDefinition, both of which AWS documents as Resource *. Trust is pinned to environment:dev or environment:prod plus job_workflow_ref on deploy-api.yaml at refs/heads/main or refs/tags/v*. iam:PassRole is limited to the ECS task and execution roles. ECR push and SSM parameter reads are already resource-scoped."
"id": "checkov-CKV_AWS_111-42",
"justification": "LINE SHIFT ONLY: dropping the workflow_ref trust condition shifts github_deploy from 49 to 42. The permission document is unchanged. Original justification: githubdeploy-meal-order-manager needs ecr:GetAuthorizationToken and ecs:RegisterTaskDefinition, both of which AWS documents as Resource *. Trust is pinned to environment:dev or environment:prod and job_workflow_ref on org cd-hcp-fargate.yaml@*. iam:PassRole is limited to the ECS task and execution roles. ECR push and SSM parameter reads are already resource-scoped."
}
]
}

View file

@ -12,10 +12,6 @@ Use one of: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `rele
- **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty.
- State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers.
## Security and Cross-Review
- Sensitive surfaces (payment flows, authentication, secrets handling, untrusted input) require security review.
- IAM role, policy, or resource-permission changes require cross-family review. Lambda handler signature changes alone do not.
## CI and Workflow References
- CI must pass before merge.
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.

View file

@ -1,4 +1,4 @@
FROM python:3.12-slim
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
WORKDIR /app
COPY src/shared/requirements.txt /tmp/shared-requirements.txt

View file

@ -10,26 +10,21 @@ Automates weekly meal ordering from [Redefine Meals](https://www.redefinemeals.c
## Architecture
```
Monday 7:30am ET Employees (Mon–Thu) Thursday 11:59pm ET
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ GitHub Actions │ │ orders.seahaven │ │ EventBridge │
│ - Scrape menu │────S3 upload───▶│ .com │ │ Scheduler (ET) │
│ - HMAC publish │ │ (CloudFront+S3) │──POST───┐ │ → jobs SQS │
│ - Slack notify │ └──────────────────┘ │ └─────────┬────────┘
└─────────────────┘ ▼ │
┌──────────┐ │
Thu 10am: Slack DM │ ALB + │◀──────────┘
reminders to employees │ Fargate │
who haven't ordered │ Flask │
└────┬─────┘
▼
┌──────────┐
│ DynamoDB │
│ orders │
└──────────┘
EventBridge Scheduler (America/New_York) Employees
┌──────────────────────────────────────┐ ┌────────────────────┐
│ Mon 6:55 roster, Mon 7:30 menu │ │ orders.seahaven.com│
│ Thu 10:00 reminder, Thu 23:59 close │ │ CloudFront + S3 │
└──────────────────┬───────────────────┘ └─────────┬──────────┘
│ jobs SQS │ POST /api
▼ ▼
┌──────────────────────────────────────────────────┐
│ Fargate: Flask + SQS worker │
│ menu → DynamoDB │
│ form HTML → S3, then invalidate /index.html │
└──────────────────────────────────────────────────┘
```
The production HTTP app is `src/server/app.py` (gunicorn). Close, aggregate/PDF, Slack reminder, and roster sync run in the same task from a dedicated SQS consumer (`src/server/worker.py`). Playwright scrape stays in GitHub Actions.
The production HTTP app is `src/server/app.py` (gunicorn). Menu publish, close, aggregate/PDF, Slack reminder, and roster sync run in the same task from a dedicated SQS consumer (`src/server/worker.py`). Menu publish fetches the Redefine HTML catalog. It does not run a browser.
### Form frontend decisions
@ -45,21 +40,21 @@ the generated HTML, and the generated deployment artifact remains self-contained
| When | What | How |
|------|------|-----|
| Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge Scheduler → jobs SQS → Fargate |
| Monday 7:30am ET | Scrape menu, generate form, HMAC-publish menu, upload form to S3, post link to Slack | GitHub Actions cron |
| Monday 7:30am ET | Fetch menu, generate form, write menu, upload form to S3, invalidate CloudFront, post link to Slack | EventBridge Scheduler → jobs SQS |
| Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 form → CloudFront `/api/*` → ALB → Flask → DynamoDB |
| Thursday 10am ET | DM employees who haven't ordered yet | EventBridge Scheduler → jobs SQS |
| Thursday 11:59pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge Scheduler → jobs SQS |
### Weekly menu publication boundary
### Weekly menu publication
The scheduled GitHub workflow has no DynamoDB permissions. It sends two HMAC
requests with `X-Meals-Publish-Key` from Parameter Store to the ALB:
Monday 7:30am Eastern, EventBridge Scheduler enqueues `publish_menu`. The Fargate worker fetches the Redefine menu HTML, parses the embedded catalog, writes that Eastern-time week's menu to DynamoDB, renders the form, uploads it to the form bucket, invalidates `/index.html`, and posts to Slack.
`scripts/upload_menu.py` remains a manual HMAC fallback for one production Monday:
- `GET /api/publish/settings` returns only the bulk discount and company subsidy.
- `POST /api/publish/menu` validates and writes the current Eastern-time week's menu.
Publish routes are omitted from CloudFront. The generated form uses relative
`/api/...` paths so it stays same-origin on `orders.seahaven.com`.
Publish routes are omitted from CloudFront. The generated form uses relative `/api/...` paths so it stays same-origin on `orders.seahaven.com`.
### Reports (written to `meal-order-manager-reports-*` at Thursday close)
@ -74,12 +69,14 @@ Publish routes are omitted from CloudFront. The generated form uses relative
Workspace: `meal-order-manager-prod` / `meal-order-manager-dev` (us-east-1)
- **ECS Fargate** — Flask + gunicorn + SQS job consumer. Desired count 2 in prod, 1 in dev.
- **VPC** — Prod attaches to the After Hours VPC (`existing_vpc_id` / `existing_public_subnet_ids` from afterhours-shift-manager outputs). The 10.60 CIDR is unused fallback.
- **HTTP contract** — `openapi.yaml`, linted in CI with `npm run openapi:lint` (Redocly `extends: recommended`, same as internal-portal and afterhours-shift-manager).
- **ALB** — origin for CloudFront `/api` behaviors and weekly-menu HMAC publish. Idle timeout 120s.
- **ECR** — API image. GitHub Actions `deploy-api.yaml` owns the image; Terraform ignores `container_definitions`.
- **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports + weekly summary PDF)
- **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com`. API origin is the ALB (HTTP-only).
- **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config)
- **SQS** — `meal-order-manager-jobs` (+ DLQ). EventBridge Scheduler in `America/New_York` enqueues close, reminder, and roster sync.
- **SQS** — `meal-order-manager-jobs` (+ DLQ). EventBridge Scheduler in `America/New_York` enqueues menu publish, close, reminder, and roster sync.
- **Secrets Manager** — Slack bot token
- **CloudWatch Alarms** — ALB 5xx, ECS CPU, jobs DLQ, DynamoDB throttles, all notifying `site-alerts`
- **HCP Terraform** — workspace `meal-order-manager-<env>` in project `seahaven-<env>`. Working directory `terraform/`. VCS file triggers should be `terraform/**` only after the image deploy workflow owns `src/`. Do not `terraform apply` locally to prod.
@ -141,10 +138,11 @@ Local `:5050` is the same Flask app as production. DynamoDB is used when AWS cre
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt -r requirements-api.txt
playwright install chromium
PYTHONPATH=src:src/shared python3 -m server.app
```
Form tests and `src/scraper/recon.py` need `playwright install chromium`. Menu publish does not.
### Deploy to AWS
Image deploys are GitHub Actions `deploy-api.yaml` (push to `main` → dev, GitHub Release → prod). Infrastructure applies through HCP Terraform. First apply of the new `tf-managed` IAM policies needs the hcptf-bootstrap window.
@ -184,14 +182,14 @@ python3 src/aggregator/aggregate.py # generate CSV reports
```
meal-order-manager/
├── .github/workflows/
│ ├── weekly-menu.yml # Monday cron: scrape + HMAC publish + notify
│ ├── deploy-api.yaml # Image CD to Fargate
│ ├── ci.yml # PR checks
│ └── ci-terraform.yaml # terraform fmt / validate
│ └── ci.yml # PR checks (lint, pytest, template JS, terraform, ci-complete)
├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue)
├── openapi.yaml # Employee HTTP contract (Redocly recommended)
├── .redocly.yaml
├── Dockerfile
├── src/
│ ├── scraper/ # Playwright menu scraper
│ ├── scraper/ # HTML menu parser (recon scripts still use Playwright)
│ ├── server/ # Flask API, form generator, job handlers
│ ├── aggregator/ # Order aggregation + CSV reports
│ └── shared/shared/ # db, secrets, slack, pdf helpers

665
openapi.yaml Normal file
View file

@ -0,0 +1,665 @@
openapi: 3.1.0
info:
title: Meal Order Manager
version: 0.1.0
description: >
Flask HTTP API on ECS Fargate behind orders.seahaven.com. The internal
portal SPA calls menu, submit, and admin with a Cognito ID token from
GET /api/auth/meals-token. Weekly-menu GitHub Actions uses HMAC publish
routes that CloudFront does not expose. JSON errors are currently
`{ error: string }`. Health matches the portal BFF `{ stage, sha }`.
Lint with the same Redocly `extends: recommended` config as
internal-portal and afterhours-shift-manager.
contact:
name: Sea Haven Engineering
license:
name: Proprietary
identifier: LicenseRef-SeaHaven
servers:
- url: /
description: orders.seahaven.com CloudFront / local Flask :5050
tags:
- name: Runtime
description: Unauthenticated health
- name: Menu
description: Public weekly menu and form status
- name: Orders
description: Employee submit and own-order lookup
- name: Admin
description: Admin order edit and summary PDF
- name: Publish
description: Weekly-menu HMAC publish (not on CloudFront)
paths:
/api/health:
get:
operationId: getHealth
tags: [Runtime]
summary: Runtime health
security: []
responses:
"200":
description: Process is up
content:
application/json:
schema:
$ref: "#/components/schemas/Health"
"403":
$ref: "#/components/responses/StringError"
/api/menu/{week}:
get:
operationId: getMenu
tags: [Menu]
summary: Published menu for a week
security: []
parameters:
- $ref: "#/components/parameters/MenuWeekPath"
responses:
"200":
description: Menu payload
content:
application/json:
schema:
$ref: "#/components/schemas/MenuPayload"
"400":
$ref: "#/components/responses/StringError"
"404":
$ref: "#/components/responses/StringError"
/api/form-status/{week}:
get:
operationId: getFormStatus
tags: [Menu]
summary: Open or closed for a week
security: []
parameters:
- $ref: "#/components/parameters/MenuWeekPath"
responses:
"200":
description: Form status
content:
application/json:
schema:
$ref: "#/components/schemas/FormStatus"
"400":
$ref: "#/components/responses/StringError"
/api/submit-order:
post:
operationId: submitOrder
tags: [Orders]
summary: Place or replace this week's order
security:
- portalCognito: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SubmitBody"
responses:
"200":
description: Order saved
content:
application/json:
schema:
$ref: "#/components/schemas/SubmitResult"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
"404":
$ref: "#/components/responses/StringError"
"410":
$ref: "#/components/responses/StringError"
"429":
$ref: "#/components/responses/StringError"
"503":
$ref: "#/components/responses/StringError"
/api/orders/{week}:
get:
operationId: getMyOrder
tags: [Orders]
summary: Caller's order only
security:
- portalCognito: []
parameters:
- $ref: "#/components/parameters/OrderWeekPath"
responses:
"200":
description: Empty list when the caller has no order
content:
application/json:
schema:
$ref: "#/components/schemas/MyOrders"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
"503":
$ref: "#/components/responses/StringError"
/api/admin/orders:
get:
operationId: adminListOrders
tags: [Admin]
summary: List weeks or one week's orders
security:
- portalCognito: []
parameters:
- name: week
in: query
schema:
type: string
responses:
"200":
description: Weeks list or week detail
content:
application/json:
schema:
$ref: "#/components/schemas/AdminOrdersResponse"
"403":
$ref: "#/components/responses/StringError"
put:
operationId: adminUpdateOrder
tags: [Admin]
summary: Recalculate and replace an order
security:
- portalCognito: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AdminUpdateBody"
responses:
"200":
description: Updated
content:
application/json:
schema:
$ref: "#/components/schemas/AdminMutation"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
"404":
$ref: "#/components/responses/StringError"
"503":
$ref: "#/components/responses/StringError"
delete:
operationId: adminDeleteOrder
tags: [Admin]
summary: Delete an order
security:
- portalCognito: []
parameters:
- name: week
in: query
required: true
schema:
type: string
- name: email
in: query
required: true
schema:
type: string
responses:
"200":
description: Deleted
content:
application/json:
schema:
$ref: "#/components/schemas/AdminMutation"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
"404":
$ref: "#/components/responses/StringError"
/api/admin/summary-pdf:
get:
operationId: adminSummaryPdf
tags: [Admin]
summary: Presigned summary PDF URL
security:
- portalCognito: []
parameters:
- name: week
in: query
required: true
schema:
type: string
responses:
"200":
description: Short-lived URL
content:
application/json:
schema:
$ref: "#/components/schemas/SummaryPdf"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
"404":
$ref: "#/components/responses/StringError"
"500":
$ref: "#/components/responses/StringError"
/api/roster:
get:
operationId: getRoster
tags: [Orders]
summary: Name and email list used by the static form
security: []
responses:
"200":
description: Roster
content:
application/json:
schema:
$ref: "#/components/schemas/FormRoster"
"403":
$ref: "#/components/responses/StringError"
/api/publish/settings:
get:
operationId: publishSettings
tags: [Publish]
summary: Bulk discount and subsidy for scrape
security:
- publishKey: []
responses:
"200":
description: Pricing fields only
content:
application/json:
schema:
$ref: "#/components/schemas/PublishSettings"
"403":
$ref: "#/components/responses/StringError"
/api/publish/menu:
post:
operationId: publishMenu
tags: [Publish]
summary: Write this week's scraped menu
security:
- publishKey: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishMenuBody"
responses:
"200":
description: Published
content:
application/json:
schema:
$ref: "#/components/schemas/PublishMenuResult"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
components:
securitySchemes:
portalCognito:
type: http
scheme: bearer
bearerFormat: JWT
description: Portal Cognito ID token. GIS google_id_token is also accepted on submit until cutover.
publishKey:
type: apiKey
in: header
name: X-Meals-Publish-Key
parameters:
MenuWeekPath:
name: week
in: path
required: true
description: "`current` or `YYYY-WNN`. Other values are 400."
schema:
type: string
minLength: 1
OrderWeekPath:
name: week
in: path
required: true
description: "`YYYY-WNN` or `YYYY-MM-DD`. `current` is 400."
schema:
type: string
minLength: 1
responses:
StringError:
description: Current meals JSON error
content:
application/json:
schema:
$ref: "#/components/schemas/StringErrorBody"
schemas:
Health:
type: object
additionalProperties: false
required: [stage, sha]
properties:
stage:
type: string
minLength: 1
description: Workspace stage (`dev`, `prod`, or `local`)
sha:
type: string
minLength: 1
description: Git SHA or `unknown` locally
StringErrorBody:
type: object
additionalProperties: false
required: [error]
properties:
error:
type: string
minLength: 1
Meal:
type: object
additionalProperties: true
required: [name, price]
properties:
name:
type: string
price:
type: number
calories:
type: [string, number, "null"]
protein:
type: [string, number, "null"]
description:
type: [string, "null"]
dietary_tags:
type: [array, "null"]
items:
type: string
image_url:
type: [string, "null"]
is_new:
type: boolean
MenuPayload:
type: object
additionalProperties: false
required:
- week
- form_status
- meal_count
- meals
- bulk_discount_percent
- company_subsidy_percent
properties:
week:
type: string
form_status:
type: string
scraped_at:
type: [string, "null"]
menu_url:
type: [string, "null"]
meal_count:
type: integer
meals:
type: array
items:
$ref: "#/components/schemas/Meal"
bulk_discount_percent:
type: number
company_subsidy_percent:
type: number
order_deadline:
type: string
FormStatus:
type: object
additionalProperties: false
required: [week, status]
properties:
week:
type: string
status:
type: string
reopen_at:
type: integer
SubmitItem:
type: object
additionalProperties: true
required: [name, quantity]
properties:
name:
type: string
quantity:
type: number
retail_price:
type: number
SubmitBody:
type: object
additionalProperties: true
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/SubmitItem"
google_id_token:
type: string
SubmitResult:
type: object
additionalProperties: false
required: [status]
properties:
status:
type: string
message:
type: string
total:
type: number
MyOrders:
type: object
additionalProperties: false
required: [week, orders]
properties:
week:
type: string
orders:
type: array
items:
type: object
additionalProperties: false
required: [employee_email]
properties:
employee_email:
type: string
AdminWeek:
type: object
additionalProperties: true
required: [week]
properties:
week:
type: string
form_status:
type: string
meal_count:
type: integer
order_count:
type: integer
AdminOrderItem:
type: object
additionalProperties: false
required: [name, quantity, retail_price, price, subtotal]
properties:
name:
type: string
quantity:
type: integer
retail_price:
type: number
price:
type: number
subtotal:
type: number
AdminOrder:
type: object
additionalProperties: false
required: [employee_name, employee_email, items, total, submitted_at]
properties:
employee_name:
type: string
employee_email:
type: string
items:
type: array
items:
$ref: "#/components/schemas/AdminOrderItem"
total:
type: number
submitted_at:
type: string
AdminWeeks:
type: object
additionalProperties: false
required: [weeks]
properties:
weeks:
type: array
items:
$ref: "#/components/schemas/AdminWeek"
AdminWeekOrders:
type: object
additionalProperties: false
required: [week, orders, total_employees, grand_total]
properties:
week:
type: string
orders:
type: array
items:
$ref: "#/components/schemas/AdminOrder"
total_employees:
type: integer
grand_total:
type: number
AdminOrdersResponse:
oneOf:
- $ref: "#/components/schemas/AdminWeeks"
- $ref: "#/components/schemas/AdminWeekOrders"
AdminUpdateBody:
type: object
additionalProperties: false
required: [week, email, items]
properties:
week:
type: string
email:
type: string
items:
type: array
items:
$ref: "#/components/schemas/SubmitItem"
AdminMutation:
type: object
additionalProperties: false
required: [status, week]
properties:
status:
type: string
week:
type: string
email:
type: string
total:
type: number
SummaryPdf:
type: object
additionalProperties: false
required: [week, url]
properties:
week:
type: string
url:
type: string
format: uri
FormRoster:
type: object
additionalProperties: false
required: [employees]
properties:
employees:
type: array
items:
type: object
additionalProperties: false
required: [name, email]
properties:
name:
type: string
email:
type: string
PublishSettings:
type: object
additionalProperties: false
required: [bulk_discount_percent, company_subsidy_percent]
properties:
bulk_discount_percent:
type: number
company_subsidy_percent:
type: number
PublishMenuBody:
type: object
additionalProperties: true
required: [meals]
properties:
meals:
type: array
minItems: 1
items:
$ref: "#/components/schemas/Meal"
scraped_at:
type: string
menu_url:
type: string
PublishMenuResult:
type: object
additionalProperties: false
required: [status, week, meal_count]
properties:
status:
type: string
week:
type: string
meal_count:
type: integer

46
package-lock.json generated
View file

@ -9,9 +9,10 @@
"version": "1.0.0",
"devDependencies": {
"@eslint/js": "10.0.1",
"eslint": "10.10.0",
"globals": "17.12.0",
"prettier": "3.9.8"
"@redocly/cli": "2.57.0",
"eslint": "10.11.0",
"globals": "17.13.0",
"prettier": "3.9.9"
}
},
"node_modules/@cacheable/memory": {
@ -256,6 +257,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/cli": {
"version": "2.57.0",
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.57.0.tgz",
"integrity": "sha512-1d5fVyUaYlMNgCHUoyE2vUyxBhs/jmOHgsX/kFmfWzESw4f/G/OV/SU9E55rU7aUNmw9rHj1vXmL6yUdIn+KKQ==",
"dev": true,
"license": "MIT",
"bin": {
"openapi": "bin/cli.js",
"redocly": "bin/cli.js"
},
"engines": {
"node": ">=22.12.0 || >=20.19.0 <21.0.0",
"npm": ">=10"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -328,9 +344,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -408,9 +424,9 @@
}
},
"node_modules/eslint": {
"version": "10.10.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz",
"integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==",
"version": "10.11.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz",
"integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
"dev": true,
"license": "MIT",
"workspaces": [
@ -643,9 +659,9 @@
}
},
"node_modules/globals": {
"version": "17.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
"integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
"version": "17.13.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
"dev": true,
"license": "MIT",
"engines": {
@ -890,9 +906,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"version": "3.9.9",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
"dev": true,
"license": "MIT",
"bin": {

View file

@ -6,12 +6,14 @@
"check:templates": "npm run lint:templates && npm run format:templates",
"format:templates": "prettier --check \"src/server/templates/*.js\"",
"format:templates:write": "prettier --write \"src/server/templates/*.js\"",
"lint:templates": "eslint \"src/server/templates/*.js\""
"lint:templates": "eslint \"src/server/templates/*.js\"",
"openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"eslint": "10.10.0",
"globals": "17.12.0",
"prettier": "3.9.8"
"@redocly/cli": "2.57.0",
"eslint": "10.11.0",
"globals": "17.13.0",
"prettier": "3.9.9"
}
}

View file

@ -1,6 +1,7 @@
flask==3.1.3
gunicorn==23.0.0
boto3==1.43.97
gunicorn==26.2.0
boto3==1.43.107
sentry-sdk==2.71.0
jinja2==3.1.6
fpdf2==2.8.8
PyJWT[crypto]==2.14.0
fpdf2==2.8.9
PyJWT[crypto]==2.15.1

View file

@ -1,4 +1,4 @@
playwright==1.63.0
flask==3.1.3
boto3==1.43.97
boto3==1.43.107
jinja2==3.1.6

View file

@ -0,0 +1,271 @@
"""Fill a Redefine Meals guest cart from an admin order-list CSV.
Opens a visible browser, adds each matched meal, then stops on the cart page.
Log in and check out in that window. Press Enter when finished, or close the
window. The script does not store a password and does not open checkout itself.
python3 scripts/fill_redefine_cart.py order-list-2026-W39.csv
"""
from __future__ import annotations
import argparse
import sys
import threading
from pathlib import Path
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from playwright.sync_api import Error as PlaywrightError # noqa: E402
from playwright.sync_api import sync_playwright # noqa: E402
from scraper.fill_cart import ( # noqa: E402
FillCartError,
FillPlan,
build_plan,
cart_lines,
error_text,
quantity_for,
)
from scraper.parse_menu import ( # noqa: E402
MenuParseError,
extract_catalog_products,
fetch_menu_html,
)
DEFAULT_MENU_URL = "https://www.redefinemeals.com/menu"
_CART_JS = """
async ({ method, path, body }) => {
const headers = {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
};
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/);
if (match) {
headers["X-XSRF-TOKEN"] = decodeURIComponent(match[1]);
}
const init = { method, credentials: "same-origin", headers };
if (body !== null && body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
const response = await fetch(path, init);
const text = await response.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch (error) {
data = { message: text.slice(0, 200) };
}
}
return { ok: response.ok, status: response.status, data };
}
"""
def main(argv: list[str] | None = None) -> int:
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("csv_path", type=Path, help="Admin order-list CSV")
parser.add_argument("--menu-url", default=DEFAULT_MENU_URL)
args = parser.parse_args(argv)
try:
csv_text = args.csv_path.read_text(encoding="utf-8-sig")
menu_html = fetch_menu_html(args.menu_url)
products = extract_catalog_products(menu_html)
plan = build_plan(csv_text, products)
except UnicodeDecodeError:
print("CSV must be UTF-8.", file=sys.stderr)
return 1
except (OSError, FillCartError, MenuParseError) as exc:
print(exc, file=sys.stderr)
return 1
_print_plan(plan)
if not plan.adds:
print("Nothing matched. Not opening a browser.")
return 1 if plan.skipped else 0
return _fill(plan, args.menu_url)
def _fill(plan: FillPlan, menu_url: str) -> int:
with sync_playwright() as playwright:
try:
browser = playwright.chromium.launch(headless=False)
except PlaywrightError as exc:
print(f"Browser failed: {exc}", file=sys.stderr)
print(
"Install the browser with: playwright install chromium",
file=sys.stderr,
)
return 1
return _fill_browser(browser, plan, menu_url)
def _fill_browser(browser, plan: FillPlan, menu_url: str) -> int:
try:
try:
page = browser.new_page()
except PlaywrightError as exc:
print(f"Failed: {exc}")
print(f"Added 0. Failed 1. Skipped {len(plan.skipped)}.")
return 1
return _fill_page(page, plan, menu_url)
finally:
try:
browser.close()
except PlaywrightError:
pass
def _fill_page(page, plan: FillPlan, menu_url: str) -> int:
failed: list[str] = []
added = 0
ready = False
try:
page.goto(menu_url, wait_until="domcontentloaded", timeout=60000)
cart = _cart_request(page, "GET", "/api/cart")
existing = cart_lines(cart)
if existing:
print("The cart already has items:")
for line in existing:
print(f" {line.name} x{line.quantity}")
if not _confirm_clear():
print("Exiting without adding.")
return 1
cleared = _cart_request(page, "POST", "/api/cart/clear", {})
if cart_lines(cleared):
print("The cart still has items after clear. Exiting without adding.")
return 1
posted = []
page_closed = False
for index, item in enumerate(plan.adds):
try:
_cart_request(
page,
"POST",
"/api/cart/add",
{
"uuid": item.uuid,
"quantity": item.quantity,
"properties": None,
},
)
except RuntimeError as exc:
failed.append(f"{item.name}: {exc}")
print(f"Failed: {item.name}: {exc}")
continue
except PlaywrightError as exc:
failed.append(f"{item.name}: {exc}")
print(f"Failed: {item.name}: {exc}")
for rest in plan.adds[index + 1 :]:
message = f"{rest.name}: not attempted"
failed.append(message)
print(f"Failed: {message}")
page_closed = True
break
posted.append(item)
if not page_closed:
try:
current = cart_lines(_cart_request(page, "GET", "/api/cart"))
except RuntimeError as exc:
failed.append(f"Could not read the cart: {exc}")
print(f"Failed: Could not read the cart: {exc}")
else:
for item in posted:
found = quantity_for(current, item.uuid)
if found != item.quantity:
message = (
f"{item.name}: requested {item.quantity}, cart has {found}"
)
failed.append(message)
print(f"Failed: {message}")
else:
added += 1
origin = _origin(page.url or menu_url)
page.goto(f"{origin}/cart", wait_until="domcontentloaded", timeout=60000)
ready = True
except (RuntimeError, PlaywrightError) as exc:
failed.append(str(exc))
print(f"Failed: {exc}")
print(f"Added {added}. Failed {len(failed)}. Skipped {len(plan.skipped)}.")
if ready:
_wait_for_review(page)
return 1 if failed or plan.skipped else 0
def _cart_request(page, method: str, path: str, body: object | None = None) -> object:
result = page.evaluate(_CART_JS, {"method": method, "path": path, "body": body})
if not isinstance(result, dict) or not result.get("ok"):
status = result.get("status") if isinstance(result, dict) else 0
data = result.get("data") if isinstance(result, dict) else None
raise RuntimeError(error_text(int(status or 0), data))
return result.get("data")
def _confirm_clear() -> bool:
if not sys.stdin.isatty():
print("Refusing to clear a cart without a typed yes.")
return False
answer = input("Type yes to clear the cart and add this order: ")
return answer.strip().casefold() == "yes"
def _wait_for_review(page) -> None:
print(
"Cart is ready. Log in and check out in this browser window.\n"
"Press Enter here after you are done. Closing the window also stops the script."
)
finished = threading.Event()
def _wait_for_enter() -> None:
try:
input()
except EOFError:
pass
finished.set()
if sys.stdin.isatty():
threading.Thread(target=_wait_for_enter, daemon=True).start()
while not finished.is_set():
try:
closed = page.is_closed()
except PlaywrightError:
return
if closed:
return
try:
page.wait_for_timeout(250)
except PlaywrightError:
return
def _print_plan(plan: FillPlan) -> None:
if plan.ignored_zero:
print(f"Ignored {plan.ignored_zero} items with quantity 0.")
if plan.adds:
print("Adding:")
for item in plan.adds:
print(f" {item.name} x{item.quantity}")
if plan.skipped:
print("Skipped:")
for line in plan.skipped:
print(f" {line.name}: {line.reason}")
def _origin(url: str) -> str:
parts = urlsplit(url)
return f"{parts.scheme}://{parts.netloc}"
if __name__ == "__main__":
raise SystemExit(main())

0
src/scraper/__init__.py Normal file
View file

203
src/scraper/fill_cart.py Normal file
View file

@ -0,0 +1,203 @@
"""Match an admin order-list CSV to the Redefine menu catalog.
The browser script uses this plan. Nothing here contacts the site.
"""
from __future__ import annotations
import csv
import io
from dataclasses import dataclass
_FORMULA_PREFIX = set("=+-@\t\r")
class FillCartError(ValueError):
"""The order-list CSV cannot be planned."""
@dataclass(frozen=True)
class OrderLine:
name: str
quantity: int
@dataclass(frozen=True)
class PlannedAdd:
name: str
quantity: int
uuid: str
@dataclass(frozen=True)
class SkippedLine:
name: str
reason: str
@dataclass(frozen=True)
class FillPlan:
adds: tuple[PlannedAdd, ...]
skipped: tuple[SkippedLine, ...]
ignored_zero: int
@dataclass(frozen=True)
class CartLine:
name: str
quantity: int
product_uuid: str | None
def build_plan(csv_text: str, products: list) -> FillPlan:
parsed = _parse_order_list(csv_text)
adds, skipped = _match(parsed.lines, products)
return FillPlan(
adds=tuple(adds),
skipped=parsed.skipped + tuple(skipped),
ignored_zero=parsed.ignored_zero,
)
def cart_lines(cart: object) -> list[CartLine]:
if not isinstance(cart, dict):
return []
items = cart.get("items")
if not isinstance(items, list):
return []
lines = []
for item in items:
if not isinstance(item, dict):
continue
product = item.get("product") if isinstance(item.get("product"), dict) else {}
name = str(product.get("name") or item.get("name") or "Unknown item").strip()
product_uuid = product.get("uuid") or item.get("uuid")
lines.append(
CartLine(
name=name or "Unknown item",
quantity=_as_int(item.get("quantity")),
product_uuid=str(product_uuid) if product_uuid else None,
)
)
return lines
def quantity_for(lines: list[CartLine], product_uuid: str) -> int:
return sum(line.quantity for line in lines if line.product_uuid == product_uuid)
def error_text(status: int, data: object) -> str:
"""Short cart-API failure text. Vendor bodies can include stack traces."""
message = ""
if isinstance(data, dict):
raw = data.get("message")
if isinstance(raw, str):
message = " ".join(raw.split())
if message:
return f"HTTP {status}: {message[:200]}"
return f"HTTP {status}"
@dataclass(frozen=True)
class _ParsedCsv:
lines: tuple[OrderLine, ...]
skipped: tuple[SkippedLine, ...]
ignored_zero: int
def _parse_order_list(csv_text: str) -> _ParsedCsv:
text = csv_text.lstrip("\ufeff")
if not text.strip():
raise FillCartError("CSV is empty")
reader = csv.DictReader(io.StringIO(text))
if reader.fieldnames is None:
raise FillCartError("CSV must have Item and Quantity columns")
fields = {(name or "").strip().casefold(): name for name in reader.fieldnames}
if "item" not in fields or "quantity" not in fields:
raise FillCartError("CSV must have Item and Quantity columns")
totals: dict[str, int] = {}
display: dict[str, str] = {}
order: list[str] = []
skipped: list[SkippedLine] = []
ignored_zero = 0
for row in reader:
raw_name = row.get(fields["item"]) or ""
name = _unescape_item(str(raw_name).strip())
raw_qty = str(row.get(fields["quantity"]) or "").strip()
if not name and not raw_qty:
continue
if not name:
skipped.append(SkippedLine("(blank)", "missing a name"))
continue
if not raw_qty.isdigit():
skipped.append(SkippedLine(name, f"invalid quantity {raw_qty!r}"))
continue
quantity = int(raw_qty)
if quantity == 0:
ignored_zero += 1
continue
key = name.casefold()
if key not in totals:
order.append(key)
display[key] = name
totals[key] = 0
totals[key] += quantity
lines = tuple(OrderLine(display[key], totals[key]) for key in order)
return _ParsedCsv(lines=lines, skipped=tuple(skipped), ignored_zero=ignored_zero)
def _unescape_item(name: str) -> str:
if len(name) > 1 and name[0] == "'" and name[1] in _FORMULA_PREFIX:
return name[1:]
return name
def _match(
lines: tuple[OrderLine, ...], products: list
) -> tuple[list[PlannedAdd], list[SkippedLine]]:
available: dict[str, list[tuple[str, str]]] = {}
unavailable: set[str] = set()
for product in products:
if not isinstance(product, dict):
continue
name = str(product.get("name") or "").strip()
if not name:
continue
key = name.casefold()
if product.get("available") is False:
unavailable.add(key)
continue
uuid = str(product.get("uuid") or "").strip()
if not uuid:
continue
available.setdefault(key, []).append((name, uuid))
adds: list[PlannedAdd] = []
skipped: list[SkippedLine] = []
for line in lines:
key = line.name.casefold()
matches = available.get(key, [])
if len(matches) == 1:
adds.append(PlannedAdd(matches[0][0], line.quantity, matches[0][1]))
elif len(matches) > 1:
skipped.append(SkippedLine(line.name, "matches more than one menu item"))
elif key in unavailable:
skipped.append(SkippedLine(line.name, "unavailable on the menu"))
else:
skipped.append(SkippedLine(line.name, "not on the menu"))
return adds, skipped
def _as_int(value: object) -> int:
if isinstance(value, bool) or value is None:
return 0
if isinstance(value, int):
return value
text = str(value).strip()
if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
return int(text)
return 0

176
src/scraper/parse_menu.py Normal file
View file

@ -0,0 +1,176 @@
"""Parse the Redefine Meals menu from the HTML catalog embedded on the page.
The menu document includes an <orders-page> element whose :products and
:newest-ids attributes are JSON. That replaces the Playwright DOM scrape.
"""
from __future__ import annotations
import html
import json
import re
import urllib.error
import urllib.request
from datetime import datetime
from zoneinfo import ZoneInfo
EASTERN = ZoneInfo("America/New_York")
PRODUCTS_MARKER = ":products='"
NEWEST_MARKER = ":newest-ids='"
_TAG_RE = re.compile(r"<[^>]+>")
_SPACE_RE = re.compile(r"\s+")
_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
class MenuParseError(RuntimeError):
"""The menu page did not contain a usable catalog."""
def fetch_menu_html(url: str, *, timeout: float = 30) -> str:
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8", "replace")
except urllib.error.URLError as exc:
raise MenuParseError(f"Failed to fetch {url}") from exc
def fetch_menu(url: str, *, timeout: float = 30) -> dict:
page = fetch_menu_html(url, timeout=timeout)
return parse_menu_html(page, menu_url=url)
def extract_catalog_products(page: str) -> list:
"""Return the raw product objects embedded on the menu page."""
products = _extract_json_attr(page, PRODUCTS_MARKER)
if not isinstance(products, list):
raise MenuParseError(":products must be a JSON array")
if not products:
raise MenuParseError("Menu catalog is empty")
return products
def parse_menu_html(page: str, *, menu_url: str, scraped_at: str | None = None) -> dict:
products = _extract_json_attr(page, PRODUCTS_MARKER)
newest = _extract_json_attr(page, NEWEST_MARKER)
if not isinstance(products, list):
raise MenuParseError(":products must be a JSON array")
if not isinstance(newest, list):
raise MenuParseError(":newest-ids must be a JSON array")
if not products:
raise MenuParseError("Menu catalog is empty")
newest_ids = {str(item) for item in newest}
meals = []
for product in products:
if not isinstance(product, dict):
raise MenuParseError("Each catalog entry must be an object")
if product.get("available") is False:
continue
meals.append(_meal_from_product(product, newest_ids))
if not meals:
raise MenuParseError("Scraped 0 meals")
return {
"scraped_at": scraped_at or datetime.now(EASTERN).isoformat(),
"menu_url": menu_url,
"meal_count": len(meals),
"meals": meals,
}
def _extract_json_attr(page: str, marker: str):
index = page.find(marker)
if index < 0:
raise MenuParseError(f"Menu page is missing {marker}")
raw = html.unescape(page[index + len(marker) :])
try:
value, _end = json.JSONDecoder().raw_decode(raw)
except json.JSONDecodeError as exc:
raise MenuParseError(f"Menu page has invalid JSON after {marker}") from exc
return value
def _meal_from_product(product: dict, newest_ids: set[str]) -> dict:
name = str(product.get("name") or "").strip()
if not name:
raise MenuParseError("A catalog entry is missing a name")
details = product.get("details") if isinstance(product.get("details"), dict) else {}
concerns = product.get("dietary_concerns") or []
if not isinstance(concerns, list):
raise MenuParseError(f"{name} dietary_concerns must be a list")
return {
"name": name,
"price": _price(product),
"calories": _calories(details),
"protein": _protein(details),
"dietary_tags": [str(tag).strip() for tag in concerns if str(tag).strip()],
"image_url": _image_url(product),
"is_new": str(product.get("id")) in newest_ids,
"description": _plain_text(product.get("description")),
}
def _price(product: dict) -> float:
raw = (
product.get("sale_price")
if product.get("on_sale") is True
else product.get("price")
)
name = str(product.get("name") or "meal")
if isinstance(raw, bool) or raw is None or str(raw).strip() == "":
raise MenuParseError(f"{name} is missing a price")
try:
price = float(raw)
except (TypeError, ValueError) as exc:
raise MenuParseError(f"{name} has an invalid price") from exc
if price < 0:
raise MenuParseError(f"{name} has a negative price")
return price
def _calories(details: dict) -> int | None:
raw = details.get("calories")
if raw is None or str(raw).strip() == "":
return None
text = str(raw).strip().lower().removesuffix("cal").strip()
try:
return int(text)
except ValueError:
return None
def _protein(details: dict) -> str | None:
raw = details.get("protein")
if raw is None or str(raw).strip() == "":
return None
text = str(raw).strip()
if text.lower().endswith("g"):
return text
return f"{text}g"
def _image_url(product: dict) -> str | None:
media = product.get("media_urls")
images = media.get("images") if isinstance(media, dict) else None
if not images or not isinstance(images, list):
return None
first = images[0]
if not isinstance(first, dict):
return None
return first.get("original") or first.get("thumbnail")
def _plain_text(value) -> str | None:
if value is None:
return None
text = html.unescape(str(value))
text = _SPACE_RE.sub(" ", _TAG_RE.sub(" ", text)).strip()
return text or None

View file

@ -1,17 +1,14 @@
"""
Redefine Meals menu scraper.
Navigates to the menu page using a headless browser, waits for the
Vue.js SPA to render, and extracts structured meal data from the DOM.
Also intercepts network requests to detect any JSON API that could
replace the browser scrape in the future.
"""
"""Fetch the Redefine Meals menu and write menu JSON for local form generation."""
import json
import sys
from datetime import datetime
from pathlib import Path
from playwright.sync_api import sync_playwright, TimeoutError as PwTimeout
try:
from scraper.parse_menu import MenuParseError, fetch_menu
except ImportError:
from parse_menu import MenuParseError, fetch_menu
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.json"
@ -21,236 +18,8 @@ def load_config():
return json.load(f)
def scrape_menu(url: str, *, headless: bool = True, timeout_ms: int = 60_000) -> dict:
"""
Returns {
"scraped_at": ISO timestamp,
"menu_url": str,
"api_endpoints_found": [str],
"meals": [ { name, price, calories, protein, dietary_tags,
image_url, is_new, description } ]
}
Raises RuntimeError if the page fails to load or no meals are found.
"""
api_endpoints = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=headless)
context = browser.new_context(
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
)
page = context.new_page()
def on_response(response):
ct = response.headers.get("content-type", "")
if "json" in ct and "/api/" in response.url and "cart" not in response.url:
api_endpoints.append(response.url)
page.on("response", on_response)
try:
page.goto(url, wait_until="networkidle", timeout=timeout_ms)
except PwTimeout:
browser.close()
raise RuntimeError(f"Timed out loading {url}")
page.wait_for_timeout(3000)
articles = page.query_selector_all("article.editorial_card")
if not articles:
browser.close()
raise RuntimeError(
"No meal cards found on page. The site layout may have changed. "
"Run recon.py to inspect the current structure."
)
meals = []
for article in articles:
meal = _extract_card(article)
if meal:
meals.append(meal)
# Try to get descriptions via Quick View modals
_enrich_with_descriptions(page, articles, meals)
browser.close()
if not meals:
raise RuntimeError("Scraped 0 meals — extraction selectors are likely broken.")
for meal in meals:
meal["dietary_tags"] = _clean_tags(meal["dietary_tags"])
return {
"scraped_at": datetime.now().isoformat(),
"menu_url": url,
"api_endpoints_found": api_endpoints,
"meal_count": len(meals),
"meals": meals,
}
def _extract_card(article) -> dict | None:
try:
name_el = article.query_selector("h2.meal_title")
if not name_el:
return None
name = name_el.inner_text().strip()
price_el = article.query_selector(".meal_price")
price_text = price_el.inner_text().strip() if price_el else ""
price = _parse_price(price_text)
cal_el = article.query_selector(".card_macros_brief")
calories = None
protein = None
if cal_el:
macros_text = cal_el.inner_text()
calories, protein = _parse_macros(macros_text)
tag_els = article.query_selector_all(".diet_mini_tag")
dietary_tags = [
t.inner_text().strip().title() for t in tag_els if t.inner_text().strip()
]
img_el = article.query_selector("img.main_meal_img")
image_url = img_el.get_attribute("src") if img_el else None
is_new = article.query_selector(".new_badge_pulse") is not None
return {
"name": name,
"price": price,
"calories": calories,
"protein": protein,
"dietary_tags": dietary_tags,
"image_url": image_url,
"is_new": is_new,
"description": None,
}
except Exception as e:
print(f" Warning: failed to extract a card: {e}", file=sys.stderr)
return None
def _enrich_with_descriptions(page, articles, meals):
"""Click each meal's Quick View overlay to grab the description."""
for i, article in enumerate(articles):
if i >= len(meals):
break
try:
overlay = article.query_selector(".card_overlay")
if not overlay:
continue
article.query_selector(".card_media_wrap").click()
page.wait_for_timeout(800)
modal = page.query_selector(
".modal.show, [class*='modal'][class*='show'], [class*='quickview']"
)
if not modal:
# Try broader selector
modal = page.query_selector(
"[class*='modal']:not([style*='display: none'])"
)
if modal and modal.is_visible():
desc_el = modal.query_selector(
"[class*='description'], [class*='desc'], .meal_description"
)
if desc_el:
desc_text = desc_el.inner_text().strip()
# Filter out price strings and very short text
if (
desc_text
and len(desc_text) > 10
and not desc_text.startswith("$")
):
meals[i]["description"] = desc_text
# Grab full macro details if available
detail_tags = modal.query_selector_all(
".diet_mini_tag, [class*='lifestyle'] span"
)
for tag_el in detail_tags:
tag_text = tag_el.inner_text().strip().title()
if tag_text and tag_text not in meals[i]["dietary_tags"]:
meals[i]["dietary_tags"].append(tag_text)
# Close modal
close_btn = modal.query_selector(
"button[class*='close'], [aria-label='Close'], .btn-close"
)
if close_btn:
close_btn.click()
else:
page.keyboard.press("Escape")
page.wait_for_timeout(300)
except Exception as e:
print(
f" Warning: Quick View failed for meal {i} ({meals[i]['name']}): {e}",
file=sys.stderr,
)
try:
page.keyboard.press("Escape")
page.wait_for_timeout(300)
except Exception:
pass
KNOWN_TAGS = [
"Gluten Free",
"Dairy Free",
"Grass-Fed",
"Low Carb",
"Keto",
"Vegan",
"Vegetarian",
"Nut Free",
]
def _clean_tags(raw_tags: list[str]) -> list[str]:
"""Split concatenated tags and deduplicate."""
import re
cleaned = set()
for raw in raw_tags:
# Split on known tag boundaries (e.g., "Gluten Freedairy Free" → "Gluten Free", "Dairy Free")
remaining = raw
for known in KNOWN_TAGS:
if known.lower() in remaining.lower():
cleaned.add(known)
remaining = re.sub(
re.escape(known), "", remaining, flags=re.IGNORECASE
).strip()
if remaining and len(remaining) > 2:
cleaned.add(remaining.strip().title())
return sorted(cleaned)
def _parse_price(text: str) -> float | None:
text = text.replace("$", "").replace(",", "").strip()
try:
return float(text)
except ValueError:
return None
def _parse_macros(text: str) -> tuple[int | None, str | None]:
"""Parse '570cal • 39gP' into (570, '39g')."""
import re
cal_match = re.search(r"(\d+)\s*cal", text, re.IGNORECASE)
prot_match = re.search(r"(\d+g?)\s*P", text)
calories = int(cal_match.group(1)) if cal_match else None
protein = prot_match.group(1) if prot_match else None
if protein and not protein.endswith("g"):
protein += "g"
return calories, protein
def scrape_menu(url: str, **_kwargs) -> dict:
return fetch_menu(url)
def main():
@ -261,31 +30,30 @@ def main():
)
output_dir.mkdir(exist_ok=True)
print(f"Scraping menu from {url} ...")
result = scrape_menu(url)
print(f"Fetching menu from {url} ...")
try:
result = scrape_menu(url)
except MenuParseError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
week_str = datetime.now().strftime("%Y-W%U")
output_file = output_dir / f"menu-{week_str}.json"
with open(output_file, "w") as f:
json.dump(result, f, indent=2)
print(f"\nScraped {result['meal_count']} meals")
if result["api_endpoints_found"]:
print("API endpoints detected (potential future shortcut):")
for ep in result["api_endpoints_found"]:
print(f" {ep}")
print(f"\nFetched {result['meal_count']} meals")
print(f"Output saved to {output_file}")
# Print summary table
print(f"\n{'Name':<40} {'Price':>7} {'Cal':>5} {'Prot':>5} {'Tags'}")
print("-" * 90)
for m in result["meals"]:
tags = ", ".join(m["dietary_tags"]) if m["dietary_tags"] else ""
new = " *NEW*" if m["is_new"] else ""
price = f"${m['price']:.2f}" if m["price"] else "?"
cal = str(m["calories"]) if m["calories"] else "?"
prot = m["protein"] or "?"
print(f"{(m['name'] + new):<40} {price:>7} {cal:>5} {prot:>5} {tags}")
for meal in result["meals"]:
tags = ", ".join(meal["dietary_tags"]) if meal["dietary_tags"] else ""
new = " *NEW*" if meal["is_new"] else ""
price = f"${meal['price']:.2f}" if meal["price"] is not None else "?"
cal = str(meal["calories"]) if meal["calories"] else "?"
prot = meal["protein"] or "?"
print(f"{(meal['name'] + new):<40} {price:>7} {cal:>5} {prot:>5} {tags}")
if __name__ == "__main__":

View file

@ -13,6 +13,7 @@ import json
from flask import Flask, Response, jsonify, request, send_file
import server.sentry_init # noqa: F401
from server import http_api
CORS_ORIGINS = [

View file

@ -54,13 +54,15 @@ def generate_form(
bulk_discount: float = 0,
company_subsidy: float = 0,
google_client_id: str = "",
week: str | None = None,
) -> str:
google_client_id = google_client_id.strip()
api_url = (api_url or "").rstrip("/")
if api_url and not google_client_id:
raise ValueError("Google client ID is required in cloud mode")
week = datetime.now().strftime("%Y-W%U")
if not week:
week = datetime.now().strftime("%Y-W%U")
scraped_at = menu.get("scraped_at", "unknown")
deadline = config.get("order_deadline", "Thursday 11:59 PM")
has_discount = bulk_discount > 0 or company_subsidy > 0

View file

@ -279,15 +279,20 @@ def lambda_handler(event, context):
return response(405, {"error": "Method not allowed"})
def handle_menu(event):
"""Return the published menu in the portal's public MenuPayload shape."""
def _week_from_path(event):
requested_week = (event.get("pathParameters") or {}).get("week", "")
if requested_week == "current":
week = current_week()
elif re.fullmatch(r"\d{4}-W\d{2}", requested_week):
week = requested_week
else:
return response(400, {"error": "week path param must be current or YYYY-WNN"})
return current_week(), None
if re.fullmatch(r"\d{4}-W\d{2}", requested_week):
return requested_week, None
return None, response(400, {"error": "week path param must be current or YYYY-WNN"})
def handle_menu(event):
"""Return the published menu in the portal's public MenuPayload shape."""
week, error = _week_from_path(event)
if error is not None:
return error
menu = get_menu(week)
if menu is None:
@ -358,7 +363,7 @@ def _require_publish_key(event) -> dict | None:
def handle_publish_settings(event=None):
"""Return only the pricing fields needed by the weekly-menu workflow."""
"""Return only the pricing fields needed by the manual HMAC publish fallback."""
denied = _require_publish_key(event or {})
if denied:
return denied
@ -623,7 +628,9 @@ def handle_my_orders(event):
def handle_form_status(event):
week = event.get("pathParameters", {}).get("week", current_week())
week, error = _week_from_path(event)
if error is not None:
return error
status = get_form_status(week)
result = {"week": week, "status": status}
if status == "closed":

View file

@ -42,6 +42,10 @@ def run_job(payload: dict) -> dict:
if event_type == "sync_roster":
from server.jobs.sync_roster import lambda_handler
return lambda_handler(payload, None)
if event_type == "publish_menu":
from server.jobs.publish_menu import lambda_handler
return lambda_handler(payload, None)
from server.jobs.notify import lambda_handler

View file

@ -0,0 +1,131 @@
"""Monday menu publish: fetch the catalog, write it, upload the form, notify."""
from __future__ import annotations
import os
import boto3
from scraper.parse_menu import fetch_menu
from server.generate_form import generate_form
from shared.db import current_week, get_settings, put_menu
from shared.secrets import get_parameter
DEFAULT_MENU_URL = "https://www.redefinemeals.com/menu"
DEFAULT_DEADLINE = "Thursday at 11:59 PM"
FORM_BUCKET_PARAM = "/meal-order-manager/deploy/form-bucket"
DISTRIBUTION_ID_PARAM = "/meal-order-manager/deploy/distribution-id"
def lambda_handler(event, context):
del event, context
menu_url = os.environ.get("MENU_URL", DEFAULT_MENU_URL).strip() or DEFAULT_MENU_URL
menu = fetch_menu(menu_url)
week = current_week()
settings = get_settings()
bulk_discount = float(settings.get("bulk_discount_percent") or 0)
company_subsidy = float(settings.get("company_subsidy_percent") or 0)
google_client_id = _google_client_id()
bucket = _configured("FORM_BUCKET", "FORM_BUCKET_PARAM", FORM_BUCKET_PARAM)
distribution_id = _configured(
"DISTRIBUTION_ID", "DISTRIBUTION_ID_PARAM", DISTRIBUTION_ID_PARAM
)
form_url = os.environ.get("FORM_URL", "").strip()
if not form_url:
raise RuntimeError("FORM_URL is required")
html = generate_form(
menu,
{"order_deadline": DEFAULT_DEADLINE, "roster": []},
bulk_discount=bulk_discount,
company_subsidy=company_subsidy,
google_client_id=google_client_id,
week=week,
)
put_menu(week, menu)
_upload_form(bucket, week, html)
_invalidate(distribution_id, week, menu.get("scraped_at") or week)
_notify(form_url)
return {
"status": "published",
"week": week,
"meal_count": menu["meal_count"],
}
def _google_client_id() -> str:
direct = os.environ.get("GOOGLE_CLIENT_ID", "").strip()
if direct:
return direct
param = os.environ.get("GOOGLE_CLIENT_ID_PARAM", "").strip()
if not param:
raise RuntimeError("GOOGLE_CLIENT_ID_PARAM is required")
client_id = get_parameter(param).strip()
if not client_id:
raise RuntimeError("Google client ID is empty")
return client_id
def _configured(env_name: str, param_env: str, default_param: str) -> str:
direct = os.environ.get(env_name, "").strip()
if direct:
return direct
param = os.environ.get(param_env, default_param).strip() or default_param
value = get_parameter(param).strip()
if not value:
raise RuntimeError(f"{env_name} is empty")
return value
def _upload_form(bucket: str, week: str, html: str) -> None:
body = html.encode("utf-8")
s3 = boto3.client("s3")
s3.put_object(
Bucket=bucket,
Key="index.html",
Body=body,
ContentType="text/html",
CacheControl="no-cache",
)
s3.put_object(
Bucket=bucket,
Key=f"archive/{week}.html",
Body=body,
ContentType="text/html",
)
def _invalidate(distribution_id: str, week: str, scraped_at: str) -> None:
reference = f"publish-menu-{week}-{scraped_at}".replace(":", "-")
boto3.client("cloudfront").create_invalidation(
DistributionId=distribution_id,
InvalidationBatch={
"Paths": {"Quantity": 1, "Items": ["/index.html"]},
"CallerReference": reference[:128],
},
)
def _notify(form_url: str) -> None:
from shared.slack import post_channel_message
text = f"This week's meal order is open! Deadline: {DEFAULT_DEADLINE}."
blocks = [
{
"type": "header",
"text": {"type": "plain_text", "text": "Meal Order Open"},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"*<{form_url}|Place your order>*\n\n*Deadline:* {DEFAULT_DEADLINE}\n"
),
},
},
]
result = post_channel_message(text, blocks)
if not result.get("ok"):
raise RuntimeError(f"Slack API error: {result.get('error')}")

177
src/server/sentry_init.py Normal file
View file

@ -0,0 +1,177 @@
"""Flask Sentry SDK init for meal-order-manager.
Imported for side effect from ``server.app``. ``init_sentry()`` is a no-op when
the DSN is unset, empty, or the literal ``unset``, so pytest and local
``python -m server.app`` never talk to Sentry. When ``SENTRY_DSN`` is absent,
the DSN is read from SSM via ``SENTRY_DSN_PARAM``. ``ParameterNotFound`` is
treated as unset so a mixed-PR race cannot crash gunicorn.
``before_send`` strips auth, cookies, the publish HMAC header, request bodies,
secrety extras, and exception stack-frame locals.
``include_local_variables=False`` keeps those locals out of the event in the
first place.
"""
from __future__ import annotations
import os
import sentry_sdk
from botocore.exceptions import ClientError
from sentry_sdk.integrations.flask import FlaskIntegration
_HEADER_DROP_NAMES = frozenset(
{
"authorization",
"x-auth-token",
"cookie",
"x-amz-security-token",
"x-slack-signature",
"x-meals-publish-key",
}
)
_DROP_REQUEST_KEYS = frozenset(
{
"body",
"Body",
"data",
"cookies",
"raw_email",
"prompt",
"secret",
"SecretString",
"hmac",
"keys",
}
)
_DROP_EXTRA_NEEDLES = (
"body",
"email",
"prompt",
"secret",
"hmac",
"token",
"mime",
"raw_email",
"password",
"signing",
)
def _drop_header(name):
lower = str(name).lower()
return lower in _HEADER_DROP_NAMES or lower.startswith("x-amz-")
def _scrub_headers(headers):
if isinstance(headers, dict):
return {k: v for k, v in headers.items() if not _drop_header(k)}
if isinstance(headers, list):
kept = []
for pair in headers:
if isinstance(pair, (list, tuple)) and pair and _drop_header(pair[0]):
continue
kept.append(pair)
return kept
return headers
def _stacktraces(event):
traces = []
stacktrace = event.get("stacktrace")
if isinstance(stacktrace, dict):
traces.append(stacktrace)
for section in ("exception", "threads"):
container = event.get(section)
if not isinstance(container, dict):
continue
values = container.get("values")
if not isinstance(values, list):
continue
for item in values:
if not isinstance(item, dict):
continue
inner = item.get("stacktrace")
if isinstance(inner, dict):
traces.append(inner)
return traces
def _strip_stack_locals(event):
"""Drop frame locals. Names like ``raw``/``item`` still hold secrets."""
for stacktrace in _stacktraces(event):
frames = stacktrace.get("frames")
if not isinstance(frames, list):
continue
for frame in frames:
if isinstance(frame, dict):
frame.pop("vars", None)
def _before_send(event, _hint):
request = event.get("request")
if isinstance(request, dict):
headers = request.get("headers")
if headers is not None:
request["headers"] = _scrub_headers(headers)
for key in list(request):
if key in _DROP_REQUEST_KEYS or str(key).lower() in {"body", "data"}:
request.pop(key, None)
extra = event.get("extra")
if isinstance(extra, dict):
for key in list(extra):
lower = str(key).lower()
if any(needle in lower for needle in _DROP_EXTRA_NEEDLES):
extra.pop(key, None)
_strip_stack_locals(event)
return event
def _is_parameter_not_found(exc: Exception) -> bool:
response_data = getattr(exc, "response", {})
if not isinstance(response_data, dict):
return False
return response_data.get("Error", {}).get("Code") == "ParameterNotFound"
def _dsn_from_param() -> str:
param = os.environ.get("SENTRY_DSN_PARAM", "").strip()
if not param:
return ""
from shared.secrets import get_parameter
try:
return get_parameter(param, decrypt=True).strip()
except ClientError as exc:
if _is_parameter_not_found(exc):
return ""
raise
def _resolve_dsn() -> str:
dsn = os.environ.get("SENTRY_DSN", "").strip()
if dsn:
return dsn
return _dsn_from_param()
def init_sentry() -> None:
dsn = _resolve_dsn()
if not dsn or dsn.lower() == "unset":
return
kwargs = {
"dsn": dsn,
"integrations": [FlaskIntegration()],
"send_default_pii": False,
"include_local_variables": False,
"enable_logs": False,
"traces_sample_rate": 0.0,
"before_send": _before_send,
"environment": os.environ.get("STAGE", "").strip() or "local",
}
sha = os.environ.get("GIT_SHA", "").strip()
if sha:
kwargs["release"] = sha
sentry_sdk.init(**kwargs)
init_sentry()

View file

@ -29,6 +29,7 @@ let countdownTimer = null;
let mealsListDelegationBound = false;
const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
const GOOGLE_SESSION_KEY = "seahaven.meals.googleIdToken";
window.googleCredential = null;
@ -95,6 +96,68 @@ function waitForGoogleAuth(attempt = 0) {
}
}
function decodeJwtPayload(token) {
if (!token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const json = new TextDecoder().decode(
Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)),
);
return JSON.parse(json);
} catch (e) {
return null;
}
}
function googleTokenIsUsable(token) {
const payload = decodeJwtPayload(token);
if (!payload || !payload.email) return false;
if (typeof payload.exp === "number" && payload.exp * 1000 <= Date.now()) {
return false;
}
if (GOOGLE_CLIENT_ID && payload.aud && payload.aud !== GOOGLE_CLIENT_ID) {
return false;
}
return true;
}
function readStoredGoogleToken() {
try {
return sessionStorage.getItem(GOOGLE_SESSION_KEY);
} catch (e) {
return null;
}
}
function persistGoogleSession(token) {
try {
if (googleTokenIsUsable(token)) {
sessionStorage.setItem(GOOGLE_SESSION_KEY, token);
} else {
sessionStorage.removeItem(GOOGLE_SESSION_KEY);
}
} catch (e) {}
}
function clearGoogleSession() {
try {
sessionStorage.removeItem(GOOGLE_SESSION_KEY);
} catch (e) {}
}
function restoreGoogleSession() {
const token = readStoredGoogleToken();
if (!googleTokenIsUsable(token)) {
if (token) clearGoogleSession();
return false;
}
handleCredentialResponse({ credential: token });
return true;
}
function initGoogleAuth() {
google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID,
@ -107,17 +170,19 @@ function initGoogleAuth() {
text: "signin_with",
width: 300,
});
if (restoreGoogleSession()) return;
try {
google.accounts.id.prompt();
} catch (e) {}
}
function handleCredentialResponse(response) {
googleCredential = response.credential;
const token = response && response.credential;
const payload = decodeJwtPayload(token);
if (!payload || !payload.email) return;
googleCredential = token;
syncGoogleCredential();
const b64 = response.credential
.split(".")[1]
.replace(/-/g, "+")
.replace(/_/g, "/");
const payloadBytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
persistGoogleSession(token);
googleUser = { name: payload.name, email: payload.email };
document.getElementById("auth-overlay").classList.add("is-hidden");
@ -149,6 +214,7 @@ function signOut() {
googleCredential = null;
googleUser = null;
syncGoogleCredential();
clearGoogleSession();
if (
CONFIG.authMode === "google" &&
typeof google !== "undefined" &&

View file

@ -10,8 +10,10 @@ import sys
import time
import boto3
import sentry_sdk
from server.jobs import run_job
from server.sentry_init import init_sentry
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
@ -27,6 +29,7 @@ def _stop(_signum, _frame) -> None:
def main() -> None:
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
init_sentry()
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
if not queue_url:
@ -57,6 +60,7 @@ def main() -> None:
continue
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt)
except Exception:
sentry_sdk.capture_exception()
logger.exception("job failed; leaving message for retry")

View file

@ -1,3 +1,3 @@
boto3==1.43.97
fpdf2==2.8.8
PyJWT[crypto]==2.14.0
boto3==1.43.107
fpdf2==2.8.9
PyJWT[crypto]==2.15.1

View file

@ -2,61 +2,71 @@
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.65.0"
constraints = "6.65.0"
version = "6.67.0"
constraints = "6.67.0"
hashes = [
"h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=",
"h1:1QFvuV0+K3GieeXSlb7qi9Q334XrsnMP8dCRwpM7fkc=",
"h1:36ZBGQTzM6dW8dLQ145loI9yw6/fSbRV+fvLGCeEubc=",
"h1:4uHlr+eDGOjf71giwLidIfGsMP6g5x2wkSGP5xq9EpM=",
"h1:9fSxZKfaAGrNSzXIz53IP2EmC1LYdO/fGYl7T87Y36Q=",
"h1:GJCK46UtrJMGSyByH4SiDytbwX11bg79jvTGZ27BGWU=",
"h1:H0qzEAMrqydb8eTZdebso8nS/b8w1BNHysP8nmM4pLk=",
"h1:RjeO6m/SvlhGUCDrwTdj99kJhKl/rC1zE9B5mi3YhVw=",
"h1:Yx8Kv/T/BHVE8S1WEyHsFdu4HcsAQIl5e/831DeoQ5k=",
"h1:ZFDxAUFzk1A2BPbLgu1LhAJ3erD4BbqZsF25yQobN4o=",
"h1:anUZ356aBWvbHzQalF036qNKO+RISPmq6ycIL4OdXxk=",
"h1:fhsSsZmfNFf4wErbcsmu1/ek1/TVUy7NCuMYeOpA1aE=",
"h1:l+w5eqL9UqpiVZ2ll0r+YvWAPjIL/WtqV8xqfH1+apM=",
"h1:nt0kyMKN9kDNXKHOejaAo7LQIemP3tyntYjxHY32P0M=",
"h1:o2tj5YHQU1QNgANW2YAbjj0opl0BRJZl8W9trjYsqdA=",
"zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be",
"zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3",
"zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917",
"zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8",
"zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09",
"zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8",
"zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7",
"zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5",
"zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294",
"zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e",
"h1:01Y50Z+67vWZmsW9e8FQTj9NT/XW+x1n0YGdz2x4BpY=",
"h1:6dffiL4BGVg+Ac2/64N+svhucYstBnVTTu55hWEhmq8=",
"h1:8kRViFkyn96SufnuV3Oi3QmfL+DiyxlhAPcSX2jH0T0=",
"h1:EAfdlvAeLCxhO9G5nnZ6Ti1zsmQP1aClgS41rneOijY=",
"h1:EB9ixYOZrSlYD7wtJxf88qwoyyWrlKDKxhzaCLIb3t4=",
"h1:Ksjd+RJVccOFRlbg3gpoJjL7T3SMPHxso2dPzAVTyRE=",
"h1:OgdIUAQDtJBxlKjoPgChD1w57vl6hm6PyJHiBYgsgQA=",
"h1:Syy68cIDOz7sXpxhjrkCwNHvmOj9VeaLVTJ/XnUKSuU=",
"h1:Tz5wi4X4Gkj2I1Gif4MOtfrj4JerCz+5KqSi6Xj+n/A=",
"h1:XaBXhLvtX5lUYkv5fHKzzMfoZXaIh5zU8hvM4jG2TXI=",
"h1:fOwuAcOFTGOU0GY1m4NHhDgTAdTSiU+9qZ+REdp5HIU=",
"h1:gyduBRrLO8EiRf8zA1aiLRoWydrUMw+TG0pUbOXo1g4=",
"h1:iDfjW95J79sAMaOxeln73bKerm9SBemvLTrKepODumw=",
"h1:xH+es0QQOteWlNptLqZzI6k8y94Aq/11S7lozotvO2c=",
"h1:zLmFaFw5LptpWftHL3O6+7uykOH/0F9AmyVQ7V6kslY=",
"zh:111d5686a1f4ccbc888bb5e2229308bcdd9149898c6af97969fcdfb0e7bd2aa0",
"zh:21b3b7693bd9754c039fd546f03ed09e7510c6189aa5ee37176f144cf8dd5f95",
"zh:25e03c7f025ff4851889537605aa560d2e39bd738b33a5204b8037eb164b475f",
"zh:2817a046f1060e25bf04b0eb78f4e251130bb92dc82ec1264ce156dc9bf78e67",
"zh:2d318d674c3ec9dbd97ddb10b12ad4827be4f508c43ba2ae1e0523baa9e367e3",
"zh:2f07ab356718267299e10b6072472ded29d82753883027bf43bcd687ac72feb2",
"zh:32307e67f83bc0dc94d38cfcd23d782166a09e0e975e2a5aa7d7a3e7ca5d3da8",
"zh:4ce94853264097dd7f4542b3cfd18d2b98b06d23321db45d5e384ea275a57f63",
"zh:869656c41cc7c7412f213e488f98167cff61deafcb8cf245d25d056e8dfdc263",
"zh:8fd8c814e9d8edf152552047db23bf5b5d63f22e6b0b5d47b2af851376e99349",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f",
"zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab",
"zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af",
"zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87",
"zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59",
"zh:9cbc02ceef9bd469da497a1e7065ff984bdacfbe919ac3cce804a86c13b6e2c6",
"zh:9ea55dda2767acfc1f6337fc7d29b1d4d79d6f8f04871f8ad99a6078d2865994",
"zh:d7149df0819fb57a160489db45d33951765b8f0118daa3b4cd549a0135bc97a1",
"zh:e5819937bb7043d08c9829b32d52d9fb55a5b9a4d8d55d550d47a3d7392db546",
"zh:f93bc38dbc0ad53842303f30eec7a22c525c4d56209b54ec33a8d375cfc4e4fd",
]
}
provider "registry.terraform.io/hashicorp/random" {
version = "3.8.1"
constraints = "3.8.1"
version = "3.9.1"
constraints = "3.9.1"
hashes = [
"h1:Eexl06+6J+s75uD46+WnZtpJZYRVUMB0AiuPBifK6Jc=",
"h1:fdfOl1HabDT42XLH8qjmfTbVZpgQZ5lyOyOa+GQhm0w=",
"h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=",
"zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4",
"zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae",
"zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57",
"zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0",
"zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66",
"zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511",
"h1:0rmWNCsP90oLS9KtLiqcNmkRzsaOZD95THXyuGYUfOM=",
"h1:4AN630toDK+4Is2MQcbGA37yR56nIEDPO0qv8ivZD2M=",
"h1:7uiStw0Rl9KOdX5UNMG/sp9nyadoD4LZekQTiYlYPhE=",
"h1:9RiO3l/4iDguiVMryazTvf8ZAOAwbS0LPWl/XRo2El4=",
"h1:AjDEYpTXfyc3CupsrJYjgxCYnqxdpCLPkaLKPiw9WTA=",
"h1:PYbnOqRuGn4c0/Ae1f7yOS/0zvmXNHFJRZjuF4KECnM=",
"h1:PlW+UZ4EElQF3NQwf41KQwavFujab3Czc51zu9dyVM8=",
"h1:VGeIpAn+nL6i8a6Y1W7XIq+Z6XFIWu5al4f2PNageoo=",
"h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=",
"h1:nozfr4CZq73d4HjubKJundX/8A+Mj282oS/hyNfjUPU=",
"h1:uFgaD8lhFrHzFm88V3/+wypq5AzCUdzXR60vLSlZ2cI=",
"h1:xxRd4yd9LvPiDqeiGQj7FhZHwD08tDcIToAAePfoahE=",
"zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7",
"zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26",
"zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2",
"zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1",
"zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb",
"zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f",
"zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa",
"zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097",
"zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9",
"zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05",
"zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8",
"zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b",
"zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699",
"zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b",
"zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc",
"zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c",
]
}

View file

@ -68,3 +68,33 @@ check "dev_has_no_custom_domain" {
error_message = "attach_custom_domain must be false in non-prod; use the CloudFront distribution domain."
}
}
check "prod_reuses_afterhours_vpc" {
assert {
condition = !local.is_prod || var.existing_vpc_id != ""
error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60."
}
}
check "existing_vpc_pair" {
assert {
condition = (var.existing_vpc_id == "") == (length(var.existing_public_subnet_ids) == 0)
error_message = "existing_vpc_id and existing_public_subnet_ids must both be set or both be empty."
}
}
check "existing_vpc_two_az" {
assert {
condition = var.existing_vpc_id == "" || length(var.existing_public_subnet_ids) >= 2
error_message = "existing_public_subnet_ids must include at least two subnets."
}
}
check "existing_subnets_in_vpc" {
assert {
condition = alltrue([
for subnet in data.aws_subnet.existing_public : subnet.vpc_id == var.existing_vpc_id
])
error_message = "Every existing_public_subnet_ids value must belong to existing_vpc_id."
}
}

View file

@ -39,7 +39,7 @@ resource "aws_ecr_lifecycle_policy" "api" {
resource "aws_security_group" "alb" {
name = "${local.project}-alb"
description = "Public ALB for meal-order-manager"
vpc_id = aws_vpc.this.id
vpc_id = local.vpc_id
ingress {
# CloudFront prefix lists cannot cover GitHub-hosted weekly-menu HMAC
@ -63,7 +63,7 @@ resource "aws_security_group" "alb" {
resource "aws_security_group" "api" {
name = "${local.project}-api"
description = "Fargate tasks for meal-order-manager"
vpc_id = aws_vpc.this.id
vpc_id = local.vpc_id
ingress {
description = "From ALB"
@ -86,7 +86,7 @@ resource "aws_lb" "api" {
load_balancer_type = "application"
idle_timeout = 120
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
subnets = local.public_subnet_ids
drop_invalid_header_fields = true
}
@ -95,7 +95,7 @@ resource "aws_lb_target_group" "api" {
name = "${local.project}-api"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.this.id
vpc_id = local.vpc_id
target_type = "ip"
health_check {
@ -150,6 +150,7 @@ locals {
{ name = "PORTAL_COGNITO_AUDIENCE_PARAM", value = aws_ssm_parameter.portal_cognito_audience.name },
{ name = "PORTAL_COGNITO_TRUST_PARAM", value = aws_ssm_parameter.portal_cognito_trust.name },
{ name = "PUBLISH_KEY_PARAM", value = aws_ssm_parameter.publish_key.name },
{ name = "SENTRY_DSN_PARAM", value = aws_ssm_parameter.sentry_dsn.name },
{ name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id },
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
{ name = "AWS_DEFAULT_REGION", value = var.aws_region },
@ -202,7 +203,7 @@ resource "aws_ecs_service" "api" {
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.public[*].id
subnets = local.public_subnet_ids
security_groups = [aws_security_group.api.id]
assign_public_ip = true
}

View file

@ -225,6 +225,8 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/githubdeploy-meal-order-manager"]
}
# Kept so this apply can delete githubdeploy-meal-order-manager-weekly-menu.
# Drop the statement after that role is gone.
statement {
sid = "WriteDeployRoles"
effect = "Allow"

View file

@ -38,6 +38,23 @@ data "aws_iam_policy_document" "ecs_task_boundary" {
resources = ["${aws_s3_bucket.reports.arn}/*"]
}
statement {
sid = "FormObjects"
effect = "Allow"
actions = ["s3:PutObject"]
resources = [
"${aws_s3_bucket.form.arn}/index.html",
"${aws_s3_bucket.form.arn}/archive/*.html",
]
}
statement {
sid = "InvalidateForm"
effect = "Allow"
actions = ["cloudfront:CreateInvalidation"]
resources = [aws_cloudfront_distribution.form.arn]
}
statement {
sid = "JobsQueue"
effect = "Allow"

View file

@ -1,4 +1,5 @@
# GitHub Actions OIDC role for .github/workflows/deploy-api.yaml.
# GitHub Actions OIDC role for the thin deploy-api.yaml caller of
# org reusable cd-hcp-fargate.yaml.
data "aws_iam_policy_document" "github_deploy_assume" {
statement {
@ -26,12 +27,13 @@ data "aws_iam_policy_document" "github_deploy_assume" {
]
}
# AWS STS GitHub condition keys include job_workflow_ref, not workflow_ref.
# A workflow_ref condition fail-closes AssumeRoleWithWebIdentity.
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [
"Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/heads/main",
"Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/tags/v*",
"Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*",
]
}
}

View file

@ -1,113 +0,0 @@
# GitHub Actions OIDC role for .github/workflows/weekly-menu.yml.
#
# Trust is pinned three ways (aud, sub to main, job_workflow_ref to the
# weekly-menu workflow at main) so no other workflow in the repo can assume it.
# Permissions mirror the mgmt github-oidc-deploy-roles weekly-menu role, retargeted
# to prod resources and without form-api-key (SigV4 publish path).
#
# OIDC provider ARN is literal (not a data source): hcptf-meal-order-manager-plan
# lacks iam:GetOpenIDConnectProvider, and the provider is account-stable.
data "aws_iam_policy_document" "weekly_menu_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.github_oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:Sea-Haven-Industries/meal-order-manager:ref:refs/heads/main"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = ["Sea-Haven-Industries/meal-order-manager/.github/workflows/weekly-menu.yml@refs/heads/main"]
}
}
}
resource "aws_iam_role" "weekly_menu" {
name = "githubdeploy-meal-order-manager-weekly-menu"
path = "/tf-managed/"
description = "GitHub Actions weekly-menu scrape/publish for meal-order-manager"
assume_role_policy = data.aws_iam_policy_document.weekly_menu_assume.json
max_session_duration = 3600
# Not a Lambda execution role. Config omits permissions_boundary so a later
# apply will not PutRolePermissionsBoundary the Lambda ceiling back. Live still
# has seahaven-lambda-execution-boundary; omitting without ignore_changes would
# plan DeleteRolePermissionsBoundary, which hcptf-meal-order-manager is denied
# (DenyBoundaryTampering). Ignore the attribute so this apply does not touch
# the ceiling. An administrator deletes the live attachment, then a follow-up
# drops this lifecycle after refresh-only updates state to null.
lifecycle {
ignore_changes = [permissions_boundary]
}
}
data "aws_iam_policy_document" "weekly_menu" {
statement {
sid = "SlackBotSecret"
effect = "Allow"
actions = [
"secretsmanager:GetSecretValue",
]
resources = [var.slack_bot_secret_arn]
}
statement {
sid = "DeployAndAppParams"
effect = "Allow"
actions = [
"ssm:GetParameter",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/api-url",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/form-bucket",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/distribution-id",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/form-url",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.google_client_id_param}",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.slack_channel_param}",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/publish-key",
]
}
statement {
sid = "FormObjects"
effect = "Allow"
actions = [
"s3:PutObject",
]
resources = [
"${aws_s3_bucket.form.arn}/index.html",
"${aws_s3_bucket.form.arn}/archive/*.html",
]
}
statement {
sid = "InvalidateForm"
effect = "Allow"
actions = [
"cloudfront:CreateInvalidation",
]
resources = [aws_cloudfront_distribution.form.arn]
}
}
resource "aws_iam_role_policy" "weekly_menu" {
name = "weekly-menu-publish"
role = aws_iam_role.weekly_menu.id
policy = data.aws_iam_policy_document.weekly_menu.json
}

View file

@ -7,8 +7,9 @@ locals {
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
# Prod has no default VPC. 10.60 is unused in 011934824531
# (10.0 proposal-system, 10.20 payments-dashboard, 10.40 syslog, 10.80 apm-wo).
# Created only when existing_vpc_id is empty. Prod attaches to afterhours 10.70.
# 10.60 is the unused fallback CIDR, not a second prod VPC.
manage_vpc = var.existing_vpc_id == "" && !local.is_prod
vpc_cidr = "10.60.0.0/16"
public_subnet_cidrs = ["10.60.0.0/24", "10.60.1.0/24"]

View file

@ -43,11 +43,6 @@ output "orders_table_name" {
value = aws_dynamodb_table.orders.name
}
output "weekly_menu_role_arn" {
description = "OIDC role ARN for .github/workflows/weekly-menu.yml (repo secret AWS_WEEKLY_MENU_ROLE_ARN)."
value = aws_iam_role.weekly_menu.arn
}
output "github_deploy_role_arn" {
description = "OIDC role ARN for .github/workflows/deploy-api.yaml (Environment DEPLOY_ROLE_ARN)."
value = aws_iam_role.github_deploy.arn
@ -57,3 +52,13 @@ output "ecs_task_role_arn" {
description = "ECS task role; paychex-checkcomponents queue policy must allow this ARN."
value = aws_iam_role.ecs_task.arn
}
output "vpc_id" {
description = "VPC the ALB and Fargate tasks run in. Prod attaches to afterhours."
value = local.vpc_id
}
output "public_subnet_ids" {
description = "Public subnet IDs for the ALB and Fargate tasks."
value = local.public_subnet_ids
}

View file

@ -17,6 +17,11 @@ locals {
schedule = "cron(55 6 ? * MON *)"
event = "sync_roster"
}
publish-menu = {
description = "Publish the weekly menu Monday 7:30am Eastern"
schedule = "cron(30 7 ? * MON *)"
event = "publish_menu"
}
}
}

View file

@ -4,6 +4,17 @@
# created and rotated out-of-band because it varies per environment; data.tf
# reads it. Do not turn that lookup into a resource.
resource "aws_ssm_parameter" "sentry_dsn" {
name = "${local.ssm_prefix}/sentry-dsn"
type = "SecureString"
value = "unset"
description = "Sentry DSN for meal-order-manager. PutParameter writes the live value; Terraform ignores it. Empty or unset disables the SDK."
lifecycle {
ignore_changes = [value]
}
}
resource "aws_ssm_parameter" "slack_channel_id" {
name = local.slack_channel_param
type = "String"
@ -39,9 +50,8 @@ resource "aws_ssm_parameter" "portal_cognito_trust" {
# Deploy-time lookups
# ---------------------------------------------------------------------------
#
# These replace the CloudFormation stack outputs that
# .github/workflows/weekly-menu.yml used to read, so the job can resolve its
# deploy targets without a CloudFormation stack.
# Deploy targets for the image workflow and the publish_menu job.
# There is no CloudFormation stack.
resource "aws_ssm_parameter" "deploy_api_url" {
name = "${local.ssm_prefix}/deploy/api-url"
@ -89,19 +99,19 @@ resource "aws_ssm_parameter" "deploy_form_bucket" {
name = "${local.ssm_prefix}/deploy/form-bucket"
type = "String"
value = aws_s3_bucket.form.id
description = "S3 bucket holding the order form; sync target for the weekly-menu deploy job"
description = "S3 bucket holding the order form; upload target for the publish_menu job"
}
resource "aws_ssm_parameter" "deploy_distribution_id" {
name = "${local.ssm_prefix}/deploy/distribution-id"
type = "String"
value = aws_cloudfront_distribution.form.id
description = "CloudFront distribution ID; cache-invalidation target for the weekly-menu deploy job"
description = "CloudFront distribution ID; cache-invalidation target for the publish_menu job"
}
resource "aws_ssm_parameter" "deploy_form_url" {
name = "${local.ssm_prefix}/deploy/form-url"
type = "String"
value = local.form_url
description = "Public order form URL; reported by the weekly-menu deploy job"
description = "Public order form URL; linked from the publish_menu Slack post"
}

View file

@ -91,3 +91,15 @@ variable "checkcomponents_queue_arn" {
type = string
default = "arn:aws:sqs:us-east-1:011934824531:paychex-checkcomponents"
}
variable "existing_vpc_id" {
description = "When set, place the ALB and Fargate tasks in this VPC instead of creating one. Prod attaches to the afterhours VPC."
type = string
default = ""
}
variable "existing_public_subnet_ids" {
description = "Public subnet IDs in existing_vpc_id. Required with existing_vpc_id; ignored when that variable is empty."
type = list(string)
default = []
}

View file

@ -4,11 +4,11 @@ terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.65.0"
version = "6.67.0"
}
random = {
source = "hashicorp/random"
version = "3.8.1"
version = "3.9.1"
}
}

View file

@ -1,8 +1,32 @@
data "aws_availability_zones" "available" {
count = local.manage_vpc ? 1 : 0
state = "available"
}
data "aws_vpc" "existing" {
count = var.existing_vpc_id == "" ? 0 : 1
id = var.existing_vpc_id
}
data "aws_subnet" "existing_public" {
for_each = toset(var.existing_public_subnet_ids)
id = each.value
}
resource "terraform_data" "prod_requires_afterhours_vpc" {
input = var.existing_vpc_id
lifecycle {
precondition {
condition = !local.is_prod || var.existing_vpc_id != ""
error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60."
}
}
}
resource "aws_vpc" "this" {
count = local.manage_vpc ? 1 : 0
cidr_block = local.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
@ -11,6 +35,13 @@ resource "aws_vpc" "this" {
Name = "${local.project}-vpc"
}
lifecycle {
precondition {
condition = !local.is_prod
error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60."
}
}
# First apply updates the live hcptf apply role before CreateVpc.
depends_on = [
aws_iam_role_policy_attachments_exclusive.hcptf_apply,
@ -19,7 +50,9 @@ resource "aws_vpc" "this" {
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
count = local.manage_vpc ? 1 : 0
vpc_id = aws_vpc.this[0].id
tags = {
Name = "${local.project}-igw"
@ -27,11 +60,11 @@ resource "aws_internet_gateway" "this" {
}
resource "aws_subnet" "public" {
count = length(local.public_subnet_cidrs)
count = local.manage_vpc ? length(local.public_subnet_cidrs) : 0
vpc_id = aws_vpc.this.id
vpc_id = aws_vpc.this[0].id
cidr_block = local.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
availability_zone = data.aws_availability_zones.available[0].names[count.index]
map_public_ip_on_launch = true
tags = {
@ -40,7 +73,9 @@ resource "aws_subnet" "public" {
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
count = local.manage_vpc ? 1 : 0
vpc_id = aws_vpc.this[0].id
tags = {
Name = "${local.project}-public"
@ -48,14 +83,46 @@ resource "aws_route_table" "public" {
}
resource "aws_route" "public_default" {
route_table_id = aws_route_table.public.id
count = local.manage_vpc ? 1 : 0
route_table_id = aws_route_table.public[0].id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
gateway_id = aws_internet_gateway.this[0].id
}
resource "aws_route_table_association" "public" {
count = length(local.public_subnet_cidrs)
count = local.manage_vpc ? length(local.public_subnet_cidrs) : 0
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
route_table_id = aws_route_table.public[0].id
}
locals {
vpc_id = local.manage_vpc ? aws_vpc.this[0].id : try(data.aws_vpc.existing[0].id, var.existing_vpc_id)
public_subnet_ids = local.manage_vpc ? aws_subnet.public[*].id : var.existing_public_subnet_ids
}
moved {
from = aws_vpc.this
to = aws_vpc.this[0]
}
moved {
from = aws_internet_gateway.this
to = aws_internet_gateway.this[0]
}
moved {
from = aws_route_table.public
to = aws_route_table.public[0]
}
moved {
from = aws_route.public_default
to = aws_route.public_default[0]
}
moved {
from = data.aws_availability_zones.available
to = data.aws_availability_zones.available[0]
}

View file

@ -3,12 +3,16 @@
import os
import sys
import pytest
# Several Lambda handlers construct boto3.client(...) at module load. On a CI
# runner with no AWS config this raises NoRegionError during test collection
# (a real region is read from ~/.aws/config locally, masking it). Set a default
# region before any import. Client construction is offline; real calls are mocked.
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
os.environ.setdefault("AWS_REGION", "us-east-1")
os.environ.pop("SENTRY_DSN", None)
os.environ.pop("SENTRY_DSN_PARAM", None)
# Add the repo root so `import functions.<name>.handler` resolves under a bare
# `pytest` invocation. `python -m pytest` injects the CWD automatically, but CI
@ -25,3 +29,12 @@ sys.path.insert(0, os.path.abspath(_src_dir))
# without requiring a real Lambda layer or .aws-sam build.
_shared_layer_dir = os.path.join(_src_dir, "shared")
sys.path.insert(0, os.path.abspath(_shared_layer_dir))
@pytest.fixture(autouse=True)
def _clear_sentry_dsn_env():
os.environ.pop("SENTRY_DSN", None)
os.environ.pop("SENTRY_DSN_PARAM", None)
yield
os.environ.pop("SENTRY_DSN", None)
os.environ.pop("SENTRY_DSN_PARAM", None)

9
tests/fixtures/menu_page.html vendored Normal file
View file

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<body>
<orders-page
:products='[{"id":2238,"name":"Korean Steak Bowl","description":"<div>Shaved ribeye &amp; rice.</div>","type":"meal","price":"12.49","on_sale":false,"sale_price":"0.00","media_urls":{"images":[{"original":"https://example.com/korean.png"}]},"dietary_concerns":["Gluten Free","Dairy Free"],"available":true,"details":{"calories":"590","protein":"50"}},{"id":9,"name":"Sale Bowl","description":"<div>On sale.</div>","price":"14.00","on_sale":true,"sale_price":"9.50","media_urls":{"images":[{"original":"https://example.com/sale.png"}]},"dietary_concerns":[],"available":true,"details":{"calories":"400cal","protein":"30g"}},{"id":3,"name":"Hidden Bowl","description":"<div>Unavailable.</div>","price":"5.00","on_sale":false,"sale_price":"0.00","media_urls":{"images":[]},"dietary_concerns":[],"available":false,"details":{"calories":"100","protein":"10"}}]'
:newest-ids='[2238]'
></orders-page>
</body>
</html>

265
tests/test_fill_cart.py Normal file
View file

@ -0,0 +1,265 @@
"""CSV parsing and menu matching for the Redefine cart filler."""
import importlib.util
from pathlib import Path
from playwright.sync_api import Error as PlaywrightError
from scraper.fill_cart import build_plan, cart_lines, error_text, quantity_for
from scraper.parse_menu import extract_catalog_products
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "fill_redefine_cart.py"
_spec = importlib.util.spec_from_file_location("fill_redefine_cart", _SCRIPT)
fill_redefine_cart = importlib.util.module_from_spec(_spec)
assert _spec.loader is not None
_spec.loader.exec_module(fill_redefine_cart)
PRODUCTS = [
{
"name": "Korean Steak Bowl",
"uuid": "korean-id",
"available": True,
},
{
"name": "Sale Bowl",
"uuid": "sale-id",
"available": True,
},
{
"name": "Hidden Bowl",
"uuid": "hidden-id",
"available": False,
},
{
"name": "Twin Bowl",
"uuid": "twin-a",
"available": True,
},
{
"name": "twin bowl",
"uuid": "twin-b",
"available": True,
},
{
"name": "No Id Bowl",
"uuid": "",
"available": True,
},
]
def test_build_plan_matches_names_and_skips_the_rest():
csv_text = "\n".join(
[
"Item,Quantity",
"korean steak bowl,4",
"Sale Bowl,2",
"Sale Bowl,3",
"Hidden Bowl,1",
"Twin Bowl,8",
"Missing Bowl,1",
"No Id Bowl,1",
"=cmd,1",
"Zero Bowl,0",
"Bad Qty,nope",
]
)
# The admin exporter prefixes formula-like names with an apostrophe.
csv_text = csv_text.replace("=cmd", "'=cmd")
plan = build_plan(csv_text, PRODUCTS)
assert [(item.name, item.quantity, item.uuid) for item in plan.adds] == [
("Korean Steak Bowl", 4, "korean-id"),
("Sale Bowl", 5, "sale-id"),
]
assert [(line.name, line.reason) for line in plan.skipped] == [
("Bad Qty", "invalid quantity 'nope'"),
("Hidden Bowl", "unavailable on the menu"),
("Twin Bowl", "matches more than one menu item"),
("Missing Bowl", "not on the menu"),
("No Id Bowl", "not on the menu"),
("=cmd", "not on the menu"),
]
assert plan.ignored_zero == 1
def test_build_plan_reads_quoted_fields_and_a_byte_order_mark():
csv_text = '\ufeffItem,Quantity\r\n"Bowl, Large",2\r\n'
products = [{"name": "Bowl, Large", "uuid": "bowl", "available": True}]
plan = build_plan(csv_text, products)
assert plan.adds[0].name == "Bowl, Large"
assert plan.adds[0].quantity == 2
assert plan.skipped == ()
def test_build_plan_requires_the_admin_columns():
try:
build_plan("Meal,Qty\nSoup,1\n", PRODUCTS)
except ValueError as exc:
assert "Item and Quantity" in str(exc)
else:
raise AssertionError("expected a column error")
def test_extract_catalog_products_reads_the_menu_attribute():
page = """
<orders-page
:products='[{"name":"Korean Steak Bowl","uuid":"korean-id","available":true}]'
:newest-ids='[]'
></orders-page>
"""
assert extract_catalog_products(page)[0]["uuid"] == "korean-id"
def test_cart_lines_use_the_product_uuid_and_quantity():
cart = {
"items": [
{
"uuid": "line-1",
"quantity": "4",
"product": {"name": "Korean Steak Bowl", "uuid": "korean-id"},
}
]
}
lines = cart_lines(cart)
assert lines[0].name == "Korean Steak Bowl"
assert lines[0].product_uuid == "korean-id"
assert quantity_for(lines, "korean-id") == 4
assert quantity_for(lines, "line-1") == 0
class _CartPage:
def __init__(self, responses):
self._responses = list(responses)
def goto(self, url, **kwargs):
return None
def evaluate(self, script, payload):
response = self._responses.pop(0)
if isinstance(response, Exception):
raise response
return response
def test_main_rejects_a_non_utf8_csv(tmp_path, capsys):
path = tmp_path / "order.csv"
path.write_bytes(b"Item,Quantity\nCaf\xe9 Bowl,1\n")
code = fill_redefine_cart.main([str(path)])
error = capsys.readouterr().err
assert code == 1
assert error.strip() == "CSV must be UTF-8."
assert "Traceback" not in error
def test_fill_browser_prints_a_summary_when_the_page_cannot_open(capsys):
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
class ClosedBrowser:
def __init__(self):
self.closed = False
def new_page(self):
raise PlaywrightError("browser has been closed")
def close(self):
self.closed = True
browser = ClosedBrowser()
code = fill_redefine_cart._fill_browser(
browser, plan, "https://www.redefinemeals.com/menu"
)
output = capsys.readouterr().out
assert code == 1
assert browser.closed
assert "Traceback" not in output
assert "Added 0. Failed 1. Skipped 0." in output
def test_fill_page_prints_a_summary_when_the_window_closes_mid_add(capsys):
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
page = _CartPage(
[
{"ok": True, "status": 200, "data": {"items": []}},
PlaywrightError("Target page, context or browser has been closed"),
]
)
code = fill_redefine_cart._fill_page(
page, plan, "https://www.redefinemeals.com/menu"
)
output = capsys.readouterr().out
assert code == 1
assert "Traceback" not in output
assert "Added 0. Failed 1. Skipped 0." in output
assert "Cart is ready" not in output
def test_fill_page_counts_items_left_when_the_window_closes(capsys):
plan = build_plan(
"Item,Quantity\nKorean Steak Bowl,1\nSale Bowl,2\n",
PRODUCTS,
)
page = _CartPage(
[
{"ok": True, "status": 200, "data": {"items": []}},
PlaywrightError("Target page, context or browser has been closed"),
]
)
code = fill_redefine_cart._fill_page(
page, plan, "https://www.redefinemeals.com/menu"
)
output = capsys.readouterr().out
assert code == 1
assert "Korean Steak Bowl:" in output
assert "Sale Bowl: not attempted" in output
assert "Added 0. Failed 2. Skipped 0." in output
def test_fill_page_prints_a_summary_when_the_first_cart_read_fails(capsys):
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
page = _CartPage(
[
{
"ok": False,
"status": 500,
"data": {"message": "cart unavailable", "trace": [{"file": "x"}]},
}
]
)
code = fill_redefine_cart._fill_page(
page, plan, "https://www.redefinemeals.com/menu"
)
output = capsys.readouterr().out
assert code == 1
assert "HTTP 500: cart unavailable" in output
assert "x" not in output.split("Failed:", 1)[-1]
assert "Added 0. Failed 1. Skipped 0." in output
def test_error_text_keeps_the_message_and_drops_the_trace():
body = {
"message": "Product is not available.",
"exception": "HttpException",
"file": "/home/app/secret.php",
"trace": [{"file": "/home/app/secret.php"}],
}
text = error_text(422, body)
assert text == "HTTP 422: Product is not available."
assert "secret.php" not in text

View file

@ -75,18 +75,20 @@ def _render_for_browser(*, google: bool = False) -> str:
return html
def _google_credential() -> str:
payload = json.dumps(
{
"name": "Test Admin",
"email": "test-admin@example.com",
"picture": (
"data:image/svg+xml,"
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
),
}
).encode()
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
def _google_credential(extra: dict | None = None) -> str:
payload_obj = {
"name": "Test Admin",
"email": "test-admin@example.com",
"picture": (
"data:image/svg+xml,"
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
),
}
if extra:
payload_obj.update(extra)
encoded = (
base64.urlsafe_b64encode(json.dumps(payload_obj).encode()).decode().rstrip("=")
)
return f"e30.{encoded}.signature"
@ -156,9 +158,11 @@ class TestGenerateFormStructural:
assert "x-api-key" not in html
def test_cloud_configuration_has_no_form_api_key(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
for text in (workflow, http_api):
publish_job = (
REPO_ROOT / "src" / "server" / "jobs" / "publish_menu.py"
).read_text()
for text in (http_api, publish_job):
assert "FORM_APIKEY" not in text
assert "form-api-key" not in text
assert "x-api-key" not in text
@ -169,19 +173,30 @@ class TestGenerateFormStructural:
assert "SUBMIT_RATE_PER_SEC = 5.0" in http_api
assert "def _allow_submit()" in http_api
def test_weekly_menu_uses_hmac_publish_without_dynamodb(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
def test_manual_publish_stays_hmac_and_scheduled_job_is_in_process(self):
script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text()
locals_tf = (REPO_ROOT / "terraform" / "locals.tf").read_text()
scheduler = (REPO_ROOT / "terraform" / "scheduler.tf").read_text()
publish_job = (
REPO_ROOT / "src" / "server" / "jobs" / "publish_menu.py"
).read_text()
for route in ("/api/publish/settings", "/api/publish/menu"):
assert route in script
assert 'authorizer = "HMAC"' in locals_tf
assert "X-Meals-Publish-Key" in script
assert "scripts/upload_menu.py settings" in workflow
assert "scripts/upload_menu.py publish" in workflow
assert "aws dynamodb" not in workflow
assert 'boto3.resource("dynamodb")' not in script
assert 'event = "publish_menu"' in scheduler
assert 'schedule = "cron(30 7 ? * MON *)"' in scheduler
assert "put_menu" in publish_job
assert not (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").exists()
def test_explicit_week_is_embedded_in_the_form(self):
menu, config = _load_fixtures()
html = generate_form(menu, config, week="2026-W38")
config_blob = _extract_config(html)
assert config_blob["week"] == "2026-W38"
assert "/api/form-status/2026-W38" in html
def test_local_and_google_render(self):
local = _render(google=False)
@ -410,6 +425,7 @@ class TestGenerateFormPlaywright:
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},
@ -933,6 +949,7 @@ class TestGenerateFormGooglePlaywright:
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},
@ -1007,6 +1024,105 @@ class TestGenerateFormGooglePlaywright:
finally:
browser.close()
def test_refresh_restores_google_session(self, tmp_path):
sync_api = pytest.importorskip("playwright.sync_api")
sync_playwright = sync_api.sync_playwright
out = tmp_path / "google-session-form.html"
out.write_text(_render_for_browser(google=True))
credential = _google_credential({"exp": 2_000_000_000})
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=True)
except Exception as exc:
raise RuntimeError(
"Chromium is required for form Playwright tests. "
"Run: playwright install --with-deps chromium"
) from exc
try:
page = browser.new_page()
page.add_init_script(
"""window.google = {
accounts: {
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},
};"""
)
page.route(
"https://accounts.google.com/gsi/client",
lambda route: route.abort(),
)
_mock_form_routes(page)
page.goto(out.as_uri(), wait_until="domcontentloaded")
page.evaluate(
"(token) => handleCredentialResponse({ credential: token })",
credential,
)
page.wait_for_function(
"""() => {
const app = document.getElementById('app');
return app && !app.classList.contains('is-hidden');
}"""
)
assert page.evaluate(
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const app = document.getElementById('app');
const overlay = document.getElementById('auth-overlay');
const email = document.getElementById('user-email');
return app && !app.classList.contains('is-hidden')
&& overlay && overlay.classList.contains('is-hidden')
&& email && email.textContent === 'test-admin@example.com';
}"""
)
page.evaluate("signOut()")
assert (
page.evaluate(
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
)
is None
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const overlay = document.getElementById('auth-overlay');
const app = document.getElementById('app');
return overlay && !overlay.classList.contains('is-hidden')
&& app && app.classList.contains('is-hidden');
}"""
)
page.evaluate(
"(token) => sessionStorage.setItem('seahaven.meals.googleIdToken', token)",
_google_credential({"exp": 1}),
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const overlay = document.getElementById('auth-overlay');
const app = document.getElementById('app');
return overlay && !overlay.classList.contains('is-hidden')
&& app && app.classList.contains('is-hidden')
&& !sessionStorage.getItem('seahaven.meals.googleIdToken');
}"""
)
finally:
browser.close()
@pytest.fixture(scope="class")
@classmethod
def signed_in_admin_page(cls, tmp_path_factory):
@ -1036,6 +1152,7 @@ class TestGenerateFormGooglePlaywright:
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},

View file

@ -0,0 +1,47 @@
"""OpenAPI 3.1 + Redocly recommended, matching internal-portal (DEV-289)."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_openapi_uses_redocly_recommended():
redocly = (ROOT / ".redocly.yaml").read_text()
package = (ROOT / "package.json").read_text()
spec = (ROOT / "openapi.yaml").read_text()
ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text()
assert "extends:" in redocly
assert "- recommended" in redocly
assert "operation-2xx-response: off" in redocly
assert "operation-4xx-response: error" in redocly
assert "rule/operation-2xx-or-3xx-response" in redocly
assert "severity: error" in redocly
health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0]
assert '"403":' in health
assert '"400":' not in health
roster = spec.split("/api/roster:", 1)[1].split("\n /", 1)[0]
assert '"403":' in roster
assert '"400":' not in roster
form_status = spec.split("/api/form-status/{week}:", 1)[1].split("\n /", 1)[0]
assert '"400":' in form_status
menu = spec.split("/api/menu/{week}:", 1)[1].split("\n /", 1)[0]
orders = spec.split("/api/orders/{week}:", 1)[1].split("\n /", 1)[0]
assert "MenuWeekPath" in menu
assert "MenuWeekPath" in form_status
assert "OrderWeekPath" in orders
assert "WeekPath" not in spec.split("components:", 1)[1].split("MenuWeekPath", 1)[0]
assert "`current` or `YYYY-WNN`" in spec
assert "`YYYY-WNN` or `YYYY-MM-DD`" in spec
meal = spec.split(" Meal:", 1)[1].split("\n MenuPayload:", 1)[0]
assert 'type: [string, number, "null"]' in meal
assert 'type: [string, "null"]' in meal
assert 'menu_url:\n type: [string, "null"]' in spec
assert "root: openapi.yaml" in redocly
assert '"openapi:lint"' in package
dev = json.loads(package)["devDependencies"]
assert dev["@redocly/cli"]
assert "npm run openapi:lint" in ci
assert "openapi: 3.1.0" in spec
assert "required: [stage, sha]" in spec
assert "{ error: string }" in spec or "`{ error: string }`" in spec

66
tests/test_parse_menu.py Normal file
View file

@ -0,0 +1,66 @@
"""Parser for the catalog embedded on the Redefine menu page."""
from pathlib import Path
import pytest
from scraper.parse_menu import MenuParseError, extract_catalog_products, parse_menu_html
FIXTURE = Path(__file__).resolve().parent / "fixtures" / "menu_page.html"
def test_parse_menu_html_maps_catalog_fields():
page = FIXTURE.read_text()
menu = parse_menu_html(
page,
menu_url="https://www.redefinemeals.com/menu",
scraped_at="2026-09-21T07:30:00-04:00",
)
assert menu["meal_count"] == 2
assert menu["scraped_at"] == "2026-09-21T07:30:00-04:00"
korean, sale = menu["meals"]
assert korean["name"] == "Korean Steak Bowl"
assert korean["price"] == 12.49
assert korean["calories"] == 590
assert korean["protein"] == "50g"
assert korean["dietary_tags"] == ["Gluten Free", "Dairy Free"]
assert korean["image_url"] == "https://example.com/korean.png"
assert korean["is_new"] is True
assert korean["description"] == "Shaved ribeye & rice."
assert sale["name"] == "Sale Bowl"
assert sale["price"] == 9.50
assert sale["calories"] == 400
assert sale["protein"] == "30g"
assert sale["is_new"] is False
assert sale["description"] == "On sale."
def test_parse_menu_html_rejects_a_missing_catalog():
with pytest.raises(MenuParseError, match="missing :products"):
parse_menu_html("<html></html>", menu_url="https://example.com/menu")
def test_extract_catalog_products_rejects_an_empty_catalog():
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
with pytest.raises(MenuParseError, match="empty"):
extract_catalog_products(page)
def test_parse_menu_html_rejects_an_empty_catalog():
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
with pytest.raises(MenuParseError, match="empty"):
parse_menu_html(page, menu_url="https://example.com/menu")
def test_parse_menu_html_rejects_a_meal_without_a_price():
page = """
<orders-page
:products='[{"id":1,"name":"Broken","price":null,"on_sale":false,"available":true}]'
:newest-ids='[]'
></orders-page>
"""
with pytest.raises(MenuParseError, match="missing a price"):
parse_menu_html(page, menu_url="https://example.com/menu")

145
tests/test_publish_menu.py Normal file
View file

@ -0,0 +1,145 @@
"""publish_menu writes the menu, then the form, then Slack."""
from unittest.mock import MagicMock, patch
import pytest
from server.jobs import publish_menu
from server.jobs import run_job
MENU = {
"scraped_at": "2026-09-21T07:30:00-04:00",
"menu_url": "https://www.redefinemeals.com/menu",
"meal_count": 1,
"meals": [{"name": "Korean Steak Bowl", "price": 12.49}],
}
ENV = {
"MENU_URL": "https://www.redefinemeals.com/menu",
"GOOGLE_CLIENT_ID": "client.apps.googleusercontent.com",
"FORM_BUCKET": "meal-order-manager-form-test",
"DISTRIBUTION_ID": "E123",
"FORM_URL": "https://orders.seahaven.com",
"TABLE_NAME": "meal-order-manager-orders",
}
def _clients(s3, cloudfront):
def client(name, **_kwargs):
if name == "s3":
return s3
if name == "cloudfront":
return cloudfront
raise AssertionError(name)
return client
@patch("shared.slack.post_channel_message", return_value={"ok": True})
@patch("server.jobs.publish_menu.put_menu")
@patch(
"server.jobs.publish_menu.get_settings",
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 50},
)
@patch("server.jobs.publish_menu.generate_form", return_value="<html>form</html>")
@patch("server.jobs.publish_menu.current_week", return_value="2026-W38")
@patch("server.jobs.publish_menu.fetch_menu", return_value=MENU)
@patch("server.jobs.publish_menu.boto3.client")
def test_publish_menu_uploads_after_the_menu_write(
mock_client,
mock_fetch,
mock_week,
mock_generate,
mock_settings,
mock_put,
mock_slack,
):
s3 = MagicMock()
cloudfront = MagicMock()
mock_client.side_effect = _clients(s3, cloudfront)
order = []
mock_put.side_effect = lambda *args, **kwargs: order.append("menu")
s3.put_object.side_effect = lambda **kwargs: order.append(kwargs["Key"])
cloudfront.create_invalidation.side_effect = lambda **kwargs: order.append(
"invalidate"
)
mock_slack.side_effect = lambda *args, **kwargs: (
order.append("slack") or {"ok": True}
)
with patch.dict("os.environ", ENV, clear=False):
result = publish_menu.lambda_handler({}, None)
assert result == {"status": "published", "week": "2026-W38", "meal_count": 1}
mock_fetch.assert_called_once_with("https://www.redefinemeals.com/menu")
mock_week.assert_called_once()
mock_settings.assert_called_once()
mock_generate.assert_called_once()
assert mock_generate.call_args.kwargs["week"] == "2026-W38"
assert mock_generate.call_args.kwargs["google_client_id"] == ENV["GOOGLE_CLIENT_ID"]
assert mock_generate.call_args.kwargs["bulk_discount"] == 10
assert mock_generate.call_args.kwargs["company_subsidy"] == 50
mock_put.assert_called_once_with("2026-W38", MENU)
assert s3.put_object.call_count == 2
index = s3.put_object.call_args_list[0].kwargs
archive = s3.put_object.call_args_list[1].kwargs
assert index["Bucket"] == ENV["FORM_BUCKET"]
assert index["Key"] == "index.html"
assert index["CacheControl"] == "no-cache"
assert archive["Key"] == "archive/2026-W38.html"
invalidation = cloudfront.create_invalidation.call_args.kwargs
assert invalidation["DistributionId"] == "E123"
assert invalidation["InvalidationBatch"]["Paths"]["Items"] == ["/index.html"]
mock_slack.assert_called_once()
assert mock_slack.call_args.args[0] == (
"This week's meal order is open! Deadline: Thursday at 11:59 PM."
)
assert mock_slack.call_args.args[1][1]["text"]["text"] == (
f"*<{ENV['FORM_URL']}|Place your order>*\n\n*Deadline:* Thursday at 11:59 PM\n"
)
assert order == [
"menu",
"index.html",
"archive/2026-W38.html",
"invalidate",
"slack",
]
@patch("shared.slack.post_channel_message", return_value={"ok": True})
@patch("server.jobs.publish_menu.put_menu")
@patch("server.jobs.publish_menu.get_settings", return_value={})
@patch("server.jobs.publish_menu.generate_form", return_value="<html>form</html>")
@patch("server.jobs.publish_menu.current_week", return_value="2026-W38")
@patch("server.jobs.publish_menu.fetch_menu", return_value=MENU)
@patch("server.jobs.publish_menu.boto3.client")
def test_publish_menu_does_not_notify_when_upload_fails(
mock_client,
_fetch,
_week,
_generate,
_settings,
mock_put,
mock_slack,
):
s3 = MagicMock()
s3.put_object.side_effect = RuntimeError("s3 down")
mock_client.side_effect = _clients(s3, MagicMock())
with patch.dict("os.environ", ENV, clear=False):
with pytest.raises(RuntimeError, match="s3 down"):
publish_menu.lambda_handler({}, None)
mock_put.assert_called_once()
mock_slack.assert_not_called()
def test_run_job_dispatches_publish_menu():
with patch(
"server.jobs.publish_menu.lambda_handler", return_value={"status": "published"}
) as handler:
result = run_job({"event": "publish_menu"})
assert result == {"status": "published"}
handler.assert_called_once()

220
tests/test_sentry_init.py Normal file
View file

@ -0,0 +1,220 @@
"""sentry_init: DSN no-op, FlaskIntegration, and before_send scrub."""
from unittest.mock import patch
from botocore.exceptions import ClientError
from sentry_sdk.integrations.flask import FlaskIntegration
import server.sentry_init as sentry_mod
_FAKE_DSN = "https://key@o1.ingest.sentry.io/1"
def _parameter_not_found():
return ClientError(
{"Error": {"Code": "ParameterNotFound", "Message": "not found"}},
"GetParameter",
)
def test_unset_dsn_does_not_init():
with (
patch.dict("os.environ", {}, clear=False),
patch("sentry_sdk.init") as mocked,
):
# Ensure both sources are absent even if a prior test set them.
import os
os.environ.pop("SENTRY_DSN", None)
os.environ.pop("SENTRY_DSN_PARAM", None)
sentry_mod.init_sentry()
mocked.assert_not_called()
def test_empty_dsn_does_not_init(monkeypatch):
monkeypatch.setenv("SENTRY_DSN", "")
monkeypatch.delenv("SENTRY_DSN_PARAM", raising=False)
with patch("sentry_sdk.init") as mocked:
sentry_mod.init_sentry()
mocked.assert_not_called()
def test_literal_unset_dsn_does_not_init(monkeypatch):
monkeypatch.setenv("SENTRY_DSN", "unset")
with patch("sentry_sdk.init") as mocked:
sentry_mod.init_sentry()
mocked.assert_not_called()
def test_set_dsn_inits_flask_integration(monkeypatch):
monkeypatch.setenv("SENTRY_DSN", _FAKE_DSN)
monkeypatch.setenv("STAGE", "dev")
monkeypatch.setenv("GIT_SHA", "abc123def")
with patch("sentry_sdk.init") as mocked:
sentry_mod.init_sentry()
mocked.assert_called_once()
kwargs = mocked.call_args.kwargs
assert kwargs["dsn"] == _FAKE_DSN
assert kwargs["send_default_pii"] is False
assert kwargs["include_local_variables"] is False
assert kwargs["enable_logs"] is False
assert kwargs["traces_sample_rate"] == 0.0
assert kwargs["before_send"] is sentry_mod._before_send
assert kwargs["environment"] == "dev"
assert kwargs["release"] == "abc123def"
integrations = kwargs["integrations"]
assert len(integrations) == 1
assert isinstance(integrations[0], FlaskIntegration)
def test_missing_stage_defaults_environment_to_local(monkeypatch):
monkeypatch.setenv("SENTRY_DSN", _FAKE_DSN)
monkeypatch.delenv("STAGE", raising=False)
monkeypatch.delenv("GIT_SHA", raising=False)
with patch("sentry_sdk.init") as mocked:
sentry_mod.init_sentry()
kwargs = mocked.call_args.kwargs
assert kwargs["environment"] == "local"
assert "release" not in kwargs
def test_sentry_dsn_param_fetches_from_ssm(monkeypatch):
monkeypatch.delenv("SENTRY_DSN", raising=False)
monkeypatch.setenv("SENTRY_DSN_PARAM", "/meal-order-manager/sentry-dsn")
monkeypatch.setenv("STAGE", "dev")
monkeypatch.setenv("GIT_SHA", "deadbeef")
with (
patch("shared.secrets.get_parameter", return_value=_FAKE_DSN) as mock_get,
patch("sentry_sdk.init") as mocked,
):
sentry_mod.init_sentry()
mock_get.assert_called_once_with("/meal-order-manager/sentry-dsn", decrypt=True)
mocked.assert_called_once()
assert mocked.call_args.kwargs["dsn"] == _FAKE_DSN
assert isinstance(mocked.call_args.kwargs["integrations"][0], FlaskIntegration)
def test_sentry_dsn_param_unset_value_does_not_init(monkeypatch):
monkeypatch.delenv("SENTRY_DSN", raising=False)
monkeypatch.setenv("SENTRY_DSN_PARAM", "/meal-order-manager/sentry-dsn")
with (
patch("shared.secrets.get_parameter", return_value="unset"),
patch("sentry_sdk.init") as mocked,
):
sentry_mod.init_sentry()
mocked.assert_not_called()
def test_sentry_dsn_param_not_found_does_not_init(monkeypatch):
monkeypatch.delenv("SENTRY_DSN", raising=False)
monkeypatch.setenv("SENTRY_DSN_PARAM", "/meal-order-manager/sentry-dsn")
with (
patch("shared.secrets.get_parameter", side_effect=_parameter_not_found()),
patch("sentry_sdk.init") as mocked,
):
sentry_mod.init_sentry()
mocked.assert_not_called()
def test_before_send_strips_auth_and_publish_key_headers():
event = {
"request": {
"headers": {
"Authorization": "Bearer secret",
"X-Meals-Publish-Key": "hmac-secret",
"X-Auth-Token": "tok",
"Cookie": "session=abc",
"X-Amz-Date": "20260101T000000Z",
"Content-Type": "application/json",
},
"url": "https://example.invalid/api/submit-order",
}
}
out = sentry_mod._before_send(event, {})
assert out["request"]["headers"] == {"Content-Type": "application/json"}
assert out["request"]["url"] == "https://example.invalid/api/submit-order"
def test_before_send_strips_list_headers():
event = {
"request": {
"headers": [
("Authorization", "Bearer secret"),
("X-Meals-Publish-Key", "hmac-secret"),
("Content-Type", "application/json"),
]
}
}
out = sentry_mod._before_send(event, {})
assert out["request"]["headers"] == [("Content-Type", "application/json")]
def test_before_send_drops_body_and_secret_keys():
event = {
"request": {
"body": '{"google_id_token":"ya29.secret"}',
"data": {"google_id_token": "ya29.secret"},
"method": "POST",
},
"extra": {
"google_id_token": "ya29.secret",
"publish_hmac": "aabbcc",
"bot_token": "xoxb-secret",
"week": "2026-W38",
},
}
out = sentry_mod._before_send(event, {})
assert "body" not in out["request"]
assert "data" not in out["request"]
assert out["request"]["method"] == "POST"
assert "google_id_token" not in out["extra"]
assert "publish_hmac" not in out["extra"]
assert "bot_token" not in out["extra"]
assert out["extra"]["week"] == "2026-W38"
def test_before_send_drops_exception_and_thread_frame_locals():
event = {
"exception": {
"values": [
{
"stacktrace": {
"frames": [
{
"function": "handler",
"vars": {
"google_id_token": "ya29.secret",
"SecretString": "aabbcc",
},
}
]
}
}
]
},
"threads": {
"values": [
{
"stacktrace": {
"frames": [
{
"function": "_require_publish_key",
"vars": {"provided": "hmac-secret"},
}
]
}
}
]
},
"stacktrace": {
"frames": [{"function": "get_secret", "vars": {"item": {"token": "x"}}}]
},
}
out = sentry_mod._before_send(event, {})
assert "vars" not in out["exception"]["values"][0]["stacktrace"]["frames"][0]
assert "vars" not in out["threads"]["values"][0]["stacktrace"]["frames"][0]
assert "vars" not in out["stacktrace"]["frames"][0]
assert (
out["exception"]["values"][0]["stacktrace"]["frames"][0]["function"]
== "handler"
)

View file

@ -1446,6 +1446,33 @@ def test_form_status_open(mock_week, mock_status):
assert "reopen_at" not in body, "reopen_at should NOT be present when form is open"
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
def test_form_status_current_maps_to_current_week(mock_week, mock_status):
from submit_order_handler import lambda_handler
event = _make_event(
method="GET", path="/form-status/current", path_parameters={"week": "current"}
)
status, body = _parse_response(lambda_handler(event, None))
assert status == 200, f"Expected 200, got {status}: {body}"
assert body["week"] == "2026-W20"
mock_status.assert_called_once_with("2026-W20")
@patch("submit_order_handler.get_form_status")
def test_form_status_rejects_invalid_week(mock_status):
from submit_order_handler import lambda_handler
event = _make_event(
method="GET", path="/form-status/nope", path_parameters={"week": "nope"}
)
status, body = _parse_response(lambda_handler(event, None))
assert status == 400, f"Expected 400, got {status}: {body}"
assert "week path param" in body["error"]
mock_status.assert_not_called()
@patch("submit_order_handler.get_form_status", return_value="closed")
@patch("submit_order_handler.current_week", return_value="2026-W20")
def test_form_status_closed_reopen_at(mock_week, mock_status):
@ -2137,6 +2164,24 @@ def test_my_orders_empty_when_none(mock_looks_like, mock_portal, mock_get):
assert body == {"week": "2026-W36", "orders": []}
@patch("submit_order_handler.get_order")
@patch(
"submit_order_handler._verify_portal_token",
return_value={
"name": "Portal Employee",
"email": "portal.employee@seahavenind.com",
},
)
@patch("submit_order_handler.looks_like_cognito_token", return_value=True)
def test_my_orders_rejects_current_week(mock_looks_like, mock_portal, mock_get):
status, body = _parse_response(
submit_order_handler.lambda_handler(_orders_event(week="current"), None)
)
assert status == 400
assert "YYYY-WNN" in body["error"]
mock_get.assert_not_called()
def test_my_orders_requires_bearer():
status, body = _parse_response(
submit_order_handler.lambda_handler(_orders_event(token=""), None)

View file

@ -0,0 +1,18 @@
"""githubdeploy OIDC trust pins the org reusable with AWS-supported claims."""
from pathlib import Path
IAM = Path(__file__).resolve().parents[1] / "terraform" / "iam_github_deploy.tf"
def test_github_deploy_trust_uses_org_reusable_and_caller():
text = IAM.read_text()
assert "token.actions.githubusercontent.com:sub" in text
assert "repo:Sea-Haven-Industries/meal-order-manager:environment:dev" in text
assert "repo:Sea-Haven-Industries/meal-order-manager:environment:prod" in text
assert (
"Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*" in text
)
assert "token.actions.githubusercontent.com:job_workflow_ref" in text
assert "token.actions.githubusercontent.com:workflow_ref" not in text
assert "cd-hcp-spa.yaml" not in text

View file

@ -5,6 +5,14 @@ from pathlib import Path
IAM = Path(__file__).resolve().parents[1] / "terraform" / "iam.tf"
def test_task_boundary_can_publish_the_order_form():
text = IAM.read_text()
assert 'sid = "FormObjects"' in text
assert "cloudfront:CreateInvalidation" in text
assert "${aws_s3_bucket.form.arn}/index.html" in text
assert not (IAM.parent / "iam_github_weekly_menu.tf").exists()
def test_checkcomponents_send_omitted_when_queue_arn_empty():
text = IAM.read_text()
assert "compact([var.checkcomponents_queue_arn])" not in text

View file

@ -0,0 +1,40 @@
"""Sentry DSN is an SSM SecureString; the task receives the parameter name."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TERRAFORM = ROOT / "terraform"
def _read(name: str) -> str:
return (TERRAFORM / name).read_text()
def test_sentry_dsn_is_secure_string_stub():
ssm_tf = _read("ssm.tf")
assert 'resource "aws_ssm_parameter" "sentry_dsn"' in ssm_tf
assert 'name = "${local.ssm_prefix}/sentry-dsn"' in ssm_tf
assert 'type = "SecureString"' in ssm_tf
assert 'value = "unset"' in ssm_tf
assert "ignore_changes = [value]" in ssm_tf
assert 'data "aws_ssm_parameter" "sentry_dsn_value"' not in ssm_tf
def test_ecs_task_receives_sentry_dsn_parameter_name():
ecs_tf = _read("ecs.tf")
assert ecs_tf.count("SENTRY_DSN_PARAM") == 1
assert "aws_ssm_parameter.sentry_dsn.name" in ecs_tf
assert "SENTRY_DSN " not in ecs_tf
def test_terraform_does_not_embed_a_sentry_dsn():
for path in TERRAFORM.glob("*.tf"):
text = path.read_text()
assert "ingest.sentry.io" not in text
assert "SENTRY_DSN =" not in text
def test_deploy_api_injects_sentry_dsn_param():
workflow = (ROOT / ".github/workflows/deploy-api.yaml").read_text()
assert "extra-task-env:" in workflow
assert '"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"' in workflow

View file

@ -1,4 +1,4 @@
"""Meals owns a dedicated VPC. Prod has no default VPC."""
"""Meals creates a VPC unless existing_vpc_id is set (prod shares afterhours)."""
from pathlib import Path
@ -14,13 +14,32 @@ def test_meals_owns_a_vpc_instead_of_looking_up_default():
vpc = _read("vpc.tf")
ecs = _read("ecs.tf")
locals_tf = _read("locals.tf")
variables = _read("variables.tf")
data = _read("data.tf")
assert 'resource "aws_vpc" "this"' in vpc
assert "cidr_block = local.vpc_cidr" in vpc
assert 'vpc_cidr = "10.60.0.0/16"' in locals_tf
assert "count = local.manage_vpc ? 1 : 0" in vpc
assert 'variable "existing_vpc_id"' in variables
assert 'variable "existing_public_subnet_ids"' in variables
assert (
'manage_vpc = var.existing_vpc_id == "" && !local.is_prod' in locals_tf
)
assert 'data "aws_vpc" "default"' not in ecs
assert "data.aws_vpc.default" not in ecs
assert "data.aws_subnets.default" not in ecs
assert "aws_vpc.this.id" in ecs
assert "aws_subnet.public[*].id" in ecs
assert "vpc_id = local.vpc_id" in ecs
assert "subnets = local.public_subnet_ids" in ecs
assert "subnets = local.public_subnet_ids" in ecs
assert "ec2:CreateVpc" in _read("hcp_iam.tf")
assert 'check "existing_vpc_pair"' in data
assert 'check "existing_subnets_in_vpc"' in data
assert "from = aws_vpc.this" in vpc
assert "to = aws_vpc.this[0]" in vpc
outputs = _read("outputs.tf")
assert 'output "vpc_id"' in outputs
assert "value = local.vpc_id" in outputs
assert 'output "public_subnet_ids"' in outputs
assert 'check "prod_reuses_afterhours_vpc"' in data
assert "prod_requires_afterhours_vpc" in vpc
assert "Do not mint 10.60." in vpc
assert 'count = var.existing_vpc_id == "" ? 0 : 1' in vpc

View file

@ -51,3 +51,20 @@ def test_worker_deletes_completed_jobs(mock_run, mock_client):
sqs.delete_message.assert_called_once_with(
QueueUrl="https://sqs.example/jobs", ReceiptHandle="rh-1"
)
@patch("server.worker.sentry_sdk.capture_exception")
@patch("server.worker.boto3.client")
@patch("server.worker.run_job")
def test_worker_captures_job_failures(mock_run, mock_client, mock_capture):
sqs = MagicMock()
mock_client.return_value = sqs
sqs.receive_message.side_effect = _one_message_then_stop({"event": "close"})
mock_run.side_effect = RuntimeError("boom")
with patch.dict("os.environ", {"JOBS_QUEUE_URL": "https://sqs.example/jobs"}):
worker._running = True
worker.main()
mock_capture.assert_called_once()
sqs.delete_message.assert_not_called()