diff --git a/.github/workflows/test-sharding.yml b/.github/workflows/test-sharding.yml new file mode 100644 index 00000000..ae6f19e7 --- /dev/null +++ b/.github/workflows/test-sharding.yml @@ -0,0 +1,125 @@ +# Parallelized test sharding — splits the Vitest suite across 4 concurrent +# workers, reducing CI runtime proportionally to shard count. +# +# Sharding strategy +# ----------------- +# Shard count: 4 +# - Each shard executes roughly 25 % of test files. +# - Spin-up cost per worker is ~15 s; suite currently runs ~60 s total, +# giving an effective ~45 s wall-clock with 4 shards (3× speedup after +# overhead). Revisit shard count when any shard regularly exceeds 30 s. +# +# Shard safety requirements (enforced by vitest.config.ts `sequence.shuffle: false`) +# - Tests must not rely on shared mutable global state across describe blocks. +# - Each test file must be independently runnable in any order. +# - Top-level `vi.mock()` declarations are fine; singleton state must be +# reset in `beforeEach` so it does not bleed between tests in a shard. +# +# Result aggregation +# - Each shard uploads its JSON coverage report as an artifact. +# - The `merge-coverage` job downloads all shard artifacts and merges them +# with `vitest merge-reports`, producing a single combined coverage summary. + +name: Parallelized Test Sharding + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Test shard ${{ matrix.shard }}/${{ strategy.job-total }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run test shard ${{ matrix.shard }}/4 + run: | + npm run --workspace @craft/backend test -- \ + --reporter=json \ + --outputFile=coverage/shard-${{ matrix.shard }}.json \ + --shard=${{ matrix.shard }}/4 + env: + ARTIFACT_SIGNING_SECRET: test-artifact-signing-secret-32b!! + + - name: Upload shard coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-shard-${{ matrix.shard }} + path: coverage/shard-${{ matrix.shard }}.json + retention-days: 7 + + merge-coverage: + name: Merge coverage reports + runs-on: ubuntu-latest + needs: test + if: always() + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Download all shard coverage artifacts + uses: actions/download-artifact@v4 + with: + pattern: coverage-shard-* + merge-multiple: true + path: coverage/shards + + - name: Merge shard reports into combined summary + run: npx vitest merge-reports coverage/shards --reporter=text + + frontend-test: + name: Frontend test shard ${{ matrix.shard }}/${{ strategy.job-total }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + shard: [1, 2] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run frontend test shard ${{ matrix.shard }}/2 + run: | + npm run --workspace @craft/frontend test -- \ + --shard=${{ matrix.shard }}/2 diff --git a/apps/backend/src/app/api/deployments/[id]/dns/verify/route.test.ts b/apps/backend/src/app/api/deployments/[id]/dns/verify/route.test.ts index ef715b8e..fd5d3e67 100644 --- a/apps/backend/src/app/api/deployments/[id]/dns/verify/route.test.ts +++ b/apps/backend/src/app/api/deployments/[id]/dns/verify/route.test.ts @@ -163,4 +163,135 @@ describe('POST /api/deployments/[id]/dns/verify', () => { expect(body.requiredTier).toBe('pro'); expect(body.upgradeUrl).toBe('/pricing'); }); + + // DNS record validation — A record is not a supported method; only TXT and CNAME are valid + // WCAG SC 3.3.1: invalid input should produce a clear error response (400) + it('returns 400 when method is an unsupported DNS record type (A record)', async () => { + mockFrom.mockReturnValue(withProTier([])); + const { POST } = await import('./route'); + const res = await POST(makeRequest({ method: 'a-record', token: '1.2.3.4' }), { params }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeTruthy(); + }); + + it('returns 400 when request body is not valid JSON', async () => { + mockFrom.mockReturnValue(withProTier([])); + const { POST } = await import('./route'); + const req = new NextRequest('http://localhost/api/deployments/dep-1/dns/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not-json{{', + }); + const res = await POST(req, { params }); + expect(res.status).toBe(400); + }); + + it('returns 400 when token is not a string for txt method', async () => { + mockFrom.mockReturnValue(withProTier([])); + const { POST } = await import('./route'); + const res = await POST(makeRequest({ method: 'txt', token: 12345 }), { params }); + expect(res.status).toBe(400); + }); + + // DNS verification failure — TXT record present but value does not match + it('returns 200 with verified=false when TXT record value does not match', async () => { + mockFrom.mockReturnValue( + withProTier([{ data: { custom_domain: 'example.com' }, error: null }]), + ); + mockVerifyViaTxt.mockResolvedValue({ + verified: false, + method: 'txt', + domain: 'example.com', + errorCode: 'TOKEN_MISMATCH', + }); + + const { POST } = await import('./route'); + const res = await POST(makeRequest({ method: 'txt', token: 'wrong-token' }), { params }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.verified).toBe(false); + expect(body.errorCode).toBe('TOKEN_MISMATCH'); + }); + + // DNS verification failure — CNAME points to wrong target + it('returns 200 with verified=false when CNAME does not point to expected target', async () => { + mockFrom.mockReturnValue( + withProTier([{ data: { custom_domain: 'www.example.com' }, error: null }]), + ); + mockVerifyViaCname.mockResolvedValue({ + verified: false, + method: 'cname', + domain: 'www.example.com', + errorCode: 'WRONG_TARGET', + }); + + const { POST } = await import('./route'); + const res = await POST(makeRequest({ method: 'cname' }), { params }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.verified).toBe(false); + expect(body.errorCode).toBe('WRONG_TARGET'); + }); + + // DNS verification throws — upstream DNS resolver unavailable + it('returns 500 when DNS verification throws an unexpected error', async () => { + mockFrom.mockReturnValue( + withProTier([{ data: { custom_domain: 'example.com' }, error: null }]), + ); + mockVerifyViaTxt.mockRejectedValue(new Error('DNS resolver unavailable')); + + const { POST } = await import('./route'); + const res = await POST(makeRequest({ method: 'txt', token: 'tok' }), { params }); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/DNS resolver unavailable/); + }); + + // CNAME verification throws — network timeout during DNS lookup + it('returns 500 when CNAME lookup times out', async () => { + mockFrom.mockReturnValue( + withProTier([{ data: { custom_domain: 'www.example.com' }, error: null }]), + ); + mockVerifyViaCname.mockRejectedValue(new Error('DNS lookup timed out')); + + const { POST } = await import('./route'); + const res = await POST(makeRequest({ method: 'cname' }), { params }); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/timed out/); + }); + + // Deployment not found in DB + it('returns 404 when deployment row does not exist in database', async () => { + mockFrom.mockReturnValue( + withProTier([{ data: null, error: { message: 'No rows found' } }]), + ); + const { POST } = await import('./route'); + const res = await POST(makeRequest({ method: 'txt', token: 'tok' }), { params }); + expect(res.status).toBe(404); + }); + + // Enterprise tier is also allowed (not just pro) + it('allows enterprise-tier users to verify DNS', async () => { + mockFrom.mockReturnValue( + makeSupabaseQuery([ + { data: { user_id: fakeUser.id }, error: null }, + { data: { subscription_tier: 'enterprise' }, error: null }, + { data: { custom_domain: 'corp.example.com' }, error: null }, + ]), + ); + mockVerifyViaTxt.mockResolvedValue({ verified: true, method: 'txt', domain: 'corp.example.com' }); + + const { POST } = await import('./route'); + const res = await POST(makeRequest({ method: 'txt', token: 'ent-token' }), { params }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.verified).toBe(true); + }); }); diff --git a/apps/backend/src/app/api/deployments/[id]/https/route.test.ts b/apps/backend/src/app/api/deployments/[id]/https/route.test.ts index 058bc696..ba008eec 100644 --- a/apps/backend/src/app/api/deployments/[id]/https/route.test.ts +++ b/apps/backend/src/app/api/deployments/[id]/https/route.test.ts @@ -142,6 +142,78 @@ describe('POST /api/deployments/[id]/https', () => { expect(body.state).toBe('pending'); expect(body.domain).toBe('example.com'); }); + + // Certificate provisioning begins in 'pending' state immediately after domain is added + it('returns pending cert state immediately after domain add', async () => { + mockFrom.mockReturnValue(withProTier([{ data: fullDeployment, error: null }])); + mockAddDomain.mockResolvedValue(undefined); + mockGetCertificate.mockResolvedValue({ domain: 'example.com', state: 'pending', expiresAt: null }); + + const { POST } = await import('./route'); + const res = await POST(makeRequest('POST'), { params }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.state).toBe('pending'); + expect(body.expiresAt).toBeNull(); + }); + + // AUTH_FAILED from Vercel API maps to 500 + it('returns 500 when Vercel authentication fails (AUTH_FAILED)', async () => { + mockFrom.mockReturnValue(withProTier([{ data: fullDeployment, error: null }])); + const { VercelApiError } = await import('@/services/vercel.service'); + mockAddDomain.mockRejectedValue(new VercelApiError('Invalid Vercel token', 'AUTH_FAILED')); + + const { POST } = await import('./route'); + expect((await POST(makeRequest('POST'), { params })).status).toBe(500); + }); + + // Generic unexpected error from addDomain + it('returns 500 on unexpected addDomain error', async () => { + mockFrom.mockReturnValue(withProTier([{ data: fullDeployment, error: null }])); + mockAddDomain.mockRejectedValue(new Error('Unexpected Vercel error')); + + const { POST } = await import('./route'); + const res = await POST(makeRequest('POST'), { params }); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/Unexpected Vercel error/); + }); + + // getCertificate fails after domain successfully added + it('propagates error when getCertificate throws after successful domain add', async () => { + mockFrom.mockReturnValue(withProTier([{ data: fullDeployment, error: null }])); + mockAddDomain.mockResolvedValue(undefined); + mockGetCertificate.mockRejectedValue(new Error('Certificate fetch failed')); + + const { POST } = await import('./route'); + // The route has no try/catch around getCertificate after addDomain — unhandled throw + await expect(POST(makeRequest('POST'), { params })).rejects.toThrow('Certificate fetch failed'); + }); + + // Retry-After header is rounded up to nearest second + it('rounds Retry-After up to nearest second for fractional retryAfterMs', async () => { + mockFrom.mockReturnValue(withProTier([{ data: fullDeployment, error: null }])); + const { VercelApiError } = await import('@/services/vercel.service'); + mockAddDomain.mockRejectedValue(new VercelApiError('rate limited', 'RATE_LIMITED', 1500)); + + const { POST } = await import('./route'); + const res = await POST(makeRequest('POST'), { params }); + expect(res.status).toBe(429); + expect(res.headers.get('Retry-After')).toBe('2'); + }); + + // Rate limit with no retryAfterMs — header should not be set + it('omits Retry-After header when retryAfterMs is undefined', async () => { + mockFrom.mockReturnValue(withProTier([{ data: fullDeployment, error: null }])); + const { VercelApiError } = await import('@/services/vercel.service'); + mockAddDomain.mockRejectedValue(new VercelApiError('rate limited', 'RATE_LIMITED')); + + const { POST } = await import('./route'); + const res = await POST(makeRequest('POST'), { params }); + expect(res.status).toBe(429); + expect(res.headers.get('Retry-After')).toBeNull(); + }); }); describe('GET /api/deployments/[id]/https', () => { @@ -192,4 +264,75 @@ describe('GET /api/deployments/[id]/https', () => { expect(body.requiredTier).toBe('pro'); expect(body.upgradeUrl).toBe('/pricing'); }); + + // Certificate provisioning state polling — pending → issued → active + // Callers poll GET to track provisioning progress after the initial POST + it('returns issued cert state during provisioning (polling cycle 1)', async () => { + mockFrom.mockReturnValue(withProTier([{ data: fullDeployment, error: null }])); + mockGetCertificate.mockResolvedValue({ domain: 'example.com', state: 'issued' }); + + const { GET } = await import('./route'); + const res = await GET(makeRequest('GET'), { params }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.state).toBe('issued'); + }); + + it('returns active cert state with expiresAt once provisioning completes', async () => { + mockFrom.mockReturnValue(withProTier([{ data: fullDeployment, error: null }])); + mockGetCertificate.mockResolvedValue({ + domain: 'example.com', + state: 'active', + expiresAt: '2027-06-01T00:00:00Z', + }); + + const { GET } = await import('./route'); + const res = await GET(makeRequest('GET'), { params }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.state).toBe('active'); + expect(body.expiresAt).toBe('2027-06-01T00:00:00Z'); + }); + + // Provisioning stalled — Vercel returns an error state + it('returns error state when certificate provisioning fails on Vercel side', async () => { + mockFrom.mockReturnValue(withProTier([{ data: fullDeployment, error: null }])); + mockGetCertificate.mockResolvedValue({ + domain: 'example.com', + state: 'error', + error: 'CAA record mismatch', + }); + + const { GET } = await import('./route'); + const res = await GET(makeRequest('GET'), { params }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.state).toBe('error'); + expect(body.error).toMatch(/CAA record mismatch/); + }); + + // getCertificate throws — Vercel API unavailable during polling + it('returns 500 when getCertificate throws during polling', async () => { + mockFrom.mockReturnValue(withProTier([{ data: fullDeployment, error: null }])); + mockGetCertificate.mockRejectedValue(new Error('Vercel API unreachable')); + + const { GET } = await import('./route'); + const res = await GET(makeRequest('GET'), { params }); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/Vercel API unreachable/); + }); + + // GET 404 when vercel_project_id is missing + it('returns 404 when vercel_project_id is missing on GET', async () => { + mockFrom.mockReturnValue( + withProTier([{ data: { custom_domain: 'example.com', vercel_project_id: null }, error: null }]), + ); + const { GET } = await import('./route'); + expect((await GET(makeRequest('GET'), { params })).status).toBe(404); + }); }); diff --git a/apps/backend/src/services/health-monitor.fault-injection.test.ts b/apps/backend/src/services/health-monitor.fault-injection.test.ts new file mode 100644 index 00000000..f6a2d9af --- /dev/null +++ b/apps/backend/src/services/health-monitor.fault-injection.test.ts @@ -0,0 +1,434 @@ +/** + * Fault injection tests for HealthMonitorService + * + * Simulates failures in each monitored dependency — database, Vercel network, + * Stellar network, Stripe endpoint — and verifies that the service reports the + * correct degraded/unhealthy status and aggregates partial failures correctly. + * + * Dependency graph under test: + * HealthMonitorService + * ├── Supabase DB (deployment URL lookup, uptime recording, owner lookup) + * ├── fetch() (HEAD request to the monitored deployment URL) + * └── analyticsService.recordUptimeCheck() + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HealthMonitorService } from './health-monitor.service'; + +// ── Supabase mock ───────────────────────────────────────────────────────────── + +const mockFrom = vi.fn(); + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => ({ from: mockFrom }), +})); + +// ── Analytics mock ──────────────────────────────────────────────────────────── + +const mockRecordUptimeCheck = vi.fn().mockResolvedValue(undefined); + +vi.mock('./analytics.service', () => ({ + analyticsService: { recordUptimeCheck: mockRecordUptimeCheck }, +})); + +// ── fetch mock ──────────────────────────────────────────────────────────────── + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Build a supabase chain returning the given value from .single() */ +function buildChain(resolvedValue: { data: unknown; error: unknown }) { + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue(resolvedValue), + }; +} + +/** Healthy fetch response */ +const OK_RESPONSE = { ok: true, status: 200 }; + +/** Network-level connection refused error */ +const ECONNREFUSED = new Error('ECONNREFUSED — connection refused'); + +/** AbortSignal timeout error */ +const TIMEOUT_ERROR = new Error('The operation was aborted due to timeout'); +TIMEOUT_ERROR.name = 'TimeoutError'; + +// ── Individual dependency failure scenarios ─────────────────────────────────── + +describe('HealthMonitorService — fault injection: database dependency', () => { + let service: HealthMonitorService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new HealthMonitorService(); + }); + + it('reports unhealthy when Supabase returns null deployment (DB miss)', async () => { + mockFrom.mockReturnValue(buildChain({ data: null, error: null })); + + const result = await service.checkDeploymentHealth('dep-db-miss'); + + expect(result.isHealthy).toBe(false); + expect(result.error).toMatch(/Deployment URL not found/); + expect(result.statusCode).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('reports unhealthy when Supabase returns a database error object', async () => { + mockFrom.mockReturnValue( + buildChain({ data: null, error: { message: 'connection to server failed' } }), + ); + + const result = await service.checkDeploymentHealth('dep-db-error'); + + expect(result.isHealthy).toBe(false); + expect(result.statusCode).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('reports unhealthy when deployment_url field is missing in DB row', async () => { + mockFrom.mockReturnValue( + buildChain({ data: { deployment_url: undefined }, error: null }), + ); + + const result = await service.checkDeploymentHealth('dep-no-url'); + + expect(result.isHealthy).toBe(false); + expect(result.error).toMatch(/Deployment URL not found/); + }); + + it('returns empty array when DB returns null active-deployments list', async () => { + const chain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + }; + mockFrom.mockReturnValue(chain); + (chain.eq as ReturnType).mockResolvedValue({ data: null, error: null }); + + const results = await service.checkAllDeployments(); + + expect(results).toEqual([]); + }); + + it('does not call notifyDowntime when DB owner lookup returns null', async () => { + const urlChain = buildChain({ data: { deployment_url: 'https://vercel.app/dep' }, error: null }); + const ownerChain = buildChain({ data: null, error: null }); + let callCount = 0; + mockFrom.mockImplementation(() => (callCount++ === 0 ? urlChain : ownerChain)); + mockFetch.mockResolvedValue({ ok: false, status: 503 }); + + const notifySpy = vi.spyOn(service, 'notifyDowntime'); + await service.monitorDeployment('dep-owner-miss'); + + expect(notifySpy).not.toHaveBeenCalled(); + }); +}); + +describe('HealthMonitorService — fault injection: Vercel network dependency', () => { + let service: HealthMonitorService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new HealthMonitorService(); + mockFrom.mockReturnValue( + buildChain({ data: { deployment_url: 'https://my-project.vercel.app' }, error: null }), + ); + }); + + it('reports unhealthy when Vercel endpoint returns 502 Bad Gateway', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 502 }); + + const result = await service.checkDeploymentHealth('dep-vercel-502'); + + expect(result.isHealthy).toBe(false); + expect(result.statusCode).toBe(502); + expect(result.error).toBeNull(); + }); + + it('reports unhealthy when Vercel endpoint returns 503 Service Unavailable', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 503 }); + + const result = await service.checkDeploymentHealth('dep-vercel-503'); + + expect(result.isHealthy).toBe(false); + expect(result.statusCode).toBe(503); + }); + + it('reports unhealthy and zero responseTime on Vercel connection refused', async () => { + mockFetch.mockRejectedValue(ECONNREFUSED); + + const result = await service.checkDeploymentHealth('dep-vercel-refused'); + + expect(result.isHealthy).toBe(false); + expect(result.statusCode).toBeNull(); + expect(result.responseTime).toBe(0); + expect(result.error).toMatch(/ECONNREFUSED/); + }); + + it('reports unhealthy on Vercel request timeout', async () => { + mockFetch.mockRejectedValue(TIMEOUT_ERROR); + + const result = await service.checkDeploymentHealth('dep-vercel-timeout'); + + expect(result.isHealthy).toBe(false); + expect(result.statusCode).toBeNull(); + expect(result.error).toMatch(/timeout|aborted/i); + }); + + it('records downtime with analytics on Vercel failure', async () => { + mockFetch.mockRejectedValue(ECONNREFUSED); + + await service.checkDeploymentHealth('dep-vercel-down'); + + expect(mockRecordUptimeCheck).toHaveBeenCalledWith('dep-vercel-down', false); + }); +}); + +describe('HealthMonitorService — fault injection: Stellar network dependency', () => { + let service: HealthMonitorService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new HealthMonitorService(); + mockFrom.mockReturnValue( + buildChain({ data: { deployment_url: 'https://horizon.stellar.org/health' }, error: null }), + ); + }); + + it('reports unhealthy when Stellar Horizon returns 503', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 503 }); + + const result = await service.checkDeploymentHealth('dep-stellar-503'); + + expect(result.isHealthy).toBe(false); + expect(result.statusCode).toBe(503); + }); + + it('reports unhealthy on Stellar network timeout', async () => { + mockFetch.mockRejectedValue(TIMEOUT_ERROR); + + const result = await service.checkDeploymentHealth('dep-stellar-timeout'); + + expect(result.isHealthy).toBe(false); + expect(result.error).toMatch(/timeout|aborted/i); + }); + + it('records downtime with analytics when Stellar is unreachable', async () => { + mockFetch.mockRejectedValue(new Error('Network request failed')); + + await service.checkDeploymentHealth('dep-stellar-unreachable'); + + expect(mockRecordUptimeCheck).toHaveBeenCalledWith('dep-stellar-unreachable', false); + }); +}); + +describe('HealthMonitorService — fault injection: Stripe endpoint dependency', () => { + let service: HealthMonitorService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new HealthMonitorService(); + mockFrom.mockReturnValue( + buildChain({ data: { deployment_url: 'https://api.stripe.com/healthcheck' }, error: null }), + ); + }); + + it('reports unhealthy when Stripe API returns 500 Internal Server Error', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + const result = await service.checkDeploymentHealth('dep-stripe-500'); + + expect(result.isHealthy).toBe(false); + expect(result.statusCode).toBe(500); + }); + + it('reports unhealthy when Stripe is rate-limited (429)', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 429 }); + + const result = await service.checkDeploymentHealth('dep-stripe-429'); + + expect(result.isHealthy).toBe(false); + expect(result.statusCode).toBe(429); + }); + + it('reports unhealthy on Stripe connection timeout', async () => { + mockFetch.mockRejectedValue(TIMEOUT_ERROR); + + const result = await service.checkDeploymentHealth('dep-stripe-timeout'); + + expect(result.isHealthy).toBe(false); + expect(result.responseTime).toBe(0); + }); +}); + +// ── Partial failure scenarios ───────────────────────────────────────────────── + +describe('HealthMonitorService — fault injection: partial failures', () => { + let service: HealthMonitorService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new HealthMonitorService(); + }); + + it('correctly aggregates when one of three deployments is unhealthy', async () => { + const deployments = [ + { id: 'dep-vercel', deployment_url: 'https://app.vercel.app' }, + { id: 'dep-stellar', deployment_url: 'https://horizon.stellar.org/health' }, + { id: 'dep-stripe', deployment_url: 'https://api.stripe.com/healthcheck' }, + ]; + + // DB query for the active deployments list + const listChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + }; + (listChain.eq as ReturnType).mockResolvedValue({ + data: deployments.map(d => ({ id: d.id })), + error: null, + }); + + // Per-deployment URL lookups + let urlCallCount = 0; + mockFrom.mockImplementation(() => { + if (urlCallCount === 0) { + urlCallCount++; + return listChain; + } + const dep = deployments[urlCallCount - 1]; + urlCallCount++; + return buildChain({ data: { deployment_url: dep?.deployment_url }, error: null }); + }); + + // Vercel healthy, Stellar down, Stripe healthy + mockFetch + .mockResolvedValueOnce({ ok: true, status: 200 }) + .mockRejectedValueOnce(new Error('Stellar unreachable')) + .mockResolvedValueOnce({ ok: true, status: 200 }); + + const results = await service.checkAllDeployments(); + + expect(results).toHaveLength(3); + const stellar = results.find(r => r.deploymentId === 'dep-stellar'); + const vercel = results.find(r => r.deploymentId === 'dep-vercel'); + const stripe = results.find(r => r.deploymentId === 'dep-stripe'); + + expect(stellar?.isHealthy).toBe(false); + expect(vercel?.isHealthy).toBe(true); + expect(stripe?.isHealthy).toBe(true); + }); + + it('reports all unhealthy when every dependency fails simultaneously', async () => { + const deploymentIds = ['dep-a', 'dep-b', 'dep-c']; + + const listChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + }; + (listChain.eq as ReturnType).mockResolvedValue({ + data: deploymentIds.map(id => ({ id })), + error: null, + }); + + let callCount = 0; + mockFrom.mockImplementation(() => { + if (callCount === 0) { + callCount++; + return listChain; + } + callCount++; + return buildChain({ data: { deployment_url: 'https://example.com' }, error: null }); + }); + + mockFetch.mockRejectedValue(new Error('All services down')); + + const results = await service.checkAllDeployments(); + + expect(results).toHaveLength(3); + expect(results.every(r => r.isHealthy === false)).toBe(true); + }); + + it('records uptime check for each dependency independently', async () => { + const ids = ['dep-healthy', 'dep-down']; + const listChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + }; + (listChain.eq as ReturnType).mockResolvedValue({ + data: ids.map(id => ({ id })), + error: null, + }); + + let callCount = 0; + mockFrom.mockImplementation(() => { + if (callCount === 0) { + callCount++; + return listChain; + } + callCount++; + return buildChain({ data: { deployment_url: 'https://example.com' }, error: null }); + }); + + mockFetch + .mockResolvedValueOnce({ ok: true, status: 200 }) + .mockRejectedValueOnce(new Error('connection refused')); + + await service.checkAllDeployments(); + + expect(mockRecordUptimeCheck).toHaveBeenCalledWith('dep-healthy', true); + expect(mockRecordUptimeCheck).toHaveBeenCalledWith('dep-down', false); + }); +}); + +// ── Combined dependency failures ────────────────────────────────────────────── + +describe('HealthMonitorService — fault injection: combined failures', () => { + let service: HealthMonitorService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new HealthMonitorService(); + }); + + it('handles DB failure gracefully when analytics write also fails', async () => { + mockFrom.mockReturnValue(buildChain({ data: null, error: null })); + mockRecordUptimeCheck.mockRejectedValueOnce(new Error('analytics unavailable')); + + // DB miss means analytics is never called; no throw should propagate + const result = await service.checkDeploymentHealth('dep-combined-db-analytics'); + + expect(result.isHealthy).toBe(false); + expect(result.error).toMatch(/Deployment URL not found/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns unhealthy when network fails and still reports responseTime as 0', async () => { + mockFrom.mockReturnValue( + buildChain({ data: { deployment_url: 'https://my-app.vercel.app' }, error: null }), + ); + mockFetch.mockRejectedValue(ECONNREFUSED); + + const result = await service.checkDeploymentHealth('dep-combined-net'); + + expect(result.isHealthy).toBe(false); + expect(result.responseTime).toBe(0); + expect(result.statusCode).toBeNull(); + }); + + it('notifies downtime owner when network is down and DB owner lookup succeeds', async () => { + const urlChain = buildChain({ data: { deployment_url: 'https://my-app.vercel.app' }, error: null }); + const ownerChain = buildChain({ data: { user_id: 'user-42' }, error: null }); + let callCount = 0; + mockFrom.mockImplementation(() => (callCount++ === 0 ? urlChain : ownerChain)); + mockFetch.mockRejectedValue(ECONNREFUSED); + + const notifySpy = vi.spyOn(service, 'notifyDowntime').mockResolvedValue(undefined); + await service.monitorDeployment('dep-notify'); + + expect(notifySpy).toHaveBeenCalledWith('dep-notify', 'user-42'); + }); +}); diff --git a/apps/backend/src/services/health-monitor.service.ts b/apps/backend/src/services/health-monitor.service.ts index 5a0f2ee9..4b57f448 100644 --- a/apps/backend/src/services/health-monitor.service.ts +++ b/apps/backend/src/services/health-monitor.service.ts @@ -1,6 +1,37 @@ import { createClient } from '@/lib/supabase/server'; import { analyticsService } from './analytics.service'; +/** + * HealthMonitorService — dependency graph + * + * External dependencies checked during health monitoring: + * + * ┌─────────────────────────────────────────────────────────┐ + * │ HealthMonitorService │ + * │ │ + * │ checkDeploymentHealth(id) │ + * │ ├── [DB] Supabase → deployments.deployment_url │ + * │ ├── [NET] fetch(deployment_url) — HEAD request │ + * │ │ (URL may point to Vercel, Stellar, Stripe, │ + * │ │ or any monitored service endpoint) │ + * │ └── [SVC] analyticsService.recordUptimeCheck() │ + * │ │ + * │ checkAllDeployments() │ + * │ ├── [DB] Supabase → deployments (status+is_active) │ + * │ └── → checkDeploymentHealth() × N │ + * │ │ + * │ monitorDeployment(id) │ + * │ ├── → checkDeploymentHealth() │ + * │ ├── [DB] Supabase → deployments.user_id │ + * │ └── → notifyDowntime() [console / future webhook] │ + * └─────────────────────────────────────────────────────────┘ + * + * Failure modes: + * - Database unavailable → isHealthy: false, error set + * - Network timeout → isHealthy: false, error: timeout message + * - Non-2xx response → isHealthy: false, statusCode set + * - Analytics write fails → health result still returned (best-effort) + */ export class HealthMonitorService { /** * Check deployment health diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index 774d8ace..a95e8e59 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./vitest.setup.ts'], + // Shard safety: no mutable module-level singletons; each vi.mock is + // reset in beforeEach. Safe to run with --shard=/. + sequence: { shuffle: false }, }, resolve: { alias: { diff --git a/apps/frontend/tests/accessibility/components.a11y.test.ts b/apps/frontend/tests/accessibility/components.a11y.test.ts index d9267220..bf7d0482 100644 --- a/apps/frontend/tests/accessibility/components.a11y.test.ts +++ b/apps/frontend/tests/accessibility/components.a11y.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest'; -import { render } from '@testing-library/react'; +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; import { axe, toHaveNoViolations } from 'jest-axe'; import { DeploymentStatusBadge } from '../../src/components/deployments/DeploymentStatusBadge'; import { ErrorState } from '../../src/components/app/ErrorState'; @@ -7,9 +8,34 @@ import { RetryButton } from '../../src/components/app/RetryButton'; import { StatusBadge } from '../../src/components/app/StatusBadge'; import { NavItem } from '../../src/components/app/NavItem'; import { Breadcrumbs } from '../../src/components/app/Breadcrumbs'; +import { Sidebar } from '../../src/components/app/Sidebar'; +import { MobileDrawer } from '../../src/components/app/MobileDrawer'; + +vi.mock('next/navigation', () => ({ + usePathname: () => '/app/deployments', +})); + +vi.mock('next/link', () => ({ + default: ({ href, children, className, ...props }: any) => + React.createElement('a', { href, className, ...props }, children), +})); expect.extend(toHaveNoViolations); +// ── Shared fixtures ─────────────────────────────────────────────────────────── + +const mockIcon = React.createElement('svg', { 'aria-hidden': 'true' }); + +const fakeUser = { id: 'u1', name: 'Jane Doe', email: 'jane@example.com', role: 'user' as const }; + +const navItems = [ + { id: 'nav-deployments', label: 'Deployments', icon: mockIcon, path: '/app/deployments' }, + { id: 'nav-templates', label: 'Templates', icon: mockIcon, path: '/app/templates' }, + { id: 'nav-settings', label: 'Settings', icon: mockIcon, path: '/app/settings' }, +]; + +// ── Accessibility Tests ─────────────────────────────────────────────────────── + describe('Accessibility Tests for Frontend Components', () => { describe('DeploymentStatusBadge', () => { it('should have no accessibility violations', async () => { @@ -109,6 +135,70 @@ describe('Accessibility Tests for Frontend Components', () => { const link = getByRole('link'); expect(link).toHaveAttribute('href', '/test'); }); + + // WCAG 2.4.3 Focus Order / 4.1.2 Name, Role, Value + // Active nav item must expose aria-current="page" so screen readers + // announce the current location within the navigation landmark. + it('exposes aria-current="page" on the active nav link (WCAG 2.4.3)', () => { + const { getByRole } = render( + + ); + const link = getByRole('link'); + expect(link).toHaveAttribute('aria-current', 'page'); + }); + + it('does not set aria-current on an inactive nav link', () => { + const { getByRole } = render( + + ); + const link = getByRole('link'); + expect(link).not.toHaveAttribute('aria-current'); + }); + + // WCAG 2.4.6 Headings and Labels — link must have a non-empty accessible name + it('has a non-empty accessible name from its label text (WCAG 2.4.6)', () => { + const { getByRole } = render( + + ); + expect(getByRole('link', { name: /settings/i })).toBeTruthy(); + }); + + // WCAG 4.1.2 Name, Role, Value — disabled item must not be a focusable link + it('renders as a non-link element when disabled to prevent keyboard focus trap (WCAG 4.1.2)', () => { + const { queryByRole } = render( + + ); + expect(queryByRole('link')).toBeNull(); + }); + + // WCAG 2.1.1 Keyboard — badge count is visible text, not an icon-only label + it('badge count is visible accessible text, not icon-only (WCAG 1.1.1)', () => { + const { getByText } = render( + + ); + expect(getByText('7')).toBeTruthy(); + }); + + it('displays 99+ badge for large counts without losing context', () => { + const { getByText } = render( + + ); + expect(getByText('99+')).toBeTruthy(); + }); + + it('has no axe violations for active state (WCAG 2.4.3)', async () => { + const { container } = render( + + ); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no axe violations for disabled state (WCAG 4.1.2)', async () => { + const { container } = render( + + ); + expect(await axe(container)).toHaveNoViolations(); + }); }); describe('Breadcrumbs', () => { @@ -135,6 +225,55 @@ describe('Accessibility Tests for Frontend Components', () => { const list = container.querySelector('[role="list"]'); expect(list).toBeTruthy(); }); + + // WCAG 2.4.8 Location — the breadcrumb nav must be labelled so screen + // readers can distinguish it from other navigation landmarks on the page. + it('breadcrumb nav has aria-label="Breadcrumb" for landmark identification (WCAG 2.4.8)', () => { + const { container } = render( + + ); + const nav = container.querySelector('nav'); + expect(nav).toHaveAttribute('aria-label', 'Breadcrumb'); + }); + + // WCAG 4.1.2 Name, Role, Value — intermediate items are links; last item + // is the current page, represented as plain text (not a link) so AT users + // understand they are on this page already. + it('renders intermediate crumbs as links and last crumb as plain text (WCAG 4.1.2)', () => { + const { getAllByRole, queryByRole, getByText } = render( + + ); + const links = getAllByRole('link'); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute('href', '/'); + expect(links[1]).toHaveAttribute('href', '/app/deployments'); + // Current page item is plain text, not a link + expect(getByText('my-app').tagName).not.toBe('A'); + }); + + it('has no axe violations for a multi-level breadcrumb path (WCAG 2.4.8)', async () => { + const { container } = render( + + ); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('renders nothing when items array is empty (no orphan nav landmark)', () => { + const { container } = render(); + expect(container.querySelector('nav')).toBeNull(); + }); }); describe('Color Contrast', () => { @@ -156,4 +295,184 @@ describe('Accessibility Tests for Frontend Components', () => { expect(message).toContain('Please fill in all required fields'); }); }); + + // ── Sidebar ──────────────────────────────────────────────────────────────── + // + // WCAG 2.4.1 Bypass Blocks: the sidebar must be an