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
128 changes: 128 additions & 0 deletions src/api_auth_guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
jest.mock('./log', () => ({
Log: () => ({
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn()
})
}));

import api from './api';
import { CoreFunctions } from './api_productions_core_functions';
import { ConnectionQueue } from './connection_queue';

// Minimal manager mocks — requireApiKey rejects in the preHandler before any
// handler runs, so the managers only need to satisfy api() construction.
const mockDbManager = {
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn().mockResolvedValue(undefined),
getProductions: jest.fn().mockResolvedValue([]),
getProductionsLength: jest.fn().mockResolvedValue(0)
} as any;

Check warning on line 21 in src/api_auth_guard.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

const mockProductionManager = {
load: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
once: jest.fn(),
emit: jest.fn()
} as any;

Check warning on line 28 in src/api_auth_guard.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

const mockIngestManager = {
load: jest.fn().mockResolvedValue(undefined),
startPolling: jest.fn()
} as any;

Check warning on line 33 in src/api_auth_guard.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

const buildServer = () =>
api({
title: 'auth-guard-test',
smbServerBaseUrl: 'http://localhost',
endpointIdleTimeout: '60',
publicHost: 'http://localhost',
dbManager: mockDbManager,
productionManager: mockProductionManager,
ingestManager: mockIngestManager,
coreFunctions: new CoreFunctions(
mockProductionManager,
new ConnectionQueue()
)
});

// Previously-unguarded production/session mutation endpoints that must now
// reject unauthenticated requests when API_KEY is set (#222). These requests
// carry no Origin/Referer header, so the CSRF hook lets them through. Each
// payload is schema-valid so the request clears body validation (which runs
// before preHandler in Fastify) and reaches the requireApiKey guard, proving
// it is the guard — not schema validation — that rejects with 401.
const guardedRoutes: { method: string; url: string; payload?: unknown }[] = [
{
method: 'POST',
url: '/api/v1/production/1/line',
payload: { name: 'Line X' }
},
{
method: 'PATCH',
url: '/api/v1/production/1/line/line-a',
payload: { name: 'Line X' }
},
{ method: 'DELETE', url: '/api/v1/production/1/line/line-a' },
{
method: 'POST',
url: '/api/v1/production/1/line/line-a/participants/sess-1/disconnect'
},
{
method: 'PATCH',
url: '/api/v1/session/sess-1',
payload: { sdpAnswer: 'v=0' }
}
];

