From 895e06187d48701cb57b7b84a88bd1bc4748fe0e Mon Sep 17 00:00:00 2001 From: thiennatra Date: Wed, 15 Apr 2026 15:48:59 +0700 Subject: [PATCH] fix: call API CORS --- apps/tracking-api/src/app.ts | 48 ++++++++++++++ apps/tracking-api/src/config.ts | 9 +++ .../test/admin-events-route.test.ts | 54 ++++++++++++--- .../test/security/admin-auth.test.ts | 3 + apps/tracking-api/test/track-route.test.ts | 65 +++++++++++++++++++ 5 files changed, 171 insertions(+), 8 deletions(-) diff --git a/apps/tracking-api/src/app.ts b/apps/tracking-api/src/app.ts index eb5693c..f5e472e 100644 --- a/apps/tracking-api/src/app.ts +++ b/apps/tracking-api/src/app.ts @@ -45,6 +45,9 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance { nodeEnv: 'test', port: 0, databaseUrl: '', + cors: { + allowOrigins: [] + }, observability: { metricsEnabled: true }, @@ -74,6 +77,51 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance { }; const appConfig = resolveConfig(); + const corsAllowOrigins = appConfig.cors.allowOrigins; + + const resolveCorsOrigin = (originHeader: string | undefined): string | null => { + if (!originHeader || corsAllowOrigins.length === 0) { + return null; + } + + if (corsAllowOrigins.includes('*')) { + return '*'; + } + + return corsAllowOrigins.includes(originHeader) ? originHeader : null; + }; + + const applyTrackCorsHeaders = (request: FastifyRequest, reply: FastifyReply): void => { + const originHeader = typeof request.headers.origin === 'string' ? request.headers.origin : undefined; + const corsOrigin = resolveCorsOrigin(originHeader); + + if (!corsOrigin) { + return; + } + + reply.header('access-control-allow-origin', corsOrigin); + if (corsOrigin !== '*') { + reply.header('vary', 'origin'); + } + reply.header('access-control-allow-methods', 'POST,OPTIONS'); + reply.header( + 'access-control-allow-headers', + 'content-type,x-tracking-secret,x-tracking-timestamp,x-tracking-signature' + ); + reply.header('access-control-max-age', '86400'); + }; + + app.options('/track', async (request, reply) => { + applyTrackCorsHeaders(request, reply); + return reply.code(204).send(); + }); + + app.addHook('onSend', async (request, reply) => { + if ((request.raw.url ?? '').startsWith('/track')) { + applyTrackCorsHeaders(request, reply); + } + }); + const eventRepository = options.eventRepository ?? new PostgresEventRepository(getDbPool(appConfig.databaseUrl)); const eventReadRepository = options.eventReadRepository ?? new PostgresEventReadRepository(getDbPool(appConfig.databaseUrl)); diff --git a/apps/tracking-api/src/config.ts b/apps/tracking-api/src/config.ts index 7ea1e93..31441f4 100644 --- a/apps/tracking-api/src/config.ts +++ b/apps/tracking-api/src/config.ts @@ -9,6 +9,9 @@ export type AppConfig = { nodeEnv: string; port: number; databaseUrl: string; + cors: { + allowOrigins: string[]; + }; observability: { metricsEnabled: boolean; }; @@ -122,6 +125,12 @@ export function getConfig(): AppConfig { nodeEnv, port, databaseUrl, + cors: { + allowOrigins: (process.env.TRACKING_CORS_ALLOW_ORIGINS ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + }, observability: { metricsEnabled: parseBoolean(process.env.METRICS_ENABLED, true) }, diff --git a/apps/tracking-api/test/admin-events-route.test.ts b/apps/tracking-api/test/admin-events-route.test.ts index 7fc460b..78e4312 100644 --- a/apps/tracking-api/test/admin-events-route.test.ts +++ b/apps/tracking-api/test/admin-events-route.test.ts @@ -1,7 +1,37 @@ import { describe, expect, it } from 'vitest'; import { buildApp } from '../src/app.js'; +import type { AppConfig } from '../src/config.js'; import { createEventDetail, createEventListItem, InMemoryEventReadRepository, RecordingDeliveryJobDispatcher } from './support/fakes.js'; +function buildTestConfig(overrides: Partial = {}): AppConfig { + return { + nodeEnv: 'test', + port: 0, + databaseUrl: 'postgres://unused', + cors: { + allowOrigins: [] + }, + observability: { + metricsEnabled: true + }, + security: { + authMode: 'off', + adminApiToken: undefined, + signatureSkewSeconds: 300, + rateLimit: { + enabled: false, + windowMs: 60_000, + maxRequests: 100 + } + }, + routerQueue: { + queueName: 'router-deliveries', + redis: {} + }, + ...overrides + }; +} + describe('GET /admin/events', () => { it('returns recent events with paging metadata', async () => { const app = buildApp({ @@ -24,7 +54,8 @@ describe('GET /admin/events', () => { ] } }), - deliveryJobDispatcher: new RecordingDeliveryJobDispatcher() + deliveryJobDispatcher: new RecordingDeliveryJobDispatcher(), + config: buildTestConfig() }); const response = await app.inject({ @@ -57,7 +88,8 @@ describe('GET /admin/events', () => { it('clamps invalid paging query params', async () => { const app = buildApp({ eventReadRepository: new InMemoryEventReadRepository(), - deliveryJobDispatcher: new RecordingDeliveryJobDispatcher() + deliveryJobDispatcher: new RecordingDeliveryJobDispatcher(), + config: buildTestConfig() }); const response = await app.inject({ @@ -81,7 +113,8 @@ describe('GET /admin/events', () => { const repository = new InMemoryEventReadRepository(); const app = buildApp({ eventReadRepository: repository, - deliveryJobDispatcher: new RecordingDeliveryJobDispatcher() + deliveryJobDispatcher: new RecordingDeliveryJobDispatcher(), + config: buildTestConfig() }); const response = await app.inject({ @@ -117,7 +150,8 @@ describe('GET /admin/events/:event_id', () => { }); const app = buildApp({ eventReadRepository: repository, - deliveryJobDispatcher: new RecordingDeliveryJobDispatcher() + deliveryJobDispatcher: new RecordingDeliveryJobDispatcher(), + config: buildTestConfig() }); const response = await app.inject({ @@ -152,7 +186,8 @@ describe('GET /admin/events/:event_id', () => { it('returns not found for an unknown event', async () => { const app = buildApp({ eventReadRepository: new InMemoryEventReadRepository({ eventById: null }), - deliveryJobDispatcher: new RecordingDeliveryJobDispatcher() + deliveryJobDispatcher: new RecordingDeliveryJobDispatcher(), + config: buildTestConfig() }); const response = await app.inject({ @@ -182,7 +217,8 @@ describe('POST /admin/events/:event_id/replay', () => { const dispatcher = new RecordingDeliveryJobDispatcher(); const app = buildApp({ eventReadRepository: repository, - deliveryJobDispatcher: dispatcher + deliveryJobDispatcher: dispatcher, + config: buildTestConfig() }); const response = await app.inject({ @@ -220,7 +256,8 @@ describe('POST /admin/events/:event_id/replay', () => { const dispatcher = new RecordingDeliveryJobDispatcher(); const app = buildApp({ eventReadRepository: repository, - deliveryJobDispatcher: dispatcher + deliveryJobDispatcher: dispatcher, + config: buildTestConfig() }); const response = await app.inject({ @@ -249,7 +286,8 @@ describe('POST /admin/events/:event_id/replay', () => { eventReadRepository: new InMemoryEventReadRepository({ eventById: createEventDetail() }), - deliveryJobDispatcher: new RecordingDeliveryJobDispatcher() + deliveryJobDispatcher: new RecordingDeliveryJobDispatcher(), + config: buildTestConfig() }); const response = await app.inject({ diff --git a/apps/tracking-api/test/security/admin-auth.test.ts b/apps/tracking-api/test/security/admin-auth.test.ts index 8588bde..8184fe3 100644 --- a/apps/tracking-api/test/security/admin-auth.test.ts +++ b/apps/tracking-api/test/security/admin-auth.test.ts @@ -8,6 +8,9 @@ function buildTestConfig(overrides: Partial = {}): AppConfig { nodeEnv: 'development', port: 0, databaseUrl: 'postgres://unused', + cors: { + allowOrigins: [] + }, observability: { metricsEnabled: true }, diff --git a/apps/tracking-api/test/track-route.test.ts b/apps/tracking-api/test/track-route.test.ts index 0eefa8f..72f7bc2 100644 --- a/apps/tracking-api/test/track-route.test.ts +++ b/apps/tracking-api/test/track-route.test.ts @@ -19,6 +19,9 @@ describe('POST /track', () => { nodeEnv: 'test', port: 0, databaseUrl: 'postgres://unused', + cors: { + allowOrigins: [] + }, observability: { metricsEnabled: true }, @@ -259,6 +262,68 @@ describe('POST /track', () => { await app.close(); }); + it('handles CORS preflight for allowed origin on /track', async () => { + const app = buildApp({ + eventRepository: repository, + deliveryJobDispatcher: new RecordingDeliveryJobDispatcher(), + logger: createLoggerSpy(), + config: buildTestConfig({ + cors: { + allowOrigins: ['http://localhost:5173'] + } + }) + }); + + const response = await app.inject({ + method: 'OPTIONS', + url: '/track', + headers: { + origin: 'http://localhost:5173' + } + }); + + expect(response.statusCode).toBe(204); + expect(response.headers['access-control-allow-origin']).toBe('http://localhost:5173'); + expect(response.headers['access-control-allow-methods']).toBe('POST,OPTIONS'); + expect(response.headers['access-control-allow-headers']).toContain('x-tracking-secret'); + + await app.close(); + }); + + it('returns CORS headers on POST /track for allowed origin', async () => { + const app = buildApp({ + eventRepository: repository, + deliveryJobDispatcher: new RecordingDeliveryJobDispatcher(), + logger: createLoggerSpy(), + config: buildTestConfig({ + cors: { + allowOrigins: ['http://localhost:5173'] + } + }) + }); + + const response = await app.inject({ + method: 'POST', + url: '/track', + headers: { + 'content-type': 'application/json', + origin: 'http://localhost:5173' + }, + payload: { + event_id: 'evt-cors-001', + event_name: 'purchase', + session: { + session_id: 'sess-cors-001' + } + } + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['access-control-allow-origin']).toBe('http://localhost:5173'); + + await app.close(); + }); + it('rate limits excessive requests when limiter is enabled', async () => { const deliveryJobDispatcher = new RecordingDeliveryJobDispatcher(); const logger = createLoggerSpy();