mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-06 19:21:57 +00:00
Compare commits
25 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b39c8b27ea | ||
|
|
f2ef1df881 | ||
|
|
2fe7133411 | ||
|
|
cfa14c4240 | ||
|
|
3bd98aad8d | ||
|
|
7943207e97 | ||
|
|
c15ea8cee1 | ||
|
|
69359df69d | ||
|
|
bf1f3ea182 | ||
|
|
cc506f3c1f | ||
|
|
517e404e75 | ||
|
|
aa15277112 | ||
|
|
4d1c79b110 | ||
|
|
fdf595ea19 | ||
|
|
f7957436bd | ||
|
|
f632020b20 | ||
|
|
7574fc471a | ||
|
|
63b31fcdc3 | ||
|
|
449cc10b9f | ||
|
|
78f6d1dbe4 | ||
|
|
77780899c2 | ||
|
|
d7ad49d00f | ||
|
|
fb3a181e0c | ||
|
|
596e949eef | ||
|
|
3efff5e784 |
59 changed files with 3235 additions and 967 deletions
32
.github/workflows/ci-terraform.yaml
vendored
32
.github/workflows/ci-terraform.yaml
vendored
|
|
@ -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
|
||||
59
.github/workflows/ci.yml
vendored
59
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
225
.github/workflows/deploy-api.yaml
vendored
225
.github/workflows/deploy-api.yaml
vendored
|
|
@ -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"}'
|
||||
|
|
|
|||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
179
.github/workflows/weekly-menu.yml
vendored
179
.github/workflows/weekly-menu.yml
vendored
|
|
@ -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"
|
||||
|
|
@ -1 +0,0 @@
|
|||
extends: .github
|
||||
32
.redocly.yaml
Normal file
32
.redocly.yaml
Normal 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
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
58
README.md
58
README.md
|
|
@ -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
665
openapi.yaml
Normal 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
46
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
playwright==1.63.0
|
||||
flask==3.1.3
|
||||
boto3==1.43.97
|
||||
boto3==1.43.107
|
||||
jinja2==3.1.6
|
||||
|
|
|
|||
271
scripts/fill_redefine_cart.py
Normal file
271
scripts/fill_redefine_cart.py
Normal 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
0
src/scraper/__init__.py
Normal file
203
src/scraper/fill_cart.py
Normal file
203
src/scraper/fill_cart.py
Normal 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
176
src/scraper/parse_menu.py
Normal 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
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
131
src/server/jobs/publish_menu.py
Normal file
131
src/server/jobs/publish_menu.py
Normal 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
177
src/server/sentry_init.py
Normal 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()
|
||||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
106
terraform/.terraform.lock.hcl
generated
106
terraform/.terraform.lock.hcl
generated
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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@*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
9
tests/fixtures/menu_page.html
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<orders-page
|
||||
:products='[{"id":2238,"name":"Korean Steak Bowl","description":"<div>Shaved ribeye & 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
265
tests/test_fill_cart.py
Normal 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
|
||||
|
|
@ -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() {},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
47
tests/test_openapi_contract.py
Normal file
47
tests/test_openapi_contract.py
Normal 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
66
tests/test_parse_menu.py
Normal 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
145
tests/test_publish_menu.py
Normal 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
220
tests/test_sentry_init.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
18
tests/test_terraform_github_deploy.py
Normal file
18
tests/test_terraform_github_deploy.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
40
tests/test_terraform_sentry.py
Normal file
40
tests/test_terraform_sentry.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue