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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
"demo:seed": "bun run scripts/demo-seed.ts",
"demo:media": "bun run scripts/capture-demo-media.ts",
"demo:screenshots": "bun run scripts/capture-demo-media.ts --mode=screenshots",
"demo:video": "bun run scripts/capture-demo-media.ts --mode=video"
"demo:video": "bun run scripts/capture-demo-media.ts --mode=video",
"rc:smoke": "bun run scripts/rc-smoke.ts"
},
"dependencies": {
"ioredis": "^5.10.0",
Expand Down
172 changes: 172 additions & 0 deletions scripts/rc-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!/usr/bin/env bun

import crypto from 'node:crypto';
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';

const PORT = Number(process.env.RC_SMOKE_PORT || '3000');
const BASE_URL = `http://localhost:${PORT}`;
const ADMIN_API_KEY = process.env.ADMIN_API_KEY || 'dev-admin-key-12345';
const SIGNING_SECRET = process.env.DEVICE_WEBHOOK_SECRET || process.env.BACKEND_SIGNING_SECRET || 'dev-secret';

const localEnv = {
...process.env,
PORT: String(PORT),
NODE_ENV: process.env.NODE_ENV || 'development',
ADMIN_API_KEY,
DEVICE_WEBHOOK_SECRET: SIGNING_SECRET,
BACKEND_SIGNING_SECRET: SIGNING_SECRET,
UNKNOWN_POSTURE_MODE: process.env.UNKNOWN_POSTURE_MODE || 'allow',
DISABLE_OUTBOUND_WEBHOOKS: '1',
DEMO_MODE: '1',
};

type Check = { name: string; ok: boolean; detail?: string };
const checks: Check[] = [];

function record(name: string, ok: boolean, detail?: string) {
checks.push({ name, ok, detail });
const status = ok ? '✅' : '❌';
console.log(`${status} ${name}${detail ? ` - ${detail}` : ''}`);
}

async function sleep(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}

async function jsonFetch(path: string, init?: RequestInit) {
const response = await fetch(`${BASE_URL}${path}`, init);
let body: any = null;
try { body = await response.json(); } catch {}
return { response, body };
}

async function isHealthy() {
try {
const r = await fetch(`${BASE_URL}/api/health`);
return r.ok;
} catch {
return false;
}
}

async function waitForHealthy(timeoutMs = 90_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (await isHealthy()) return true;
await sleep(1000);
}
return false;
}

async function ensureServer(): Promise<{ child: ChildProcess | null; started: boolean }> {
if (await isHealthy()) return { child: null, started: false };
const child = spawn('npm', ['run', 'dev'], {
cwd: process.cwd(),
env: localEnv,
detached: true,
stdio: 'ignore',
});
child.unref();
const healthy = await waitForHealthy();
if (!healthy) throw new Error('Local server did not become healthy in time');
return { child, started: true };
}

async function stopServer(child: ChildProcess | null, started: boolean) {
if (!started) return;
try {
if (child?.pid) process.kill(-child.pid, 'SIGTERM');
} catch {}
try {
await Bun.$`pkill -f 'next dev' || true`.quiet();
} catch {}
}

function sign(payload: unknown) {
return crypto.createHmac('sha256', SIGNING_SECRET).update(JSON.stringify(payload)).digest('hex');
}

async function run() {
let server: ChildProcess | null = null;
let started = false;

try {
const ensured = await ensureServer();
server = ensured.child;
started = ensured.started;
record('local server available', true, started ? 'started by rc-smoke' : 'reused existing');

const health = await jsonFetch('/api/health');
record('/api/health returns healthy', health.response.ok && (health.body?.ok === true || health.body?.status === 'healthy'));

for (const scenario of ['all', 'compliant', 'non-compliant', 'unknown']) {
const result = await jsonFetch(`/api/demo/verify?scenario=${scenario}`);
record(`/api/demo/verify?scenario=${scenario} returns PASS`, result.response.ok && result.body?.status === 'PASS');
}

const v1Health = await jsonFetch('/api/v1/health');
record('/api/v1/health returns healthy without auth', v1Health.response.ok && v1Health.body?.status === 'healthy');

const devicesNoAuth = await jsonFetch('/api/v1/devices');
record('/api/v1/devices fails without API key', devicesNoAuth.response.status === 401);

const devicesWithAuth = await jsonFetch('/api/v1/devices', { headers: { 'x-api-key': ADMIN_API_KEY } });
record('/api/v1/devices succeeds with ADMIN_API_KEY', devicesWithAuth.response.status === 200);

const minimumPayload = {
eventId: `evt-${Date.now()}`,
timestamp: new Date().toISOString(),
badge: { badgeId: 'badge-001', employeeId: 'emp-001', cardSerialNumber: 'csn-001' },
device: { deviceId: 'device-001', deviceSerial: 'serial-001' },
reader: { readerType: 'BLE' },
context: { locationId: 'loc-001' },
};

const missingSig = await jsonFetch('/api/v1/session/start', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(minimumPayload),
});
record('/api/v1/session/start rejects missing signature', missingSig.response.status === 401);

const malformedPayload = {
timestamp: new Date().toISOString(),
nonce: `malformed-${Date.now()}`,
reader: { readerType: 'BLE' },
context: { locationId: 'loc-001' },
};
const malformedSig = sign(malformedPayload);
const malformed = await jsonFetch('/api/v1/session/start', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-signature': malformedSig },
body: JSON.stringify(malformedPayload),
});
record('/api/v1/session/start rejects signed malformed payload with INVALID_SESSION_START_PAYLOAD', malformed.response.status === 400 && malformed.body?.code === 'INVALID_SESSION_START_PAYLOAD');

