extended tests covetage

This commit is contained in:
Dmitri 2026-07-02 14:22:33 +02:00
parent e1711b42c2
commit 57a705f3ba
8 changed files with 1053 additions and 43 deletions

View File

@ -8,10 +8,10 @@
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"build": "tsc -p tsconfig.json && node scripts/copy-runtime-assets.ts", "build": "tsc -p tsconfig.json && node scripts/copy-runtime-assets.ts",
"test": "node --test tests/*.test.ts", "test": "node --test tests/*.test.ts",
"test:integration": "node --test tests/integration/*.test.ts", "test:integration": "node --test --test-concurrency=1 tests/integration/*.test.ts",
"test:e2e": "node --test tests/e2e/*.test.ts", "test:e2e": "node --test tests/e2e/*.test.ts",
"test:all": "npm run test && npm run test:integration && npm run test:e2e", "test:all": "npm run test && npm run test:integration && npm run test:e2e",
"verify": "npm run typecheck && npm run lint && npm run check:esm-boundaries && npm run test", "verify": "npm run typecheck && npm run lint && npm run check:esm-boundaries && npm run test:all",
"check:esm-boundaries": "node scripts/check-esm-boundaries.ts", "check:esm-boundaries": "node scripts/check-esm-boundaries.ts",
"check:public-access": "node scripts/check-public-access-hardening.ts", "check:public-access": "node scripts/check-public-access-hardening.ts",
"fix:public-access": "node scripts/check-public-access-hardening.ts --fix", "fix:public-access": "node scripts/check-public-access-hardening.ts --fix",

View File

