完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)
这个提交包含在:
@@ -0,0 +1,171 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const usersRoutes = require("../../../src/routes/users");
|
||||
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
||||
|
||||
async function buildApp(permissionCodes) {
|
||||
const app = Fastify();
|
||||
const encryptedBank = encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, "6222000012345678");
|
||||
const encryptedIdentity = encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, "110101199001011234");
|
||||
const actor = {
|
||||
id: ACTOR_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
roleCodes: [],
|
||||
permissionCodes,
|
||||
};
|
||||
const employeeFindUnique = vi.fn().mockResolvedValue({
|
||||
id: EMPLOYEE_ID,
|
||||
bank_name: "合成银行",
|
||||
bank_account_encrypted: encryptedBank,
|
||||
id_number_encrypted: encryptedIdentity,
|
||||
});
|
||||
const employeeUpdate = vi.fn(async ({ data }) => ({ id: EMPLOYEE_ID, ...data }));
|
||||
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
||||
const transaction = vi.fn(async (callback) => callback({
|
||||
employee: {
|
||||
findUnique: employeeFindUnique,
|
||||
update: employeeUpdate,
|
||||
},
|
||||
auditLog: { create: auditCreate },
|
||||
}));
|
||||
|
||||
app.decorate("config", { fieldEncryptionKey: FIELD_ENCRYPTION_KEY });
|
||||
app.decorate("prisma", {
|
||||
employee: { findUnique: employeeFindUnique },
|
||||
auditLog: { create: auditCreate },
|
||||
$transaction: transaction,
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||
const permissions = employee?.permissionCodes ?? [];
|
||||
return permissions.includes("*") || permissions.includes(permissionCode);
|
||||
});
|
||||
app.decorate("assertPermission", (employee, permissionCode) => {
|
||||
if (!app.hasPermission(employee, permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
return permissionCode;
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code ?? "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? {},
|
||||
},
|
||||
}));
|
||||
|
||||
await app.register(usersRoutes, { prefix: "/api" });
|
||||
return { app, auditCreate, employeeFindUnique, employeeUpdate, transaction };
|
||||
}
|
||||
|
||||
describe("user sensitive field permissions", () => {
|
||||
it("rejects an OA system administrator before any sensitive employee query", async () => {
|
||||
const testApp = await buildApp(["system.accounts.manage", "system.roles.manage", "system.settings"]);
|
||||
|
||||
const response = await testApp.app.inject({ method: "GET", url: `/api/users/${EMPLOYEE_ID}/sensitive` });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(testApp.employeeFindUnique).not.toHaveBeenCalled();
|
||||
expect(testApp.auditCreate).not.toHaveBeenCalled();
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("returns only bank fields to finance and audits the authorized scope", async () => {
|
||||
const testApp = await buildApp(["employee.bank.read"]);
|
||||
|
||||
const response = await testApp.app.inject({ method: "GET", url: `/api/users/${EMPLOYEE_ID}/sensitive` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
bankName: "合成银行",
|
||||
bankAccount: "6222000012345678",
|
||||
});
|
||||
expect(response.json().data).not.toHaveProperty("idNumber");
|
||||
expect(testApp.employeeFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: EMPLOYEE_ID },
|
||||
select: {
|
||||
id: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
},
|
||||
});
|
||||
expect(testApp.auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "SYSTEM_USER_SENSITIVE_VIEWED",
|
||||
extra: { scopes: ["bank"] },
|
||||
}),
|
||||
});
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("rejects finance writes before opening a transaction", async () => {
|
||||
const testApp = await buildApp(["employee.bank.read"]);
|
||||
|
||||
const response = await testApp.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
|
||||
payload: { bankName: "新银行" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(testApp.transaction).not.toHaveBeenCalled();
|
||||
expect(testApp.employeeUpdate).not.toHaveBeenCalled();
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("requires every requested sensitive-field permission before mutation", async () => {
|
||||
const testApp = await buildApp(["employee.bank.manage"]);
|
||||
|
||||
const response = await testApp.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
|
||||
payload: { bankName: "新银行", idNumber: "110101199001011234" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(testApp.transaction).not.toHaveBeenCalled();
|
||||
expect(testApp.employeeUpdate).not.toHaveBeenCalled();
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("writes only authorized fields and keeps plaintext out of the audit", async () => {
|
||||
const testApp = await buildApp(["employee.bank.manage", "employee.identity.manage"]);
|
||||
|
||||
const response = await testApp.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
|
||||
payload: { bankAccount: "6222000099995678", idNumber: "110101199901011234" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
id: EMPLOYEE_ID,
|
||||
updatedFields: ["bankAccountMasked", "idNumberMasked"],
|
||||
});
|
||||
const auditArgument = testApp.auditCreate.mock.calls[0][0];
|
||||
expect(auditArgument.data.extra).toEqual({
|
||||
changes: {
|
||||
bankAccountMasked: "6222********5678",
|
||||
idNumberMasked: "110101********1234",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(auditArgument)).not.toContain("6222000099995678");
|
||||
expect(JSON.stringify(auditArgument)).not.toContain("110101199901011234");
|
||||
await testApp.app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户