diff --git a/backend/package.json b/backend/package.json index e207a55..accf690 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,8 @@ "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: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", "check:esm-boundaries": "node scripts/check-esm-boundaries.ts", "check:public-access": "node scripts/check-public-access-hardening.ts", diff --git a/backend/tests/e2e/runtime-context.test.ts b/backend/tests/e2e/runtime-context.test.ts new file mode 100644 index 0000000..53fdbda --- /dev/null +++ b/backend/tests/e2e/runtime-context.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import express from 'express'; +import test from 'node:test'; + +import { runtimeContextMiddleware } from '../../src/middlewares/runtime-context.ts'; +import runtimeContextRoutes from '../../src/routes/runtime-context.ts'; +import { startTestServer } from '../http-test-utils.ts'; + +function buildRuntimeContextApp() { + const app = express(); + app.use(runtimeContextMiddleware); + app.use('/api/runtime-context', runtimeContextRoutes); + return app; +} + +void test('GET /api/runtime-context returns default admin runtime context', async () => { + const server = await startTestServer(buildRuntimeContextApp()); + try { + const response = await fetch(`${server.baseUrl}/api/runtime-context`); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + mode: 'admin', + projectSlug: null, + }); + } finally { + await server.close(); + } +}); + +void test('GET /api/runtime-context exposes valid runtime headers over HTTP', async () => { + const server = await startTestServer(buildRuntimeContextApp()); + try { + const response = await fetch(`${server.baseUrl}/api/runtime-context`, { + headers: { + 'x-runtime-environment': 'production', + 'x-runtime-project-slug': 'museum-tour', + }, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + mode: 'admin', + projectSlug: null, + headerEnvironment: 'production', + headerProjectSlug: 'museum-tour', + }); + } finally { + await server.close(); + } +}); + +void test('GET /api/runtime-context ignores unsupported runtime environment headers', async () => { + const server = await startTestServer(buildRuntimeContextApp()); + try { + const response = await fetch(`${server.baseUrl}/api/runtime-context`, { + headers: { + 'x-runtime-environment': 'qa', + 'x-runtime-project-slug': 'museum-tour', + }, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + mode: 'admin', + projectSlug: null, + headerProjectSlug: 'museum-tour', + }); + } finally { + await server.close(); + } +}); diff --git a/backend/tests/http-test-utils.ts b/backend/tests/http-test-utils.ts new file mode 100644 index 0000000..30371a4 --- /dev/null +++ b/backend/tests/http-test-utils.ts @@ -0,0 +1,39 @@ +import http from 'node:http'; +import type { Express } from 'express'; + +export interface RunningTestServer { + baseUrl: string; + close: () => Promise; +} + +export function startTestServer(app: Express): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer(app); + + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + const address = server.address(); + + if (typeof address !== 'object' || address === null) { + server.close(); + reject(new Error('Test server did not expose a listening address.')); + return; + } + + resolve({ + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((closeResolve, closeReject) => { + server.close((error) => { + if (error) { + closeReject(error); + return; + } + closeResolve(); + }); + }), + }); + }); + }); +} diff --git a/backend/tests/integration/router-factory.test.ts b/backend/tests/integration/router-factory.test.ts new file mode 100644 index 0000000..61ce071 --- /dev/null +++ b/backend/tests/integration/router-factory.test.ts @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import express from 'express'; +import test from 'node:test'; +import type { RequestHandler } from 'express'; +import { createRequest, createResponse } from 'node-mocks-http'; +import type { Body, Headers, RequestMethod, RequestOptions } from 'node-mocks-http'; + +import { createEntityRouter } from '../../src/factories/router.factory.ts'; +import { commonErrorHandler } from '../../src/helpers.ts'; +import type { + EntityRouterDbApi, + EntityRouterService, +} from '../../src/types/index.ts'; +import { + setCurrentUser, + setRuntimeContext, +} from '../../src/utils/request-context.ts'; + +const USER_ID = '0f5cb907-73d3-45da-9e42-bf3e4b499efb'; +const ENTITY_ID = 'aa0b89ab-b0db-4d6c-a59c-c7785fc3eae5'; + +interface RecordedCall { + name: string; + payload: unknown; +} + +function contextMiddleware(): RequestHandler { + return (req, _res, next) => { + setCurrentUser(req, { + id: USER_ID, + app_role: { + name: 'Test Role', + permissions: [ + { name: 'READ_TEST_ENTITIES' }, + { name: 'CREATE_TEST_ENTITIES' }, + { name: 'UPDATE_TEST_ENTITIES' }, + { name: 'DELETE_TEST_ENTITIES' }, + ], + }, + }); + setRuntimeContext(req, { + mode: 'admin', + projectSlug: null, + headerEnvironment: 'stage', + headerProjectSlug: 'demo-tour', + }); + next(); + }; +} + +function buildHarness() { + const calls: RecordedCall[] = []; + const service: EntityRouterService, Record> = { + create: (options) => { + calls.push({ name: 'create', payload: options }); + return Promise.resolve({ id: ENTITY_ID, ...options.data }); + }, + update: (options) => { + calls.push({ name: 'update', payload: options }); + return Promise.resolve(true); + }, + remove: (options) => { + calls.push({ name: 'remove', payload: options }); + return Promise.resolve(true); + }, + deleteByIds: (options) => { + calls.push({ name: 'deleteByIds', payload: options }); + return Promise.resolve(true); + }, + bulkImport: () => { + calls.push({ name: 'bulkImport', payload: null }); + return Promise.resolve(); + }, + }; + const dbApi: EntityRouterDbApi = { + MODEL: { + rawAttributes: { + id: {}, + name: {}, + createdAt: {}, + }, + }, + findAll: (query, options) => { + calls.push({ name: 'findAll', payload: { query, options } }); + return Promise.resolve({ + rows: [{ id: ENTITY_ID, name: 'Lobby' }], + count: 1, + }); + }, + findAllAutocomplete: (options) => { + calls.push({ name: 'findAllAutocomplete', payload: options }); + return Promise.resolve([{ id: ENTITY_ID, label: 'Lobby' }]); + }, + findBy: (options) => { + calls.push({ name: 'findBy', payload: options }); + return Promise.resolve({ id: ENTITY_ID, name: 'Lobby' }); + }, + }; + + const app = express(); + app.use(contextMiddleware()); + app.use('/entities', createEntityRouter('test_entities', service, dbApi)); + app.use(commonErrorHandler); + + return { app, calls }; +} + +interface AppRequestOptions { + method: RequestMethod; + url: string; + headers?: Headers; + body?: Body; +} + +function dispatchApp(app: express.Express, options: AppRequestOptions) { + const requestOptions: RequestOptions = { + method: options.method, + url: options.url, + }; + if (options.headers !== undefined) { + requestOptions.headers = options.headers; + } + if (options.body !== undefined) { + requestOptions.body = options.body; + } + + const req = createRequest(requestOptions); + const res = createResponse({ eventEmitter: EventEmitter }); + + return new Promise((resolve, reject) => { + res.on('end', () => resolve(res)); + app(req, res, (error?: unknown) => { + if (error) { + reject( + error instanceof Error + ? error + : new Error('Express app returned a non-Error callback value.'), + ); + return; + } + resolve(res); + }); + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function asRecord(value: unknown): Record { + if (isRecord(value)) return value; + throw new TypeError('Expected value to be an object record.'); +} + +void test('createEntityRouter passes current user, runtime context, and host into create service calls', async () => { + const { app, calls } = buildHarness(); + const response = await dispatchApp(app, { + method: 'POST', + url: '/entities', + headers: { + referer: 'https://builder.example.test/projects', + }, + body: { data: { name: 'Lobby' } }, + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response._getData(), { id: ENTITY_ID, name: 'Lobby' }); + + const call = calls.find((item) => item.name === 'create'); + assert.ok(call); + const payload = asRecord(call.payload); + assert.equal(payload.host, 'https://builder.example.test'); + assert.equal(asRecord(payload.currentUser).id, USER_ID); + assert.equal( + asRecord(payload.runtimeContext).headerProjectSlug, + 'demo-tour', + ); +}); + +void test('createEntityRouter normalizes list query controls and preserves filters', async () => { + const { app, calls } = buildHarness(); + const response = await dispatchApp(app, { + method: 'GET', + url: '/entities?limit=1000&page=2&sort=asc&field=name&unknown=keep', + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response._getData(), { + rows: [{ id: ENTITY_ID, name: 'Lobby' }], + count: 1, + }); + + const call = calls.find((item) => item.name === 'findAll'); + assert.ok(call); + const payload = asRecord(call.payload); + assert.deepEqual(payload.query, { + limit: 1000, + page: 2, + unknown: 'keep', + sort: 'ASC', + field: 'name', + }); + assert.equal(asRecord(payload.options).currentUser !== undefined, true); +}); + +void test('createEntityRouter rejects mismatched body ids before update service call', async () => { + const { app, calls } = buildHarness(); + const response = await dispatchApp(app, { + method: 'PUT', + url: `/entities/${ENTITY_ID}`, + body: { + data: { + id: '6a373ead-7ff4-4f1c-9a69-29ff1e97420d', + name: 'Mismatch', + }, + }, + }); + + assert.equal(response.statusCode, 400); + assert.equal(response._getData(), 'Request body id does not match route id'); + assert.equal(calls.some((item) => item.name === 'update'), false); +}); + +void test('createEntityRouter emits CSV exports with configured fields', async () => { + const { app } = buildHarness(); + const response = await dispatchApp(app, { + method: 'GET', + url: '/entities?filetype=csv', + }); + + assert.equal(response.statusCode, 200); + const disposition: unknown = response.getHeader('content-disposition'); + if (typeof disposition !== 'string') { + throw new TypeError('Expected content-disposition response header.'); + } + assert.match(disposition, /export\.csv/); + const csvPayload: unknown = response._getData(); + if (typeof csvPayload !== 'string') { + throw new TypeError('Expected CSV response body.'); + } + assert.equal( + csvPayload.trim(), + '"id","createdAt"\n"aa0b89ab-b0db-4d6c-a59c-c7785fc3eae5",', + ); +}); diff --git a/backend/tests/upload-session-manager.test.ts b/backend/tests/upload-session-manager.test.ts new file mode 100644 index 0000000..095c1aa --- /dev/null +++ b/backend/tests/upload-session-manager.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import UploadSessionManager from '../src/services/file/UploadSessionManager.ts'; + +function makeSessionManager(ttlMs = 60_000): { + manager: UploadSessionManager; + rootDir: string; +} { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-sessions-')); + return { + manager: new UploadSessionManager({ sessionDir: rootDir, ttlMs }), + rootDir, + }; +} + +void test('UploadSessionManager creates metadata and tracks uploaded chunks', async () => { + const { manager, rootDir } = makeSessionManager(); + try { + const sessionId = manager.createSession({ + filename: 'tour.mp4', + folder: 'assets', + totalChunks: 2, + totalSize: 11, + userId: 'user-1', + contentType: 'video/mp4', + }); + + let meta = manager.readMeta(sessionId); + assert.equal(meta?.filename, 'tour.mp4'); + assert.equal(meta?.uploadedChunks !== undefined, true); + assert.equal(manager.isComplete(sessionId), false); + + await manager.saveChunk(sessionId, 0, Buffer.from('hello ')); + await manager.saveChunk(sessionId, 1, Buffer.from('world')); + + meta = manager.readMeta(sessionId); + assert.equal(meta?.uploadedChunks[0]?.size, 6); + assert.equal(meta?.uploadedChunks[1]?.size, 5); + assert.equal(manager.chunkExists(sessionId, 0), true); + assert.equal(manager.isComplete(sessionId), true); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + +void test('UploadSessionManager assembles chunks in index order', async () => { + const { manager, rootDir } = makeSessionManager(); + try { + const sessionId = manager.createSession({ + filename: 'image.bin', + folder: 'assets', + totalChunks: 3, + totalSize: 9, + }); + await manager.saveChunk(sessionId, 2, Buffer.from('ghi')); + await manager.saveChunk(sessionId, 0, Buffer.from('abc')); + await manager.saveChunk(sessionId, 1, Buffer.from('def')); + + const targetPath = path.join(rootDir, 'assembled', 'image.bin'); + await manager.assembleChunks(sessionId, targetPath); + + assert.equal(fs.readFileSync(targetPath, 'utf8'), 'abcdefghi'); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + +void test('UploadSessionManager removes expired and invalid sessions during cleanup', () => { + const { manager, rootDir } = makeSessionManager(1); + try { + const expiredSessionId = manager.createSession({ + filename: 'expired.txt', + folder: 'assets', + totalChunks: 1, + totalSize: 1, + }); + const meta = manager.readMeta(expiredSessionId); + assert.ok(meta); + meta.updatedAt = new Date(Date.now() - 10_000).toISOString(); + manager.writeMeta(expiredSessionId, meta); + + const invalidSessionId = 'invalid-session'; + const invalidSessionDir = manager.getSessionDir(invalidSessionId); + fs.mkdirSync(invalidSessionDir, { recursive: true }); + fs.writeFileSync( + manager.getMetaPath(invalidSessionId), + JSON.stringify({ sessionId: invalidSessionId }), + 'utf8', + ); + + manager.cleanupExpiredSessions(); + + assert.equal(fs.existsSync(manager.getSessionDir(expiredSessionId)), false); + assert.equal(fs.existsSync(invalidSessionDir), false); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +});