349 行
12 KiB
JavaScript
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",
|
|
}),
|
|
},
|
|
}));
|
|
});
|
|
});
|