diff --git a/backend/package.json b/backend/package.json index accf690..957c4fa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/services/publish.ts b/backend/src/services/publish.ts index 3b3e297..adb31c2 100644 --- a/backend/src/services/publish.ts +++ b/backend/src/services/publish.ts @@ -301,52 +301,45 @@ export default class PublishService { currentUser: PublishServiceCurrentUser | undefined, transaction: Transaction, ): Promise { - 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; diff --git a/backend/tests/check-permissions.test.ts b/backend/tests/check-permissions.test.ts index 5ea07f5..89168c9 100644 --- a/backend/tests/check-permissions.test.ts +++ b/backend/tests/check-permissions.test.ts @@ -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((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((resolve) => { + checkCrudPermissions('tour_pages')(req, res, (nextError?: unknown) => { + resolve(nextError instanceof Error ? nextError : null); + }); + }); + + assert.equal(error, null); +}); diff --git a/backend/tests/file-service.test.ts b/backend/tests/file-service.test.ts new file mode 100644 index 0000000..8a7b40e --- /dev/null +++ b/backend/tests/file-service.test.ts @@ -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; + } + } +}); diff --git a/backend/tests/integration/auth-service.test.ts b/backend/tests/integration/auth-service.test.ts new file mode 100644 index 0000000..4acb932 --- /dev/null +++ b/backend/tests/integration/auth-service.test.ts @@ -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 { + let timeoutId: NodeJS.Timeout | undefined; + const timeout = new Promise((_, 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, +): Promise { + 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 { + 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 { + 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); + }); +}); diff --git a/backend/tests/integration/publish-service.test.ts b/backend/tests/integration/publish-service.test.ts new file mode 100644 index 0000000..9b593f2 --- /dev/null +++ b/backend/tests/integration/publish-service.test.ts @@ -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 { + let timeoutId: NodeJS.Timeout | undefined; + const timeout = new Promise((_, 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, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + 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, + 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); + }); +}); diff --git a/backend/tests/request-validation.test.ts b/backend/tests/request-validation.test.ts index 65d039c..0b3e95d 100644 --- a/backend/tests/request-validation.test.ts +++ b/backend/tests/request-validation.test.ts @@ -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 { + 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); +}); diff --git a/backend/tests/runtime-public.test.ts b/backend/tests/runtime-public.test.ts new file mode 100644 index 0000000..f7f6fbd --- /dev/null +++ b/backend/tests/runtime-public.test.ts @@ -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 { + 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', + }); +});