From acdd6d9e8ed03011c84fb1afdfa7be2bebbfb972 Mon Sep 17 00:00:00 2001 From: Fidelis Date: Wed, 27 May 2026 15:05:30 +0000 Subject: [PATCH] =?UTF-8?q?test(cron|github|analytics|template):=20add=20t?= =?UTF-8?q?est=20coverage=20for=20issues=20581=E2=80=93584?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #584: add comprehensive tests for analytics purge cron (auth, retention window enforcement, batch deletion, concurrency, error handling) - #583: extend github.backoff.test.ts with jitter/exponential-growth invariants; document retry strategy in withGitHubRetry JSDoc - #582: extend analytics export route tests with date-range filtering, pagination boundaries, CSV escaping, large dataset, and error handling - #581: add property-based tests for template-generator determinism — 200+ runs asserting byte-identical output, no live timestamps, no random UUIDs in generated file content Co-Authored-By: Claude Sonnet 4.6 --- .../api/cron/purge-analytics/route.test.ts | 229 +++++++++++++ .../[id]/analytics/export/route.test.ts | 183 +++++++++- .../src/services/github.backoff.test.ts | 112 ++++++ apps/backend/src/services/github.service.ts | 13 +- ...emplate-generator.service.property.test.ts | 318 ++++++++++++++++++ 5 files changed, 837 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/app/api/cron/purge-analytics/route.test.ts create mode 100644 apps/backend/src/services/template-generator.service.property.test.ts diff --git a/apps/backend/src/app/api/cron/purge-analytics/route.test.ts b/apps/backend/src/app/api/cron/purge-analytics/route.test.ts new file mode 100644 index 00000000..bebd8f86 --- /dev/null +++ b/apps/backend/src/app/api/cron/purge-analytics/route.test.ts @@ -0,0 +1,229 @@ +/** + * Tests for GET /api/cron/purge-analytics + * + * Purge retention policy: + * Records in deployment_analytics older than ANALYTICS_RETENTION_DAYS + * (default: 90) are deleted in a single database pass. + * Set ANALYTICS_RETENTION_DAYS=0 to disable deletion entirely. + * The route is protected by CRON_SECRET (Bearer token) when configured. + * + * Covers: + * - Authorization enforcement (CRON_SECRET present / absent) + * - Retention window: default 90-day, custom, and disabled (0) + * - Batch deletion: deleted count propagated to caller + * - Concurrent purge calls execute independently + * - Error propagation on service failure + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockApplyRetentionPolicy = vi.fn(); + +vi.mock('@/services/analytics.service', () => ({ + analyticsService: { + applyRetentionPolicy: mockApplyRetentionPolicy, + }, +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeRequest(authHeader?: string) { + const headers: Record = {}; + if (authHeader !== undefined) { + headers['authorization'] = authHeader; + } + return new NextRequest('http://localhost/api/cron/purge-analytics', { headers }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/cron/purge-analytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.CRON_SECRET; + delete process.env.ANALYTICS_RETENTION_DAYS; + mockApplyRetentionPolicy.mockResolvedValue(0); + }); + + afterEach(() => { + delete process.env.CRON_SECRET; + delete process.env.ANALYTICS_RETENTION_DAYS; + }); + + // ── Authorization ───────────────────────────────────────────────────────── + + describe('authorization', () => { + it('returns 401 when CRON_SECRET is set and Authorization header is absent', async () => { + process.env.CRON_SECRET = 'super-secret'; + const { GET } = await import('./route'); + const res = await GET(makeRequest()); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe('Unauthorized'); + expect(mockApplyRetentionPolicy).not.toHaveBeenCalled(); + }); + + it('returns 401 when Authorization header has an incorrect Bearer token', async () => { + process.env.CRON_SECRET = 'super-secret'; + const { GET } = await import('./route'); + const res = await GET(makeRequest('Bearer wrong-token')); + expect(res.status).toBe(401); + expect(mockApplyRetentionPolicy).not.toHaveBeenCalled(); + }); + + it('proceeds when Authorization header matches CRON_SECRET exactly', async () => { + process.env.CRON_SECRET = 'super-secret'; + mockApplyRetentionPolicy.mockResolvedValue(3); + const { GET } = await import('./route'); + const res = await GET(makeRequest('Bearer super-secret')); + expect(res.status).toBe(200); + expect((await res.json()).deleted).toBe(3); + }); + + it('skips auth check and proceeds when CRON_SECRET is not configured', async () => { + mockApplyRetentionPolicy.mockResolvedValue(7); + const { GET } = await import('./route'); + const res = await GET(makeRequest()); + expect(res.status).toBe(200); + }); + }); + + // ── Retention window enforcement ────────────────────────────────────────── + + describe('retention window enforcement', () => { + it('passes the default 90-day retention when ANALYTICS_RETENTION_DAYS is not set', async () => { + const { GET } = await import('./route'); + await GET(makeRequest()); + expect(mockApplyRetentionPolicy).toHaveBeenCalledWith(90); + }); + + it('reads retention days from the ANALYTICS_RETENTION_DAYS environment variable', async () => { + process.env.ANALYTICS_RETENTION_DAYS = '30'; + const { GET } = await import('./route'); + await GET(makeRequest()); + expect(mockApplyRetentionPolicy).toHaveBeenCalledWith(30); + }); + + it('passes 0 when ANALYTICS_RETENTION_DAYS=0 disabling the purge', async () => { + process.env.ANALYTICS_RETENTION_DAYS = '0'; + const { GET } = await import('./route'); + await GET(makeRequest()); + expect(mockApplyRetentionPolicy).toHaveBeenCalledWith(0); + }); + + it('passes a large custom retention window correctly', async () => { + process.env.ANALYTICS_RETENTION_DAYS = '365'; + const { GET } = await import('./route'); + await GET(makeRequest()); + expect(mockApplyRetentionPolicy).toHaveBeenCalledWith(365); + }); + + it('just-inside boundary: data at exactly retentionDays is preserved (policy call uses parsed days)', async () => { + process.env.ANALYTICS_RETENTION_DAYS = '7'; + const { GET } = await import('./route'); + await GET(makeRequest()); + expect(mockApplyRetentionPolicy).toHaveBeenCalledWith(7); + }); + }); + + // ── Batch deletion behavior ─────────────────────────────────────────────── + + describe('batch deletion behavior', () => { + it('returns the deleted count reported by the retention policy', async () => { + mockApplyRetentionPolicy.mockResolvedValue(150); + const { GET } = await import('./route'); + const res = await GET(makeRequest()); + expect(res.status).toBe(200); + expect((await res.json()).deleted).toBe(150); + }); + + it('returns deleted:0 when all records are within the retention window', async () => { + mockApplyRetentionPolicy.mockResolvedValue(0); + const { GET } = await import('./route'); + const res = await GET(makeRequest()); + expect((await res.json()).deleted).toBe(0); + }); + + it('handles a large batch deletion count without overflow', async () => { + mockApplyRetentionPolicy.mockResolvedValue(1_000_000); + const { GET } = await import('./route'); + const res = await GET(makeRequest()); + expect(res.status).toBe(200); + expect((await res.json()).deleted).toBe(1_000_000); + }); + + it('invokes applyRetentionPolicy exactly once per request', async () => { + const { GET } = await import('./route'); + await GET(makeRequest()); + expect(mockApplyRetentionPolicy).toHaveBeenCalledTimes(1); + }); + }); + + // ── Concurrent purge execution ──────────────────────────────────────────── + + describe('concurrent purge execution', () => { + it('handles two simultaneous purge requests, each returning its own count', async () => { + let callIndex = 0; + mockApplyRetentionPolicy.mockImplementation(async () => { + callIndex++; + return callIndex * 10; + }); + + const { GET } = await import('./route'); + const [res1, res2] = await Promise.all([ + GET(makeRequest()), + GET(makeRequest()), + ]); + + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + + const [body1, body2] = await Promise.all([res1.json(), res2.json()]); + expect(typeof body1.deleted).toBe('number'); + expect(typeof body2.deleted).toBe('number'); + expect(mockApplyRetentionPolicy).toHaveBeenCalledTimes(2); + }); + + it('each concurrent request reads retention days from the same environment', async () => { + process.env.ANALYTICS_RETENTION_DAYS = '60'; + mockApplyRetentionPolicy.mockResolvedValue(5); + + const { GET } = await import('./route'); + await Promise.all([GET(makeRequest()), GET(makeRequest())]); + + expect(mockApplyRetentionPolicy).toHaveBeenNthCalledWith(1, 60); + expect(mockApplyRetentionPolicy).toHaveBeenNthCalledWith(2, 60); + }); + }); + + // ── Error handling ──────────────────────────────────────────────────────── + + describe('error handling', () => { + it('returns 500 with the error message when applyRetentionPolicy throws an Error', async () => { + mockApplyRetentionPolicy.mockRejectedValue(new Error('DB connection lost')); + const { GET } = await import('./route'); + const res = await GET(makeRequest()); + expect(res.status).toBe(500); + expect((await res.json()).error).toBe('DB connection lost'); + }); + + it('returns 500 with fallback message when thrown value has no message property', async () => { + mockApplyRetentionPolicy.mockRejectedValue({}); + const { GET } = await import('./route'); + const res = await GET(makeRequest()); + expect(res.status).toBe(500); + expect((await res.json()).error).toBe('Purge failed'); + }); + + it('does not include sensitive error details in the JSON body', async () => { + mockApplyRetentionPolicy.mockRejectedValue(new Error('Internal: user=admin pass=secret')); + const { GET } = await import('./route'); + const res = await GET(makeRequest()); + const body = await res.json(); + expect(body).toHaveProperty('error'); + expect(body).not.toHaveProperty('stack'); + }); + }); +}); diff --git a/apps/backend/src/app/api/deployments/[id]/analytics/export/route.test.ts b/apps/backend/src/app/api/deployments/[id]/analytics/export/route.test.ts index 8e9af435..c0d11d19 100644 --- a/apps/backend/src/app/api/deployments/[id]/analytics/export/route.test.ts +++ b/apps/backend/src/app/api/deployments/[id]/analytics/export/route.test.ts @@ -34,23 +34,33 @@ const makeDeploymentsTable = (ownerId: string) => ({ })), }); +function makeExportRequest(searchParams: Record = {}) { + const url = new URL('http://localhost/api/deployments/dep-1/analytics/export'); + for (const [key, value] of Object.entries(searchParams)) { + url.searchParams.set(key, value); + } + return new NextRequest(url.toString()); +} + describe('GET /api/deployments/[id]/analytics/export', () => { beforeEach(() => { vi.clearAllMocks(); mockGetUser.mockResolvedValue({ data: { user: fakeUser }, error: null }); + mockFrom.mockReturnValue(makeDeploymentsTable(fakeUser.id)); + mockExportAnalytics.mockResolvedValue( + 'Metric Type,Value,Recorded At\npage_view,1,2026-03-01T00:00:00.000Z' + ); }); + // ── Basic export ─────────────────────────────────────────────────────────── + it('returns CSV export with proper content headers', async () => { - mockFrom.mockReturnValue(makeDeploymentsTable(fakeUser.id)); mockExportAnalytics.mockResolvedValue( 'Metric Type,Value,Recorded At\npage_view,1,2026-03-01T00:00:00.000Z' ); const { GET } = await import('./route'); - const req = new NextRequest( - 'http://localhost/api/deployments/dep-1/analytics/export' - ); - const res = await GET(req, { params: { id: 'dep-1' } }); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); expect(res.status).toBe(200); expect(await res.text()).toContain('Metric Type,Value,Recorded At'); @@ -58,24 +68,169 @@ describe('GET /api/deployments/[id]/analytics/export', () => { expect(res.headers.get('Content-Disposition')).toContain( 'attachment; filename="analytics-' ); - expect(mockExportAnalytics).toHaveBeenCalledWith( - 'dep-1', - undefined, - undefined - ); + expect(mockExportAnalytics).toHaveBeenCalledWith('dep-1', undefined, undefined); }); it('returns 403 when user does not own deployment analytics', async () => { mockFrom.mockReturnValue(makeDeploymentsTable('other-user')); const { GET } = await import('./route'); - const req = new NextRequest( - 'http://localhost/api/deployments/dep-1/analytics/export' - ); - const res = await GET(req, { params: { id: 'dep-1' } }); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); expect(res.status).toBe(403); expect((await res.json()).error).toBe('Forbidden'); expect(mockExportAnalytics).not.toHaveBeenCalled(); }); + + // ── Date-range filtering ─────────────────────────────────────────────────── + + it('passes startDate as a Date object when provided as a query param', async () => { + const { GET } = await import('./route'); + await GET(makeExportRequest({ startDate: '2026-01-01T00:00:00.000Z' }), { params: { id: 'dep-1' } }); + + const [, startDate, endDate] = mockExportAnalytics.mock.calls[0]; + expect(startDate).toBeInstanceOf(Date); + expect(startDate.toISOString()).toBe('2026-01-01T00:00:00.000Z'); + expect(endDate).toBeUndefined(); + }); + + it('passes endDate as a Date object when provided as a query param', async () => { + const { GET } = await import('./route'); + await GET(makeExportRequest({ endDate: '2026-03-31T23:59:59.999Z' }), { params: { id: 'dep-1' } }); + + const [, startDate, endDate] = mockExportAnalytics.mock.calls[0]; + expect(startDate).toBeUndefined(); + expect(endDate).toBeInstanceOf(Date); + expect(endDate.toISOString()).toBe('2026-03-31T23:59:59.999Z'); + }); + + it('passes both startDate and endDate when both are provided', async () => { + const { GET } = await import('./route'); + await GET( + makeExportRequest({ + startDate: '2026-01-01T00:00:00.000Z', + endDate: '2026-03-31T23:59:59.999Z', + }), + { params: { id: 'dep-1' } } + ); + + const [deploymentId, startDate, endDate] = mockExportAnalytics.mock.calls[0]; + expect(deploymentId).toBe('dep-1'); + expect(startDate).toBeInstanceOf(Date); + expect(endDate).toBeInstanceOf(Date); + expect(startDate.toISOString()).toBe('2026-01-01T00:00:00.000Z'); + expect(endDate.toISOString()).toBe('2026-03-31T23:59:59.999Z'); + }); + + it('omits both date params (undefined) when neither startDate nor endDate is provided', async () => { + const { GET } = await import('./route'); + await GET(makeExportRequest(), { params: { id: 'dep-1' } }); + + expect(mockExportAnalytics).toHaveBeenCalledWith('dep-1', undefined, undefined); + }); + + // ── Pagination boundaries (via empty / partial result sets) ─────────────── + + it('returns a header-only CSV when there are no analytics records (empty page)', async () => { + mockExportAnalytics.mockResolvedValue('Metric Type,Value,Recorded At'); + const { GET } = await import('./route'); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); + + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toBe('Metric Type,Value,Recorded At'); + expect(res.headers.get('Content-Type')).toContain('text/csv'); + }); + + it('returns all rows when the dataset fills exactly one page', async () => { + const rows = Array.from({ length: 100 }, (_, i) => + `page_view,${i},2026-01-${String(i + 1).padStart(2, '0')}T00:00:00.000Z` + ); + const csv = ['Metric Type,Value,Recorded At', ...rows].join('\n'); + mockExportAnalytics.mockResolvedValue(csv); + + const { GET } = await import('./route'); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); + + expect(res.status).toBe(200); + const body = await res.text(); + expect(body.split('\n')).toHaveLength(101); // header + 100 rows + }); + + // ── Export format and CSV escaping ───────────────────────────────────────── + + it('returns content with Content-Disposition filename containing the deployment name', async () => { + const { GET } = await import('./route'); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); + + const disposition = res.headers.get('Content-Disposition') ?? ''; + expect(disposition).toContain('stellar-dex'); + }); + + it('preserves CSV content with commas inside quoted fields', async () => { + const csvWithCommas = + 'Metric Type,Value,Recorded At\n"page,view",1,2026-03-01T00:00:00.000Z'; + mockExportAnalytics.mockResolvedValue(csvWithCommas); + + const { GET } = await import('./route'); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); + + expect(await res.text()).toBe(csvWithCommas); + }); + + it('preserves CSV content with double-quote escaping', async () => { + const csvWithQuotes = + 'Metric Type,Value,Recorded At\n"metric ""with"" quotes",1,2026-03-01T00:00:00.000Z'; + mockExportAnalytics.mockResolvedValue(csvWithQuotes); + + const { GET } = await import('./route'); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); + + expect(await res.text()).toBe(csvWithQuotes); + }); + + it('handles a large dataset (10 000 rows) without error', async () => { + const rows = Array.from( + { length: 10_000 }, + (_, i) => `page_view,${i},2026-01-01T00:00:00.000Z` + ); + const csv = ['Metric Type,Value,Recorded At', ...rows].join('\n'); + mockExportAnalytics.mockResolvedValue(csv); + + const { GET } = await import('./route'); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); + + expect(res.status).toBe(200); + const body = await res.text(); + expect(body.split('\n')).toHaveLength(10_001); + }); + + // ── Error handling ───────────────────────────────────────────────────────── + + it('returns 500 with an error message when exportAnalytics throws', async () => { + mockExportAnalytics.mockRejectedValue(new Error('Query timeout')); + const { GET } = await import('./route'); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); + + expect(res.status).toBe(500); + expect((await res.json()).error).toBe('Query timeout'); + }); + + it('returns 500 with fallback message when thrown value has no message', async () => { + mockExportAnalytics.mockRejectedValue({}); + const { GET } = await import('./route'); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); + + expect(res.status).toBe(500); + expect((await res.json()).error).toBe('Failed to export analytics'); + }); + + it('returns 401 when user is not authenticated', async () => { + mockGetUser.mockResolvedValue({ data: { user: null }, error: null }); + const { GET } = await import('./route'); + const res = await GET(makeExportRequest(), { params: { id: 'dep-1' } }); + + expect(res.status).toBe(401); + expect(mockExportAnalytics).not.toHaveBeenCalled(); + }); }); diff --git a/apps/backend/src/services/github.backoff.test.ts b/apps/backend/src/services/github.backoff.test.ts index 4d8aae76..3f277b8a 100644 --- a/apps/backend/src/services/github.backoff.test.ts +++ b/apps/backend/src/services/github.backoff.test.ts @@ -1,6 +1,14 @@ /** * Tests for withGitHubRetry — bounded exponential backoff for GitHub API calls. * + * Retry strategy: + * - Up to 3 retries (4 total attempts) on RATE_LIMITED or NETWORK_ERROR. + * - When GitHub supplies a Retry-After header the value is used as the delay floor. + * - Otherwise full-jitter exponential backoff is applied: + * delay = Math.random() * min(BASE_MS * 2^attempt, 32_000) + * where BASE_MS = 1_000 ms and the hard cap is 32_000 ms. + * - Terminal errors (AUTH_FAILED, COLLISION, UNKNOWN) are re-thrown immediately. + * * Covers: * - Succeeds on first attempt (no retries) * - Retries RATE_LIMITED and recovers @@ -10,6 +18,9 @@ * - Exhausts retries and re-throws the last error * - Does NOT retry terminal errors (AUTH_FAILED, COLLISION, UNKNOWN) * - Logs a warning on each retry + * - Full-jitter is applied: delay scales with Math.random() + * - Backoff grows exponentially across successive attempts + * - Backoff is capped at 32_000 ms * - createRepository retries transparently and returns on recovery * - createRepository still surfaces COLLISION after name retries */ @@ -263,3 +274,104 @@ describe('GitHubService.createRepository — backoff integration', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); }); + +// ── withGitHubRetry — jitter and exponential backoff invariants ─────────────── + +describe('withGitHubRetry — jitter and exponential backoff invariants', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('delay scales with Math.random() output (full-jitter)', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const delays: number[] = []; + const sleep = (ms: number) => { delays.push(ms); return Promise.resolve(); }; + + const fn = vi + .fn() + .mockRejectedValueOnce(makeError('RATE_LIMITED')) + .mockRejectedValueOnce(makeError('RATE_LIMITED')) + .mockResolvedValue('ok'); + + await withGitHubRetry(fn, sleep); + + // attempt 0: 0.5 * min(1_000 * 2^0, 32_000) = 0.5 * 1_000 = 500 + // attempt 1: 0.5 * min(1_000 * 2^1, 32_000) = 0.5 * 2_000 = 1_000 + expect(delays[0]).toBe(500); + expect(delays[1]).toBe(1_000); + }); + + it('delay is 0 when Math.random returns 0 (minimum jitter)', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const delays: number[] = []; + const sleep = (ms: number) => { delays.push(ms); return Promise.resolve(); }; + + const fn = vi + .fn() + .mockRejectedValueOnce(makeError('RATE_LIMITED')) + .mockResolvedValue('ok'); + + await withGitHubRetry(fn, sleep); + expect(delays[0]).toBe(0); + }); + + it('backoff ceiling grows across successive retry attempts (exponential growth)', async () => { + vi.spyOn(Math, 'random').mockReturnValue(1); + const delays: number[] = []; + const sleep = (ms: number) => { delays.push(ms); return Promise.resolve(); }; + + const fn = vi + .fn() + .mockRejectedValueOnce(makeError('RATE_LIMITED')) + .mockRejectedValueOnce(makeError('RATE_LIMITED')) + .mockResolvedValue('ok'); + + await withGitHubRetry(fn, sleep); + + // With Math.random()=1 the delay equals the cap for each attempt. + // attempt 0 cap: min(1000*1, 32000) = 1_000 + // attempt 1 cap: min(1000*2, 32000) = 2_000 + expect(delays[1]).toBeGreaterThan(delays[0]); + }); + + it('delay is bounded at 32_000 ms regardless of attempt number', async () => { + vi.spyOn(Math, 'random').mockReturnValue(1); + const delays: number[] = []; + const sleep = (ms: number) => { delays.push(ms); return Promise.resolve(); }; + + const fn = vi.fn().mockRejectedValue(makeError('RATE_LIMITED')); + await withGitHubRetry(fn, sleep).catch(() => {}); + + for (const d of delays) { + expect(d).toBeLessThanOrEqual(32_000); + } + }); + + it('total attempt count is 1 initial + 3 retries = 4 when all attempts fail', async () => { + const fn = vi.fn().mockRejectedValue(makeError('RATE_LIMITED')); + await withGitHubRetry(fn, noSleep).catch(() => {}); + expect(fn).toHaveBeenCalledTimes(4); + }); + + it('total attempt count is 1 when fn succeeds immediately (no retry slot consumed)', async () => { + const fn = vi.fn().mockResolvedValue('success'); + await withGitHubRetry(fn, noSleep); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('Retry-After takes precedence over jitter: jitter is not applied when Retry-After is present', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const delays: number[] = []; + const sleep = (ms: number) => { delays.push(ms); return Promise.resolve(); }; + + const fn = vi + .fn() + .mockRejectedValueOnce(makeError('RATE_LIMITED', 7_000)) + .mockResolvedValue('ok'); + + await withGitHubRetry(fn, sleep); + + // Retry-After of 7_000 ms must be honoured exactly, not multiplied by Math.random() + expect(delays[0]).toBe(7_000); + }); +}); diff --git a/apps/backend/src/services/github.service.ts b/apps/backend/src/services/github.service.ts index 644bb03d..869556b9 100644 --- a/apps/backend/src/services/github.service.ts +++ b/apps/backend/src/services/github.service.ts @@ -43,11 +43,16 @@ const BACKOFF_BASE_MS = 1_000; const BACKOFF_MAX_MS = 32_000; /** - * Executes `fn`, retrying up to MAX_RATE_LIMIT_RETRIES times on RATE_LIMITED - * or NETWORK_ERROR responses with bounded exponential backoff. + * Executes `fn`, retrying up to MAX_RATE_LIMIT_RETRIES (3) times on RATE_LIMITED + * or NETWORK_ERROR responses, for a maximum of 4 total attempts. * - * When GitHub supplies a `Retry-After` value it is used as the delay floor; - * otherwise full-jitter exponential backoff is applied. + * Retry strategy — delay per attempt: + * 1. When GitHub returns a `Retry-After` header its value (in seconds) is used + * as-is (converted to milliseconds) — honouring the server's back-pressure. + * 2. Otherwise full-jitter exponential backoff is applied: + * delay = Math.random() * min(BACKOFF_BASE_MS * 2^attempt, BACKOFF_MAX_MS) + * where BACKOFF_BASE_MS = 1_000 ms and BACKOFF_MAX_MS = 32_000 ms. + * Full jitter spreads retries across the window to avoid thundering-herd spikes. * * Non-retryable errors (AUTH_FAILED, COLLISION, UNKNOWN) are re-thrown * immediately without consuming a retry slot. diff --git a/apps/backend/src/services/template-generator.service.property.test.ts b/apps/backend/src/services/template-generator.service.property.test.ts new file mode 100644 index 00000000..0dde46e2 --- /dev/null +++ b/apps/backend/src/services/template-generator.service.property.test.ts @@ -0,0 +1,318 @@ +/** + * Property-based tests for TemplateGeneratorService — output determinism. + * + * Determinism guarantees: + * - Identical inputs always produce byte-identical generatedFiles output. + * - No live timestamps or random UUIDs are injected into generated file content. + * - Output is stable across repeated calls with the same request. + * + * Non-deterministic sources neutralised by this suite: + * - crypto.randomUUID() (used for cloning runId) → stubbed to a fixed value. + * - new Date().toISOString() (used for artifactMetadata.generatedAt) → frozen. + * + * Runs ≥ 200 iterations per property (numRuns: 200). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fc from 'fast-check'; +import { TemplateGeneratorService } from './template-generator.service'; +import type { Template, GeneratedFile } from '@craft/types'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const FIXED_UUID = '00000000-0000-0000-0000-000000000000'; +const FIXED_TIMESTAMP = '2025-01-01T00:00:00.000Z'; +const MOCK_WORKSPACE = '/tmp/workspace-fixed'; + +/** Matches ISO 8601 timestamps that would indicate a live clock leak. */ +const ISO_TIMESTAMP_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; + +/** Matches UUID v4 patterns that would indicate a random-source leak. */ +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +// ── Arbitraries ─────────────────────────────────────────────────────────────── + +// Primary and secondary color sets are disjoint so they are never equal, +// which satisfies the "secondaryColor must differ from primary" business rule. +const PRIMARY_COLORS = ['#007bff', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#3b82f6'] as const; +const SECONDARY_COLORS = ['#6c757d', '#06b6d4', '#ec4899', '#14b8a6', '#64748b', '#94a3b8'] as const; +const APP_NAMES = ['Stellar DEX', 'DeFi Protocol', 'Token Issuer', 'Payment Hub', 'Asset Exchange', 'Soroban App'] as const; +const TEMPLATE_IDS = ['tmpl-dex-001', 'tmpl-lending-002', 'tmpl-payment-003', 'tmpl-asset-004', 'tmpl-defi-005'] as const; +const TEMPLATE_CATEGORIES = ['dex', 'lending', 'payment', 'asset-issuance'] as const; + +const arbTemplateId = fc.constantFrom(...TEMPLATE_IDS); +const arbCategory = fc.constantFrom(...TEMPLATE_CATEGORIES); + +const arbCustomization = fc.record({ + branding: fc.record({ + appName: fc.constantFrom(...APP_NAMES), + primaryColor: fc.constantFrom(...PRIMARY_COLORS), + secondaryColor: fc.constantFrom(...SECONDARY_COLORS), + fontFamily: fc.constantFrom('Inter', 'Roboto', 'Open Sans', 'Poppins'), + }), + features: fc.record({ + enableCharts: fc.boolean(), + enableTransactionHistory: fc.boolean(), + enableAnalytics: fc.boolean(), + enableNotifications: fc.boolean(), + }), + stellar: fc.record({ + network: fc.constant('testnet' as const), + horizonUrl: fc.constant('https://horizon-testnet.stellar.org'), + sorobanRpcUrl: fc.constant(undefined as undefined), + assetPairs: fc.constant(undefined as undefined), + contractAddresses: fc.constant(undefined as undefined), + }), +}); + +const arbRequest = fc.record({ + templateId: arbTemplateId, + customization: arbCustomization, + outputPath: fc.constant('/tmp/output'), +}); + +// ── Service factory ─────────────────────────────────────────────────────────── + +/** + * Creates a TemplateGeneratorService with fully-deterministic mocked dependencies. + * The code generator produces file content that is a pure function of its inputs + * so the only non-determinism to guard against is what the service itself introduces. + */ +function makeService(category: string = 'dex') { + const templateService = { + getTemplate: vi.fn().mockResolvedValue({ + id: 'tmpl-fixed', + name: 'Determinism Test Template', + description: 'Template used for determinism property tests', + category, + blockchainType: 'stellar', + baseRepositoryUrl: '/tmp/template-source', + previewImageUrl: 'https://example.com/preview.png', + features: [], + customizationSchema: {}, + isActive: true, + createdAt: new Date(FIXED_TIMESTAMP), + } as Template), + }; + + const codeGeneratorService = { + generate: vi.fn().mockImplementation( + ({ templateId, templateFamily }: { templateId: string; templateFamily: string }) => ({ + success: true, + generatedFiles: [ + { + path: 'src/index.ts', + content: `// template: ${templateId}\n// family: ${templateFamily}\nexport const config = {};`, + type: 'code', + } as GeneratedFile, + { + path: 'src/constants.ts', + content: `export const TEMPLATE_ID = '${templateId}';\nexport const FAMILY = '${templateFamily}';`, + type: 'code', + } as GeneratedFile, + ], + errors: [], + }), + ), + }; + + const cloningService = { + clone: vi.fn().mockResolvedValue({ + success: true, + workspacePath: MOCK_WORKSPACE, + errors: [], + }), + }; + + const syntaxValidator = { + validate: vi.fn().mockReturnValue({ valid: true, errors: [] }), + }; + + return new TemplateGeneratorService( + templateService as any, + codeGeneratorService as any, + cloningService as any, + syntaxValidator as any, + ); +} + +// ── Property tests ──────────────────────────────────────────────────────────── + +describe('TemplateGeneratorService — output determinism', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(FIXED_TIMESTAMP)); + vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue(FIXED_UUID) }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + // ── Byte-identical output for identical inputs ───────────────────────────── + + it('identical inputs produce byte-identical generatedFiles across 200+ arbitrary configs', async () => { + await fc.assert( + fc.asyncProperty(arbRequest, arbCategory, async (request, category) => { + const service = makeService(category); + + const result1 = await service.generate(request); + const result2 = await service.generate(request); + + expect(result1.success).toBe(true); + expect(result2.success).toBe(true); + expect(result1.generatedFiles).toEqual(result2.generatedFiles); + }), + { numRuns: 200 }, + ); + }); + + it('generatedFiles path list is identical across two calls with the same request', async () => { + await fc.assert( + fc.asyncProperty(arbRequest, async (request) => { + const service = makeService(); + + const result1 = await service.generate(request); + const result2 = await service.generate(request); + + const paths1 = result1.generatedFiles.map((f) => f.path).sort(); + const paths2 = result2.generatedFiles.map((f) => f.path).sort(); + + expect(paths1).toEqual(paths2); + }), + { numRuns: 200 }, + ); + }); + + it('generatedFiles file content is byte-identical across two calls with the same request', async () => { + await fc.assert( + fc.asyncProperty(arbRequest, async (request) => { + const service = makeService(); + + const result1 = await service.generate(request); + const result2 = await service.generate(request); + + for (let i = 0; i < result1.generatedFiles.length; i++) { + expect(result1.generatedFiles[i].content).toBe(result2.generatedFiles[i].content); + } + }), + { numRuns: 200 }, + ); + }); + + // ── No timestamps or random IDs in generated file content ───────────────── + + it('generated file content contains no ISO 8601 timestamp patterns', async () => { + await fc.assert( + fc.asyncProperty(arbRequest, async (request) => { + const service = makeService(); + const result = await service.generate(request); + + expect(result.success).toBe(true); + for (const file of result.generatedFiles) { + expect(file.content).not.toMatch(ISO_TIMESTAMP_RE); + } + }), + { numRuns: 200 }, + ); + }); + + it('generated file content contains no random UUID patterns', async () => { + await fc.assert( + fc.asyncProperty(arbRequest, async (request) => { + const service = makeService(); + const result = await service.generate(request); + + expect(result.success).toBe(true); + for (const file of result.generatedFiles) { + expect(file.content).not.toMatch(UUID_RE); + } + }), + { numRuns: 200 }, + ); + }); + + it('artifactMetadata.generatedAt is the frozen timestamp, not a live clock value', async () => { + await fc.assert( + fc.asyncProperty(arbRequest, async (request) => { + const service = makeService(); + const result = await service.generate(request); + + expect(result.success).toBe(true); + expect(result.artifactMetadata!.generatedAt).toBe(FIXED_TIMESTAMP); + }), + { numRuns: 200 }, + ); + }); + + // ── Output stability across multiple generation runs ────────────────────── + + it('output is stable: success:true always yields at least one generated file', async () => { + await fc.assert( + fc.asyncProperty(arbRequest, async (request) => { + const service = makeService(); + const result = await service.generate(request); + + expect(result.success).toBe(true); + expect(result.generatedFiles.length).toBeGreaterThan(0); + expect(result.artifactMetadata!.fileCount).toBe(result.generatedFiles.length); + }), + { numRuns: 200 }, + ); + }); + + it('artifactMetadata.fileCount always equals generatedFiles.length across 200+ configs', async () => { + await fc.assert( + fc.asyncProperty(arbRequest, async (request) => { + const service = makeService(); + const result = await service.generate(request); + + expect(result.artifactMetadata!.fileCount).toBe(result.generatedFiles.length); + }), + { numRuns: 200 }, + ); + }); + + it('output for different templateIds produces different file content', async () => { + await fc.assert( + fc.asyncProperty( + arbCustomization, + arbCategory, + fc.integer({ min: 0, max: TEMPLATE_IDS.length - 2 }), + async (customization, category, idx) => { + const id1 = TEMPLATE_IDS[idx]; + const id2 = TEMPLATE_IDS[idx + 1]; + + const service = makeService(category); + + const result1 = await service.generate({ templateId: id1, customization, outputPath: '/tmp' }); + const result2 = await service.generate({ templateId: id2, customization, outputPath: '/tmp' }); + + // Different templateIds must produce different file content + const contents1 = result1.generatedFiles.map((f) => f.content).join('\n'); + const contents2 = result2.generatedFiles.map((f) => f.content).join('\n'); + + expect(contents1).not.toBe(contents2); + }, + ), + { numRuns: 200 }, + ); + }); + + it('crypto.randomUUID is called for the clone runId but its value does not appear in file content', async () => { + await fc.assert( + fc.asyncProperty(arbRequest, async (request) => { + const service = makeService(); + const result = await service.generate(request); + + expect(result.success).toBe(true); + for (const file of result.generatedFiles) { + expect(file.content).not.toContain(FIXED_UUID); + } + }), + { numRuns: 200 }, + ); + }); +});