mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #571 from MBombeck/release/v1.31.4
release: v1.31.4 operational hardening
This commit is contained in:
40 files changed
+1782
-548
No files matched your search
@@ -37,6 +37,57 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-policy:
|
||||
name: Validate release policy
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate release policy
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
echo "::notice::Pull-request image validation does not publish release tags."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$GITHUB_REF_TYPE" != "tag" ] || [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "::error::Release ref '$GITHUB_REF_NAME' is not a strict SemVer tag (expected vMAJOR.MINOR.PATCH with no leading zeroes or prerelease suffix)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
if [ "$package_version" != "$tag_version" ]; then
|
||||
echo "::error::package.json version '$package_version' does not match release tag '$GITHUB_REF_NAME'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
|
||||
echo "::error::Release commit $GITHUB_SHA is not an ancestor of origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
successful_quality_runs="$(
|
||||
gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA&event=push&status=success&per_page=100" \
|
||||
--jq '[.workflow_runs[] | select(.name == "Security & Quality" and .head_branch == "main")] | length'
|
||||
)"
|
||||
if [ "$successful_quality_runs" -lt 1 ]; then
|
||||
echo "::error::Release commit $GITHUB_SHA has no successful prior Security & Quality push run on main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release policy passed for $GITHUB_REF_NAME at $GITHUB_SHA."
|
||||
|
||||
# ── Stage 1 ────────────────────────────────────────────────────────
|
||||
# Build each architecture on its own native GitHub-hosted runner so
|
||||
# Next.js's static-page workers run on real silicon instead of QEMU
|
||||
@@ -54,6 +105,7 @@ jobs:
|
||||
# manifest in a third step, so consumers see a single tag that
|
||||
# auto-selects on `docker pull` per local platform.
|
||||
build:
|
||||
needs: [release-policy]
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# 25 min wall clock — native amd64 build finished inside 12 min
|
||||
@@ -265,6 +317,11 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: [build]
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
image_ref: ${{ steps.published.outputs.image_ref }}
|
||||
image_tag: ${{ steps.published.outputs.image_tag }}
|
||||
image_digest: ${{ steps.published.outputs.image_digest }}
|
||||
build_sha: ${{ steps.published.outputs.build_sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -335,9 +392,21 @@ jobs:
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE_REF }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect published manifest
|
||||
- name: Resolve published image
|
||||
id: published
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
|
||||
image_digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
if [[ ! "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "::error::Could not resolve a valid manifest digest for ${IMAGE_REF}:${IMAGE_TAG}." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "image_ref=$IMAGE_REF" >> "$GITHUB_OUTPUT"
|
||||
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "image_digest=$image_digest" >> "$GITHUB_OUTPUT"
|
||||
echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
||||
docker buildx imagetools inspect "${IMAGE_REF}@${image_digest}"
|
||||
|
||||
# v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore).
|
||||
# Signing the INDEX digest covers both per-platform manifests in
|
||||
@@ -357,91 +426,59 @@ jobs:
|
||||
|
||||
- name: Sign multi-arch manifest (keyless)
|
||||
env:
|
||||
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
|
||||
IMAGE_DIGEST: ${{ steps.published.outputs.image_digest }}
|
||||
run: |
|
||||
digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
if [ -z "$digest" ]; then
|
||||
echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted."
|
||||
exit 1
|
||||
fi
|
||||
echo "Signing ${IMAGE_REF}@${digest}"
|
||||
cosign sign --yes "${IMAGE_REF}@${digest}"
|
||||
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}"
|
||||
echo "Signing ${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
cosign sign --yes "${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${IMAGE_DIGEST}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
|
||||
# v1.4.15: trigger the Coolify deploy automatically once GHCR has
|
||||
# the new image. Replaces the former manual SSH + force-pull recipe
|
||||
# on the deployment host.
|
||||
#
|
||||
# Runs only on the publish path (push to `main` or to a `v*` tag), never
|
||||
# on PRs. `continue-on-error: true` because the image is *already*
|
||||
# published to GHCR by the time we reach this step — a Coolify-side
|
||||
# outage must not retroactively fail the build. The maintainer still
|
||||
# has the manual recipe as fallback.
|
||||
#
|
||||
# Required GitHub repository secrets (set by the maintainer once):
|
||||
# COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false
|
||||
# COOLIFY_TOKEN = Bearer token from Coolify UI -> Keys & Tokens
|
||||
# Both secrets are referenced exclusively here. If either is unset, the
|
||||
# step short-circuits to a skip rather than calling Coolify with empty
|
||||
# auth.
|
||||
#
|
||||
# v1.4.26 P6-3 — maintainer toggle.
|
||||
# Three releases in a row shipped via the host-side SSH-retag fallback
|
||||
# because the secrets above silently absent + the warn-only skip
|
||||
# below let the workflow stay green. The `vars.COOLIFY_AUTO_DEPLOY`
|
||||
# repository variable now distinguishes the two intended modes:
|
||||
#
|
||||
# COOLIFY_AUTO_DEPLOY=on → if secrets present, deploy; if absent,
|
||||
# FAIL the step so the maintainer sees the
|
||||
# missing-secret gap on the next release.
|
||||
# COOLIFY_AUTO_DEPLOY=off → unconditional skip with a notice. Use
|
||||
# this when the host-side SSH recipe is
|
||||
# the intended deploy path.
|
||||
# (unset / any other) → warn-and-skip (legacy behaviour).
|
||||
#
|
||||
# Set the variable under repo Settings → Secrets and variables →
|
||||
# Actions → Variables (NOT Secrets — it is intentionally readable).
|
||||
verify-published-image:
|
||||
name: Verify exact published image
|
||||
needs: merge
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
uses: ./.github/workflows/post-publish-verify.yml
|
||||
with:
|
||||
image_ref: ${{ needs.merge.outputs.image_ref }}
|
||||
image_tag: ${{ needs.merge.outputs.image_tag }}
|
||||
image_digest: ${{ needs.merge.outputs.image_digest }}
|
||||
build_sha: ${{ needs.merge.outputs.build_sha }}
|
||||
secrets: inherit
|
||||
|
||||
promote:
|
||||
name: Promote verified image to Coolify
|
||||
needs: [merge, verify-published-image]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Trigger Coolify deploy
|
||||
continue-on-error: true
|
||||
env:
|
||||
COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }}
|
||||
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
|
||||
COOLIFY_AUTO_DEPLOY: ${{ vars.COOLIFY_AUTO_DEPLOY }}
|
||||
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
|
||||
IMAGE_TAG: ${{ needs.merge.outputs.image_tag }}
|
||||
IMAGE_DIGEST: ${{ needs.merge.outputs.image_digest }}
|
||||
BUILD_SHA: ${{ needs.merge.outputs.build_sha }}
|
||||
run: |
|
||||
mode="${COOLIFY_AUTO_DEPLOY:-}"
|
||||
if [ "$mode" = "off" ]; then
|
||||
echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook. Maintainer uses the host-side SSH recipe instead."
|
||||
echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook."
|
||||
exit 0
|
||||
fi
|
||||
if [ "$mode" != "on" ]; then
|
||||
echo "::warning::COOLIFY_AUTO_DEPLOY is not 'on' or 'off' — skipping auto-deploy. Set an explicit mode in repository variables."
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then
|
||||
if [ "$mode" = "on" ]; then
|
||||
echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both under repo Settings → Secrets and variables → Actions, or set COOLIFY_AUTO_DEPLOY=off to silence."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable, or set the COOLIFY_AUTO_DEPLOY variable to 'off' to silence this warning."
|
||||
exit 0
|
||||
echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both credentials or disable promotion explicitly." >&2
|
||||
exit 1
|
||||
fi
|
||||
# v1.4.31 — wait for GHCR cache propagation before firing
|
||||
# the Coolify webhook. Five releases in a row (v1.4.27
|
||||
# through v1.4.30.1) the webhook reported "finished" but
|
||||
# Coolify still pulled the prior `:latest` digest. The
|
||||
# `docker buildx imagetools create` + `inspect` steps
|
||||
# above confirm the manifest exists at registry-write time,
|
||||
# but GHCR's CDN edges can take 30-90 s to propagate the
|
||||
# new digest. The webhook fires inside that window, so
|
||||
# Coolify pulls "latest" while the edge still serves the
|
||||
# prior digest, gets "no change", and returns success.
|
||||
# Waiting out the propagation window before firing the
|
||||
# webhook closes that gap.
|
||||
echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…"
|
||||
|
||||
echo "Waiting 90 s for registry propagation before promoting ${IMAGE_TAG}@${IMAGE_DIGEST}."
|
||||
sleep 90
|
||||
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
|
||||
# and re-pull the registry digest. If the maintainer's secret
|
||||
# URL already carries a `?force=` query, leave it alone;
|
||||
# otherwise append `?force=true` (or `&force=true` if other
|
||||
# query params are present) so doc-only deploys still trigger
|
||||
# a registry-digest check.
|
||||
deploy_url="$COOLIFY_WEBHOOK"
|
||||
if ! echo "$deploy_url" | grep -q "force="; then
|
||||
if echo "$deploy_url" | grep -q "?"; then
|
||||
@@ -450,18 +487,18 @@ jobs:
|
||||
deploy_url="${deploy_url}?force=true"
|
||||
fi
|
||||
fi
|
||||
echo "Triggering Coolify deploy for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7})"
|
||||
response=$(curl --silent --show-error --max-time 30 \
|
||||
|
||||
echo "Triggering Coolify deploy for ${IMAGE_TAG}@${IMAGE_DIGEST} (source ${BUILD_SHA})."
|
||||
response="$(curl --silent --show-error --max-time 30 \
|
||||
--write-out "\n__HTTP_STATUS__:%{http_code}" \
|
||||
--request GET "$deploy_url" \
|
||||
--header "Authorization: Bearer $COOLIFY_TOKEN")
|
||||
--header "Authorization: Bearer $COOLIFY_TOKEN")"
|
||||
status="${response##*__HTTP_STATUS__:}"
|
||||
body="${response%__HTTP_STATUS__:*}"
|
||||
echo "Coolify response (HTTP $status):"
|
||||
echo "$body"
|
||||
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
|
||||
echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)."
|
||||
echo "::error::Coolify deploy webhook returned HTTP $status for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)."
|
||||
echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status."
|
||||
echo "Coolify deploy queued for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}."
|
||||
@@ -37,10 +37,11 @@ jobs:
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
|
||||
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID: v1
|
||||
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
|
||||
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
AUTH_RP_NAME: HealthLog
|
||||
AUTH_RP_ID: localhost
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
SESSION_COOKIE_SECURE: "false"
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
@@ -87,10 +88,11 @@ jobs:
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
- name: Upload Playwright HTML report on failure
|
||||
if: failure()
|
||||
- name: Upload Playwright HTML report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -38,6 +38,6 @@ jobs:
|
||||
# string "0" — failing crypto's hex/base64 length check and breaking
|
||||
# the backups + integration-status suites for 50+ runs (regression
|
||||
# since v1.4.13). Quoting forces a literal scalar.
|
||||
API_TOKEN_HMAC_KEY: "integration-tests-do-not-use-in-prod"
|
||||
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
SESSION_SECRET: "integration-tests"
|
||||
@@ -1,33 +1,42 @@
|
||||
name: Post-publish verify
|
||||
|
||||
# Informational reachability gate that runs AFTER `Build & Publish Docker
|
||||
# Image` finishes a `main` (or tag) build. Pulls the just-published image
|
||||
# from GHCR and probes:
|
||||
# - the image manifest is fetchable (i.e. the push actually completed)
|
||||
# - the container starts and `/api/version` returns the expected version
|
||||
# - the container's `/api/health` endpoint reports OK
|
||||
#
|
||||
# Does NOT block deploys — `continue-on-error` is set on every probe step
|
||||
# so a transient registry blip doesn't go red. This is a smoke gate the
|
||||
# v1.4.14 release would have caught: the main push appeared to "succeed"
|
||||
# from the deploy host's POV, but the host had to pull the tag image
|
||||
# because main's manifest never finished pushing.
|
||||
#
|
||||
# Triggered by `workflow_run` so it only runs after the build workflow
|
||||
# actually completes — chaining via `needs:` would force the build into
|
||||
# the same workflow file.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build & Publish Docker Image"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_ref:
|
||||
description: Exact registry repository to verify
|
||||
required: true
|
||||
type: string
|
||||
image_tag:
|
||||
description: Exact published release tag
|
||||
required: true
|
||||
type: string
|
||||
image_digest:
|
||||
description: Exact published multi-arch manifest digest
|
||||
required: true
|
||||
type: string
|
||||
build_sha:
|
||||
description: Expected short source commit SHA
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_ref:
|
||||
description: Exact registry repository to verify
|
||||
required: true
|
||||
type: string
|
||||
image_tag:
|
||||
description: "GHCR image tag to verify (defaults to `main`)"
|
||||
required: false
|
||||
default: "main"
|
||||
description: Exact published release tag
|
||||
required: true
|
||||
type: string
|
||||
image_digest:
|
||||
description: Exact published multi-arch manifest digest
|
||||
required: true
|
||||
type: string
|
||||
build_sha:
|
||||
description: Expected short source commit SHA
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -35,14 +44,15 @@ permissions:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Pull + smoke-test published image
|
||||
name: Pull and smoke-test exact published image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Skip if the upstream build itself failed — there's nothing to verify.
|
||||
# Manual dispatch always runs.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
env:
|
||||
IMAGE_REF: ${{ inputs.image_ref }}
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
IMAGE_DIGEST: ${{ inputs.image_digest }}
|
||||
BUILD_SHA: ${{ inputs.build_sha }}
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -60,15 +70,6 @@ jobs:
|
||||
--health-retries 10
|
||||
|
||||
steps:
|
||||
- name: Resolve image tag
|
||||
id: tag
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "tag=${{ github.event.inputs.image_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=main" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
@@ -76,69 +77,97 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Pull image
|
||||
id: pull
|
||||
continue-on-error: true
|
||||
- name: Pull exact image
|
||||
run: |
|
||||
IMAGE="ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }}"
|
||||
echo "Pulling $IMAGE"
|
||||
docker pull "$IMAGE"
|
||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||
TAG_REF="${IMAGE_REF}:${IMAGE_TAG}"
|
||||
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
|
||||
- name: Start container
|
||||
if: steps.pull.outcome == 'success'
|
||||
id: run
|
||||
continue-on-error: true
|
||||
echo "Pulling published tag $TAG_REF"
|
||||
docker pull "$TAG_REF"
|
||||
resolved_digest="$(docker buildx imagetools inspect "$TAG_REF" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
if [ "$resolved_digest" != "$IMAGE_DIGEST" ]; then
|
||||
echo "::error::Published tag digest mismatch: $TAG_REF resolved to '$resolved_digest', expected '$IMAGE_DIGEST'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pulling immutable image $DIGEST_REF"
|
||||
docker pull "$DIGEST_REF"
|
||||
|
||||
- name: Run migrations
|
||||
run: |
|
||||
# Minimal env to satisfy the strict-env validators on boot.
|
||||
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
docker run --rm \
|
||||
--network host \
|
||||
--entrypoint node \
|
||||
-e DATABASE_URL="$DATABASE_URL" \
|
||||
"$DIGEST_REF" \
|
||||
/opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
|
||||
|
||||
- name: Start exact image
|
||||
run: |
|
||||
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
docker run -d --name healthlog-verify \
|
||||
--network host \
|
||||
-e DATABASE_URL='postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public' \
|
||||
-e DATABASE_URL="$DATABASE_URL" \
|
||||
-e ENCRYPTION_KEYS='{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
|
||||
-e ENCRYPTION_ACTIVE_KEY_ID=v1 \
|
||||
-e API_TOKEN_HMAC_KEY=verify-hmac-key-32-bytes-padding-x \
|
||||
-e API_TOKEN_HMAC_KEY=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc \
|
||||
-e AUTH_RP_NAME=HealthLog \
|
||||
-e AUTH_RP_ID=localhost \
|
||||
-e AUTH_RP_ORIGIN=http://localhost:3000 \
|
||||
-e NODE_ENV=production \
|
||||
-e HEALTHLOG_PROCESS_TYPE=web \
|
||||
-e PORT=3000 \
|
||||
"${{ steps.pull.outputs.image }}"
|
||||
"$DIGEST_REF"
|
||||
|
||||
# Wait up to 60 s for the server to start answering.
|
||||
- name: Verify exact version
|
||||
run: |
|
||||
body=""
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf -o /dev/null http://localhost:3000/api/version; then
|
||||
echo "ready after ${i}*2s"
|
||||
if body="$(curl -sf http://localhost:3000/api/version)"; then
|
||||
echo "Version endpoint ready after $((i * 2))s"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$body" ]; then
|
||||
echo "::error::Exact image did not expose /api/version within 60 seconds." >&2
|
||||
docker logs healthlog-verify || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXPECTED_VERSION="${IMAGE_TAG#v}"
|
||||
echo "$body" | jq .
|
||||
if ! jq -e --arg expected "$EXPECTED_VERSION" '.data.version == $expected' <<< "$body" >/dev/null; then
|
||||
actual="$(jq -r '.data.version // "<missing>"' <<< "$body")"
|
||||
echo "::error::Version mismatch: exact image reports '$actual', expected '$EXPECTED_VERSION' (release tag '$IMAGE_TAG')." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e --arg expected "$BUILD_SHA" '.data.buildSha == $expected' <<< "$body" >/dev/null; then
|
||||
actual="$(jq -r '.data.buildSha // "<missing>"' <<< "$body")"
|
||||
echo "::error::Build SHA mismatch: exact image reports '$actual', expected '$BUILD_SHA'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify health
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if body="$(curl -sf http://localhost:3000/api/health)" && jq -e '.status == "ok"' <<< "$body" >/dev/null; then
|
||||
echo "$body" | jq .
|
||||
echo "Health endpoint ready after $((i * 2))s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "::warning::Container did not become ready within 60 s"
|
||||
echo "::error::Exact image did not report healthy within 60 seconds." >&2
|
||||
docker logs healthlog-verify || true
|
||||
exit 1
|
||||
|
||||
- name: Probe /api/version
|
||||
if: steps.run.outcome == 'success'
|
||||
- name: Container logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -e
|
||||
BODY=$(curl -sf http://localhost:3000/api/version)
|
||||
echo "$BODY" | jq .
|
||||
VER=$(echo "$BODY" | jq -r '.data.version // .version')
|
||||
echo "::notice::published image ${{ steps.tag.outputs.tag }} reports version=$VER"
|
||||
|
||||
- name: Probe /api/health
|
||||
if: steps.run.outcome == 'success'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
curl -sf http://localhost:3000/api/health | jq .
|
||||
|
||||
- name: Container logs (always)
|
||||
if: always() && steps.run.outcome == 'success'
|
||||
continue-on-error: true
|
||||
run: docker logs healthlog-verify | tail -100
|
||||
run: docker logs healthlog-verify
|
||||
|
||||
- name: Tear down
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: docker rm -f healthlog-verify || true
|
||||
run: docker rm -f healthlog-verify
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.31.4] — 2026-07-21
|
||||
|
||||
- **Web and background processes now start and recover predictably.** Invalid
|
||||
encryption-key configuration fails before the service accepts traffic,
|
||||
worker-only processes report their real health, connection limits are
|
||||
divided safely across replicas, and provider backfills keep their bounded
|
||||
queues during reconnects.
|
||||
- **Published images are reproducible and verified before promotion.** The
|
||||
release pipeline builds one multi-architecture image, validates its digest,
|
||||
startup, migrations, health, and reported version, and only then promotes
|
||||
that exact artifact to production.
|
||||
- **CI now uses isolated, deterministic test environments.** Integration tests
|
||||
receive their own database and cryptographic settings before application
|
||||
code loads, worktrees install hooks consistently, and browser tests use the
|
||||
intended application process on every platform.
|
||||
- **Concurrent iOS registrations no longer fail intermittently.** Device and
|
||||
APNs notification-channel reconciliation now share one database lock and
|
||||
transaction, so simultaneous first registration remains idempotent.
|
||||
|
||||
No migrations. No breaking changes.
|
||||
|
||||
## [1.31.3] — 2026-07-20
|
||||
|
||||
- **Document summaries now stay in the language you chose.** The same account
|
||||
|
||||
+11
-16
@@ -1,14 +1,15 @@
|
||||
# ── Stage 1: Dependencies ──────────────────────────────────
|
||||
FROM node:22-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@10.31.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY scripts/prepare.mjs scripts/prepare.mjs
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# ── Stage 2: Build ─────────────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@10.31.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# v1.4.43 B11 — version build-arg threaded into the layer cache key.
|
||||
@@ -45,17 +46,16 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm build
|
||||
|
||||
# ── Stage 3: Production runner ─────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
|
||||
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
|
||||
# timestamp formatting) resolve to the actual offset instead of silently
|
||||
# falling back to UTC on Alpine images that ship without it.
|
||||
RUN apk add --no-cache tzdata && \
|
||||
corepack enable && corepack prepare pnpm@latest --activate
|
||||
corepack enable && corepack prepare pnpm@10.31.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_PATH="/opt/pg-boss/node_modules"
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
# v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage
|
||||
@@ -80,6 +80,10 @@ RUN addgroup --system --gid 1001 nodejs && \
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
# Next's standalone output must carry the externalized worker dependencies.
|
||||
# Prisma's pure-JS adapter stays bundled; only modules loaded through native
|
||||
# Node resolution belong in this runtime assertion.
|
||||
RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
|
||||
|
||||
# @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the
|
||||
# native binary's package into the standalone tree but NOT the pnpm symlinks that
|
||||
@@ -123,15 +127,6 @@ COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||
COPY --from=builder /app/src/generated ./src/generated
|
||||
|
||||
# Install all worker runtime dependencies in a shared prefix.
|
||||
# NODE_PATH makes them available to Node.js module resolution.
|
||||
# These packages are listed in next.config.ts serverExternalPackages
|
||||
# and are NOT bundled by Next.js — they must exist at runtime.
|
||||
RUN mkdir -p /opt/pg-boss && \
|
||||
cd /opt/pg-boss && \
|
||||
npm init -y && \
|
||||
npm install --omit=dev pg-boss@12.18 @prisma/adapter-pg@7.8 pg@8.20
|
||||
|
||||
# Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree)
|
||||
RUN mkdir -p /opt/prisma-cli && \
|
||||
cd /opt/prisma-cli && \
|
||||
|
||||
+2
-2
@@ -227,7 +227,7 @@ services:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1",
|
||||
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
@@ -245,7 +245,7 @@ services:
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: healthlog-worker
|
||||
# environment:
|
||||
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
|
||||
# NODE_ENV: production
|
||||
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
|
||||
+43
-1
@@ -9,7 +9,7 @@ set -e
|
||||
# Validating here guarantees a clear, deploy-platform-agnostic
|
||||
# failure message before we touch the database.
|
||||
missing=""
|
||||
for var in DATABASE_URL ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
|
||||
for var in DATABASE_URL API_TOKEN_HMAC_KEY; do
|
||||
eval "value=\$$var"
|
||||
if [ -z "$value" ]; then
|
||||
missing="$missing $var"
|
||||
@@ -21,6 +21,48 @@ if [ -n "$missing" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ENCRYPTION_KEYS supersedes the legacy single key, so validate the same
|
||||
# fail-closed shape the runtime crypto loader accepts before touching the DB.
|
||||
if ! node -e '
|
||||
const validKey = (value) => {
|
||||
if (typeof value !== "string") return false;
|
||||
if (/^[0-9a-fA-F]{64}$/.test(value)) return true;
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(value)) return false;
|
||||
try { return Buffer.from(value, "base64").length === 32; }
|
||||
catch { return false; }
|
||||
};
|
||||
const rawKeyring = (process.env.ENCRYPTION_KEYS || "").trim();
|
||||
if (rawKeyring) {
|
||||
let keyring;
|
||||
try { keyring = JSON.parse(rawKeyring); }
|
||||
catch { throw new Error("ENCRYPTION_KEYS is invalid JSON"); }
|
||||
if (!keyring || typeof keyring !== "object" || Array.isArray(keyring)) {
|
||||
throw new Error("ENCRYPTION_KEYS is invalid: expected an object map");
|
||||
}
|
||||
const entries = Object.entries(keyring);
|
||||
if (
|
||||
entries.length === 0 ||
|
||||
entries.some(([id, value]) =>
|
||||
!/^[A-Za-z0-9_-]{1,32}$/.test(id) || !validKey(value)
|
||||
)
|
||||
) {
|
||||
throw new Error("ENCRYPTION_KEYS is invalid: every entry must be a valid 32-byte key");
|
||||
}
|
||||
const activeId =
|
||||
(process.env.ENCRYPTION_ACTIVE_KEY_ID || "").trim() ||
|
||||
(entries.length === 1 ? entries[0][0] : "");
|
||||
if (!activeId || !Object.prototype.hasOwnProperty.call(keyring, activeId)) {
|
||||
throw new Error("ENCRYPTION_ACTIVE_KEY_ID must name an entry in ENCRYPTION_KEYS");
|
||||
}
|
||||
} else if (!validKey((process.env.ENCRYPTION_KEY || "").trim())) {
|
||||
throw new Error("ENCRYPTION_KEY is missing or invalid");
|
||||
}
|
||||
'; then
|
||||
echo "HealthLog: ERROR — encryption configuration is invalid." >&2
|
||||
echo "HealthLog: set a valid ENCRYPTION_KEY or ENCRYPTION_KEYS with ENCRYPTION_ACTIVE_KEY_ID." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Wait for PostgreSQL ──────────────────────────────────────
|
||||
MAX_RETRIES=30
|
||||
RETRY=0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.31.3
|
||||
version: 1.31.4
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -94,9 +94,27 @@ test.describe("Settings → Export & Import", () => {
|
||||
}) => {
|
||||
// v1.18.0 (S5) — the panel (and its disclosure) live on the
|
||||
// dedicated Gesundheitsakte section now.
|
||||
await page.route("**/api/share-links", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: [], error: null, meta: null }),
|
||||
});
|
||||
});
|
||||
const sharingDataReady = page.waitForResponse(
|
||||
(response) =>
|
||||
response.status() === 200 &&
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === "/api/share-links",
|
||||
);
|
||||
await page.goto("/settings/gesundheitsakte", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await sharingDataReady;
|
||||
const toggle = page.getByTestId("health-record-included-data-toggle");
|
||||
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
||||
// Collapsed on first render — the checklist panel is absent.
|
||||
|
||||
@@ -57,7 +57,14 @@ interface CreatedShare {
|
||||
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
|
||||
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
|
||||
|
||||
const sharingDataReady = page.waitForResponse(
|
||||
(response) =>
|
||||
response.status() === 200 &&
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === "/api/share-links",
|
||||
);
|
||||
await page.goto("/settings/sharing");
|
||||
await sharingDataReady;
|
||||
await page.locator("#share-label").fill(label);
|
||||
|
||||
// Open the document picker and isolate the seeded trio.
|
||||
@@ -69,13 +76,18 @@ async function createShareWithDocs(page: Page): Promise<CreatedShare> {
|
||||
// Exactly the three share-fixture documents match the prefix.
|
||||
const rows = list.getByRole("button");
|
||||
await expect(rows).toHaveCount(3);
|
||||
for (let i = 0; i < 3; i++) await rows.nth(i).click();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const row = rows.nth(i);
|
||||
await row.click();
|
||||
await expect(row).toHaveAttribute("aria-pressed", "true");
|
||||
}
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
|
||||
// Three chips staged before create.
|
||||
await expect(
|
||||
page.getByTestId("share-attached-chips").getByRole("listitem"),
|
||||
).toHaveCount(3);
|
||||
await expect(page.getByRole("button", { name: "Create link" })).toBeEnabled();
|
||||
|
||||
await page.getByRole("button", { name: "Create link" }).click();
|
||||
|
||||
@@ -150,9 +162,14 @@ test.describe("clinician document sharing", () => {
|
||||
clinician.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
// Class A image → inline <img> at the token-scoped serve route.
|
||||
await expect(
|
||||
clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`),
|
||||
).toBeVisible();
|
||||
const inlineImage = clinician.getByRole("img", {
|
||||
name: "Share e2e scan",
|
||||
});
|
||||
await expect(inlineImage).toBeVisible();
|
||||
await expect(inlineImage).toHaveAttribute(
|
||||
"src",
|
||||
new RegExp(`/d/${SHARE_JPEG_DOC_ID}$`),
|
||||
);
|
||||
// Class A PDF → inline <iframe> at the same route family.
|
||||
await expect(
|
||||
clinician.locator(`iframe[src*="/d/${SHARE_PDF_DOC_ID}"]`),
|
||||
|
||||
@@ -231,7 +231,6 @@ const nextConfig: NextConfig = {
|
||||
serverExternalPackages: [
|
||||
"@prisma/client",
|
||||
"pg-boss",
|
||||
"@prisma/adapter-pg",
|
||||
"pg",
|
||||
// Document PDF handling MUST run the real, un-bundled modules. When
|
||||
// Turbopack bundles pdfjs-dist into the server chunks, its runtime
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.31.3",
|
||||
"version": "1.31.4",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
@@ -24,7 +24,7 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
|
||||
"prepare": "node scripts/prepare.mjs",
|
||||
"dev": "next dev",
|
||||
"predev": "node scripts/generate-i18n-boot.mjs",
|
||||
"prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs",
|
||||
@@ -142,6 +142,9 @@
|
||||
"@prisma/engines"
|
||||
],
|
||||
"overrides": {
|
||||
"dompurify@<=3.4.10": "^3.4.11",
|
||||
"postcss@<8.5.10": "^8.5.10",
|
||||
"@hono/node-server@<1.19.13": "^1.19.13",
|
||||
"hono@<4.12.25": "^4.12.25",
|
||||
"picomatch@<4.0.4": "^4.0.4"
|
||||
}
|
||||
|
||||
@@ -22,13 +22,13 @@ export default defineConfig({
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
failOnFlakyTests: !!process.env.CI,
|
||||
// Two retries on CI: under a loaded shared runner, DOM-settle-gated
|
||||
// assertions (wizard step transitions, list refetch → card provenance,
|
||||
// disclosure toggles) intermittently sample a mid-transition frame. A
|
||||
// rotating single test failed each run while 211 passed; a second retry
|
||||
// absorbs that transient contention without masking a real failure (a true
|
||||
// break fails all three attempts). The default expect timeout is also
|
||||
// lifted from 5s → 10s for the same settle headroom.
|
||||
// retry distinguishes transient contention from a persistent break, while
|
||||
// failOnFlakyTests keeps either outcome visible to CI. The default expect
|
||||
// timeout is lifted from 5s → 10s for the same settle headroom.
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: process.env.CI
|
||||
|
||||
Generated
+9
-33
@@ -5,6 +5,9 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
dompurify@<=3.4.10: ^3.4.11
|
||||
postcss@<8.5.10: ^8.5.10
|
||||
'@hono/node-server@<1.19.13': ^1.19.13
|
||||
hono@<4.12.25: ^4.12.25
|
||||
picomatch@<4.0.4: ^4.0.4
|
||||
|
||||
@@ -879,12 +882,6 @@ packages:
|
||||
'@hexagon/base64@1.1.28':
|
||||
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||
|
||||
'@hono/node-server@1.19.11':
|
||||
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4.12.25
|
||||
|
||||
'@hono/node-server@1.19.14':
|
||||
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
@@ -4006,8 +4003,8 @@ packages:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
dompurify@3.3.3:
|
||||
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
|
||||
dompurify@3.4.12:
|
||||
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
|
||||
|
||||
dot-prop@6.0.1:
|
||||
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
|
||||
@@ -5355,11 +5352,6 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@3.3.15:
|
||||
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@3.3.16:
|
||||
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -5733,10 +5725,6 @@ packages:
|
||||
resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.4.31:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.14:
|
||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -7777,10 +7765,6 @@ snapshots:
|
||||
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
'@hono/node-server@1.19.11(hono@4.12.25)':
|
||||
dependencies:
|
||||
hono: 4.12.25
|
||||
|
||||
'@hono/node-server@1.19.14(hono@4.12.25)':
|
||||
dependencies:
|
||||
hono: 4.12.25
|
||||
@@ -8538,7 +8522,7 @@ snapshots:
|
||||
'@electric-sql/pglite': 0.4.1
|
||||
'@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1)
|
||||
'@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1)
|
||||
'@hono/node-server': 1.19.11(hono@4.12.25)
|
||||
'@hono/node-server': 1.19.14(hono@4.12.25)
|
||||
'@prisma/get-platform': 7.2.0
|
||||
'@prisma/query-plan-executor': 7.2.0
|
||||
'@prisma/streams-local': 0.1.2
|
||||
@@ -10760,7 +10744,7 @@ snapshots:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
||||
dompurify@3.3.3:
|
||||
dompurify@3.4.12:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
optional: true
|
||||
@@ -11897,7 +11881,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
canvg: 3.0.11
|
||||
core-js: 3.49.0
|
||||
dompurify: 3.3.3
|
||||
dompurify: 3.4.12
|
||||
html2canvas: 1.4.1
|
||||
|
||||
jsx-ast-utils@3.3.5:
|
||||
@@ -12236,8 +12220,6 @@ snapshots:
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
nanoid@3.3.15: {}
|
||||
|
||||
nanoid@3.3.16: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
@@ -12257,7 +12239,7 @@ snapshots:
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.10.38
|
||||
caniuse-lite: 1.0.30001799
|
||||
postcss: 8.4.31
|
||||
postcss: 8.5.20
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7)
|
||||
@@ -12637,12 +12619,6 @@ snapshots:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.15
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.14:
|
||||
dependencies:
|
||||
nanoid: 3.3.12
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.31.3";
|
||||
/* @sw-version-fallback */ "v1.31.4";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import integrationConfig from "../../vitest.integration.config.mts";
|
||||
|
||||
function repositoryRoot(): string {
|
||||
const testPath = expect.getState().testPath;
|
||||
if (!testPath) throw new Error("Vitest did not expose the current test path");
|
||||
return join(dirname(testPath), "../..");
|
||||
}
|
||||
|
||||
function readRepoFile(path: string): string {
|
||||
return readFileSync(join(repositoryRoot(), path), "utf8");
|
||||
}
|
||||
|
||||
type WorkflowStep = {
|
||||
if?: string;
|
||||
uses?: string;
|
||||
with?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
type WorkflowJob = {
|
||||
env?: Record<string, string>;
|
||||
steps?: WorkflowStep[];
|
||||
};
|
||||
|
||||
type Workflow = {
|
||||
jobs: Record<string, WorkflowJob>;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Playwright CI determinism", () => {
|
||||
it("fails CI when a retry resolves a flaky test", async () => {
|
||||
vi.stubEnv("CI", "true");
|
||||
// Re-import after stubbing CI so config conditionals are evaluated as CI.
|
||||
const { default: config } = await import("../../playwright.config");
|
||||
|
||||
expect(config.retries).toBe(2);
|
||||
expect(config.failOnFlakyTests).toBe(true);
|
||||
expect(config.reporter).toEqual([["github"], ["html", { open: "never" }]]);
|
||||
});
|
||||
|
||||
it("retains the Playwright report after every workflow outcome", () => {
|
||||
const workflow = parse(
|
||||
readRepoFile(".github/workflows/e2e.yml"),
|
||||
) as Workflow;
|
||||
const upload = workflow.jobs.e2e?.steps?.find((step) =>
|
||||
step.uses?.startsWith("actions/upload-artifact@"),
|
||||
);
|
||||
|
||||
expect(upload).toBeDefined();
|
||||
expect(upload?.if).toBe("always()");
|
||||
expect(upload?.with).toMatchObject({
|
||||
name: "playwright-report",
|
||||
path: "playwright-report/",
|
||||
"retention-days": 7,
|
||||
"if-no-files-found": "ignore",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses production-valid secrets and plain-HTTP cookies", () => {
|
||||
const workflow = parse(
|
||||
readRepoFile(".github/workflows/e2e.yml"),
|
||||
) as Workflow;
|
||||
const env = workflow.jobs.e2e?.env;
|
||||
|
||||
expect(env?.API_TOKEN_HMAC_KEY).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(env?.SESSION_COOKIE_SECURE).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration environment isolation", () => {
|
||||
it("registers a worker setup bridge and authoritative test secrets", () => {
|
||||
const test = integrationConfig.test;
|
||||
|
||||
expect(test?.setupFiles ?? []).toContain(
|
||||
"./tests/integration/environment-setup.ts",
|
||||
);
|
||||
expect(test?.env).toMatchObject({
|
||||
TZ: "UTC",
|
||||
ENCRYPTION_KEY:
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
ENCRYPTION_KEYS: "",
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "",
|
||||
API_TOKEN_HMAC_KEY:
|
||||
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
SESSION_SECRET: "integration-test-session-secret-32-bytes",
|
||||
});
|
||||
expect(
|
||||
existsSync(
|
||||
join(repositoryRoot(), "tests/integration/environment-setup.ts"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("container dependency installation", () => {
|
||||
it("copies the prepare helper before the Git-free frozen install", () => {
|
||||
const dockerfile = readRepoFile("Dockerfile");
|
||||
|
||||
expect(dockerfile).toContain(
|
||||
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" +
|
||||
"COPY scripts/prepare.mjs scripts/prepare.mjs\n" +
|
||||
"RUN pnpm install --frozen-lockfile --prod=false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("production dependency advisory floors", () => {
|
||||
it("pins vulnerable transitive ranges to compatible patched versions", () => {
|
||||
const packageJson = JSON.parse(readRepoFile("package.json")) as {
|
||||
pnpm?: { overrides?: Record<string, string> };
|
||||
};
|
||||
|
||||
expect(packageJson.pnpm?.overrides).toMatchObject({
|
||||
"dompurify@<=3.4.10": "^3.4.11",
|
||||
"postcss@<8.5.10": "^8.5.10",
|
||||
"@hono/node-server@<1.19.13": "^1.19.13",
|
||||
"hono@<4.12.25": "^4.12.25",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of temporaryDirectories.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
type GitMarker = "absent" | "directory" | "file";
|
||||
|
||||
type PrepareResult = {
|
||||
invocationLog: string | null;
|
||||
result: SpawnSyncReturns<string>;
|
||||
};
|
||||
|
||||
function runPrepareFixture(
|
||||
gitMarker: GitMarker,
|
||||
lefthookStatus = 0,
|
||||
): PrepareResult {
|
||||
const testPath = expect.getState().testPath;
|
||||
if (!testPath) throw new Error("Vitest did not expose the current test path");
|
||||
const root = join(dirname(testPath), "../..");
|
||||
const directory = mkdtempSync(join(tmpdir(), "healthlog-prepare-"));
|
||||
temporaryDirectories.push(directory);
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(root, "package.json"), "utf8"),
|
||||
) as {
|
||||
scripts: { prepare: string };
|
||||
};
|
||||
const prepareHelper = join(root, "scripts/prepare.mjs");
|
||||
if (existsSync(prepareHelper)) {
|
||||
mkdirSync(join(directory, "scripts"));
|
||||
cpSync(prepareHelper, join(directory, "scripts/prepare.mjs"));
|
||||
}
|
||||
|
||||
if (gitMarker === "directory") {
|
||||
mkdirSync(join(directory, ".git"));
|
||||
} else if (gitMarker === "file") {
|
||||
writeFileSync(
|
||||
join(directory, ".git"),
|
||||
"gitdir: ../shared/.git/worktrees/fixture\n",
|
||||
);
|
||||
}
|
||||
|
||||
const binDirectory = join(directory, "bin");
|
||||
const invocationLog = join(directory, "lefthook.log");
|
||||
mkdirSync(binDirectory);
|
||||
const lefthook = join(
|
||||
binDirectory,
|
||||
process.platform === "win32" ? "lefthook.cmd" : "lefthook",
|
||||
);
|
||||
writeFileSync(
|
||||
lefthook,
|
||||
process.platform === "win32"
|
||||
? '@echo off\r\necho %*>>"%FIXTURE_LEFTHOOK_LOG%"\r\nexit /b %FIXTURE_LEFTHOOK_STATUS%\r\n'
|
||||
: '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$FIXTURE_LEFTHOOK_LOG"\nexit "$FIXTURE_LEFTHOOK_STATUS"\n',
|
||||
);
|
||||
if (process.platform !== "win32") chmodSync(lefthook, 0o755);
|
||||
|
||||
const result = spawnSync(packageJson.scripts.prepare, {
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
|
||||
FIXTURE_LEFTHOOK_LOG: invocationLog,
|
||||
FIXTURE_LEFTHOOK_STATUS: String(lefthookStatus),
|
||||
},
|
||||
shell: true,
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
invocationLog: existsSync(invocationLog)
|
||||
? readFileSync(invocationLog, "utf8").replaceAll("\r\n", "\n")
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("package prepare hook installation", () => {
|
||||
it("enables command-shim execution on Windows only", () => {
|
||||
const testPath = expect.getState().testPath;
|
||||
if (!testPath)
|
||||
throw new Error("Vitest did not expose the current test path");
|
||||
const source = readFileSync(
|
||||
join(dirname(testPath), "../prepare.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain('shell: process.platform === "win32"');
|
||||
});
|
||||
|
||||
it("skips hook installation only when Git metadata is absent", () => {
|
||||
const { invocationLog, result } = runPrepareFixture("absent");
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(invocationLog).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["directory", "file"] as const)(
|
||||
"installs hooks when .git is a %s",
|
||||
(gitMarker) => {
|
||||
const { invocationLog, result } = runPrepareFixture(gitMarker);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(invocationLog).toBe("install --force\n");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["directory", "file"] as const)(
|
||||
"propagates Lefthook failure when .git is a %s",
|
||||
(gitMarker) => {
|
||||
const { invocationLog, result } = runPrepareFixture(gitMarker, 42);
|
||||
|
||||
expect(invocationLog).toBe("install --force\n");
|
||||
expect(result.status).toBe(42);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
function readRepoFile(path: string): string {
|
||||
const testPath = expect.getState().testPath;
|
||||
if (!testPath) throw new Error("Vitest did not expose the current test path");
|
||||
return readFileSync(join(dirname(testPath), "../..", path), "utf8");
|
||||
}
|
||||
type WorkflowStep = {
|
||||
name?: string;
|
||||
run?: string;
|
||||
"continue-on-error"?: boolean;
|
||||
};
|
||||
type WorkflowJob = {
|
||||
needs?: string | string[];
|
||||
outputs?: Record<string, string>;
|
||||
steps?: WorkflowStep[];
|
||||
uses?: string;
|
||||
with?: Record<string, string>;
|
||||
};
|
||||
type Workflow = {
|
||||
on?: {
|
||||
workflow_call?: { inputs?: Record<string, { required?: boolean }> };
|
||||
};
|
||||
jobs: Record<string, WorkflowJob>;
|
||||
};
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of temporaryDirectories.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function loadWorkflow(name: string): Workflow {
|
||||
return parse(readRepoFile(join(".github/workflows", name))) as Workflow;
|
||||
}
|
||||
|
||||
function stepScript(
|
||||
workflow: Workflow,
|
||||
jobName: string,
|
||||
stepName: string,
|
||||
): string {
|
||||
const step = workflow.jobs[jobName]?.steps?.find(
|
||||
(candidate) => candidate.name === stepName,
|
||||
);
|
||||
if (!step?.run) {
|
||||
throw new Error(`Missing ${jobName}/${stepName} run step`);
|
||||
}
|
||||
return step.run;
|
||||
}
|
||||
|
||||
function runReleasePolicy(
|
||||
overrides: Record<string, string> = {},
|
||||
): SpawnSyncReturns<string> {
|
||||
const directory = mkdtempSync(join(tmpdir(), "healthlog-release-policy-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const binDirectory = join(directory, "bin");
|
||||
mkdirSync(binDirectory);
|
||||
writeFileSync(
|
||||
join(directory, "package.json"),
|
||||
JSON.stringify({ version: overrides.FIXTURE_PACKAGE_VERSION ?? "1.31.4" }),
|
||||
);
|
||||
|
||||
const gitPath = join(binDirectory, "git");
|
||||
writeFileSync(
|
||||
gitPath,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = "fetch" ]; then exit 0; fi
|
||||
if [ "$1" = "merge-base" ] && [ "$FIXTURE_MAIN_ANCESTRY" = "yes" ]; then exit 0; fi
|
||||
exit 1
|
||||
`,
|
||||
);
|
||||
chmodSync(gitPath, 0o755);
|
||||
|
||||
const ghPath = join(binDirectory, "gh");
|
||||
writeFileSync(
|
||||
ghPath,
|
||||
`#!/bin/sh
|
||||
if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi
|
||||
`,
|
||||
);
|
||||
chmodSync(ghPath, 0o755);
|
||||
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
return spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-euo",
|
||||
"pipefail",
|
||||
"-c",
|
||||
stepScript(workflow, "release-policy", "Validate release policy"),
|
||||
],
|
||||
{
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
|
||||
GITHUB_EVENT_NAME: "push",
|
||||
GITHUB_REF_TYPE: "tag",
|
||||
GITHUB_REF_NAME: "v1.31.4",
|
||||
GITHUB_SHA: "0123456789abcdef0123456789abcdef01234567",
|
||||
GITHUB_REPOSITORY: "MBombeck/HealthLog",
|
||||
GH_TOKEN: "fixture-token",
|
||||
FIXTURE_MAIN_ANCESTRY: "yes",
|
||||
FIXTURE_PRIOR_CI: "yes",
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function runExactPull(resolvedDigest: string): {
|
||||
result: SpawnSyncReturns<string>;
|
||||
commands: string;
|
||||
} {
|
||||
const directory = mkdtempSync(join(tmpdir(), "healthlog-image-pull-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const dockerPath = join(directory, "docker");
|
||||
const commandLog = join(directory, "commands.log");
|
||||
writeFileSync(
|
||||
dockerPath,
|
||||
`#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "$FIXTURE_COMMAND_LOG"
|
||||
if [ "$1" = "pull" ]; then exit 0; fi
|
||||
if [ "$1" = "buildx" ] && [ "$2" = "imagetools" ] && [ "$3" = "inspect" ]; then
|
||||
printf '\"%s\"\\n' "$FIXTURE_RESOLVED_DIGEST"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
);
|
||||
chmodSync(dockerPath, 0o755);
|
||||
|
||||
const workflow = loadWorkflow("post-publish-verify.yml");
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-euo",
|
||||
"pipefail",
|
||||
"-c",
|
||||
stepScript(workflow, "verify", "Pull exact image"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${directory}:${process.env.PATH ?? ""}`,
|
||||
IMAGE_REF: "ghcr.io/mbombeck/healthlog",
|
||||
IMAGE_TAG: "v1.31.4",
|
||||
IMAGE_DIGEST:
|
||||
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
FIXTURE_COMMAND_LOG: commandLog,
|
||||
FIXTURE_RESOLVED_DIGEST: resolvedDigest,
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
result,
|
||||
commands: readFileSync(commandLog, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
function runPromotion(
|
||||
overrides: Record<string, string> = {},
|
||||
): SpawnSyncReturns<string> {
|
||||
const directory = mkdtempSync(join(tmpdir(), "healthlog-promotion-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const sleepPath = join(directory, "sleep");
|
||||
writeFileSync(sleepPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(sleepPath, 0o755);
|
||||
const curlPath = join(directory, "curl");
|
||||
writeFileSync(
|
||||
curlPath,
|
||||
`#!/bin/sh
|
||||
printf '{\"deployment_uuid\":\"fixture\"}\\n__HTTP_STATUS__:%s' "$FIXTURE_HTTP_STATUS"
|
||||
`,
|
||||
);
|
||||
chmodSync(curlPath, 0o755);
|
||||
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
return spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-euo",
|
||||
"pipefail",
|
||||
"-c",
|
||||
stepScript(workflow, "promote", "Trigger Coolify deploy"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${directory}:${process.env.PATH ?? ""}`,
|
||||
COOLIFY_AUTO_DEPLOY: "on",
|
||||
COOLIFY_WEBHOOK: "https://coolify.example/deploy?uuid=fixture",
|
||||
COOLIFY_TOKEN: "fixture-token",
|
||||
IMAGE_TAG: "v1.31.4",
|
||||
IMAGE_DIGEST:
|
||||
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
BUILD_SHA: "0123456789ab",
|
||||
FIXTURE_HTTP_STATUS: "200",
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("release container inputs", () => {
|
||||
it("pins every Node stage to one reviewed multi-arch digest", () => {
|
||||
const dockerfile = readRepoFile("Dockerfile");
|
||||
const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? [];
|
||||
|
||||
expect(fromLines).toHaveLength(3);
|
||||
expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual(
|
||||
new Set([
|
||||
"FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the packageManager pnpm version instead of a moving tag", () => {
|
||||
const dockerfile = readRepoFile("Dockerfile");
|
||||
const packageJson = JSON.parse(readRepoFile("package.json")) as {
|
||||
packageManager: string;
|
||||
};
|
||||
expect(packageJson.packageManager).toBe("pnpm@10.31.0");
|
||||
expect(dockerfile).not.toContain("pnpm@latest");
|
||||
expect(
|
||||
dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("bundles Prisma's adapter and verifies only runtime externals", () => {
|
||||
const dockerfile = readRepoFile("Dockerfile");
|
||||
const nextConfig = readRepoFile("next.config.ts");
|
||||
expect(dockerfile).not.toContain("/opt/pg-boss");
|
||||
expect(dockerfile).not.toContain('NODE_PATH="/opt/pg-boss/node_modules"');
|
||||
expect(dockerfile).toMatch(
|
||||
/require\.resolve\(['"]pg-boss['"]\)[\s\S]*require\.resolve\(['"]pg['"]\)/,
|
||||
);
|
||||
expect(dockerfile).not.toContain("require.resolve('@prisma/adapter-pg')");
|
||||
expect(dockerfile).not.toContain("require.resolve('@prisma/client')");
|
||||
expect(nextConfig).not.toMatch(
|
||||
/serverExternalPackages:\s*\[[\s\S]*["']@prisma\/adapter-pg["']/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release pre-build policy", () => {
|
||||
it("accepts a strict SemVer tag that matches package.json on main with prior CI", () => {
|
||||
const result = runReleasePolicy();
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("Release policy passed");
|
||||
});
|
||||
|
||||
it.each(["v1.2", "v01.2.3", "v1.2.3-rc.1", "1.2.3"])(
|
||||
"rejects non-strict release tag %s",
|
||||
(tag) => {
|
||||
const result = runReleasePolicy({ GITHUB_REF_NAME: tag });
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("strict SemVer");
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a tag that does not match package.json", () => {
|
||||
const result = runReleasePolicy({ FIXTURE_PACKAGE_VERSION: "1.31.3" });
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("package.json version");
|
||||
});
|
||||
|
||||
it("rejects a release commit outside main ancestry", () => {
|
||||
const result = runReleasePolicy({ FIXTURE_MAIN_ANCESTRY: "no" });
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("ancestor of origin/main");
|
||||
});
|
||||
|
||||
it("rejects a release commit without successful prior quality CI", () => {
|
||||
const result = runReleasePolicy({ FIXTURE_PRIOR_CI: "no" });
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("successful prior Security & Quality");
|
||||
});
|
||||
|
||||
it("gates every image build on the release policy", () => {
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
const needs = workflow.jobs.build?.needs;
|
||||
|
||||
expect(Array.isArray(needs) ? needs : [needs]).toContain("release-policy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exact post-publish verification", () => {
|
||||
it("passes the published ref, tag, and digest into the blocking verifier", () => {
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
const merge = workflow.jobs.merge;
|
||||
const verify = workflow.jobs["verify-published-image"];
|
||||
|
||||
expect(merge?.outputs).toEqual({
|
||||
image_ref: "${{ steps.published.outputs.image_ref }}",
|
||||
image_tag: "${{ steps.published.outputs.image_tag }}",
|
||||
image_digest: "${{ steps.published.outputs.image_digest }}",
|
||||
build_sha: "${{ steps.published.outputs.build_sha }}",
|
||||
});
|
||||
expect(verify?.uses).toBe("./.github/workflows/post-publish-verify.yml");
|
||||
expect(verify?.needs).toBe("merge");
|
||||
expect(verify?.with).toEqual({
|
||||
image_ref: "${{ needs.merge.outputs.image_ref }}",
|
||||
image_tag: "${{ needs.merge.outputs.image_tag }}",
|
||||
image_digest: "${{ needs.merge.outputs.image_digest }}",
|
||||
build_sha: "${{ needs.merge.outputs.build_sha }}",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires exact image identity inputs for every invocation", () => {
|
||||
const workflow = loadWorkflow("post-publish-verify.yml");
|
||||
const inputs = workflow.on?.workflow_call?.inputs;
|
||||
|
||||
expect(inputs?.image_ref?.required).toBe(true);
|
||||
expect(inputs?.image_tag?.required).toBe(true);
|
||||
expect(inputs?.image_digest?.required).toBe(true);
|
||||
expect(inputs?.build_sha?.required).toBe(true);
|
||||
});
|
||||
|
||||
it("pulls the exact tag and digest when the registry resolves correctly", () => {
|
||||
const digest =
|
||||
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const { result, commands } = runExactPull(digest);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(commands).toContain("pull ghcr.io/mbombeck/healthlog:v1.31.4");
|
||||
expect(commands).toContain(
|
||||
"pull ghcr.io/mbombeck/healthlog@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when the published tag resolves to another digest", () => {
|
||||
const { result } = runExactPull(
|
||||
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("digest mismatch");
|
||||
});
|
||||
|
||||
it("keeps pull, migration, start, version, and health checks blocking", () => {
|
||||
const workflow = loadWorkflow("post-publish-verify.yml");
|
||||
const steps = workflow.jobs.verify?.steps ?? [];
|
||||
const requiredSteps = [
|
||||
"Pull exact image",
|
||||
"Run migrations",
|
||||
"Start exact image",
|
||||
"Verify exact version",
|
||||
"Verify health",
|
||||
];
|
||||
|
||||
for (const name of requiredSteps) {
|
||||
const step = steps.find((candidate) => candidate.name === name);
|
||||
expect(step, `missing ${name}`).toBeDefined();
|
||||
expect(step?.["continue-on-error"], name).not.toBe(true);
|
||||
}
|
||||
|
||||
expect(stepScript(workflow, "verify", "Run migrations")).toContain(
|
||||
"${IMAGE_REF}@${IMAGE_DIGEST}",
|
||||
);
|
||||
const startScript = stepScript(workflow, "verify", "Start exact image");
|
||||
expect(startScript).toContain("${IMAGE_REF}@${IMAGE_DIGEST}");
|
||||
expect(startScript).toMatch(/API_TOKEN_HMAC_KEY=[0-9a-f]{64}(?:\s|\\)/);
|
||||
const versionScript = stepScript(
|
||||
workflow,
|
||||
"verify",
|
||||
"Verify exact version",
|
||||
);
|
||||
expect(versionScript).toContain('EXPECTED_VERSION="${IMAGE_TAG#v}"');
|
||||
expect(versionScript).toContain('jq -e --arg expected "$EXPECTED_VERSION"');
|
||||
expect(stepScript(workflow, "verify", "Verify health")).toContain(
|
||||
"jq -e '.status == \"ok\"'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coolify promotion policy", () => {
|
||||
it("waits for exact-image verification and keeps enabled promotion blocking", () => {
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
const promotion = workflow.jobs.promote;
|
||||
const needs = promotion?.needs;
|
||||
const trigger = promotion?.steps?.find(
|
||||
(step) => step.name === "Trigger Coolify deploy",
|
||||
);
|
||||
|
||||
expect(Array.isArray(needs) ? needs : [needs]).toEqual(
|
||||
expect.arrayContaining(["merge", "verify-published-image"]),
|
||||
);
|
||||
expect(trigger).toBeDefined();
|
||||
expect(trigger?.["continue-on-error"]).not.toBe(true);
|
||||
expect(
|
||||
workflow.jobs.merge?.steps?.some(
|
||||
(step) => step.name === "Trigger Coolify deploy",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an enabled promotion only after a successful webhook response", () => {
|
||||
const result = runPromotion();
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("Coolify deploy queued");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["webhook", { COOLIFY_WEBHOOK: "" }],
|
||||
["token", { COOLIFY_TOKEN: "" }],
|
||||
])("rejects enabled promotion with missing %s credentials", (_name, env) => {
|
||||
const result = runPromotion(env);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("COOLIFY_AUTO_DEPLOY=on");
|
||||
});
|
||||
|
||||
it("fails enabled promotion when Coolify rejects the webhook", () => {
|
||||
const result = runPromotion({ FIXTURE_HTTP_STATUS: "503" });
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("returned HTTP 503");
|
||||
});
|
||||
|
||||
it("allows explicit disabled promotion without credentials", () => {
|
||||
const result = runPromotion({
|
||||
COOLIFY_AUTO_DEPLOY: "off",
|
||||
COOLIFY_WEBHOOK: "",
|
||||
COOLIFY_TOKEN: "",
|
||||
});
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("COOLIFY_AUTO_DEPLOY=off");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
if (!existsSync(".git")) {
|
||||
console.log("Skipping Lefthook install: Git metadata is absent");
|
||||
} else {
|
||||
const result = spawnSync("lefthook", ["install", "--force"], {
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(`Failed to run Lefthook: ${result.error.message}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.exitCode = result.status ?? 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => false,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
function request(pathname: string): NextRequest {
|
||||
return new NextRequest(`http://localhost${pathname}`);
|
||||
}
|
||||
|
||||
describe("worker-only process HTTP boundary", () => {
|
||||
it("allows the process-aware health endpoint through", () => {
|
||||
const response = proxy(request("/api/health"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("X-HealthLog-Process-Type")).toBeNull();
|
||||
});
|
||||
|
||||
it("continues to refuse non-health HTTP traffic", () => {
|
||||
const response = proxy(request("/api/version"));
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("X-HealthLog-Process-Type")).toBe("worker");
|
||||
});
|
||||
});
|
||||
@@ -274,6 +274,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Keep channel reconciliation under the same identity locks as the device
|
||||
// row. Moving it after commit lets two first-time registrations race the
|
||||
// compound unique key and intermittently turns one valid request into 500.
|
||||
if (apnsToken) {
|
||||
await tx.notificationChannel.upsert({
|
||||
where: { userId_type: { userId: user.id, type: "APNS" } },
|
||||
create: {
|
||||
userId: user.id,
|
||||
type: "APNS",
|
||||
enabled: true,
|
||||
config: encrypt("{}"),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
return { id: device.id };
|
||||
});
|
||||
|
||||
@@ -292,22 +308,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { id } = registration;
|
||||
|
||||
// Reconcile APNs channel state only after the canonical device transaction
|
||||
// commits. Config is an encrypted empty record by design — the per-device
|
||||
// token and environment live on the Device row.
|
||||
if (apnsToken) {
|
||||
await prisma.notificationChannel.upsert({
|
||||
where: { userId_type: { userId: user.id, type: "APNS" } },
|
||||
create: {
|
||||
userId: user.id,
|
||||
type: "APNS",
|
||||
enabled: true,
|
||||
config: encrypt("{}"),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("devices.register", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface WorkerStatusFixture {
|
||||
running: boolean;
|
||||
startedAt: string | null;
|
||||
lastHeartbeat: string | null;
|
||||
lastReminderCheck: string | null;
|
||||
lastWithingsSync: string | null;
|
||||
lastInsightsRun: string | null;
|
||||
jobsProcessed: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
const { queryRaw, getWorkerStatus, getGlobalBoss, getSession } = vi.hoisted(
|
||||
() => ({
|
||||
queryRaw: vi.fn(async () => 1),
|
||||
getWorkerStatus: vi.fn((): WorkerStatusFixture => ({
|
||||
running: false,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
getGlobalBoss: vi.fn((): object | null => null),
|
||||
getSession: vi.fn(async () => null),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { $queryRaw: queryRaw },
|
||||
}));
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({ getWorkerStatus }));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss }));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession }));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
async function health() {
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
return { response, body: (await response.json()) as { status: string } };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryRaw.mockResolvedValue(1);
|
||||
getWorkerStatus.mockReturnValue({
|
||||
running: false,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
getGlobalBoss.mockReturnValue(null);
|
||||
getSession.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("GET /api/health process topology", () => {
|
||||
it("web mode requires DB and producer readiness, not an in-process worker", async () => {
|
||||
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
|
||||
getGlobalBoss.mockReturnValue({});
|
||||
|
||||
const { response, body } = await health();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
it("worker mode requires DB and worker readiness, not web producer readiness", async () => {
|
||||
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "worker");
|
||||
getWorkerStatus.mockReturnValue({
|
||||
running: true,
|
||||
startedAt: "2026-07-20T00:00:00.000Z",
|
||||
lastHeartbeat: "2026-07-20T00:00:00.000Z",
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
|
||||
const { response, body } = await health();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
it("all mode requires both producer and worker readiness", async () => {
|
||||
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "all");
|
||||
getWorkerStatus.mockReturnValue({
|
||||
running: true,
|
||||
startedAt: "2026-07-20T00:00:00.000Z",
|
||||
lastHeartbeat: "2026-07-20T00:00:00.000Z",
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
|
||||
const missingProducer = await health();
|
||||
expect(missingProducer.response.status).toBe(503);
|
||||
expect(missingProducer.body).toEqual({ status: "degraded" });
|
||||
|
||||
getGlobalBoss.mockReturnValue({});
|
||||
const ready = await health();
|
||||
expect(ready.response.status).toBe(200);
|
||||
expect(ready.body).toEqual({ status: "ok" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { shouldRunWeb, shouldRunWorker } from "@/lib/process-type";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
@@ -19,7 +21,13 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const status = dbOk && worker.running ? "ok" : "degraded";
|
||||
const producerReady = getGlobalBoss() !== null;
|
||||
const status =
|
||||
dbOk &&
|
||||
(!shouldRunWeb() || producerReady) &&
|
||||
(!shouldRunWorker() || worker.running)
|
||||
? "ok"
|
||||
: "degraded";
|
||||
const statusCode = status === "ok" ? 200 : 503;
|
||||
|
||||
const cacheHeaders = {
|
||||
|
||||
@@ -1,65 +1,69 @@
|
||||
/**
|
||||
* v1.4.40 W-POOL — pool-config contract for `src/lib/db.ts`.
|
||||
*
|
||||
* The v1.4.39 empirical cold-mount trace
|
||||
* (`.planning/round-v1439-empirical-trace.md` § B2) traced the
|
||||
* Wave-B/C dashboard-query stall to the Prisma `pg.Pool` falling
|
||||
* back to the library default ceiling of 10 connections. W-POOL
|
||||
* pinned the ceiling to 20 via `getPoolMax()`; this test prevents a
|
||||
* future refactor from silently dropping the override and re-introducing
|
||||
* the saturation regression on a power-user cold mount.
|
||||
*
|
||||
* The test only exercises the `getPoolMax()` env-resolver. Constructing
|
||||
* a real `PrismaClient` requires a live `DATABASE_URL`, which would
|
||||
* make this an integration test instead of a unit test — the helper
|
||||
* isolation is intentional.
|
||||
* Pool configuration stays unit-tested here so connection ceilings and wait
|
||||
* timeouts cannot silently fall back to separate library defaults.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { buildSessionOptions, getPoolMax, getStatementTimeoutMs } from "../db";
|
||||
import {
|
||||
buildSessionOptions,
|
||||
getConnectionBudget,
|
||||
getPgBossPoolMax,
|
||||
getPoolConnectionTimeoutMs,
|
||||
getPrismaPoolMax,
|
||||
getStatementTimeoutMs,
|
||||
} from "../db";
|
||||
|
||||
describe("getPoolMax", () => {
|
||||
const originalEnv = process.env.DATABASE_POOL_MAX;
|
||||
describe("per-process database connection budget", () => {
|
||||
const originalDatabaseUrl = process.env.DATABASE_URL;
|
||||
const originalConnectionLimit = process.env.DB_CONNECTION_LIMIT;
|
||||
const originalPoolTimeout = process.env.DB_POOL_TIMEOUT;
|
||||
const originalLegacyPoolMax = process.env.DATABASE_POOL_MAX;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DATABASE_URL =
|
||||
"postgresql://healthlog:test@db:5432/healthlog?connection_limit=20&pool_timeout=20";
|
||||
delete process.env.DB_CONNECTION_LIMIT;
|
||||
delete process.env.DB_POOL_TIMEOUT;
|
||||
delete process.env.DATABASE_POOL_MAX;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.DATABASE_POOL_MAX;
|
||||
} else {
|
||||
process.env.DATABASE_POOL_MAX = originalEnv;
|
||||
}
|
||||
const restore = (name: string, value: string | undefined) => {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
};
|
||||
restore("DATABASE_URL", originalDatabaseUrl);
|
||||
restore("DB_CONNECTION_LIMIT", originalConnectionLimit);
|
||||
restore("DB_POOL_TIMEOUT", originalPoolTimeout);
|
||||
restore("DATABASE_POOL_MAX", originalLegacyPoolMax);
|
||||
});
|
||||
|
||||
it("defaults to 20 when DATABASE_POOL_MAX is unset", () => {
|
||||
expect(getPoolMax()).toBe(20);
|
||||
it("splits the URL connection_limit across Prisma and pg-boss", () => {
|
||||
expect(getConnectionBudget()).toBe(20);
|
||||
expect(getPrismaPoolMax()).toBe(18);
|
||||
expect(getPgBossPoolMax()).toBe(2);
|
||||
expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe(getConnectionBudget());
|
||||
});
|
||||
|
||||
it("honours DATABASE_POOL_MAX when it parses to a positive int", () => {
|
||||
it("uses the existing pool_timeout seconds for both pool constructors", () => {
|
||||
expect(getPoolConnectionTimeoutMs()).toBe(20_000);
|
||||
});
|
||||
|
||||
it("prefers explicit DB_CONNECTION_LIMIT and DB_POOL_TIMEOUT values", () => {
|
||||
process.env.DB_CONNECTION_LIMIT = "12";
|
||||
process.env.DB_POOL_TIMEOUT = "7";
|
||||
|
||||
expect(getConnectionBudget()).toBe(12);
|
||||
expect(getPrismaPoolMax()).toBe(10);
|
||||
expect(getPgBossPoolMax()).toBe(2);
|
||||
expect(getPoolConnectionTimeoutMs()).toBe(7_000);
|
||||
});
|
||||
|
||||
it("keeps DATABASE_POOL_MAX as a backward-compatible explicit override", () => {
|
||||
process.env.DATABASE_POOL_MAX = "30";
|
||||
expect(getPoolMax()).toBe(30);
|
||||
});
|
||||
|
||||
it("falls back to 20 on a malformed DATABASE_POOL_MAX", () => {
|
||||
process.env.DATABASE_POOL_MAX = "not-a-number";
|
||||
expect(getPoolMax()).toBe(20);
|
||||
});
|
||||
|
||||
it("falls back to 20 on a non-positive DATABASE_POOL_MAX", () => {
|
||||
process.env.DATABASE_POOL_MAX = "0";
|
||||
expect(getPoolMax()).toBe(20);
|
||||
process.env.DATABASE_POOL_MAX = "-5";
|
||||
expect(getPoolMax()).toBe(20);
|
||||
});
|
||||
|
||||
it("never returns the library default of 10 on the happy path", () => {
|
||||
// The whole point of the W-POOL change. If anybody removes the
|
||||
// `max:` config from `createPrismaClient` and reverts to the
|
||||
// library default, this assertion stops the regression before the
|
||||
// next saturated cold mount.
|
||||
expect(getPoolMax()).toBeGreaterThanOrEqual(20);
|
||||
expect(getConnectionBudget()).toBe(30);
|
||||
expect(getPrismaPoolMax()).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const entrypoint = join(repoRoot, "docker-entrypoint.sh");
|
||||
const key = "a".repeat(64);
|
||||
let fakeBin: string;
|
||||
|
||||
beforeAll(() => {
|
||||
fakeBin = mkdtempSync(join(tmpdir(), "healthlog-entrypoint-"));
|
||||
const fakeNode = join(fakeBin, "node");
|
||||
writeFileSync(
|
||||
fakeNode,
|
||||
`#!/bin/sh
|
||||
case "$1" in
|
||||
-e)
|
||||
case "$2" in
|
||||
*ENCRYPTION_KEYS*) exec "$REAL_NODE" "$@" ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
`,
|
||||
);
|
||||
chmodSync(fakeNode, 0o755);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(fakeBin, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runEntrypoint(overrides: Record<string, string | undefined>) {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
REAL_NODE: process.execPath,
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: "postgresql://healthlog:test@db:5432/healthlog",
|
||||
API_TOKEN_HMAC_KEY: "test-hmac-key",
|
||||
...overrides,
|
||||
};
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (value === undefined) delete env[name];
|
||||
}
|
||||
return spawnSync("sh", [entrypoint, "true"], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
describe("docker entrypoint crypto configuration", () => {
|
||||
it("accepts the legacy ENCRYPTION_KEY configuration", () => {
|
||||
const result = runEntrypoint({
|
||||
ENCRYPTION_KEY: key,
|
||||
ENCRYPTION_KEYS: undefined,
|
||||
ENCRYPTION_ACTIVE_KEY_ID: undefined,
|
||||
});
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
}, 20_000);
|
||||
|
||||
it("accepts a keyring-only configuration", () => {
|
||||
const result = runEntrypoint({
|
||||
ENCRYPTION_KEY: undefined,
|
||||
ENCRYPTION_KEYS: JSON.stringify({ v2: key }),
|
||||
ENCRYPTION_ACTIVE_KEY_ID: undefined,
|
||||
});
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
}, 20_000);
|
||||
|
||||
it("fails closed when ENCRYPTION_KEYS is invalid even if a legacy key exists", () => {
|
||||
const result = runEntrypoint({
|
||||
ENCRYPTION_KEY: key,
|
||||
ENCRYPTION_KEYS: JSON.stringify({ v2: "not-a-32-byte-key" }),
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "v2",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("ENCRYPTION_KEYS");
|
||||
expect(result.stderr).toContain("invalid");
|
||||
});
|
||||
|
||||
it("fails closed when the active key is absent from the keyring", () => {
|
||||
const result = runEntrypoint({
|
||||
ENCRYPTION_KEY: undefined,
|
||||
ENCRYPTION_KEYS: JSON.stringify({ v2: key }),
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "v3",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("ENCRYPTION_ACTIVE_KEY_ID");
|
||||
});
|
||||
});
|
||||
+49
-28
@@ -5,34 +5,54 @@ const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.4.40 W-POOL — Prisma `pg.Pool` ceiling raised from the library
|
||||
* default of 10 → 20.
|
||||
*
|
||||
* The v1.4.39 empirical cold-mount trace
|
||||
* (`.planning/round-v1439-empirical-trace.md` § B2) showed thick
|
||||
* `/api/analytics` holding ≥ 8 of the 10 default pool slots for
|
||||
* 6.5 s on the maintainer's 347k-row tenant, starving every other Wave-B and
|
||||
* Wave-C useQuery fan-out for the duration. The production Postgres runs a
|
||||
* `max_connections` of 200 (raised from the stock 100), so a 20-slot
|
||||
* Node-side pool sits well under the server's hard cap with plenty of
|
||||
* headroom for concurrent power-users. Note the web + worker containers
|
||||
* share this one Postgres, so the effective ceiling covers both pools.
|
||||
* Complements the W-POOL `p-limit(4)` cap on the analytics fan-out itself —
|
||||
* the bounded concurrency keeps any single analytics call to ≤ 4 slots, so
|
||||
* the remaining 16 stay available for every other dashboard query.
|
||||
*
|
||||
* Env-overridable via `DATABASE_POOL_MAX` so an operator running
|
||||
* with a smaller Postgres `max_connections` can dial down without a
|
||||
* code change.
|
||||
*/
|
||||
export function getPoolMax(): number {
|
||||
const raw = process.env.DATABASE_POOL_MAX;
|
||||
if (raw) {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
const DEFAULT_CONNECTION_BUDGET = 20;
|
||||
const DEFAULT_POOL_TIMEOUT_SECONDS = 20;
|
||||
const PG_BOSS_CONNECTIONS = 2;
|
||||
|
||||
function positiveInteger(raw: string | undefined): number | null {
|
||||
if (!raw) return null;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function databaseUrlParameter(name: string): string | undefined {
|
||||
const rawUrl = process.env.DATABASE_URL;
|
||||
if (!rawUrl) return undefined;
|
||||
try {
|
||||
return new URL(rawUrl).searchParams.get(name) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return 20;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total PostgreSQL connections this process may own. Compose already exposes
|
||||
* this as DB_CONNECTION_LIMIT through DATABASE_URL's `connection_limit`.
|
||||
* DATABASE_POOL_MAX remains a backward-compatible explicit override.
|
||||
*/
|
||||
export function getConnectionBudget(): number {
|
||||
const configured =
|
||||
positiveInteger(process.env.DB_CONNECTION_LIMIT) ??
|
||||
positiveInteger(process.env.DATABASE_POOL_MAX) ??
|
||||
positiveInteger(databaseUrlParameter("connection_limit")) ??
|
||||
DEFAULT_CONNECTION_BUDGET;
|
||||
return Math.max(2, configured);
|
||||
}
|
||||
|
||||
export function getPgBossPoolMax(): number {
|
||||
return Math.min(PG_BOSS_CONNECTIONS, getConnectionBudget() - 1);
|
||||
}
|
||||
|
||||
export function getPrismaPoolMax(): number {
|
||||
return getConnectionBudget() - getPgBossPoolMax();
|
||||
}
|
||||
|
||||
export function getPoolConnectionTimeoutMs(): number {
|
||||
const seconds =
|
||||
positiveInteger(process.env.DB_POOL_TIMEOUT) ??
|
||||
positiveInteger(databaseUrlParameter("pool_timeout")) ??
|
||||
DEFAULT_POOL_TIMEOUT_SECONDS;
|
||||
return seconds * 1_000;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +99,8 @@ function createPrismaClient() {
|
||||
const sessionOptions = buildSessionOptions();
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL!,
|
||||
max: getPoolMax(),
|
||||
max: getPrismaPoolMax(),
|
||||
connectionTimeoutMillis: getPoolConnectionTimeoutMs(),
|
||||
...(sessionOptions ? { options: sessionOptions } : {}),
|
||||
});
|
||||
return new PrismaClient({ adapter });
|
||||
|
||||
@@ -16,8 +16,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {},
|
||||
buildSessionOptions: () => null,
|
||||
getPoolMax: () => 5,
|
||||
toJson: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,44 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
const { start, PgBoss } = vi.hoisted(() => {
|
||||
const start = vi.fn(async () => undefined);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const on = vi.fn();
|
||||
const PgBoss = vi.fn(function MockPgBoss(this: Record<string, unknown>) {
|
||||
this.start = start;
|
||||
this.stop = stop;
|
||||
this.on = on;
|
||||
interface MockBoss {
|
||||
start: Mock<() => Promise<unknown>>;
|
||||
stop: Mock<(options?: { graceful?: boolean }) => Promise<void>>;
|
||||
on: Mock<(event: string, handler: (error: Error) => void) => MockBoss>;
|
||||
emitError(error: Error): void;
|
||||
}
|
||||
|
||||
const { PgBoss, instances, startOutcomes } = vi.hoisted(() => {
|
||||
const instances: MockBoss[] = [];
|
||||
const startOutcomes: Array<() => Promise<unknown>> = [];
|
||||
const PgBoss = vi.fn(function MockPgBoss(this: MockBoss) {
|
||||
let errorHandler: ((error: Error) => void) | undefined;
|
||||
this.start = vi.fn(() =>
|
||||
(startOutcomes.shift() ?? (() => Promise.resolve(undefined)))(),
|
||||
);
|
||||
this.stop = vi.fn(async () => undefined);
|
||||
this.on = vi.fn((event: string, handler: (error: Error) => void) => {
|
||||
if (event === "error") errorHandler = handler;
|
||||
return this;
|
||||
});
|
||||
this.emitError = (error: Error) => errorHandler?.(error);
|
||||
instances.push(this);
|
||||
});
|
||||
return { start, PgBoss };
|
||||
return { PgBoss, instances, startOutcomes };
|
||||
});
|
||||
|
||||
vi.mock("pg-boss", () => ({ PgBoss }));
|
||||
import { getGlobalBoss, startGlobalBossProducer } from "../boss-instance";
|
||||
import {
|
||||
createWorkerBoss,
|
||||
getGlobalBoss,
|
||||
startGlobalBossProducer,
|
||||
} from "../boss-instance";
|
||||
|
||||
const globalState = globalThis as Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
instances.length = 0;
|
||||
startOutcomes.length = 0;
|
||||
delete globalState.__healthlog_pgboss__;
|
||||
delete globalState.__healthlog_pgboss_start__;
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
describe("web-process pg-boss producer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it("waits for delayed pg-boss schema readiness with capped exponential backoff", async () => {
|
||||
vi.useFakeTimers();
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
startOutcomes.push(
|
||||
() => Promise.reject(new Error("schema not ready")),
|
||||
() => Promise.reject(new Error("schema still not ready")),
|
||||
() => Promise.reject(new Error("schema still not ready")),
|
||||
() => Promise.resolve(undefined),
|
||||
);
|
||||
|
||||
it("retries startup and caps the send-only connection pool", async () => {
|
||||
start
|
||||
.mockRejectedValueOnce(new Error("schema not ready"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const boss = await startGlobalBossProducer("postgres://healthlog@test/db", {
|
||||
maxAttempts: 2,
|
||||
retryDelayMs: 0,
|
||||
const starting = startGlobalBossProducer("postgres://healthlog@test/db", {
|
||||
maxAttempts: 4,
|
||||
retryBaseDelayMs: 10,
|
||||
retryMaxDelayMs: 20,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
const boss = await starting;
|
||||
|
||||
expect(PgBoss).toHaveBeenCalledTimes(2);
|
||||
expect(PgBoss).toHaveBeenCalledWith({
|
||||
expect(PgBoss).toHaveBeenCalledTimes(4);
|
||||
expect(timeoutSpy.mock.calls.map((call) => call[1])).toEqual([10, 20, 20]);
|
||||
expect(PgBoss).toHaveBeenLastCalledWith({
|
||||
connectionString: "postgres://healthlog@test/db",
|
||||
max: 2,
|
||||
connectionTimeoutMillis: 20_000,
|
||||
migrate: false,
|
||||
schedule: false,
|
||||
supervise: false,
|
||||
});
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
expect(boss).toBe(getGlobalBoss());
|
||||
});
|
||||
|
||||
it("keeps supervised reconnect active after a bounded failed batch", async () => {
|
||||
vi.useFakeTimers();
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
startOutcomes.push(() => Promise.resolve(undefined));
|
||||
await startGlobalBossProducer("postgres://healthlog@test/db", {
|
||||
maxAttempts: 2,
|
||||
retryBaseDelayMs: 10,
|
||||
retryMaxDelayMs: 20,
|
||||
});
|
||||
|
||||
startOutcomes.push(
|
||||
() => Promise.reject(new Error("db unavailable 1")),
|
||||
() => Promise.reject(new Error("db unavailable 2")),
|
||||
() => Promise.resolve(undefined),
|
||||
);
|
||||
instances[0].emitError(new Error("connection lost"));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(PgBoss).toHaveBeenCalledTimes(4);
|
||||
expect(timeoutSpy.mock.calls.map((call) => call[1])).toEqual([10, 20]);
|
||||
expect(instances[0].stop).toHaveBeenCalledWith({ graceful: false });
|
||||
expect(getGlobalBoss()).toBe(instances[3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worker pg-boss pool", () => {
|
||||
it("uses the pg-boss share of the process connection budget", () => {
|
||||
createWorkerBoss("postgres://healthlog@test/db");
|
||||
|
||||
expect(PgBoss).toHaveBeenCalledWith({
|
||||
connectionString: "postgres://healthlog@test/db",
|
||||
max: 2,
|
||||
connectionTimeoutMillis: 20_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,8 @@
|
||||
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.1.
|
||||
*/
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { buildSessionOptions, getPoolMax, toJson } from "@/lib/db";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import type { Job } from "pg-boss";
|
||||
|
||||
import { extractExportXml } from "@/lib/import/unzip-export-xml";
|
||||
@@ -94,20 +93,7 @@ export function _setWorkerPrismaForTests(client: PrismaClient | null): void {
|
||||
}
|
||||
|
||||
function getWorkerPrisma(): PrismaClient {
|
||||
if (!workerPrismaSingleton) {
|
||||
// Inherit the web client's pool ceiling + per-session statement timeouts
|
||||
// (`src/lib/db.ts`). The timeout is per-statement, not per-job, so the
|
||||
// long-running bulk import stays safe while a single wedged UPSERT can no
|
||||
// longer pin a worker connection indefinitely.
|
||||
const sessionOptions = buildSessionOptions();
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL!,
|
||||
max: getPoolMax(),
|
||||
...(sessionOptions ? { options: sessionOptions } : {}),
|
||||
});
|
||||
workerPrismaSingleton = new PrismaClient({ adapter });
|
||||
}
|
||||
return workerPrismaSingleton;
|
||||
return workerPrismaSingleton ?? prisma;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+131
-46
@@ -7,15 +7,24 @@
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
|
||||
import { getPgBossPoolMax, getPoolConnectionTimeoutMs } from "@/lib/db";
|
||||
|
||||
const BOSS_KEY = "__healthlog_pgboss__" as const;
|
||||
const BOSS_START_KEY = "__healthlog_pgboss_start__" as const;
|
||||
const PRODUCER_POOL_MAX = 2;
|
||||
const PRODUCER_START_MAX_ATTEMPTS = 5;
|
||||
const PRODUCER_START_RETRY_DELAY_MS = 1_000;
|
||||
const PRODUCER_RETRY_BASE_DELAY_MS = 250;
|
||||
const PRODUCER_RETRY_MAX_DELAY_MS = 2_000;
|
||||
|
||||
export interface ProducerStartOptions {
|
||||
maxAttempts?: number;
|
||||
retryDelayMs?: number;
|
||||
retryBaseDelayMs?: number;
|
||||
retryMaxDelayMs?: number;
|
||||
}
|
||||
|
||||
interface ProducerRetryPolicy {
|
||||
maxAttempts: number;
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
@@ -24,20 +33,127 @@ function wait(ms: number): Promise<void> {
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function setGlobalBoss(boss: PgBoss) {
|
||||
(globalThis as Record<string, unknown>)[BOSS_KEY] = boss;
|
||||
function resolveRetryPolicy(
|
||||
options: ProducerStartOptions,
|
||||
): ProducerRetryPolicy {
|
||||
const maxAttempts = Math.max(
|
||||
1,
|
||||
Math.trunc(options.maxAttempts ?? PRODUCER_START_MAX_ATTEMPTS),
|
||||
);
|
||||
const baseDelayMs = Math.max(
|
||||
0,
|
||||
Math.trunc(options.retryBaseDelayMs ?? PRODUCER_RETRY_BASE_DELAY_MS),
|
||||
);
|
||||
const maxDelayMs = Math.max(
|
||||
baseDelayMs,
|
||||
Math.trunc(options.retryMaxDelayMs ?? PRODUCER_RETRY_MAX_DELAY_MS),
|
||||
);
|
||||
return { maxAttempts, baseDelayMs, maxDelayMs };
|
||||
}
|
||||
|
||||
function retryDelayMs(
|
||||
failedAttempt: number,
|
||||
policy: ProducerRetryPolicy,
|
||||
): number {
|
||||
return Math.min(
|
||||
policy.maxDelayMs,
|
||||
policy.baseDelayMs * 2 ** Math.max(0, failedAttempt - 1),
|
||||
);
|
||||
}
|
||||
|
||||
export function setGlobalBoss(boss: PgBoss | null): void {
|
||||
const globalState = globalThis as Record<string, unknown>;
|
||||
if (boss) globalState[BOSS_KEY] = boss;
|
||||
else delete globalState[BOSS_KEY];
|
||||
}
|
||||
|
||||
export function getGlobalBoss(): PgBoss | null {
|
||||
return ((globalThis as Record<string, unknown>)[BOSS_KEY] as PgBoss) ?? null;
|
||||
}
|
||||
|
||||
function databaseOptions(connectionString: string) {
|
||||
return {
|
||||
connectionString,
|
||||
max: getPgBossPoolMax(),
|
||||
connectionTimeoutMillis: getPoolConnectionTimeoutMs(),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function createWorkerBoss(connectionString: string): PgBoss {
|
||||
return new PgBoss(databaseOptions(connectionString));
|
||||
}
|
||||
|
||||
function producerOptions(connectionString: string) {
|
||||
return {
|
||||
...databaseOptions(connectionString),
|
||||
migrate: false,
|
||||
schedule: false,
|
||||
supervise: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function startProducerAttempts(
|
||||
connectionString: string,
|
||||
policy: ProducerRetryPolicy,
|
||||
): Promise<PgBoss> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
|
||||
const boss = new PgBoss(producerOptions(connectionString));
|
||||
let ready = false;
|
||||
boss.on("error", (error: unknown) => {
|
||||
console.error("[pg-boss-producer] error", error);
|
||||
if (ready) superviseProducerReconnect(boss, connectionString, policy);
|
||||
});
|
||||
try {
|
||||
await boss.start();
|
||||
ready = true;
|
||||
setGlobalBoss(boss);
|
||||
return boss;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await boss.stop({ graceful: false }).catch(() => undefined);
|
||||
if (attempt < policy.maxAttempts) {
|
||||
await wait(retryDelayMs(attempt, policy));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function superviseProducerReconnect(
|
||||
failedBoss: PgBoss,
|
||||
connectionString: string,
|
||||
policy: ProducerRetryPolicy,
|
||||
): void {
|
||||
const globalState = globalThis as Record<string, unknown>;
|
||||
if (getGlobalBoss() !== failedBoss || globalState[BOSS_START_KEY]) return;
|
||||
|
||||
setGlobalBoss(null);
|
||||
const reconnecting = (async () => {
|
||||
await failedBoss.stop({ graceful: false }).catch(() => undefined);
|
||||
for (;;) {
|
||||
try {
|
||||
return await startProducerAttempts(connectionString, policy);
|
||||
} catch (error) {
|
||||
console.error("[pg-boss-producer] reconnect batch exhausted", error);
|
||||
await wait(policy.maxDelayMs);
|
||||
}
|
||||
}
|
||||
})();
|
||||
globalState[BOSS_START_KEY] = reconnecting;
|
||||
void reconnecting.finally(() => {
|
||||
if (globalState[BOSS_START_KEY] === reconnecting) {
|
||||
delete globalState[BOSS_START_KEY];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the web process's send-only pg-boss connection exactly once.
|
||||
*
|
||||
* `PgBoss.start()` is still required to open/check the database connection.
|
||||
* Disabling migration, schedules, and supervision keeps this process from
|
||||
* performing worker duties; `send()` remains available through the manager.
|
||||
* `PgBoss.start()` is required to confirm the worker-owned schema exists.
|
||||
* Migration, schedules, and pg-boss maintenance stay disabled in web mode;
|
||||
* this module only supervises the producer connection itself.
|
||||
*/
|
||||
export async function startGlobalBossProducer(
|
||||
connectionString: string | null | undefined,
|
||||
@@ -53,47 +169,16 @@ export async function startGlobalBossProducer(
|
||||
Promise<PgBoss | null> | undefined;
|
||||
if (starting) return starting;
|
||||
|
||||
const maxAttempts = Math.max(
|
||||
1,
|
||||
Math.trunc(options.maxAttempts ?? PRODUCER_START_MAX_ATTEMPTS),
|
||||
const startPromise = startProducerAttempts(
|
||||
connectionString,
|
||||
resolveRetryPolicy(options),
|
||||
);
|
||||
const retryDelayMs = Math.max(
|
||||
0,
|
||||
options.retryDelayMs ?? PRODUCER_START_RETRY_DELAY_MS,
|
||||
);
|
||||
const startPromise = (async () => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const boss = new PgBoss({
|
||||
connectionString,
|
||||
max: PRODUCER_POOL_MAX,
|
||||
migrate: false,
|
||||
schedule: false,
|
||||
supervise: false,
|
||||
});
|
||||
boss.on("error", (error: unknown) => {
|
||||
console.error("[pg-boss-producer] error", error);
|
||||
});
|
||||
try {
|
||||
await boss.start();
|
||||
setGlobalBoss(boss);
|
||||
return boss;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await boss.stop({ graceful: false }).catch(() => {});
|
||||
if (attempt < maxAttempts) {
|
||||
await wait(retryDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
})();
|
||||
|
||||
globalState[BOSS_START_KEY] = startPromise;
|
||||
try {
|
||||
return await startPromise;
|
||||
} catch (error) {
|
||||
delete globalState[BOSS_START_KEY];
|
||||
throw error;
|
||||
} finally {
|
||||
if (globalState[BOSS_START_KEY] === startPromise) {
|
||||
delete globalState[BOSS_START_KEY];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,8 @@
|
||||
* Usage: Run as a standalone process or call startReminderWorker() from a
|
||||
* custom server setup. In dev, use: npx tsx src/lib/jobs/reminder-worker.ts
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status";
|
||||
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { createWorkerBoss, setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { assertSubsystemEnabled } from "@/lib/process-type";
|
||||
import { DATABASE_URL, workerLog } from "./reminder/shared";
|
||||
import {
|
||||
@@ -50,7 +49,7 @@ export async function startReminderWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
const boss = new PgBoss(DATABASE_URL);
|
||||
const boss = createWorkerBoss(DATABASE_URL);
|
||||
|
||||
boss.on("error", (error: unknown) => {
|
||||
workerLog("error", "boss emitted error", error);
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
/**
|
||||
* F-DB-1 — the worker Prisma client must inherit the same pool ceiling +
|
||||
* session timeouts as the web client (`src/lib/db.ts`).
|
||||
*
|
||||
* Before this fix the worker adapter was built with a bare
|
||||
* `{ connectionString }`, so a pathological nightly drain on a heavy tenant
|
||||
* could run unbounded, pin a connection indefinitely, and wedge the shared
|
||||
* worker pool. This test pins the contract: `getWorkerPrisma()` constructs its
|
||||
* `PrismaPg` adapter with `max` from `getPoolMax()` and the `options` startup
|
||||
* string from `buildSessionOptions()`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const adapterCtor = vi.fn();
|
||||
const clientCtor = vi.fn();
|
||||
@@ -23,6 +12,7 @@ vi.mock("@prisma/adapter-pg", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/generated/prisma/client", () => ({
|
||||
Prisma: {},
|
||||
PrismaClient: class {
|
||||
constructor(...args: unknown[]) {
|
||||
clientCtor(...args);
|
||||
@@ -30,78 +20,24 @@ vi.mock("@/generated/prisma/client", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getWorkerPrisma", () => {
|
||||
describe("worker Prisma connection ownership", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
adapterCtor.mockClear();
|
||||
clientCtor.mockClear();
|
||||
process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/db";
|
||||
delete process.env.DATABASE_POOL_MAX;
|
||||
delete process.env.DATABASE_STATEMENT_TIMEOUT_MS;
|
||||
process.env.DATABASE_URL =
|
||||
"postgres://user:pass@localhost:5432/db?connection_limit=20&pool_timeout=20";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATABASE_POOL_MAX;
|
||||
delete process.env.DATABASE_STATEMENT_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
it("builds the adapter with a pool cap and session timeouts", async () => {
|
||||
const { getWorkerPrisma } = await import("../shared");
|
||||
// Importing ../shared pulls in src/lib/db.ts, which constructs the web
|
||||
// client's adapter at module load. Clear so we count only the worker's.
|
||||
adapterCtor.mockClear();
|
||||
clientCtor.mockClear();
|
||||
getWorkerPrisma();
|
||||
it("reuses the process Prisma client instead of opening a second pool", async () => {
|
||||
const [{ prisma }, { getWorkerPrisma }] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
import("../shared"),
|
||||
]);
|
||||
|
||||
expect(getWorkerPrisma()).toBe(prisma);
|
||||
expect(getWorkerPrisma()).toBe(prisma);
|
||||
expect(adapterCtor).toHaveBeenCalledTimes(1);
|
||||
const config = adapterCtor.mock.calls[0][0] as {
|
||||
connectionString: string;
|
||||
max: number;
|
||||
options?: string;
|
||||
};
|
||||
expect(config.connectionString).toBe(
|
||||
"postgres://user:pass@localhost:5432/db",
|
||||
);
|
||||
// Default pool ceiling — must not fall back to the pg library default of 10.
|
||||
expect(config.max).toBe(20);
|
||||
// Both session timeouts applied at connection establishment.
|
||||
expect(config.options).toContain("-c statement_timeout=60000");
|
||||
expect(config.options).toContain(
|
||||
"-c idle_in_transaction_session_timeout=60000",
|
||||
);
|
||||
});
|
||||
|
||||
it("honours the shared env knobs", async () => {
|
||||
process.env.DATABASE_POOL_MAX = "12";
|
||||
process.env.DATABASE_STATEMENT_TIMEOUT_MS = "30000";
|
||||
const { getWorkerPrisma } = await import("../shared");
|
||||
adapterCtor.mockClear();
|
||||
getWorkerPrisma();
|
||||
|
||||
const config = adapterCtor.mock.calls[0][0] as {
|
||||
max: number;
|
||||
options?: string;
|
||||
};
|
||||
expect(config.max).toBe(12);
|
||||
expect(config.options).toContain("-c statement_timeout=30000");
|
||||
});
|
||||
|
||||
it("omits the options string when the timeout is disabled", async () => {
|
||||
process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0";
|
||||
const { getWorkerPrisma } = await import("../shared");
|
||||
adapterCtor.mockClear();
|
||||
getWorkerPrisma();
|
||||
|
||||
const config = adapterCtor.mock.calls[0][0] as { options?: string };
|
||||
expect(config.options).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses a single client across calls", async () => {
|
||||
const { getWorkerPrisma } = await import("../shared");
|
||||
clientCtor.mockClear();
|
||||
const a = getWorkerPrisma();
|
||||
const b = getWorkerPrisma();
|
||||
expect(a).toBe(b);
|
||||
expect(clientCtor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,8 @@
|
||||
* Extracted from reminder-worker.ts, which owns the queue names, cron
|
||||
* schedules, and boss.work registrations.
|
||||
*/
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { buildSessionOptions, getPoolMax } from "@/lib/db";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export function parseTimeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
@@ -31,31 +30,10 @@ export const DATABASE_URL = process.env.DATABASE_URL!;
|
||||
// immediate semantics.
|
||||
export const BOOT_BACKFILL_STAGGER_SECONDS = 30;
|
||||
|
||||
// Reuse a single PrismaClient across all job handlers to avoid connection pool exhaustion
|
||||
|
||||
let workerPrisma: PrismaClient | null = null;
|
||||
|
||||
// Every queue handler shares the process-level client and its single bounded
|
||||
// pg.Pool. Separate worker clients multiplied the configured connection limit.
|
||||
export function getWorkerPrisma(): PrismaClient {
|
||||
if (!workerPrisma) {
|
||||
// Apply the same pool ceiling + session timeouts the web client uses
|
||||
// (`src/lib/db.ts`). Without them the worker client ran unbounded: a
|
||||
// pathological nightly drain on a heavy tenant could pin a connection
|
||||
// indefinitely and wedge the shared worker pool. `getPoolMax()` caps the
|
||||
// slot count; `buildSessionOptions()` applies `statement_timeout` +
|
||||
// `idle_in_transaction_session_timeout` at connection establishment so
|
||||
// every worker session is timeout-bounded from its first query. Both share
|
||||
// the same `DATABASE_POOL_MAX` / `DATABASE_STATEMENT_TIMEOUT_MS` env knobs
|
||||
// as the web tier, and the web + worker containers share one Postgres so a
|
||||
// single ceiling covers both pools.
|
||||
const sessionOptions = buildSessionOptions();
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: DATABASE_URL,
|
||||
max: getPoolMax(),
|
||||
...(sessionOptions ? { options: sessionOptions } : {}),
|
||||
});
|
||||
workerPrisma = new PrismaClient({ adapter });
|
||||
}
|
||||
return workerPrisma;
|
||||
return prisma;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ export function proxy(request: NextRequest) {
|
||||
// Worker-only container: refuse HTTP traffic with a clear hint instead of
|
||||
// serving requests that would either crash (no DB pool ready for writes from
|
||||
// the worker) or duplicate work that the dedicated web container is doing.
|
||||
if (!shouldRunWeb()) {
|
||||
if (!shouldRunWeb() && pathname !== "/api/health") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient } from "./setup";
|
||||
|
||||
const TEST_ENCRYPTION_KEY =
|
||||
"0000000000000000000000000000000000000000000000000000000000000000";
|
||||
const TEST_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum";
|
||||
const TEST_SESSION_SECRET = "integration-test-session-secret-32-bytes";
|
||||
|
||||
describe("integration worker environment", () => {
|
||||
it("replaces ambient database and encryption configuration with test values", async () => {
|
||||
expect(process.env.DATABASE_URL).toContain("healthlog_test");
|
||||
expect(process.env.DATABASE_URL).not.toContain("ambient_database");
|
||||
expect(process.env.ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY);
|
||||
expect(process.env.ENCRYPTION_KEYS).toBeUndefined();
|
||||
expect(process.env.ENCRYPTION_ACTIVE_KEY_ID).toBeUndefined();
|
||||
expect(process.env.API_TOKEN_HMAC_KEY).toBe(TEST_HMAC_KEY);
|
||||
expect(process.env.SESSION_SECRET).toBe(TEST_SESSION_SECRET);
|
||||
|
||||
const rows = await getPrismaClient().$queryRaw<
|
||||
Array<{ database_name: string }>
|
||||
>`
|
||||
SELECT current_database() AS database_name
|
||||
`;
|
||||
expect(rows).toEqual([{ database_name: "healthlog_test" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { inject } from "vitest";
|
||||
|
||||
declare module "vitest" {
|
||||
export interface ProvidedContext {
|
||||
integrationDatabaseUrl: string;
|
||||
}
|
||||
}
|
||||
|
||||
process.env.DATABASE_URL = inject("integrationDatabaseUrl");
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0000000000000000000000000000000000000000000000000000000000000000";
|
||||
delete process.env.ENCRYPTION_KEYS;
|
||||
delete process.env.ENCRYPTION_ACTIVE_KEY_ID;
|
||||
process.env.API_TOKEN_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum";
|
||||
process.env.SESSION_SECRET = "integration-test-session-secret-32-bytes";
|
||||
@@ -18,6 +18,7 @@ import { execSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { TestProject } from "vitest/node";
|
||||
import {
|
||||
PostgreSqlContainer,
|
||||
type StartedPostgreSqlContainer,
|
||||
@@ -29,7 +30,9 @@ const PROJECT_ROOT = resolve(__dirname, "..", "..");
|
||||
|
||||
let container: StartedPostgreSqlContainer | null = null;
|
||||
|
||||
export default async function setup(): Promise<() => Promise<void>> {
|
||||
export default async function setup(
|
||||
project: TestProject,
|
||||
): Promise<() => Promise<void>> {
|
||||
container = await new PostgreSqlContainer("postgres:16-alpine")
|
||||
.withDatabase("healthlog_test")
|
||||
.withUsername("healthlog")
|
||||
@@ -38,10 +41,9 @@ export default async function setup(): Promise<() => Promise<void>> {
|
||||
|
||||
const url = container.getConnectionUri() + "?schema=public&pgbouncer=false";
|
||||
process.env.DATABASE_URL = url;
|
||||
project.provide("integrationDatabaseUrl", url);
|
||||
|
||||
// Provided so child processes (e.g. Prisma's CLI) inherit the URL.
|
||||
// Vitest also forwards process.env to test workers, so the
|
||||
// application Prisma singleton sees this URL when it loads.
|
||||
// The explicit child environment keeps Prisma CLI on the same container.
|
||||
execSync("pnpm db:migrate:deploy", {
|
||||
cwd: PROJECT_ROOT,
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -1,39 +1,11 @@
|
||||
/**
|
||||
* Per-test helpers for the integration suite. The Postgres testcontainer
|
||||
* is started ONCE in `global-setup.ts` and torn down at the end of the
|
||||
* run, so test files only need:
|
||||
*
|
||||
* import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
*
|
||||
* beforeEach(async () => { await truncateAllTables(getPrismaClient()); });
|
||||
*
|
||||
* No beforeAll(startTestDb) / afterAll(stopTestDb) is needed — the
|
||||
* container is alive whenever the test file is loaded, and Vitest's
|
||||
* worker inherits `process.env.DATABASE_URL` from globalSetup so the
|
||||
* application's Prisma singleton (`src/lib/db.ts`) connects to the
|
||||
* testcontainer transparently.
|
||||
* is started once in global-setup.ts and bridged into each worker by
|
||||
* environment-setup.ts before application modules load.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
// v1.23 — every integration test imports this module. The free-text health-note
|
||||
// columns (mood + measurement) are now AES-256-GCM at rest, so any test that
|
||||
// writes/reads a note needs an encryption key. Crypto reads the key lazily on
|
||||
// first `encrypt()`, so a `??=` default here covers every test without touching
|
||||
// each file (individual tests that set their own key still win — `??=`).
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
// v1.30.32 — session cookies now carry a CSPRNG secret whose HMAC is what
|
||||
// lands in `sessions.token_hash`, so `createSession` needs this key exactly
|
||||
// like token issuance already did. It is a Core-required variable in
|
||||
// `scripts/env-manifest.json` (the app refuses to boot without it), so
|
||||
// defaulting it here matches production rather than papering over a gap.
|
||||
// Same `??=` treatment as the encryption key above: files that set their own
|
||||
// still win.
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
/**
|
||||
* The application's Prisma singleton. Tests use this so any code
|
||||
* imported via `await import("@/lib/...")` shares the exact same client
|
||||
|
||||
@@ -15,7 +15,16 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
// Same host-timezone pin as the unit config — CI runs UTC, so the
|
||||
// integration contracts must be read against the same clock.
|
||||
env: { TZ: "UTC" },
|
||||
env: {
|
||||
TZ: "UTC",
|
||||
ENCRYPTION_KEY:
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
ENCRYPTION_KEYS: "",
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "",
|
||||
API_TOKEN_HMAC_KEY:
|
||||
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
SESSION_SECRET: "integration-test-session-secret-32-bytes",
|
||||
},
|
||||
include: ["tests/integration/**/*.test.ts"],
|
||||
// Container boot + migration apply is slow; give each test a generous
|
||||
// budget and beforeAll/afterAll twice that.
|
||||
@@ -39,6 +48,9 @@ export default defineConfig({
|
||||
// expensive boot cost is unaffected.
|
||||
isolate: true,
|
||||
// One container for the whole run; tests truncate between them.
|
||||
// Bridge the global Testcontainers URL into each isolated worker before
|
||||
// application modules read DATABASE_URL at import time.
|
||||
setupFiles: ["./tests/integration/environment-setup.ts"],
|
||||
globalSetup: ["./tests/integration/global-setup.ts"],
|
||||
},
|
||||
resolve: {
|
||||
|
||||
Reference in new issue
Block a user