295 行
8.2 KiB
JavaScript
295 行
8.2 KiB
JavaScript
const Fastify = require('fastify');
|
|
|
|
const { AppError } = require('../../../src/utils/AppError');
|
|
const assetsRoutes = require('../../../src/routes/assets');
|
|
|
|
function buildAssetRecord({
|
|
id = 'asset-1',
|
|
assetNo = 'FA-2026-000001',
|
|
serialNo = 'SN-001',
|
|
assetType = 'fixed_asset',
|
|
sensitivityLevel = 'internal',
|
|
assigneeEmployeeId = 'employee-1',
|
|
ownerEmployeeId = null,
|
|
keeperEmployeeId = null,
|
|
archiveLocation = null,
|
|
externalRef = null,
|
|
sourceHash = null,
|
|
confidentialityNote = null,
|
|
metadata = null,
|
|
name = '测试笔记本',
|
|
} = {}) {
|
|
const now = new Date('2026-03-11T00:00:00.000Z');
|
|
return {
|
|
id,
|
|
asset_no: assetNo,
|
|
name,
|
|
asset_type: assetType,
|
|
sensitivity_level: sensitivityLevel,
|
|
category_id: 'cat-1',
|
|
legal_entity_id: 'legal-1',
|
|
cost_center_id: 'cc-1',
|
|
purchase_request_id: null,
|
|
serial_no: serialNo,
|
|
location: '深圳办公室',
|
|
status: 'in_use',
|
|
assignee_employee_id: assigneeEmployeeId,
|
|
owner_employee_id: ownerEmployeeId,
|
|
keeper_employee_id: keeperEmployeeId,
|
|
archive_location: archiveLocation,
|
|
external_ref: externalRef,
|
|
source_hash: sourceHash,
|
|
verification_no: null,
|
|
confidentiality_note: confidentialityNote,
|
|
metadata,
|
|
last_verified_at: null,
|
|
expires_at: null,
|
|
activated_at: now,
|
|
retired_at: null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
category: null,
|
|
legal_entity: {
|
|
id: 'legal-1',
|
|
code: 'HY',
|
|
name: '海韵科技',
|
|
},
|
|
cost_center: {
|
|
id: 'cc-1',
|
|
code: 'CC001',
|
|
name: '研发中心',
|
|
legal_entity_id: 'legal-1',
|
|
},
|
|
purchase_request: null,
|
|
assignee: {
|
|
id: assigneeEmployeeId,
|
|
employee_no: 'E001',
|
|
name: '张三',
|
|
department: null,
|
|
},
|
|
owner: null,
|
|
keeper: null,
|
|
};
|
|
}
|
|
|
|
function buildPrisma({ items = [] } = {}) {
|
|
return {
|
|
assetCard: {
|
|
findMany: vi.fn().mockResolvedValue(items),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function buildApp({ employee, prisma }) {
|
|
const app = Fastify();
|
|
|
|
app.decorate('prisma', prisma);
|
|
app.decorate('config', {});
|
|
app.decorate('authenticate', async (request) => {
|
|
if (!request.headers.authorization) {
|
|
throw new AppError({
|
|
code: 'AUTH_001',
|
|
message: '未登录',
|
|
statusCode: 401,
|
|
});
|
|
}
|
|
request.employee = employee;
|
|
});
|
|
app.decorate('hasPermission', (actor, permissionCode) => {
|
|
return (actor?.permissionCodes ?? []).includes('*') || (actor?.permissionCodes ?? []).includes(permissionCode);
|
|
});
|
|
app.decorate('requirePermission', (permissionCode) => async (request) => {
|
|
await app.authenticate(request);
|
|
if (!app.hasPermission(request.employee, permissionCode)) {
|
|
throw new AppError({
|
|
code: 'PERM_001',
|
|
message: '无权限',
|
|
statusCode: 403,
|
|
details: { permission: permissionCode },
|
|
});
|
|
}
|
|
});
|
|
app.setErrorHandler((error, request, reply) => {
|
|
if (error instanceof AppError) {
|
|
return reply.status(error.statusCode).send({
|
|
requestId: request.id,
|
|
error: {
|
|
code: error.code,
|
|
message: error.message,
|
|
details: error.details ?? {},
|
|
},
|
|
});
|
|
}
|
|
|
|
return reply.status(500).send({
|
|
requestId: request.id,
|
|
error: { code: 'SYS_001', message: '服务器内部错误', details: {} },
|
|
});
|
|
});
|
|
|
|
await app.register(assetsRoutes, { prefix: '/api' });
|
|
return app;
|
|
}
|
|
|
|
describe('assets search route', () => {
|
|
it('uses one scoped OR query for assetNo, serialNo, VER/hash/ref/location and name lookup', async () => {
|
|
const prisma = buildPrisma({
|
|
items: [buildAssetRecord()],
|
|
});
|
|
const app = await buildApp({
|
|
employee: { id: 'admin-1', permissionCodes: ['asset.manage'] },
|
|
prisma,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/assets?q=FA-2026-000001',
|
|
headers: { authorization: 'Bearer token' },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data.items[0]).toEqual(
|
|
expect.objectContaining({
|
|
assetNo: 'FA-2026-000001',
|
|
serialNo: 'SN-001',
|
|
}),
|
|
);
|
|
expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(1);
|
|
expect(prisma.assetCard.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
AND: expect.arrayContaining([
|
|
expect.objectContaining({}),
|
|
expect.objectContaining({
|
|
OR: expect.arrayContaining([
|
|
{ asset_no: 'FA-2026-000001' },
|
|
{ serial_no: 'FA-2026-000001' },
|
|
{ verification_no: 'FA-2026-000001' },
|
|
{ source_hash: 'FA-2026-000001' },
|
|
{ name: { contains: 'FA-2026-000001', mode: 'insensitive' } },
|
|
]),
|
|
}),
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it('forces own-scope lookup even when caller passes mine=0', async () => {
|
|
const prisma = buildPrisma({
|
|
items: [buildAssetRecord({ assigneeEmployeeId: 'employee-1', assetNo: 'FA-2026-000002', serialNo: 'SN-002' })],
|
|
});
|
|
const app = await buildApp({
|
|
employee: { id: 'employee-1', permissionCodes: ['asset.view_own'] },
|
|
prisma,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/assets?q=SN-002&mine=0',
|
|
headers: { authorization: 'Bearer token' },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data.mine).toBe(true);
|
|
expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(1);
|
|
expect(prisma.assetCard.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
AND: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
OR: [
|
|
{ assignee_employee_id: 'employee-1' },
|
|
{ owner_employee_id: 'employee-1' },
|
|
{ keeper_employee_id: 'employee-1' },
|
|
],
|
|
}),
|
|
expect.objectContaining({
|
|
OR: expect.arrayContaining([{ serial_no: 'SN-002' }]),
|
|
}),
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it('redacts sensitive electronic asset fields for asset.view_all callers that are not related employees', async () => {
|
|
const prisma = buildPrisma({
|
|
items: [
|
|
buildAssetRecord({
|
|
id: 'asset-3',
|
|
assetNo: 'EA-DOC-2026-000001',
|
|
assetType: 'electronic_document',
|
|
sensitivityLevel: 'confidential',
|
|
archiveLocation: '离线档案柜-A',
|
|
externalRef: 'DOC-001',
|
|
sourceHash: 'sha256:abcdef',
|
|
confidentialityNote: '仅负责人可查看',
|
|
metadata: { source: 'redacted_index' },
|
|
}),
|
|
],
|
|
});
|
|
const app = await buildApp({
|
|
employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] },
|
|
prisma,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/assets?q=EA-DOC-2026-000001',
|
|
headers: { authorization: 'Bearer token' },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data.items[0]).toEqual(
|
|
expect.objectContaining({
|
|
assetNo: 'EA-DOC-2026-000001',
|
|
archiveLocation: null,
|
|
externalRef: null,
|
|
sourceHash: null,
|
|
confidentialityNote: null,
|
|
metadata: null,
|
|
sensitiveFieldsRedacted: true,
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it('rejects redacted index import items that still contain full local paths', async () => {
|
|
const prisma = buildPrisma();
|
|
const app = await buildApp({
|
|
employee: { id: 'admin-1', permissionCodes: ['asset.manage'] },
|
|
prisma,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/assets/import/redacted-doc-index',
|
|
headers: { authorization: 'Bearer token' },
|
|
payload: {
|
|
dryRun: true,
|
|
items: [
|
|
{
|
|
name: '合同索引',
|
|
archiveLocation: '/home/shunli/Projects/faculty-daily-assistant/公司/合同.pdf',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.details).toEqual(
|
|
expect.objectContaining({
|
|
'items.0.archiveLocation': 'local_absolute_path',
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
});
|