From c7b7494151057a776efaa4a7b10130ba23e83ef0 Mon Sep 17 00:00:00 2001 From: "cartsnitch-engineer[bot]" <269717931+cartsnitch-engineer[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:49:55 +0000 Subject: [PATCH 1/2] fix: e2e route mocking and color contrast accessibility (#221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes CAR-673, CAR-676. Replaces VITE_MOCK_AUTH with Playwright route mocking for all e2e tests. Fixes color contrast (text-gray-400 → text-gray-600). --- e2e/fixtures.ts | 90 +++++++++++++++++++++- e2e/journeys/j1-registration-login.spec.ts | 24 ++---- e2e/journeys/j8-unauth-access.spec.ts | 20 ++--- e2e/smoke.spec.ts | 4 +- src/pages/Dashboard.tsx | 6 +- 5 files changed, 107 insertions(+), 37 deletions(-) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 07e12e6..8f61575 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,4 +1,4 @@ -import { test as base, expect } from "@playwright/test"; +import { test as base, expect, type Page } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; export const test = base.extend<{ axeCheck: void }>({ @@ -10,3 +10,91 @@ export const test = base.extend<{ axeCheck: void }>({ }); export { expect } from "@playwright/test"; + +const MOCK_USER_ID = "mock_user_123"; +const MOCK_SESSION_ID = "mock_session_456"; + +async function mockAuthRoutes(page: Page, authenticated = false) { + await page.route(/.*\/auth\/sign-up\/email.*/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + token: null, + user: { + id: MOCK_USER_ID, + email: "mock@cartsnitch.test", + name: "Mock User", + emailVerified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }), + }); + }); + + await page.route(/.*\/auth\/sign-in\/email.*/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + redirect: false, + token: "mock_token_123", + user: { + id: MOCK_USER_ID, + email: "mock@cartsnitch.test", + name: "Mock User", + emailVerified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }), + }); + }); + + await page.route(/.*\/auth\/get-session.*/, async (route) => { + if (authenticated) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + session: { + id: MOCK_SESSION_ID, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ipAddress: null, + userAgent: null, + }, + user: { + id: MOCK_USER_ID, + email: "mock@cartsnitch.test", + name: "Mock User", + emailVerified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }), + }); + } else { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "Unauthorized" }), + }); + } + }); +} + +export async function mockSessionDelayed(page: Page, delayMs = 3000) { + await page.route(/.*\/auth\/get-session.*/, async (route) => { + await new Promise((r) => setTimeout(r, delayMs)); + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "Unauthorized" }), + }); + }); +} + +export { mockAuthRoutes }; diff --git a/e2e/journeys/j1-registration-login.spec.ts b/e2e/journeys/j1-registration-login.spec.ts index b1b28a4..a811cc8 100644 --- a/e2e/journeys/j1-registration-login.spec.ts +++ b/e2e/journeys/j1-registration-login.spec.ts @@ -1,17 +1,18 @@ import { test, expect } from '@playwright/test'; +import { mockAuthRoutes } from '../fixtures'; const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`; test.describe('J1: Registration and Login', () => { - test('can register a new account and lands on dashboard', async ({ page }) => { + test('can register a new account and see check your email screen', async ({ page }) => { + await mockAuthRoutes(page, false); await page.goto('/register'); await page.fill('[placeholder="Full Name"]', 'Betty Tester'); await page.fill('[placeholder="Email"]', uniqueEmail()); await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!'); await page.click('button[type="submit"]'); - await expect(page).toHaveURL('http://localhost:5173/'); - await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /check your email/i })).toBeVisible(); }); test('shows validation error when registration fields are empty', async ({ page }) => { @@ -30,22 +31,9 @@ test.describe('J1: Registration and Login', () => { }); test('can sign in with credentials and land on dashboard', async ({ page }) => { - // Register first so we have a real account - const email = uniqueEmail(); - await page.goto('/register'); - await page.fill('[placeholder="Full Name"]', 'Login Betty'); - await page.fill('[placeholder="Email"]', email); - await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!'); - await page.click('button[type="submit"]'); - await expect(page).toHaveURL('http://localhost:5173/'); - - // Sign out by clearing the mock session (reload with no session) - await page.goto('/'); - await page.reload(); - - // Now sign in + await mockAuthRoutes(page, true); await page.goto('/login'); - await page.fill('[placeholder="Email"]', email); + await page.fill('[placeholder="Email"]', 'test@cartsnitch.test'); await page.fill('[placeholder="Password"]', 'TestPass123!'); await page.click('button[type="submit"]'); diff --git a/e2e/journeys/j8-unauth-access.spec.ts b/e2e/journeys/j8-unauth-access.spec.ts index 9ed40da..7c9d17d 100644 --- a/e2e/journeys/j8-unauth-access.spec.ts +++ b/e2e/journeys/j8-unauth-access.spec.ts @@ -1,9 +1,9 @@ import { test, expect } from '@playwright/test'; +import { mockAuthRoutes, mockSessionDelayed } from '../fixtures'; test.describe('J8: Unauthenticated Access', () => { test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => { - // No session cookie — start fresh - await page.context().clearCookies(); + await mockAuthRoutes(page, false); await page.goto('/'); await expect(page).toHaveURL(/\/login/); @@ -11,7 +11,7 @@ test.describe('J8: Unauthenticated Access', () => { }); test('redirects /purchases to /login when not authenticated', async ({ page }) => { - await page.context().clearCookies(); + await mockAuthRoutes(page, false); await page.goto('/purchases'); await expect(page).toHaveURL(/\/login/); @@ -19,7 +19,7 @@ test.describe('J8: Unauthenticated Access', () => { }); test('redirects /products to /login when not authenticated', async ({ page }) => { - await page.context().clearCookies(); + await mockAuthRoutes(page, false); await page.goto('/products'); await expect(page).toHaveURL(/\/login/); @@ -27,7 +27,7 @@ test.describe('J8: Unauthenticated Access', () => { }); test('redirects /coupons to /login when not authenticated', async ({ page }) => { - await page.context().clearCookies(); + await mockAuthRoutes(page, false); await page.goto('/coupons'); await expect(page).toHaveURL(/\/login/); @@ -35,15 +35,9 @@ test.describe('J8: Unauthenticated Access', () => { }); test('shows loading spinner while auth session is pending', async ({ page }) => { - // Intercept but don't respond — session stays pending - await page.context().clearCookies(); - await page.request.fetch('/api/auth/session', { - method: 'GET', - }); - - // Just navigate to a protected route — ProtectedRoute will show spinner while session is pending + await mockSessionDelayed(page, 3000); await page.goto('/purchases'); - // Spinner is visible briefly; once resolved, should redirect to login + await expect(page.locator('.animate-spin')).toBeVisible({ timeout: 2000 }); await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }); }); }); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 2819d15..7d7cf3c 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,8 +1,8 @@ -import { test, expect } from './fixtures'; +import { test, expect, mockAuthRoutes } from './fixtures'; test('app loads', async ({ page }) => { + await mockAuthRoutes(page, false); await page.goto('/'); - // Unauthenticated users are redirected to /login await expect(page).toHaveURL(/\/login/); await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible(); }); diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index c3f428a..d7dd8d5 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -79,21 +79,21 @@ function AuthenticatedDashboard({ userName }: { userName: string }) {

Watching

{watchingAlerts.length}

-

price alerts

+

price alerts

This Month

${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}

-

grocery spend

+

grocery spend

{/* Price trend sparklines */}

Price Trends

-
+
Connect a store to see price trends
From 1bb669f3ca73c6b1b7b6dfd4890c690b980362c4 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Wed, 15 Apr 2026 03:47:13 +0000 Subject: [PATCH 2/2] fix: add Grype CVE ignores and cache-bust Debian apt-get upgrade layers Co-Authored-By: Paperclip --- .github/workflows/ci.yml | 16 ++++++++++++++++ .grype.yaml | 4 ++++ api/Dockerfile | 2 ++ receiptwitness/Dockerfile | 2 ++ 4 files changed, 24 insertions(+) create mode 100644 .grype.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 202a9ef..8c2252a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,6 +166,8 @@ jobs: - name: Scan frontend image for vulnerabilities uses: anchore/scan-action@v5 id: scan + env: + GRYPE_CONFIG: .grype.yaml with: image: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}" fail-build: true @@ -263,6 +265,8 @@ jobs: - name: Scan auth image for vulnerabilities uses: anchore/scan-action@v5 id: scan + env: + GRYPE_CONFIG: .grype.yaml with: image: "${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}:sha-${{ github.sha }}" fail-build: true @@ -343,12 +347,16 @@ jobs: load: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + APT_CACHE_BUST=${{ github.run_id }} cache-from: type=gha cache-to: type=gha,mode=max - name: Scan receiptwitness image for vulnerabilities uses: anchore/scan-action@v5 id: scan + env: + GRYPE_CONFIG: .grype.yaml with: image: "${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}:sha-${{ github.sha }}" fail-build: true @@ -371,6 +379,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + APT_CACHE_BUST=${{ github.run_id }} cache-from: type=gha build-and-push-api: @@ -429,12 +439,16 @@ jobs: load: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + APT_CACHE_BUST=${{ github.run_id }} cache-from: type=gha cache-to: type=gha,mode=max - name: Scan api image for vulnerabilities uses: anchore/scan-action@v5 id: scan + env: + GRYPE_CONFIG: .grype.yaml with: image: "${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}:sha-${{ github.sha }}" fail-build: true @@ -457,6 +471,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + APT_CACHE_BUST=${{ github.run_id }} cache-from: type=gha deploy-dev: diff --git a/.grype.yaml b/.grype.yaml new file mode 100644 index 0000000..001d21a --- /dev/null +++ b/.grype.yaml @@ -0,0 +1,4 @@ +ignore: + # Python 3.12 CVEs — only fixed in 3.13+, cannot upgrade major version safely + - vulnerability: CVE-2025-13836 + - vulnerability: CVE-2026-4519 \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index 771d5ec..7e5f04e 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,5 +1,6 @@ FROM python:3.12-slim AS build +ARG APT_CACHE_BUST=0 RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \ libpq-dev \ build-essential \ @@ -12,6 +13,7 @@ RUN pip install --no-cache-dir --prefix=/install . FROM python:3.12-slim AS prod +ARG APT_CACHE_BUST=0 RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/receiptwitness/Dockerfile b/receiptwitness/Dockerfile index 79e53a3..efd756c 100644 --- a/receiptwitness/Dockerfile +++ b/receiptwitness/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /app # build-essential and libpq-dev are needed to compile any C-extension wheels # (e.g. psycopg2 fallback). No git needed — common/ is copied from the repo root. +ARG APT_CACHE_BUST=0 RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \ libpq-dev \ build-essential \ @@ -25,6 +26,7 @@ FROM python:3.12-slim AS prod WORKDIR /app # Install Playwright system dependencies for Chromium +ARG APT_CACHE_BUST=0 RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \ libnss3 \ libatk1.0-0 \