@ -301,52 +301,45 @@ export default class PublishService {
currentUser: PublishServiceCurrentUser | undefined, currentUser: PublishServiceCurrentUser | undefined,
transaction: Transaction, transaction: Transaction,
): Promise<PublishSummary> { ): Promise<PublishSummary> {
const [ const sourcePages = await db.tour_pages.findAll({
sourcePages, where: { projectId, environment: fromEnv },
sourceAudioTracks, transaction,
sourceTransitionSettings, });
sourceUiControlSettings, const sourceAudioTracks = await db.project_audio_tracks.findAll({
] = await Promise.all([ where: { projectId, environment: fromEnv },
db.tour_pages.findAll({ transaction,
});
const sourceTransitionSettings =
await db.project_transition_settings.findOne({
where: { projectId, environment: fromEnv }, where: { projectId, environment: fromEnv },
transaction, transaction,
}), });
db.project_audio_tracks.findAll({ const sourceUiControlSettings =
await db.project_ui_control_settings.findOne({
where: { projectId, environment: fromEnv }, where: { projectId, environment: fromEnv },
transaction, transaction,
}), });
db.project_transition_settings.findOne({
where: { projectId, environment: fromEnv },
transaction,
}),
db.project_ui_control_settings.findOne({
where: { projectId, environment: fromEnv },
transaction,
}),
]);
await Promise.all([ await db.tour_pages.destroy({
db.tour_pages.destroy({ where: { projectId, environment: toEnv },
where: { projectId, environment: toEnv }, transaction,
transaction, force: true,
force: true, });
}), await db.project_audio_tracks.destroy({
db.project_audio_tracks.destroy({ where: { projectId, environment: toEnv },
where: { projectId, environment: toEnv }, transaction,
transaction, force: true,
force: true, });
}), await db.project_transition_settings.destroy({
db.project_transition_settings.destroy({ where: { projectId, environment: toEnv },
where: { projectId, environment: toEnv }, transaction,
transaction, force: true,
force: true, });
}), await db.project_ui_control_settings.destroy({
db.project_ui_control_settings.destroy({ where: { projectId, environment: toEnv },
where: { projectId, environment: toEnv }, transaction,
transaction, force: true,
force: true, });
}),
]);
const actorId = currentUser?.id || null; const actorId = currentUser?.id || null;

View File

@ -1,9 +1,15 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createRequest, createResponse } from 'node-mocks-http';
import { import {
checkCrudPermissions,
getCrudPermissionName, getCrudPermissionName,
} from '../src/middlewares/check-permissions.ts'; } from '../src/middlewares/check-permissions.ts';
import {
setCurrentUser,
setRuntimePublicRequest,
} from '../src/utils/request-context.ts';
void test('getCrudPermissionName honors explicit permission override', () => { void test('getCrudPermissionName honors explicit permission override', () => {
assert.equal( assert.equal(
@ -22,3 +28,44 @@ void test('getCrudPermissionName keeps default method-derived permission without
'DELETE_PAGE_ELEMENTS', 'DELETE_PAGE_ELEMENTS',
); );
}); });
void test('checkCrudPermissions allows users to read their own user route without broad READ_USERS permission', async () => {
const req = createRequest({
method: 'GET',
url: '/user-1',
params: { id: 'user-1' },
});
const res = createResponse();
setCurrentUser(req, {
id: 'user-1',
app_role: {
name: 'Public',
permissions: [],
},
});
const error = await new Promise<Error | null>((resolve) => {
checkCrudPermissions('users')(req, res, (nextError?: unknown) => {
resolve(nextError instanceof Error ? nextError : null);
});
});
assert.equal(error, null);
});
void test('checkCrudPermissions allows runtime public reads for presentation entities', async () => {
const req = createRequest({
method: 'GET',
url: '/',
});
const res = createResponse();
setRuntimePublicRequest(req, true);
const error = await new Promise<Error | null>((resolve) => {
checkCrudPermissions('tour_pages')(req, res, (nextError?: unknown) => {
resolve(nextError instanceof Error ? nextError : null);
});
});
assert.equal(error, null);
});

View File

@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createErrorResponse,
generatePresignedUrls,
isValidPath,
} from '../src/services/file.ts';
void test('isValidPath allows relative storage keys and rejects traversal or protocol inputs', () => {
assert.equal(isValidPath('projects/demo/image.jpg'), true);
assert.equal(isValidPath('nested/folder/video.mp4'), true);
assert.equal(isValidPath('../secret.txt'), false);
assert.equal(isValidPath('folder/../../secret.txt'), false);
assert.equal(isValidPath('https://example.test/file.jpg'), false);
assert.equal(isValidPath('s3://bucket/file.jpg'), false);
assert.equal(isValidPath('folder//file.jpg'), false);
assert.equal(isValidPath('folder/\0/file.jpg'), false);
assert.equal(isValidPath(' '), false);
assert.equal(isValidPath(null), false);
});
void test('createErrorResponse returns a stable structured error payload', () => {
assert.deepEqual(
createErrorResponse('Invalid file paths detected', 'INVALID_PATH', {
invalidPaths: ['../secret.txt'],
}),
{
message: 'Invalid file paths detected',
code: 'INVALID_PATH',
details: {
invalidPaths: ['../secret.txt'],
},
},
);
});
void test('generatePresignedUrls returns backend download URLs for local storage provider', async () => {
const previousProvider = process.env.FILE_STORAGE_PROVIDER;
process.env.FILE_STORAGE_PROVIDER = 'local';
try {
assert.deepEqual(
await generatePresignedUrls(['projects/demo/image 1.jpg']),
{
'projects/demo/image 1.jpg':
'/api/file/download?privateUrl=projects%2Fdemo%2Fimage%201.jpg',
},
);
} finally {
if (previousProvider === undefined) {
delete process.env.FILE_STORAGE_PROVIDER;
} else {
process.env.FILE_STORAGE_PROVIDER = previousProvider;
}
}
});

View File

