diff --git a/backend/src/diagnostics.test.ts b/backend/src/diagnostics.test.ts index 23314a7..4e7be03 100644 --- a/backend/src/diagnostics.test.ts +++ b/backend/src/diagnostics.test.ts @@ -1,27 +1,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; -import { getAllFlags } from './lib/feature-flags.js'; -import { getBufferedEvents, flushEvents } from './lib/telemetry.js'; +import { SignJWT } from 'jose'; import { config } from './lib/config.js'; -import { PRODUCT_ID, productConfig } from './lib/product-config.js'; +import { productConfig } from './lib/product-config.js'; +import { assertDiagnosticsAccess, diagnosticsRoutes } from './lib/diagnostics-routes.js'; let app: FastifyInstance; beforeAll(async () => { app = Fastify({ logger: false }); - app.get('/api/diagnostics/flags', async () => getAllFlags()); - app.get('/api/diagnostics/telemetry', async () => ({ events: getBufferedEvents() })); - app.post('/api/diagnostics/telemetry/flush', async () => ({ flushed: flushEvents().length })); - app.get('/api/diagnostics/config', async () => ({ - productId: PRODUCT_ID, - serviceName: config.SERVICE_NAME, - port: config.PORT, - nodeEnv: config.NODE_ENV, - dbProvider: config.DB_PROVIDER, - telemetryEnabled: config.TELEMETRY_ENABLED, - featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED, - })); + await diagnosticsRoutes(app); app.get('/health', async (req) => ({ status: 'ok', service: config.SERVICE_NAME, @@ -41,6 +30,18 @@ afterAll(async () => { await app.close(); }); +function makeToken(role: string) { + return new SignJWT({ + sub: 'diagnostics-user', + role, + type: 'access', + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('5m') + .sign(new TextEncoder().encode(config.JWT_SECRET)); +} + describe('diagnostics routes', () => { it('GET /api/diagnostics/flags returns feature flags', async () => { const res = await app.inject({ method: 'GET', url: '/api/diagnostics/flags' }); @@ -101,4 +102,29 @@ describe('diagnostics routes', () => { expect(typeof body.displayName).toBe('string'); expect(typeof body.backendPort).toBe('number'); }); + + it('keeps diagnostics open outside production for local smoke ergonomics', async () => { + await expect(assertDiagnosticsAccess({ headers: {} }, 'development')).resolves.toBeUndefined(); + await expect(assertDiagnosticsAccess({ headers: {} }, 'test')).resolves.toBeUndefined(); + }); + + it('requires auth for diagnostics in production', async () => { + await expect(assertDiagnosticsAccess({ headers: {} }, 'production')).rejects.toThrow('Unauthorized'); + }); + + it('allows production diagnostics for admin or owner roles only', async () => { + const adminToken = await makeToken('admin'); + const ownerToken = await makeToken('owner'); + const viewerToken = await makeToken('viewer'); + + await expect( + assertDiagnosticsAccess({ headers: { authorization: `Bearer ${adminToken}` } }, 'production') + ).resolves.toBeUndefined(); + await expect( + assertDiagnosticsAccess({ headers: { authorization: `Bearer ${ownerToken}` } }, 'production') + ).resolves.toBeUndefined(); + await expect( + assertDiagnosticsAccess({ headers: { authorization: `Bearer ${viewerToken}` } }, 'production') + ).rejects.toThrow('Insufficient permissions'); + }); }); diff --git a/backend/src/lib/diagnostics-routes.ts b/backend/src/lib/diagnostics-routes.ts new file mode 100644 index 0000000..2706208 --- /dev/null +++ b/backend/src/lib/diagnostics-routes.ts @@ -0,0 +1,53 @@ +import { requireRole } from './auth.js'; +import { config } from './config.js'; +import { getAllFlags } from './feature-flags.js'; +import { PRODUCT_ID } from './product-config.js'; +import { flushEvents, getBufferedEvents } from './telemetry.js'; + +type AuthLikeRequest = { headers: { authorization?: string } }; +type DiagnosticsHandler = (req: AuthLikeRequest) => Promise; +type DiagnosticsRouteApp = { + get: (path: string, handler: DiagnosticsHandler) => unknown; + post: (path: string, handler: DiagnosticsHandler) => unknown; +}; + +export async function assertDiagnosticsAccess( + req: AuthLikeRequest, + nodeEnv: string = config.NODE_ENV, +): Promise { + if (nodeEnv !== 'production') { + return; + } + + await requireRole(req, 'admin', 'owner'); +} + +export async function diagnosticsRoutes(app: DiagnosticsRouteApp) { + app.get('/api/diagnostics/flags', async req => { + await assertDiagnosticsAccess(req); + return getAllFlags(); + }); + + app.get('/api/diagnostics/telemetry', async req => { + await assertDiagnosticsAccess(req); + return { events: getBufferedEvents() }; + }); + + app.post('/api/diagnostics/telemetry/flush', async req => { + await assertDiagnosticsAccess(req); + return { flushed: flushEvents().length }; + }); + + app.get('/api/diagnostics/config', async req => { + await assertDiagnosticsAccess(req); + return { + productId: PRODUCT_ID, + serviceName: config.SERVICE_NAME, + port: config.PORT, + nodeEnv: config.NODE_ENV, + dbProvider: config.DB_PROVIDER, + telemetryEnabled: config.TELEMETRY_ENABLED, + featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED, + }; + }); +} diff --git a/backend/src/server.test.ts b/backend/src/server.test.ts index cc4c615..eeed50f 100644 --- a/backend/src/server.test.ts +++ b/backend/src/server.test.ts @@ -5,6 +5,7 @@ const registerOptionalJwtContextMock = vi.fn(async () => undefined); const startServiceMock = vi.fn(async () => undefined); const initCosmosIfNeededMock = vi.fn(async () => undefined); const initDatastoreMock = vi.fn(() => undefined); +const diagnosticsRoutesMock = vi.fn(async () => undefined); const appMock = { register: vi.fn(async () => undefined), @@ -61,6 +62,7 @@ vi.mock('./lib/field-encrypt.js', () => ({ initEncryption: vi.fn(async () => und vi.mock('./lib/request-context.js', () => ({ getUserId: vi.fn(), getRequestProductId: vi.fn() })); vi.mock('./lib/feature-flags.js', () => ({ getAllFlags: vi.fn(() => ({})) })); vi.mock('./lib/telemetry.js', () => ({ getBufferedEvents: vi.fn(() => []), flushEvents: vi.fn(() => []) })); +vi.mock('./lib/diagnostics-routes.js', () => ({ diagnosticsRoutes: diagnosticsRoutesMock })); vi.mock('./modules/note-shares/repository.js', () => ({ findShareByToken: vi.fn(async () => null) })); vi.mock('./modules/notes/repository.js', () => ({ getNote: vi.fn(async () => null) })); @@ -81,6 +83,7 @@ describe('server bootstrap', () => { expect(createServiceAppMock).toHaveBeenCalledOnce(); expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); expect(appMock.register).toHaveBeenCalledTimes(14); + expect(diagnosticsRoutesMock).toHaveBeenCalledWith(appMock); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' }); }); }); diff --git a/backend/src/server.ts b/backend/src/server.ts index bcfea1a..bb30f6e 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -19,9 +19,8 @@ import { initEncryption } from './lib/field-encrypt.js'; import { initWebhookSubscriber, stopWebhookSubscriber } from './lib/webhook-subscriber.js'; import { initDatastore } from './lib/datastore.js'; import { config } from './lib/config.js'; -import { getAllFlags } from './lib/feature-flags.js'; -import { getBufferedEvents, flushEvents } from './lib/telemetry.js'; import { DISPLAY_NAME, PRODUCT_ID, productConfig } from './lib/product-config.js'; +import { diagnosticsRoutes } from './lib/diagnostics-routes.js'; import type { JwtPayload } from './lib/request-context.js'; import { findShareByToken } from './modules/note-shares/repository.js'; import * as noteRepo from './modules/notes/repository.js'; @@ -108,19 +107,8 @@ app.get('/api/bootstrap', async () => ({ backendPort: config.PORT, })); -// ── Diagnostics routes (no auth) ──────────────────────────────── -app.get('/api/diagnostics/flags', async () => getAllFlags()); -app.get('/api/diagnostics/telemetry', async () => ({ events: getBufferedEvents() })); -app.post('/api/diagnostics/telemetry/flush', async () => ({ flushed: flushEvents().length })); -app.get('/api/diagnostics/config', async () => ({ - productId: PRODUCT_ID, - serviceName: config.SERVICE_NAME, - port: config.PORT, - nodeEnv: config.NODE_ENV, - dbProvider: config.DB_PROVIDER, - telemetryEnabled: config.TELEMETRY_ENABLED, - featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED, -})); +// ── Diagnostics routes (dev/test open, production admin/owner gated) ─────── +await diagnosticsRoutes(app); await initEncryption(PRODUCT_ID, app.log);