describe('management endpoints require API key when API_KEY is set (#222)', () => {
const originalApiKey = process.env.API_KEY;

beforeAll(() => {
process.env.API_KEY = 'secret-guard-test';
});

afterAll(() => {
if (originalApiKey === undefined) {
delete process.env.API_KEY;
} else {
process.env.API_KEY = originalApiKey;
}
});

it.each(guardedRoutes)(
'rejects unauthenticated $method $url with 401',
async ({ method, url, payload }) => {
const server = await buildServer();

const response = await server.inject({
method: method as any,

Check warning on line 100 in src/api_auth_guard.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
url,
payload: payload ?? {}
});

expect(response.statusCode).toBe(401);
expect(response.json()).toEqual({ error: 'Unauthorized' });

await server.close();
}
);

it('allows an authenticated request past the API key guard', async () => {
const server = await buildServer();

// With a valid Bearer token the requireApiKey guard passes; the request
// then fails downstream (no such production), i.e. it is no longer a 401.
const response = await server.inject({
method: 'POST',
url: '/api/v1/production/1/line',
headers: { authorization: 'Bearer secret-guard-test' },
payload: { name: 'Line X' }
});

expect(response.statusCode).not.toBe(401);

await server.close();
});
});
6 changes: 6 additions & 0 deletions src/api_groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';
import { FastifyPluginCallback } from 'fastify';
import { DbManager } from './db/interface';
import { requireApiKey } from './auth';
import {
ErrorResponse,
NewPreset,
Expand Down Expand Up @@ -36,6 +37,7 @@ const apiGroups: FastifyPluginCallback<ApiGroupsOptions> = (
instance.get(
'/preset',
{
preHandler: requireApiKey,
schema: {
response: {
200: PresetListResponse
Expand All @@ -51,6 +53,7 @@ const apiGroups: FastifyPluginCallback<ApiGroupsOptions> = (
instance.post(
'/preset',
{
preHandler: requireApiKey,
schema: {
body: NewPreset,
response: {
Expand Down Expand Up @@ -79,6 +82,7 @@ const apiGroups: FastifyPluginCallback<ApiGroupsOptions> = (
instance.get(
'/preset/:id',
{
preHandler: requireApiKey,
schema: {
params: Type.Object({ id: Type.String({ maxLength: 128 }) }),
response: {
Expand All @@ -98,6 +102,7 @@ const apiGroups: FastifyPluginCallback<ApiGroupsOptions> = (
instance.patch(
'/preset/:id',
{
preHandler: requireApiKey,
schema: {
params: Type.Object({ id: Type.String({ maxLength: 128 }) }),
body: UpdatePreset,
Expand Down Expand Up @@ -142,6 +147,7 @@ const apiGroups: FastifyPluginCallback<ApiGroupsOptions> = (
instance.delete(
'/preset/:id',
{
preHandler: requireApiKey,
schema: {
params: Type.Object({ id: Type.String({ maxLength: 128 }) }),
response: {
Expand Down
6 changes: 6 additions & 0 deletions src/api_ingests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { IngestManager } from './ingest_manager';
import { Log } from './log';
import { DbManager } from './db/interface';
import { requireApiKey } from './auth';

export interface ApiIngestsOptions {
dbManager: DbManager;
Expand All @@ -35,6 +36,11 @@ const apiIngests: FastifyPluginCallback<ApiIngestsOptions> = (
}>(
'/ingest',
{
// NOTE: this route-level guard is currently unreachable — the global
// preHandler hook above 501-gates every ingest route before it runs. It
// is kept (rather than removed) so the guard is already in place if the
// Ingest API is ever enabled.
preHandler: requireApiKey,
schema: {
description:
'Create a new Ingest. The device data will be fetched from the specified IP address.',
Expand Down
11 changes: 11 additions & 0 deletions src/api_productions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from './models';
import { ProductionManager } from './production_manager';
import { ISmbProtocol, SmbProtocol } from './smb';
import { requireApiKey } from './auth';

export interface ApiProductionsOptions {
smbServerBaseUrl: string;
Expand Down Expand Up @@ -130,6 +131,7 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
}>(
'/production',
{
preHandler: requireApiKey,
schema: {
description: 'Create a new Production.',
body: NewProduction,
Expand Down Expand Up @@ -355,6 +357,7 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
}>(
'/production/:productionId',
{
preHandler: requireApiKey,
schema: {
description: 'Modify an existing Production line.',
params: ProductionIdParams,
Expand Down Expand Up @@ -467,6 +470,7 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
}>(
'/production/:productionId/line',
{
preHandler: requireApiKey,
schema: {
description: 'Add a new Line to a Production.',
params: ProductionIdParams,
Expand Down Expand Up @@ -585,6 +589,7 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
}>(
'/production/:productionId/line/:lineId',
{
preHandler: requireApiKey,
schema: {
description: 'Modify an existing Production line.',
params: ProductionLineParams,
Expand Down Expand Up @@ -653,6 +658,7 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
}>(
'/production/:productionId/line/:lineId',
{
preHandler: requireApiKey,
schema: {
description: 'Removes a line from a production.',
params: ProductionLineParams,
Expand Down Expand Up @@ -703,6 +709,7 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
}>(
'/session',
{
preHandler: requireApiKey,
schema: {
description:
'Initiate connection protocol. Generates sdp offer describing remote SMB instance.',
Expand Down Expand Up @@ -815,6 +822,7 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
}>(
'/session/:sessionId',
{
preHandler: requireApiKey,
schema: {
description:
'Provide client local SDP description as request body to finalize connection protocol.',
Expand Down Expand Up @@ -903,6 +911,7 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
}>(
'/production/:productionId',
{
preHandler: requireApiKey,
schema: {
description: 'Deletes a Production.',
params: ProductionIdParams,
Expand Down Expand Up @@ -946,6 +955,7 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
}>(
'/session/:sessionId',
{
preHandler: requireApiKey,
schema: {
description: 'Deletes a Connection from ProductionManager.',
params: SessionIdParams,
Expand Down Expand Up @@ -1063,6 +1073,7 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
}>(
'/production/:productionId/line/:lineId/participants/:sessionId/disconnect',
{
preHandler: requireApiKey,
schema: {
description:
'Force-disconnect a participant from a line by backend session id. ' +
Expand Down
Loading
Loading