@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TestContext } from 'node:test';
import bcrypt from 'bcrypt';
import type { Transaction } from 'sequelize';
import config from '../../src/config.ts';
import db from '../../src/db/models/index.ts';
import UsersDBApi from '../../src/db/api/users.ts';
import AuthService from '../../src/services/auth.ts';
import type { UserRecord } from '../../src/types/index.ts';
const suffix = `${Date.now()}-${process.pid}`;
void test.after(async () => {
await db.sequelize.close();
});
async function authenticateWithTimeout(timeoutMs = 1500): Promise<void> {
let timeoutId: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`Database unavailable after ${timeoutMs}ms`)),
timeoutMs,
);
});
try {
await Promise.race([db.sequelize.authenticate(), timeout]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
async function withTransaction(
t: TestContext,
callback: (transaction: Transaction) => Promise<void>,
): Promise<void> {
try {
await authenticateWithTimeout();
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
t.skip(`Database unavailable: ${message}`);
return;
}
const transaction = await db.sequelize.transaction();
try {
await callback(transaction);
} finally {
await transaction.rollback();
}
}
async function createAuthUser(
email: string,
password: string,
transaction: Transaction,
): Promise<UserRecord> {
const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds);
return db.users.create(
{
email,
password: hashedPassword,
emailVerified: false,
disabled: false,
provider: config.providers.LOCAL,
createdById: null,
updatedById: null,
},
{ transaction },
);
}
async function getUserPassword(
id: string,
transaction: Transaction,
): Promise<string> {
const user = await db.users.findByPk(id, { transaction });
if (!user?.password) {
throw new Error('Expected user password to exist.');
}
return user.password;
}
function requireEmail(user: UserRecord): string {
if (!user.email) {
throw new Error('Expected user email to exist.');
}
return user.email;
}
void test('passwordReset updates password for a valid reset token', async (t) => {
await withTransaction(t, async (transaction) => {
const user = await createAuthUser(
`reset-${suffix}@example.test`,
'old-password',
transaction,
);
const token = await UsersDBApi.generatePasswordResetToken(requireEmail(user), {
transaction,
});
await AuthService.passwordReset(token, 'new-password', { transaction });
const updatedPassword = await getUserPassword(user.id, transaction);
assert.equal(await bcrypt.compare('new-password', updatedPassword), true);
assert.equal(await bcrypt.compare('old-password', updatedPassword), false);
});
});
void test('passwordReset rejects invalid reset tokens', async (t) => {
await withTransaction(t, async (transaction) => {
await assert.rejects(
() =>
AuthService.passwordReset('invalid-token', 'new-password', {
transaction,
}),
/Password reset link is invalid or has expired/,
);
});
});
void test('passwordUpdate requires current password and rejects password reuse', async (t) => {
await withTransaction(t, async (transaction) => {
const user = await createAuthUser(
`update-${suffix}@example.test`,
'current-password',
transaction,
);
const currentPassword = await getUserPassword(user.id, transaction);
await assert.rejects(
() =>
AuthService.passwordUpdate('wrong-password', 'next-password', {
currentUser: {
id: user.id,
password: currentPassword,
},
transaction,
}),
/credentials/,
);
await assert.rejects(
() =>
AuthService.passwordUpdate('current-password', 'current-password', {
currentUser: {
id: user.id,
password: currentPassword,
},
transaction,
}),
/same password/i,
);
});
});
void test('verifyEmail marks users as verified for a valid email token', async (t) => {
await withTransaction(t, async (transaction) => {
const user = await createAuthUser(
`verify-${suffix}@example.test`,
'password',
transaction,
);
const token = await UsersDBApi.generateEmailVerificationToken(requireEmail(user), {
transaction,
});
assert.equal(
await AuthService.verifyEmail(token, { transaction }),
true,
);
const reloaded = await db.users.findByPk(user.id, { transaction });
assert.equal(reloaded?.emailVerified, true);
});
});

View File

@ -0,0 +1,456 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TestContext } from 'node:test';
import type { Transaction } from 'sequelize';
import db from '../../src/db/models/index.ts';
import PublishService from '../../src/services/publish.ts';
import type {
ProjectCreatePayload,
ProjectModelRecord,
ProjectTransitionSettingsFieldMapping,
ProjectUiControlSettingsFieldMapping,
PublishCloneData,
PublishClonePayload,
PublishCloneSource,
RuntimeEnvironment,
TourPageCreatePayload,
} from '../../src/types/index.ts';
const suffix = `${Date.now()}-${process.pid}`;
const actorId = '094121b9-c567-469a-b256-ba221b7fd5d6';
void test.after(async () => {
await db.sequelize.close();
});
async function authenticateWithTimeout(timeoutMs = 1500): Promise<void> {
let timeoutId: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`Database unavailable after ${timeoutMs}ms`)),
timeoutMs,
);
});
try {
await Promise.race([db.sequelize.authenticate(), timeout]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
async function withTransaction(
t: TestContext,
callback: (transaction: Transaction) => Promise<void>,
): Promise<void> {
try {
await authenticateWithTimeout();
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
t.skip(`Database unavailable: ${message}`);
return;
}
const transaction = await db.sequelize.transaction();
try {
await callback(transaction);
} finally {
await transaction.rollback();
}
}
async function createProject(
slug: string,
transaction: Transaction,
): Promise<ProjectModelRecord> {
const data: ProjectCreatePayload = {
id: undefined,
name: `Publish Test ${slug}`,
slug,
description: undefined,
logo_url: undefined,
favicon_url: undefined,
og_image_url: undefined,
design_width: undefined,
design_height: undefined,
production_presentation_visibility: 'public',
importHash: null,
createdById: null,
updatedById: null,
};
return db.projects.create(data, { transaction });
}
interface TourPageSeed {
projectId: string;
environment: RuntimeEnvironment;
name: string;
slug: string;
sortOrder: number;
}
function createTourPage(
data: TourPageSeed,
transaction: Transaction,
) {
const payload: TourPageCreatePayload = {
id: undefined,
projectId: data.projectId,
environment: data.environment,
source_key: null,
name: data.name,
slug: data.slug,
sort_order: data.sortOrder,
background_image_url: `${data.environment}/${data.slug}.jpg`,
background_video_url: null,
background_embed_url: null,
background_audio_url: null,
background_audio_autoplay: true,
background_audio_loop: true,
background_audio_start_time: null,
background_audio_end_time: null,
background_loop: false,
background_video_autoplay: true,
background_video_loop: true,
background_video_muted: true,
background_video_start_time: null,
background_video_end_time: null,
design_width: null,
design_height: null,
requires_auth: false,
ui_schema_json: {
elements: [{ id: `${data.slug}-button`, type: 'button' }],
},
global_ui_controls_settings_json: {
sound: { enabled: true },
},
importHash: null,
createdById: null,
updatedById: null,
};
return db.tour_pages.create(
payload,
{ transaction },
);
}
async function countPages(
projectId: string,
environment: RuntimeEnvironment,
transaction: Transaction,
): Promise<number> {
const result = await db.tour_pages.findAndCountAll({
where: { projectId, environment },
include: [],
distinct: true,
order: [['createdAt', 'ASC']],
transaction,
});
return result.count;
}
function createDevAudioTrack(
data: {
projectId: string;
name: string;
slug: string;
url: string;
loop: boolean;
volume: number;
sort_order: number;
is_enabled: boolean;
},
transaction: Transaction,
): Promise<PublishCloneSource> {
return db.project_audio_tracks.create(
{
...data,
environment: 'dev',
createdById: actorId,
updatedById: actorId,
},
{ transaction },
);
}
interface TargetAudioTrackSeed {
projectId: string;
environment: 'stage' | 'production';
name: string;
slug: string;
url: string;
}
function createTargetAudioTrack(
data: TargetAudioTrackSeed,
transaction: Transaction,
): Promise<void> {
const payload: PublishClonePayload = {
...data,
source_key: 'old-source',
createdById: null,
updatedById: null,
};
return db.project_audio_tracks.bulkCreate(
[payload],
{ transaction, returning: false },
);
}
function createTransitionSettings(
projectId: string,
environment: RuntimeEnvironment,
data: Omit<ProjectTransitionSettingsFieldMapping, 'id' | 'source_key'>,
transaction: Transaction,
) {
return db.project_transition_settings.create(
{
id: undefined,
source_key: null,
...data,
projectId,
environment,
createdById: null,
updatedById: null,
},
{ transaction },
);
}
function createUiControlSettings(
projectId: string,
environment: RuntimeEnvironment,
data: Omit<ProjectUiControlSettingsFieldMapping, 'id' | 'source_key'>,
transaction: Transaction,
) {
return db.project_ui_control_settings.create(
{
id: undefined,
source_key: null,
...data,
projectId,
environment,
createdById: null,
updatedById: null,
},
{ transaction },
);
}
function cloneData(record: PublishCloneSource | null): PublishCloneData {
if (!record) {
throw new Error('Expected clone record to exist.');
}
return record.toJSON();
}
void test('copyDevToStage replaces stage content with dev pages, audio, and settings', async (t) => {
await withTransaction(t, async (transaction) => {
const project = await createProject(
`publish-copy-dev-stage-${suffix}`,
transaction,
);
const sourcePage = await createTourPage(
{
projectId: project.id,
environment: 'dev',
name: 'Dev Lobby',
slug: 'lobby',
sortOrder: 1,
},
transaction,
);
await createTourPage(
{
projectId: project.id,
environment: 'stage',
name: 'Old Stage Lobby',
slug: 'old-lobby',
sortOrder: 99,
},
transaction,
);
const sourceAudio = await createDevAudioTrack(
{
projectId: project.id,
name: 'Narration',
slug: 'narration',
url: 'audio/narration.mp3',
loop: true,
volume: 0.7,
sort_order: 1,
is_enabled: true,
},
transaction,
);
await createTargetAudioTrack(
{
projectId: project.id,
environment: 'stage',
name: 'Old Stage Audio',
slug: 'old-stage-audio',
url: 'audio/old.mp3',
},
transaction,
);
const sourceTransition = await createTransitionSettings(
project.id,
'dev',
{
transition_type: 'fade',
duration_ms: 450,
easing: 'linear',
overlay_color: '#111111',
},
transaction,
);
const sourceTransitionPlain = sourceTransition.get({ plain: true });
const sourceUiControls = await createUiControlSettings(
project.id,
'dev',
{
settings_json: {
fullscreen: { enabled: false },
sound: { enabled: true },
},
},
transaction,
);
const sourceUiControlsPlain = sourceUiControls.get({ plain: true });
await createUiControlSettings(
project.id,
'stage',
{
settings_json: { old: true },
},
transaction,
);
const summary = await PublishService.copyDevToStage(
project.id,
{ id: actorId },
transaction,
);
assert.deepEqual(summary, {
pages_copied: 1,
audios_copied: 1,
transition_settings_copied: 1,
ui_control_settings_copied: 1,
});
assert.equal(await countPages(project.id, 'dev', transaction), 1);
assert.equal(await countPages(project.id, 'stage', transaction), 1);
const copiedPage = await db.tour_pages.findOne({
where: {
projectId: project.id,
environment: 'stage',
slug: 'lobby',
},
transaction,
});
assert.ok(copiedPage);
assert.notEqual(copiedPage.id, sourcePage.id);
assert.equal(copiedPage.source_key, sourcePage.id);
assert.deepEqual(copiedPage.ui_schema_json, {
elements: [{ id: 'lobby-button', type: 'button' }],
});
const stageAudios = await db.project_audio_tracks.findAll({
where: { projectId: project.id, environment: 'stage' },
transaction,
});
const copiedAudio = stageAudios.map((row) => row.toJSON()).find(
(row) => row.slug === 'narration',
);
assert.ok(copiedAudio);
assert.equal(copiedAudio.source_key, sourceAudio.id);
assert.equal(copiedAudio.url, 'audio/narration.mp3');
const copiedTransition = cloneData(
await db.project_transition_settings.findOne({
where: { projectId: project.id, environment: 'stage' },
transaction,
}),
);
assert.equal(copiedTransition.source_key, sourceTransitionPlain.id);
assert.equal(copiedTransition.duration_ms, 450);
const copiedUiControls = cloneData(
await db.project_ui_control_settings.findOne({
where: { projectId: project.id, environment: 'stage' },
transaction,
}),
);
assert.equal(copiedUiControls.source_key, sourceUiControlsPlain.id);
assert.deepEqual(copiedUiControls.settings_json, {
fullscreen: { enabled: false },
sound: { enabled: true },
});
});
});
void test('copyStageToProduction leaves stage content intact while replacing production', async (t) => {
await withTransaction(t, async (transaction) => {
const project = await createProject(
`publish-copy-stage-production-${suffix}`,
transaction,
);
const sourcePage = await createTourPage(
{
projectId: project.id,
environment: 'stage',
name: 'Stage Intro',
slug: 'intro',
sortOrder: 1,
},
transaction,
);
await createTourPage(
{
projectId: project.id,
environment: 'production',
name: 'Old Production Intro',
slug: 'old-intro',
sortOrder: 1,
},
transaction,
);
const summary = await PublishService.copyStageToProduction(
project.id,
undefined,
transaction,
);
assert.deepEqual(summary, {
pages_copied: 1,
audios_copied: 0,
transition_settings_copied: 0,
ui_control_settings_copied: 0,
});
assert.equal(await countPages(project.id, 'stage', transaction), 1);
assert.equal(await countPages(project.id, 'production', transaction), 1);
const productionPage = await db.tour_pages.findOne({
where: {
projectId: project.id,
environment: 'production',
slug: 'intro',
},
transaction,
});
assert.ok(productionPage);
assert.notEqual(productionPage.id, sourcePage.id);
assert.equal(productionPage.source_key, sourcePage.id);
});
});

View File

@ -8,6 +8,8 @@ import { commonErrorHandler } from '../src/helpers.ts';
import { validateRequest } from '../src/middlewares/validate-request.ts'; import { validateRequest } from '../src/middlewares/validate-request.ts';
import { import {
crud, crud,
file,
publish,
projects, projects,
tourPages, tourPages,
users, users,
@ -37,6 +39,14 @@ function runMiddleware(
}); });
} }
function getBodyRecord(req: Request): Record<string, unknown> {
const body: unknown = req.body;
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
return Object.fromEntries(Object.entries(body));
}
throw new TypeError('Expected request body to be an object.');
}
void test('validateRequest applies converted sanitized values to request parts', async () => { void test('validateRequest applies converted sanitized values to request parts', async () => {
const req = createRequest({ const req = createRequest({
query: { query: {
@ -145,3 +155,102 @@ void test('validateRequest rejects invalid schema maps during route setup', () =
/Unsupported request validation part/, /Unsupported request validation part/,
); );
}); });
void test('publish schemas reject malformed project ids before service calls', async () => {
const publishError = await runMiddleware(
validateRequest(publish.publish),
createRequest({
body: {
projectId: 'not-a-uuid',
title: 'Production',
},
}),
);
const saveToStageError = await runMiddleware(
validateRequest(publish.saveToStage),
createRequest({
body: {
projectId: 'not-a-uuid',
},
}),
);
assert.equal(publishError?.isRequestValidation, true);
assert.equal(saveToStageError?.isRequestValidation, true);
});
void test('file presign schema enforces non-empty capped URL batches', async () => {
const emptyError = await runMiddleware(
validateRequest(file.presign),
createRequest({ body: { urls: [] } }),
);
const tooManyError = await runMiddleware(
validateRequest(file.presign),
createRequest({
body: {
urls: Array.from({ length: 51 }, (_value, index) => `asset-${index}.jpg`),
},
}),
);
assert.equal(emptyError?.isRequestValidation, true);
assert.equal(tooManyError?.isRequestValidation, true);
});
void test('file upload schemas validate path params and convert chunk index', async () => {
const uploadError = await runMiddleware(
validateRequest(file.upload),
createRequest({
params: {
table: '../assets',
field: 'image',
},
}),
);
const chunkReq = createRequest({
params: {
sessionId: '094121b9-c567-469a-b256-ba221b7fd5d6',
chunkIndex: '7',
},
});
const chunkError = await runMiddleware(validateRequest(file.chunk), chunkReq);
assert.equal(uploadError?.isRequestValidation, true);
assert.equal(chunkError, null);
assert.equal(chunkReq.params.chunkIndex, 7);
});
void test('tour page reorder schema defaults dev environment and requires ordered ids', async () => {
const validReq = createRequest({
body: {
data: {
projectId: '094121b9-c567-469a-b256-ba221b7fd5d6',
orderedPageIds: ['6a373ead-7ff4-4f1c-9a69-29ff1e97420d'],
},
},
});
const validError = await runMiddleware(validateRequest(tourPages.reorder), validReq);
const missingOrderError = await runMiddleware(
validateRequest(tourPages.reorder),
createRequest({
body: {
data: {
projectId: '094121b9-c567-469a-b256-ba221b7fd5d6',
},
},
}),
);
assert.equal(validError, null);
const validatedData = getBodyRecord(validReq).data;
assert.equal(
validatedData !== null &&
typeof validatedData === 'object' &&
!Array.isArray(validatedData) &&
'environment' in validatedData
? validatedData.environment
: undefined,
'dev',
);
assert.equal(missingOrderError?.isRequestValidation, true);
});

View File

@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { NextFunction, Request, RequestHandler } from 'express';
import { createRequest, createResponse } from 'node-mocks-http';
import {
blockNonPublicRuntimeListEndpoints,
sanitizePublicRuntimeListResponse,
} from '../src/middlewares/runtime-public.ts';
import { setRuntimePublicRequest } from '../src/utils/request-context.ts';
function runMiddleware(
middleware: RequestHandler,
req: Request,
): Promise<boolean> {
const res = createResponse();
return new Promise((resolve, reject) => {
const next: NextFunction = (error?: unknown) => {
if (error) {
reject(
error instanceof Error
? error
: new Error('Middleware returned a non-Error callback value.'),
);
return;
}
resolve(true);
};
middleware(req, res, next);
if (res._isEndCalled()) {
resolve(false);
}
});
}
void test('blockNonPublicRuntimeListEndpoints blocks public runtime CSV exports', () => {
const req = createRequest({
method: 'GET',
url: '/?filetype=csv',
query: { filetype: 'csv' },
});
setRuntimePublicRequest(req, true);
const res = createResponse();
let nextCalled = false;
blockNonPublicRuntimeListEndpoints('projects')(req, res, () => {
nextCalled = true;
});
assert.equal(nextCalled, false);
assert.equal(res.statusCode, 404);
assert.deepEqual(res._getData(), { message: 'Not found' });
});
void test('blockNonPublicRuntimeListEndpoints blocks non-allowed public runtime paths', () => {
const req = createRequest({
method: 'GET',
url: '/autocomplete',
});
setRuntimePublicRequest(req, true);
const res = createResponse();
let nextCalled = false;
blockNonPublicRuntimeListEndpoints('projects')(req, res, () => {
nextCalled = true;
});
assert.equal(nextCalled, false);
assert.equal(res.statusCode, 404);
});
void test('blockNonPublicRuntimeListEndpoints allows configured runtime project settings path', async () => {
const req = createRequest({
method: 'GET',
url: '/project/094121b9-c567-469a-b256-ba221b7fd5d6/env/production',
});
setRuntimePublicRequest(req, true);
assert.equal(
await runMiddleware(
blockNonPublicRuntimeListEndpoints('project_transition_settings'),
req,
),
true,
);
});
void test('sanitizePublicRuntimeListResponse strips non-public fields from rows and records', () => {
const listReq = createRequest({
method: 'GET',
url: '/',
});
setRuntimePublicRequest(listReq, true);
const listRes = createResponse();
sanitizePublicRuntimeListResponse('projects')(listReq, listRes, () => {});
listRes.send({
rows: [
{
id: 'project-1',
name: 'Lobby',
slug: 'lobby',
secret: 'do-not-leak',
},
],
count: 1,
});
assert.deepEqual(listRes._getData(), {
rows: [
{
id: 'project-1',
name: 'Lobby',
slug: 'lobby',
},
],
count: 1,
});
const recordReq = createRequest({
method: 'GET',
url: '/',
});
setRuntimePublicRequest(recordReq, true);
const recordRes = createResponse();
sanitizePublicRuntimeListResponse('tour_pages')(recordReq, recordRes, () => {});
recordRes.send({
id: 'page-1',
slug: 'intro',
ui_schema_json: { elements: [] },
internal_note: 'do-not-leak',
});
assert.deepEqual(recordRes._getData(), {
id: 'page-1',
slug: 'intro',
ui_schema_json: { elements: [] },
});
});
void test('sanitizePublicRuntimeListResponse does not affect authenticated admin requests', () => {
const req = createRequest({
method: 'GET',
url: '/',
});
const res = createResponse();
sanitizePublicRuntimeListResponse('projects')(req, res, () => {});
res.send({
id: 'project-1',
slug: 'lobby',
secret: 'visible-to-admin-path',
});
assert.deepEqual(res._getData(), {
id: 'project-1',
slug: 'lobby',
secret: 'visible-to-admin-path',
});
});