统一 Node 22 与后端安全运行基线(07-15-production-trust-closeout-cleanup)
这个提交包含在:
@@ -0,0 +1,90 @@
|
||||
const { initTracing, shutdownTracing } = require("../../../src/observability/tracing");
|
||||
|
||||
function buildDependencies({ constructError = null } = {}) {
|
||||
const instances = [];
|
||||
const resourceFromAttributes = vi.fn((attributes) => ({ attributes }));
|
||||
const getNodeAutoInstrumentations = vi.fn((options) => ({ options }));
|
||||
const logger = { log: vi.fn(), error: vi.fn() };
|
||||
|
||||
class TestNodeSDK {
|
||||
constructor(options) {
|
||||
if (constructError) {
|
||||
throw constructError;
|
||||
}
|
||||
this.options = options;
|
||||
this.start = vi.fn();
|
||||
this.shutdown = vi.fn(async () => {});
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
overrides: {
|
||||
NodeSDK: TestNodeSDK,
|
||||
ConsoleSpanExporter: class TestConsoleSpanExporter {},
|
||||
resourceFromAttributes,
|
||||
getNodeAutoInstrumentations,
|
||||
logger,
|
||||
},
|
||||
instances,
|
||||
resourceFromAttributes,
|
||||
getNodeAutoInstrumentations,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
describe("observability tracing", () => {
|
||||
afterEach(async () => {
|
||||
await shutdownTracing();
|
||||
});
|
||||
|
||||
it("does not initialize the SDK when traces are disabled", () => {
|
||||
const dependencies = buildDependencies();
|
||||
|
||||
expect(initTracing({ otelTracesEnabled: false }, dependencies.overrides)).toBeNull();
|
||||
expect(dependencies.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("initializes the SDK with the service resource and filesystem instrumentation disabled", () => {
|
||||
const dependencies = buildDependencies();
|
||||
|
||||
const result = initTracing({ otelTracesEnabled: true }, dependencies.overrides);
|
||||
|
||||
expect(result).toBe(dependencies.instances[0]);
|
||||
expect(dependencies.resourceFromAttributes).toHaveBeenCalledWith({ "service.name": "hyb-oa-server" });
|
||||
expect(dependencies.getNodeAutoInstrumentations).toHaveBeenCalledWith({
|
||||
"@opentelemetry/instrumentation-fs": { enabled: false },
|
||||
});
|
||||
expect(result.start).toHaveBeenCalledOnce();
|
||||
expect(dependencies.logger.log).toHaveBeenCalledWith("OTel tracing initialized");
|
||||
});
|
||||
|
||||
it("supports a distinct worker service name", () => {
|
||||
const dependencies = buildDependencies();
|
||||
|
||||
initTracing({ otelTracesEnabled: true }, {
|
||||
...dependencies.overrides,
|
||||
serviceName: "hyb-oa-worker",
|
||||
});
|
||||
|
||||
expect(dependencies.resourceFromAttributes).toHaveBeenCalledWith({ "service.name": "hyb-oa-worker" });
|
||||
});
|
||||
|
||||
it("contains initialization failures and leaves tracing disabled", () => {
|
||||
const error = new Error("otel_init_failed");
|
||||
const dependencies = buildDependencies({ constructError: error });
|
||||
|
||||
expect(initTracing({ otelTracesEnabled: true }, dependencies.overrides)).toBeNull();
|
||||
expect(dependencies.logger.error).toHaveBeenCalledWith("Error initializing OTel tracing", error);
|
||||
});
|
||||
|
||||
it("shuts down an initialized SDK once", async () => {
|
||||
const dependencies = buildDependencies();
|
||||
const instance = initTracing({ otelTracesEnabled: true }, dependencies.overrides);
|
||||
|
||||
await shutdownTracing();
|
||||
await shutdownTracing();
|
||||
|
||||
expect(instance.shutdown).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,11 @@ const INACTIVE_EMPLOYEE_ID = "00000000-0000-4000-8000-000000000004";
|
||||
const POLICY_ID = "10000000-0000-4000-8000-000000000001";
|
||||
const GENERAL_ID = "10000000-0000-4000-8000-000000000002";
|
||||
const HIDDEN_ID = "10000000-0000-4000-8000-000000000003";
|
||||
const TEST_FUTURE_OFFSET_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function buildFutureDate() {
|
||||
return new Date(Date.now() + TEST_FUTURE_OFFSET_MS);
|
||||
}
|
||||
|
||||
function buildEmployee(overrides = {}) {
|
||||
return {
|
||||
@@ -47,7 +52,7 @@ function buildAnnouncement(overrides = {}) {
|
||||
category: "policy",
|
||||
requires_acknowledgement: true,
|
||||
acknowledgement_statement: "我已阅读并理解该制度。",
|
||||
acknowledgement_due_at: new Date("2026-06-01T15:59:59.000Z"),
|
||||
acknowledgement_due_at: buildFutureDate(),
|
||||
created_at: now,
|
||||
created_by: ADMIN_ID,
|
||||
updated_at: now,
|
||||
@@ -76,11 +81,12 @@ function matchAnnouncementWhere(item, where = {}) {
|
||||
if (where.enabled !== undefined && item.enabled !== where.enabled) {
|
||||
return false;
|
||||
}
|
||||
const now = new Date("2026-05-28T02:00:00.000Z");
|
||||
if (where.OR && item.starts_at && item.starts_at > now) {
|
||||
const startsAtUpperBound = where.OR?.find((clause) => clause.starts_at?.lte)?.starts_at.lte ?? new Date();
|
||||
const endsAtLowerBound = where.AND?.[0]?.OR?.find((clause) => clause.ends_at?.gte)?.ends_at.gte ?? new Date();
|
||||
if (where.OR && item.starts_at && item.starts_at > startsAtUpperBound) {
|
||||
return false;
|
||||
}
|
||||
if (where.AND && item.ends_at && item.ends_at < now) {
|
||||
if (where.AND && item.ends_at && item.ends_at < endsAtLowerBound) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -107,7 +113,7 @@ function createPrismaState(options = {}) {
|
||||
buildAnnouncement({
|
||||
id: HIDDEN_ID,
|
||||
title: "未来公告",
|
||||
starts_at: new Date("2026-06-01T00:00:00.000Z"),
|
||||
starts_at: buildFutureDate(),
|
||||
}),
|
||||
];
|
||||
const acknowledgements = options.acknowledgements ?? [];
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
const { validateDevSeedSafety } = require("../../../src/scripts/dev-seed-safety");
|
||||
const { ensureEmployee } = require("../../../src/scripts/seed-dev");
|
||||
|
||||
function safeEnv(overrides = {}) {
|
||||
return {
|
||||
NODE_ENV: "development",
|
||||
SEED_DEV_ALLOW: "1",
|
||||
SEED_DEV_DATABASE_CONFIRM: "hyb_oa_test",
|
||||
DATABASE_URL: "postgresql://local-user:[email protected]:5432/hyb_oa_test?schema=public",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function employeePayload() {
|
||||
return {
|
||||
employeeNo: "DEV001",
|
||||
name: "开发员工",
|
||||
phone: "00000000000",
|
||||
email: "[email protected]",
|
||||
password: "OneTimePassword1234",
|
||||
departmentId: "department-1",
|
||||
legalEntityId: "entity-1",
|
||||
title: "测试",
|
||||
directManagerId: null,
|
||||
roles: ["employee"],
|
||||
hireDate: new Date("2020-01-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrisma(existing = { id: "employee-1" }) {
|
||||
return {
|
||||
employee: {
|
||||
findFirst: vi.fn(async () => existing),
|
||||
update: vi.fn(async () => ({ id: existing?.id ?? "employee-1" })),
|
||||
create: vi.fn(async () => ({ id: "employee-created" })),
|
||||
},
|
||||
employeeRole: {
|
||||
deleteMany: vi.fn(async () => ({ count: 0 })),
|
||||
createMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("dev seed safety guard", () => {
|
||||
it("rejects production even when all explicit switches are present", () => {
|
||||
expect(() => validateDevSeedSafety(safeEnv({ NODE_ENV: "production" }))).toThrowError(
|
||||
expect.objectContaining({ code: "production_forbidden" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects execution without the explicit allow switch", () => {
|
||||
expect(() => validateDevSeedSafety(safeEnv({ SEED_DEV_ALLOW: "" }))).toThrowError(
|
||||
expect.objectContaining({ code: "explicit_allow_required" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-loopback database targets", () => {
|
||||
expect(() => validateDevSeedSafety(safeEnv({
|
||||
DATABASE_URL: "postgresql://local-user:[email protected]:5432/hyb_oa_test",
|
||||
}))).toThrowError(expect.objectContaining({ code: "database_host_not_local" }));
|
||||
});
|
||||
|
||||
it("rejects a query-string host override that would bypass the loopback authority", () => {
|
||||
expect(() => validateDevSeedSafety(safeEnv({
|
||||
DATABASE_URL: "postgresql://local-user:[email protected]:5432/hyb_oa_test?host=database.internal",
|
||||
}))).toThrowError(expect.objectContaining({ code: "database_host_not_local" }));
|
||||
});
|
||||
|
||||
it("rejects a missing or mismatched database confirmation", () => {
|
||||
expect(() => validateDevSeedSafety(safeEnv({ SEED_DEV_DATABASE_CONFIRM: "" }))).toThrowError(
|
||||
expect.objectContaining({ code: "database_confirmation_mismatch" }),
|
||||
);
|
||||
expect(() => validateDevSeedSafety(safeEnv({ SEED_DEV_DATABASE_CONFIRM: "another_database" }))).toThrowError(
|
||||
expect.objectContaining({ code: "database_confirmation_mismatch" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["localhost", "127.0.0.1", "[::1]"])("accepts the local host %s with exact confirmation", (host) => {
|
||||
expect(validateDevSeedSafety(safeEnv({
|
||||
DATABASE_URL: `postgresql://local-user:local-password@${host}:5432/hyb_oa_test`,
|
||||
}))).toEqual({ databaseName: "hyb_oa_test", resetPasswords: false });
|
||||
});
|
||||
|
||||
it("enables one-time password rotation only through the separate reset switch", () => {
|
||||
expect(validateDevSeedSafety(safeEnv({ SEED_DEV_RESET_PASSWORDS: "1" }))).toEqual({
|
||||
databaseName: "hyb_oa_test",
|
||||
resetPasswords: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not include database credentials or target details in refusal messages", () => {
|
||||
const env = safeEnv({
|
||||
DATABASE_URL: "postgresql://sensitive-user:[email protected]:5432/sensitive_database",
|
||||
SEED_DEV_DATABASE_CONFIRM: "sensitive_database",
|
||||
});
|
||||
|
||||
try {
|
||||
validateDevSeedSafety(env);
|
||||
throw new Error("expected guard refusal");
|
||||
} catch (error) {
|
||||
expect(error.message).not.toMatch(/super-secret|sensitive-user|database\.internal|sensitive_database/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dev seed employee password behavior", () => {
|
||||
it("preserves the password fields of an existing account by default", async () => {
|
||||
const prisma = buildPrisma();
|
||||
|
||||
const result = await ensureEmployee(prisma, employeePayload());
|
||||
|
||||
const updateData = prisma.employee.update.mock.calls[0][0].data;
|
||||
expect(updateData).not.toHaveProperty("password_hash");
|
||||
expect(updateData).not.toHaveProperty("must_change_password");
|
||||
expect(result.seedPasswordAction).toBe("preserved");
|
||||
});
|
||||
|
||||
it("rotates an existing password only when requested and marks it one-time", async () => {
|
||||
const prisma = buildPrisma();
|
||||
|
||||
const result = await ensureEmployee(prisma, employeePayload(), { resetPasswords: true });
|
||||
|
||||
const updateData = prisma.employee.update.mock.calls[0][0].data;
|
||||
expect(updateData.password_hash).toEqual(expect.any(String));
|
||||
expect(updateData.password_hash).not.toContain("OneTimePassword1234");
|
||||
expect(updateData.must_change_password).toBe(true);
|
||||
expect(result.seedPasswordAction).toBe("reset");
|
||||
});
|
||||
|
||||
it("creates a missing development account without printing its credential", async () => {
|
||||
const prisma = buildPrisma(null);
|
||||
|
||||
const result = await ensureEmployee(prisma, employeePayload());
|
||||
|
||||
const createData = prisma.employee.create.mock.calls[0][0].data;
|
||||
expect(createData.password_hash).toEqual(expect.any(String));
|
||||
expect(createData.password_hash).not.toContain("OneTimePassword1234");
|
||||
expect(createData.must_change_password).toBe(true);
|
||||
expect(result.seedPasswordAction).toBe("created");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,22 @@
|
||||
const { resolveEmailDeliveryStatus } = require("../../../src/services/email");
|
||||
const {
|
||||
resolveEmailDeliveryStatus,
|
||||
sendNotificationEmail,
|
||||
} = require("../../../src/services/email");
|
||||
|
||||
function buildEmailConfig(overrides = {}) {
|
||||
return {
|
||||
emailEnabled: true,
|
||||
emailFrom: "[email protected]",
|
||||
emailBaseUrl: "https://oa.hyinda.test/",
|
||||
appOrigin: "",
|
||||
smtpHost: "smtp.hyinda.test",
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUser: "sender",
|
||||
smtpPass: "test-only-password",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("email delivery status", () => {
|
||||
it("reports ready only when SMTP delivery has the required public settings", () => {
|
||||
@@ -35,3 +53,110 @@ describe("email delivery status", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification email delivery", () => {
|
||||
it("skips delivery without creating a transporter when email is disabled", async () => {
|
||||
const createTransport = vi.fn();
|
||||
const prisma = { employee: { findMany: vi.fn() } };
|
||||
|
||||
const result = await sendNotificationEmail({
|
||||
prisma,
|
||||
config: buildEmailConfig({ emailEnabled: false }),
|
||||
recipientEmployeeIds: ["employee-1"],
|
||||
subject: "待办提醒",
|
||||
body: "请处理待办。",
|
||||
createTransport,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ skipped: true, sent: 0, failed: 0 });
|
||||
expect(createTransport).not.toHaveBeenCalled();
|
||||
expect(prisma.employee.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a Nodemailer 9 transporter and sends text mail with a safe link", async () => {
|
||||
const sendMail = vi.fn(async () => ({ messageId: "message-1" }));
|
||||
const createTransport = vi.fn(() => ({ sendMail }));
|
||||
const prisma = {
|
||||
employee: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: "employee-1", email: "[email protected]", name: "员工一" },
|
||||
{ id: "employee-2", email: null, name: "员工二" },
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sendNotificationEmail({
|
||||
prisma,
|
||||
config: buildEmailConfig({ smtpHost: "smtp-success.hyinda.test" }),
|
||||
recipientEmployeeIds: ["employee-1", "employee-2", "employee-1"],
|
||||
subject: "待办提醒",
|
||||
body: "请处理待办。",
|
||||
type: "workflow_task",
|
||||
targetPath: "/workflows/tasks/1",
|
||||
requestId: "request-1",
|
||||
createTransport,
|
||||
});
|
||||
|
||||
expect(createTransport).toHaveBeenCalledWith({
|
||||
host: "smtp-success.hyinda.test",
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: { user: "sender", pass: "test-only-password" },
|
||||
});
|
||||
expect(sendMail).toHaveBeenCalledWith({
|
||||
from: "[email protected]",
|
||||
to: "[email protected]",
|
||||
subject: "待办提醒",
|
||||
text: "请处理待办。\n\n查看详情:https://oa.hyinda.test/workflows/tasks/1",
|
||||
headers: {
|
||||
"x-hyb-notification-type": "workflow_task",
|
||||
"x-request-id": "request-1",
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ skipped: false, sent: 1, failed: 1 });
|
||||
});
|
||||
|
||||
it("counts send failures without logging the recipient email address", async () => {
|
||||
const error = new Error("smtp_rejected");
|
||||
error.code = "EENVELOPE";
|
||||
error.command = "RCPT TO";
|
||||
error.responseCode = 550;
|
||||
error.response = "550 rejected [email protected]";
|
||||
error.rejected = ["[email protected]"];
|
||||
const createTransport = vi.fn(() => ({ sendMail: vi.fn(async () => { throw error; }) }));
|
||||
const logger = { error: vi.fn() };
|
||||
const prisma = {
|
||||
employee: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: "employee-3", email: "[email protected]", name: "员工三" },
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sendNotificationEmail({
|
||||
prisma,
|
||||
config: buildEmailConfig({ smtpHost: "smtp-failure.hyinda.test" }),
|
||||
logger,
|
||||
requestId: "request-2",
|
||||
recipientEmployeeIds: ["employee-3"],
|
||||
subject: "待办提醒",
|
||||
body: "请处理待办。",
|
||||
createTransport,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ skipped: false, sent: 0, failed: 1 });
|
||||
expect(logger.error).toHaveBeenCalledOnce();
|
||||
const [context] = logger.error.mock.calls[0];
|
||||
expect(context).toMatchObject({
|
||||
errorCode: "email_delivery_failed",
|
||||
providerCode: "EENVELOPE",
|
||||
smtpCommand: "RCPT TO",
|
||||
smtpResponseCode: 550,
|
||||
requestId: "request-2",
|
||||
employeeId: "employee-3",
|
||||
});
|
||||
expect(context).not.toHaveProperty("err");
|
||||
expect(context).not.toHaveProperty("email");
|
||||
expect(JSON.stringify(context)).not.toContain("[email protected]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("workspace small-team navigation defaults", () => {
|
||||
});
|
||||
|
||||
const mainLabels = navigation.sections.main.map((item) => item.label);
|
||||
expect(mainLabels).toEqual(["工作台", "报销", "采购申请", "项目", "资产", "请假记录"]);
|
||||
expect(mainLabels).toEqual(["工作台", "报销", "采购", "项目", "资产", "请假记录"]);
|
||||
expect(mainLabels).not.toContain("待我处理");
|
||||
expect(mainLabels).not.toContain("票据");
|
||||
expect(mainLabels).not.toContain("个人设置");
|
||||
@@ -150,8 +150,8 @@ describe("workspace small-team navigation defaults", () => {
|
||||
"工作台",
|
||||
"待我处理",
|
||||
"报销",
|
||||
"采购申请",
|
||||
"合同台账",
|
||||
"采购",
|
||||
"合同",
|
||||
"项目",
|
||||
"资产",
|
||||
"团队",
|
||||
|
||||
在新工单中引用
屏蔽一个用户