feat: secure analysis PLY sampling
This commit is contained in:
126
server/analysisPlySample.test.ts
Normal file
126
server/analysisPlySample.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import express from 'express';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticated: true,
|
||||
resultTaskId: 7,
|
||||
}));
|
||||
|
||||
vi.mock('./_core/auth.js', () => ({
|
||||
authenticateRequest: vi.fn(async () => mocks.authenticated
|
||||
? { id: 1, role: 'user', mustChangePassword: 0 }
|
||||
: null),
|
||||
}));
|
||||
|
||||
vi.mock('./db', () => ({
|
||||
getTaskAnalysisResultById: vi.fn(async (id: number) => id === 11
|
||||
? { id, taskId: mocks.resultTaskId }
|
||||
: undefined),
|
||||
listTaskPointcloudFiles: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'analysis-ply-route-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
function writePly(relativePath: string, withColor = false): void {
|
||||
const resultDir = join(dataDir, 'tasks', '7', 'analysis', '11');
|
||||
const filePath = join(resultDir, relativePath);
|
||||
mkdirSync(join(filePath, '..'), { recursive: true });
|
||||
const properties = withColor
|
||||
? ['x', 'y', 'z', 'red', 'green', 'blue']
|
||||
: ['x', 'y', 'z'];
|
||||
const header = Buffer.from([
|
||||
'ply',
|
||||
'format binary_little_endian 1.0',
|
||||
'element vertex 2',
|
||||
...properties.map((name) => `property float ${name}`),
|
||||
'end_header',
|
||||
'',
|
||||
].join('\n'), 'ascii');
|
||||
const values = withColor
|
||||
? [[1, 2, 3, 255, 0, 128], [4, 5, 6, 0, 255, 64]]
|
||||
: [[7, 8, 9], [10, 11, 12]];
|
||||
const body = Buffer.alloc(values.length * properties.length * 4);
|
||||
values.flat().forEach((value, index) => body.writeFloatLE(value, index * 4));
|
||||
writeFileSync(filePath, Buffer.concat([header, body]));
|
||||
}
|
||||
|
||||
describe('POST /api/files/sample/analysis', () => {
|
||||
let origin: string;
|
||||
let closeServer: () => Promise<void>;
|
||||
|
||||
beforeAll(async () => {
|
||||
writePly('plain.ply');
|
||||
writePly('nested/color.ply', true);
|
||||
const { fileRouter } = await import('./fileRoutes');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/files', fileRouter);
|
||||
const server = app.listen(0);
|
||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
closeServer = () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeServer();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.authenticated = true;
|
||||
mocks.resultTaskId = 7;
|
||||
});
|
||||
|
||||
async function post(body: unknown) {
|
||||
return fetch(`${origin}/api/files/sample/analysis`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it('采样单个无色 PLY 并返回 16 字节步长', async () => {
|
||||
const response = await post({ taskId: 7, resultId: 11, files: ['plain.ply'], totalPoints: 2 });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('X-Point-Count')).toBe('2');
|
||||
expect(response.headers.get('X-Has-Color')).toBe('0');
|
||||
expect((await response.arrayBuffer()).byteLength).toBe(2 * 16);
|
||||
});
|
||||
|
||||
it('采样多个 PLY,彩色响应使用 28 字节步长且不超预算', async () => {
|
||||
const response = await post({
|
||||
taskId: 7,
|
||||
resultId: 11,
|
||||
files: ['plain.ply', 'nested/color.ply'],
|
||||
totalPoints: 3,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('X-Point-Count')).toBe('3');
|
||||
expect(response.headers.get('X-Has-Color')).toBe('1');
|
||||
expect((await response.arrayBuffer()).byteLength).toBe(3 * 28);
|
||||
});
|
||||
|
||||
it('拒绝未登录访问', async () => {
|
||||
mocks.authenticated = false;
|
||||
expect((await post({ taskId: 7, resultId: 11, files: ['plain.ply'] })).status).toBe(401);
|
||||
});
|
||||
|
||||
it('拒绝错误的任务与结果组合', async () => {
|
||||
mocks.resultTaskId = 8;
|
||||
expect((await post({ taskId: 7, resultId: 11, files: ['plain.ply'] })).status).toBe(404);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['路径穿越', '../plain.ply', 403],
|
||||
['绝对路径', '/tmp/plain.ply', 400],
|
||||
['非 PLY 文件', 'result.json', 400],
|
||||
['缺失文件', 'missing.ply', 404],
|
||||
])('拒绝%s', async (_label, file, status) => {
|
||||
expect((await post({ taskId: 7, resultId: 11, files: [file] })).status).toBe(status);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user