feat: secure analysis PLY sampling

This commit is contained in:
2026-08-13 18:16:57 +08:00
parent 29a69793db
commit 9f4482bea1
5 changed files with 350 additions and 61 deletions

View 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);
});
});