From 7d0205df05915937f87879bfe4fea15f098f99fb Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 15:12:03 -0400 Subject: [PATCH] =?UTF-8?q?test(12-04):=20RED=20=E2=80=94=20wizard=20must?= =?UTF-8?q?=20validate=20VAPID=20before=20proceeding=20(SETUP-02=20gap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 4 VAPID validation tests to SetupPage.test.tsx (CR-01 gap closure) - Tests assert: validateSetupVapid is called, VAPID row renders, Continue is blocked when VAPID fails, Continue appears only after all 3 pass - 3 tests currently FAIL (RED) — current code lacks validateSetupVapid import and has no vapid ValidationRow or vapid gate on bothPassed Co-Authored-By: Claude Sonnet 4.6 --- apps/pwa/src/routes/SetupPage.test.tsx | 108 ++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/apps/pwa/src/routes/SetupPage.test.tsx b/apps/pwa/src/routes/SetupPage.test.tsx index 2ad92be..32ca942 100644 --- a/apps/pwa/src/routes/SetupPage.test.tsx +++ b/apps/pwa/src/routes/SetupPage.test.tsx @@ -15,7 +15,7 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router'; import { SetupPage } from './SetupPage.js'; @@ -199,3 +199,109 @@ describe('SetupPage — Already Locked screen', () => { }); }); }); + +// ── Tests: Step 2 VAPID validation (CR-01 / SETUP-02 gap closure) ──────────── +// +// RED gate: these tests must FAIL against the current SetupPage.tsx because: +// - validateSetupVapid is not imported in SetupPage.tsx +// - No VAPID ValidationRow is rendered +// - bothPassed is gated on db+oidc only (not vapid) + +describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = makeQueryClient(); + vi.resetAllMocks(); + }); + + /** + * Helper: advance from Welcome (step 1) to Instance Configuration (step 2). + */ + async function advanceToStep2() { + renderSetupPage(queryClient); + const continueBtn = await screen.findByRole('button', { name: 'Continue' }); + fireEvent.click(continueBtn); + // Step 2 heading appears + await screen.findByText('Instance Configuration'); + } + + /** + * Helper: fill all 4 fields in step 2 and click "Save & Validate". + * Mocks must be set up by the caller. + */ + async function fillAndSubmitStep2() { + const { postSetupConfig } = await import('../api/client.js'); + (postSetupConfig as ReturnType).mockResolvedValue(undefined); + + fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } }); + fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } }); + fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } }); + fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' })); + } + + it('calls validateSetupVapid after DB and OIDC pass in step 2', async () => { + const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); + (validateSetupDb as ReturnType).mockResolvedValue(undefined); + (validateSetupOidc as ReturnType).mockResolvedValue(undefined); + (validateSetupVapid as ReturnType).mockResolvedValue(undefined); + + await advanceToStep2(); + await fillAndSubmitStep2(); + + await waitFor(() => { + expect(validateSetupVapid as ReturnType).toHaveBeenCalledTimes(1); + }); + }); + + it('renders a VAPID validation row after step 2 validation completes', async () => { + const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); + (validateSetupDb as ReturnType).mockResolvedValue(undefined); + (validateSetupOidc as ReturnType).mockResolvedValue(undefined); + (validateSetupVapid as ReturnType).mockResolvedValue(undefined); + + await advanceToStep2(); + await fillAndSubmitStep2(); + + // After all three resolve, the VAPID success text should appear in the ValidationRow + await waitFor(() => { + expect(screen.getByText(/VAPID keys verified/i)).toBeInTheDocument(); + }); + }); + + it('does NOT show Continue when VAPID validation fails', async () => { + const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); + (validateSetupDb as ReturnType).mockResolvedValue(undefined); + (validateSetupOidc as ReturnType).mockResolvedValue(undefined); + (validateSetupVapid as ReturnType).mockRejectedValue( + new Error('VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.') + ); + + await advanceToStep2(); + await fillAndSubmitStep2(); + + // Wait for the VAPID failure to land in the UI + await waitFor(() => { + expect(screen.getByText(/VAPID validation failed/i)).toBeInTheDocument(); + }); + + // Continue button must NOT be present when VAPID fails + expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull(); + }); + + it('shows Continue only when db, oidc, AND vapid all pass', async () => { + const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); + (validateSetupDb as ReturnType).mockResolvedValue(undefined); + (validateSetupOidc as ReturnType).mockResolvedValue(undefined); + (validateSetupVapid as ReturnType).mockResolvedValue(undefined); + + await advanceToStep2(); + await fillAndSubmitStep2(); + + // Continue appears only after all three validations pass + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument(); + }); + }); +});