You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
mail-yubao/tests/unit/resilience-scenarios.test.ts

450 lines
16 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 韧性设计 A-* 场景矩阵(docx/异常场景与系统韧性设计 §1–§10 / §12)
* 不连真实 IMAP/CC;覆盖纯逻辑 + 解析/门禁/限流/鉴权规则。
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import ExcelJS from "exceljs";
import AdmZip from "adm-zip";
import { classifyMail } from "@/services/parse/classify";
import { parsePackingListBuffer } from "@/services/parse/packing-list";
import { extractSpreadsheetFromZip } from "@/services/parse/zip-extract";
import { resolveNoSpreadsheetOutcome } from "@/services/parse/pipeline";
import { mapChannel } from "@/services/parse/channel-map";
import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate";
import {
assertTransition,
canTransition,
resolveAfterParse,
} from "@/services/state-machine";
import { isValidContainerNo, normalizeContainerNo } from "@/utils/iso6346";
import { parseFlexibleDate, round4, toShanghaiDateString } from "@/utils/dates";
import {
extractMailMeta,
sanitizeFilename,
truncateMessageId,
} from "@/services/imap/snapshot";
import { extractLoginToken } from "@/services/cc/auth";
import { normalizeCcCode } from "@/services/cc/code";
import { isRecentlyCreated } from "@/services/import/compensation";
import {
isImapSyncAlert,
nextPollDelayMs,
} from "@/services/imap/runtime-status";
import {
checkSlidingWindow,
resetRateLimitBuckets,
} from "@/lib/rate-limit";
import { verifyCredentials } from "@/lib/session";
import { isAdmin, parseBigIntId } from "@/lib/api";
import { ConfirmImportError } from "@/services/import/confirm";
import { resetEnvCache } from "@/lib/env";
import { computeRawHash, computeShipmentsHash, sha256 } from "@/utils/hash";
async function xlsx(rows: {
headers: string[];
data: unknown[][];
sheet?: string;
}): Promise<Buffer> {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(rows.sheet ?? "卡派资料");
ws.addRow(rows.headers);
for (const r of rows.data) ws.addRow(r);
return Buffer.from(await wb.xlsx.writeBuffer());
}
describe("A-1 输入层", () => {
it("A-1.1 空主题 → (无主题) + 仍可分类", () => {
const meta = extractMailMeta({ subject: " " } as never);
expect(meta.subject).toBe("(无主题)");
expect(meta.subjectMissing).toBe(true);
const r = classifyMail({ subject: "", body: "请查收卡派", filenames: ["a.xlsx"] });
expect(r.mail_type).toBeTruthy();
});
it("A-1.2 / A-1.3 无正文无附件 → REJECTED;NEW 无清单 → NO_SHIPMENT_ROWS", () => {
expect(
resolveNoSpreadsheetOutcome({
mailType: "NEW_CONTAINER",
hasAttachments: false,
}),
).toEqual({
status: "REJECTED_VALIDATION",
lastError: "NO_SHIPMENT_ROWS",
});
expect(
resolveAfterParse({
mailType: "NEW_CONTAINER",
validCount: 0,
errorCode: "NO_SHIPMENT_ROWS",
}),
).toBe("REJECTED_VALIDATION");
});
it("A-1.4 有附件无表格 → PARSE_FAILED / NO_SUPPORTED_SPREADSHEET", () => {
expect(
resolveNoSpreadsheetOutcome({
mailType: "NEW_CONTAINER",
hasAttachments: true,
}),
).toEqual({
status: "PARSE_FAILED",
lastError: "NO_SUPPORTED_SPREADSHEET",
});
});
it("A-1.5 缺关键列 → PARSE_FAILED + missing_columns", async () => {
const buf = await xlsx({
headers: ["备注", "随便"],
data: [["x", "y"]],
});
const parsed = await parsePackingListBuffer(buf);
expect(parsed.errorCode).toBe("PARSE_FAILED");
expect(parsed.lineage.missing_columns).toEqual(
expect.arrayContaining(["F_FBACode", "F_Transporter", "F_CTNS"]),
);
});
it("A-1.6 非法柜号 ISO 失败", () => {
expect(isValidContainerNo("BAD")).toBe(false);
expect(isValidContainerNo("ABCD1234567", true)).toBe(false);
expect(normalizeContainerNo(" matu2745683 ")).toBe("MATU2745683");
});
it("A-1.7 半空行 INVALID,其它 VALID 不阻断", async () => {
const buf = await xlsx({
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重", "柜号"],
data: [
["ABQ2", "卡派", 1, 0.5, 10, "MATU2745683"],
["", "", 0, "", "", ""],
["FTW1", "卡派", 2, 1.2, 20, "MATU2745683"],
],
});
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
expect(parsed.errorCode).toBeUndefined();
const valid = parsed.shipments.filter((s) => s.row_status === "VALID");
const invalid = parsed.shipments.filter((s) => s.row_status === "INVALID");
expect(valid.length).toBeGreaterThanOrEqual(2);
expect(invalid.length).toBeGreaterThanOrEqual(1);
const ratio = invalid.length / parsed.shipments.length;
expect(ratio).toBeLessThanOrEqual(1);
});
it("A-1.9 未映射渠道仍可为 VALID + CHANNEL_UNMAPPED", async () => {
const mapped = mapChannel("神秘渠道XYZ");
expect(mapped.unmapped).toBe(true);
expect(mapped.transporter.length).toBeLessThanOrEqual(30);
const buf = await xlsx({
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"],
data: [["ABQ2", "神秘渠道XYZ", 3, 0.8, 12]],
});
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
expect(parsed.shipments[0].warnings).toContain("CHANNEL_UNMAPPED");
expect(parsed.shipments[0].row_status).toBe("VALID");
});
it("A-1.10 体积/重量带单位可解析;乱码 → null 行仍 VALID", async () => {
const buf = await xlsx({
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"],
data: [["ABQ2", "卡派", 1, "1.25 cbm", "12.5kg"]],
});
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
expect(parsed.shipments[0].row_status).toBe("VALID");
expect(parsed.shipments[0].F_CBM).toBe(1.25);
expect(parsed.shipments[0].F_Weight).toBe(12.5);
const bad = await xlsx({
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"],
data: [["ABQ2", "卡派", 1, "约莫", "N/A"]],
});
const parsedBad = await parsePackingListBuffer(bad, { enableIsoCheck: false });
// 实现:体积/重量解析失败 → null;packing-fill 现把缺失当 INVALID
// (韧性 §1.10 写「仍 VALID」,与填写规范「体积重量必填」不一致,见审查结论)
expect(parsedBad.shipments[0].F_CBM).toBeNull();
expect(parsedBad.shipments[0].F_Weight).toBeNull();
expect(parsedBad.shipments[0].row_status).toBe("INVALID");
expect(parsedBad.shipments[0].invalid_reasons).toEqual(
expect.arrayContaining(["总体积缺失", "毛重缺失"]),
);
});
it("A-1.11 坏日期 → null;地址截断 500", async () => {
expect(parseFlexibleDate("not-a-date")).toBeNull();
expect(toShanghaiDateString("2026.05.15")).toBe("2026-05-15");
const longAddr = "A".repeat(600);
const buf = await xlsx({
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重", "派送地址", "最早送仓"],
data: [["ABQ2", "卡派", 1, 0.4, 8, longAddr, "坏日期"]],
});
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
expect(parsed.shipments[0].F_Address?.length).toBe(500);
expect(parsed.shipments[0].F_Expected_DeliveryDateB).toBeNull();
});
});
describe("A-2 / A-9 确认门禁与幂等", () => {
it("A-2.1 / A-2.2 非 PENDING 不可导入;IMPORTING/SUCCESS 不可重解析", () => {
expect(
canEnterForecastConfirm({
status: "PENDING_CONFIRM",
validShipmentCount: 1,
}),
).toBe(true);
expect(
canEnterForecastConfirm({
status: "IMPORTING",
validShipmentCount: 1,
}),
).toBe(false);
expect(canTransition("PENDING_CONFIRM", "IMPORTING")).toBe(true);
expect(canTransition("IMPORTING", "PARSED")).toBe(false);
expect(canTransition("SUCCESS", "PARSING")).toBe(false);
expect(canTransition("PARSE_FAILED", "PARSING")).toBe(true);
});
it("A-2.4 预报确认不看邮件主类型,只看状态与柜/货件数据", () => {
expect(
canEnterForecastConfirm({
status: "PENDING_CONFIRM",
validShipmentCount: 2,
}),
).toBe(true);
});
it("A-9.1 未映射未 ack → ACK_UNMAPPED_REQUIRED", () => {
const err = new ConfirmImportError(
"ACK_UNMAPPED_REQUIRED",
"存在未映射渠道,请确认后提交",
);
expect(err.status).toBe(400);
expect(err.code).toBe("ACK_UNMAPPED_REQUIRED");
});
it("A-9.5 accepted_count / shipments_hash 稳定(勾选顺序无关)", () => {
expect(computeShipmentsHash("MATU2745683", [3, 1, 2])).toBe(
computeShipmentsHash("MATU2745683", [1, 2, 3]),
);
});
it("A-2.5 VERSION_CONFLICT 409", () => {
const err = new ConfirmImportError("VERSION_CONFLICT", "版本冲突");
expect(err.status).toBe(409);
});
it("A-2.1 ALREADY_IMPORTING 409", () => {
const err = new ConfirmImportError("ALREADY_IMPORTING", "正在导入");
expect(err.status).toBe(409);
});
});
describe("A-3 IMAP / A-4 CC 容错规则", () => {
it("A-3.1 连续失败≥3 告警;≥5 指数退避上限 5min", () => {
expect(
isImapSyncAlert({
consecutiveFail: 2,
lastError: null,
lastSuccessAt: null,
lastAttemptAt: null,
lastProcessed: 0,
updatedAt: "",
}),
).toBe(false);
expect(
isImapSyncAlert({
consecutiveFail: 3,
lastError: "timeout",
lastSuccessAt: null,
lastAttemptAt: null,
lastProcessed: 0,
updatedAt: "",
}),
).toBe(true);
expect(nextPollDelayMs(4, 60_000)).toBe(60_000);
expect(nextPollDelayMs(20, 60_000)).toBe(5 * 60_000);
});
it("A-3.2 / A-3.3 近 10 分钟新建视为模糊成功可收敛", () => {
const now = Date.now();
expect(isRecentlyCreated(new Date(now - 60_000).toISOString(), now)).toBe(
true,
);
expect(
isRecentlyCreated(new Date(now - 11 * 60_000).toISOString(), now),
).toBe(false);
});
it("A-4.1 / A-4.5 登录 token 宽松解析;无法解析 → 空", () => {
expect(extractLoginToken("abc-token")).toBe("abc-token");
expect(extractLoginToken({ token: "t1" })).toBe("t1");
expect(extractLoginToken({ baseinfo: { token: "t2" } })).toBe("t2");
expect(extractLoginToken({ foo: 1 })).toBe("");
expect(extractLoginToken(null)).toBe("");
});
it("A-4.x CC code 字符串可归一", () => {
expect(normalizeCcCode("200")).toBe(200);
expect(normalizeCcCode(410)).toBe(410);
expect(normalizeCcCode(undefined)).toBe(0);
});
});
describe("A-5 解析容错", () => {
it("A-5.2 zip 一层可解;嵌套 zip 忽略", () => {
const ok = new AdmZip();
ok.addFile("卡派.xlsx", Buffer.from("x"));
const okRes = extractSpreadsheetFromZip(ok.toBuffer());
expect(okRes.files[0]?.filename).toBe("卡派.xlsx");
const inner = new AdmZip();
inner.addFile("a.xlsx", Buffer.from("x"));
const outer = new AdmZip();
outer.addFile("nested.zip", inner.toBuffer());
const nested = extractSpreadsheetFromZip(outer.toBuffer());
expect(nested.warnings).toContain("zip.nested_ignored");
expect(nested.files).toHaveLength(0);
});
it("A-5.2 路径穿越条目跳过或只留 basename", () => {
const zip = new AdmZip();
zip.addFile("folder/../../evil.xlsx", Buffer.from("x"));
const res = extractSpreadsheetFromZip(zip.toBuffer());
const skipped = res.warnings.includes("path_traversal_skipped");
const onlyBase = res.files.every((f) => !f.filename.includes(".."));
expect(skipped || onlyBase).toBe(true);
});
it("A-5.3 无「卡派」sheet → fallback 第一 sheet", async () => {
const buf = await xlsx({
sheet: "Sheet1",
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"],
data: [["ABQ2", "卡派", 1, 0.3, 5]],
});
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
expect(parsed.lineage.sheetFallback).toBe(true);
expect(parsed.shipments[0].row_status).toBe("VALID");
});
it("A-5.5 硬限制 >hard → REJECTED_VALIDATION;软限仅标记", async () => {
const buf = await xlsx({
headers: ["仓库ID", "渠道", "件数"],
data: Array.from({ length: 8 }, (_, i) => [`W${i}`, "卡派", 1]),
});
const hard = await parsePackingListBuffer(buf, {
enableIsoCheck: false,
hardLimit: 5,
});
expect(hard.errorCode).toBe("REJECTED_VALIDATION");
expect(hard.shipments).toHaveLength(0);
const soft = await parsePackingListBuffer(buf, {
enableIsoCheck: false,
softLimit: 3,
hardLimit: 100,
});
expect(soft.errorCode).toBeUndefined();
expect(soft.lineage.soft_limit_exceeded).toBe(true);
expect(soft.shipments.length).toBe(8);
});
it("A-5.6 正文柜号 ≠ 附件柜号 → 冲突规则", () => {
const body = normalizeContainerNo("TCLU1234567");
const att = normalizeContainerNo("MATU2745683");
expect(body).not.toBe(att);
});
it("A-5.8 重新解析允许 PARSE_FAILED/REJECTED/PARSED;禁止 SUCCESS/IMPORTING", () => {
expect(canTransition("PARSE_FAILED", "PARSING")).toBe(true);
expect(canTransition("REJECTED_VALIDATION", "PARSING")).toBe(true);
expect(canTransition("PARSED", "PARSING")).toBe(true);
expect(canTransition("FAILED", "PARSING")).toBe(true);
expect(canTransition("SUCCESS", "PARSING")).toBe(false);
expect(canTransition("PARTIAL_SUCCESS", "PARSING")).toBe(false);
expect(canTransition("IMPORTING", "PARSING")).toBe(false);
});
it("A-1.8 / A-5.x 附件名 sanitize 防穿越", () => {
expect(sanitizeFilename("../../etc/passwd")).toBe("passwd");
expect(sanitizeFilename("a<>b.xlsx")).toBe("a__b.xlsx");
expect(sanitizeFilename("")).toBe("attachment");
});
});
describe("A-6 状态机 / A-7 幂等", () => {
it("A-6.2 PARSING → FETCHED(stale reaper)", () => {
expect(canTransition("PARSING", "FETCHED")).toBe(true);
});
it("A-6.3 IMPORTING → FAILED(超时回收)", () => {
expect(canTransition("IMPORTING", "FAILED")).toBe(true);
expect(() => assertTransition("IMPORTING", "PENDING_CONFIRM")).toThrow();
});
it("A-6.6 非法迁移拒绝", () => {
expect(() => assertTransition("SUCCESS", "PENDING_CONFIRM")).toThrow();
expect(canTransition("IGNORED", "PARSING")).toBe(false);
});
it("A-7.3 hash 碰撞分叉:加盐后不等于 base", () => {
const base = computeRawHash({
date: "2026-01-01T00:00:00.000Z",
from: "a@b.com",
subject: "x",
filenames: ["a.xlsx"],
});
expect(sha256(`${base}|acc|INBOX|99`)).not.toBe(base);
});
it("A-7.4 Message-ID 超长截断 191(UNIQUE 列)", () => {
const long = `<${"x".repeat(300)}@ex.com>`;
expect(truncateMessageId(long)).toHaveLength(191);
});
});
describe("A-9 计算 / A-10 权限", () => {
beforeEach(() => {
process.env.DATABASE_URL =
process.env.DATABASE_URL ||
"mysql://app:app@localhost:7023/email_forecast";
process.env.SESSION_SECRET =
process.env.SESSION_SECRET ||
"unit-test-session-secret-at-least-32-chars!!";
resetEnvCache();
resetRateLimitBuckets();
});
afterEach(() => {
resetRateLimitBuckets();
});
it("A-9.2 浮点 round4", () => {
expect(round4(0.12345)).toBe(0.1235);
});
it("A-9.3 上海时区跨日", () => {
expect(toShanghaiDateString(new Date("2026-07-12T16:30:00.000Z"))).toBe(
"2026-07-13",
);
});
it("A-10.1 错误口令拒绝;空 session 非 admin", () => {
expect(verifyCredentials("admin", "wrong-password")).toBeNull();
expect(isAdmin({})).toBe(false);
expect(isAdmin({ username: "ops", role: "ops" })).toBe(false);
expect(isAdmin({ username: "admin", role: "admin" })).toBe(true);
});
it("A-10.5 导入限流 10/分钟 → 第 11 次拒绝", () => {
const key = "import:ops";
for (let i = 0; i < 10; i++) {
expect(checkSlidingWindow(key, 10, 60_000)).toBe(true);
}
expect(checkSlidingWindow(key, 10, 60_000)).toBe(false);
});
it("A-10.x 伪造 mail_id 非法 → parseBigIntId null", () => {
expect(parseBigIntId("abc")).toBeNull();
expect(parseBigIntId("-1")).toBeNull();
expect(parseBigIntId("12")).toBe(12n);
});
});