WARCH3/WCHECK1/WEXT1 完成受控上线后续自动化
Issue: WARCH3, WCHECK1, WEXT1
这个提交包含在:
@@ -86,6 +86,7 @@ describe("check-wecom-approval-fixtures", () => {
|
||||
live: false,
|
||||
applicantEmployeeNo: "SMOKE002",
|
||||
approverEmployeeNo: "SMOKE101",
|
||||
targets: ["leave", "missed_punch"],
|
||||
format: "json",
|
||||
});
|
||||
const live = parseArgs([
|
||||
@@ -105,6 +106,28 @@ describe("check-wecom-approval-fixtures", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("supports WEXT1 outing and overtime dry-run targets without changing defaults", async () => {
|
||||
const options = parseArgs(["--format=json", "--target=outing", "--targets=overtime"]);
|
||||
expect(options.targets).toEqual(["outing", "overtime"]);
|
||||
|
||||
const createTemplateImpl = vi.fn();
|
||||
const applyEventImpl = vi.fn();
|
||||
const report = await executeApprovalFixtures(options, {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
actorRows: actorRows(),
|
||||
createTemplateImpl,
|
||||
applyEventImpl,
|
||||
generatedAt: "2026-05-20T10:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_FIXTURE_DRY_RUN_READY");
|
||||
expect(report.scope.targets).toEqual(["outing", "overtime"]);
|
||||
expect(report.checks.fixturePayloads.map((item) => item.target)).toEqual(["outing", "overtime"]);
|
||||
expect(createTemplateImpl).not.toHaveBeenCalled();
|
||||
expect(applyEventImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes dry-run without calling WeCom write APIs", async () => {
|
||||
const createTemplateImpl = vi.fn();
|
||||
const applyEventImpl = vi.fn();
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
const {
|
||||
buildSecretCheck,
|
||||
buildWriteGate,
|
||||
executeCheckinSummaryArchive,
|
||||
parseArgs,
|
||||
} = require("../../../src/scripts/check-wecom-checkin-summary-archive");
|
||||
const {
|
||||
sanitizeCheckinApiResponse,
|
||||
} = require("../../../src/services/integrations/wecomCheckin");
|
||||
|
||||
const SENSITIVE_USER_ID = ["checkin", "summary", "full", "userid", "should", "not", "leak"].join("_");
|
||||
const SENSITIVE_AGENT_SECRET = ["checkin", "agent", "secret", "should", "not", "leak"].join("-");
|
||||
const SENSITIVE_CHECKIN_SECRET = ["checkin", "override", "secret", "should", "not", "leak"].join("-");
|
||||
|
||||
function baseConfig(overrides = {}) {
|
||||
return {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
wecomCorpId: "corp",
|
||||
wecomAgentId: "1000002",
|
||||
wecomAgentSecret: SENSITIVE_AGENT_SECRET,
|
||||
wecomCheckinSecret: "",
|
||||
wecomRequestTimeoutMs: 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function employeeRows(overrides = {}) {
|
||||
return [
|
||||
{
|
||||
id: "employee-1",
|
||||
employee_no: "SMOKE002",
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
wecom_identity_binding: { external_user_id: SENSITIVE_USER_ID },
|
||||
},
|
||||
{
|
||||
id: "employee-2",
|
||||
employee_no: "SMOKE101",
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
wecom_identity_binding: { external_user_id: `${SENSITIVE_USER_ID}_approver` },
|
||||
},
|
||||
].map((row) => ({ ...row, ...(overrides[row.employee_no] || {}) }));
|
||||
}
|
||||
|
||||
function prismaStub() {
|
||||
const dailyRows = [
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id_prefix: "chec***",
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
record_count: 1,
|
||||
first_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
last_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
exception_count: 0,
|
||||
abnormal_count: 0,
|
||||
result_summary: { normal: 1 },
|
||||
raw_hash_digest: "a".repeat(64),
|
||||
},
|
||||
];
|
||||
return {
|
||||
employee: { findMany: vi.fn(async () => employeeRows()) },
|
||||
wecomCheckinSyncRun: {
|
||||
create: vi.fn(async () => ({ id: "sync-run-1" })),
|
||||
update: vi.fn(async () => ({ id: "sync-run-1" })),
|
||||
},
|
||||
wecomCheckinRecord: {
|
||||
createMany: vi.fn(async () => ({ count: 1 })),
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
sync_run_id: "sync-run-1",
|
||||
employee_id: "employee-1",
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
checkin_time: new Date("2026-05-20T01:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
checkin_type: "OnDuty",
|
||||
exception_type: null,
|
||||
source_type: "app",
|
||||
result: "normal",
|
||||
raw_hash: "a".repeat(64),
|
||||
},
|
||||
]),
|
||||
},
|
||||
wecomCheckinDailySummary: {
|
||||
upsert: vi.fn(async () => ({})),
|
||||
findMany: vi.fn(async () => dailyRows),
|
||||
},
|
||||
wecomCheckinMonthlySummary: {
|
||||
upsert: vi.fn(async () => ({})),
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
month: "2026-05",
|
||||
summary_day_count: 1,
|
||||
checkin_day_count: 1,
|
||||
exception_day_count: 0,
|
||||
abnormal_record_count: 0,
|
||||
missing_day_candidate_count: 0,
|
||||
first_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
last_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
wecomCheckinRuleSnapshot: {
|
||||
upsert: vi.fn(async () => ({})),
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id_prefix: "chec***",
|
||||
snapshot_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
rule_hash: "b".repeat(64),
|
||||
rule_summary: { keyCount: 2 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("check-wecom-checkin-summary-archive", () => {
|
||||
it("parses defaults and write gate", () => {
|
||||
const options = parseArgs(["--live", "--persist-to-db", "--employee-no=SMOKE002", "--format=json"]);
|
||||
expect(options.live).toBe(true);
|
||||
expect(options.persistToDb).toBe(true);
|
||||
expect(options.employeeNos).toEqual(["SMOKE002"]);
|
||||
expect(buildWriteGate(options, {})).toMatchObject({
|
||||
status: "NEEDS_CONFIG",
|
||||
missing: ["WECOM_CHECKIN_ARCHIVE_WRITE"],
|
||||
});
|
||||
expect(buildWriteGate(options, { WECOM_CHECKIN_ARCHIVE_WRITE: "1" })).toMatchObject({
|
||||
status: "OK",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("documents fixed Secret source with explicit checkin override", () => {
|
||||
expect(buildSecretCheck(baseConfig())).toMatchObject({
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
configured: true,
|
||||
});
|
||||
expect(buildSecretCheck(baseConfig({ wecomCheckinSecret: SENSITIVE_CHECKIN_SECRET }))).toMatchObject({
|
||||
source: "WECOM_CHECKIN_SECRET",
|
||||
configured: true,
|
||||
override: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads offline archive without WeCom API and redacts report", async () => {
|
||||
const prisma = prismaStub();
|
||||
const report = await executeCheckinSummaryArchive(parseArgs(["--offline", "--format=json"]), {
|
||||
prisma,
|
||||
config: baseConfig(),
|
||||
employees: employeeRows(),
|
||||
generatedAt: "2026-05-20T04:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_CHECKIN_ARCHIVE_OFFLINE_READY");
|
||||
expect(report.checks.offline.counts.daily).toBe(1);
|
||||
expect(JSON.stringify(report)).not.toContain(SENSITIVE_USER_ID);
|
||||
});
|
||||
|
||||
it("live persists OA summaries only after write env", async () => {
|
||||
const prisma = prismaStub();
|
||||
const fetchCheckinDataImpl = vi.fn(async ({ requireSyncEnabled, externalUserIds }) => {
|
||||
expect(requireSyncEnabled).toBe(false);
|
||||
expect(externalUserIds).toContain(SENSITIVE_USER_ID);
|
||||
return {
|
||||
ok: true,
|
||||
code: "wecom_checkin_fetched",
|
||||
records: [
|
||||
{
|
||||
userid: SENSITIVE_USER_ID,
|
||||
checkin_time: 1779238800,
|
||||
checkin_type: "OnDuty",
|
||||
result: "normal",
|
||||
},
|
||||
],
|
||||
response: { errcode: 0, checkindataCount: 1 },
|
||||
};
|
||||
});
|
||||
const fetchCheckinRulesImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
code: "wecom_checkin_rule_fetched",
|
||||
rules: [{ userid: SENSITIVE_USER_ID, groupname: "默认", loc_info: "must-not-leak" }],
|
||||
response: { errcode: 0, ruleInfoCount: 1 },
|
||||
}));
|
||||
|
||||
const report = await executeCheckinSummaryArchive(parseArgs([
|
||||
"--live",
|
||||
"--persist-to-db",
|
||||
"--format=json",
|
||||
"--start-date=2026-05-20",
|
||||
"--end-date=2026-05-20",
|
||||
]), {
|
||||
prisma,
|
||||
config: baseConfig(),
|
||||
employees: employeeRows(),
|
||||
env: { WECOM_CHECKIN_ARCHIVE_WRITE: "1" },
|
||||
fetchCheckinDataImpl,
|
||||
fetchCheckinRulesImpl,
|
||||
generatedAt: "2026-05-20T04:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_CHECKIN_ARCHIVE_READY");
|
||||
expect(prisma.wecomCheckinRecord.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.wecomCheckinDailySummary.upsert).toHaveBeenCalled();
|
||||
expect(prisma.wecomCheckinRuleSnapshot.upsert).toHaveBeenCalled();
|
||||
const text = JSON.stringify(report);
|
||||
expect(text).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(text).not.toContain(SENSITIVE_AGENT_SECRET);
|
||||
expect(text).not.toContain("must-not-leak");
|
||||
});
|
||||
|
||||
it("classifies API errors as external config blockers", async () => {
|
||||
const shortSensitiveUserId = "SMOKE002";
|
||||
const report = await executeCheckinSummaryArchive(parseArgs(["--live", "--format=json"]), {
|
||||
prisma: prismaStub(),
|
||||
config: baseConfig(),
|
||||
employees: employeeRows(),
|
||||
fetchCheckinDataImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
code: "wecom_checkin_api_error",
|
||||
message: "WeCom checkin errcode 301062",
|
||||
records: [],
|
||||
response: { errcode: 301062, invaliduser: "SM***" },
|
||||
})),
|
||||
fetchCheckinRulesImpl: vi.fn(),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP");
|
||||
expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(JSON.stringify(report.checks.live.api.response)).not.toContain(shortSensitiveUserId);
|
||||
});
|
||||
|
||||
it("redacts invaliduser values from checkin API responses", () => {
|
||||
const sanitized = sanitizeCheckinApiResponse({
|
||||
errcode: 301062,
|
||||
errmsg: "invalid user",
|
||||
invaliduser: "SMOKE002|SMOKE101",
|
||||
});
|
||||
|
||||
expect(sanitized.invaliduser).toBe("SM***|SM***");
|
||||
expect(JSON.stringify(sanitized)).not.toContain("SMOKE002");
|
||||
expect(JSON.stringify(sanitized)).not.toContain("SMOKE101");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
const {
|
||||
assertAlertSendAllowed,
|
||||
buildAlertMessage,
|
||||
executeOpsAlert,
|
||||
parseArgs,
|
||||
shouldSendForSummary,
|
||||
} = require("../../../src/scripts/send-wecom-ops-alert");
|
||||
|
||||
const SENSITIVE_USER_ID = ["ops", "alert", "full", "userid", "should", "not", "leak"].join("_");
|
||||
const SENSITIVE_TOKEN = ["ops", "alert", "token", "should", "not", "leak"].join("-");
|
||||
|
||||
function baseOptions(overrides = {}) {
|
||||
return {
|
||||
summaryFile: "",
|
||||
recipientEmployeeNos: ["wangshunli", "HYD000006"],
|
||||
send: false,
|
||||
force: false,
|
||||
format: "json",
|
||||
reportDir: "/tmp/wecom-ops-alert-test",
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseSummary(overrides = {}) {
|
||||
return {
|
||||
issueId: "WARCH3",
|
||||
status: "PASS_WITH_FINDINGS",
|
||||
findingCount: 1,
|
||||
reportDir: "/app/artifacts/qa-flow/warch3-ops-test",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function recipientRows(overrides = {}) {
|
||||
return [
|
||||
{
|
||||
employeeNo: "wangshunli",
|
||||
status: "active",
|
||||
archived: false,
|
||||
wecomExternalUserId: SENSITIVE_USER_ID,
|
||||
},
|
||||
{
|
||||
employeeNo: "HYD000006",
|
||||
status: "active",
|
||||
archived: false,
|
||||
wecomExternalUserId: SENSITIVE_USER_ID,
|
||||
},
|
||||
].map((row) => ({ ...row, ...(overrides[row.employeeNo] || {}) }));
|
||||
}
|
||||
|
||||
describe("send-wecom-ops-alert", () => {
|
||||
it("parses recipients and enforces double confirmation", () => {
|
||||
const options = parseArgs([
|
||||
"--summary-file=/tmp/summary.json",
|
||||
"--recipient-employee-no=wangshunli",
|
||||
"--recipient-employee-no",
|
||||
"HYD000006",
|
||||
"--send",
|
||||
"--format=json",
|
||||
]);
|
||||
|
||||
expect(options.summaryFile).toBe("/tmp/summary.json");
|
||||
expect(options.recipientEmployeeNos).toEqual(["wangshunli", "HYD000006"]);
|
||||
expect(options.send).toBe(true);
|
||||
expect(() => assertAlertSendAllowed(options, {})).toThrow(/WARCH3_OPS_ALERT_SEND=1/);
|
||||
expect(() => assertAlertSendAllowed(options, { WARCH3_OPS_ALERT_SEND: "1" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("builds a dehydrated message from summary only", () => {
|
||||
const message = buildAlertMessage(baseSummary({
|
||||
reportDir: `/app/${SENSITIVE_TOKEN}/summary`,
|
||||
}));
|
||||
expect(message.title).toContain("PASS_WITH_FINDINGS");
|
||||
expect(message.body).toContain("阶段:WARCH3");
|
||||
expect(message.body).toContain("finding:1");
|
||||
expect(message.body).not.toContain("手机号");
|
||||
});
|
||||
|
||||
it("keeps PASS without findings as no-send unless forced", () => {
|
||||
const summary = baseSummary({ status: "PASS_ARCHIVE_OPS_READY", findingCount: 0 });
|
||||
expect(shouldSendForSummary(summary, baseOptions())).toBe(false);
|
||||
expect(shouldSendForSummary(summary, baseOptions({ force: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("dry-runs alert without sending and redacts recipients", async () => {
|
||||
const sendMessageImpl = vi.fn();
|
||||
const report = await executeOpsAlert(baseOptions(), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows(),
|
||||
sendMessageImpl,
|
||||
env: {},
|
||||
config: { wecomRequestTimeoutMs: 1000 },
|
||||
generatedAt: "2026-05-20T04:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_ALERT_DRY_RUN_READY");
|
||||
expect(report.delivery.status).toBe("DRY_RUN_READY");
|
||||
expect(sendMessageImpl).not.toHaveBeenCalled();
|
||||
const text = JSON.stringify(report);
|
||||
expect(text).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(text).toContain("ops_***");
|
||||
});
|
||||
|
||||
it("sends only after confirmation and does not leak full userid", async () => {
|
||||
const sendMessageImpl = vi.fn(async ({ recipientExternalUserIds, message }) => {
|
||||
expect(recipientExternalUserIds).toEqual([SENSITIVE_USER_ID]);
|
||||
expect(message.body).toContain("报告:/app/artifacts/qa-flow/warch3-ops-test");
|
||||
return {
|
||||
ok: true,
|
||||
code: "wecom_app_sent",
|
||||
message: null,
|
||||
response: { errcode: 0, recipientCount: 1 },
|
||||
};
|
||||
});
|
||||
const report = await executeOpsAlert(baseOptions({ send: true }), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows(),
|
||||
sendMessageImpl,
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: { wecomRequestTimeoutMs: 1000 },
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_ALERT_SENT");
|
||||
expect(report.delivery.status).toBe("OK");
|
||||
expect(sendMessageImpl).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.stringify(report)).not.toContain(SENSITIVE_USER_ID);
|
||||
});
|
||||
|
||||
it("sends to the valid admin recipient while recording alias gaps", async () => {
|
||||
const sendMessageImpl = vi.fn(async ({ recipientExternalUserIds }) => {
|
||||
expect(recipientExternalUserIds).toEqual([SENSITIVE_USER_ID]);
|
||||
return {
|
||||
ok: true,
|
||||
code: "wecom_app_sent",
|
||||
message: null,
|
||||
response: { errcode: 0, recipientCount: 1 },
|
||||
};
|
||||
});
|
||||
const report = await executeOpsAlert(baseOptions({ send: true }), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows({
|
||||
wangshunli: { employeeNo: "missing", wecomExternalUserId: "" },
|
||||
}).filter((row) => row.employeeNo !== "missing"),
|
||||
sendMessageImpl,
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: { wecomRequestTimeoutMs: 1000 },
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_ALERT_SENT_WITH_CONFIG_GAPS");
|
||||
expect(report.recipientGaps.map((item) => item.id)).toContain("recipient_missing");
|
||||
expect(report.delivery.status).toBe("OK");
|
||||
expect(sendMessageImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("classifies missing binding as config gap", async () => {
|
||||
const report = await executeOpsAlert(baseOptions({ send: true }), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows({
|
||||
wangshunli: { wecomExternalUserId: "" },
|
||||
HYD000006: { wecomExternalUserId: "" },
|
||||
}),
|
||||
sendMessageImpl: vi.fn(),
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_ALERT_NEEDS_CONFIG");
|
||||
expect(report.recipientGaps.map((item) => item.id)).toContain("recipient_missing_wecom_binding");
|
||||
});
|
||||
|
||||
it("classifies WeCom API error as external config blocker", async () => {
|
||||
const report = await executeOpsAlert(baseOptions({ send: true }), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows(),
|
||||
sendMessageImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
code: "wecom_app_api_error",
|
||||
message: "WeCom app message errcode 48002",
|
||||
response: { errcode: 48002, invaliduser: SENSITIVE_USER_ID },
|
||||
})),
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP");
|
||||
expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(JSON.stringify(report)).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(JSON.stringify(report.delivery.response)).toContain("ops_***");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
const {
|
||||
buildDailySummaries,
|
||||
buildMonthlySummaries,
|
||||
buildRuleSnapshots,
|
||||
maskExternalUserId,
|
||||
sanitizeDailySummary,
|
||||
summarizeRuleOption,
|
||||
} = require("../../../src/services/integrations/wecomCheckinArchive");
|
||||
|
||||
const SENSITIVE_USER_ID = ["checkin", "archive", "full", "userid", "should", "not", "leak"].join("_");
|
||||
|
||||
describe("wecomCheckinArchive", () => {
|
||||
it("builds daily summaries from normalized records without raw payload fields", () => {
|
||||
const summaries = buildDailySummaries([
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
checkin_time: new Date("2026-05-20T01:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
checkin_type: "OnDuty",
|
||||
exception_type: "",
|
||||
source_type: "app",
|
||||
result: "normal",
|
||||
raw_hash: "a".repeat(64),
|
||||
},
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
checkin_time: new Date("2026-05-20T10:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
checkin_type: "OffDuty",
|
||||
exception_type: "late",
|
||||
source_type: "app",
|
||||
result: "exception",
|
||||
raw_hash: "b".repeat(64),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect(summaries[0]).toMatchObject({
|
||||
employee_id: "employee-1",
|
||||
external_user_id_prefix: "chec***",
|
||||
record_count: 2,
|
||||
exception_count: 1,
|
||||
abnormal_count: 1,
|
||||
});
|
||||
expect(summaries[0].checkin_type_summary).toEqual({ OnDuty: 1, OffDuty: 1 });
|
||||
expect(JSON.stringify(summaries)).not.toContain(SENSITIVE_USER_ID);
|
||||
});
|
||||
|
||||
it("builds monthly summaries from daily summaries", () => {
|
||||
const daily = buildDailySummaries([
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
checkin_time: new Date("2026-05-19T01:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-19T00:00:00.000Z"),
|
||||
checkin_type: "OnDuty",
|
||||
result: "normal",
|
||||
raw_hash: "a".repeat(64),
|
||||
},
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
checkin_time: new Date("2026-05-20T01:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
checkin_type: "OnDuty",
|
||||
result: "normal",
|
||||
raw_hash: "b".repeat(64),
|
||||
},
|
||||
]);
|
||||
const monthly = buildMonthlySummaries(daily, { expectedWorkdaysByMonth: { "2026-05": 3 } });
|
||||
|
||||
expect(monthly).toHaveLength(1);
|
||||
expect(monthly[0]).toMatchObject({
|
||||
employee_id: "employee-1",
|
||||
month: "2026-05",
|
||||
summary_day_count: 2,
|
||||
checkin_day_count: 2,
|
||||
missing_day_candidate_count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes rule snapshots without location, face, device or raw payload", () => {
|
||||
const employeeByExternalUserId = new Map([[SENSITIVE_USER_ID, { id: "employee-1" }]]);
|
||||
const snapshots = buildRuleSnapshots({
|
||||
snapshotDate: "2026-05-20",
|
||||
employeeByExternalUserId,
|
||||
rules: [
|
||||
{
|
||||
userid: SENSITIVE_USER_ID,
|
||||
groupname: { text: "默认规则" },
|
||||
checkin_time: [{ work_sec: 32400 }],
|
||||
loc_infos: [{ lat: 30, lng: 120 }],
|
||||
deviceid: "sensitive-device",
|
||||
face: "sensitive-face",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshots).toHaveLength(1);
|
||||
expect(snapshots[0].employee_id).toBe("employee-1");
|
||||
expect(snapshots[0].external_user_id_prefix).toBe(maskExternalUserId(SENSITIVE_USER_ID));
|
||||
const text = JSON.stringify(snapshots);
|
||||
expect(text).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(text).not.toContain("sensitive-device");
|
||||
expect(text).not.toContain("sensitive-face");
|
||||
expect(text).not.toContain("loc_infos");
|
||||
});
|
||||
|
||||
it("sanitizes daily rows for reports", () => {
|
||||
const sanitized = sanitizeDailySummary({
|
||||
employee_id: "employee-1",
|
||||
external_user_id_prefix: "chec***",
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
record_count: 1,
|
||||
first_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
last_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
exception_count: 0,
|
||||
abnormal_count: 0,
|
||||
result_summary: { normal: 1 },
|
||||
raw_hash_digest: "a".repeat(64),
|
||||
});
|
||||
|
||||
expect(sanitized).toMatchObject({
|
||||
employeeId: "employee-1",
|
||||
userIdPrefix: "chec***",
|
||||
checkinDate: "2026-05-20",
|
||||
rawHashDigestPrefix: "aaaaaaaaaaaa...",
|
||||
});
|
||||
});
|
||||
|
||||
it("masks short external user ids without exposing the full value", () => {
|
||||
expect(maskExternalUserId("ABCD")).toBe("A***");
|
||||
expect(maskExternalUserId("SMOKE002")).toBe("SM***");
|
||||
expect(maskExternalUserId("SMOKE002X")).toBe("SMOK***");
|
||||
});
|
||||
|
||||
it("filters sensitive rule keys from summaries", () => {
|
||||
const summary = summarizeRuleOption({
|
||||
userid: SENSITIVE_USER_ID,
|
||||
loc_info: "must-not-appear",
|
||||
device: "must-not-appear",
|
||||
face: "must-not-appear",
|
||||
groupname: "默认规则",
|
||||
});
|
||||
const text = JSON.stringify(summary);
|
||||
expect(text).not.toContain("loc_info");
|
||||
expect(text).not.toContain("device");
|
||||
expect(text).not.toContain("face");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户