WARCH1 实现企微审批最小历史留存
这个提交包含在:
@@ -10,7 +10,7 @@ const TOKEN = "callback-token";
|
||||
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
||||
const CORP_ID = "ww-callback-corp";
|
||||
|
||||
async function buildApp({ config = {}, evidenceStore } = {}) {
|
||||
async function buildApp({ config = {}, evidenceStore, prisma } = {}) {
|
||||
const app = Fastify();
|
||||
app.decorate("config", {
|
||||
wecomCorpId: CORP_ID,
|
||||
@@ -18,6 +18,9 @@ async function buildApp({ config = {}, evidenceStore } = {}) {
|
||||
wecomCallbackEncodingAesKey: AES_KEY,
|
||||
...config,
|
||||
});
|
||||
if (prisma) {
|
||||
app.decorate("prisma", prisma);
|
||||
}
|
||||
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api", evidenceStore });
|
||||
return app;
|
||||
}
|
||||
@@ -106,6 +109,61 @@ describe("wecomApprovalCallback routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("uses the default DB evidence store when prisma is decorated", async () => {
|
||||
const prisma = buildArchiveMockPrisma();
|
||||
const app = await buildApp({ prisma });
|
||||
const sample = buildEncryptedApprovalEventSample({
|
||||
token: TOKEN,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: CORP_ID,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK",
|
||||
templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK",
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/wecom/approval/callback?${urlQuery(sample)}`,
|
||||
headers: { "content-type": "text/xml" },
|
||||
payload: sample.encryptedXml,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(prisma.wecomApprovalCallbackEvent.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.wecomApprovalArchive.upsert.mock.calls[0][0].create.sp_no).toBe("SPFULLIDENTIFIERSHOULDNOTLEAK");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns non-200 when default DB evidence persistence fails so WeCom can retry", async () => {
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async () => {
|
||||
throw new Error("db unavailable");
|
||||
}),
|
||||
};
|
||||
const app = await buildApp({ prisma });
|
||||
const sample = buildEncryptedApprovalEventSample({
|
||||
token: TOKEN,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: CORP_ID,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK",
|
||||
templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK",
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/wecom/approval/callback?${urlQuery(sample)}`,
|
||||
headers: { "content-type": "text/xml" },
|
||||
payload: sample.encryptedXml,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.body).toBe("callback error");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("reports missing callback config without treating it as pass", async () => {
|
||||
const app = await buildApp({ config: { wecomCallbackToken: "" } });
|
||||
const response = await app.inject({
|
||||
@@ -118,3 +176,31 @@ describe("wecomApprovalCallback routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
function buildArchiveMockPrisma() {
|
||||
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" })),
|
||||
},
|
||||
wecomApprovalCallbackEvent: {
|
||||
upsert: vi.fn(async () => ({ id: "event-1", retry_count: 0 })),
|
||||
},
|
||||
};
|
||||
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
|
||||
return prisma;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
const {
|
||||
executeArchiveCheck,
|
||||
parseArgs,
|
||||
sanitizeArchiveRow,
|
||||
} = require("../../../src/scripts/check-wecom-approval-archive");
|
||||
const { maskApprovalIdentifier } = require("../../../src/services/integrations/wecomApproval");
|
||||
|
||||
const SENSITIVE_SP_NO = "SPARCHIVEREADFULLIDENTIFIERSHOULDNOTLEAK";
|
||||
const SENSITIVE_TEMPLATE_ID = "TPLARCHIVEREADFULLIDENTIFIERSHOULDNOTLEAK";
|
||||
const SENSITIVE_USER_ID = "archive_read_userid_full_should_not_leak";
|
||||
const SENSITIVE_REASON = "原因全文不得进入离线报告";
|
||||
|
||||
function archiveRow(overrides = {}) {
|
||||
return {
|
||||
id: "archive-1",
|
||||
sp_no: SENSITIVE_SP_NO,
|
||||
template_id: SENSITIVE_TEMPLATE_ID,
|
||||
template_type: "leave",
|
||||
normalized_status: "approved",
|
||||
submitted_at: new Date("2026-05-19T01:00:00.000Z"),
|
||||
completed_at: new Date("2026-05-19T02:00:00.000Z"),
|
||||
status_observed_at: new Date("2026-05-19T02:00:00.000Z"),
|
||||
last_source: "compensation_pull",
|
||||
readability_status: "PASS_READABLE",
|
||||
single_tap_approve_risk: true,
|
||||
template_adjustment_required: false,
|
||||
participants: [{
|
||||
role: "applicant",
|
||||
node_index: 0,
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
mapping_status: "mapped_or_external",
|
||||
is_current: false,
|
||||
}],
|
||||
actions: [{
|
||||
action_type: "approve",
|
||||
operator_external_user_id: "approver_full_userid_should_not_leak",
|
||||
action_time: new Date("2026-05-19T02:00:00.000Z"),
|
||||
node_index: 1,
|
||||
source: "compensation_pull",
|
||||
comment_summary: SENSITIVE_REASON,
|
||||
}],
|
||||
field_summaries: [{
|
||||
field_name: "请假原因",
|
||||
field_type: "Textarea",
|
||||
sensitive_level: "restricted",
|
||||
display_allowed: false,
|
||||
normalized_value: SENSITIVE_REASON,
|
||||
}],
|
||||
reconcile_findings: [],
|
||||
_count: {
|
||||
participants: 1,
|
||||
actions: 1,
|
||||
field_summaries: 1,
|
||||
attachment_summaries: 0,
|
||||
callback_events: 1,
|
||||
compensation_items: 1,
|
||||
reconcile_findings: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("check-wecom-approval-archive", () => {
|
||||
it("parses offline archive filters", () => {
|
||||
const options = parseArgs([
|
||||
"--offline",
|
||||
"--format=json",
|
||||
"--template=leave",
|
||||
"--status=approved",
|
||||
"--limit=50",
|
||||
]);
|
||||
|
||||
expect(options).toMatchObject({
|
||||
offline: true,
|
||||
format: "json",
|
||||
templates: ["leave"],
|
||||
status: "approved",
|
||||
limit: 50,
|
||||
});
|
||||
expect(() => parseArgs(["--template=outing"])).toThrow(/Unsupported --template/);
|
||||
expect(() => parseArgs(["--limit=101"])).toThrow(/--limit/);
|
||||
});
|
||||
|
||||
it("sanitizes archive rows without leaking full identifiers or restricted values", () => {
|
||||
const sanitized = sanitizeArchiveRow(archiveRow());
|
||||
const serialized = JSON.stringify(sanitized);
|
||||
|
||||
expect(sanitized.spNoPrefix).toBe(maskApprovalIdentifier(SENSITIVE_SP_NO));
|
||||
expect(sanitized.templateIdPrefix).toBe(maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID));
|
||||
expect(sanitized.participants[0].userIdPrefix).toBe(maskApprovalIdentifier(SENSITIVE_USER_ID));
|
||||
expect(sanitized.fieldSummaries[0]).toMatchObject({
|
||||
fieldName: null,
|
||||
fieldType: "Textarea",
|
||||
sensitiveLevel: "restricted",
|
||||
displayAllowed: false,
|
||||
});
|
||||
expect(serialized).not.toContain(SENSITIVE_SP_NO);
|
||||
expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||
expect(serialized).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(serialized).not.toContain(SENSITIVE_REASON);
|
||||
});
|
||||
|
||||
it("passes when OA DB archived history can be read offline", async () => {
|
||||
const report = await executeArchiveCheck(parseArgs(["--offline", "--format=json"]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: {},
|
||||
readArchiveRowsImpl: vi.fn(async () => [archiveRow()]),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_ARCHIVE_HISTORY_READABLE");
|
||||
expect(report.scope.wecomApiCalls).toBe(false);
|
||||
expect(report.checks.archiveRead.status).toBe("OK");
|
||||
expect(report.checks.archiveRead.count).toBe(1);
|
||||
});
|
||||
|
||||
it("returns PASS_WITH_FINDINGS when open reconcile findings exist", async () => {
|
||||
const report = await executeArchiveCheck(parseArgs(["--offline"]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: {},
|
||||
readArchiveRowsImpl: vi.fn(async () => [archiveRow({
|
||||
reconcile_findings: [{
|
||||
finding_type: "callback_missing_for_pulled_detail",
|
||||
status: "open",
|
||||
severity: "info",
|
||||
source: "compensation_compare",
|
||||
detected_at: new Date("2026-05-19T03:00:00.000Z"),
|
||||
}],
|
||||
})]),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_WITH_FINDINGS");
|
||||
expect(report.checks.archives[0].openFindingCounts).toMatchObject({
|
||||
callback_missing_for_pulled_detail: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns PARTIAL_ARCHIVE_NEEDS_DATA when no archive rows exist", async () => {
|
||||
const report = await executeArchiveCheck(parseArgs(["--offline"]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: {},
|
||||
readArchiveRowsImpl: vi.fn(async () => []),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_ARCHIVE_NEEDS_DATA");
|
||||
expect(report.configGaps.map((item) => item.id)).toContain("archive_history_data");
|
||||
});
|
||||
|
||||
it("classifies DB read errors as external blockers", async () => {
|
||||
const report = await executeArchiveCheck(parseArgs(["--offline"]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: {},
|
||||
readArchiveRowsImpl: vi.fn(async () => {
|
||||
throw new Error("database down with secret-like-value-should-be-redacted");
|
||||
}),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP");
|
||||
expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(report.mainBlockers[0]).toMatchObject({
|
||||
id: "archive_db_read",
|
||||
reason: "oa_archive_db_read_failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -98,12 +98,14 @@ describe("check-wecom-approval-compensation-poc", () => {
|
||||
expect(options.size).toBe(50);
|
||||
expect(options.discoverTemplateIds).toBe(true);
|
||||
expect(options.discoverApplicantEmployeeNo).toBe("SMOKE002");
|
||||
expect(options.persistToDb).toBe(false);
|
||||
expect(options.templates).toEqual({
|
||||
leave: "tpl-leave",
|
||||
missed_punch: "tpl-missed",
|
||||
});
|
||||
expect(() => parseArgs(["--template=outing:tpl"])).toThrow(/unsupported target/);
|
||||
expect(() => parseArgs(["--size=101"])).toThrow(/--size/);
|
||||
expect(parseArgs(["--persist-to-db"]).persistToDb).toBe(true);
|
||||
});
|
||||
|
||||
it("builds a default 72-hour time window", () => {
|
||||
@@ -396,6 +398,195 @@ describe("check-wecom-approval-compensation-poc", () => {
|
||||
expect(fetchApprovalInfoListImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchApprovalDetailImpl).toHaveBeenCalledTimes(2);
|
||||
expect(report.checks.callbackCompare.findings).toEqual([]);
|
||||
expect(fetchApprovalDetailImpl.mock.calls.every((call) => call[0].includeRawPayload === false)).toBe(true);
|
||||
expect(report.checks.archivePersistence.status).toBe("SKIPPED");
|
||||
});
|
||||
|
||||
it("requires both --persist-to-db and WECOM_ARCHIVE_WRITE=1 before OA archive writes", async () => {
|
||||
const report = await executeCompensationPoc(baseOptions({
|
||||
live: true,
|
||||
persistToDb: true,
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: {
|
||||
DATABASE_URL: "postgres://example",
|
||||
},
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
fetchApprovalInfoListImpl: vi.fn(async () => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: { errcode: 0, spNoCount: 0 },
|
||||
spNos: [],
|
||||
nextCursor: "",
|
||||
})),
|
||||
fetchApprovalDetailImpl: vi.fn(),
|
||||
readCallbackEvidenceImpl: vi.fn(async () => []),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_COMPENSATION_NEEDS_CONFIG");
|
||||
expect(report.checks.archivePersistence).toMatchObject({
|
||||
status: "NEEDS_CONFIG",
|
||||
reason: "missing_archive_write_switch",
|
||||
});
|
||||
expect(report.configGaps.map((item) => item.id)).toContain("archive_persistence");
|
||||
});
|
||||
|
||||
it("persists pulled details to OA archive only when the double switch is present", async () => {
|
||||
const sensitiveLeaveSpNo = `${SENSITIVE_TEMPLATE_ID}-sp`;
|
||||
const sensitiveMissedSpNo = "tpl-missed-punch-sp";
|
||||
const mockPrisma = buildArchiveMockPrisma();
|
||||
mockPrisma.wecomApprovalCallbackEvent.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
callback_kind: "approval_status_change",
|
||||
approval_type: "leave",
|
||||
wecom_status: "2",
|
||||
sp_no: sensitiveLeaveSpNo,
|
||||
template_id: SENSITIVE_TEMPLATE_ID,
|
||||
received_at: new Date("2026-05-20T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
callback_kind: "approval_status_change",
|
||||
approval_type: "missed_punch",
|
||||
wecom_status: "2",
|
||||
sp_no: sensitiveMissedSpNo,
|
||||
template_id: "tpl-missed-punch",
|
||||
received_at: new Date("2026-05-20T00:00:01.000Z"),
|
||||
},
|
||||
]);
|
||||
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: {
|
||||
errcode: 0,
|
||||
spNoCount: 1,
|
||||
spNoPrefixes: [maskApprovalIdentifier(`${filters[0].value}-sp`)],
|
||||
},
|
||||
spNos: [`${filters[0].value}-sp`],
|
||||
nextCursor: "",
|
||||
}));
|
||||
const fetchApprovalDetailImpl = vi.fn(async ({ spNo, includeRawPayload }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: detailSummary(spNo, {
|
||||
spStatus: "2",
|
||||
templateIdPrefix: spNo === sensitiveLeaveSpNo
|
||||
? maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID)
|
||||
: maskApprovalIdentifier("tpl-missed-punch"),
|
||||
}),
|
||||
rawPayload: includeRawPayload ? {
|
||||
errcode: 0,
|
||||
info: {
|
||||
sp_no: spNo,
|
||||
template_id: spNo === sensitiveLeaveSpNo ? SENSITIVE_TEMPLATE_ID : "tpl-missed-punch",
|
||||
sp_name: spNo === sensitiveLeaveSpNo ? "OA-POC-请假" : "OA-POC-补卡",
|
||||
sp_status: "2",
|
||||
apply_time: "1779148800",
|
||||
applyer: { userid: SENSITIVE_USER_ID },
|
||||
apply_data: { contents: [] },
|
||||
sp_record: [],
|
||||
},
|
||||
} : undefined,
|
||||
response: { errcode: 0, spStatus: "2" },
|
||||
}));
|
||||
const readCallbackEvidenceImpl = vi.fn(async () => []);
|
||||
const report = await executeCompensationPoc(baseOptions({
|
||||
live: true,
|
||||
persistToDb: true,
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: {
|
||||
DATABASE_URL: "postgres://example",
|
||||
WECOM_ARCHIVE_WRITE: "1",
|
||||
},
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: mockPrisma,
|
||||
fetchApprovalInfoListImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
readCallbackEvidenceImpl,
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_COMPENSATION_POC");
|
||||
expect(report.checks.callbackEvidence.source).toBe("db");
|
||||
expect(readCallbackEvidenceImpl).not.toHaveBeenCalled();
|
||||
expect(fetchApprovalDetailImpl.mock.calls.every((call) => call[0].includeRawPayload === true)).toBe(true);
|
||||
expect(mockPrisma.wecomApprovalCompensationRun.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrisma.wecomApprovalArchive.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(mockPrisma.wecomApprovalCompensationRun.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: "run-archive-1" },
|
||||
data: expect.objectContaining({
|
||||
status: "completed",
|
||||
item_count: 2,
|
||||
detail_count: 2,
|
||||
}),
|
||||
}));
|
||||
expect(report.checks.archivePersistence).toMatchObject({
|
||||
status: "OK",
|
||||
storedCount: 2,
|
||||
failedCount: 0,
|
||||
});
|
||||
const serialized = JSON.stringify(report);
|
||||
expect(serialized).not.toContain(SENSITIVE_SP_NO);
|
||||
expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||
expect(serialized).not.toContain(SENSITIVE_USER_ID);
|
||||
});
|
||||
|
||||
it("records compare findings on the correct archive when masked prefixes collide", async () => {
|
||||
const leaveSpNo = "20260519-leave-approval-full-id";
|
||||
const missedSpNo = "20260519-missed-punch-sp";
|
||||
const mockPrisma = buildArchiveMockPrisma();
|
||||
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: { errcode: 0, spNoCount: 1 },
|
||||
spNos: [filters[0].value === SENSITIVE_TEMPLATE_ID ? leaveSpNo : missedSpNo],
|
||||
nextCursor: "",
|
||||
}));
|
||||
const fetchApprovalDetailImpl = vi.fn(async ({ spNo, includeRawPayload }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: detailSummary(spNo, { spStatus: "2" }),
|
||||
rawPayload: includeRawPayload ? {
|
||||
errcode: 0,
|
||||
info: {
|
||||
sp_no: spNo,
|
||||
template_id: spNo === leaveSpNo ? SENSITIVE_TEMPLATE_ID : "tpl-missed-punch",
|
||||
sp_name: spNo === leaveSpNo ? "OA-POC-请假" : "OA-POC-补卡",
|
||||
sp_status: "2",
|
||||
apply_time: "1779148800",
|
||||
applyer: { userid: SENSITIVE_USER_ID },
|
||||
apply_data: { contents: [] },
|
||||
sp_record: [],
|
||||
},
|
||||
} : undefined,
|
||||
response: { errcode: 0, spStatus: "2" },
|
||||
}));
|
||||
|
||||
const report = await executeCompensationPoc(baseOptions({
|
||||
live: true,
|
||||
persistToDb: true,
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: {
|
||||
DATABASE_URL: "postgres://example",
|
||||
WECOM_ARCHIVE_WRITE: "1",
|
||||
},
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: mockPrisma,
|
||||
fetchApprovalInfoListImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
readCallbackEvidenceImpl: vi.fn(async () => []),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_WITH_FINDINGS");
|
||||
expect(report.checks.callbackCompare.findings).toHaveLength(2);
|
||||
expect(report.checks.callbackCompare.findings.every((item) => item.spNoPrefix === "20260519***")).toBe(true);
|
||||
const archiveIds = mockPrisma.wecomApprovalReconcileFinding.upsert.mock.calls
|
||||
.map((call) => call[0].create.archive_id)
|
||||
.sort();
|
||||
expect(archiveIds).toEqual(["archive-leave", "archive-missed"]);
|
||||
const serialized = JSON.stringify(report);
|
||||
expect(serialized).not.toContain(leaveSpNo);
|
||||
expect(serialized).not.toContain(missedSpNo);
|
||||
});
|
||||
|
||||
it("returns PASS_WITH_FINDINGS for missing callback and status mismatch", async () => {
|
||||
@@ -561,3 +752,47 @@ describe("check-wecom-approval-compensation-poc", () => {
|
||||
expect(statusLabelFromSpStatus("2")).toBe("已通过");
|
||||
});
|
||||
});
|
||||
|
||||
function buildArchiveMockPrisma() {
|
||||
const prisma = {
|
||||
wecomIdentityBinding: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
},
|
||||
wecomApprovalCompensationRun: {
|
||||
create: vi.fn(async () => ({
|
||||
id: "run-archive-1",
|
||||
started_at: new Date("2026-05-20T00:00:00.000Z"),
|
||||
})),
|
||||
update: vi.fn(async (args) => ({ id: args.where.id, ...args.data })),
|
||||
},
|
||||
wecomApprovalArchive: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
upsert: vi.fn(async (args) => ({
|
||||
id: args.create.sp_no.endsWith("missed-punch-sp") ? "archive-missed" : "archive-leave",
|
||||
...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-archive" })),
|
||||
},
|
||||
wecomApprovalCompensationItem: {
|
||||
upsert: vi.fn(async () => ({ id: "item-archive" })),
|
||||
},
|
||||
wecomApprovalCallbackEvent: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
wecomApprovalReconcileFinding: {
|
||||
upsert: vi.fn(async () => ({ id: "finding-archive" })),
|
||||
},
|
||||
};
|
||||
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
|
||||
return prisma;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户