extended tests covetage
This commit is contained in:
parent
e1711b42c2
commit
57a705f3ba
@ -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",
|
||||||
|
|||||||
@ -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,
|
|
||||||
sourceAudioTracks,
|
|
||||||
sourceTransitionSettings,
|
|
||||||
sourceUiControlSettings,
|
|
||||||
] = await Promise.all([
|
|
||||||
db.tour_pages.findAll({
|
|
||||||
where: { projectId, environment: fromEnv },
|
where: { projectId, environment: fromEnv },
|
||||||
transaction,
|
transaction,
|
||||||
}),
|
});
|
||||||
db.project_audio_tracks.findAll({
|
const sourceAudioTracks = await db.project_audio_tracks.findAll({
|
||||||
where: { projectId, environment: fromEnv },
|
where: { projectId, environment: fromEnv },
|
||||||
transaction,
|
transaction,
|
||||||
}),
|
});
|
||||||
db.project_transition_settings.findOne({
|
const sourceTransitionSettings =
|
||||||
|
await db.project_transition_settings.findOne({
|
||||||
where: { projectId, environment: fromEnv },
|
where: { projectId, environment: fromEnv },
|
||||||
transaction,
|
transaction,
|
||||||
}),
|
});
|
||||||
db.project_ui_control_settings.findOne({
|
const sourceUiControlSettings =
|
||||||
|
await db.project_ui_control_settings.findOne({
|
||||||
where: { projectId, environment: fromEnv },
|
where: { projectId, environment: fromEnv },
|
||||||
transaction,
|
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,
|
||||||
}),
|
});
|
||||||
db.project_audio_tracks.destroy({
|
await db.project_audio_tracks.destroy({
|
||||||
where: { projectId, environment: toEnv },
|
where: { projectId, environment: toEnv },
|
||||||
transaction,
|
transaction,
|
||||||
force: true,
|
force: true,
|
||||||
}),
|
});
|
||||||
db.project_transition_settings.destroy({
|
await db.project_transition_settings.destroy({
|
||||||
where: { projectId, environment: toEnv },
|
where: { projectId, environment: toEnv },
|
||||||
transaction,
|
transaction,
|
||||||
force: true,
|
force: true,
|
||||||
}),
|
});
|
||||||
db.project_ui_control_settings.destroy({
|
await 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;
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
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 { 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);
|
||||||
|
});
|
||||||
|
|||||||
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