330 行
12 KiB
JavaScript
330 行
12 KiB
JavaScript
const {
|
|
AUDIT_ACTION,
|
|
applyReset,
|
|
preflight,
|
|
resolveMode,
|
|
verifyCompletedPasswordChange,
|
|
verifyState,
|
|
} = require("../../../src/scripts/reset-employee-smoke-password");
|
|
const { ROLE_PERMISSIONS } = require("../../../src/constants/permissions");
|
|
|
|
const TARGET_ID = "00000000-0000-4000-8000-000000000101";
|
|
|
|
function employeeRow(overrides = {}) {
|
|
return {
|
|
id: TARGET_ID,
|
|
token_version: 7,
|
|
employee_no: "SMOKE-EMPLOYEE",
|
|
name: "OA试运行账号",
|
|
email: "[email protected]",
|
|
phone: null,
|
|
status: "active",
|
|
employment_status: "active",
|
|
archived_at: null,
|
|
must_change_password: false,
|
|
login_failed_count: 0,
|
|
login_failed_first_at: null,
|
|
login_locked_until: null,
|
|
email_login_code_failed_count: 0,
|
|
email_login_code_failed_first_at: null,
|
|
email_login_code_locked_until: null,
|
|
password_reset_failed_count: 0,
|
|
password_reset_failed_first_at: null,
|
|
password_reset_locked_until: null,
|
|
roles: [{ role_code: "employee" }],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function rawEmployeeRow(row) {
|
|
const { roles: _roles, ...raw } = row;
|
|
return raw;
|
|
}
|
|
|
|
function createFixture({ rows = [employeeRow()], auditTargetIds = [TARGET_ID] } = {}) {
|
|
const tx = {
|
|
$queryRaw: vi.fn(async (strings) => {
|
|
const sql = strings.join(" ");
|
|
if (sql.includes('FROM "employees"')) {
|
|
return rows.length === 1 ? [rawEmployeeRow(rows[0])] : [];
|
|
}
|
|
if (sql.includes('FROM "employee_roles"')) {
|
|
return rows.length === 1 ? rows[0].roles : [];
|
|
}
|
|
throw new Error("unexpected_query");
|
|
}),
|
|
employee: {
|
|
update: vi
|
|
.fn()
|
|
.mockResolvedValue({ must_change_password: true, token_version: 8 }),
|
|
},
|
|
authSession: { updateMany: vi.fn().mockResolvedValue({ count: 2 }) },
|
|
passwordResetCode: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
|
|
emailLoginCode: { updateMany: vi.fn().mockResolvedValue({ count: 3 }) },
|
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
|
};
|
|
const prisma = {
|
|
employee: {
|
|
findMany: vi.fn(async (query) => {
|
|
if (query?.where?.id?.in) {
|
|
return rows.filter((row) => query.where.id.in.includes(row.id));
|
|
}
|
|
return rows;
|
|
}),
|
|
},
|
|
authSession: { count: vi.fn().mockResolvedValue(0) },
|
|
passwordResetCode: { count: vi.fn().mockResolvedValue(0) },
|
|
emailLoginCode: { count: vi.fn().mockResolvedValue(0) },
|
|
auditLog: {
|
|
findMany: vi
|
|
.fn()
|
|
.mockResolvedValue(auditTargetIds.map((target_id) => ({ target_id }))),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
},
|
|
$transaction: vi.fn(async (callback, options) => {
|
|
expect(options).toEqual({ isolationLevel: "Serializable" });
|
|
return callback(tx);
|
|
}),
|
|
};
|
|
return { prisma, tx };
|
|
}
|
|
|
|
describe("employee smoke break-glass password reset", () => {
|
|
it("defaults to read-only preflight and requires the exact apply confirmation", () => {
|
|
expect(resolveMode([])).toEqual({ mode: "preflight" });
|
|
expect(resolveMode(["--dry-run"])).toEqual({ mode: "preflight" });
|
|
expect(resolveMode(["--preflight"])).toEqual({ mode: "preflight" });
|
|
expect(resolveMode(["--apply", "EMPLOYEE_SMOKE_BREAK_GLASS"])).toEqual({
|
|
mode: "apply",
|
|
});
|
|
expect(() => resolveMode(["--apply"])).toThrow("explicit_confirmation_required");
|
|
expect(() => resolveMode(["--apply", "wrong"])).toThrow(
|
|
"explicit_confirmation_required",
|
|
);
|
|
});
|
|
|
|
it("passes preflight only for one exact employee smoke role without management permissions", async () => {
|
|
const { prisma } = createFixture();
|
|
|
|
await expect(preflight(prisma)).resolves.toMatchObject({
|
|
status: "PASS",
|
|
targetMatches: 1,
|
|
exactTarget: true,
|
|
mustChangePassword: false,
|
|
mustChangePasswordMatches: true,
|
|
rolesExact: true,
|
|
hasWildcard: false,
|
|
hasProjectManage: false,
|
|
hasProjectViewAll: false,
|
|
hasSystemSettings: false,
|
|
contractManagePermissionsAbsent: true,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["no target", []],
|
|
["multiple targets", [employeeRow(), employeeRow({ id: "00000000-0000-4000-8000-000000000102" })]],
|
|
["non smoke", [employeeRow({ employee_no: "E100", name: "普通员工", email: "[email protected]" })]],
|
|
["identifier missing", [employeeRow({ email: null, phone: null })]],
|
|
["status inactive", [employeeRow({ status: "inactive" })]],
|
|
["employment inactive", [employeeRow({ employment_status: "inactive" })]],
|
|
["archived", [employeeRow({ archived_at: new Date() })]],
|
|
["locked", [employeeRow({ login_locked_until: new Date() })]],
|
|
["must change already", [employeeRow({ must_change_password: true })]],
|
|
["extra role", [employeeRow({ roles: [{ role_code: "employee" }, { role_code: "manager" }] })]],
|
|
])("blocks preflight for %s", async (_label, rows) => {
|
|
const { prisma } = createFixture({ rows });
|
|
await expect(preflight(prisma)).resolves.toMatchObject({ status: "BLOCKED" });
|
|
});
|
|
|
|
it("blocks when the employee role permission mapping gains contract management", async () => {
|
|
ROLE_PERMISSIONS.employee.push("project.manage");
|
|
try {
|
|
const { prisma } = createFixture();
|
|
await expect(preflight(prisma)).resolves.toMatchObject({
|
|
status: "BLOCKED",
|
|
rolesExact: true,
|
|
hasProjectManage: true,
|
|
contractManagePermissionsAbsent: false,
|
|
});
|
|
} finally {
|
|
ROLE_PERMISSIONS.employee.pop();
|
|
}
|
|
});
|
|
|
|
it("writes only target authentication state and never writes roles or employee identity", async () => {
|
|
const { prisma, tx } = createFixture();
|
|
|
|
await expect(applyReset(prisma, "UniqueBreakGlass9Qz!")).resolves.toEqual({
|
|
status: "PASS",
|
|
targetMatches: 1,
|
|
exactTarget: true,
|
|
passwordUpdated: true,
|
|
mustChangePassword: true,
|
|
tokenVersionIncremented: true,
|
|
locksCleared: true,
|
|
rolesExact: true,
|
|
contractManagePermissionsAbsent: true,
|
|
revokedSessions: 2,
|
|
invalidatedPasswordResetCodes: 1,
|
|
consumedEmailLoginCodes: 3,
|
|
auditCreated: true,
|
|
});
|
|
|
|
const update = tx.employee.update.mock.calls[0][0];
|
|
expect(update.where).toEqual({ id: TARGET_ID });
|
|
expect(Object.keys(update.data).sort()).toEqual(
|
|
[
|
|
"email_login_code_failed_count",
|
|
"email_login_code_failed_first_at",
|
|
"email_login_code_locked_until",
|
|
"login_failed_count",
|
|
"login_failed_first_at",
|
|
"login_locked_until",
|
|
"must_change_password",
|
|
"password_hash",
|
|
"password_reset_failed_count",
|
|
"password_reset_failed_first_at",
|
|
"password_reset_last_requested_at",
|
|
"password_reset_locked_until",
|
|
"password_reset_request_count",
|
|
"password_reset_request_first_at",
|
|
"token_version",
|
|
].sort(),
|
|
);
|
|
expect(update.data).not.toHaveProperty("roles");
|
|
expect(update.data).not.toHaveProperty("status");
|
|
expect(update.data).not.toHaveProperty("employment_status");
|
|
expect(update.data).not.toHaveProperty("archived_at");
|
|
expect(update.data).not.toHaveProperty("email");
|
|
expect(update.data).not.toHaveProperty("phone");
|
|
expect(tx.authSession.updateMany).toHaveBeenCalledWith({
|
|
where: { employee_id: TARGET_ID, revoked_at: null },
|
|
data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) },
|
|
});
|
|
expect(tx.passwordResetCode.updateMany).toHaveBeenCalledWith({
|
|
where: { employee_id: TARGET_ID, invalidated_at: null },
|
|
data: { invalidated_at: expect.any(Date) },
|
|
});
|
|
expect(tx.emailLoginCode.updateMany).toHaveBeenCalledWith({
|
|
where: { employee_id: TARGET_ID, used_at: null },
|
|
data: { used_at: expect.any(Date) },
|
|
});
|
|
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
actor_id: TARGET_ID,
|
|
target_id: TARGET_ID,
|
|
action: AUDIT_ACTION,
|
|
extra: { credentialRecorded: false },
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("fails closed when the exact role changes after preflight", async () => {
|
|
const { prisma, tx } = createFixture();
|
|
tx.$queryRaw.mockImplementation(async (strings) => {
|
|
const sql = strings.join(" ");
|
|
if (sql.includes('FROM "employees"')) return [rawEmployeeRow(employeeRow())];
|
|
return [{ role_code: "employee" }, { role_code: "manager" }];
|
|
});
|
|
|
|
await expect(applyReset(prisma, "UniqueBreakGlass9Qz!")).rejects.toThrow(
|
|
"employee_smoke_target_changed",
|
|
);
|
|
expect(tx.employee.update).not.toHaveBeenCalled();
|
|
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed when the token version changes after preflight", async () => {
|
|
const { prisma, tx } = createFixture();
|
|
tx.$queryRaw.mockImplementation(async (strings) => {
|
|
const sql = strings.join(" ");
|
|
if (sql.includes('FROM "employees"')) {
|
|
return [rawEmployeeRow(employeeRow({ token_version: 8 }))];
|
|
}
|
|
return [{ role_code: "employee" }];
|
|
});
|
|
|
|
await expect(applyReset(prisma, "UniqueBreakGlass9Qz!")).rejects.toThrow(
|
|
"employee_smoke_target_changed",
|
|
);
|
|
expect(tx.employee.update).not.toHaveBeenCalled();
|
|
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("verifies reset state with exact role and all four management permissions absent", async () => {
|
|
const row = employeeRow({ must_change_password: true });
|
|
const { prisma } = createFixture({ rows: [row] });
|
|
|
|
await expect(verifyState(prisma)).resolves.toMatchObject({
|
|
status: "PASS",
|
|
mustChangePassword: true,
|
|
mustChangePasswordMatches: true,
|
|
targetMatches: 1,
|
|
rolesExact: true,
|
|
hasWildcard: false,
|
|
hasProjectManage: false,
|
|
hasProjectViewAll: false,
|
|
hasSystemSettings: false,
|
|
contractManagePermissionsAbsent: true,
|
|
activeSessions: 0,
|
|
activePasswordResetCodes: 0,
|
|
activeEmailLoginCodes: 0,
|
|
auditPresent: true,
|
|
});
|
|
});
|
|
|
|
it("blocks verify when the reset target has any extra role", async () => {
|
|
const row = employeeRow({
|
|
must_change_password: true,
|
|
roles: [{ role_code: "employee" }, { role_code: "manager" }],
|
|
});
|
|
const { prisma } = createFixture({ rows: [row] });
|
|
|
|
await expect(verifyState(prisma)).resolves.toMatchObject({
|
|
status: "BLOCKED",
|
|
targetMatches: 1,
|
|
rolesExact: false,
|
|
});
|
|
});
|
|
|
|
it.each(["*", "project.manage", "project.view_all", "system.settings"])(
|
|
"blocks verify when employee permissions contain %s",
|
|
async (permission) => {
|
|
ROLE_PERMISSIONS.employee.push(permission);
|
|
try {
|
|
const row = employeeRow({ must_change_password: true });
|
|
const { prisma } = createFixture({ rows: [row] });
|
|
const result = await verifyState(prisma);
|
|
expect(result).toMatchObject({
|
|
status: "BLOCKED",
|
|
rolesExact: true,
|
|
contractManagePermissionsAbsent: false,
|
|
});
|
|
} finally {
|
|
ROLE_PERMISSIONS.employee.pop();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("verifies the completed forced password change without exposing the target", async () => {
|
|
const { prisma } = createFixture();
|
|
prisma.authSession.count.mockResolvedValue(1);
|
|
prisma.auditLog.count.mockResolvedValue(1);
|
|
|
|
const result = await verifyCompletedPasswordChange(prisma);
|
|
expect(result).toMatchObject({
|
|
status: "PASS",
|
|
mustChangePassword: false,
|
|
mustChangePasswordMatches: true,
|
|
rolesExact: true,
|
|
contractManagePermissionsAbsent: true,
|
|
activeSessions: 1,
|
|
passwordChangeAuditPresent: true,
|
|
breakGlassAuditPresent: true,
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("[email protected]");
|
|
expect(JSON.stringify(result)).not.toContain(TARGET_ID);
|
|
});
|
|
});
|