const validSig = sign(minimumPayload);
const valid = await jsonFetch('/api/v1/session/start', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-signature': validSig },
body: JSON.stringify(minimumPayload),
});
record('/api/v1/session/start accepts valid signed minimum payload', valid.response.status === 200);

await Bun.$`npm run demo:media`.env(localEnv);
record('demo media generation produced artifacts/demo-media/capture-report.json', existsSync('artifacts/demo-media/capture-report.json'));
} finally {
await stopServer(server, started);
}

const failed = checks.filter((c) => !c.ok);
if (failed.length > 0) {
console.error(`\n❌ rc:smoke failed (${failed.length} checks).`);
process.exit(1);
}
console.log(`\n✅ rc:smoke passed (${checks.length} checks).`);
}

run().catch((error) => {
console.error(`❌ rc:smoke error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
14 changes: 14 additions & 0 deletions src/app/api/v1/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ function isFreshTimestamp(timestamp: unknown) {
return Number.isFinite(time) && Math.abs(Date.now() - time) <= 5 * 60 * 1000;
}


function isValidSessionStartPayload(payload: unknown) {
if (!payload || typeof payload !== "object") return false;
const p = payload as { device?: { deviceId?: unknown }; badge?: { badgeId?: unknown; badgeUid?: unknown } };
const hasDeviceId = typeof p.device?.deviceId === "string" && p.device.deviceId.trim().length > 0;
const hasBadgeId = typeof p.badge?.badgeId === "string" && p.badge.badgeId.trim().length > 0;
const hasBadgeUid = typeof p.badge?.badgeUid === "string" && p.badge.badgeUid.trim().length > 0;
return hasDeviceId && (hasBadgeId || hasBadgeUid);
}

function getPath(params: { path?: string[] }) {
return `/${(params.path || []).join("/")}`;
}
Expand Down Expand Up @@ -271,6 +281,10 @@ export async function POST(request: NextRequest, context: RouteContext) {
}

if (path === "/session/start") {
if (!isValidSessionStartPayload(body)) {
return error(400, "INVALID_SESSION_START_PAYLOAD", "Session start payload must include device.deviceId and badge.badgeId or badge.badgeUid");
}

return json({
sessionId: crypto.randomUUID(),
decision: "ALLOW",
Expand Down
42 changes: 40 additions & 2 deletions tests/api/integration-v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Tests for public API endpoints and third-party integrations
*/

import crypto from 'node:crypto';
import { describe, it, expect, beforeAll } from 'vitest';

describe('API v1 - Public Endpoints', () => {
Expand All @@ -12,8 +13,6 @@ describe('API v1 - Public Endpoints', () => {
const signingSecret = process.env.DEVICE_WEBHOOK_SECRET || process.env.BACKEND_SIGNING_SECRET || 'dev-secret';

function signPayload(payload: unknown) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const crypto = require('crypto') as typeof import('crypto');
return crypto
.createHmac('sha256', signingSecret)
.update(JSON.stringify(payload))
Expand Down Expand Up @@ -228,6 +227,45 @@ describe('API v1 - Public Endpoints', () => {
expect(response.status).toBe(401);
});


it('should reject missing signature with 401', async () => {
const payload = {
device: { deviceId: 'test-device-123' },
badge: { badgeId: 'badge-001' },
timestamp: new Date().toISOString(),
};

const response = await fetch(`${baseUrl}/session/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});

expect(response.status).toBe(401);
});

it('should reject signed malformed payload with INVALID_SESSION_START_PAYLOAD', async () => {
const payload = {
timestamp: new Date().toISOString(),
nonce: 'malformed-' + Date.now(),
};

const signature = signPayload(payload);

const response = await fetch(`${baseUrl}/session/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': signature,
},
body: JSON.stringify(payload),
});

expect(response.status).toBe(400);
const data = await response.json() as { code?: string };
expect(data.code).toBe('INVALID_SESSION_START_PAYLOAD');
});

it('should enforce timestamp window', async () => {
const oldTimestamp = new Date(Date.now() - 6 * 60 * 1000).toISOString(); // 6 minutes ago

Expand Down
Loading