From 8c101242723f878d94c33b3470c7e976bc7ae053 Mon Sep 17 00:00:00 2001 From: Kevin Riehl Date: Wed, 6 May 2026 15:49:01 -0700 Subject: [PATCH] Build, scan, and push images to Harbor on every main push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires up the CD half of the pipeline. New jobs build multi-stage Docker images for the frontend and backend, run a Trivy image scan that fails on HIGH/CRITICAL findings, and push to harbor.tehriehldeal.com on main only. Each push tags (from package.json), , and latest; a pre-push existence check refuses to overwrite a version tag that already points at a different digest, forcing a real bump. The Vite frontend now reads runtime config from window.__RUNTIME_CONFIG__, populated by /config.js which nginx renders from container env vars at startup via envsubst. A getConfig() helper falls back to import.meta.env for `pnpm dev` and Vitest, so existing test scaffolding keeps working. PWA workbox excludes /config.js from precache and serves it NetworkOnly to keep stale config from surviving a container restart. Bumps frontend 0.0.0→0.1.0 and backend 0.0.1→0.1.0 (production deployment is a meaningful new capability for both packages). Also fixes four pre-existing tsc -b errors that the new vite build step in the frontend Dockerfile would otherwise hit: global.fetch → globalThis.fetch in three test files, null-guard in Activity.tsx account filter, type cast on Recharts Pie onClick in Dashboard.tsx, typed callback signature on the auth.test.ts onAuthStateChange mock. Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 24 ++ .gitea/workflows/ci.yml | 229 ++++++++++++++++-- tehriehlbudget-backend/Dockerfile | 41 ++++ tehriehlbudget-backend/package.json | 2 +- tehriehlbudget-frontend/Dockerfile | 28 +++ tehriehlbudget-frontend/docker-entrypoint.sh | 8 + tehriehlbudget-frontend/eslint.config.js | 7 + tehriehlbudget-frontend/index.html | 1 + tehriehlbudget-frontend/nginx.conf | 39 +++ tehriehlbudget-frontend/package.json | 2 +- tehriehlbudget-frontend/public/config.js | 1 + .../public/config.template.js | 5 + .../src/components/ReceiptViewer.test.tsx | 12 +- .../src/components/ReceiptViewer.tsx | 3 +- .../src/components/TransactionForm.test.tsx | 14 +- .../src/components/TransactionForm.tsx | 3 +- tehriehlbudget-frontend/src/lib/api.test.ts | 26 +- tehriehlbudget-frontend/src/lib/api.ts | 3 +- .../src/lib/runtime-config.ts | 16 ++ tehriehlbudget-frontend/src/lib/supabase.ts | 5 +- .../src/pages/Activity.tsx | 2 +- .../src/pages/Dashboard.tsx | 7 +- .../src/stores/auth.test.ts | 2 +- tehriehlbudget-frontend/src/vite-env.d.ts | 10 + tehriehlbudget-frontend/vite.config.ts | 12 +- 25 files changed, 444 insertions(+), 58 deletions(-) create mode 100644 .dockerignore create mode 100644 tehriehlbudget-backend/Dockerfile create mode 100644 tehriehlbudget-frontend/Dockerfile create mode 100644 tehriehlbudget-frontend/docker-entrypoint.sh create mode 100644 tehriehlbudget-frontend/nginx.conf create mode 100644 tehriehlbudget-frontend/public/config.js create mode 100644 tehriehlbudget-frontend/public/config.template.js create mode 100644 tehriehlbudget-frontend/src/lib/runtime-config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c48e16c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +**/node_modules +**/dist +**/coverage +**/.env +**/.env.* +!**/.env.example +**/*.log +**/*.tsbuildinfo +.git +.gitea +.github +.gitignore +.gitleaks.toml +.vscode +.idea +.claude +.husky +.prettierignore +.prettierrc +docker-compose.yml +README.md +ProjectPlan.md +TODO.md +LICENSE diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f922c05..df7a468 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -152,21 +152,214 @@ jobs: name: semgrep-report path: semgrep.sarif -# ───────────────────────────────────────────────────────────────────────────── -# Future stages (not yet enabled — Dockerfiles do not exist yet): -# -# build-images: -# needs: [test, lint, secrets-scan, vuln-scan, sast] -# - docker build tehriehlbudget-backend → harbor./tehriehlbudget/backend: -# - docker build tehriehlbudget-frontend → harbor./tehriehlbudget/frontend: -# -# image-scan: -# needs: build-images -# - trivy image scan on each built image (severity gate ON here from day one) -# - re-run trivy SBOM on the image so Harbor gets an image-level CycloneDX -# -# push: -# needs: image-scan -# - docker login harbor. (creds via Gitea Actions secrets) -# - docker push backend + frontend tags -# ───────────────────────────────────────────────────────────────────────────── + build-images: + runs-on: ubuntu-latest + needs: [test, lint, secrets-scan, vuln-scan, sast] + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Compute image tags + run: | + SHA_SHORT=$(git rev-parse --short HEAD) + BACKEND_VERSION=$(jq -r .version tehriehlbudget-backend/package.json) + FRONTEND_VERSION=$(jq -r .version tehriehlbudget-frontend/package.json) + echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV" + echo "BACKEND_VERSION=${BACKEND_VERSION}" >> "$GITHUB_ENV" + echo "FRONTEND_VERSION=${FRONTEND_VERSION}" >> "$GITHUB_ENV" + + - name: Build backend image + run: | + docker buildx build --load \ + -f tehriehlbudget-backend/Dockerfile \ + -t tehriehlbudget-backend:${SHA_SHORT} \ + . + + - name: Build frontend image + run: | + docker buildx build --load \ + -f tehriehlbudget-frontend/Dockerfile \ + -t tehriehlbudget-frontend:${SHA_SHORT} \ + . + + - name: Save images for downstream jobs + run: | + docker save \ + tehriehlbudget-backend:${SHA_SHORT} \ + tehriehlbudget-frontend:${SHA_SHORT} \ + -o /tmp/images.tar + + - uses: actions/upload-artifact@v3 + with: + name: built-images + path: /tmp/images.tar + retention-days: 1 + + image-scan: + runs-on: ubuntu-latest + needs: build-images + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v3 + with: + name: built-images + path: /tmp + + - name: Load images + run: | + docker load -i /tmp/images.tar + SHA_SHORT=$(git rev-parse --short HEAD) + echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV" + + - name: Trivy image scan — backend (HIGH/CRITICAL gate) + run: | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$PWD:/src" \ + aquasec/trivy:latest image \ + --severity HIGH,CRITICAL \ + --exit-code 1 \ + --ignore-unfixed \ + --format sarif --output /src/trivy-image-backend.sarif \ + tehriehlbudget-backend:${SHA_SHORT} + + - name: Trivy image scan — frontend (HIGH/CRITICAL gate) + run: | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$PWD:/src" \ + aquasec/trivy:latest image \ + --severity HIGH,CRITICAL \ + --exit-code 1 \ + --ignore-unfixed \ + --format sarif --output /src/trivy-image-frontend.sarif \ + tehriehlbudget-frontend:${SHA_SHORT} + + - name: Trivy SBOM — backend + if: always() + run: | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$PWD:/src" \ + aquasec/trivy:latest image \ + --format cyclonedx --output /src/sbom-backend.cdx.json \ + tehriehlbudget-backend:${SHA_SHORT} + + - name: Trivy SBOM — frontend + if: always() + run: | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$PWD:/src" \ + aquasec/trivy:latest image \ + --format cyclonedx --output /src/sbom-frontend.cdx.json \ + tehriehlbudget-frontend:${SHA_SHORT} + + - uses: actions/upload-artifact@v3 + if: always() + with: + name: trivy-image-reports + path: | + trivy-image-backend.sarif + trivy-image-frontend.sarif + sbom-backend.cdx.json + sbom-frontend.cdx.json + + push: + runs-on: ubuntu-latest + needs: image-scan + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + HARBOR_HOST: harbor.tehriehldeal.com + HARBOR_PROJECT: tehriehlbudget + steps: + - uses: actions/checkout@v4 + with: + # Need full history so the back-pushed git tag can be created against + # the right commit, and so token-auth on push to origin works. + fetch-depth: 0 + + - uses: actions/download-artifact@v3 + with: + name: built-images + path: /tmp + + - name: Load images and compute tag inputs + run: | + docker load -i /tmp/images.tar + SHA_SHORT=$(git rev-parse --short HEAD) + BACKEND_VERSION=$(jq -r .version tehriehlbudget-backend/package.json) + FRONTEND_VERSION=$(jq -r .version tehriehlbudget-frontend/package.json) + echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV" + echo "BACKEND_VERSION=${BACKEND_VERSION}" >> "$GITHUB_ENV" + echo "FRONTEND_VERSION=${FRONTEND_VERSION}" >> "$GITHUB_ENV" + + - name: Refuse to overwrite an existing version tag + env: + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + run: | + set -eu + check_tag() { + local svc="$1" ver="$2" + local url="https://${HARBOR_HOST}/api/v2.0/projects/${HARBOR_PROJECT}/repositories/${svc}/artifacts/${ver}/tags" + local code + code=$(curl -s -o /dev/null -w "%{http_code}" -u "${HARBOR_USERNAME}:${HARBOR_PASSWORD}" "${url}") + if [ "$code" = "200" ]; then + echo "::error::Tag ${HARBOR_PROJECT}/${svc}:${ver} already exists in Harbor. Bump ${svc}/package.json before merging." + exit 1 + fi + if [ "$code" != "404" ]; then + echo "::warning::Unexpected status ${code} checking ${url} — proceeding." + fi + } + check_tag backend "${BACKEND_VERSION}" + check_tag frontend "${FRONTEND_VERSION}" + + - name: Log in to Harbor + env: + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + run: | + echo "${HARBOR_PASSWORD}" | docker login "${HARBOR_HOST}" -u "${HARBOR_USERNAME}" --password-stdin + + - name: Tag and push backend image + run: | + REPO="${HARBOR_HOST}/${HARBOR_PROJECT}/backend" + docker tag tehriehlbudget-backend:${SHA_SHORT} ${REPO}:${BACKEND_VERSION} + docker tag tehriehlbudget-backend:${SHA_SHORT} ${REPO}:${SHA_SHORT} + docker tag tehriehlbudget-backend:${SHA_SHORT} ${REPO}:latest + docker push ${REPO}:${BACKEND_VERSION} + docker push ${REPO}:${SHA_SHORT} + docker push ${REPO}:latest + + - name: Tag and push frontend image + run: | + REPO="${HARBOR_HOST}/${HARBOR_PROJECT}/frontend" + docker tag tehriehlbudget-frontend:${SHA_SHORT} ${REPO}:${FRONTEND_VERSION} + docker tag tehriehlbudget-frontend:${SHA_SHORT} ${REPO}:${SHA_SHORT} + docker tag tehriehlbudget-frontend:${SHA_SHORT} ${REPO}:latest + docker push ${REPO}:${FRONTEND_VERSION} + docker push ${REPO}:${SHA_SHORT} + docker push ${REPO}:latest + + - name: Push back per-package git tags + env: + # Auto-injected per-run token; has push permission to this repo. + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + git config user.name "gitea-actions[bot]" + git config user.email "gitea-actions[bot]@tehriehldeal.com" + git tag "backend-v${BACKEND_VERSION}" -m "backend ${BACKEND_VERSION}" 2>/dev/null || true + git tag "frontend-v${FRONTEND_VERSION}" -m "frontend ${FRONTEND_VERSION}" 2>/dev/null || true + ORIGIN="$(git remote get-url origin | sed -E "s#https?://([^/]+)#https://${GITEA_TOKEN}@\1#")" + # A failed tag push shouldn't undo a successful image push — log + # and proceed so we don't poison subsequent retries. + git push "$ORIGIN" --tags || echo "::warning::Tag push to origin failed — images are pushed; create the tags manually if needed." + + - name: Log out of Harbor + if: always() + run: docker logout "${HARBOR_HOST}" || true diff --git a/tehriehlbudget-backend/Dockerfile b/tehriehlbudget-backend/Dockerfile new file mode 100644 index 0000000..987cfa1 --- /dev/null +++ b/tehriehlbudget-backend/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1.7 +ARG NODE_VERSION=20 + +FROM node:${NODE_VERSION}-alpine AS deps +RUN apk add --no-cache libc6-compat openssl +RUN corepack enable && corepack prepare pnpm@9 --activate +WORKDIR /repo +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ +COPY tehriehlbudget-backend/package.json tehriehlbudget-backend/ +COPY tehriehlbudget-frontend/package.json tehriehlbudget-frontend/ +RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile --filter tehriehlbudget-backend... + +FROM deps AS build +WORKDIR /repo +COPY tehriehlbudget-backend/ tehriehlbudget-backend/ +RUN pnpm --filter tehriehlbudget-backend exec prisma generate +RUN pnpm --filter tehriehlbudget-backend run build + +FROM deps AS prod-deps +WORKDIR /repo +RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile --prod --filter tehriehlbudget-backend... + +FROM node:${NODE_VERSION}-alpine AS runtime +RUN apk add --no-cache libc6-compat openssl tini +WORKDIR /app +RUN addgroup -S nodeapp && adduser -S nodeapp -G nodeapp +ENV NODE_ENV=production +COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/dist ./dist +COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/prisma ./prisma +COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/package.json ./package.json +COPY --from=prod-deps --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/node_modules ./node_modules +# Overlay generated Prisma client from the build stage (the prod-deps stage +# pruned the `prisma` CLI devDep, which removes the client during install). +COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/node_modules/.prisma ./node_modules/.prisma +COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/node_modules/@prisma ./node_modules/@prisma +USER nodeapp +EXPOSE 3000 +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "dist/main"] diff --git a/tehriehlbudget-backend/package.json b/tehriehlbudget-backend/package.json index 45f4c93..887c6b5 100644 --- a/tehriehlbudget-backend/package.json +++ b/tehriehlbudget-backend/package.json @@ -1,6 +1,6 @@ { "name": "tehriehlbudget-backend", - "version": "0.0.1", + "version": "0.1.0", "description": "", "author": "", "private": true, diff --git a/tehriehlbudget-frontend/Dockerfile b/tehriehlbudget-frontend/Dockerfile new file mode 100644 index 0000000..80c4b68 --- /dev/null +++ b/tehriehlbudget-frontend/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1.7 +ARG NODE_VERSION=20 + +FROM node:${NODE_VERSION}-alpine AS deps +RUN corepack enable && corepack prepare pnpm@9 --activate +WORKDIR /repo +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ +COPY tehriehlbudget-backend/package.json tehriehlbudget-backend/ +COPY tehriehlbudget-frontend/package.json tehriehlbudget-frontend/ +RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile --filter tehriehlbudget-frontend... + +FROM deps AS build +WORKDIR /repo +COPY tehriehlbudget-frontend/ tehriehlbudget-frontend/ +# Build with no VITE_* env: import.meta.env values resolve to "" so the bundle +# carries no compile-time secrets. window.__RUNTIME_CONFIG__ supplies them. +RUN pnpm --filter tehriehlbudget-frontend run build + +FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime +USER root +RUN apk add --no-cache gettext +COPY --from=build --chown=nginx:nginx /repo/tehriehlbudget-frontend/dist /usr/share/nginx/html +COPY --chown=nginx:nginx tehriehlbudget-frontend/nginx.conf /etc/nginx/conf.d/default.conf +COPY --chown=nginx:nginx tehriehlbudget-frontend/docker-entrypoint.sh /docker-entrypoint.d/40-render-config.sh +RUN chmod +x /docker-entrypoint.d/40-render-config.sh +USER nginx +EXPOSE 8080 diff --git a/tehriehlbudget-frontend/docker-entrypoint.sh b/tehriehlbudget-frontend/docker-entrypoint.sh new file mode 100644 index 0000000..3aaed07 --- /dev/null +++ b/tehriehlbudget-frontend/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +# Render runtime config from container env vars. The explicit variable list +# prevents envsubst from touching any other ${...} occurrences in static files. +envsubst '${VITE_SUPABASE_URL} ${VITE_SUPABASE_ANON_KEY} ${VITE_API_URL}' \ + < /usr/share/nginx/html/config.template.js \ + > /usr/share/nginx/html/config.js diff --git a/tehriehlbudget-frontend/eslint.config.js b/tehriehlbudget-frontend/eslint.config.js index fe6095f..51e3dbb 100644 --- a/tehriehlbudget-frontend/eslint.config.js +++ b/tehriehlbudget-frontend/eslint.config.js @@ -25,6 +25,13 @@ export default defineConfig([ // sync in PWAUpdatePrompt) trip it. Track these as warnings until // we can refactor each one with proper visual testing. 'react-hooks/set-state-in-effect': 'warn', + // Allow underscore-prefixed args/vars as the standard signal for + // intentionally-unused parameters (e.g. typed callback signatures + // on mocks where the body doesn't reference the arg). + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], }, }, { diff --git a/tehriehlbudget-frontend/index.html b/tehriehlbudget-frontend/index.html index f9cef73..111496a 100644 --- a/tehriehlbudget-frontend/index.html +++ b/tehriehlbudget-frontend/index.html @@ -14,6 +14,7 @@
+ diff --git a/tehriehlbudget-frontend/nginx.conf b/tehriehlbudget-frontend/nginx.conf new file mode 100644 index 0000000..21c4828 --- /dev/null +++ b/tehriehlbudget-frontend/nginx.conf @@ -0,0 +1,39 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Compress text-ish assets on the fly + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + # Block dotfiles served from the static root + location ~ /\. { + deny all; + } + + # Runtime config is rewritten at every container start; never cache it + location = /config.js { + add_header Cache-Control "no-store" always; + try_files $uri =404; + } + + # index.html must always be revalidated so a deploy is picked up + location = /index.html { + add_header Cache-Control "no-store" always; + try_files $uri =404; + } + + # Vite emits hashed filenames into /assets — safe to cache aggressively + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } + + # SPA fallback: route everything else through index.html + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/tehriehlbudget-frontend/package.json b/tehriehlbudget-frontend/package.json index ac113b3..fa544ad 100644 --- a/tehriehlbudget-frontend/package.json +++ b/tehriehlbudget-frontend/package.json @@ -1,7 +1,7 @@ { "name": "tehriehlbudget-frontend", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/tehriehlbudget-frontend/public/config.js b/tehriehlbudget-frontend/public/config.js new file mode 100644 index 0000000..8787e30 --- /dev/null +++ b/tehriehlbudget-frontend/public/config.js @@ -0,0 +1 @@ +window.__RUNTIME_CONFIG__ = {}; diff --git a/tehriehlbudget-frontend/public/config.template.js b/tehriehlbudget-frontend/public/config.template.js new file mode 100644 index 0000000..263f33d --- /dev/null +++ b/tehriehlbudget-frontend/public/config.template.js @@ -0,0 +1,5 @@ +window.__RUNTIME_CONFIG__ = { + VITE_SUPABASE_URL: '${VITE_SUPABASE_URL}', + VITE_SUPABASE_ANON_KEY: '${VITE_SUPABASE_ANON_KEY}', + VITE_API_URL: '${VITE_API_URL}', +}; diff --git a/tehriehlbudget-frontend/src/components/ReceiptViewer.test.tsx b/tehriehlbudget-frontend/src/components/ReceiptViewer.test.tsx index 8bbd52e..e56a9f5 100644 --- a/tehriehlbudget-frontend/src/components/ReceiptViewer.test.tsx +++ b/tehriehlbudget-frontend/src/components/ReceiptViewer.test.tsx @@ -11,7 +11,7 @@ vi.mock('@/lib/supabase', () => ({ import { ReceiptViewer } from './ReceiptViewer'; -const originalFetch = global.fetch; +const originalFetch = globalThis.fetch; const originalCreateObjectURL = URL.createObjectURL; const originalRevokeObjectURL = URL.revokeObjectURL; @@ -20,13 +20,13 @@ const blobUrl = 'blob:mock-url'; describe('ReceiptViewer', () => { beforeEach(() => { mockGetSession.mockReset(); - global.fetch = vi.fn(); + globalThis.fetch = vi.fn(); URL.createObjectURL = vi.fn(() => blobUrl); URL.revokeObjectURL = vi.fn(); }); afterEach(() => { - global.fetch = originalFetch; + globalThis.fetch = originalFetch; URL.createObjectURL = originalCreateObjectURL; URL.revokeObjectURL = originalRevokeObjectURL; }); @@ -38,7 +38,7 @@ describe('ReceiptViewer', () => { it('renders an when the loaded blob is an image', async () => { mockGetSession.mockResolvedValue({ data: { session: { access_token: 't' } } }); - (global.fetch as any).mockResolvedValue({ + (globalThis.fetch as any).mockResolvedValue({ ok: true, blob: async () => new Blob(['x'], { type: 'image/jpeg' }), }); @@ -51,7 +51,7 @@ describe('ReceiptViewer', () => { it('renders an iframe for PDF receipts', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); - (global.fetch as any).mockResolvedValue({ + (globalThis.fetch as any).mockResolvedValue({ ok: true, blob: async () => new Blob(['x'], { type: 'application/pdf' }), }); @@ -65,7 +65,7 @@ describe('ReceiptViewer', () => { it('shows the load error when the file fetch fails', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); - (global.fetch as any).mockResolvedValue({ ok: false, status: 404 }); + (globalThis.fetch as any).mockResolvedValue({ ok: false, status: 404 }); render( {}} />); diff --git a/tehriehlbudget-frontend/src/components/ReceiptViewer.tsx b/tehriehlbudget-frontend/src/components/ReceiptViewer.tsx index f7f25ec..1005f1c 100644 --- a/tehriehlbudget-frontend/src/components/ReceiptViewer.tsx +++ b/tehriehlbudget-frontend/src/components/ReceiptViewer.tsx @@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Button } from '@/components/ui/button'; import { Download, ExternalLink } from 'lucide-react'; import { supabase } from '@/lib/supabase'; +import { getConfig } from '@/lib/runtime-config'; interface Props { receiptPath: string | null; @@ -37,7 +38,7 @@ export function ReceiptViewer({ receiptPath, onClose }: Props) { setError(null); try { const { userId, filename } = parseReceiptPath(receiptPath); - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + const apiUrl = getConfig('VITE_API_URL') || 'http://localhost:3000'; const { data: session } = await supabase.auth.getSession(); const token = session.session?.access_token; diff --git a/tehriehlbudget-frontend/src/components/TransactionForm.test.tsx b/tehriehlbudget-frontend/src/components/TransactionForm.test.tsx index b063d6c..5f0ee6c 100644 --- a/tehriehlbudget-frontend/src/components/TransactionForm.test.tsx +++ b/tehriehlbudget-frontend/src/components/TransactionForm.test.tsx @@ -20,16 +20,16 @@ const categories = [ { id: 'cat-2', name: 'Dining' } as any, ]; -const originalFetch = global.fetch; +const originalFetch = globalThis.fetch; describe('TransactionForm', () => { beforeEach(() => { mockGetSession.mockReset(); - global.fetch = vi.fn(); + globalThis.fetch = vi.fn(); }); afterEach(() => { - global.fetch = originalFetch; + globalThis.fetch = originalFetch; }); it('keeps the submit button disabled until a primary account is picked', () => { @@ -152,7 +152,7 @@ describe('TransactionForm', () => { mockGetSession.mockResolvedValue({ data: { session: { access_token: 'tok' } }, }); - (global.fetch as any).mockResolvedValue({ + (globalThis.fetch as any).mockResolvedValue({ ok: true, json: async () => ({ path: 'receipts/u/abc.jpg' }), }); @@ -185,8 +185,8 @@ describe('TransactionForm', () => { fireEvent.change(fileInput, { target: { files: [file] } }); fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - await waitFor(() => expect(global.fetch).toHaveBeenCalled()); - const fetchCall = (global.fetch as any).mock.calls[0]; + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + const fetchCall = (globalThis.fetch as any).mock.calls[0]; expect(fetchCall[0]).toMatch(/\/files\/upload$/); expect(fetchCall[1].headers.Authorization).toBe('Bearer tok'); @@ -228,6 +228,6 @@ describe('TransactionForm', () => { expect.objectContaining({ receiptPath: 'receipts/u/old.jpg' }), ); // No upload should have been attempted - expect(global.fetch).not.toHaveBeenCalled(); + expect(globalThis.fetch).not.toHaveBeenCalled(); }); }); diff --git a/tehriehlbudget-frontend/src/components/TransactionForm.tsx b/tehriehlbudget-frontend/src/components/TransactionForm.tsx index 82a9724..dea0d5b 100644 --- a/tehriehlbudget-frontend/src/components/TransactionForm.tsx +++ b/tehriehlbudget-frontend/src/components/TransactionForm.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/select'; import { Paperclip } from 'lucide-react'; import { supabase } from '@/lib/supabase'; +import { getConfig } from '@/lib/runtime-config'; import { toDateInputValue, todayInputValue } from '@/lib/dates'; const TRANSACTION_TYPES = ['INCOME', 'EXPENSE', 'TRANSFER'] as const; @@ -85,7 +86,7 @@ export function TransactionForm({ formData.append('file', receiptFile); const { data: session } = await supabase.auth.getSession(); const token = session.session?.access_token; - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + const apiUrl = getConfig('VITE_API_URL') || 'http://localhost:3000'; const res = await fetch(`${apiUrl}/files/upload`, { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, diff --git a/tehriehlbudget-frontend/src/lib/api.test.ts b/tehriehlbudget-frontend/src/lib/api.test.ts index 6a03577..19b722e 100644 --- a/tehriehlbudget-frontend/src/lib/api.test.ts +++ b/tehriehlbudget-frontend/src/lib/api.test.ts @@ -10,23 +10,23 @@ vi.mock('./supabase', () => ({ import { api } from './api'; -const originalFetch = global.fetch; +const originalFetch = globalThis.fetch; describe('api', () => { beforeEach(() => { mockGetSession.mockReset(); - global.fetch = vi.fn(); + globalThis.fetch = vi.fn(); }); afterEach(() => { - global.fetch = originalFetch; + globalThis.fetch = originalFetch; }); it('attaches a Bearer token when a Supabase session is present', async () => { mockGetSession.mockResolvedValue({ data: { session: { access_token: 'tok-abc' } }, }); - (global.fetch as any).mockResolvedValue({ + (globalThis.fetch as any).mockResolvedValue({ ok: true, status: 200, json: async () => ({ ok: true }), @@ -34,13 +34,13 @@ describe('api', () => { await api.get('/anything'); - const [, init] = (global.fetch as any).mock.calls[0]; + const [, init] = (globalThis.fetch as any).mock.calls[0]; expect(init.headers.Authorization).toBe('Bearer tok-abc'); }); it('omits the Authorization header when there is no session', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); - (global.fetch as any).mockResolvedValue({ + (globalThis.fetch as any).mockResolvedValue({ ok: true, status: 200, json: async () => ({}), @@ -48,13 +48,13 @@ describe('api', () => { await api.get('/anything'); - const [, init] = (global.fetch as any).mock.calls[0]; + const [, init] = (globalThis.fetch as any).mock.calls[0]; expect(init.headers.Authorization).toBeUndefined(); }); it('returns parsed JSON on success', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); - (global.fetch as any).mockResolvedValue({ + (globalThis.fetch as any).mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: 1 }), @@ -66,7 +66,7 @@ describe('api', () => { it('returns undefined for a 204 No Content response', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); - (global.fetch as any).mockResolvedValue({ + (globalThis.fetch as any).mockResolvedValue({ ok: true, status: 204, json: async () => { @@ -80,7 +80,7 @@ describe('api', () => { it('throws with the server-supplied message on a JSON error response', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); - (global.fetch as any).mockResolvedValue({ + (globalThis.fetch as any).mockResolvedValue({ ok: false, status: 400, statusText: 'Bad Request', @@ -92,7 +92,7 @@ describe('api', () => { it('falls back to statusText when the error body is not JSON', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); - (global.fetch as any).mockResolvedValue({ + (globalThis.fetch as any).mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', @@ -106,7 +106,7 @@ describe('api', () => { it('JSON-stringifies the body on POST/PATCH', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); - (global.fetch as any).mockResolvedValue({ + (globalThis.fetch as any).mockResolvedValue({ ok: true, status: 200, json: async () => ({}), @@ -114,7 +114,7 @@ describe('api', () => { await api.post('/x', { name: 'kevin' }); - const [, init] = (global.fetch as any).mock.calls[0]; + const [, init] = (globalThis.fetch as any).mock.calls[0]; expect(init.method).toBe('POST'); expect(init.body).toBe(JSON.stringify({ name: 'kevin' })); }); diff --git a/tehriehlbudget-frontend/src/lib/api.ts b/tehriehlbudget-frontend/src/lib/api.ts index 634e138..81bf918 100644 --- a/tehriehlbudget-frontend/src/lib/api.ts +++ b/tehriehlbudget-frontend/src/lib/api.ts @@ -1,6 +1,7 @@ import { supabase } from './supabase'; +import { getConfig } from './runtime-config'; -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; +const API_URL = getConfig('VITE_API_URL') || 'http://localhost:3000'; async function getAuthHeaders(): Promise> { const { data } = await supabase.auth.getSession(); diff --git a/tehriehlbudget-frontend/src/lib/runtime-config.ts b/tehriehlbudget-frontend/src/lib/runtime-config.ts new file mode 100644 index 0000000..eaceb72 --- /dev/null +++ b/tehriehlbudget-frontend/src/lib/runtime-config.ts @@ -0,0 +1,16 @@ +type RuntimeKey = 'VITE_SUPABASE_URL' | 'VITE_SUPABASE_ANON_KEY' | 'VITE_API_URL'; + +declare global { + interface Window { + __RUNTIME_CONFIG__?: Partial>; + } +} + +export function getConfig(key: RuntimeKey): string { + if (typeof window !== 'undefined') { + const fromWindow = window.__RUNTIME_CONFIG__?.[key]; + if (fromWindow !== undefined && fromWindow !== '') return fromWindow; + } + const fromBuild = (import.meta.env as Record)[key]; + return fromBuild ?? ''; +} diff --git a/tehriehlbudget-frontend/src/lib/supabase.ts b/tehriehlbudget-frontend/src/lib/supabase.ts index 2271911..8734050 100644 --- a/tehriehlbudget-frontend/src/lib/supabase.ts +++ b/tehriehlbudget-frontend/src/lib/supabase.ts @@ -1,7 +1,8 @@ import { createClient } from '@supabase/supabase-js'; +import { getConfig } from './runtime-config'; -const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +const supabaseUrl = getConfig('VITE_SUPABASE_URL'); +const supabaseAnonKey = getConfig('VITE_SUPABASE_ANON_KEY'); export const STAY_LOGGED_IN_KEY = 'auth:stay_logged_in'; diff --git a/tehriehlbudget-frontend/src/pages/Activity.tsx b/tehriehlbudget-frontend/src/pages/Activity.tsx index 6ab9810..eee3d63 100644 --- a/tehriehlbudget-frontend/src/pages/Activity.tsx +++ b/tehriehlbudget-frontend/src/pages/Activity.tsx @@ -246,7 +246,7 @@ export function Activity() { onValueChange={(v) => setFilters((f) => ({ ...f, - accountId: v === 'all' ? undefined : v, + accountId: !v || v === 'all' ? undefined : v, })) } > diff --git a/tehriehlbudget-frontend/src/pages/Dashboard.tsx b/tehriehlbudget-frontend/src/pages/Dashboard.tsx index 2257033..743646a 100644 --- a/tehriehlbudget-frontend/src/pages/Dashboard.tsx +++ b/tehriehlbudget-frontend/src/pages/Dashboard.tsx @@ -300,9 +300,10 @@ export function Dashboard() { cy="50%" outerRadius={80} label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} - onClick={(entry: { categoryId?: string }) => { - if (entry.categoryId) { - navigate(`/transactions?categoryId=${entry.categoryId}`); + onClick={(entry) => { + const categoryId = (entry as unknown as { categoryId?: string }).categoryId; + if (categoryId) { + navigate(`/transactions?categoryId=${categoryId}`); } }} > diff --git a/tehriehlbudget-frontend/src/stores/auth.test.ts b/tehriehlbudget-frontend/src/stores/auth.test.ts index 0b899d4..e171f96 100644 --- a/tehriehlbudget-frontend/src/stores/auth.test.ts +++ b/tehriehlbudget-frontend/src/stores/auth.test.ts @@ -7,7 +7,7 @@ const { mockSupabase } = vi.hoisted(() => ({ signUp: vi.fn(), signOut: vi.fn(), getSession: vi.fn(), - onAuthStateChange: vi.fn(() => ({ + onAuthStateChange: vi.fn((_cb?: (event: string, session: unknown) => void) => ({ data: { subscription: { unsubscribe: vi.fn() } }, })), }, diff --git a/tehriehlbudget-frontend/src/vite-env.d.ts b/tehriehlbudget-frontend/src/vite-env.d.ts index 066a09d..5aeb2d1 100644 --- a/tehriehlbudget-frontend/src/vite-env.d.ts +++ b/tehriehlbudget-frontend/src/vite-env.d.ts @@ -1,3 +1,13 @@ /// /// /// + +interface ImportMetaEnv { + readonly VITE_SUPABASE_URL: string; + readonly VITE_SUPABASE_ANON_KEY: string; + readonly VITE_API_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/tehriehlbudget-frontend/vite.config.ts b/tehriehlbudget-frontend/vite.config.ts index da18060..3ef172f 100644 --- a/tehriehlbudget-frontend/vite.config.ts +++ b/tehriehlbudget-frontend/vite.config.ts @@ -43,13 +43,21 @@ export default defineConfig({ ], }, workbox: { - // Avoid caching API responses — always go to network - navigateFallbackDenylist: [/^\/api\//], + // /config.js is rendered at container start from runtime env vars; it + // must never be precached or cached at runtime, otherwise a stale build + // can keep serving an old config after a restart. + globIgnores: ['**/config.js', '**/config.template.js'], + // Avoid caching API responses or runtime config — always go to network + navigateFallbackDenylist: [/^\/api\//, /^\/config\.js$/], runtimeCaching: [ { urlPattern: ({ url }) => url.pathname.startsWith('/api/'), handler: 'NetworkOnly', }, + { + urlPattern: ({ url }) => url.pathname === '/config.js', + handler: 'NetworkOnly', + }, { urlPattern: ({ request }) => request.destination === 'image', handler: 'CacheFirst',