WARCH1 实现企微审批最小历史留存
这个提交包含在:
@@ -0,0 +1,245 @@
|
||||
const {
|
||||
normalizeApprovalDetailPayload,
|
||||
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("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("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",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户