mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
test: remove infrastructure-dependent integration suites (#1573)
This commit is contained in:
1 parent
f88b0f69b2
commit
10fc79ab37
199 files changed
+688
-46963
No files matched your search
@@ -1,249 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {existsSync} from 'node:fs';
|
||||
import {getDefaultPostgresClient, initPostgres, quoteIdentifier, shutdownPostgres} from '@pkgs/postgres/src/Client';
|
||||
import cassandra from 'cassandra-driver';
|
||||
import {afterAll, beforeAll, describe, expect, test} from 'vitest';
|
||||
import {defineTable} from './CassandraTableDsl';
|
||||
import {Db} from './CassandraTypes';
|
||||
import {ensurePostgresKvSchema, PostgresKvQueryExecutor, pruneExpiredPostgresKvRows} from './PostgresKvQueryExecutor';
|
||||
|
||||
interface TestRow {
|
||||
tenant_id: string;
|
||||
item_id: bigint;
|
||||
created_at: Date;
|
||||
payload: Buffer | null;
|
||||
tags: Set<string> | null;
|
||||
counts: Map<string, bigint> | null;
|
||||
local_day: cassandra.types.LocalDate | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
function postgresHost(): string {
|
||||
return process.env.FLUXER_POSTGRES_TEST_HOST ?? (existsSync('/.dockerenv') ? 'host.docker.internal' : '127.0.0.1');
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const kvTable = `fluxer_kv_test_${randomUUID().replaceAll('-', '_')}`;
|
||||
const logicalTableName = `pg_kv_test_${randomUUID().replaceAll('-', '_')}`;
|
||||
const TestRows = defineTable<TestRow, 'tenant_id' | 'item_id', 'tenant_id'>({
|
||||
name: logicalTableName,
|
||||
columns: ['tenant_id', 'item_id', 'created_at', 'payload', 'tags', 'counts', 'local_day', 'note'],
|
||||
primaryKey: ['tenant_id', 'item_id'],
|
||||
partitionKey: ['tenant_id'],
|
||||
});
|
||||
|
||||
describe('PostgresKvQueryExecutor', () => {
|
||||
let executor: PostgresKvQueryExecutor;
|
||||
let initialized = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initPostgres({
|
||||
host: postgresHost(),
|
||||
port: Number(process.env.FLUXER_POSTGRES_PORT ?? 5432),
|
||||
database: process.env.FLUXER_POSTGRES_DATABASE ?? 'fluxer',
|
||||
username: process.env.FLUXER_POSTGRES_USERNAME ?? 'fluxer',
|
||||
password: process.env.FLUXER_POSTGRES_PASSWORD ?? 'fluxer',
|
||||
kvTable,
|
||||
maxConnections: 4,
|
||||
});
|
||||
initialized = true;
|
||||
const client = getDefaultPostgresClient();
|
||||
await ensurePostgresKvSchema(client);
|
||||
executor = new PostgresKvQueryExecutor(client);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!initialized) return;
|
||||
try {
|
||||
await getDefaultPostgresClient().query(`DROP TABLE IF EXISTS ${quoteIdentifier(kvTable)}`);
|
||||
} finally {
|
||||
await shutdownPostgres();
|
||||
}
|
||||
});
|
||||
|
||||
test('round trips typed values and conditional writes through a real Postgres KV table', async () => {
|
||||
const row: TestRow = {
|
||||
tenant_id: 'tenant-a',
|
||||
item_id: 10n,
|
||||
created_at: new Date('2026-01-02T03:04:05.006Z'),
|
||||
payload: Buffer.from('hello postgres kv'),
|
||||
tags: new Set(['alpha', 'beta']),
|
||||
counts: new Map([['seen', 5n]]),
|
||||
local_day: cassandra.types.LocalDate.fromString('2026-01-02'),
|
||||
note: 'first',
|
||||
};
|
||||
|
||||
await expect(executor.executeQuery<{['[applied]']: boolean}>(TestRows.insertIfNotExists(row))).resolves.toEqual([
|
||||
{'[applied]': true},
|
||||
]);
|
||||
await expect(
|
||||
executor.executeQuery<{['[applied]']: boolean}>(TestRows.insertIfNotExists({...row, note: 'second'})),
|
||||
).resolves.toEqual([{'[applied]': false}]);
|
||||
|
||||
const fetched = await executor.executeQuery<TestRow>(
|
||||
TestRows.select({
|
||||
where: [TestRows.where.eq('tenant_id'), TestRows.where.eq('item_id')],
|
||||
limit: 1,
|
||||
}).bind({tenant_id: 'tenant-a', item_id: 10n}),
|
||||
);
|
||||
|
||||
expect(fetched).toHaveLength(1);
|
||||
expect(fetched[0]!.item_id).toBe(10n);
|
||||
expect(fetched[0]!.created_at.getTime()).toBe(row.created_at.getTime());
|
||||
expect(fetched[0]!.payload?.equals(row.payload!)).toBe(true);
|
||||
expect([...fetched[0]!.tags!.values()].sort()).toEqual(['alpha', 'beta']);
|
||||
expect(fetched[0]!.counts!.get('seen')).toBe(5n);
|
||||
expect(fetched[0]!.local_day?.toString()).toBe('2026-01-02');
|
||||
expect(fetched[0]!.note).toBe('first');
|
||||
});
|
||||
|
||||
test('filters, orders, patches, pages, and deletes logical rows', async () => {
|
||||
await executor.executeBatch(
|
||||
[
|
||||
TestRows.upsertAll({
|
||||
tenant_id: 'tenant-b',
|
||||
item_id: 1n,
|
||||
created_at: new Date('2026-02-01T00:00:00.000Z'),
|
||||
payload: null,
|
||||
tags: null,
|
||||
counts: null,
|
||||
local_day: null,
|
||||
note: 'one',
|
||||
}),
|
||||
TestRows.upsertAll({
|
||||
tenant_id: 'tenant-b',
|
||||
item_id: 2n,
|
||||
created_at: new Date('2026-02-02T00:00:00.000Z'),
|
||||
payload: null,
|
||||
tags: null,
|
||||
counts: null,
|
||||
local_day: null,
|
||||
note: 'two',
|
||||
}),
|
||||
TestRows.upsertAll({
|
||||
tenant_id: 'tenant-b',
|
||||
item_id: 3n,
|
||||
created_at: new Date('2026-02-03T00:00:00.000Z'),
|
||||
payload: null,
|
||||
tags: null,
|
||||
counts: null,
|
||||
local_day: null,
|
||||
note: 'three',
|
||||
}),
|
||||
].map((query) => ({query: query.cql, params: query.params, meta: query.kvMeta})),
|
||||
);
|
||||
|
||||
const firstPage = await executor.executePagedQuery<TestRow>(
|
||||
TestRows.select({
|
||||
where: TestRows.where.eq('tenant_id'),
|
||||
orderBy: {col: 'item_id', direction: 'DESC'},
|
||||
}).bind({tenant_id: 'tenant-b'}),
|
||||
{pageSize: 2},
|
||||
);
|
||||
expect(firstPage.rows.map((row) => row.item_id)).toEqual([3n, 2n]);
|
||||
expect(firstPage.pageState).not.toBeNull();
|
||||
|
||||
const secondPage = await executor.executePagedQuery<TestRow>(
|
||||
TestRows.select({
|
||||
where: TestRows.where.eq('tenant_id'),
|
||||
orderBy: {col: 'item_id', direction: 'DESC'},
|
||||
}).bind({tenant_id: 'tenant-b'}),
|
||||
{pageSize: 2, pageState: firstPage.pageState},
|
||||
);
|
||||
expect(secondPage.rows.map((row) => row.item_id)).toEqual([1n]);
|
||||
expect(secondPage.pageState).toBeNull();
|
||||
|
||||
await executor.executeQuery(TestRows.patchByPk({tenant_id: 'tenant-b', item_id: 2n}, {note: Db.clear()}));
|
||||
const patched = await executor.executeQuery<TestRow>(
|
||||
TestRows.select({where: [TestRows.where.eq('tenant_id'), TestRows.where.eq('item_id')], limit: 1}).bind({
|
||||
tenant_id: 'tenant-b',
|
||||
item_id: 2n,
|
||||
}),
|
||||
);
|
||||
expect(patched[0]!.note).toBeNull();
|
||||
|
||||
await executor.executeQuery(TestRows.delete({where: TestRows.where.eq('tenant_id')}).bind({tenant_id: 'tenant-b'}));
|
||||
const remaining = await executor.executeQuery<TestRow>(
|
||||
TestRows.select({where: TestRows.where.eq('tenant_id')}).bind({tenant_id: 'tenant-b'}),
|
||||
);
|
||||
expect(remaining).toEqual([]);
|
||||
});
|
||||
|
||||
test('filters expired rows and prunes them physically', async () => {
|
||||
const client = getDefaultPostgresClient();
|
||||
await executor.executeQuery(
|
||||
TestRows.upsertAllWithTtl(
|
||||
{
|
||||
tenant_id: 'tenant-expired',
|
||||
item_id: 1n,
|
||||
created_at: new Date('2026-03-01T00:00:00.000Z'),
|
||||
payload: null,
|
||||
tags: null,
|
||||
counts: null,
|
||||
local_day: null,
|
||||
note: 'temporary',
|
||||
},
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
await sleep(1100);
|
||||
|
||||
const visibleRows = await executor.executeQuery<TestRow>(
|
||||
TestRows.select({where: TestRows.where.eq('tenant_id')}).bind({tenant_id: 'tenant-expired'}),
|
||||
);
|
||||
expect(visibleRows).toEqual([]);
|
||||
|
||||
const physicalBefore = await client.query<{count: string}>(
|
||||
`SELECT count(*)::text AS count FROM ${quoteIdentifier(kvTable)} WHERE table_name = $1 AND row_data ->> 'tenant_id' = $2`,
|
||||
[logicalTableName, 'tenant-expired'],
|
||||
);
|
||||
expect(Number(physicalBefore.rows[0]!.count)).toBe(1);
|
||||
|
||||
const pruned = await pruneExpiredPostgresKvRows(client, 10);
|
||||
expect(pruned).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const physicalAfter = await client.query<{count: string}>(
|
||||
`SELECT count(*)::text AS count FROM ${quoteIdentifier(kvTable)} WHERE table_name = $1 AND row_data ->> 'tenant_id' = $2`,
|
||||
[logicalTableName, 'tenant-expired'],
|
||||
);
|
||||
expect(Number(physicalAfter.rows[0]!.count)).toBe(0);
|
||||
});
|
||||
|
||||
test('patching a logically expired row makes it visible again without keeping stale TTL', async () => {
|
||||
await executor.executeQuery(
|
||||
TestRows.upsertAllWithTtl(
|
||||
{
|
||||
tenant_id: 'tenant-resurrect',
|
||||
item_id: 1n,
|
||||
created_at: new Date('2026-04-01T00:00:00.000Z'),
|
||||
payload: null,
|
||||
tags: null,
|
||||
counts: null,
|
||||
local_day: null,
|
||||
note: 'temporary',
|
||||
},
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
await sleep(1100);
|
||||
await executor.executeQuery(
|
||||
TestRows.patchByPk({tenant_id: 'tenant-resurrect', item_id: 1n}, {note: Db.set('resurrected')}),
|
||||
);
|
||||
|
||||
const rows = await executor.executeQuery<TestRow>(
|
||||
TestRows.select({
|
||||
where: [TestRows.where.eq('tenant_id'), TestRows.where.eq('item_id')],
|
||||
limit: 1,
|
||||
}).bind({tenant_id: 'tenant-resurrect', item_id: 1n}),
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.note).toBe('resurrected');
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import http from 'node:http';
|
||||
import type {AddressInfo} from 'node:net';
|
||||
import {type FakeNcmecFailure, FakeNcmecServer} from './FakeNcmecServer';
|
||||
|
||||
const host = process.env.FAKE_NCMEC_HOST ?? '127.0.0.1';
|
||||
const port = readPort('FAKE_NCMEC_PORT', 9090);
|
||||
const controlHost = process.env.FAKE_NCMEC_CONTROL_HOST ?? '127.0.0.1';
|
||||
const controlPort = readPort('FAKE_NCMEC_CONTROL_PORT', port + 1);
|
||||
const publicHost = process.env.FAKE_NCMEC_PUBLIC_HOST ?? (host === '0.0.0.0' ? '<reachable-host-or-ip>' : host);
|
||||
const username = process.env.FAKE_NCMEC_USERNAME ?? 'usr123';
|
||||
const password = process.env.FAKE_NCMEC_PASSWORD ?? 'pswd123';
|
||||
|
||||
if (host === controlHost && port === controlPort) {
|
||||
throw new Error('FAKE_NCMEC_CONTROL_PORT must differ from FAKE_NCMEC_PORT when both servers bind the same host');
|
||||
}
|
||||
|
||||
const fake = new FakeNcmecServer({username, password});
|
||||
|
||||
function log(...parts: Array<unknown>): void {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[fake-ncmec ${ts}]`, ...parts);
|
||||
}
|
||||
|
||||
await fake.start({
|
||||
host,
|
||||
port,
|
||||
onRequest: (entry) => {
|
||||
log(
|
||||
`${entry.remoteAddress ?? '?'} ${entry.method} ${entry.path} -> ${entry.statusCode} ${entry.durationMs}ms (${entry.requestId})`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const controlServer = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
log(`control ${req.method} ${url.pathname}`);
|
||||
if (req.method === 'GET' && url.pathname === '/_state') {
|
||||
res.writeHead(200, {'content-type': 'application/json'});
|
||||
res.end(JSON.stringify(fake.getReports(), null, 2));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname.startsWith('/_report/')) {
|
||||
const reportId = decodeURIComponent(url.pathname.slice('/_report/'.length));
|
||||
const report = fake.getReport(reportId);
|
||||
if (!report) {
|
||||
res.writeHead(404, {'content-type': 'application/json'});
|
||||
res.end(JSON.stringify({error: `Unknown report ${reportId}`}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {'content-type': 'application/json'});
|
||||
res.end(JSON.stringify(report, null, 2));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/_reset') {
|
||||
fake.reset();
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/_fail') {
|
||||
const mode = (url.searchParams.get('mode') ?? 'none') as FakeNcmecFailure;
|
||||
if (!VALID_FAILURE_MODES.has(mode)) {
|
||||
res.writeHead(400, {'content-type': 'application/json'});
|
||||
res.end(JSON.stringify({error: `Invalid failure mode ${mode}`}));
|
||||
return;
|
||||
}
|
||||
fake.setFailure(mode);
|
||||
res.writeHead(200, {'content-type': 'application/json'});
|
||||
res.end(JSON.stringify({failure: mode}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found. Try GET /_state, POST /_reset, POST /_fail?mode=...');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => controlServer.listen(controlPort, controlHost, () => resolve()));
|
||||
|
||||
const address = controlServer.address() as AddressInfo;
|
||||
|
||||
log(`NCMEC-compatible fake server ready`);
|
||||
|
||||
log(` NCMEC bind → http://${fake.host}:${fake.port}/ispws`);
|
||||
|
||||
log(` NCMEC base URL → http://${publicHost}:${fake.port}/ispws`);
|
||||
|
||||
log(` control endpoint → http://${address.address}:${address.port}/_state`);
|
||||
|
||||
log(` credentials → ${username}:${password}`);
|
||||
|
||||
if (publicHost.startsWith('<')) {
|
||||
log(' set FAKE_NCMEC_PUBLIC_HOST to the hostname or IP your prod API can reach');
|
||||
}
|
||||
|
||||
log(' point prod Config.ncmec.baseUrl at the NCMEC base URL above');
|
||||
|
||||
function shutdown(signal: string): void {
|
||||
log(`received ${signal}, shutting down`);
|
||||
Promise.allSettled([fake.stop(), new Promise<void>((resolve) => controlServer.close(() => resolve()))]).then(() =>
|
||||
process.exit(0),
|
||||
);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
const VALID_FAILURE_MODES = new Set<FakeNcmecFailure>([
|
||||
'none',
|
||||
'submit',
|
||||
'upload',
|
||||
'fileinfo',
|
||||
'finish',
|
||||
'retract',
|
||||
'unauthorized',
|
||||
]);
|
||||
|
||||
function readPort(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
||||
throw new Error(`${name} must be an integer between 0 and 65535`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import type {AddressInfo} from 'node:net';
|
||||
import {Readable} from 'node:stream';
|
||||
import {XMLParser, XMLValidator} from 'fast-xml-parser';
|
||||
|
||||
export type FakeNcmecFailure = 'none' | 'submit' | 'upload' | 'fileinfo' | 'finish' | 'retract' | 'unauthorized';
|
||||
|
||||
export interface FakeNcmecFileRecord {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
md5: string;
|
||||
submittedDetails: boolean;
|
||||
detailsXml: string | null;
|
||||
}
|
||||
|
||||
export interface FakeNcmecReportRecord {
|
||||
reportId: string;
|
||||
reportXml: string;
|
||||
files: Array<FakeNcmecFileRecord>;
|
||||
finished: boolean;
|
||||
retracted: boolean;
|
||||
authUsername: string;
|
||||
}
|
||||
|
||||
interface FakeNcmecServerOptions {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface FakeNcmecServerStartOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
onRequest?: (entry: FakeNcmecRequestLogEntry) => void;
|
||||
}
|
||||
|
||||
interface FakeNcmecRequestLogEntry {
|
||||
method: string;
|
||||
path: string;
|
||||
statusCode: number;
|
||||
requestId: string;
|
||||
durationMs: number;
|
||||
remoteAddress: string | null;
|
||||
}
|
||||
|
||||
const xmlParser = new XMLParser({ignoreAttributes: false, parseTagValue: false, trimValues: true});
|
||||
|
||||
export class FakeNcmecServer {
|
||||
private server: http.Server | null = null;
|
||||
private listenPort = 0;
|
||||
private listenHost = '127.0.0.1';
|
||||
private onRequest: ((entry: FakeNcmecRequestLogEntry) => void) | null = null;
|
||||
private reportCounter = 1000000;
|
||||
private fileCounter = 1;
|
||||
private requestCounter = 1;
|
||||
private readonly reports = new Map<string, FakeNcmecReportRecord>();
|
||||
private failureMode: FakeNcmecFailure = 'none';
|
||||
|
||||
constructor(private readonly options: FakeNcmecServerOptions) {}
|
||||
|
||||
async start(startOptions: FakeNcmecServerStartOptions = {}): Promise<void> {
|
||||
if (this.server) {
|
||||
throw new Error('FakeNcmecServer already started');
|
||||
}
|
||||
this.listenHost = startOptions.host ?? '127.0.0.1';
|
||||
this.onRequest = startOptions.onRequest ?? null;
|
||||
this.server = http.createServer((req, res) => this.handle(req, res));
|
||||
await new Promise<void>((resolve) => this.server!.listen(startOptions.port ?? 0, this.listenHost, () => resolve()));
|
||||
this.listenPort = (this.server!.address() as AddressInfo).port;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.server) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
this.server = null;
|
||||
this.listenPort = 0;
|
||||
this.onRequest = null;
|
||||
}
|
||||
|
||||
get baseUrl(): string {
|
||||
if (!this.server) throw new Error('FakeNcmecServer not started');
|
||||
return `http://${this.listenHost}:${this.listenPort}/ispws`;
|
||||
}
|
||||
|
||||
get host(): string {
|
||||
if (!this.server) throw new Error('FakeNcmecServer not started');
|
||||
return this.listenHost;
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
if (!this.server) throw new Error('FakeNcmecServer not started');
|
||||
return this.listenPort;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.reports.clear();
|
||||
this.reportCounter = 1000000;
|
||||
this.fileCounter = 1;
|
||||
this.requestCounter = 1;
|
||||
this.failureMode = 'none';
|
||||
}
|
||||
|
||||
setFailure(mode: FakeNcmecFailure): void {
|
||||
this.failureMode = mode;
|
||||
}
|
||||
|
||||
getReports(): Array<FakeNcmecReportRecord> {
|
||||
return [...this.reports.values()];
|
||||
}
|
||||
|
||||
getReport(reportId: string): FakeNcmecReportRecord | null {
|
||||
return this.reports.get(reportId) ?? null;
|
||||
}
|
||||
|
||||
private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
const pathname = url.pathname.replace(/^\/ispws/, '') || '/';
|
||||
const requestId = `req-${this.requestCounter++}`;
|
||||
const startedAt = Date.now();
|
||||
res.setHeader('Request-ID', requestId);
|
||||
res.on('finish', () => {
|
||||
this.onRequest?.({
|
||||
method: req.method ?? 'GET',
|
||||
path: pathname,
|
||||
statusCode: res.statusCode,
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
remoteAddress: req.socket.remoteAddress ?? null,
|
||||
});
|
||||
});
|
||||
if (this.failureMode === 'unauthorized') {
|
||||
return this.sendStatus(res, 401, '');
|
||||
}
|
||||
if (!this.checkBasicAuth(req)) {
|
||||
return this.sendStatus(res, 401, '');
|
||||
}
|
||||
const injectedHeader = (req.headers['x-test-fail'] ?? '').toString() as FakeNcmecFailure;
|
||||
const effectiveFailure: FakeNcmecFailure = injectedHeader || this.failureMode;
|
||||
if (req.method === 'GET' && pathname === '/status') {
|
||||
const remoteIp = req.socket.remoteAddress ?? 'unknown';
|
||||
return this.sendXml(
|
||||
res,
|
||||
200,
|
||||
ok(
|
||||
`<responseDescription>Remote User : ${this.options.username}, Remote Ip : ${remoteIp}</responseDescription>`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/xsd') {
|
||||
return this.sendText(res, 200, '<schema></schema>');
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
return this.sendStatus(res, 405, '');
|
||||
}
|
||||
const request = await this.toWebRequest(req, url);
|
||||
switch (pathname) {
|
||||
case '/submit':
|
||||
return this.handleSubmit(request, res, effectiveFailure);
|
||||
case '/upload':
|
||||
return this.handleUpload(request, res, effectiveFailure);
|
||||
case '/fileinfo':
|
||||
return this.handleFileInfo(request, res, effectiveFailure);
|
||||
case '/finish':
|
||||
return this.handleFinish(request, res, effectiveFailure);
|
||||
case '/retract':
|
||||
return this.handleRetract(request, res, effectiveFailure);
|
||||
default:
|
||||
return this.sendStatus(res, 404, '');
|
||||
}
|
||||
} catch (error) {
|
||||
this.sendStatus(res, 500, String(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSubmit(request: Request, res: http.ServerResponse, failure: FakeNcmecFailure): Promise<void> {
|
||||
if (failure === 'submit') {
|
||||
return this.sendXml(res, 200, errorXml(1100, 'Save failed'));
|
||||
}
|
||||
if (!isXmlContentType(request)) {
|
||||
return this.sendXml(res, 200, errorXml(4000, 'Invalid request'));
|
||||
}
|
||||
const body = await request.text();
|
||||
if (XMLValidator.validate(body) !== true) {
|
||||
return this.sendXml(res, 200, errorXml(4110, 'Malformed XML submittal'));
|
||||
}
|
||||
const report = parseReportDocument(body);
|
||||
if (!hasRequiredReportFields(report)) {
|
||||
return this.sendXml(res, 200, errorXml(4100, 'Validation failed'));
|
||||
}
|
||||
const reportId = String(this.reportCounter++);
|
||||
this.reports.set(reportId, {
|
||||
reportId,
|
||||
reportXml: body,
|
||||
files: [],
|
||||
finished: false,
|
||||
retracted: false,
|
||||
authUsername: this.options.username,
|
||||
});
|
||||
return this.sendXml(res, 200, ok(`<reportId>${reportId}</reportId>`));
|
||||
}
|
||||
|
||||
private async handleUpload(request: Request, res: http.ServerResponse, failure: FakeNcmecFailure): Promise<void> {
|
||||
if (failure === 'upload') {
|
||||
return this.sendXml(res, 200, errorXml(1111, 'File upload failed'));
|
||||
}
|
||||
const form = await request.formData();
|
||||
const reportId = String(form.get('id') ?? '');
|
||||
const file = form.get('file');
|
||||
const record = this.reports.get(reportId);
|
||||
if (!reportId || !record) {
|
||||
return this.sendXml(res, 200, errorXml(5001, 'Report does not exist'));
|
||||
}
|
||||
if (record.retracted) {
|
||||
return this.sendXml(res, 200, errorXml(5101, 'Report already retracted'));
|
||||
}
|
||||
if (record.finished) {
|
||||
return this.sendXml(res, 200, errorXml(5102, 'Report already finished'));
|
||||
}
|
||||
if (!(file instanceof Blob)) {
|
||||
return this.sendXml(res, 200, errorXml(4200, 'Malformed file submittal'));
|
||||
}
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const md5 = crypto.createHash('md5').update(buffer).digest('hex');
|
||||
const fileId = `file-${this.fileCounter++}`;
|
||||
record.files.push({
|
||||
fileId,
|
||||
filename: (file as File).name || 'upload.bin',
|
||||
size: buffer.byteLength,
|
||||
md5,
|
||||
submittedDetails: false,
|
||||
detailsXml: null,
|
||||
});
|
||||
return this.sendXml(res, 200, ok(`<reportId>${reportId}</reportId><fileId>${fileId}</fileId><hash>${md5}</hash>`));
|
||||
}
|
||||
|
||||
private async handleFileInfo(request: Request, res: http.ServerResponse, failure: FakeNcmecFailure): Promise<void> {
|
||||
if (failure === 'fileinfo') {
|
||||
return this.sendXml(res, 200, errorXml(1300, 'Update failed'));
|
||||
}
|
||||
if (!isXmlContentType(request)) {
|
||||
return this.sendXml(res, 200, errorXml(4000, 'Invalid request'));
|
||||
}
|
||||
const body = await request.text();
|
||||
if (XMLValidator.validate(body) !== true) {
|
||||
return this.sendXml(res, 200, errorXml(4110, 'Malformed XML submittal'));
|
||||
}
|
||||
const fileDetails = parseFileDetailsDocument(body);
|
||||
if (!hasFileDetailsFields(fileDetails)) {
|
||||
return this.sendXml(res, 200, errorXml(4100, 'Validation failed'));
|
||||
}
|
||||
const reportId = nonBlankString(fileDetails?.reportId);
|
||||
const fileId = nonBlankString(fileDetails?.fileId);
|
||||
const record = reportId ? this.reports.get(reportId) : null;
|
||||
if (!record) {
|
||||
return this.sendXml(res, 200, errorXml(5001, 'Report does not exist'));
|
||||
}
|
||||
if (record.retracted) {
|
||||
return this.sendXml(res, 200, errorXml(5101, 'Report already retracted'));
|
||||
}
|
||||
if (record.finished) {
|
||||
return this.sendXml(res, 200, errorXml(5102, 'Report already finished'));
|
||||
}
|
||||
const file = record.files.find((f) => f.fileId === fileId);
|
||||
if (!file) {
|
||||
return this.sendXml(res, 200, errorXml(5002, 'File does not exist'));
|
||||
}
|
||||
file.submittedDetails = true;
|
||||
file.detailsXml = body;
|
||||
return this.sendXml(res, 200, ok(`<reportId>${reportId}</reportId>`));
|
||||
}
|
||||
|
||||
private async handleFinish(request: Request, res: http.ServerResponse, failure: FakeNcmecFailure): Promise<void> {
|
||||
if (failure === 'finish') {
|
||||
return this.sendXml(res, 200, errorXml(1300, 'Update failed'));
|
||||
}
|
||||
const form = await request.formData();
|
||||
const reportId = String(form.get('id') ?? '');
|
||||
const record = this.reports.get(reportId);
|
||||
if (!record) {
|
||||
return this.sendXml(res, 200, errorXml(5001, 'Report does not exist'));
|
||||
}
|
||||
if (record.retracted) {
|
||||
return this.sendXml(res, 200, errorXml(5101, 'Report already retracted'));
|
||||
}
|
||||
if (record.finished) {
|
||||
return this.sendXml(res, 200, errorXml(5102, 'Report already finished'));
|
||||
}
|
||||
record.finished = true;
|
||||
const files = record.files.map((f) => ` <fileId>${f.fileId}</fileId>`).join('\n');
|
||||
const body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<reportDoneResponse>
|
||||
<responseCode>0</responseCode>
|
||||
<reportId>${reportId}</reportId>
|
||||
<files>
|
||||
${files}
|
||||
</files>
|
||||
</reportDoneResponse>`;
|
||||
return this.sendXml(res, 200, body);
|
||||
}
|
||||
|
||||
private async handleRetract(request: Request, res: http.ServerResponse, failure: FakeNcmecFailure): Promise<void> {
|
||||
if (failure === 'retract') {
|
||||
return this.sendXml(res, 200, errorXml(1300, 'Update failed'));
|
||||
}
|
||||
const form = await request.formData();
|
||||
const reportId = String(form.get('id') ?? '');
|
||||
const record = this.reports.get(reportId);
|
||||
if (!record) {
|
||||
return this.sendXml(res, 200, errorXml(5001, 'Report does not exist'));
|
||||
}
|
||||
if (record.retracted) {
|
||||
return this.sendXml(res, 200, errorXml(5101, 'Report already retracted'));
|
||||
}
|
||||
if (record.finished) {
|
||||
return this.sendXml(res, 200, errorXml(5102, 'Report already finished'));
|
||||
}
|
||||
record.retracted = true;
|
||||
return this.sendXml(res, 200, ok(`<reportId>${reportId}</reportId>`));
|
||||
}
|
||||
|
||||
private checkBasicAuth(req: http.IncomingMessage): boolean {
|
||||
const header = req.headers.authorization ?? '';
|
||||
if (!header.toLowerCase().startsWith('basic ')) return false;
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf-8');
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx === -1) return false;
|
||||
const user = decoded.slice(0, idx);
|
||||
const pass = decoded.slice(idx + 1);
|
||||
return user === this.options.username && pass === this.options.password;
|
||||
}
|
||||
|
||||
private async toWebRequest(req: http.IncomingMessage, url: URL): Promise<Request> {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
if (v === undefined) continue;
|
||||
if (Array.isArray(v)) headers.set(k, v.join(','));
|
||||
else headers.set(k, String(v));
|
||||
}
|
||||
const method = req.method ?? 'GET';
|
||||
const body = method === 'GET' || method === 'HEAD' ? undefined : (Readable.toWeb(req) as ReadableStream);
|
||||
return new Request(url, {method, headers, body, duplex: 'half'} as RequestInit & {
|
||||
duplex: 'half';
|
||||
});
|
||||
}
|
||||
|
||||
private sendXml(res: http.ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status, {'content-type': 'application/xml; charset=utf-8'});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
private sendText(res: http.ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status, {'content-type': 'text/plain; charset=utf-8'});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
private sendStatus(res: http.ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status);
|
||||
res.end(body);
|
||||
}
|
||||
}
|
||||
|
||||
function ok(extra: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<reportResponse>
|
||||
<responseCode>0</responseCode>
|
||||
<responseDescription>Success</responseDescription>
|
||||
${extra}
|
||||
</reportResponse>`;
|
||||
}
|
||||
|
||||
function errorXml(code: number, description: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<reportResponse>
|
||||
<responseCode>${code}</responseCode>
|
||||
<responseDescription>${description}</responseDescription>
|
||||
</reportResponse>`;
|
||||
}
|
||||
|
||||
function isXmlContentType(request: Request): boolean {
|
||||
return (request.headers.get('content-type') ?? '').toLowerCase().includes('text/xml');
|
||||
}
|
||||
|
||||
function parseReportDocument(xml: string): Record<string, unknown> | null {
|
||||
const parsed = xmlParser.parse(xml) as Record<string, unknown>;
|
||||
return objectField(parsed, 'report');
|
||||
}
|
||||
|
||||
function parseFileDetailsDocument(xml: string): Record<string, unknown> | null {
|
||||
const parsed = xmlParser.parse(xml) as Record<string, unknown>;
|
||||
return objectField(parsed, 'fileDetails');
|
||||
}
|
||||
|
||||
function hasRequiredReportFields(report: Record<string, unknown> | null): boolean {
|
||||
const incidentSummary = objectField(report, 'incidentSummary');
|
||||
const reporter = objectField(report, 'reporter');
|
||||
const reportingPerson = objectField(reporter, 'reportingPerson');
|
||||
return Boolean(
|
||||
nonBlankString(incidentSummary?.incidentType) &&
|
||||
nonBlankString(incidentSummary?.incidentDateTime) &&
|
||||
nonBlankString(reportingPerson?.email),
|
||||
);
|
||||
}
|
||||
|
||||
function hasFileDetailsFields(fileDetails: Record<string, unknown> | null): boolean {
|
||||
return Boolean(nonBlankString(fileDetails?.reportId) && nonBlankString(fileDetails?.fileId));
|
||||
}
|
||||
|
||||
function objectField(value: unknown, key: string): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const field = (value as Record<string, unknown>)[key];
|
||||
if (!field || typeof field !== 'object' || Array.isArray(field)) return null;
|
||||
return field as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'bigint') {
|
||||
return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3,7 +3,26 @@
|
||||
import crypto from 'node:crypto';
|
||||
import {XMLParser, XMLValidator} from 'fast-xml-parser';
|
||||
import {HttpResponse, http, type RequestHandler} from 'msw';
|
||||
import type {FakeNcmecFailure, FakeNcmecFileRecord, FakeNcmecReportRecord} from '../../fake-ncmec/FakeNcmecServer';
|
||||
|
||||
type FakeNcmecFailure = 'none' | 'submit' | 'upload' | 'fileinfo' | 'finish' | 'retract' | 'unauthorized';
|
||||
|
||||
interface FakeNcmecFileRecord {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
md5: string;
|
||||
submittedDetails: boolean;
|
||||
detailsXml: string | null;
|
||||
}
|
||||
|
||||
interface FakeNcmecReportRecord {
|
||||
reportId: string;
|
||||
reportXml: string;
|
||||
files: Array<FakeNcmecFileRecord>;
|
||||
finished: boolean;
|
||||
retracted: boolean;
|
||||
authUsername: string;
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://ncmec.test/ispws';
|
||||
const USERNAME = 'usr123';
|
||||
|
||||
Reference in new issue
Block a user