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
103 changes: 103 additions & 0 deletions .github/workflows/cdn-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: CDN Configuration Deploy

on:
push:
branches: [main, develop]
paths:
- 'infra/fastly/**'
- '.github/workflows/cdn-deploy.yml'
workflow_dispatch:
inputs:
provider:
description: 'CDN provider'
required: true
default: 'fastly'
type: choice
options:
- fastly
- cloudflare

env:
NODE_VERSION: '20'

jobs:
validate-vcl:
name: Validate Fastly VCL
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Check VCL syntax (basic)
run: |
test -f infra/fastly/snippets/recv.vcl
test -f infra/fastly/snippets/fetch.vcl
grep -q "s-maxage" infra/fastly/snippets/fetch.vcl
grep -q "/plans" infra/fastly/snippets/recv.vcl
echo "VCL snippet validation passed"

deploy-fastly:
name: Deploy Fastly VCL Snippet
needs: validate-vcl
if: >
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.provider == 'fastly')
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Deploy VCL snippet to Fastly
env:
FASTLY_API_TOKEN: ${{ secrets.FASTLY_API_TOKEN }}
FASTLY_SERVICE_ID: ${{ secrets.FASTLY_SERVICE_ID }}
run: |
if [ -z "$FASTLY_API_TOKEN" ] || [ -z "$FASTLY_SERVICE_ID" ]; then
echo "Fastly credentials not configured — skipping deploy (CI validation only)"
exit 0
fi
chmod +x scripts/deploy-fastly-vcl.sh
./scripts/deploy-fastly-vcl.sh infra/fastly/snippets

deploy-cloudflare:
name: Deploy Cloudflare Cache Rules
needs: validate-vcl
if: github.event_name == 'workflow_dispatch' && github.event.inputs.provider == 'cloudflare'
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Verify Cloudflare cache tag support
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
run: |
if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ZONE_ID" ]; then
echo "Cloudflare credentials not configured — skipping deploy"
exit 0
fi
echo "Cloudflare purge-by-tag configured via CDN_PROVIDER=cloudflare"
echo "Origin sets Cache-Tag header alongside Surrogate-Key"

run-cache-tests:
name: CDN Cache Unit Tests
needs: validate-vcl
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

- name: Install dependencies
run: npm ci --legacy-peer-deps

- name: Run CDN cache tests
run: npm run test:cdn
2 changes: 2 additions & 0 deletions backend/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Backend Jest setup – polyfill Expo/RN globals not present in Node. */
(globalThis as { __DEV__?: boolean }).__DEV__ = false;
51 changes: 51 additions & 0 deletions backend/server/createApiServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* SubTrackr public API HTTP server factory.
*
* Mounts CDN-cacheable routes behind edge-cache header middleware.
*/

import express, { type Express } from 'express';
import { cacheHeadersMiddleware } from '../shared/middleware';
import { createPublicApiRouter } from '../subscription/router/publicApiRouter';
import { API_VERSION_HEADER, API_VERSION_VALUE } from '../services/shared/apiResponse';

export interface CreateApiServerOptions {
/** Optional middleware applied before cache headers (e.g. auth). */
beforeCache?: express.RequestHandler[];
}

export function createApiServer(options: CreateApiServerOptions = {}): Express {
const app = express();

app.disable('x-powered-by');
app.use(express.json());

if (options.beforeCache) {
for (const mw of options.beforeCache) {
app.use(mw);
}
}

app.use((_req, res, next) => {
res.setHeader(API_VERSION_HEADER, API_VERSION_VALUE);
next();
});

app.use(cacheHeadersMiddleware());
app.use(createPublicApiRouter());

app.use((_req, res) => {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Not found' } });
});

return app;
}

export function startApiServer(port: number = Number(process.env.PORT ?? 3000)): Express {
const app = createApiServer();
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`SubTrackr API listening on port ${port}`);
});
return app;
}
2 changes: 2 additions & 0 deletions backend/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createApiServer, startApiServer } from './createApiServer';
export type { CreateApiServerOptions } from './createApiServer';
10 changes: 10 additions & 0 deletions backend/server/start.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Entry point for the SubTrackr public API server.
*
* Usage: npm run api:start
* Env: PORT (default 3000), CDN_PROVIDER, CDN_API_TOKEN, CDN_SERVICE_ID
*/

import { startApiServer } from './createApiServer';

startApiServer();
8 changes: 6 additions & 2 deletions backend/services/shared/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
error: 3,
};

// Change this via env later
const CURRENT_LEVEL: LogLevel = __DEV__ ? 'debug' : 'info';
// Change this via env later (__DEV__ is an Expo/RN global; absent in plain Node)
const CURRENT_LEVEL: LogLevel =
typeof (globalThis as { __DEV__?: boolean }).__DEV__ !== 'undefined' &&
(globalThis as { __DEV__?: boolean }).__DEV__
? 'debug'
: 'info';

