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: "smoke-employee@example.test", 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: "person@example.test" })]], ["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("smoke-employee@example.test"); expect(JSON.stringify(result)).not.toContain(TARGET_ID); }); });