openvidu/openvidu-dashboard/dashboard-ce/backend/test/integration/api.test.ts

651 lines
20 KiB
TypeScript

import { type Db, MongoClient } from 'mongodb';
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
import { SCHEMA_VERSION, type SchemaMetaDocument } from '@openvidu-dashboard/typings';
import type { RouteRegistry } from '../../src/api/route-registry.js';
import { AuthService } from '../../src/auth/auth-service.js';
import { SessionRepository } from '../../src/auth/session-repository.js';
import { provisionSigningKey } from '../../src/auth/signing-key.js';
import { TOKENS } from '../../src/di/tokens.js';
import { silentLogger } from '../support/silent-logger.js';
import { boot, type BootedDashboard, refreshCookie } from '../support/boot.js';
const mongoUri = inject('mongoUri');
const migrationFields = {
writerVersion: '3.10.0',
fromSchemaVersion: 0,
counts: { events: 0, stats: 0, synthetic: 0, skipped: 0 }
};
async function seedSchema(db: Db, version = SCHEMA_VERSION): Promise<void> {
await db.collection<SchemaMetaDocument>('schema_meta').insertOne({
_id: 'schema',
version,
writerVersion: '3.10.0',
updatedAt: new Date()
});
}
async function login(dashboard: BootedDashboard, headers: Record<string, string> = {}, password = 'secret') {
return fetch(`${dashboard.url}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ username: 'admin', password })
});
}
async function refresh(dashboard: BootedDashboard, cookie: string | undefined) {
return fetch(`${dashboard.url}/api/v1/auth/refresh`, {
method: 'POST',
headers: cookie === undefined ? {} : { Cookie: `ov_dashboard_refresh=${cookie}` }
});
}
async function token(response: Response): Promise<string> {
return ((await response.json()) as { accessToken: string }).accessToken;
}
describe.skipIf(mongoUri === '')('API and authentication', () => {
let dashboard: BootedDashboard;
beforeAll(async () => {
dashboard = await boot(mongoUri, {}, { seed: (db) => seedSchema(db) });
});
afterAll(async () => {
await dashboard.stop();
});
it('runs the full session lifecycle', async () => {
const loggedIn = await login(dashboard);
const body = (await loggedIn.clone().json()) as { accessToken: string; expiresAt: string };
const cookie = refreshCookie(loggedIn);
expect(loggedIn.status).toBe(200);
expect(new Date(body.expiresAt).getTime() - Date.now()).toBeGreaterThan(14 * 60_000);
expect(cookie?.header).toBe(
`ov_dashboard_refresh=${cookie?.value ?? ''}; Path=/api/v1/auth; Max-Age=604800; HttpOnly; SameSite=Strict`
);
const me = await fetch(`${dashboard.url}/api/v1/auth/me`, {
headers: { Authorization: `Bearer ${body.accessToken}` }
});
expect(await me.json()).toMatchObject({ username: 'admin', edition: 'ce', expiresAt: body.expiresAt });
const rotated = await refresh(dashboard, cookie?.value);
const next = refreshCookie(rotated);
expect(rotated.status).toBe(200);
expect(next?.value).toBeDefined();
expect(next?.value).not.toBe(cookie?.value);
const logout = await fetch(`${dashboard.url}/api/v1/auth/logout`, {
method: 'POST',
headers: { Cookie: `ov_dashboard_refresh=${next?.value ?? ''}` }
});
expect(logout.status).toBe(204);
expect(refreshCookie(logout)?.header).toContain('Max-Age=0');
expect((await refresh(dashboard, next?.value)).status).toBe(401);
expect((await fetch(`${dashboard.url}/api/v1/auth/logout`, { method: 'POST' })).status).toBe(204);
});
it('rejects wrong credentials and malformed bodies', async () => {
const wrong = await login(dashboard, {}, 'nope');
const malformed = await fetch(`${dashboard.url}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'admin' })
});
const issues = ((await malformed.json()) as { error: { details: { issues: { path: string[] }[] } } }).error
.details.issues;
expect(wrong.status).toBe(401);
expect(((await wrong.json()) as { error: { code: string } }).error.code).toBe('INVALID_CREDENTIALS');
expect(malformed.status).toBe(400);
expect(issues.map((issue) => issue.path)).toEqual(expect.arrayContaining([['username'], ['password']]));
});
it('sets Secure when a trusted proxy reports HTTPS', async () => {
const response = await login(dashboard, { 'X-Forwarded-Proto': 'https' });
expect(refreshCookie(response)?.header).toContain('; Secure;');
});
it('lets two tabs refresh with the same token at once', async () => {
const cookie = refreshCookie(await login(dashboard))?.value;
const [first, second] = await Promise.all([refresh(dashboard, cookie), refresh(dashboard, cookie)]);
expect([first.status, second.status]).toEqual([200, 200]);
expect((await refresh(dashboard, refreshCookie(first)?.value)).status).toBe(200);
expect((await refresh(dashboard, refreshCookie(second)?.value)).status).toBe(200);
});
it('revokes the family when a rotated token is replayed later', async () => {
const cookie = refreshCookie(await login(dashboard))?.value;
const successor = refreshCookie(await refresh(dashboard, cookie))?.value;
dashboard.advance(5 * 60_000);
const replay = await refresh(dashboard, cookie);
expect(replay.status).toBe(401);
expect(refreshCookie(replay)?.header).toContain('Max-Age=0');
expect((await refresh(dashboard, successor)).status).toBe(401);
expect(
(
await dashboard.db
.collection('dashboard_sessions')
.find({ revokedAt: { $exists: true } })
.toArray()
).length
).toBeGreaterThan(0);
});
it('protects routes and reports missing and expired tokens', async () => {
const missing = await fetch(`${dashboard.url}/api/v1/auth/me`);
const accessToken = await token(await login(dashboard));
dashboard.advance(16 * 60_000);
const expired = await fetch(`${dashboard.url}/api/v1/info`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
expect(missing.status).toBe(401);
expect(missing.headers.get('www-authenticate')).toBe('Bearer');
expect(((await missing.json()) as { error: { details: unknown } }).error.details).toEqual({
reason: 'missing'
});
expect(expired.status).toBe(401);
expect(((await expired.json()) as { error: { details: unknown } }).error.details).toEqual({
reason: 'expired'
});
});
it('answers the public subset of info without a token', async () => {
const response = await fetch(`${dashboard.url}/api/v1/info`);
expect(await response.json()).toEqual({ edition: 'ce', versions: { dashboard: '3.10.0-test' } });
});
it('answers ready, health and the legacy health check without a token', async () => {
expect(await (await fetch(`${dashboard.url}/api/v1/ready`)).json()).toEqual({
status: 'ready',
checks: { mongo: 'ok', schema: 'ok' }
});
expect(await (await fetch(`${dashboard.url}/api/v1/health`)).json()).toEqual({
status: 'ok',
version: '3.10.0-test'
});
expect((await fetch(`${dashboard.url}/api/healthcheck`)).status).toBe(200);
expect((await fetch(`${dashboard.url}/api/v1/docs`)).status).toBe(404);
});
});
describe.skipIf(mongoUri === '')('info', () => {
it('reports a running writer', async () => {
const dashboard = await boot(
mongoUri,
{},
{
seed: async (db) => {
await seedSchema(db);
await db.collection<SchemaMetaDocument>('schema_meta').insertMany([
{
_id: 'migration',
state: 'completed',
processed: 1_200,
total: 1_200,
startedAt: new Date('2026-10-13T08:00:00Z'),
...migrationFields
},
{
_id: 'retention',
eventsMs: 604_800_000,
statsMs: 604_800_000,
tracksMs: 604_800_000,
entitiesMs: 7_776_000_000,
rollups5MinMs: 604_800_000,
rollupsHourlyMs: 34_560_000_000,
rollupsDailyMs: null,
updatedAt: new Date()
},
{
_id: 'capabilities',
timeseries: true,
transactions: true,
serverVersion: '8.0.29',
detectedAt: new Date()
}
]);
await db.collection('rooms').insertMany(
Array.from({ length: 12 }, (_, i) => ({
_id: `RM_${String(i)}`,
createdAt: new Date(Date.UTC(2026, 9, 1 + i))
})) as never[]
);
await db.collection('rooms').createIndex({ createdAt: -1 });
}
}
);
try {
const accessToken = await token(await login(dashboard));
const info = (await (
await fetch(`${dashboard.url}/api/v1/info`, { headers: { Authorization: `Bearer ${accessToken}` } })
).json()) as Record<string, unknown>;
expect(info).toMatchObject({
edition: 'ce',
versions: { dashboard: '3.10.0-test', server: '3.10.0' },
schema: { dashboard: SCHEMA_VERSION, writer: SCHEMA_VERSION, status: 'ok' },
migration: {
state: 'completed',
progress: { processed: 1_200, total: 1_200, startedAt: '2026-10-13T08:00:00.000Z' }
},
retention: {
eventsMs: 604_800_000,
statsMs: 604_800_000,
tracksMs: 604_800_000,
entitiesMs: 7_776_000_000,
rollups5MinMs: 604_800_000,
rollupsHourlyMs: 34_560_000_000,
rollupsDailyMs: null
},
database: { timeseriesSupported: true },
features: [],
limits: { rankedSortMaxRangeMs: 604_800_000, participantRankedSortMaxRangeMs: 86_400_000 },
openapiDocsEnabled: false
});
expect(info.storage).toMatchObject({ rooms: { documents: 12, oldest: '2026-10-01T00:00:00.000Z' } });
} finally {
await dashboard.stop();
}
});
it('reports a daily rollup retention, and the default 5-minute retention when the writer recorded none', async () => {
const dashboard = await boot(
mongoUri,
{},
{
seed: async (db) => {
await seedSchema(db);
await db.collection('schema_meta').insertOne({
_id: 'retention',
eventsMs: 604_800_000,
statsMs: 172_800_000,
tracksMs: 604_800_000,
entitiesMs: 7_776_000_000,
rollupsHourlyMs: 34_560_000_000,
rollupsDailyMs: 157_680_000_000,
updatedAt: new Date()
} as never);
}
}
);
try {
const info = (await (
await fetch(`${dashboard.url}/api/v1/info`, {
headers: { Authorization: `Bearer ${await token(await login(dashboard))}` }
})
).json()) as { retention: Record<string, unknown> };
expect(info.retention).toMatchObject({ rollups5MinMs: 604_800_000, rollupsDailyMs: 157_680_000_000 });
} finally {
await dashboard.stop();
}
});
it('reports a writer that has not started, a running migration and a failed one', async () => {
const empty = await boot(mongoUri);
const migrating = await boot(
mongoUri,
{},
{
seed: async (db) => {
await seedSchema(db);
await db.collection<SchemaMetaDocument>('schema_meta').insertOne({
_id: 'migration',
state: 'running',
processed: 400_000,
total: 1_200_000,
startedAt: new Date('2026-10-13T08:00:00Z'),
...migrationFields
});
}
}
);
const failed = await boot(
mongoUri,
{},
{
seed: async (db) => {
await seedSchema(db);
await db.collection<SchemaMetaDocument>('schema_meta').insertOne({
_id: 'migration',
state: 'failed',
processed: 10,
total: 20,
startedAt: new Date('2026-10-13T08:00:00Z'),
finishedAt: new Date('2026-10-13T08:05:00Z'),
error: 'legacy event without a room',
...migrationFields
});
}
}
);
try {
const emptyInfo = await (
await fetch(`${empty.url}/api/v1/info`, {
headers: { Authorization: `Bearer ${await token(await login(empty))}` }
})
).json();
const migratingInfo = (await (
await fetch(`${migrating.url}/api/v1/info`, {
headers: { Authorization: `Bearer ${await token(await login(migrating))}` }
})
).json()) as { migration: unknown };
expect(emptyInfo).toMatchObject({
versions: { dashboard: '3.10.0-test' },
schema: { status: 'uninitialized' },
migration: { state: 'none' }
});
expect(emptyInfo).not.toHaveProperty('retention');
expect(emptyInfo).not.toHaveProperty('versions.server');
expect(migratingInfo.migration).toEqual({
state: 'running',
progress: { processed: 400_000, total: 1_200_000, startedAt: '2026-10-13T08:00:00.000Z' }
});
const failedInfo = (await (
await fetch(`${failed.url}/api/v1/info`, {
headers: { Authorization: `Bearer ${await token(await login(failed))}` }
})
).json()) as { migration: unknown };
expect(failedInfo.migration).toEqual({
state: 'failed',
progress: {
processed: 10,
total: 20,
startedAt: '2026-10-13T08:00:00.000Z',
finishedAt: '2026-10-13T08:05:00.000Z'
},
error: 'legacy event without a room'
});
} finally {
await empty.stop();
await migrating.stop();
await failed.stop();
}
});
});
describe.skipIf(mongoUri === '')('readiness', () => {
it.each([
['uninitialized', undefined, 200],
['newer', SCHEMA_VERSION + 1, 200]
])('is ready with a writer schema that is %s', async (status, version, code) => {
const dashboard = await boot(
mongoUri,
{},
{ seed: (db) => (version === undefined ? Promise.resolve() : seedSchema(db, version)) }
);
try {
const response = await fetch(`${dashboard.url}/api/v1/ready`);
expect(response.status).toBe(code);
expect(await response.json()).toEqual({ status: 'ready', checks: { mongo: 'ok', schema: status } });
} finally {
await dashboard.stop();
}
});
it('is not ready when the writer is downgraded below the minimum', async () => {
const dashboard = await boot(mongoUri, {}, { seed: (db) => seedSchema(db) });
try {
await dashboard.db
.collection<SchemaMetaDocument>('schema_meta')
.updateOne({ _id: 'schema' }, { $set: { version: 0 } });
const response = await fetch(`${dashboard.url}/api/v1/ready`);
expect(response.status).toBe(503);
expect(await response.json()).toMatchObject({
error: { code: 'NOT_READY', details: { checks: { mongo: 'ok', schema: 'older' } } }
});
} finally {
await dashboard.stop();
}
});
});
describe.skipIf(mongoUri === '')('login limiter and trusted proxies', () => {
it('blocks the eleventh failed attempt, even with the right credentials', async () => {
const dashboard = await boot(mongoUri, {}, { seed: (db) => seedSchema(db) });
try {
for (let i = 0; i < 10; i++) {
expect((await login(dashboard, {}, 'nope')).status).toBe(401);
}
const blocked = await login(dashboard);
expect(blocked.status).toBe(429);
expect(Number(blocked.headers.get('retry-after'))).toBeGreaterThan(0);
expect(((await blocked.json()) as { error: { code: string } }).error.code).toBe('RATE_LIMITED');
} finally {
await dashboard.stop();
}
});
it('counts attempts per forwarded address from a trusted proxy', async () => {
const dashboard = await boot(mongoUri, {}, { seed: (db) => seedSchema(db) });
try {
for (let i = 0; i < 10; i++) {
await login(dashboard, { 'X-Forwarded-For': '203.0.113.9' }, 'nope');
}
expect((await login(dashboard, { 'X-Forwarded-For': '203.0.113.9' })).status).toBe(429);
expect((await login(dashboard, { 'X-Forwarded-For': '203.0.113.10' })).status).toBe(200);
} finally {
await dashboard.stop();
}
});
it('ignores forwarded headers from an untrusted address', async () => {
const dashboard = await boot(
mongoUri,
{ DASHBOARD_TRUST_PROXY: '10.0.0.0/8' },
{ seed: (db) => seedSchema(db) }
);
try {
for (let i = 0; i < 10; i++) {
await login(dashboard, { 'X-Forwarded-For': `10.0.0.${String(i)}` }, 'nope');
}
const response = await login(dashboard, { 'X-Forwarded-For': '10.0.0.99', 'X-Forwarded-Proto': 'https' });
expect(response.status).toBe(429);
} finally {
await dashboard.stop();
}
});
it('builds the cookie path from DASHBOARD_BASE_PATH and honours DASHBOARD_COOKIE_SECURE', async () => {
const dashboard = await boot(
mongoUri,
{
DASHBOARD_BASE_PATH: '/dashboard/',
DASHBOARD_COOKIE_SECURE: 'true',
DASHBOARD_REFRESH_TOKEN_TTL: '43200'
},
{ seed: (db) => seedSchema(db) }
);
try {
const header = refreshCookie(await login(dashboard))?.header ?? '';
const [session] = await dashboard.db
.collection<{ createdAt: Date; expiresAt: Date }>('dashboard_sessions')
.find()
.toArray();
expect(header).toContain('Path=/dashboard/api/v1/auth; Max-Age=43200; HttpOnly; Secure; SameSite=Strict');
expect((session?.expiresAt.getTime() ?? 0) - (session?.createdAt.getTime() ?? 0)).toBe(43_200_000);
} finally {
await dashboard.stop();
}
});
it('serves the OpenAPI document in development', async () => {
const dashboard = await boot(mongoUri, { NODE_ENV: 'development' }, { seed: (db) => seedSchema(db) });
try {
const document = (await (await fetch(`${dashboard.url}/api/v1/docs/openapi.json`)).json()) as {
openapi: string;
paths: Record<string, unknown>;
};
const locked = dashboard.running.container
.get<RouteRegistry>(TOKENS.RouteRegistry)
.unimplemented()
.map((route) => `/api/v1${route.split(' ')[1] ?? ''}`);
expect(document.openapi).toBe('3.1.0');
expect(locked.length).toBeGreaterThan(0);
expect(Object.keys(document.paths).filter((path) => locked.includes(path))).toEqual([]);
expect(Object.keys(document.paths)).toEqual(
expect.arrayContaining([
'/api/v1/auth/login',
'/api/v1/auth/refresh',
'/api/v1/auth/logout',
'/api/v1/auth/me',
'/api/v1/info',
'/api/v1/health',
'/api/v1/ready'
])
);
} finally {
await dashboard.stop();
}
});
});
describe.skipIf(mongoUri === '')('signing key and sessions', () => {
let client: MongoClient;
beforeAll(async () => {
client = await MongoClient.connect(mongoUri);
});
afterAll(async () => {
await client.close();
});
it('converges concurrent replicas on one key', async () => {
const db = client.db(`keys_${String(Date.now())}`);
try {
const [first, second] = await Promise.all([
provisionSigningKey(db, undefined),
provisionSigningKey(db, undefined)
]);
const sessions = new SessionRepository(db);
const service = (key: Uint8Array) =>
new AuthService({
username: 'admin',
password: 'secret',
edition: 'ce',
key,
refreshTokenTtlSeconds: 60,
sessions,
logger: silentLogger()
});
const { accessToken } = await service(first).issueAccessToken();
expect(Buffer.from(first).equals(Buffer.from(second))).toBe(true);
expect(await db.collection('dashboard_settings').countDocuments()).toBe(1);
expect((await service(second).verifyAccessToken(accessToken)).username).toBe('admin');
expect(Buffer.from(await provisionSigningKey(db, 'x'.repeat(48))).toString()).toBe('x'.repeat(48));
} finally {
await db.dropDatabase();
}
});
it('indexes the sessions and backs both session queries with them', async () => {
const db = client.db(`sessions_${String(Date.now())}`);
const repository = new SessionRepository(db);
try {
await repository.ensureIndexes();
await repository.ensureIndexes();
const indexes = (await db.collection('dashboard_sessions').listIndexes().toArray()) as {
name: string;
expireAfterSeconds?: number;
}[];
const stages = (plan: unknown): string[] => {
const found: string[] = [];
const walk = (node: unknown) => {
if (node && typeof node === 'object') {
const record = node as Record<string, unknown>;
if (typeof record.stage === 'string') {
found.push(
typeof record.indexName === 'string'
? `${record.stage}:${record.indexName}`
: record.stage
);
}
Object.values(record).forEach(walk);
}
};
walk(plan);
return found;
};
const lookup = await db.collection('dashboard_sessions').find({ tokenHash: 'abc' }).explain('queryPlanner');
const revoke = await db.command({
explain: {
update: 'dashboard_sessions',
updates: [
{
q: { familyId: 'f', revokedAt: { $exists: false } },
u: { $set: { revokedAt: new Date() } },
multi: true
}
]
},
verbosity: 'queryPlanner'
});
expect(indexes.map((index) => index.name)).toEqual([
'_id_',
'tokenHash_unique',
'familyId',
'expiresAt_ttl'
]);
expect(indexes.find((index) => index.name === 'expiresAt_ttl')?.expireAfterSeconds).toBe(0);
// MongoDB 8 answers a point lookup on a unique index with EXPRESS_IXSCAN, still an index scan.
expect(stages(lookup.queryPlanner).some((stage) => stage.endsWith('IXSCAN:tokenHash_unique'))).toBe(true);
expect(stages(revoke.queryPlanner).some((stage) => stage.endsWith('IXSCAN:familyId'))).toBe(true);
} finally {
await db.dropDatabase();
}
});
});