extended tests covetage
This commit is contained in:
parent
e1711b42c2
commit
57a705f3ba
@ -8,10 +8,10 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"build": "tsc -p tsconfig.json && node scripts/copy-runtime-assets.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: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:public-access": "node scripts/check-public-access-hardening.ts",
|
||||
"fix:public-access": "node scripts/check-public-access-hardening.ts --fix",
|
||||
|
||||
@ -301,52 +301,45 @@ export default class PublishService {
|
||||
currentUser: PublishServiceCurrentUser | undefined,
|
||||
transaction: Transaction,
|
||||
): Promise<PublishSummary> {
|
||||
const [
|
||||
sourcePages,
|
||||
sourceAudioTracks,
|
||||
sourceTransitionSettings,
|
||||
sourceUiControlSettings,
|
||||
] = await Promise.all([
|
||||
db.tour_pages.findAll({
|
||||
const sourcePages = await db.tour_pages.findAll({
|
||||
where: { projectId, environment: fromEnv },
|
||||
transaction,
|
||||
});
|
||||
const sourceAudioTracks = await db.project_audio_tracks.findAll({
|
||||
where: { projectId, environment: fromEnv },
|
||||
transaction,
|
||||
});
|
||||
const sourceTransitionSettings =
|
||||
await db.project_transition_settings.findOne({
|
||||
where: { projectId, environment: fromEnv },
|
||||
transaction,
|
||||
}),
|
||||
db.project_audio_tracks.findAll({
|
||||
});
|
||||
const sourceUiControlSettings =
|
||||
await db.project_ui_control_settings.findOne({
|
||||
where: { projectId, environment: fromEnv },
|
||||
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([
|
||||
db.tour_pages.destroy({
|
||||
where: { projectId, environment: toEnv },
|
||||
transaction,
|
||||
force: true,
|
||||
}),
|
||||
db.project_audio_tracks.destroy({
|
||||
where: { projectId, environment: toEnv },
|
||||
transaction,
|
||||
force: true,
|
||||
}),
|
||||
db.project_transition_settings.destroy({
|
||||
where: { projectId, environment: toEnv },
|
||||
transaction,
|
||||
force: true,
|
||||
}),
|
||||
db.project_ui_control_settings.destroy({
|
||||
where: { projectId, environment: toEnv },
|
||||
transaction,
|
||||
force: true,
|
||||
}),
|
||||
]);
|
||||
await db.tour_pages.destroy({
|
||||
where: { projectId, environment: toEnv },
|
||||
transaction,
|
||||
force: true,
|
||||
});
|
||||
await db.project_audio_tracks.destroy({
|
||||
where: { projectId, environment: toEnv },
|
||||
transaction,
|
||||
force: true,
|
||||
});
|
||||
await db.project_transition_settings.destroy({
|
||||
where: { projectId, environment: toEnv },
|
||||
transaction,
|
||||
force: true,
|
||||
});
|
||||
await db.project_ui_control_settings.destroy({
|
||||
where: { projectId, environment: toEnv },
|
||||
transaction,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const actorId = currentUser?.id || null;
|
||||
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createRequest, createResponse } from 'node-mocks-http';
|
||||
|
||||
import {
|
||||
checkCrudPermissions,
|
||||
getCrudPermissionName,
|
||||
} from '../src/middlewares/check-permissions.ts';
|
||||
import {
|
||||
setCurrentUser,
|
||||
setRuntimePublicRequest,
|
||||
} from '../src/utils/request-context.ts';
|
||||
|
||||
void test('getCrudPermissionName honors explicit permission override', () => {
|
||||
assert.equal(
|
||||
@ -22,3 +28,44 @@ void test('getCrudPermissionName keeps default method-derived permission without
|
||||
'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);
|
||||
});
|
||||
|
||||
57
backend/tests/file-service.test.ts
Normal file
57
backend/tests/file-service.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
184
backend/tests/integration/auth-service.test.ts
Normal file
184
backend/tests/integration/auth-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
456
backend/tests/integration/publish-service.test.ts
Normal file
456
backend/tests/integration/publish-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -8,6 +8,8 @@ import { commonErrorHandler } from '../src/helpers.ts';
|
||||
import { validateRequest } from '../src/middlewares/validate-request.ts';
|
||||
import {
|
||||
crud,
|
||||
file,
|
||||
publish,
|
||||
projects,
|
||||
tourPages,
|
||||
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 () => {
|
||||
const req = createRequest({
|
||||
query: {
|
||||
@ -145,3 +155,102 @@ void test('validateRequest rejects invalid schema maps during route setup', () =
|
||||
/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);
|
||||
});
|
||||
|
||||
164
backend/tests/runtime-public.test.ts
Normal file
164
backend/tests/runtime-public.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user