Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/tracking-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
nodeEnv: 'test',
port: 0,
databaseUrl: '',
cors: {
allowOrigins: []
},
observability: {
metricsEnabled: true
},
Expand Down Expand Up @@ -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));
Expand Down
9 changes: 9 additions & 0 deletions apps/tracking-api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export type AppConfig = {
nodeEnv: string;
port: number;
databaseUrl: string;
cors: {
allowOrigins: string[];
};
observability: {
metricsEnabled: boolean;
};
Expand Down Expand Up @@ -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)
},
Expand Down
54 changes: 46 additions & 8 deletions apps/tracking-api/test/admin-events-route.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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({
Expand All @@ -24,7 +54,8 @@ describe('GET /admin/events', () => {
]
}
}),
deliveryJobDispatcher: new RecordingDeliveryJobDispatcher()
deliveryJobDispatcher: new RecordingDeliveryJobDispatcher(),
config: buildTestConfig()
});

const response = await app.inject({
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
3 changes: 3 additions & 0 deletions apps/tracking-api/test/security/admin-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ function buildTestConfig(overrides: Partial<AppConfig> = {}): AppConfig {
nodeEnv: 'development',
port: 0,
databaseUrl: 'postgres://unused',
cors: {
allowOrigins: []
},
observability: {
metricsEnabled: true
},
Expand Down
65 changes: 65 additions & 0 deletions apps/tracking-api/test/track-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ describe('POST /track', () => {
nodeEnv: 'test',
port: 0,
databaseUrl: 'postgres://unused',
cors: {
allowOrigins: []
},
observability: {
metricsEnabled: true
},
Expand Down Expand Up @@ -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();
Expand Down
Loading