Build, scan, and push images to Harbor on every main push
CI / test (push) Successful in 25s
CI / lint (push) Successful in 28s
CI / secrets-scan (push) Successful in 5s
CI / vuln-scan (push) Successful in 12s
CI / sast (push) Successful in 10s
CI / build-images (push) Failing after 51s
CI / image-scan (push) Has been skipped
CI / push (push) Has been skipped

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 <version> (from package.json), <sha>, 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 15:49:01 -07:00
parent 502ce99b87
commit 8c10124272
25 changed files with 444 additions and 58 deletions
+24
View File
@@ -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
+211 -18
View File
@@ -152,21 +152,214 @@ jobs:
name: semgrep-report name: semgrep-report
path: semgrep.sarif path: semgrep.sarif
# ───────────────────────────────────────────────────────────────────────────── build-images:
# Future stages (not yet enabled — Dockerfiles do not exist yet): runs-on: ubuntu-latest
# needs: [test, lint, secrets-scan, vuln-scan, sast]
# build-images: steps:
# needs: [test, lint, secrets-scan, vuln-scan, sast] - uses: actions/checkout@v4
# - docker build tehriehlbudget-backend → harbor.<host>/tehriehlbudget/backend:<sha>
# - docker build tehriehlbudget-frontend → harbor.<host>/tehriehlbudget/frontend:<sha> - uses: docker/setup-buildx-action@v3
#
# image-scan: - name: Compute image tags
# needs: build-images run: |
# - trivy image scan on each built image (severity gate ON here from day one) SHA_SHORT=$(git rev-parse --short HEAD)
# - re-run trivy SBOM on the image so Harbor gets an image-level CycloneDX BACKEND_VERSION=$(jq -r .version tehriehlbudget-backend/package.json)
# FRONTEND_VERSION=$(jq -r .version tehriehlbudget-frontend/package.json)
# push: echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV"
# needs: image-scan echo "BACKEND_VERSION=${BACKEND_VERSION}" >> "$GITHUB_ENV"
# - docker login harbor.<self-hosted-domain> (creds via Gitea Actions secrets) echo "FRONTEND_VERSION=${FRONTEND_VERSION}" >> "$GITHUB_ENV"
# - docker push backend + frontend tags
# ───────────────────────────────────────────────────────────────────────────── - 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
+41
View File
@@ -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"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "tehriehlbudget-backend", "name": "tehriehlbudget-backend",
"version": "0.0.1", "version": "0.1.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+28
View File
@@ -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
@@ -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
+7
View File
@@ -25,6 +25,13 @@ export default defineConfig([
// sync in PWAUpdatePrompt) trip it. Track these as warnings until // sync in PWAUpdatePrompt) trip it. Track these as warnings until
// we can refactor each one with proper visual testing. // we can refactor each one with proper visual testing.
'react-hooks/set-state-in-effect': 'warn', '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: '^_' },
],
}, },
}, },
{ {
+1
View File
@@ -14,6 +14,7 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+39
View File
@@ -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;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "tehriehlbudget-frontend", "name": "tehriehlbudget-frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1
View File
@@ -0,0 +1 @@
window.__RUNTIME_CONFIG__ = {};
@@ -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}',
};
@@ -11,7 +11,7 @@ vi.mock('@/lib/supabase', () => ({
import { ReceiptViewer } from './ReceiptViewer'; import { ReceiptViewer } from './ReceiptViewer';
const originalFetch = global.fetch; const originalFetch = globalThis.fetch;
const originalCreateObjectURL = URL.createObjectURL; const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL; const originalRevokeObjectURL = URL.revokeObjectURL;
@@ -20,13 +20,13 @@ const blobUrl = 'blob:mock-url';
describe('ReceiptViewer', () => { describe('ReceiptViewer', () => {
beforeEach(() => { beforeEach(() => {
mockGetSession.mockReset(); mockGetSession.mockReset();
global.fetch = vi.fn(); globalThis.fetch = vi.fn();
URL.createObjectURL = vi.fn(() => blobUrl); URL.createObjectURL = vi.fn(() => blobUrl);
URL.revokeObjectURL = vi.fn(); URL.revokeObjectURL = vi.fn();
}); });
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; globalThis.fetch = originalFetch;
URL.createObjectURL = originalCreateObjectURL; URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL; URL.revokeObjectURL = originalRevokeObjectURL;
}); });
@@ -38,7 +38,7 @@ describe('ReceiptViewer', () => {
it('renders an <img> when the loaded blob is an image', async () => { it('renders an <img> when the loaded blob is an image', async () => {
mockGetSession.mockResolvedValue({ data: { session: { access_token: 't' } } }); mockGetSession.mockResolvedValue({ data: { session: { access_token: 't' } } });
(global.fetch as any).mockResolvedValue({ (globalThis.fetch as any).mockResolvedValue({
ok: true, ok: true,
blob: async () => new Blob(['x'], { type: 'image/jpeg' }), blob: async () => new Blob(['x'], { type: 'image/jpeg' }),
}); });
@@ -51,7 +51,7 @@ describe('ReceiptViewer', () => {
it('renders an iframe for PDF receipts', async () => { it('renders an iframe for PDF receipts', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } }); mockGetSession.mockResolvedValue({ data: { session: null } });
(global.fetch as any).mockResolvedValue({ (globalThis.fetch as any).mockResolvedValue({
ok: true, ok: true,
blob: async () => new Blob(['x'], { type: 'application/pdf' }), 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 () => { it('shows the load error when the file fetch fails', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } }); mockGetSession.mockResolvedValue({ data: { session: null } });
(global.fetch as any).mockResolvedValue({ ok: false, status: 404 }); (globalThis.fetch as any).mockResolvedValue({ ok: false, status: 404 });
render(<ReceiptViewer receiptPath="receipts/user-1/missing.jpg" onClose={() => {}} />); render(<ReceiptViewer receiptPath="receipts/user-1/missing.jpg" onClose={() => {}} />);
@@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Download, ExternalLink } from 'lucide-react'; import { Download, ExternalLink } from 'lucide-react';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { getConfig } from '@/lib/runtime-config';
interface Props { interface Props {
receiptPath: string | null; receiptPath: string | null;
@@ -37,7 +38,7 @@ export function ReceiptViewer({ receiptPath, onClose }: Props) {
setError(null); setError(null);
try { try {
const { userId, filename } = parseReceiptPath(receiptPath); 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 { data: session } = await supabase.auth.getSession();
const token = session.session?.access_token; const token = session.session?.access_token;
@@ -20,16 +20,16 @@ const categories = [
{ id: 'cat-2', name: 'Dining' } as any, { id: 'cat-2', name: 'Dining' } as any,
]; ];
const originalFetch = global.fetch; const originalFetch = globalThis.fetch;
describe('TransactionForm', () => { describe('TransactionForm', () => {
beforeEach(() => { beforeEach(() => {
mockGetSession.mockReset(); mockGetSession.mockReset();
global.fetch = vi.fn(); globalThis.fetch = vi.fn();
}); });
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; globalThis.fetch = originalFetch;
}); });
it('keeps the submit button disabled until a primary account is picked', () => { it('keeps the submit button disabled until a primary account is picked', () => {
@@ -152,7 +152,7 @@ describe('TransactionForm', () => {
mockGetSession.mockResolvedValue({ mockGetSession.mockResolvedValue({
data: { session: { access_token: 'tok' } }, data: { session: { access_token: 'tok' } },
}); });
(global.fetch as any).mockResolvedValue({ (globalThis.fetch as any).mockResolvedValue({
ok: true, ok: true,
json: async () => ({ path: 'receipts/u/abc.jpg' }), json: async () => ({ path: 'receipts/u/abc.jpg' }),
}); });
@@ -185,8 +185,8 @@ describe('TransactionForm', () => {
fireEvent.change(fileInput, { target: { files: [file] } }); fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.click(screen.getByRole('button', { name: /^save$/i })); fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(global.fetch).toHaveBeenCalled()); await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled());
const fetchCall = (global.fetch as any).mock.calls[0]; const fetchCall = (globalThis.fetch as any).mock.calls[0];
expect(fetchCall[0]).toMatch(/\/files\/upload$/); expect(fetchCall[0]).toMatch(/\/files\/upload$/);
expect(fetchCall[1].headers.Authorization).toBe('Bearer tok'); expect(fetchCall[1].headers.Authorization).toBe('Bearer tok');
@@ -228,6 +228,6 @@ describe('TransactionForm', () => {
expect.objectContaining({ receiptPath: 'receipts/u/old.jpg' }), expect.objectContaining({ receiptPath: 'receipts/u/old.jpg' }),
); );
// No upload should have been attempted // No upload should have been attempted
expect(global.fetch).not.toHaveBeenCalled(); expect(globalThis.fetch).not.toHaveBeenCalled();
}); });
}); });
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Paperclip } from 'lucide-react'; import { Paperclip } from 'lucide-react';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { getConfig } from '@/lib/runtime-config';
import { toDateInputValue, todayInputValue } from '@/lib/dates'; import { toDateInputValue, todayInputValue } from '@/lib/dates';
const TRANSACTION_TYPES = ['INCOME', 'EXPENSE', 'TRANSFER'] as const; const TRANSACTION_TYPES = ['INCOME', 'EXPENSE', 'TRANSFER'] as const;
@@ -85,7 +86,7 @@ export function TransactionForm({
formData.append('file', receiptFile); formData.append('file', receiptFile);
const { data: session } = await supabase.auth.getSession(); const { data: session } = await supabase.auth.getSession();
const token = session.session?.access_token; 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`, { const res = await fetch(`${apiUrl}/files/upload`, {
method: 'POST', method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
+13 -13
View File
@@ -10,23 +10,23 @@ vi.mock('./supabase', () => ({
import { api } from './api'; import { api } from './api';
const originalFetch = global.fetch; const originalFetch = globalThis.fetch;
describe('api', () => { describe('api', () => {
beforeEach(() => { beforeEach(() => {
mockGetSession.mockReset(); mockGetSession.mockReset();
global.fetch = vi.fn(); globalThis.fetch = vi.fn();
}); });
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; globalThis.fetch = originalFetch;
}); });
it('attaches a Bearer token when a Supabase session is present', async () => { it('attaches a Bearer token when a Supabase session is present', async () => {
mockGetSession.mockResolvedValue({ mockGetSession.mockResolvedValue({
data: { session: { access_token: 'tok-abc' } }, data: { session: { access_token: 'tok-abc' } },
}); });
(global.fetch as any).mockResolvedValue({ (globalThis.fetch as any).mockResolvedValue({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ ok: true }), json: async () => ({ ok: true }),
@@ -34,13 +34,13 @@ describe('api', () => {
await api.get('/anything'); 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'); expect(init.headers.Authorization).toBe('Bearer tok-abc');
}); });
it('omits the Authorization header when there is no session', async () => { it('omits the Authorization header when there is no session', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } }); mockGetSession.mockResolvedValue({ data: { session: null } });
(global.fetch as any).mockResolvedValue({ (globalThis.fetch as any).mockResolvedValue({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({}), json: async () => ({}),
@@ -48,13 +48,13 @@ describe('api', () => {
await api.get('/anything'); 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(); expect(init.headers.Authorization).toBeUndefined();
}); });
it('returns parsed JSON on success', async () => { it('returns parsed JSON on success', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } }); mockGetSession.mockResolvedValue({ data: { session: null } });
(global.fetch as any).mockResolvedValue({ (globalThis.fetch as any).mockResolvedValue({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ id: 1 }), json: async () => ({ id: 1 }),
@@ -66,7 +66,7 @@ describe('api', () => {
it('returns undefined for a 204 No Content response', async () => { it('returns undefined for a 204 No Content response', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } }); mockGetSession.mockResolvedValue({ data: { session: null } });
(global.fetch as any).mockResolvedValue({ (globalThis.fetch as any).mockResolvedValue({
ok: true, ok: true,
status: 204, status: 204,
json: async () => { json: async () => {
@@ -80,7 +80,7 @@ describe('api', () => {
it('throws with the server-supplied message on a JSON error response', async () => { it('throws with the server-supplied message on a JSON error response', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } }); mockGetSession.mockResolvedValue({ data: { session: null } });
(global.fetch as any).mockResolvedValue({ (globalThis.fetch as any).mockResolvedValue({
ok: false, ok: false,
status: 400, status: 400,
statusText: 'Bad Request', statusText: 'Bad Request',
@@ -92,7 +92,7 @@ describe('api', () => {
it('falls back to statusText when the error body is not JSON', async () => { it('falls back to statusText when the error body is not JSON', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } }); mockGetSession.mockResolvedValue({ data: { session: null } });
(global.fetch as any).mockResolvedValue({ (globalThis.fetch as any).mockResolvedValue({
ok: false, ok: false,
status: 500, status: 500,
statusText: 'Internal Server Error', statusText: 'Internal Server Error',
@@ -106,7 +106,7 @@ describe('api', () => {
it('JSON-stringifies the body on POST/PATCH', async () => { it('JSON-stringifies the body on POST/PATCH', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } }); mockGetSession.mockResolvedValue({ data: { session: null } });
(global.fetch as any).mockResolvedValue({ (globalThis.fetch as any).mockResolvedValue({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({}), json: async () => ({}),
@@ -114,7 +114,7 @@ describe('api', () => {
await api.post('/x', { name: 'kevin' }); 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.method).toBe('POST');
expect(init.body).toBe(JSON.stringify({ name: 'kevin' })); expect(init.body).toBe(JSON.stringify({ name: 'kevin' }));
}); });
+2 -1
View File
@@ -1,6 +1,7 @@
import { supabase } from './supabase'; 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<Record<string, string>> { async function getAuthHeaders(): Promise<Record<string, string>> {
const { data } = await supabase.auth.getSession(); const { data } = await supabase.auth.getSession();
@@ -0,0 +1,16 @@
type RuntimeKey = 'VITE_SUPABASE_URL' | 'VITE_SUPABASE_ANON_KEY' | 'VITE_API_URL';
declare global {
interface Window {
__RUNTIME_CONFIG__?: Partial<Record<RuntimeKey, string>>;
}
}
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<string, string | undefined>)[key];
return fromBuild ?? '';
}
+3 -2
View File
@@ -1,7 +1,8 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { getConfig } from './runtime-config';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseUrl = getConfig('VITE_SUPABASE_URL');
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; const supabaseAnonKey = getConfig('VITE_SUPABASE_ANON_KEY');
export const STAY_LOGGED_IN_KEY = 'auth:stay_logged_in'; export const STAY_LOGGED_IN_KEY = 'auth:stay_logged_in';
@@ -246,7 +246,7 @@ export function Activity() {
onValueChange={(v) => onValueChange={(v) =>
setFilters((f) => ({ setFilters((f) => ({
...f, ...f,
accountId: v === 'all' ? undefined : v, accountId: !v || v === 'all' ? undefined : v,
})) }))
} }
> >
@@ -300,9 +300,10 @@ export function Dashboard() {
cy="50%" cy="50%"
outerRadius={80} outerRadius={80}
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
onClick={(entry: { categoryId?: string }) => { onClick={(entry) => {
if (entry.categoryId) { const categoryId = (entry as unknown as { categoryId?: string }).categoryId;
navigate(`/transactions?categoryId=${entry.categoryId}`); if (categoryId) {
navigate(`/transactions?categoryId=${categoryId}`);
} }
}} }}
> >
@@ -7,7 +7,7 @@ const { mockSupabase } = vi.hoisted(() => ({
signUp: vi.fn(), signUp: vi.fn(),
signOut: vi.fn(), signOut: vi.fn(),
getSession: vi.fn(), getSession: vi.fn(),
onAuthStateChange: vi.fn(() => ({ onAuthStateChange: vi.fn((_cb?: (event: string, session: unknown) => void) => ({
data: { subscription: { unsubscribe: vi.fn() } }, data: { subscription: { unsubscribe: vi.fn() } },
})), })),
}, },
+10
View File
@@ -1,3 +1,13 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" /> /// <reference types="vite-plugin-pwa/react" />
/// <reference types="vite-plugin-pwa/info" /> /// <reference types="vite-plugin-pwa/info" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+10 -2
View File
@@ -43,13 +43,21 @@ export default defineConfig({
], ],
}, },
workbox: { workbox: {
// Avoid caching API responses — always go to network // /config.js is rendered at container start from runtime env vars; it
navigateFallbackDenylist: [/^\/api\//], // 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: [ runtimeCaching: [
{ {
urlPattern: ({ url }) => url.pathname.startsWith('/api/'), urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
handler: 'NetworkOnly', handler: 'NetworkOnly',
}, },
{
urlPattern: ({ url }) => url.pathname === '/config.js',
handler: 'NetworkOnly',
},
{ {
urlPattern: ({ request }) => request.destination === 'image', urlPattern: ({ request }) => request.destination === 'image',
handler: 'CacheFirst', handler: 'CacheFirst',