文件
hyb-oa/server/tests/unit/services/wecomApprovalArchive.test.js

349 行
12 KiB
JavaScript

const {
normalizeApprovalDetailPayload,
listApprovalArchives,
persistApprovalDetail,
publicArchiveSummary,
recordApprovalCallbackEvent,
recordReconcileFinding,
} = require("../../../src/services/integrations/wecomApprovalArchive");
const { maskApprovalIdentifier } = require("../../../src/services/integrations/wecomApproval");
const {
buildEncryptedApprovalEventSample,
decryptWecomMessage,
extractEncryptedFromXml,
normalizeApprovalCallbackEvent,
} = require("../../../src/services/integrations/wecomCallbackCrypto");
const SENSITIVE_SP_NO = "SPARCHIVEFULLIDENTIFIERSHOULDNOTLEAK";
const SENSITIVE_TEMPLATE_ID = "TPLARCHIVEFULLIDENTIFIERSHOULDNOTLEAK";
const SENSITIVE_USER_ID = "wecom_archive_userid_full_should_not_leak";
const SENSITIVE_REASON = "这里是请假原因摘要,允许入库但默认报告不得输出全文";
const TOKEN = "callback-token";
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
const CORP_ID = "ww-archive-corp";
function approvalPayload(overrides = {}) {
return {
errcode: 0,
info: {
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
sp_name: "OA-POC-请假-2026-05-19",
sp_status: "2",
apply_time: "1779148800",
applyer: { userid: SENSITIVE_USER_ID },
apply_data: {
contents: [
{ id: "leave_type", control: "Text", title: "请假类型", value: "事假" },
{ id: "reason", control: "Textarea", title: "请假原因", value: SENSITIVE_REASON },
],
},
sp_record: [{
details: [{
userid: "approver_full_userid_should_not_leak",
sp_status: "2",
speech_time: "1779149800",
speech: "同意",
}],
}],
...overrides,
},
};
}
function buildMockPrisma() {
const prisma = {
wecomIdentityBinding: {
findUnique: vi.fn(async () => null),
},
wecomApprovalArchive: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async (args) => ({
id: "archive-1",
...args.create,
})),
},
wecomApprovalFieldSummary: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
wecomApprovalParticipant: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
wecomApprovalAction: {
upsert: vi.fn(async () => ({ id: "action-1" })),
},
wecomApprovalCompensationItem: {
upsert: vi.fn(async () => ({ id: "item-1" })),
},
wecomApprovalCallbackEvent: {
upsert: vi.fn(async () => ({ id: "event-1", retry_count: 0 })),
},
wecomApprovalReconcileFinding: {
upsert: vi.fn(async () => ({ id: "finding-1" })),
create: vi.fn(async () => ({ id: "finding-1" })),
},
};
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
return prisma;
}
describe("wecomApprovalArchive", () => {
it("normalizes approval detail while marking reason fields restricted", () => {
const detail = normalizeApprovalDetailPayload(approvalPayload(), {
source: "compensation_pull",
target: "leave",
});
expect(detail).toMatchObject({
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
normalized_status: "approved",
applicant_external_user_id: SENSITIVE_USER_ID,
});
expect(detail.fields).toHaveLength(2);
expect(detail.fields.find((item) => item.field_key.startsWith("reason"))).toMatchObject({
sensitive_level: "restricted",
display_allowed: false,
});
expect(detail.detail_digest).toMatch(/^[0-9a-f]{64}$/);
});
it("normalizes nested approval record approvers", () => {
const nestedApprover = "nested_approver_full_userid_should_not_leak";
const detail = normalizeApprovalDetailPayload(approvalPayload({
sp_record: [{
details: [{
approver: { userid: nestedApprover },
sp_status: "2",
speech_time: "1779149800",
speech: "同意",
}],
}],
}), {
source: "compensation_pull",
target: "leave",
});
expect(detail.participants).toEqual(expect.arrayContaining([
expect.objectContaining({
role: "approver",
external_user_id: nestedApprover,
}),
]));
expect(detail.actions).toEqual(expect.arrayContaining([
expect.objectContaining({
action_type: "approve",
operator_external_user_id: nestedApprover,
}),
]));
});
it("returns only masked public archive summaries", () => {
const summary = publicArchiveSummary({
id: "archive-1",
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
normalized_status: "approved",
last_source: "compensation_pull",
});
expect(summary).toEqual({
archiveId: "archive-1",
spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO),
templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID),
templateType: "leave",
normalizedStatus: "approved",
source: "compensation_pull",
});
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_SP_NO);
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TEMPLATE_ID);
});
it("lists query archives with whitelist-only DTO fields", async () => {
const archive = {
id: "archive-1",
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
sp_name: "OA-POC-请假",
normalized_status: "approved",
applicant_employee_id: null,
applicant_external_user_id: SENSITIVE_USER_ID,
applied_at: new Date("2026-05-19T08:00:00.000Z"),
submitted_at: new Date("2026-05-19T08:00:00.000Z"),
completed_at: new Date("2026-05-19T09:00:00.000Z"),
status_observed_at: new Date("2026-05-19T09:00:00.000Z"),
last_callback_at: null,
last_compensation_at: new Date("2026-05-19T10:00:00.000Z"),
last_source: "compensation_pull",
readability_status: "PASS_READABLE",
single_tap_approve_risk: true,
template_adjustment_required: false,
source_summary: { raw: "raw payload should not leak" },
applicant: null,
participants: [],
actions: [],
field_summaries: [{
id: "field-1",
field_key: "reason",
field_index: 1,
field_name: "请假原因",
field_type: "Textarea",
normalized_value: SENSITIVE_REASON,
value_preview: SENSITIVE_REASON.slice(0, 8),
sensitive_level: "restricted",
display_allowed: false,
}],
reconcile_findings: [{
id: "finding-1",
finding_type: "callback_missing_for_pulled_detail",
status: "open",
severity: "info",
source: "compensation_compare",
description: SENSITIVE_REASON,
detected_at: new Date("2026-05-19T11:00:00.000Z"),
resolved_at: null,
}],
};
const prisma = {
employee: { findMany: vi.fn(async () => []) },
wecomApprovalArchive: {
count: vi.fn(async () => 1),
findMany: vi.fn(async () => [archive]),
},
};
const result = await listApprovalArchives({
prisma,
actor: { id: "hr-1", roleCodes: ["hr"], permissionCodes: [] },
query: { scope: "all" },
});
expect(result.items).toHaveLength(1);
const text = JSON.stringify(result);
expect(text).not.toContain(SENSITIVE_SP_NO);
expect(text).not.toContain(SENSITIVE_TEMPLATE_ID);
expect(text).not.toContain(SENSITIVE_USER_ID);
expect(text).not.toContain(SENSITIVE_REASON);
expect(text).not.toContain("normalized_value");
expect(text).not.toContain("value_preview");
expect(text).not.toContain("source_summary");
expect(text).not.toContain("description");
});
it("persists full keys internally but returns redacted output", async () => {
const prisma = buildMockPrisma();
const result = await persistApprovalDetail({
prisma,
config: { wecomCorpId: CORP_ID },
payload: approvalPayload(),
spNo: SENSITIVE_SP_NO,
target: "leave",
source: "compensation_pull",
compensationRunId: "run-1",
});
expect(prisma.wecomApprovalArchive.upsert).toHaveBeenCalled();
expect(prisma.wecomApprovalArchive.upsert.mock.calls[0][0].create.sp_no).toBe(SENSITIVE_SP_NO);
expect(prisma.wecomApprovalFieldSummary.createMany.mock.calls[0][0].data.some((item) => item.normalized_value === SENSITIVE_REASON)).toBe(true);
expect(result.spNoPrefix).toBe(maskApprovalIdentifier(SENSITIVE_SP_NO));
expect(JSON.stringify(result)).not.toContain(SENSITIVE_SP_NO);
expect(JSON.stringify(result)).not.toContain(SENSITIVE_TEMPLATE_ID);
expect(JSON.stringify(result)).not.toContain(SENSITIVE_REASON);
});
it("keeps callback full identifiers non-enumerable for internal persistence", () => {
const sample = buildEncryptedApprovalEventSample({
token: TOKEN,
encodingAesKey: AES_KEY,
receiveId: CORP_ID,
timestamp: "1770000000",
nonce: "nonce",
spNo: SENSITIVE_SP_NO,
templateId: SENSITIVE_TEMPLATE_ID,
});
const decrypted = decryptWecomMessage({
encrypted: extractEncryptedFromXml(sample.encryptedXml),
encodingAesKey: AES_KEY,
receiveId: CORP_ID,
});
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
expect(summary.spNo).toBe(SENSITIVE_SP_NO);
expect(summary.templateId).toBe(SENSITIVE_TEMPLATE_ID);
expect(Object.keys(summary)).not.toContain("spNo");
expect(Object.keys(summary)).not.toContain("templateId");
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_SP_NO);
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TEMPLATE_ID);
});
it("does not erase detail children when recording callback evidence", async () => {
const prisma = buildMockPrisma();
prisma.wecomApprovalArchive.findUnique.mockResolvedValueOnce({
id: "archive-1",
corp_id_hash: "hash",
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
applicant_external_user_id: SENSITIVE_USER_ID,
normalized_status: "approved",
status_observed_at: new Date("2026-05-19T10:00:00.000Z"),
last_detail_digest: "existing-detail-digest",
});
const sample = buildEncryptedApprovalEventSample({
token: TOKEN,
encodingAesKey: AES_KEY,
receiveId: CORP_ID,
timestamp: "1770000000",
nonce: "nonce",
spNo: SENSITIVE_SP_NO,
templateId: SENSITIVE_TEMPLATE_ID,
});
const decrypted = decryptWecomMessage({
encrypted: extractEncryptedFromXml(sample.encryptedXml),
encodingAesKey: AES_KEY,
receiveId: CORP_ID,
});
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
await recordApprovalCallbackEvent({
prisma,
config: { wecomCorpId: CORP_ID },
summary,
});
expect(prisma.wecomApprovalFieldSummary.deleteMany).not.toHaveBeenCalled();
expect(prisma.wecomApprovalParticipant.deleteMany).not.toHaveBeenCalled();
expect(prisma.wecomApprovalAction.upsert).toHaveBeenCalledTimes(1);
const update = prisma.wecomApprovalArchive.upsert.mock.calls[0][0].update;
expect(update).not.toHaveProperty("applicant_external_user_id", null);
expect(update).not.toHaveProperty("last_detail_digest");
expect(update.last_callback_at).toBeInstanceOf(Date);
});
it("upserts run-scoped reconcile findings without requiring archive id", async () => {
const prisma = buildMockPrisma();
await recordReconcileFinding({
prisma,
runId: "run-1",
archiveId: null,
findingType: "callback_missing_for_pulled_detail",
evidence: { spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO) },
});
expect(prisma.wecomApprovalReconcileFinding.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: {
run_id_finding_type_evidence_digest: expect.objectContaining({
run_id: "run-1",
finding_type: "callback_missing_for_pulled_detail",
}),
},
}));
});
});