// Correlation ID generator (simple version)
const generateId = () => {
Expand Down
193 changes: 193 additions & 0 deletions backend/shared/cache/__tests__/cdnPurgeClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* Tests for CDN purge API client (Fastly / Cloudflare).
*/

import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import {
CdnPurgeClient,
NoOpCdnPurgeClient,
createCdnPurgeClientFromEnv,
resetCdnPurgeClient,
purgeSurrogateKeys,
} from '../cdnPurgeClient';

function mockFetch(status: number, body = ''): typeof fetch {
return jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
text: async () => body,
})) as unknown as typeof fetch;
}

// ── CdnPurgeClient – Fastly ───────────────────────────────────────────────────

describe('CdnPurgeClient – Fastly', () => {
it('sends purge request with Surrogate-Key header', async () => {
const fetchImpl = mockFetch(200);
const client = new CdnPurgeClient({
provider: 'fastly',
apiToken: 'test-token',
serviceId: 'svc-123',
fetchImpl,
});

const result = await client.purgeBySurrogateKeys(['plan', 'pricing']);

expect(result.success).toBe(true);
expect(result.provider).toBe('fastly');
expect(fetchImpl).toHaveBeenCalledWith(
'https://api.fastly.com/service/svc-123/purge',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Fastly-Key': 'test-token',
'Surrogate-Key': 'plan pricing',
}),
}),
);
});

it('logs error and returns failure on non-2xx response', async () => {
const fetchImpl = mockFetch(503, 'service unavailable');
const client = new CdnPurgeClient({
provider: 'fastly',
apiToken: 'test-token',
serviceId: 'svc-123',
fetchImpl,
});

const result = await client.purgeBySurrogateKeys(['plan']);

expect(result.success).toBe(false);
expect(result.statusCode).toBe(503);
expect(result.error).toContain('503');
});

it('returns success for empty key list without calling API', async () => {
const fetchImpl = mockFetch(200);
const client = new CdnPurgeClient({
provider: 'fastly',
apiToken: 'test-token',
serviceId: 'svc-123',
fetchImpl,
});

const result = await client.purgeBySurrogateKeys([]);

expect(result.success).toBe(true);
expect(fetchImpl).not.toHaveBeenCalled();
});
});

// ── CdnPurgeClient – Cloudflare ───────────────────────────────────────────────

describe('CdnPurgeClient – Cloudflare', () => {
it('sends purge request with cache tags', async () => {
const fetchImpl = mockFetch(200);
const client = new CdnPurgeClient({
provider: 'cloudflare',
apiToken: 'cf-token',
serviceId: 'zone-abc',
fetchImpl,
});

const result = await client.purgeBySurrogateKeys(['feature']);

expect(result.success).toBe(true);
expect(fetchImpl).toHaveBeenCalledWith(
'https://api.cloudflare.com/client/v4/zones/zone-abc/purge_cache',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ tags: ['feature'] }),
}),
);
});

it('handles network errors without throwing', async () => {
const fetchImpl = jest.fn(async () => {
throw new Error('network timeout');
}) as unknown as typeof fetch;

const client = new CdnPurgeClient({
provider: 'cloudflare',
apiToken: 'cf-token',
serviceId: 'zone-abc',
fetchImpl,
});

const result = await client.purgeBySurrogateKeys(['config']);

expect(result.success).toBe(false);
expect(result.error).toContain('network timeout');
});
});

// ── NoOpCdnPurgeClient ────────────────────────────────────────────────────────

describe('NoOpCdnPurgeClient', () => {
it('returns success without network calls', async () => {
const client = new NoOpCdnPurgeClient();
const result = await client.purgeBySurrogateKeys(['plan']);
expect(result.success).toBe(true);
});

it('logs warning when keys provided without CDN credentials', async () => {
const warnSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const client = new NoOpCdnPurgeClient();
await client.purgeBySurrogateKeys(['plan']);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('CDN purge skipped'));
warnSpy.mockRestore();
});

it('does not log when key list is empty', async () => {
const warnSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const client = new NoOpCdnPurgeClient();
await client.purgeBySurrogateKeys([]);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});

// ── createCdnPurgeClientFromEnv ─────────────────────────────────────────────────

describe('createCdnPurgeClientFromEnv', () => {
afterEach(() => {
resetCdnPurgeClient();
});

it('returns NoOp client when credentials are missing', () => {
const client = createCdnPurgeClientFromEnv({});
expect(client).toBeInstanceOf(NoOpCdnPurgeClient);
});

it('returns configured client when env vars are set', () => {
const client = createCdnPurgeClientFromEnv({
CDN_PROVIDER: 'fastly',
CDN_API_TOKEN: 'token',
CDN_SERVICE_ID: 'svc',
});
expect(client).toBeInstanceOf(CdnPurgeClient);
expect(client.provider).toBe('fastly');
});
});

// ── purgeSurrogateKeys ──────────────────────────────────────────────────────────

describe('purgeSurrogateKeys', () => {
beforeEach(() => {
resetCdnPurgeClient();
});

it('delegates to injected client', async () => {
const fetchImpl = mockFetch(200);
const client = new CdnPurgeClient({
provider: 'fastly',
apiToken: 'tok',
serviceId: 'svc',
fetchImpl,
});

const result = await purgeSurrogateKeys(['user'], client);
expect(result.success).toBe(true);
});
});
Loading