From c8287c48197447fca275944e687ac30618006792 Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 2 Nov 2026 22:51:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=9A=B4=EC=98=81=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=20=EB=A9=94=EC=9D=BC=20=EC=9D=B4=EC=A0=84=20=E2=80=94?= =?UTF-8?q?=20=EC=9D=B4=EC=A0=84=EC=9A=A9=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=C2=B7=20=E3=80=8C=EB=A9=94=EC=9D=BC=20=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=E3=80=8D=20=C2=B7=20=EC=9D=B4=EC=A0=84=20=EC=A4=91=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전 직원 전환의 imapsync 는 새 엔진에 IMAP 으로 들어가야 하는데, 입사 · 재설정의 임시 비밀번호는 만료라 IMAP 이 끊긴다. 직원이 비밀번호를 바꾸면 마지막 차이분을 옮길 길도 없었다. - 「입사」의 옮겨 올 메일 질문 → 이전용 비밀번호(엔진 만료 30일 뒤 · 실행 한 번 안에서 같은 값 · 메모리에만) - 메뉴 「11. 메일 이전」 — 이전 현황 · 이전용 비밀번호 다시 만들기(세션을 먼저 끊는다) · 이전 마치기 (앱 비밀번호 → 세션 → imapsync 로그인 기록 지우기 → 임시 비밀번호 + 만료 → 세션, 알림 메일 없음, 끝에 표 한 번) - 이전 중 = 엔진 만료가 미래(31일 안) + 실행 기록의 마지막 판정 기록이 이전용(hire-migration · migration-password) — 엔진 기본 만료의 찌꺼기를 이전 중으로 읽지 않게 - 재설정 · 잠금 풀기 가드, 현황의 이전 중 표시, 엔진 기본 만료는 첫 화면 알림만 Co-Authored-By: Claude Opus 5.5 --- .../modules/mail/jmap/jmap-ops-engine.spec.ts | 331 +++- .../src/modules/mail/jmap/jmap-ops-engine.ts | 99 +- backend/src/modules/ops/account-commands.ts | 146 +- backend/src/modules/ops/admin-commands.ts | 29 +- backend/src/modules/ops/lock-commands.ts | 19 +- backend/src/modules/ops/migration-commands.ts | 864 ++++++++++ backend/src/modules/ops/migration-password.ts | 74 + backend/src/modules/ops/migration-state.ts | 103 ++ .../src/modules/ops/ops-audit-log.entity.ts | 45 +- backend/src/modules/ops/ops-audit.service.ts | 3 +- backend/src/modules/ops/ops-cli.ts | 31 +- backend/src/modules/ops/ops-commands.spec.ts | 1422 ++++++++++++++++- backend/src/modules/ops/ops-data.service.ts | 106 +- backend/src/modules/ops/ops-engine.port.ts | 34 +- backend/src/modules/ops/ops-helpers.spec.ts | 238 ++- backend/src/modules/ops/ops-prompts.ts | 88 +- backend/src/modules/ops/ops-text.ts | 25 +- backend/src/modules/ops/ops.module.ts | 5 + backend/src/modules/ops/temp-password.ts | 33 +- 19 files changed, 3598 insertions(+), 97 deletions(-) create mode 100644 backend/src/modules/ops/migration-commands.ts create mode 100644 backend/src/modules/ops/migration-password.ts create mode 100644 backend/src/modules/ops/migration-state.ts diff --git a/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts b/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts index f860ec3..809ca84 100644 --- a/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts @@ -1,3 +1,5 @@ +import { Logger } from '@nestjs/common'; + import type { MailSettings } from '@/modules/mail/mail.settings'; import type { MailFilterSet, @@ -199,6 +201,7 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => { locked: false, customPermissions: false, passwordExpired: false, + passwordExpiresAt: null, twoFactorEnabled: true, appPasswords: 1, usedBytes: 2048, @@ -257,9 +260,14 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => { localPart: 'hong', name: '홍길동', quotaBytes: 5, - tempPassword: 'Temp1234Temp1234', + password: 'Temp1234Temp1234', + migrationUntil: null, + }); + expect(created).toMatchObject({ + id: 'acc9', + passwordExpired: true, + passwordExpiresAt: null, }); - expect(created).toMatchObject({ id: 'acc9', passwordExpired: true }); const create = bodyAt(3).methodCalls[0][1].create as Record< string, Record @@ -307,7 +315,8 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => { localPart: 'hong', name: '홍', quotaBytes: null, - tempPassword: 'weak', + password: 'weak', + migrationUntil: null, }), ).rejects.toMatchObject({ kind: 'weak-password' }); @@ -327,11 +336,325 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => { localPart: 'hong', name: '홍', quotaBytes: null, - tempPassword: 'x', + password: 'x', + migrationUntil: null, }), ).rejects.toMatchObject({ kind: 'exists' }); }); + describe('메일 이전 — 이전용 비밀번호(만료가 미래)', () => { + const DAY_MS = 24 * 60 * 60 * 1000; + /** 엔진의 시각 칸 — 초 단위 */ + const engineTime = (at: Date) => at.toISOString().replace(/\.\d+Z$/, 'Z'); + + it('입사 — 이전용 비밀번호와 받은 미래 시각(초 단위)을 만료로 한 번에 만든다 · 읽은 계정은 이전 중', async () => { + await signedIn(); + const until = new Date(Date.now() + 30 * DAY_MS); + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Domain/query', { ids: ['d1'] }, 'q'], + ['x:Domain/get', { list: [{ id: 'd1', name: DOMAIN }] }, 'g'], + ], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/set', { created: { a: { id: 'acc9' } } }, 's'], + ], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + [ + 'x:Account/get', + { + list: [ + { + ...araAccount, + id: 'acc9', + emailAddress: `hong@${DOMAIN}`, + credentials: { + '0': { + '@type': 'Password', + expiresAt: engineTime(until), + }, + }, + }, + ], + }, + 'g', + ], + ], + }), + ); + const created = await engine.createEmployee({ + localPart: 'hong', + name: '홍길동', + quotaBytes: null, + password: 'Migr1234Migr1234', + migrationUntil: until, + }); + expect(created).toMatchObject({ + id: 'acc9', + passwordExpired: false, + passwordExpiresAt: new Date(engineTime(until)).toISOString(), + }); + const create = bodyAt(3).methodCalls[0][1].create as Record< + string, + Record + >; + expect( + (create.a.credentials as Record>)['0'], + ).toEqual({ + '@type': 'Password', + secret: 'Migr1234Migr1234', + expiresAt: engineTime(until), + }); + expect(create.a).not.toHaveProperty('quotas'); + }); + + it('setMigrationPassword — 비밀번호 자격의 secret 과 만료(미래 · 초 단위)만 패치한다(2단계 인증은 칸에 넣지 않는다)', async () => { + await signedIn(); + const until = new Date(Date.now() + 30 * DAY_MS); + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/set', { updated: { acc1: null } }, 's'], + ], + }), + ); + await engine.setMigrationPassword('acc1', 'Migr1234Migr1234', until); + expect( + (bodyAt(3).methodCalls[0][1].update as Record).acc1, + ).toEqual({ + 'credentials/0/secret': 'Migr1234Migr1234', + 'credentials/0/expiresAt': engineTime(until), + }); + expect(Date.parse(engineTime(until))).toBeGreaterThan(Date.now()); + }); + + it('passwordExpiresAt — 만료가 미래면 그 시각(ISO), 지났으면 null(만료), 없거나 읽지 못하면 null (이전 중 판정은 운영 도구가 실행 기록과 함께)', async () => { + await signedIn(); + const future = engineTime(new Date(Date.now() + 10 * DAY_MS)); + const accountWith = (name: string, expiresAt: unknown) => ({ + ...araAccount, + id: `acc-${name}`, + name, + emailAddress: `${name}@${DOMAIN}`, + credentials: { + '0': { '@type': 'Password', secret: '****', expiresAt }, + }, + }); + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/query', { ids: ['a', 'b', 'c', 'd'] }, 'q'], + [ + 'x:Account/get', + { + list: [ + accountWith('future', future), + accountWith('past', '2020-01-01T00:00:00Z'), + accountWith('none', null), + accountWith('garbage', 'not-a-date'), + ], + }, + 'g', + ], + ], + }), + ); + const states = Object.fromEntries( + (await engine.listEmployees()).map((employee) => [ + employee.email.slice(0, employee.email.indexOf('@')), + [employee.passwordExpiresAt, employee.passwordExpired], + ]), + ); + expect(states).toEqual({ + future: [new Date(future).toISOString(), false], + past: [null, true], + none: [null, false], + garbage: [null, false], + }); + }); + + describe('passwordDefaultExpiry — x:Authentication 싱글톤의 비밀번호 기본 만료 (알림만 — 재리뷰 N1)', () => { + const authentication = (settings: Record | null) => + json({ + methodResponses: [ + [ + 'x:Authentication/get', + { + list: + settings === null ? [] : [{ id: 'singleton', ...settings }], + }, + 'a', + ], + ], + }); + + it('싱글톤을 한 번 읽고 기억한다 — 칸이 null 이거나 없으면 꺼짐', async () => { + await signedIn(); + fetchMock.mockResolvedValueOnce( + authentication({ passwordDefaultExpiry: null }), + ); + await expect(engine.passwordDefaultExpiry()).resolves.toBe('off'); + expect(bodyAt(2).methodCalls[0]).toEqual([ + 'x:Authentication/get', + { + accountId: 'adm', + ids: ['singleton'], + properties: ['passwordDefaultExpiry'], + }, + 'a', + ]); + // 이 실행 동안 기억한다 — 다시 묻지 않는다 + await expect(engine.passwordDefaultExpiry()).resolves.toBe('off'); + expect(fetchMock).toHaveBeenCalledTimes(3); + + const other = new JmapOpsEngine( + settings, + ADMIN, + accountSecurity as unknown as JmapAccountSecurityProvider, + filterScripts as unknown as JmapFilterScriptStore, + ); + fetchMock + .mockResolvedValueOnce(json(adminSession)) + .mockResolvedValueOnce( + json({ methodResponses: [['x:Account/get', { list: [] }, 'p']] }), + ) + .mockResolvedValueOnce(authentication({})); + await other.signIn('admin-secret'); + await expect(other.passwordDefaultExpiry()).resolves.toBe('off'); + }); + + it('값이 있으면 켜짐 — 그래도 계정의 미래 만료는 그대로 준다(가리지 않는다 — 판정은 운영 도구가 실행 기록과 함께)', async () => { + await signedIn(); + fetchMock.mockResolvedValueOnce( + authentication({ passwordDefaultExpiry: '90d' }), + ); + await expect(engine.passwordDefaultExpiry()).resolves.toBe('on'); + const future = engineTime(new Date(Date.now() + 10 * DAY_MS)); + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/query', { ids: ['acc1'] }, 'q'], + [ + 'x:Account/get', + { + list: [ + { + ...araAccount, + credentials: { + '0': { '@type': 'Password', expiresAt: future }, + }, + }, + ], + }, + 'g', + ], + ], + }), + ); + const [ara] = await engine.listEmployees(); + expect(ara).toMatchObject({ + email: ARA, + passwordExpiresAt: new Date(future).toISOString(), + passwordExpired: false, + }); + }); + + it('읽지 못하면 unknown — 메서드 거절 · 싱글톤 없음, 던지지 않고 경고 로그만([MAIL_001] 머리 · 종류만)', async () => { + const warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + await signedIn(); + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [['error', { type: 'forbidden' }, 'a']], + }), + ); + await expect(engine.passwordDefaultExpiry()).resolves.toBe('unknown'); + // 기억한다 — 다시 묻지 않는다 + await expect(engine.passwordDefaultExpiry()).resolves.toBe('unknown'); + expect(fetchMock).toHaveBeenCalledTimes(3); + // 같은 모듈의 다른 경고처럼 코드 머리를 달고, 종류만 — 관리자 비밀번호 · 엔진의 설명 문장은 없다 (재리뷰 N4) + const message = String(warn.mock.calls[0]?.[0]); + expect(message.startsWith('[MAIL_001]')).toBe(true); + expect(message).toContain('reason=refused/forbidden'); + expect(message).not.toContain('admin-secret'); + + const empty = new JmapOpsEngine( + settings, + ADMIN, + accountSecurity as unknown as JmapAccountSecurityProvider, + filterScripts as unknown as JmapFilterScriptStore, + ); + fetchMock + .mockResolvedValueOnce(json(adminSession)) + .mockResolvedValueOnce( + json({ methodResponses: [['x:Account/get', { list: [] }, 'p']] }), + ) + .mockResolvedValueOnce(authentication(null)); + await empty.signIn('admin-secret'); + await expect(empty.passwordDefaultExpiry()).resolves.toBe('unknown'); + + // 계정 읽기는 그대로 — 미래 만료를 준다 + const future = engineTime(new Date(Date.now() + 10 * DAY_MS)); + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/query', { ids: ['acc1'] }, 'q']], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + [ + 'x:Account/get', + { + list: [ + { + ...araAccount, + credentials: { + '0': { '@type': 'Password', expiresAt: future }, + }, + }, + ], + }, + 'g', + ], + ], + }), + ); + await expect(empty.findEmployee(ARA)).resolves.toMatchObject({ + passwordExpiresAt: new Date(future).toISOString(), + }); + warn.mockRestore(); + }); + + it('로그인 전에는 묻지도 기억하지도 않는다', async () => { + await expect(engine.passwordDefaultExpiry()).resolves.toBe('unknown'); + expect(fetchMock).not.toHaveBeenCalled(); + await signedIn(); + fetchMock.mockResolvedValueOnce( + authentication({ passwordDefaultExpiry: null }), + ); + await expect(engine.passwordDefaultExpiry()).resolves.toBe('off'); + }); + }); + }); + it('재설정 · 2단계 해제 · 잠금은 비밀번호 자격의 칸 · 권한 칸을 패치한다', async () => { await signedIn(); const account = json({ diff --git a/backend/src/modules/mail/jmap/jmap-ops-engine.ts b/backend/src/modules/mail/jmap/jmap-ops-engine.ts index d1eeca5..129b1f9 100644 --- a/backend/src/modules/mail/jmap/jmap-ops-engine.ts +++ b/backend/src/modules/mail/jmap/jmap-ops-engine.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { BusinessException } from '@/common/errors/business.exception'; @@ -20,7 +20,9 @@ import { type LockRulesState, type OpsEngine, type OpsSignInResult, + type PasswordDefaultExpiry, } from '@/modules/ops/ops-engine.port'; +import { traceTag } from '@/shared/logger/request-context'; import { JmapAccountSecurityProvider } from './jmap-account-security.provider'; import { JmapFilterScriptStore } from './jmap-filter-script'; import { basicAuthorization, toRelativeReference } from './jmap.client'; @@ -54,6 +56,8 @@ const ACCOUNT_PROPERTIES = [ ]; /** 만료를 지난 날로 — "다음 로그인 때 바꾸기" (엔진이 그 계정의 권한을 비밀번호 바꾸기만으로 줄인다 — 2026-11-02 확인) */ const EXPIRED_AT_OFFSET_MS = 60_000; +/** 엔진 전역 설정(싱글톤 객체)의 id — `x:Authentication` 등 */ +const SINGLETON_ID = 'singleton'; const HTTP_UNAUTHORIZED = 401; const HTTP_PAYMENT_REQUIRED = 402; @@ -125,6 +129,11 @@ function asNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } +/** 엔진의 시각 칸 — ISO 8601 UTC, 초 단위(밀리초를 뗀다) */ +function engineTime(at: Date): string { + return at.toISOString().replace(/\.\d+Z$/, 'Z'); +} + /** 엔진의 SetError → 도구의 까닭 (종류만 — 설명 문장은 넣지 않는다) */ function refusal(error: JmapError | undefined): OpsEngineError { const type = error?.type ?? 'unknown'; @@ -206,12 +215,16 @@ export function permissionsWithLogin( * - 게이트웨이 대리 접근: 앱 비밀번호(JmapAccountSecurityProvider) · 잠금 Sieve(JmapFilterScriptStore — 직원의 필터 · 부재중과 한 스크립트. * 엔진은 계정마다 스크립트 하나만 켠다 — 따로 스크립트를 켜면 직원의 필터 · 부재중 · 차단이 꺼진다, 2026-11-02 다시 확인) * 엔진 동작(2026-11-02 개발 엔진 확인): 만들 때 만료를 함께 받는다 · 관리자의 비밀번호 바꾸기는 OTP 를 남긴다 · - * OTP 지우기는 비밀번호 자격의 otpAuth 를 null 로 · 잠금은 계정 권한의 Merge disabledPermissions.authenticate + * OTP 지우기는 비밀번호 자격의 otpAuth 를 null 로 · 잠금은 계정 권한의 Merge disabledPermissions.authenticate · + * 만료를 미래로 두면 그 시각까지 IMAP · SMTP · JMAP 이 그대로고 지나면 스스로 만료로 다룬다(메일 이전의 이전용 비밀번호 — 넣은 값을 그대로 돌려준다) */ @Injectable() export class JmapOpsEngine implements OpsEngine { + private readonly logger = new Logger(JmapOpsEngine.name); private admin: AdminSession | null = null; private domainId: string | null = null; + /** 엔진의 비밀번호 기본 만료 — 관리자 로그인 뒤 한 번 읽고 기억한다(도구가 첫 화면에 알리기만 한다) */ + private defaultExpiry: PasswordDefaultExpiry | null = null; constructor( @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, @@ -264,6 +277,51 @@ export class JmapOpsEngine implements OpsEngine { return 'ok'; } + async passwordDefaultExpiry(): Promise { + if (this.defaultExpiry !== null) return this.defaultExpiry; + // 로그인 전이면 기억하지 않는다 — 로그인 뒤에 다시 읽는다 + if (!this.admin) return 'unknown'; + let state: PasswordDefaultExpiry; + try { + const [get] = await this.call([ + [ + 'x:Authentication/get', + { + accountId: this.session().accountId, + ids: [SINGLETON_ID], + properties: ['passwordDefaultExpiry'], + }, + 'a', + ], + ]); + const settings = this.expect< + GetResult<{ readonly passwordDefaultExpiry?: unknown }> + >(get, 'x:Authentication/get').list?.[0]; + // 칸이 없거나 null 이면 꺼짐 — 값이 있으면(기간) 켜짐. 싱글톤을 돌려주지 않으면 읽지 못한 것으로 + state = + settings === undefined + ? 'unknown' + : settings.passwordDefaultExpiry === undefined || + settings.passwordDefaultExpiry === null + ? 'off' + : 'on'; + } catch (error) { + // 종류만 남긴다 — 엔진의 설명 문장 · 비밀은 넣지 않는다 + const kind = + error instanceof OpsEngineError + ? `${error.kind}${error.detail ? `/${error.detail}` : ''}` + : error instanceof Error + ? error.name + : 'unknown'; + this.logger.warn( + `[MAIL_001]${traceTag()} 엔진의 비밀번호 기본 만료(x:Authentication)를 읽지 못했습니다 — 운영 도구는 그대로 씁니다(메일 이전 중 판정은 실행 기록으로 가른다): reason=${kind}`, + ); + state = 'unknown'; + } + this.defaultExpiry = state; + return state; + } + isEmployeeAddress(email: string): boolean { return this.isEmployee(email.trim().toLowerCase()); } @@ -298,7 +356,8 @@ export class JmapOpsEngine implements OpsEngine { readonly localPart: string; readonly name: string; readonly quotaBytes: number | null; - readonly tempPassword: string; + readonly password: string; + readonly migrationUntil: Date | null; }): Promise { const domainId = await this.requireDomainId(); const result = await this.set('Account', { @@ -312,8 +371,12 @@ export class JmapOpsEngine implements OpsEngine { credentials: { 0: { '@type': 'Password', - secret: input.tempPassword, - expiresAt: this.expiredAt(), + secret: input.password, + // 임시 비밀번호는 지난 만료(첫 로그인 때 바꾸기), 이전용은 받은 미래 시각(그때까지 IMAP 등이 그대로 — 지나면 엔진이 스스로 막는다) + expiresAt: + input.migrationUntil === null + ? this.expiredAt() + : engineTime(input.migrationUntil), }, }, ...(input.quotaBytes !== null @@ -340,6 +403,19 @@ export class JmapOpsEngine implements OpsEngine { }); } + async setMigrationPassword( + id: string, + password: string, + until: Date, + ): Promise { + const credentialId = await this.requirePasswordCredential(id); + // 미래의 만료 — 그 시각까지 IMAP · SMTP · 웹메일이 그대로다(2026-11-02 확인). 2단계 인증(otpAuth)은 칸에 넣지 않는다(재설정과 같다) + await this.update('Account', id, { + [`credentials/${credentialId}/secret`]: password, + [`credentials/${credentialId}/expiresAt`]: engineTime(until), + }); + } + async removeTwoFactor(id: string): Promise { const credentialId = await this.requirePasswordCredential(id); await this.update('Account', id, { @@ -494,9 +570,7 @@ export class JmapOpsEngine implements OpsEngine { } private expiredAt(): string { - return new Date(Date.now() - EXPIRED_AT_OFFSET_MS) - .toISOString() - .replace(/\.\d+Z$/, 'Z'); + return engineTime(new Date(Date.now() - EXPIRED_AT_OFFSET_MS)); } private toEmployee(account: JmapAccount): EngineEmployee | null { @@ -511,6 +585,10 @@ export class JmapOpsEngine implements OpsEngine { (credential) => credential?.['@type'] === 'Password', ); const expiresAt = asString(password?.expiresAt); + // 만료 시각 — 지났으면 만료(임시 비밀번호 · 30일이 지난 이전용), 미래면 그 시각. 읽지 못하면 둘 다 아니다. + // 미래의 만료가 곧 "메일 이전 중"은 아니다(기본 만료로 저장된 것일 수 있다) — 판정은 운영 도구가 실행 기록과 함께 한다 + const expiresMs = expiresAt === null ? Number.NaN : Date.parse(expiresAt); + const now = Date.now(); const description = asString(account.description)?.trim(); return { id, @@ -518,8 +596,9 @@ export class JmapOpsEngine implements OpsEngine { name: description || fallbackDisplayName(email), locked: isLocked(account.permissions), customPermissions: account.permissions?.['@type'] === 'Replace', - passwordExpired: - expiresAt !== null && Date.parse(expiresAt) <= Date.now(), + passwordExpired: expiresMs <= now, + passwordExpiresAt: + expiresMs > now ? new Date(expiresMs).toISOString() : null, twoFactorEnabled: password?.otpAuth !== undefined && password.otpAuth !== null, appPasswords: credentials.filter( diff --git a/backend/src/modules/ops/account-commands.ts b/backend/src/modules/ops/account-commands.ts index 7a62447..1c3beb0 100644 --- a/backend/src/modules/ops/account-commands.ts +++ b/backend/src/modules/ops/account-commands.ts @@ -4,20 +4,28 @@ import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import { SystemMailService } from '@/modules/system-mail/system-mail.service'; import { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; import { twoFactorRequirementFor } from '@/modules/system-settings/system-settings.types'; +import { + MIGRATION_PASSWORD_DAYS, + MigrationPasswordSession, + migrationAuditText, + migrationUntilFrom, +} from './migration-password'; +import { resetGuard } from './migration-state'; import { OpsAuditService } from './ops-audit.service'; import { OpsDataService, totalRows } from './ops-data.service'; import { OPS_ENGINE, - OpsEngineError, type EngineEmployee, type OpsEngine, } from './ops-engine.port'; import type { OpsIo } from './ops-io'; import { + MIGRATION_PASSWORD_REUSED, askAddress, askEmployee, confirmPlan, failureLine, + showMigrationPassword, showTempPassword, step, } from './ops-prompts'; @@ -25,17 +33,30 @@ import { bytesText, failureDetail, isUnknownOutcome, + timeText, toDisplayName, toQuotaBytes, } from './ops-text'; -import { generateTempPassword } from './temp-password'; +import { applyFreshPassword } from './temp-password'; -/** 임시 비밀번호를 엔진이 약하다고 받지 않을 때 새로 만들어 다시 하는 횟수 */ -const WEAK_PASSWORD_RETRIES = 2; +/** 입사의 바뀔 것 — 이전용 비밀번호 줄 (이번 실행의 값이 이미 있으면 다시 보이지 않는다) */ +function migrationPasswordPlan( + until: Date, + sessionHasPassword: boolean, +): string[] { + return [ + sessionHasPassword + ? '비밀번호 이전용 비밀번호 — 이번 실행의 값(이번 실행에서 마지막으로 보인 값)을 겁니다. 다시 보이지 않습니다' + : '비밀번호 이전용 비밀번호(무작위) — 만든 뒤 한 번만 보입니다. 이번 실행에서 옮겨 올 메일이 있는 계정은 모두 이 값을 씁니다', + ' imapsync 에만 씁니다 — 직원에게 주지 않습니다', + ` ${timeText(until)} 까지(${MIGRATION_PASSWORD_DAYS}일) — 지나면 엔진이 막습니다`, + ' 마지막 imapsync 뒤 「메일 이전 › 이전 마치기」로 직원용 임시 비밀번호를 만듭니다', + ]; +} /** * 운영 도구 — 계정 명령 (13단계 — 계약 6절 "운영 도구"): 입사 · 비밀번호 재설정 · 2단계 해제. - * 묻고 → 바뀔 것을 보이고 → y/N → 차례대로 하고 → 실행 기록(ops_audit_log)을 남긴다. 임시 비밀번호는 한 번만 보이고 어디에도 남기지 않는다 + * 묻고 → 바뀔 것을 보이고 → y/N → 차례대로 하고 → 실행 기록(ops_audit_log)을 남긴다. 임시 · 이전용 비밀번호는 한 번만 보이고 어디에도 남기지 않는다 */ @Injectable() export class AccountCommands { @@ -46,13 +67,19 @@ export class AccountCommands { private readonly audit: OpsAuditService, private readonly systemMail: SystemMailService, private readonly settings: SystemSettingsService, + private readonly migrationPassword: MigrationPasswordSession, ) {} private get domain(): string { return this.mailSettings.domain ?? ''; } - /** 입사 — 엔진 계정(직원 역할) + 임시 비밀번호(무작위 · 한 번만) + 만료(첫 로그인 때 바꾸기) + 메일함 용량 */ + /** + * 입사 — 엔진 계정(직원 역할) + 메일함 용량 + 처음 비밀번호: + * - 보통: 임시 비밀번호(무작위 · 한 번만) + 만료(첫 로그인 때 바꾸기) + * - mailcow 에서 옮겨 올 메일이 있으면: 이전용 비밀번호(이번 실행 공통 — 처음 만들 때 한 번만 보인다) + 만든 때부터 30일의 만료. + * 그때까지 IMAP 이 그대로라 imapsync 가 들어간다. 직원용 임시 비밀번호는 「메일 이전 › 이전 마치기」가 만든다 + */ async hire(io: OpsIo, operator: string): Promise { const email = await askAddress(io, this.domain, '새 직원 주소'); if (email === null) return; @@ -83,6 +110,9 @@ export class AccountCommands { io.print('용량은 0 보다 큰 숫자(GB, 소수 한 자리까지)로 넣어 주세요.'); return; } + // 메일 이전 — 옮겨 올 메일이 있으면 이전용 비밀번호로 만든다(imapsync 가 새 엔진에 IMAP 으로 들어간다. 임시 비밀번호의 지난 만료는 IMAP 을 막는다). + // 실행 기록은 hire-migration 으로 남긴다 — 메일 이전 중 판정이 이 기록을 본다(migration-state.ts) + const migrating = await io.confirm('mailcow 에서 옮겨 올 메일이 있습니까?'); // 같은 주소를 쓰던 옛 직원의 기록이 남았으면 먼저 지운다 — 새 사람에게 옛 로그인 기록 · 서명 · 2단계 기록 · 2단계 필수 예외가 넘어가지 않게 const leftoverRows = totalRows(await this.data.countRowsOf(email)); @@ -99,12 +129,18 @@ export class AccountCommands { purgeLeftover = await io.confirm('입사 전에 지울까요? (권함)'); } + // 이전용 비밀번호의 끝 — 그 계정을 만든 때부터 30일(바뀔 것에 보인 시각 그대로 건다) + const until = migrating ? migrationUntilFrom(new Date()) : null; const lines = [ `주소 ${email}`, `이름 ${name}`, `메일함 ${quota === null ? '제한 없음' : bytesText(quota)}`, - '비밀번호 임시 비밀번호(무작위) — 만든 뒤 한 번만 보입니다', - ' 첫 로그인 때 새 비밀번호를 정합니다(엔진의 비밀번호 만료)', + ...(until === null + ? [ + '비밀번호 임시 비밀번호(무작위) — 만든 뒤 한 번만 보입니다', + ' 첫 로그인 때 새 비밀번호를 정합니다(엔진의 비밀번호 만료)', + ] + : migrationPasswordPlan(until, this.migrationPassword.exists)), ...(purgeLeftover ? [`옛 기록 지우기 — ${leftoverParts.join(' · ')}`] : []), @@ -117,37 +153,66 @@ export class AccountCommands { step(io, true, `옛 기록을 지웠습니다 — ${leftoverParts.join(' · ')}`); } const localPart = email.slice(0, email.indexOf('@')); - let password = ''; - for (let attempt = 0; ; attempt += 1) { - password = generateTempPassword(); - try { - await this.engine.createEmployee({ - localPart, - name, - quotaBytes: quota, - tempPassword: password, - }); - break; - } catch (error) { - const weak = - error instanceof OpsEngineError && error.kind === 'weak-password'; - if (!weak || attempt >= WEAK_PASSWORD_RETRIES) throw error; + const create = (password: string) => + this.engine.createEmployee({ + localPart, + name, + quotaBytes: quota, + password, + migrationUntil: until, + }); + // 실행 기록의 이전용 비밀번호 — 끝나는 날만(비밀 없음) + let migrationNote: string | null = null; + if (until === null) { + const password = await applyFreshPassword(create); + step(io, true, '엔진 계정을 만들었습니다.'); + showTempPassword(io, email, password); + } else { + const migration = await this.migrationPassword.apply(create); + step(io, true, '엔진 계정을 만들었습니다.'); + if (migration.kind === 'reused') { + step(io, true, MIGRATION_PASSWORD_REUSED); + } else { + showMigrationPassword( + io, + email, + migration.password, + until, + migration.kind === 'replaced', + ); } + migrationNote = migrationAuditText(until, migration); } - step(io, true, '엔진 계정을 만들었습니다.'); - showTempPassword(io, email, password); await this.audit.record({ operator, - command: 'hire', + command: until === null ? 'hire' : 'hire-migration', target: email, result: 'success', - detail: `용량 ${quota === null ? '제한 없음' : bytesText(quota)}${purgeLeftover ? ` · 옛 기록 지움(${leftoverParts.join(' · ')})` : ''}`, + detail: [ + `용량 ${quota === null ? '제한 없음' : bytesText(quota)}`, + ...(migrationNote !== null ? [migrationNote] : []), + ...(purgeLeftover + ? [`옛 기록 지움(${leftoverParts.join(' · ')})`] + : []), + ].join(' · '), }); } catch (error) { - step(io, false, failureLine(error)); + const line = failureLine( + error, + until === null ? '임시 비밀번호' : '이전용 비밀번호', + ); + // 결과를 모르면 계정이 만들어졌을 수 있다 — 다시 입사하면 "이미 쓰고 있습니다"에서 멈추므로 그때 할 일을 함께 알린다. + // 이전용은 이 실패 기록도 판정에 들어(countsForMigration) 만들어졌으면 이전 중으로 보인다 — 다시 만들기(그 주소)로 바로 되살린다 + step( + io, + false, + isUnknownOutcome(error) + ? `${line} 다시 할 때 "이미 쓰고 있습니다"가 나오면 계정은 만들어진 것입니다 — 그때는 ${until === null ? '「비밀번호 재설정」을' : '「메일 이전 › 이전용 비밀번호 다시 만들기」(그 주소)를'} 하세요.` + : line, + ); await this.audit.record({ operator, - command: 'hire', + command: until === null ? 'hire' : 'hire-migration', target: email, result: 'failure', detail: failureDetail(error), @@ -159,6 +224,13 @@ export class AccountCommands { async resetPassword(io: OpsIo, operator: string): Promise { const employee = await askEmployee(io, this.engine, this.domain); if (employee === null) return; + // 메일 이전 중 — 재설정은 이전용 비밀번호를 막아 imapsync 를 끊고, 이전 중 로그인 기록이 직원에게 남는다(「메일 이전」으로 한다). + // 실행 기록을 읽지 못했으면 엔진의 만료가 미래인 계정은 보수적으로 막는다 + const guarded = await resetGuard(this.data, employee); + if (guarded !== null) { + io.print(guarded); + return; + } if (employee.locked) { io.print( '잠긴 계정입니다 — 먼저 「잠금 풀기」를 하세요(잠금 풀기에서 비밀번호 재설정을 함께 할 수 있습니다).', @@ -282,19 +354,11 @@ export class AccountCommands { } // 4. 엔진 — 임시 비밀번호 + 만료 - let password = ''; + let password: string; try { - for (let attempt = 0; ; attempt += 1) { - password = generateTempPassword(); - try { - await this.engine.resetPassword(employee.id, password); - break; - } catch (error) { - const weak = - error instanceof OpsEngineError && error.kind === 'weak-password'; - if (!weak || attempt >= WEAK_PASSWORD_RETRIES) throw error; - } - } + password = await applyFreshPassword((candidate) => + this.engine.resetPassword(employee.id, candidate), + ); step(io, true, '임시 비밀번호와 만료를 걸었습니다.'); } catch (error) { notes.push(`세션 ${ended}개 끊음`); diff --git a/backend/src/modules/ops/admin-commands.ts b/backend/src/modules/ops/admin-commands.ts index 2a0c34a..55bf25a 100644 --- a/backend/src/modules/ops/admin-commands.ts +++ b/backend/src/modules/ops/admin-commands.ts @@ -8,11 +8,15 @@ import { type SystemSettings, } from '@/modules/system-settings/system-settings.types'; import { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date'; +import { + notJudged, + withMigrationState, + type OpsEmployee, +} from './migration-state'; import { OpsAuditService } from './ops-audit.service'; import { OpsDataService } from './ops-data.service'; import { OPS_ENGINE, - type EngineEmployee, type EngineMailingList, type OpsEngine, } from './ops-engine.port'; @@ -31,6 +35,7 @@ import { timeText, toAddress, toDisplayName, + usageText, } from './ops-text'; /** 공용 주소 하나의 구성원 상한 — 오타로 아주 긴 목록을 만들지 않게 */ @@ -45,25 +50,24 @@ export function policyText(settings: SystemSettings): string { return `켜짐 · 시작일 ${start}${exempt > 0 ? ` · 예외 ${exempt}명` : ''}`; } -/** 현황의 한 줄 */ +/** 현황의 한 줄 — 메일 이전 중이면 이전용 비밀번호가 끝나는 한국 날짜(MM-DD)를 함께 */ export function employeeLine( - employee: EngineEmployee, + employee: OpsEmployee, lastLogin: Date | undefined, ): string { - const usage = - employee.quotaBytes === null - ? bytesText(employee.usedBytes) - : `${bytesText(employee.usedBytes)} / ${bytesText(employee.quotaBytes)}`; const flags = [ employee.locked ? '잠김' : null, employee.passwordExpired ? '임시 비밀번호' : null, + employee.migrationUntil !== null + ? `이전 중(~${kstDateString(new Date(employee.migrationUntil)).slice(5)})` + : null, ] .filter(Boolean) .join(' · '); return [ `${employee.email} (${employee.name})`, ` 상태 ${flags || '정상'} · 2단계 ${employee.twoFactorEnabled ? '켜짐' : '꺼짐'} · 앱 비밀번호 ${employee.appPasswords}개`, - ` 마지막 웹메일 로그인 ${timeText(lastLogin)} · 메일함 ${usage}`, + ` 마지막 웹메일 로그인 ${timeText(lastLogin)} · 메일함 ${usageText(employee.usedBytes, employee.quotaBytes)}`, ].join('\n'); } @@ -335,13 +339,20 @@ export class AdminCommands { const lastLogins = await this.data.lastWebmailLogins( employees.map((employee) => employee.email), ); + // 메일 이전 중 판정(실행 기록과 함께) — 읽지 못하면 이전 중 표시 없이 보이고 알린다 + const judged = await withMigrationState(this.data, employees); io.print(''); io.print(RULE); io.print(` 직원 ${employees.length}명`); io.print(RULE); - for (const employee of employees) { + for (const employee of judged ?? employees.map(notJudged)) { io.print(employeeLine(employee, lastLogins.get(employee.email))); } + if (judged === null) { + io.print( + ' 이전 중 표시를 하지 못했습니다 — 실행 기록을 읽지 못했습니다(오류 로그 참고).', + ); + } io.print(RULE); io.print(` 2단계 인증 필수 정책 ${policyText(settings)}`); io.print( diff --git a/backend/src/modules/ops/lock-commands.ts b/backend/src/modules/ops/lock-commands.ts index 00ce17e..427ff07 100644 --- a/backend/src/modules/ops/lock-commands.ts +++ b/backend/src/modules/ops/lock-commands.ts @@ -7,6 +7,7 @@ import { } from '@/modules/mail/mail.constants'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import { AccountCommands } from './account-commands'; +import { resetGuard } from './migration-state'; import { OpsAuditService } from './ops-audit.service'; import { OpsDataService, @@ -118,7 +119,7 @@ function footprintLines(footprint: AddressFootprint, first: number): string[] { * 운영 도구 — 잠금 명령 (13단계 — 계약 6절 "운영 도구"): 잠금 · 잠금 풀기 · 퇴사 마무리. * - 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근 Sieve)을 먼저 → 로그인 권한(authenticate) 빼기 → 웹메일 세션 끊기 → (묻고) 공개 링크 끊기. * 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다). 메일은 계속 받는다 - * - 잠금 풀기: 로그인 권한 되돌리기 → (묻고) 넘기기 · 자동 응답 떼기 → 비밀번호 재설정을 함께 권한다 + * - 잠금 풀기: 로그인 권한 되돌리기 → (묻고) 넘기기 · 자동 응답 떼기 → 비밀번호 재설정을 함께 권한다(메일 이전 중이면 권하지 않는다 — 재설정의 가드 resetGuard) * - 퇴사 마무리: 잠긴 계정만 — 주소를 다시 쳐서 확인한 뒤 엔진 계정 지우기(메일 영구 삭제) → 우리 DB 의 그 주소 줄 정리 */ @Injectable() @@ -393,11 +394,15 @@ export class LockCommands { ); return; } + // 비밀번호 재설정의 가드 — 메일 이전 중(또는 실행 기록을 읽지 못했는데 만료가 미래)이면 재설정을 권하지 않는다 + const resetRefusal = await resetGuard(this.data, employee); const lines = [ `대상 ${employee.name} <${employee.email}>`, '1. 로그인 권한 되돌리기 — 지금 비밀번호 · 앱 비밀번호로 다시 들어올 수 있다', ' 잠글 때 건 넘기기 · 자동 응답은 푼 뒤에 뗄지 묻는다', - ' 털린 계정이면 비밀번호 재설정을 함께 하기를 권한다(다음에 묻는다)', + resetRefusal !== null + ? ' 비밀번호 재설정은 권하지 않는다 — 메일 이전 중이거나 그런지 확인하지 못했다(푼 뒤에 까닭을 보인다)' + : ' 털린 계정이면 비밀번호 재설정을 함께 하기를 권한다(다음에 묻는다)', ]; if (!(await confirmPlan(io, '잠금 풀기', lines))) return; @@ -449,6 +454,11 @@ export class LockCommands { detail: notes.join(' · ') || null, }); + if (resetRefusal !== null) { + // 비밀번호 재설정을 권하지 않는다 — 「비밀번호 재설정」의 가드와 같다 + io.print(resetRefusal); + return; + } if ( await io.confirm('비밀번호 재설정을 함께 할까요? (털린 계정이면 권함)') ) { @@ -464,6 +474,11 @@ export class LockCommands { ); return; } + const refusedNow = await resetGuard(this.data, refreshed); + if (refusedNow !== null) { + io.print(refusedNow); + return; + } if ( await confirmPlan( io, diff --git a/backend/src/modules/ops/migration-commands.ts b/backend/src/modules/ops/migration-commands.ts new file mode 100644 index 0000000..2fa47ea --- /dev/null +++ b/backend/src/modules/ops/migration-commands.ts @@ -0,0 +1,864 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { + MIGRATION_PASSWORD_DAYS, + MigrationPasswordSession, + migrationAuditText, + migrationUntilFrom, + type MigrationPasswordUse, +} from './migration-password'; +import { + judgeOne, + withMigrationState, + type OpsEmployee, +} from './migration-state'; +import type { OpsAuditResult } from './ops-audit-log.entity'; +import { OpsAuditService } from './ops-audit.service'; +import { OpsDataService } from './ops-data.service'; +import { + OPS_ENGINE, + type EngineEmployee, + type OpsEngine, +} from './ops-engine.port'; +import type { OpsIo } from './ops-io'; +import { + MIGRATION_PASSWORD_LOST, + MIGRATION_PASSWORD_REUSED, + MIGRATION_RECORDS_UNREADABLE, + RULE, + confirmPlan, + failureLine, + lookUpEmployee, + showMigrationPassword, + step, +} from './ops-prompts'; +import { + failureDetail, + isUnknownOutcome, + timeText, + toAddress, + usageText, +} from './ops-text'; +import { applyFreshPassword } from './temp-password'; + +/** + * 비밀번호의 처지 — 이전 중(migration-state.ts 의 판정) · 만료(임시 비밀번호 · 30일이 지난 이전용 — 둘을 가를 수 없다) · + * 직원이 정함(그 밖 — 만료가 없거나, 미래의 만료가 우리 이전용이 아니다) + */ +type PasswordState = 'migrating' | 'expired' | 'set'; + +function passwordStateOf(employee: OpsEmployee): PasswordState { + if (employee.migrationUntil !== null) return 'migrating'; + return employee.passwordExpired ? 'expired' : 'set'; +} + +function isMigrating(employee: OpsEmployee): boolean { + return employee.migrationUntil !== null; +} + +/** 이전용 비밀번호가 끝나는 때 (한국 시각) */ +function untilText(employee: OpsEmployee): string { + return employee.migrationUntil === null + ? '—' + : timeText(new Date(employee.migrationUntil)); +} + +/** + * 이전용 비밀번호를 걸지 않는 까닭 — 걸어도 되면 null. + * 이전 중 · 비밀번호가 만료된 계정만 받는다 — 직원이 정한 비밀번호를 덮으면 직원이 로그인하지 못한다. + * 잠긴 계정 · 2단계 인증이 켜진 계정(비밀번호로는 IMAP 에 들어가지 못한다)도 막는다 + */ +export function renewRefusal(employee: OpsEmployee): string | null { + if (passwordStateOf(employee) === 'set') { + return '직원이 이미 비밀번호를 정한 계정입니다 — 이전용 비밀번호를 걸면 직원이 로그인하지 못합니다.'; + } + if (employee.locked) { + return '잠긴 계정입니다 — 이전용 비밀번호로도 들어가지 못합니다. 먼저 「잠금 풀기」를 하세요.'; + } + if (employee.twoFactorEnabled) { + return '2단계 인증이 켜진 계정입니다 — 비밀번호로는 IMAP 에 들어가지 못합니다. 먼저 「2단계 해제」를 하세요.'; + } + return null; +} + +/** 이전을 마치지 않는 까닭 — 마쳐도 되면 null (이전 중인 계정만 · 잠긴 계정은 막는다) */ +export function finishRefusal(employee: OpsEmployee): string | null { + const state = passwordStateOf(employee); + if (state === 'expired') { + return '이전 중인 계정이 아닙니다 — 비밀번호가 만료된 계정입니다(30일이 지난 이전 계정이거나, 직원이 아직 바꾸지 않은 임시 비밀번호). 30일이 지난 이전 계정이면 「이전용 비밀번호 다시 만들기」 뒤에 마치고, 아니면 「비밀번호 재설정」을 하세요.'; + } + if (state === 'set') { + return '이전 중인 계정이 아닙니다 — 직원이 이미 비밀번호를 정했습니다.'; + } + if (employee.locked) { + return '잠긴 계정입니다 — 먼저 「잠금 풀기」를 하세요.'; + } + return null; +} + +/** 다시 읽은 결과 — 그대로면 지금의 계정, 아니면 하지 않는 까닭 */ +type Recheck = + | { readonly ok: true; readonly employee: OpsEmployee } + | { readonly ok: false; readonly reason: string }; + +/** + * 바뀔 것을 보인 뒤 다시 읽은 계정이 그때와 같은 처지인가 — 아니면 까닭(하지 않는다). + * 그사이 다른 운영자가 마쳤거나 · 30일이 지났거나 · 잠겼을 수 있다 — 직원에게 넘긴 임시 비밀번호를 덮지 않게 + */ +export function recheck( + planned: OpsEmployee, + current: OpsEmployee | null, + refusal: (employee: OpsEmployee) => string | null, +): Recheck { + if (current === null || current.id !== planned.id) { + return { ok: false, reason: '그사이 엔진에서 그 계정을 찾지 못했습니다.' }; + } + if (passwordStateOf(current) !== passwordStateOf(planned)) { + return { + ok: false, + reason: + '그사이 비밀번호의 처지가 바뀌었습니다(다른 곳에서 마쳤거나 · 30일이 지났거나 · 직원이 바꿨습니다) — 「이전 현황」을 다시 보세요.', + }; + } + const reason = refusal(current); + return reason === null + ? { ok: true, employee: current } + : { ok: false, reason }; +} + +/** 이전 현황의 한 줄 — 주소 · 이름 · 끝나는 때 · 메일함(imapsync 뒤 mailcow 와 견준다) · 막힌 까닭 */ +export function migrationLine(employee: OpsEmployee): string { + const flags = [ + employee.locked ? '잠김' : null, + employee.twoFactorEnabled + ? '2단계 켜짐(비밀번호로 IMAP 에 들어가지 못함)' + : null, + ].filter(Boolean); + return [ + `${employee.email} (${employee.name})`, + ` 이전용 ~${untilText(employee)} · 메일함 ${usageText(employee.usedBytes, employee.quotaBytes)}${flags.length > 0 ? ` · ${flags.join(' · ')}` : ''}`, + ].join('\n'); +} + +/** 명령의 대상 — 한 사람(주소) 또는 이전 중인 계정 모두(빈 값). 모두에서 막는 계정은 건너뛰고 바뀔 것에 까닭과 함께 보인다 */ +interface Targets { + readonly all: boolean; + readonly accounts: readonly OpsEmployee[]; + readonly skipped: readonly { + readonly employee: OpsEmployee; + readonly reason: string; + }[]; +} + +/** 바뀔 것의 대상 줄 — 모두면 계정마다 한 줄(`more` 를 덧붙인다), 한 사람이면 `single` */ +function targetLines( + targets: Targets, + single: (employee: OpsEmployee) => string, + more: (employee: OpsEmployee) => string = () => '', +): string[] { + const lines = targets.all + ? [ + `대상 이전 중인 계정 ${targets.accounts.length}명 — 계정마다 차례로 한다`, + ...targets.accounts.map( + (employee) => + ` ${employee.email} (${employee.name}) — 이전용 ~${untilText(employee)}${more(employee)}`, + ), + ] + : targets.accounts.map((employee) => `대상 ${single(employee)}`); + return [ + ...lines, + ...targets.skipped.map( + ({ employee, reason }) => ` 건너뜀: ${employee.email} — ${reason}`, + ), + ]; +} + +/** 이전용 비밀번호 다시 만들기의 바뀔 것 */ +function renewPlan( + targets: Targets, + until: Date, + sessionHasPassword: boolean, +): string[] { + const expired = !targets.all && targets.accounts[0].passwordExpired; + return [ + ...targetLines(targets, (employee) => + employee.migrationUntil !== null + ? `${employee.name} <${employee.email}> — 지금 이전용 ~${untilText(employee)}` + : `${employee.name} <${employee.email}> — 비밀번호가 만료된 계정`, + ), + '1. 웹메일 로그인 모두 끊기 — 지금 비밀번호로 들어온 로그인이 이전용 비밀번호로 이어지지 않게(건 뒤 그사이 새로 들어온 것도 끊는다)', + sessionHasPassword + ? '2. 이번 실행의 이전용 비밀번호(이번 실행에서 마지막으로 보인 값)를 건다 — 다시 보이지 않는다' + : '2. 이전용 비밀번호를 무작위로 만들어 건다 — 만든 뒤 한 번만 보인다. 이번 실행에서는 모두 이 값을 쓴다', + ...(sessionHasPassword ? [` ${MIGRATION_PASSWORD_LOST}`] : []), + ' imapsync 에만 쓴다 — 직원에게 주지 않는다. 다른 실행에서 건 값은 더 쓰지 못한다(imapsync 의 비밀번호 파일을 바꾼다)', + `3. 쓸 수 있는 때 — ${timeText(until)} 까지(지금부터 ${MIGRATION_PASSWORD_DAYS}일). 지나면 엔진이 막는다`, + ...(expired + ? [' 주의: 지금 비밀번호(임시 · 지난 이전용)는 더 쓰지 못합니다'] + : []), + ]; +} + +/** 이전 마치기의 바뀔 것 — `loginEvents` 는 계정마다 지울 로그인 기록 수 */ +function finishPlan( + targets: Targets, + loginEvents: ReadonlyMap, +): string[] { + const single = targets.all ? null : targets.accounts[0]; + const events = (employee: OpsEmployee) => + loginEvents.get(employee.email) ?? 0; + const twoFactor = targets.accounts.filter( + (employee) => employee.twoFactorEnabled, + ); + return [ + ...targetLines( + targets, + (employee) => + `${employee.name} <${employee.email}> — 이전용 ~${untilText(employee)}`, + (employee) => ` · 로그인 기록 ${events(employee)}줄`, + ), + '먼저 마지막 imapsync 가 끝났는지 확인한다 — 끝나고 5분쯤 뒤(엔진 웹훅의 다시 보내기)에 한다. 이 뒤로는 이전용 비밀번호로 들어가지 못한다', + single === null + ? '1. 앱 비밀번호가 있으면 지우기' + : single.appPasswords > 0 + ? `1. 앱 비밀번호 ${single.appPasswords}개 지우기` + : '1. 앱 비밀번호 — 없다', + '2. 웹메일 로그인 모두 끊기 (확인하려 들어간 로그인 포함)', + single === null + ? '3. 이전 중 로그인 기록 지우기(위의 줄 수 — 웹메일 · IMAP · SMTP 모두) — imapsync 의 IMAP 로그인이 직원의 로그인 기록에 보이지 않게' + : `3. 이전 중 로그인 기록 ${events(single)}줄 지우기(웹메일 · IMAP · SMTP 모두) — imapsync 의 IMAP 로그인이 직원의 로그인 기록에 보이지 않게`, + '4. 직원용 임시 비밀번호 + 만료 — 첫 로그인 때 새 비밀번호를 정한다', + '5. 그사이 새로 로그인한 웹메일도 끊기', + ' 받은편지함 알림 메일은 넣지 않는다', + ...(twoFactor.length > 0 + ? [ + ` 2단계 인증이 켜진 계정 — 직원이 로그인하려면 「2단계 해제」가 먼저다: ${twoFactor.map((employee) => employee.email).join(', ')}`, + ] + : []), + ' 끝에 직원용 임시 비밀번호 표를 한 번만 보인다 — 직원에게 직접 전한다', + ]; +} + +/** 다시 만들기의 한 계정 결과 */ +interface RenewOutcome { + /** 이전용 비밀번호를 걸었다 */ + readonly applied: boolean; + /** 못 한 까닭 · 덧붙일 말 */ + readonly problem: string | null; + /** 새 값을 상자로 보였다 */ + readonly shown: boolean; +} + +/** 이전 마치기의 한 계정 — 끝의 표에 한 줄. 임시 비밀번호는 엔진에 건 뒤에만 채운다 */ +interface FinishRow { + readonly email: string; + readonly name: string; + password: string | null; + /** 못 한 까닭 · 덧붙일 말 */ + problem: string | null; +} + +/** 이전 마치기의 끝 — 직원용 임시 비밀번호(한 번만) · 못 한 까닭. 건 것이 없으면 전하라는 줄은 보이지 않는다 */ +function showFinishTable(io: OpsIo, rows: readonly FinishRow[]): void { + if (rows.length === 0) return; + const done = rows.filter((row) => row.password !== null).length; + const failed = rows.length - done; + io.print(''); + io.print(RULE); + io.print(` 이전 마치기 — 직원용 임시 비밀번호 ${done}명 · 못 함 ${failed}명`); + if (done > 0) { + io.print( + ' 이 화면을 닫으면 다시 볼 수 없습니다 — 직원에게 직접(말 · 쪽지) 전하세요.', + ); + } + io.print(RULE); + for (const row of rows) { + io.print(` ${row.email} (${row.name})`); + if (row.password !== null) { + io.print(` 임시 비밀번호 ${row.password}`); + if (row.problem !== null) io.print(` 참고: ${row.problem}`); + } else { + io.print( + ` [못 함] ${row.problem ?? '까닭을 모릅니다 — 바로 위의 줄을 보세요.'}`, + ); + } + } + io.print(RULE); + if (done > 0) io.print(' 직원은 첫 로그인 때 새 비밀번호를 정합니다.'); + if (failed > 0) { + io.print(' 못 한 계정은 까닭을 고친 뒤 「이전 마치기」를 다시 하세요.'); + } + io.print(RULE); +} + +/** + * 운영 도구 — 메일 이전 (mailcow → 새 엔진, imapsync): 이전 현황 · 이전용 비밀번호 다시 만들기 · 이전 마치기. + * "이전 중" 판정은 migration-state.ts 하나 — 엔진의 미래 만료(31일 안) + 우리 실행 기록의 마지막 비밀번호 걸기가 이전용. + * 실행 기록을 읽지 못하면 판정을 할 수 없어 이 메뉴는 멈춘다(MIGRATION_RECORDS_UNREADABLE). + * 이전용 비밀번호는 이번 실행 공통(MigrationPasswordSession)이고, 직원용 임시 비밀번호는 「이전 마치기」가 계정마다 따로 만든다. + * 비밀번호는 화면에 한 번만 보이고 실행 기록 · 로그에 남기지 않는다 + */ +@Injectable() +export class MigrationCommands { + constructor( + @Inject(OPS_ENGINE) private readonly engine: OpsEngine, + @Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings, + private readonly data: OpsDataService, + private readonly audit: OpsAuditService, + private readonly migrationPassword: MigrationPasswordSession, + ) {} + + private get domain(): string { + return this.mailSettings.domain ?? ''; + } + + /** 메일 이전 — 하위 메뉴 */ + async menu(io: OpsIo, operator: string): Promise { + io.print( + '메일 이전 — 1. 이전 현황 2. 이전용 비밀번호 다시 만들기 3. 이전 마치기 0. 돌아가기', + ); + const choice = await io.ask('고르세요: '); + if (choice === '1') await this.status(io); + else if (choice === '2') await this.renewPasswords(io, operator); + else if (choice === '3') await this.finish(io, operator); + } + + /** + * 직원 모두를 읽고 판정을 붙인다 — 엔진을 읽지 못하면 그 까닭을, 실행 기록을 읽지 못하면 판정을 할 수 없다고 알리고 null + */ + private async judgedEmployees(io: OpsIo): Promise { + let employees: EngineEmployee[]; + try { + employees = await this.engine.listEmployees(); + } catch (error) { + io.print(failureLine(error)); + return null; + } + const judged = await withMigrationState(this.data, employees); + if (judged === null) io.print(MIGRATION_RECORDS_UNREADABLE); + return judged; + } + + /** 이전 현황 — 이전 중인 계정: 주소 · 이름 · 끝나는 때 · 메일함 사용량 (읽기만 — 기록하지 않는다) */ + async status(io: OpsIo): Promise { + const employees = await this.judgedEmployees(io); + if (employees === null) return; + const migrating = employees.filter(isMigrating); + const expired = employees.filter( + (employee) => passwordStateOf(employee) === 'expired', + ).length; + // 30일이 지난 이전 계정은 이 목록에서 빠진다 — 임시 비밀번호 계정과 가를 수 없어 수만 알린다 + const expiredNote = + expired > 0 + ? ` 비밀번호가 만료된 계정 ${expired}명은 여기에 없습니다 — 직원이 아직 바꾸지 않은 임시 비밀번호이거나 30일이 지난 이전 계정입니다(이전을 이어 하려면 「이전용 비밀번호 다시 만들기」에 그 주소).` + : null; + if (migrating.length === 0) { + io.print('이전 중인 계정이 없습니다.'); + if (expiredNote !== null) io.print(expiredNote); + return; + } + io.print(''); + io.print(RULE); + io.print(' 메일 이전 중 — 이전용 비밀번호가 끝나는 때(한국 시각) · 메일함'); + io.print(RULE); + for (const employee of migrating) io.print(migrationLine(employee)); + io.print(RULE); + io.print(` 이전 중 ${migrating.length}명`); + if (expiredNote !== null) io.print(expiredNote); + io.print(RULE); + } + + /** + * 이전용 비밀번호 다시 만들기 — 웹메일 세션 끊기 → 이번 실행의 이전용 비밀번호(없으면 만들어 한 번 보인다) + 지금부터 30일 → 세션 한 번 더 끊기. + * 잃었거나 30일이 다가올 때. 주소를 넣으면 그 한 사람(이전 중 · 비밀번호가 만료된 계정), 빈 값이면 이전 중인 계정 모두 + * (만료된 계정은 넣지 않는다 — 직원에게 넘긴 임시 비밀번호와 가를 수 없다) + */ + async renewPasswords(io: OpsIo, operator: string): Promise { + const targets = await this.askTargets(io, renewRefusal); + if (targets === null) return; + const until = migrationUntilFrom(new Date()); + if ( + !(await confirmPlan( + io, + '이전용 비밀번호 다시 만들기', + renewPlan(targets, until, this.migrationPassword.exists), + )) + ) { + return; + } + const outcomes: { + readonly employee: OpsEmployee; + readonly outcome: RenewOutcome; + }[] = []; + for (const employee of targets.accounts) { + let outcome: RenewOutcome; + try { + outcome = await this.renewOne(io, operator, employee, until); + } catch (error) { + // 모르는 오류 — 그 계정만 못 한 것으로 두고 이어 간다(새로 만든 값은 걸자마자 상자로 보였다) + outcome = { + applied: false, + problem: failureLine(error, '이전용 비밀번호'), + shown: false, + }; + } + outcomes.push({ employee, outcome }); + } + if (!targets.all) return; + const applied = outcomes.filter(({ outcome }) => outcome.applied).length; + io.print(''); + io.print(RULE); + io.print( + ` 이전용 비밀번호 다시 만들기 — 걸었음 ${applied}명 · 못 함 ${outcomes.length - applied}명`, + ); + for (const { employee, outcome } of outcomes) { + if (outcome.problem === null) continue; + io.print( + ` ${employee.email} (${employee.name}) — ${outcome.applied ? '참고: ' : '[못 함] '}${outcome.problem}`, + ); + } + if (outcomes.some(({ outcome }) => outcome.shown)) { + io.print(' 새 이전용 비밀번호는 위 상자에 한 번만 보였습니다.'); + } + if (applied < outcomes.length) { + io.print( + ' 못 한 계정은 까닭을 고친 뒤 같은 실행에서 「다시 만들기」(빈 값)를 한 번 더 — 모두 이번 값이 됩니다.', + ); + } + io.print(RULE); + } + + /** + * y/N 뒤 그 계정을 엔진에서 다시 읽고 판정한다 — 바뀔 것을 보일 때와 처지가 같아야 한다(직원에게 넘긴 임시 비밀번호를 덮지 않게). + * 아니면 하지 않는 까닭과 실행 기록에 남길 말 + */ + private async reread( + planned: OpsEmployee, + refusal: (employee: OpsEmployee) => string | null, + ): Promise< + | { readonly ok: true; readonly employee: OpsEmployee } + | { readonly ok: false; readonly reason: string; readonly detail: string } + > { + let current: EngineEmployee | null; + try { + current = await this.engine.findEmployee(planned.email); + } catch (error) { + return { + ok: false, + reason: `계정을 다시 읽지 못했습니다 — ${failureLine(error)}`, + detail: `다시 읽기 실패 · ${failureDetail(error)}`, + }; + } + let judged: OpsEmployee | null = null; + if (current !== null) { + judged = await judgeOne(this.data, current); + if (judged === null) { + return { + ok: false, + reason: + '이전 중인지 다시 확인하지 못했습니다 — 실행 기록을 읽지 못했습니다(오류 로그 참고).', + detail: '다시 읽기 실패 · 실행 기록', + }; + } + } + const checked = recheck(planned, judged, refusal); + return checked.ok + ? checked + : { + ok: false, + reason: checked.reason, + detail: `하지 않음 · ${checked.reason}`, + }; + } + + /** 한 계정 — 다시 읽기 → 웹메일 세션 끊기 → 이번 실행의 이전용 비밀번호 + until → 세션 한 번 더 끊기 */ + private async renewOne( + io: OpsIo, + operator: string, + planned: OpsEmployee, + until: Date, + ): Promise { + io.print(''); + io.print(`── ${planned.name} <${planned.email}>`); + const record = (result: OpsAuditResult, detail: string) => + this.audit.record({ + operator, + command: 'migration-password', + target: planned.email, + result, + detail, + }); + const notDone = async ( + problem: string, + detail: string, + ): Promise => { + await record('failure', detail); + return { applied: false, problem, shown: false }; + }; + + // 0. 바뀔 것을 보인 뒤 그사이 바뀌었으면 하지 않는다 (직원에게 넘긴 임시 비밀번호를 덮지 않게) + const reread = await this.reread(planned, renewRefusal); + if (!reread.ok) { + step(io, false, `${reread.reason} 바꾼 것이 없습니다.`); + return notDone(reread.reason, reread.detail); + } + const { employee } = reread; + const from = + employee.migrationUntil !== null + ? '이전 중이던 계정' + : '비밀번호가 만료된 계정'; + + // 1. 웹메일 세션 — 임시 비밀번호(만료)로 들어와 비밀번호 바꾸기로 묶인 세션은 만료가 미래가 되면 갱신 때 제한이 풀린다(이전 중 메일함이 열린다). + // 끊지 못하면 비밀번호를 바꾸지 않고 멈춘다 + let ended: number; + try { + ended = await this.data.revokeSessions(employee.email); + step( + io, + true, + `웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`, + ); + } catch (error) { + const line = failureLine(error); + step( + io, + false, + `웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`, + ); + return notDone( + `웹메일 로그인을 끊지 못했습니다(비밀번호 그대로) — ${line}`, + `${from} · 세션 끊기 실패 · ${failureDetail(error)}`, + ); + } + + // 2. 엔진 — 이번 실행의 이전용 비밀번호 + until + let use: MigrationPasswordUse; + try { + use = await this.migrationPassword.apply((password) => + this.engine.setMigrationPassword(employee.id, password, until), + ); + } catch (error) { + const line = failureLine(error, '이전용 비밀번호'); + const unknown = isUnknownOutcome(error); + // 결과를 모르면 "같은 명령을 다시 하세요"(failureLine) — 이 실패 기록도 판정에 들어(countsForMigration) 걸렸으면 이전 중으로 보여 다시 된다. + // 같은 실행이면 같은 값을 다시 건다 — imapsync 의 비밀번호 파일은 그대로 둔다 + const problem = + unknown && this.migrationPassword.exists + ? `${line} 같은 실행에서 다시 하면 이번 실행의 값을 다시 겁니다.` + : line; + step( + io, + false, + unknown + ? `이전용 비밀번호를 걸었는지 모릅니다 — ${problem}` + : `이전용 비밀번호를 걸지 못했습니다 — 지금 비밀번호는 그대로입니다. ${line}`, + ); + return notDone( + problem, + `${from} · 세션 ${ended}개 끊음 · ${unknown ? '결과 모름' : '실패'} · ${failureDetail(error)}`, + ); + } + // 새로 만든 값은 곧바로 보인다(한 번만) — 뒤에서 무엇이 실패해도 이미 보였다 + if (use.kind === 'reused') { + step(io, true, MIGRATION_PASSWORD_REUSED); + } else { + step(io, true, '이전용 비밀번호를 새로 만들어 걸었습니다.'); + showMigrationPassword( + io, + employee.email, + use.password, + until, + use.kind === 'replaced', + ); + } + + // 3. 그사이(1과 2 사이) 지금 비밀번호로 새로 들어온 웹메일 + let result: 'success' | 'partial' = 'success'; + let problem: string | null = null; + const notes: string[] = []; + try { + const late = await this.data.revokeSessions(employee.email); + ended += late; + if (late > 0) { + step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`); + } + } catch (error) { + result = 'partial'; + const line = failureLine(error); + step( + io, + false, + `그사이 새로 로그인한 웹메일을 확인하지 못했습니다. ${line}`, + ); + notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`); + problem = `그사이 새로 로그인한 웹메일을 확인하지 못했습니다 — ${line}`; + } + await record( + result, + [ + migrationAuditText(until, use), + from, + `세션 ${ended}개 끊음`, + ...notes, + ].join(' · '), + ); + return { applied: true, problem, shown: use.kind !== 'reused' }; + } + + /** + * 이전 마치기 — 한 사람 또는 이전 중인 계정 모두. 계정마다 차례로(재설정과 같은 까닭 — 만료 뒤에는 대리 접근이 막힌다): + * ① 앱 비밀번호가 있으면 지우기 → ② 웹메일 세션 끊기 → ③ 이전 중 로그인 기록 지우기 → ④ 직원용 임시 비밀번호 + 지난 만료 → ⑤ 그사이 생긴 세션 한 번 더 끊기. + * 받은편지함 알림 메일은 넣지 않는다(재설정 알림은 "관리자가 비밀번호를 바꿈"이라 새 직원이 놀란다). + * 한 계정이 실패해도 나머지는 이어 하고, 끝에 임시 비밀번호 표를 한 번 보인다 — 예외가 나도 앞에서 건 것은 보인다(finally) + */ + async finish(io: OpsIo, operator: string): Promise { + const targets = await this.askTargets(io, finishRefusal); + if (targets === null) return; + // 바뀔 것에 보일 지울 로그인 기록 수 — 읽지 못하면 하지 않는다 + const loginEvents = new Map(); + try { + for (const employee of targets.accounts) { + loginEvents.set( + employee.email, + await this.data.countLoginEventsOf(employee.email), + ); + } + } catch (error) { + io.print(failureLine(error)); + return; + } + if ( + !(await confirmPlan(io, '이전 마치기', finishPlan(targets, loginEvents))) + ) { + return; + } + const rows: FinishRow[] = []; + try { + for (const planned of targets.accounts) { + const row: FinishRow = { + email: planned.email, + name: planned.name, + password: null, + problem: null, + }; + rows.push(row); + try { + await this.finishOne(io, operator, planned, row); + } catch (error) { + // 모르는 오류 — 그 계정만 못 한 것으로 두고 이어 간다(건 임시 비밀번호는 표에 남는다) + const line = failureLine(error); + if (row.problem === null) row.problem = line; + } + } + } finally { + showFinishTable(io, rows); + } + } + + /** 이전 마치기 — 한 계정의 차례. 임시 비밀번호를 걸면 곧바로 row 에 둔다(뒤에서 무엇이 실패해도 표에 보이게) */ + private async finishOne( + io: OpsIo, + operator: string, + planned: OpsEmployee, + row: FinishRow, + ): Promise { + io.print(''); + io.print(`── ${planned.name} <${planned.email}>`); + const notes: string[] = []; + const fail = async (problem: string, detail: string): Promise => { + row.problem = problem; + await this.audit.record({ + operator, + command: 'migration-finish', + target: planned.email, + result: 'failure', + detail: [...notes, detail].join(' · '), + }); + }; + + // 0. 바뀔 것을 보인 뒤 그사이 바뀌었으면 하지 않는다 — 다른 곳에서 마쳐 직원에게 넘긴 임시 비밀번호를 덮지 않게 + const reread = await this.reread(planned, finishRefusal); + if (!reread.ok) { + step(io, false, `${reread.reason} 바꾼 것이 없습니다.`); + return fail(reread.reason, reread.detail); + } + const { employee } = reread; + + // ① 앱 비밀번호 — 있으면 지운다. 지우지 못하면 아무것도 바꾸지 않고 멈춘다(재설정과 같다 — 이전 중에 만든 것이 남지 않게) + if (employee.appPasswords > 0) { + try { + const removed = await this.engine.deleteAppPasswords(employee.email); + step(io, true, `앱 비밀번호 ${removed}개를 지웠습니다.`); + notes.push(`앱 비밀번호 ${removed}개 지움`); + } catch (error) { + const line = failureLine(error); + step( + io, + false, + `앱 비밀번호를 지우지 못해 멈춥니다 — 아무것도 바꾸지 않았습니다. ${line}`, + ); + return fail( + `앱 비밀번호를 지우지 못했습니다(바꾼 것 없음) — ${line}`, + `앱 비밀번호 지우기 실패 · ${failureDetail(error)}`, + ); + } + } + + // ② 웹메일 세션 — 운영자가 확인하려 들어갔을 수 있다. 끊지 못하면 멈춘다(이전용 비밀번호 그대로 — 다시 하면 된다) + let ended: number; + try { + ended = await this.data.revokeSessions(employee.email); + step( + io, + true, + `웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`, + ); + } catch (error) { + const line = failureLine(error); + step( + io, + false, + `웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`, + ); + return fail( + `웹메일 로그인을 끊지 못했습니다(이전용 비밀번호 그대로) — ${line}`, + `세션 끊기 실패 · ${failureDetail(error)}`, + ); + } + + // ③ 이전 중 로그인 기록 — 지우지 못하면 멈춘다(넘긴 뒤에는 지울 명령이 없다 — 이전 중인 동안 다시 하면 된다) + try { + const removed = await this.data.deleteLoginEventsOf(employee.email); + step(io, true, `이전 중 로그인 기록 ${removed}줄을 지웠습니다.`); + notes.push(`로그인 기록 ${removed}줄 지움`); + } catch (error) { + notes.push(`세션 ${ended}개 끊음`); + const line = failureLine(error); + step( + io, + false, + `이전 중 로그인 기록을 지우지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`, + ); + return fail( + `이전 중 로그인 기록을 지우지 못했습니다(이전용 비밀번호 그대로) — ${line}`, + `로그인 기록 지우기 실패 · ${failureDetail(error)}`, + ); + } + + // ④ 엔진 — 직원용 임시 비밀번호 + 지난 만료(재설정과 같다). 이 뒤로는 이전용 비밀번호가 막힌다 + let password: string; + try { + password = await applyFreshPassword((candidate) => + this.engine.resetPassword(employee.id, candidate), + ); + } catch (error) { + notes.push(`세션 ${ended}개 끊음`); + const line = failureLine(error); + if (isUnknownOutcome(error)) { + // 엔진이 바꿨을 수 있다 — 바뀌었으면 다시 할 때 "이전 중이 아님"이 나온다 + step(io, false, `임시 비밀번호를 걸었는지 모릅니다 — ${line}`); + return fail( + `${line} 다시 할 때 이전 중이 아니라고 나오면 이미 바뀐 것입니다 — 「비밀번호 재설정」으로 새 임시 비밀번호를 만드세요.`, + `비밀번호 바꾸기 결과 모름 · ${failureDetail(error)}`, + ); + } + step( + io, + false, + `임시 비밀번호를 걸지 못했습니다 — 이전용 비밀번호는 그대로입니다. ${line}`, + ); + return fail( + `임시 비밀번호를 걸지 못했습니다(이전용 비밀번호 그대로) — ${line}`, + `비밀번호 바꾸기 실패 · ${failureDetail(error)}`, + ); + } + row.password = password; + step( + io, + true, + '직원용 임시 비밀번호와 만료를 걸었습니다 — 이전용 비밀번호는 더 쓰지 못합니다.', + ); + + // ⑤ 그사이(②와 ④ 사이) 이전용 비밀번호로 새로 로그인한 웹메일 + let result: 'success' | 'partial' = 'success'; + try { + const late = await this.data.revokeSessions(employee.email); + ended += late; + if (late > 0) { + step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`); + } + } catch (error) { + result = 'partial'; + const line = failureLine(error); + step( + io, + false, + `그사이 새로 로그인한 웹메일을 확인하지 못했습니다. ${line}`, + ); + notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`); + row.problem = `그사이 새로 로그인한 웹메일을 확인하지 못했습니다 — ${line}`; + } + notes.push(`세션 ${ended}개 끊음`); + await this.audit.record({ + operator, + command: 'migration-finish', + target: employee.email, + result, + detail: notes.join(' · '), + }); + } + + /** + * 대상을 묻는다 — 주소면 그 한 사람(막는 까닭이 있으면 알리고 null), 빈 값이면 이전 중인 계정 모두(막는 계정은 건너뛴다). + * 모두에는 비밀번호가 만료된 계정을 넣지 않는다 — 30일이 지난 이전 계정과 직원에게 넘긴 임시 비밀번호를 가를 수 없다 + */ + private async askTargets( + io: OpsIo, + refusal: (employee: OpsEmployee) => string | null, + ): Promise { + const input = await io.ask('주소 (빈 값이면 이전 중인 계정 모두): '); + if (input === '') { + const employees = await this.judgedEmployees(io); + if (employees === null) return null; + const migrating = employees.filter(isMigrating); + if (migrating.length === 0) { + io.print('이전 중인 계정이 없습니다.'); + return null; + } + const accounts: OpsEmployee[] = []; + const skipped: { employee: OpsEmployee; reason: string }[] = []; + for (const employee of migrating) { + const reason = refusal(employee); + if (reason === null) accounts.push(employee); + else skipped.push({ employee, reason }); + } + if (accounts.length === 0) { + io.print('이전 중인 계정에 할 수 있는 것이 없습니다:'); + for (const { employee, reason } of skipped) { + io.print(` ${employee.email} — ${reason}`); + } + return null; + } + return { all: true, accounts, skipped }; + } + const email = toAddress(input, this.domain); + if (email === null) { + io.print(`주소 모양이 맞지 않거나 @${this.domain} 주소가 아닙니다.`); + return null; + } + const found = await lookUpEmployee(io, this.engine, email); + if (found === null) return null; + const employee = await judgeOne(this.data, found); + if (employee === null) { + io.print(MIGRATION_RECORDS_UNREADABLE); + return null; + } + const reason = refusal(employee); + if (reason !== null) { + io.print(reason); + return null; + } + return { all: false, accounts: [employee], skipped: [] }; + } +} diff --git a/backend/src/modules/ops/migration-password.ts b/backend/src/modules/ops/migration-password.ts new file mode 100644 index 0000000..5b64392 --- /dev/null +++ b/backend/src/modules/ops/migration-password.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@nestjs/common'; + +import { kstDateString } from '@/shared/utils/kst-date'; +import { applyFreshPassword, isWeakPasswordRefusal } from './temp-password'; + +/** 이전용 비밀번호를 쓸 수 있는 날 수 — 입사(옮겨 올 메일 있음) · 「메일 이전 › 이전용 비밀번호 다시 만들기」가 지금부터 이만큼 건다 */ +export const MIGRATION_PASSWORD_DAYS = 30; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** 이전용 비밀번호의 끝 — `now` 부터 30일 */ +export function migrationUntilFrom(now: Date): Date { + return new Date(now.getTime() + MIGRATION_PASSWORD_DAYS * DAY_MS); +} + +/** 이번 실행의 이전용 비밀번호를 건 결과 — 새로 만들었을 때만 값을 돌려준다(그때 한 번만 보인다) */ +export type MigrationPasswordUse = + /** 이번 실행의 값을 다시 걸었다 — 보이지 않는다(처음 만들 때 보였다) */ + | { readonly kind: 'reused' } + /** 이번 실행에 처음 만들었다 */ + | { readonly kind: 'created'; readonly password: string } + /** 엔진이 이번 실행의 값을 약하다고 받지 않아 새로 만들어 바꿨다 — 앞에서 건 계정들은 옛 값 그대로다 */ + | { readonly kind: 'replaced'; readonly password: string }; + +/** 실행 기록(ops_audit_log)의 이전용 비밀번호 — 끝나는 한국 날짜만(비밀 없음). 예) 이전용(이번 실행 공통 · ~2026-12-02) */ +export function migrationAuditText( + until: Date, + use: MigrationPasswordUse, +): string { + return `이전용(이번 실행 공통${use.kind === 'replaced' ? ' · 값 바꿈' : ''} · ~${kstDateString(until)})`; +} + +/** + * 이번 실행의 이전용 비밀번호 (메일 이전 — 운영 도구 한 번 실행 안에서 같은 값). + * 운영자가 직원 수십 명의 비밀번호를 파일 수십 개에 옮겨 적지 않고 imapsync 비밀번호 파일 하나로 모두 옮기게 한다. + * 값은 이 프로세스의 메모리에만 둔다(관리자 비밀번호처럼) — 실행 기록 · 로그 · DB 에 남기지 않고, 도구를 끝낼 때 지운다(OpsCli) + */ +@Injectable() +export class MigrationPasswordSession { + private password: string | null = null; + + /** 이번 실행에 이미 만들었는가 */ + get exists(): boolean { + return this.password !== null; + } + + /** + * 이번 실행의 값으로 `apply`(엔진에 걸기)를 한다 — 없으면 새로 만들어(약하면 다시) 걸고 이번 실행의 값으로 둔다. + * 엔진이 이번 실행의 값을 약하다고 받지 않으면(이미 받은 값이라 드물다) 새로 만들어 바꾼다. 그 밖의 실패는 그대로 던진다(값은 그대로) + */ + async apply( + apply: (password: string) => Promise, + ): Promise { + const current = this.password; + if (current !== null) { + try { + await apply(current); + return { kind: 'reused' }; + } catch (error) { + if (!isWeakPasswordRefusal(error)) throw error; + } + } + const fresh = await applyFreshPassword(apply); + this.password = fresh; + return current === null + ? { kind: 'created', password: fresh } + : { kind: 'replaced', password: fresh }; + } + + /** 지운다 — 도구를 끝낼 때 */ + clear(): void { + this.password = null; + } +} diff --git a/backend/src/modules/ops/migration-state.ts b/backend/src/modules/ops/migration-state.ts new file mode 100644 index 0000000..e6636bc --- /dev/null +++ b/backend/src/modules/ops/migration-state.ts @@ -0,0 +1,103 @@ +import { MIGRATION_PASSWORD_DAYS } from './migration-password'; +import type { OpsDataService } from './ops-data.service'; +import type { EngineEmployee } from './ops-engine.port'; +import { + logUnknownFailure, + migratingNotice, + unverifiedMigrationNotice, +} from './ops-prompts'; + +/** + * 메일 이전 중 판정 — 운영 도구에 하나뿐이다. 현황 · 비밀번호 재설정 · 잠금 풀기 · 「메일 이전」(현황 · 다시 만들기 · 마치기)이 모두 이것을 쓴다. + * 이전 중 = 엔진의 비밀번호 만료가 미래(지금 + 31일 안)이고, 우리 실행 기록에서 그 주소의 판정 기록(비밀번호를 건 성공 · 이전용 명령의 실패 — + * ops-data.service.ts 의 countsForMigration) 가운데 마지막이 이전용(이전용 입사 · 다시 만들기)이다. 만료 조건이 안전망이다 — + * 이전용 명령이 실제로는 거절됐으면 계정이 없거나 옛 만료 그대로다. + * 엔진의 만료만으로는 가를 수 없다 — 엔진은 비밀번호 기본 만료가 켜져 있던 동안 직원이 정한 비밀번호에도 미래의 만료를 저장하고, 끈 뒤에도 남긴다(0.16.27) + */ + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** 이전용 비밀번호의 끝이 될 수 있는 가장 먼 때 — 걸 때 지금부터 30일, 하루 여유 */ +export const MIGRATION_WINDOW_DAYS = MIGRATION_PASSWORD_DAYS + 1; + +/** 운영 도구가 본 직원 — 엔진 계정 + 메일 이전 중 판정 */ +export interface OpsEmployee extends EngineEmployee { + /** 메일 이전 중이면 이전용 비밀번호의 끝(ISO 8601 UTC), 아니면 null — migrationUntilOf 로만 정한다 */ + readonly migrationUntil: string | null; +} + +/** 판정 — 이전 중이면 이전용 비밀번호의 끝, 아니면 null */ +export function migrationUntilOf( + employee: EngineEmployee, + lastSetIsMigration: boolean, + now: Date, +): string | null { + if (!lastSetIsMigration || employee.passwordExpiresAt === null) return null; + const until = Date.parse(employee.passwordExpiresAt); + const nowMs = now.getTime(); + if (!(until > nowMs) || until > nowMs + MIGRATION_WINDOW_DAYS * DAY_MS) { + return null; + } + return employee.passwordExpiresAt; +} + +/** + * 엔진 계정들에 판정을 붙인다 — 실행 기록은 쿼리 하나로 읽는다. + * 읽지 못하면 null(오류 로그를 남긴다) — 「메일 이전」은 멈추고, 재설정 · 잠금 풀기는 엔진만 보는 보수적인 가드(resetGuard)로 간다 + */ +export async function withMigrationState( + data: Pick, + employees: readonly EngineEmployee[], + now: Date = new Date(), +): Promise { + let migrating: ReadonlySet; + try { + migrating = await data.lastPasswordSetIsMigration( + employees.map((employee) => employee.email), + ); + } catch (error) { + logUnknownFailure(error); + return null; + } + return employees.map((employee) => ({ + ...employee, + migrationUntil: migrationUntilOf( + employee, + migrating.has(employee.email), + now, + ), + })); +} + +/** 한 사람 — 읽지 못하면 null */ +export async function judgeOne( + data: Pick, + employee: EngineEmployee, +): Promise { + const [judged] = (await withMigrationState(data, [employee])) ?? []; + return judged ?? null; +} + +/** 실행 기록을 읽지 못해 판정이 없을 때 쓰는 모양 — 이전 중이 아니다(현황 등 보이기만 하는 곳) */ +export function notJudged(employee: EngineEmployee): OpsEmployee { + return { ...employee, migrationUntil: null }; +} + +/** + * 비밀번호 재설정(잠금 풀기가 권하는 것 포함)을 하지 않는 까닭 — 해도 되면 null. + * 이전 중이면 「메일 이전」으로 안내한다. 실행 기록을 읽지 못했으면 엔진만 본다 — 만료가 미래면 이전 중일 수 있어 보수적으로 막는다(이전용 비밀번호를 잘못 덮지 않게) + */ +export async function resetGuard( + data: Pick, + employee: EngineEmployee, +): Promise { + const judged = await judgeOne(data, employee); + if (judged === null) { + return employee.passwordExpiresAt === null + ? null + : unverifiedMigrationNotice(employee.passwordExpiresAt); + } + return judged.migrationUntil === null + ? null + : migratingNotice(judged.migrationUntil); +} diff --git a/backend/src/modules/ops/ops-audit-log.entity.ts b/backend/src/modules/ops/ops-audit-log.entity.ts index 83de0c5..c7d0498 100644 --- a/backend/src/modules/ops/ops-audit-log.entity.ts +++ b/backend/src/modules/ops/ops-audit-log.entity.ts @@ -14,6 +14,49 @@ export const OPS_DETAIL_MAX_LENGTH = 500; /** 실행 결과 */ export type OpsAuditResult = 'success' | 'failure' | 'partial'; +/** 실행 기록의 명령 이름 — 기록을 남기는 곳은 모두 이 이름만 쓴다(타입이 막는다) */ +export type OpsCommand = + | 'hire' + /** 입사 — 옮겨 올 메일이 있어 이전용 비밀번호로 만들었다 */ + | 'hire-migration' + | 'password-reset' + | 'two-factor-reset' + | 'lock' + | 'unlock' + | 'offboard' + | 'link-revoke' + | 'mailing-list' + | 'policy' + | 'support-contact' + /** 메일 이전 › 이전용 비밀번호 다시 만들기 */ + | 'migration-password' + /** 메일 이전 › 이전 마치기 — 직원용 임시 비밀번호 */ + | 'migration-finish'; + +/** + * 비밀번호를 거는 명령 — 메일 이전 중 판정이 그 주소의 마지막 기록(성공 · 이전용 명령의 실패 — ops-data.service.ts 의 countsForMigration)을 본다. + * 잠금 풀기가 함께 하는 재설정도 password-reset 으로 남는다. 비밀번호를 거는 명령을 새로 만들면 여기에 더한다(빠지면 판정이 틀린다) + */ +export const PASSWORD_SETTING_COMMANDS: readonly OpsCommand[] = [ + 'hire', + 'hire-migration', + 'password-reset', + 'migration-password', + 'migration-finish', +]; + +/** 그 가운데 이전용 비밀번호를 거는 것 */ +export const MIGRATION_PASSWORD_COMMANDS: readonly OpsCommand[] = [ + 'hire-migration', + 'migration-password', +]; + +/** 비밀번호를 걸었다고 보는 결과 — partial 은 비밀번호는 걸고 뒷정리 하나를 못 한 것이다 */ +export const PASSWORD_SET_RESULTS: readonly OpsAuditResult[] = [ + 'success', + 'partial', +]; + /** * 운영 도구의 실행 기록 (13단계 — 계약 6절 "운영 도구") — 언제 · 누가 · 명령 · 대상 · 결과. * 비밀(관리자 비밀번호 · 임시 비밀번호 · 코드)은 남기지 않는다. 퇴사 마무리도 이 표는 지우지 않는다(DB 백업에 든다) @@ -32,7 +75,7 @@ export class OpsAuditLogEntity { @Column({ type: 'varchar', length: OPS_OPERATOR_MAX_LENGTH }) operator!: string; - /** 명령 — hire · password-reset · two-factor-reset · lock · unlock · offboard · link-revoke · mailing-list · policy · support-contact */ + /** 명령 — OpsCommand (hire · hire-migration · password-reset · … · migration-password · migration-finish) */ @Column({ type: 'varchar', length: COMMAND_COLUMN_LENGTH }) command!: string; diff --git a/backend/src/modules/ops/ops-audit.service.ts b/backend/src/modules/ops/ops-audit.service.ts index cedaac3..5ca79a2 100644 --- a/backend/src/modules/ops/ops-audit.service.ts +++ b/backend/src/modules/ops/ops-audit.service.ts @@ -8,12 +8,13 @@ import { OPS_TARGET_MAX_LENGTH, OpsAuditLogEntity, type OpsAuditResult, + type OpsCommand, } from './ops-audit-log.entity'; /** 실행 기록 한 줄 — 비밀(비밀번호 · 코드)은 넣지 않는다 */ export interface OpsAuditEntry { readonly operator: string; - readonly command: string; + readonly command: OpsCommand; readonly target: string | null; readonly result: OpsAuditResult; readonly detail?: string | null; diff --git a/backend/src/modules/ops/ops-cli.ts b/backend/src/modules/ops/ops-cli.ts index 172aa79..fcd0ad5 100644 --- a/backend/src/modules/ops/ops-cli.ts +++ b/backend/src/modules/ops/ops-cli.ts @@ -3,10 +3,16 @@ import { Inject, Injectable } from '@nestjs/common'; import { AccountCommands } from './account-commands'; import { AdminCommands } from './admin-commands'; import { LockCommands } from './lock-commands'; +import { MigrationCommands } from './migration-commands'; +import { MigrationPasswordSession } from './migration-password'; import { OPS_OPERATOR_MAX_LENGTH } from './ops-audit-log.entity'; import { OPS_ENGINE, OpsEngineError, type OpsEngine } from './ops-engine.port'; import { OpsInputClosed, type OpsIo } from './ops-io'; -import { RULE, failureLine } from './ops-prompts'; +import { + PASSWORD_DEFAULT_EXPIRY_NOTICE, + RULE, + failureLine, +} from './ops-prompts'; /** 운영자 이름에 받지 않는 제어 문자 */ // eslint-disable-next-line no-control-regex @@ -15,7 +21,7 @@ const OPERATOR_CONTROL = /[\u0000-\u001f]/; /** 관리자 비밀번호를 다시 묻는 횟수 */ const SIGN_IN_ATTEMPTS = 3; -/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열 개와 차례 그대로) */ +/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열한 개와 차례 그대로) */ export const OPS_MENU = [ ['1', '입사'], ['2', '비밀번호 재설정'], @@ -27,12 +33,14 @@ export const OPS_MENU = [ ['8', '공용 주소'], ['9', '현황'], ['10', '정책 · 설정'], + ['11', '메일 이전'], ['0', '끝내기'], ] as const; /** * 운영 도구의 차례 (13단계 — 계약 6절 "운영 도구") — 운영자 이름 · 관리자 비밀번호(화면에 보이지 않게 · 저장하지 않음)를 묻고 메뉴를 돈다. - * 명령마다 묻고 → 바뀔 것을 보이고 → y/N → 실행 → 실행 기록. 입력이 끝나면(Ctrl+D · Ctrl+C) 조용히 끝낸다 + * 명령마다 묻고 → 바뀔 것을 보이고 → y/N → 실행 → 실행 기록. 입력이 끝나면(Ctrl+D · Ctrl+C) 조용히 끝낸다. + * 끝날 때 이번 실행의 이전용 비밀번호(메모리에만 있다)를 지운다 */ @Injectable() export class OpsCli { @@ -41,10 +49,21 @@ export class OpsCli { private readonly accounts: AccountCommands, private readonly locks: LockCommands, private readonly admin: AdminCommands, + private readonly migration: MigrationCommands, + private readonly migrationPassword: MigrationPasswordSession, ) {} /** 끝나면 종료 코드 — 0 정상, 1 들어가지 못함 */ async run(io: OpsIo): Promise { + try { + return await this.session(io); + } finally { + // 이번 실행의 이전용 비밀번호 — 관리자 비밀번호처럼 이 프로세스의 메모리에만 두었다 + this.migrationPassword.clear(); + } + } + + private async session(io: OpsIo): Promise { try { io.print(RULE); io.print(' ARA SYSTEM 운영 도구'); @@ -73,6 +92,10 @@ export class OpsCli { const operator = await this.askOperator(io); if (operator === null) return 1; if (!(await this.signIn(io))) return 1; + // 엔진의 비밀번호 기본 만료 — 로그인 직후 한 번 읽는다. 켜져 있으면 알리기만 한다(이전 중 판정은 실행 기록으로 가른다 · 읽지 못하면 경고 로그만) + if ((await this.engine.passwordDefaultExpiry()) === 'on') { + io.print(PASSWORD_DEFAULT_EXPIRY_NOTICE); + } await this.menu(io, operator); return 0; } catch (error) { @@ -182,6 +205,8 @@ export class OpsCli { return this.admin.status(io); case '10': return this.admin.policy(io, operator); + case '11': + return this.migration.menu(io, operator); default: io.print('메뉴의 번호를 넣어 주세요.'); } diff --git a/backend/src/modules/ops/ops-commands.spec.ts b/backend/src/modules/ops/ops-commands.spec.ts index 31879dc..30091cb 100644 --- a/backend/src/modules/ops/ops-commands.spec.ts +++ b/backend/src/modules/ops/ops-commands.spec.ts @@ -8,12 +8,19 @@ import { DEFAULT_SYSTEM_SETTINGS, type SystemSettings, } from '@/modules/system-settings/system-settings.types'; +import { kstDateString } from '@/shared/utils/kst-date'; import { AccountCommands } from './account-commands'; import { AdminCommands } from './admin-commands'; import { LockCommands } from './lock-commands'; +import { MigrationCommands } from './migration-commands'; +import { MigrationPasswordSession } from './migration-password'; import type { OpsAuditEntry, OpsAuditService } from './ops-audit.service'; import { OpsCli } from './ops-cli'; -import type { AddressRows, OpsDataService } from './ops-data.service'; +import { + migrationAddressesOf, + type AddressRows, + type OpsDataService, +} from './ops-data.service'; import { OpsEngineError, type EngineEmployee, @@ -21,8 +28,15 @@ import { type LockRules, type OpsEngine, type OpsSignInResult, + type PasswordDefaultExpiry, } from './ops-engine.port'; import { OpsInputClosed, type OpsIo } from './ops-io'; +import { + MIGRATION_PASSWORD_LOST, + MIGRATION_RECORDS_UNREADABLE, + PASSWORD_DEFAULT_EXPIRY_NOTICE, +} from './ops-prompts'; +import { timeText } from './ops-text'; const DOMAIN = 'arasystem.test'; const ARA = `ara.kim@${DOMAIN}`; @@ -51,6 +65,7 @@ function employee(overrides: Partial = {}): EngineEmployee { locked: false, customPermissions: false, passwordExpired: false, + passwordExpiresAt: null, twoFactorEnabled: false, appPasswords: 2, usedBytes: 1024, @@ -113,12 +128,19 @@ class FakeEngine implements OpsEngine { readonly employees = new Map(); readonly lists: EngineMailingList[] = []; readonly createdPasswords: string[] = []; + /** 입사 때 받은 이전용 비밀번호의 끝 — 임시 비밀번호면 null */ + readonly createdMigrationUntil: (Date | null)[] = []; readonly resetPasswords: string[] = []; + readonly migrationPasswords: string[] = []; + readonly migrationUntils: Date[] = []; signInResults: OpsSignInResult[] = ['ok']; lockRules: { forwardTo: string | null; autoReply: boolean } = { forwardTo: null, autoReply: false, }; + /** 엔진의 비밀번호 기본 만료 — 도구는 첫 화면에 알리기만 한다 */ + defaultExpiry: PasswordDefaultExpiry = 'off'; + defaultExpiryReads = 0; /** 메서드 이름 → 던질 실패 */ readonly failures = new Map(); @@ -135,6 +157,11 @@ class FakeEngine implements OpsEngine { return Promise.resolve(this.signInResults.shift() ?? 'rejected'); } + passwordDefaultExpiry(): Promise { + this.defaultExpiryReads += 1; + return Promise.resolve(this.defaultExpiry); + } + isEmployeeAddress(email: string): boolean { return email.endsWith(`@${DOMAIN}`) && email !== ADMIN; } @@ -160,17 +187,20 @@ class FakeEngine implements OpsEngine { localPart: string; name: string; quotaBytes: number | null; - tempPassword: string; + password: string; + migrationUntil: Date | null; }): Promise { this.calls.push(`create:${input.localPart}`); - this.createdPasswords.push(input.tempPassword); + this.createdPasswords.push(input.password); + this.createdMigrationUntil.push(input.migrationUntil); this.fail('createEmployee'); const created = employee({ id: `acc-${input.localPart}`, email: `${input.localPart}@${DOMAIN}`, name: input.name, quotaBytes: input.quotaBytes, - passwordExpired: true, + passwordExpired: input.migrationUntil === null, + passwordExpiresAt: input.migrationUntil?.toISOString() ?? null, appPasswords: 0, }); this.employees.set(created.email, created); @@ -184,6 +214,18 @@ class FakeEngine implements OpsEngine { return Promise.resolve(); } + setMigrationPassword( + id: string, + password: string, + until: Date, + ): Promise { + this.calls.push(`setMigrationPassword:${id}`); + this.migrationPasswords.push(password); + this.migrationUntils.push(until); + this.fail('setMigrationPassword'); + return Promise.resolve(); + } + removeTwoFactor(id: string): Promise { this.calls.push(`removeTwoFactor:${id}`); this.fail('removeTwoFactor'); @@ -269,7 +311,12 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = changeTwoFactorExempt: jest.Mock; purgeAddress: jest.Mock; lastWebmailLogins: jest.Mock; + deleteLoginEventsOf: jest.Mock; + countLoginEventsOf: jest.Mock; + lastPasswordSetIsMigration: jest.Mock; }; + /** 시험 전부터 있던 실행 기록(오래된 것부터) — 이번 시험의 기록(audit.entries)보다 앞이다 */ + let history: OpsAuditEntry[]; let systemMail: { notifyPasswordReset: jest.Mock; notifyTwoFactorReset: jest.Mock; @@ -282,22 +329,41 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = }; let settingsValue: SystemSettings; let settings: { reload: jest.Mock; update: jest.Mock }; + let migrationPassword: MigrationPasswordSession; let accounts: AccountCommands; let locks: LockCommands; let admin: AdminCommands; + let migration: MigrationCommands; /** 엔진 · 서비스가 불린 차례 (한 줄로) */ const order = (): string[] => [...engine.calls]; + /** 도구 한 번 실행 */ + const cli = () => + new OpsCli(engine, accounts, locks, admin, migration, migrationPassword); + let errorLogs: string[]; + let warnLogs: string[]; + + /** 비밀(비밀번호)이 오류 · 경고 로그 어디에도 없다 */ + const expectNoSecretsInLogs = (...secrets: string[]) => { + const logs = [...errorLogs, ...warnLogs].join('\n'); + for (const secret of secrets) expect(logs).not.toContain(secret); + }; beforeEach(() => { - jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + // 경고 · 오류 로그는 버리지 않고 모은다 — 비밀이 섞이지 않았는지 본다(스택 등 뒤의 인자까지) + warnLogs = []; + jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation( + (...args: unknown[]) => void warnLogs.push(args.map(String).join(' ')), + ); errorLogs = []; jest .spyOn(Logger.prototype, 'error') .mockImplementation( - (message: unknown) => void errorLogs.push(String(message)), + (...args: unknown[]) => void errorLogs.push(args.map(String).join(' ')), ); engine = new FakeEngine(); engine.employees.set(ARA, employee()); @@ -313,7 +379,18 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = return Promise.resolve(true); }), }; + history = []; data = { + // 실제처럼 실행 기록(시험 전 기록 + 이번에 남긴 기록, 새것부터)에서 가린다 — 같은 순수 함수 + lastPasswordSetIsMigration: jest.fn((emails: readonly string[]) => + Promise.resolve( + new Set( + [ + ...migrationAddressesOf([...history, ...entries].reverse()), + ].filter((email) => emails.includes(email)), + ), + ), + ), revokeSessions: jest.fn((email: string) => { engine.calls.push(`revokeSessions:${email}`); return Promise.resolve(3); @@ -349,6 +426,11 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = }); }), lastWebmailLogins: jest.fn().mockResolvedValue(new Map()), + deleteLoginEventsOf: jest.fn((email: string) => { + engine.calls.push(`deleteLoginEvents:${email}`); + return Promise.resolve(12); + }), + countLoginEventsOf: jest.fn().mockResolvedValue(12), }; systemMail = { notifyPasswordReset: jest.fn((email: string) => { @@ -391,6 +473,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = }, ), }; + migrationPassword = new MigrationPasswordSession(); accounts = new AccountCommands( engine, mailSettings, @@ -398,6 +481,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = audit as unknown as OpsAuditService, systemMail as unknown as SystemMailService, settings as unknown as SystemSettingsService, + migrationPassword, ); locks = new LockCommands( engine, @@ -415,13 +499,20 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = sharing as unknown as FileSharingService, settings as unknown as SystemSettingsService, ); + migration = new MigrationCommands( + engine, + mailSettings, + data as unknown as OpsDataService, + audit as unknown as OpsAuditService, + migrationPassword, + ); }); afterEach(() => jest.restoreAllMocks()); describe('입사', () => { it('계정을 만들고 임시 비밀번호를 한 번만 보인다 — 실행 기록에는 비밀이 없다', async () => { - const io = new ScriptedIo(['hong.gildong', '홍길동', '10', 'y']); + const io = new ScriptedIo(['hong.gildong', '홍길동', '10', 'n', 'y']); await accounts.hire(io, OPERATOR); expect(order()).toContain('create:hong.gildong'); @@ -444,7 +535,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = it('n 이면 아무것도 만들지 않는다', async () => { await accounts.hire( - new ScriptedIo(['hong.gildong', '홍길동', '', 'n']), + new ScriptedIo(['hong.gildong', '홍길동', '', 'n', 'n']), OPERATOR, ); expect(order().some((call) => call.startsWith('create:'))).toBe(false); @@ -469,7 +560,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = it('같은 주소의 옛 기록이 남았으면 묻고 지운 뒤 만든다', async () => { data.countRowsOf.mockResolvedValueOnce({ ...NO_ROWS, loginEvents: 5 }); await accounts.hire( - new ScriptedIo(['hong.gildong', '홍길동', '', 'y', 'y']), + new ScriptedIo(['hong.gildong', '홍길동', '', 'n', 'y', 'y']), OPERATOR, ); expect(order()).toEqual([ @@ -484,7 +575,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = new OpsEngineError('weak-password'), ); await accounts.hire( - new ScriptedIo(['hong.gildong', '홍길동', '', 'y']), + new ScriptedIo(['hong.gildong', '홍길동', '', 'n', 'y']), OPERATOR, ); expect(engine.createdPasswords).toHaveLength(2); @@ -1171,9 +1262,1316 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = }); }); - describe('도구의 차례 (OpsCli)', () => { - const cli = () => new OpsCli(engine, accounts, locks, admin); + describe('메일 이전 (이전 중 = 엔진의 미래 만료 + 실행 기록의 마지막 비밀번호 걸기가 이전용)', () => { + const HONG = `hong@${DOMAIN}`; + const KIM = `kim@${DOMAIN}`; + const LEE = `lee@${DOMAIN}`; + const PARK = `park@${DOMAIN}`; + /** 시험의 지금 — 판정이 "지금 + 31일 안"을 보므로 시계를 고정한다(날짜가 지나도 같은 결과) */ + const NOW = new Date('2026-11-02T06:00:00.000Z'); + /** 한국 2026-12-01 (화) 오후 3:00 — 지금부터 29일 */ + const UNTIL = '2026-12-01T06:00:00.000Z'; + const DAY_MS = 24 * 60 * 60 * 1000; + beforeEach(() => { + // 날짜만 고정한다 — 약속 · 타이머는 그대로 + jest.useFakeTimers({ + now: NOW, + doNotFake: [ + 'nextTick', + 'queueMicrotask', + 'setImmediate', + 'clearImmediate', + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + ], + }); + // 이전용으로 입사한 기록 — 시험 계정들의 이전 중 판정의 한쪽 + for (const target of [HONG, KIM, LEE]) { + history.push({ + operator: OPERATOR, + command: 'hire-migration', + target, + result: 'success', + }); + } + }); + + afterEach(() => jest.useRealTimers()); + + /** 이전 중인 계정 — 입사 때 이전용 비밀번호로 만들었다(앱 비밀번호 없음) */ + const migrating = (overrides: Partial = {}) => + employee({ + id: 'acc-hong', + email: HONG, + name: '홍길동', + appPasswords: 0, + passwordExpiresAt: UNTIL, + ...overrides, + }); + + /** 그 비밀이 화면의 몇 줄에 보였는가 */ + const linesWith = (io: ScriptedIo, secret: string) => + io.printed.filter((line) => line.includes(secret)).length; + + /** y/N 을 물은 수 */ + const confirmations = (io: ScriptedIo) => + io.asked.filter((question) => question === '이대로 할까요?').length; + + describe('입사 — mailcow 에서 옮겨 올 메일', () => { + it('있으면 이전용 비밀번호 + 30일 뒤 만료로 만들고 다른 상자로 한 번만 보인다 · 실행 기록에는 끝나는 날만', async () => { + const before = Date.now(); + const io = new ScriptedIo(['hong', '홍길동', '', 'y', 'y']); + await accounts.hire(io, OPERATOR); + const after = Date.now(); + + expect(order()).toEqual(['create:hong']); + expect(io.asked).toContain('mailcow 에서 옮겨 올 메일이 있습니까?'); + const [password] = engine.createdPasswords; + const until = engine.createdMigrationUntil[0] as Date; + expect(until.getTime()).toBeGreaterThanOrEqual(before + 30 * DAY_MS); + expect(until.getTime()).toBeLessThanOrEqual(after + 30 * DAY_MS); + // 바뀔 것 — 이전용 · imapsync 에만 · 끝나는 때 · 다음 할 일 + expect(io.output).toContain( + '이전용 비밀번호(무작위) — 만든 뒤 한 번만', + ); + expect(io.output).toContain( + 'imapsync 에만 씁니다 — 직원에게 주지 않습니다', + ); + expect(io.output).toContain( + `${timeText(until)} 까지(30일) — 지나면 엔진이 막습니다`, + ); + // 한 번만 보이는 상자 — 임시 비밀번호 상자와 다른 글 + expect(io.output).toContain( + '이전용 비밀번호 — 지금 한 번만 보입니다. imapsync 의 비밀번호 파일(--passfile2)에만 넣고 직원에게 주지 마세요.', + ); + expect(io.output).toContain( + '이번 실행에서 옮겨 올 메일이 있는 계정을 더 만들거나 다시 만들면 모두 이 비밀번호를 씁니다', + ); + expect(io.output).not.toContain('임시 비밀번호 — 지금 한 번만'); + expect(linesWith(io, password)).toBe(1); + // 이전용 입사는 따로 이름 — 메일 이전 중 판정이 이 기록을 본다 (재리뷰 N1) + expect(audit.entries).toEqual([ + { + operator: OPERATOR, + command: 'hire-migration', + target: HONG, + result: 'success', + detail: `용량 제한 없음 · 이전용(이번 실행 공통 · ~${kstDateString(until)})`, + }, + ]); + expect(JSON.stringify(audit.entries)).not.toContain(password); + }); + + it('없으면(기본 N) 지금처럼 임시 비밀번호 + 지난 만료', async () => { + const io = new ScriptedIo(['hong', '홍길동', '', '', 'y']); + await accounts.hire(io, OPERATOR); + expect(engine.createdMigrationUntil).toEqual([null]); + expect(io.output).toContain('임시 비밀번호 — 지금 한 번만 보입니다'); + expect(io.output).not.toContain('이전용'); + expect(audit.entries[0].detail).toBe('용량 제한 없음'); + }); + + it('같은 실행의 두 번째 입사는 이번 실행의 값을 다시 걸고 보이지 않는다 — 만료는 계정마다', async () => { + await accounts.hire( + new ScriptedIo(['hong', '홍길동', '', 'y', 'y']), + OPERATOR, + ); + const second = new ScriptedIo(['kim', '김철수', '', 'y', 'y']); + await accounts.hire(second, OPERATOR); + + const [first, again] = engine.createdPasswords; + expect(again).toBe(first); + expect(second.output).toContain( + '이번 실행의 값(이번 실행에서 마지막으로 보인 값)을 겁니다. 다시 보이지 않습니다', + ); + expect(second.output).toContain( + '이번 실행의 이전용 비밀번호(이번 실행에서 마지막으로 보인 값)를 걸었습니다.', + ); + expect(linesWith(second, first)).toBe(0); + expectNoSecretsInLogs(first); + const [untilHong, untilKim] = engine.createdMigrationUntil as Date[]; + expect(untilKim.getTime()).toBeGreaterThanOrEqual(untilHong.getTime()); + expect(audit.entries.map((entry) => entry.detail)).toEqual([ + `용량 제한 없음 · 이전용(이번 실행 공통 · ~${kstDateString(untilHong)})`, + `용량 제한 없음 · 이전용(이번 실행 공통 · ~${kstDateString(untilKim)})`, + ]); + }); + + it('엔진이 이번 실행의 값을 약하다고 받지 않으면(드묾) 새로 만들어 바꾸고 새로 보인다 — 앞 계정은 옛 값이라고 알린다', async () => { + await accounts.hire( + new ScriptedIo(['hong', '홍길동', '', 'y', 'y']), + OPERATOR, + ); + engine.failures.set( + 'createEmployee', + new OpsEngineError('weak-password'), + ); + const io = new ScriptedIo(['kim', '김철수', '', 'y', 'y']); + await accounts.hire(io, OPERATOR); + + const [first, refused, fresh] = engine.createdPasswords; + expect(refused).toBe(first); + expect(fresh).not.toBe(first); + expect(linesWith(io, fresh)).toBe(1); + expect(linesWith(io, first)).toBe(0); + expect(io.output).toContain( + '앞에서 이전용 비밀번호를 건 계정은 옛 값 그대로입니다. 같은 실행에서 「다시 만들기」(빈 값)를 한 번 하면 모두 새 값으로 맞춰집니다.', + ); + expect(io.output).not.toContain('비밀번호 파일을 나누세요'); + expect(audit.entries[1].detail).toContain('값 바꿈'); + // 이번 실행의 값이 바뀌었다 — 다음 계정은 새 값 + await accounts.hire( + new ScriptedIo(['lee', '이수', '', 'y', 'y']), + OPERATOR, + ); + expect(engine.createdPasswords[3]).toBe(fresh); + }); + + it('새 실행이면 새 값 — 도구를 끝내면 이번 실행의 이전용 비밀번호를 지운다', async () => { + const io = new ScriptedIo([ + OPERATOR, + 'pw', + '1', + 'hong', + '홍길동', + '', + 'y', + 'y', + '0', + ]); + await expect(cli().run(io)).resolves.toBe(0); + expect(migrationPassword.exists).toBe(false); + + await accounts.hire( + new ScriptedIo(['kim', '김철수', '', 'y', 'y']), + OPERATOR, + ); + const [first, next] = engine.createdPasswords; + expect(next).not.toBe(first); + }); + }); + + describe('비밀번호 재설정 · 잠금 풀기의 가드', () => { + it('이전 중인 계정은 재설정하지 않고 「메일 이전」으로 안내한다', async () => { + engine.employees.set(HONG, migrating()); + const io = new ScriptedIo(['hong']); + await accounts.resetPassword(io, OPERATOR); + expect(io.output).toContain( + '메일 이전 중인 계정입니다(이전용 비밀번호 ~2026년 12월 1일 (화) 오후 3:00). 직원에게 비밀번호를 줄 때는 「메일 이전 › 이전 마치기」, 이전용 비밀번호를 잃었으면 이 도구를 끝내고 다시 시작한 뒤 「메일 이전 › 이전용 비밀번호 다시 만들기」(빈 값 = 모두)', + ); + expect(order()).toEqual([`find:${HONG}`]); + expect(audit.entries).toEqual([]); + }); + + it('잠금 풀기는 이전 중인 계정에 재설정을 권하지 않는다', async () => { + engine.employees.set(HONG, migrating({ locked: true })); + const io = new ScriptedIo(['hong', 'y']); + await locks.unlock(io, OPERATOR); + expect(order()).toEqual([ + `find:${HONG}`, + 'setLogin:acc-hong:true', + 'getLockRules', + ]); + expect( + io.asked.some((question) => question.includes('비밀번호 재설정을')), + ).toBe(false); + expect(io.output).toContain('메일 이전 중인 계정입니다'); + }); + }); + + describe('이전용 비밀번호 다시 만들기', () => { + it('이전 중인 계정 — 다시 읽기 → 웹메일 세션 끊기 → 이번 실행의 값 + 지금부터 30일 → 세션 한 번 더, 한 번만 보이고 기록 · 로그에는 비밀이 없다', async () => { + engine.employees.set(HONG, migrating()); + data.revokeSessions + .mockImplementationOnce((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(2); + }) + .mockImplementationOnce((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(1); + }); + const before = Date.now(); + const io = new ScriptedIo(['hong', 'y']); + await migration.renewPasswords(io, OPERATOR); + + expect(order()).toEqual([ + `find:${HONG}`, + `find:${HONG}`, + `revokeSessions:${HONG}`, + 'setMigrationPassword:acc-hong', + `revokeSessions:${HONG}`, + ]); + const [password] = engine.migrationPasswords; + const [until] = engine.migrationUntils; + expect(until.getTime()).toBeGreaterThanOrEqual(before + 30 * DAY_MS); + expect(io.output).toContain( + '지금 이전용 ~2026년 12월 1일 (화) 오후 3:00', + ); + expect(io.output).toContain('1. 웹메일 로그인 모두 끊기'); + expect(io.output).toContain( + '그사이 새로 로그인한 웹메일 1개도 끊었습니다', + ); + expect(linesWith(io, password)).toBe(1); + expect(audit.entries).toEqual([ + { + operator: OPERATOR, + command: 'migration-password', + target: HONG, + result: 'success', + detail: `이전용(이번 실행 공통 · ~${kstDateString(until)}) · 이전 중이던 계정 · 세션 3개 끊음`, + }, + ]); + expect(JSON.stringify(audit.entries)).not.toContain(password); + expectNoSecretsInLogs(password); + }); + + it('웹메일 세션을 끊지 못하면 비밀번호를 바꾸지 않는다 — 임시 비밀번호로 묶인 세션이 풀리지 않게 (리뷰 M1)', async () => { + engine.employees.set( + HONG, + migrating({ passwordExpiresAt: null, passwordExpired: true }), + ); + data.revokeSessions.mockRejectedValueOnce(new Error('connection lost')); + const io = new ScriptedIo(['hong', 'y']); + await migration.renewPasswords(io, OPERATOR); + expect(order()).toEqual([`find:${HONG}`, `find:${HONG}`]); + expect(io.output).toContain( + '웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다', + ); + expect(audit.entries).toHaveLength(1); + expect(audit.entries[0]).toMatchObject({ + command: 'migration-password', + result: 'failure', + }); + expect(audit.entries[0].detail).toContain('세션 끊기 실패'); + }); + + it('모두일 때 세션을 끊지 못한 계정만 못 함 — 다음 계정은 이어 한다 (리뷰 M1)', async () => { + engine.employees.set(HONG, migrating()); + engine.employees.set( + KIM, + migrating({ id: 'acc-kim', email: KIM, name: '김철수' }), + ); + data.revokeSessions.mockRejectedValueOnce(new Error('connection lost')); + const io = new ScriptedIo(['', 'y']); + await migration.renewPasswords(io, OPERATOR); + expect( + order().filter((call) => call.startsWith('setMigrationPassword')), + ).toEqual(['setMigrationPassword:acc-kim']); + expect(io.output).toContain('걸었음 1명 · 못 함 1명'); + expect(io.output).toContain( + `${HONG} (홍길동) — [못 함] 웹메일 로그인을 끊지 못했습니다(비밀번호 그대로)`, + ); + }); + + it('입사 때 만든 이번 실행의 값을 다시 걸고 보이지 않는다', async () => { + await accounts.hire( + new ScriptedIo(['hong', '홍길동', '', 'y', 'y']), + OPERATOR, + ); + engine.employees.set(KIM, migrating({ id: 'acc-kim', email: KIM })); + const io = new ScriptedIo(['kim', 'y']); + await migration.renewPasswords(io, OPERATOR); + const [created] = engine.createdPasswords; + expect(engine.migrationPasswords).toEqual([created]); + expect(linesWith(io, created)).toBe(0); + expect(io.output).toContain( + '이번 실행의 이전용 비밀번호(이번 실행에서 마지막으로 보인 값)를 걸었습니다.', + ); + // 같은 실행에서는 다시 보이지 않는다 — 잃었으면 새 실행에서 (리뷰 L2) + expect(io.output).toContain(MIGRATION_PASSWORD_LOST); + }); + + it('비밀번호가 만료된 계정(한 사람)도 받는다 — "지금 비밀번호는 더 쓰지 못합니다"를 보인다', async () => { + engine.employees.set( + HONG, + migrating({ passwordExpiresAt: null, passwordExpired: true }), + ); + const io = new ScriptedIo(['hong', 'y']); + await migration.renewPasswords(io, OPERATOR); + expect(io.output).toContain( + '주의: 지금 비밀번호(임시 · 지난 이전용)는 더 쓰지 못합니다', + ); + expect(order()).toContain('setMigrationPassword:acc-hong'); + expect(audit.entries[0].detail).toContain('비밀번호가 만료된 계정'); + }); + + it('직원이 비밀번호를 정한 계정 · 잠긴 계정 · 2단계 인증이 켜진 계정은 막는다', async () => { + const cases: [Partial, string][] = [ + [ + { passwordExpiresAt: null }, + '직원이 이미 비밀번호를 정한 계정입니다 — 이전용 비밀번호를 걸면 직원이 로그인하지 못합니다.', + ], + [{ locked: true }, '잠긴 계정입니다'], + [{ twoFactorEnabled: true }, '2단계 인증이 켜진 계정입니다'], + ]; + for (const [overrides, text] of cases) { + engine.employees.set(HONG, migrating(overrides)); + const io = new ScriptedIo(['hong']); + await migration.renewPasswords(io, OPERATOR); + expect(io.output).toContain(text); + } + expect( + order().some((call) => call.startsWith('setMigrationPassword')), + ).toBe(false); + expect(audit.entries).toEqual([]); + }); + + it('빈 값이면 이전 중인 계정 모두 — 만료된 계정은 넣지 않고 막는 계정은 건너뛴다, 새 값은 한 번만 보인다', async () => { + engine.employees.set(HONG, migrating()); + engine.employees.set( + KIM, + migrating({ id: 'acc-kim', email: KIM, name: '김철수' }), + ); + engine.employees.set( + LEE, + migrating({ + id: 'acc-lee', + email: LEE, + name: '이수', + twoFactorEnabled: true, + }), + ); + engine.employees.set( + PARK, + employee({ + id: 'acc-park', + email: PARK, + name: '박하나', + passwordExpired: true, + }), + ); + const io = new ScriptedIo(['', 'y']); + await migration.renewPasswords(io, OPERATOR); + + expect(io.output).toContain('대상 이전 중인 계정 2명'); + expect(io.output).toContain( + `건너뜀: ${LEE} — 2단계 인증이 켜진 계정입니다`, + ); + expect(io.output).not.toContain(PARK); + expect(confirmations(io)).toBe(1); + expect( + order().filter((call) => call.startsWith('setMigrationPassword')), + ).toEqual([ + 'setMigrationPassword:acc-hong', + 'setMigrationPassword:acc-kim', + ]); + const [first, second] = engine.migrationPasswords; + expect(second).toBe(first); + expect(linesWith(io, first)).toBe(1); + expect(io.output).toContain('걸었음 2명 · 못 함 0명'); + expect( + audit.entries.map((entry) => [entry.command, entry.target]), + ).toEqual([ + ['migration-password', HONG], + ['migration-password', KIM], + ]); + }); + + it('바뀔 것을 보인 뒤 그사이 이전 중이 아니게 된 계정(다른 곳에서 마침)은 하지 않는다', async () => { + engine.employees.set(HONG, migrating()); + const io = new ScriptedIo(['', 'y']); + // 목록을 읽은 뒤 · y/N 사이에 다른 운영자가 마쳤다 — 다시 읽으면 만료(임시 비밀번호) + const original = engine.findEmployee.bind(engine); + jest + .spyOn(engine, 'findEmployee') + .mockImplementation((email) => + original(email).then((found) => + found + ? { ...found, passwordExpiresAt: null, passwordExpired: true } + : null, + ), + ); + await migration.renewPasswords(io, OPERATOR); + expect( + order().some((call) => call.startsWith('setMigrationPassword')), + ).toBe(false); + expect(io.output).toContain('그사이 비밀번호의 처지가 바뀌었습니다'); + expect(io.output).toContain('걸었음 0명 · 못 함 1명'); + expect(audit.entries[0]).toMatchObject({ + command: 'migration-password', + result: 'failure', + }); + }); + + it('결과를 모르면(엔진 응답 없음) "다시 하세요"와 같은 값이라는 안내 — 다음 계정은 이번 실행의 같은 값을 건다', async () => { + engine.employees.set(HONG, migrating()); + engine.employees.set( + KIM, + migrating({ id: 'acc-kim', email: KIM, name: '김철수' }), + ); + // 이번 실행의 값 — 입사(옮겨 올 메일 있음)로 먼저 만든다(이 계정도 이전 중 — 목록의 끝) + await accounts.hire( + new ScriptedIo(['lee', '이수', '', 'y', 'y']), + OPERATOR, + ); + const [value] = engine.createdPasswords; + engine.failures.set( + 'setMigrationPassword', + new OpsEngineError('unavailable', 'timeout'), + ); + const io = new ScriptedIo(['', 'y']); + await migration.renewPasswords(io, OPERATOR); + + // 첫 계정(HONG)은 결과 모름, 나머지(KIM · LEE)는 같은 값 + expect(engine.migrationPasswords).toEqual([value, value, value]); + expect(io.output).toContain( + '이전용 비밀번호를 걸었는지 모릅니다 — 메일 엔진의 답을 받지 못해 결과를 모릅니다 (timeout) — 같은 명령을 다시 하세요.', + ); + expect(io.output).toContain( + '같은 실행에서 다시 하면 이번 실행의 값을 다시 겁니다.', + ); + expect(io.output).toContain('걸었음 2명 · 못 함 1명'); + expect(linesWith(io, value)).toBe(0); + expect(audit.entries.at(1)?.detail).toContain('결과 모름'); + expectNoSecretsInLogs(value); + }); + + it('엔진이 약하다고 거듭 받지 않으면 "이전용 비밀번호"로 알린다 (리뷰 L3)', async () => { + engine.employees.set(HONG, migrating()); + jest + .spyOn(engine, 'setMigrationPassword') + .mockRejectedValue(new OpsEngineError('weak-password')); + const io = new ScriptedIo(['hong', 'y']); + await migration.renewPasswords(io, OPERATOR); + expect(io.output).toContain( + '엔진이 이전용 비밀번호를 받지 않았습니다(강도)', + ); + expect(io.output).not.toContain('임시 비밀번호를 받지 않았습니다'); + expect(migrationPassword.exists).toBe(false); + }); + }); + + describe('이전 마치기', () => { + it('한 사람 — 앱 비밀번호 → 세션 → 이전 중 로그인 기록 → 임시 비밀번호 + 만료 → 세션 한 번 더, 알림 메일 없음 · 표에 한 번', async () => { + engine.employees.set(HONG, migrating({ appPasswords: 1 })); + data.revokeSessions + .mockImplementationOnce((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(3); + }) + .mockImplementationOnce((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(1); + }); + const io = new ScriptedIo(['hong', 'y']); + await migration.finish(io, OPERATOR); + + expect(order()).toEqual([ + `find:${HONG}`, + `find:${HONG}`, + `deleteAppPasswords:${HONG}`, + `revokeSessions:${HONG}`, + `deleteLoginEvents:${HONG}`, + 'resetPassword:acc-hong', + `revokeSessions:${HONG}`, + ]); + expect(confirmations(io)).toBe(1); + expect(systemMail.notifyPasswordReset).not.toHaveBeenCalled(); + expect(io.output).toContain('받은편지함 알림 메일은 넣지 않는다'); + // 바뀔 것 — 웹훅이 다시 보내는 시간(리뷰 L1) · 지울 로그인 기록 수(L5) + expect(io.output).toContain( + '끝나고 5분쯤 뒤(엔진 웹훅의 다시 보내기)에 한다', + ); + expect(io.output).toContain('3. 이전 중 로그인 기록 12줄 지우기'); + expect(data.countLoginEventsOf).toHaveBeenCalledWith(HONG); + const [password] = engine.resetPasswords; + expect(linesWith(io, password)).toBe(1); + expect(io.output).toContain( + '이 화면을 닫으면 다시 볼 수 없습니다 — 직원에게 직접(말 · 쪽지) 전하세요.', + ); + expect(audit.entries).toEqual([ + { + operator: OPERATOR, + command: 'migration-finish', + target: HONG, + result: 'success', + detail: + '앱 비밀번호 2개 지움 · 로그인 기록 12줄 지움 · 세션 4개 끊음', + }, + ]); + expect(JSON.stringify(audit.entries)).not.toContain(password); + expectNoSecretsInLogs(password); + }); + + it('y/N 뒤 다시 읽었더니 그사이 만료(다른 곳에서 마침 · 30일 지남)면 아무것도 하지 않는다 — 직원에게 넘긴 임시 비밀번호를 덮지 않게', async () => { + engine.employees.set(HONG, migrating()); + const original = engine.findEmployee.bind(engine); + let reads = 0; + jest.spyOn(engine, 'findEmployee').mockImplementation(async (email) => { + const found = await original(email); + reads += 1; + return reads > 1 && found + ? { ...found, passwordExpiresAt: null, passwordExpired: true } + : found; + }); + const io = new ScriptedIo(['hong', 'y']); + await migration.finish(io, OPERATOR); + expect(order()).toEqual([`find:${HONG}`, `find:${HONG}`]); + expect(io.output).toContain( + '[못 함] 그사이 비밀번호의 처지가 바뀌었습니다', + ); + expect(audit.entries[0]).toMatchObject({ result: 'failure' }); + }); + + it('① 앱 비밀번호 · ② 세션 끊기가 실패하면 임시 비밀번호를 걸지 않는다 — 건 것이 없으면 "직접 전하세요"는 없다', async () => { + engine.employees.set(HONG, migrating({ appPasswords: 1 })); + engine.failures.set( + 'deleteAppPasswords', + new OpsEngineError('refused', 'forbidden'), + ); + const apps = new ScriptedIo(['hong', 'y']); + await migration.finish(apps, OPERATOR); + expect(order()).toEqual([ + `find:${HONG}`, + `find:${HONG}`, + `deleteAppPasswords:${HONG}`, + ]); + expect(apps.output).toContain( + '[못 함] 앱 비밀번호를 지우지 못했습니다(바꾼 것 없음)', + ); + expect(apps.output).toContain('직원용 임시 비밀번호 0명 · 못 함 1명'); + expect(apps.output).not.toContain('직원에게 직접(말 · 쪽지) 전하세요'); + + engine.calls.length = 0; + engine.employees.set(HONG, migrating()); + data.revokeSessions.mockRejectedValueOnce(new Error('connection lost')); + const sessions = new ScriptedIo(['hong', 'y']); + await migration.finish(sessions, OPERATOR); + expect(order()).toEqual([`find:${HONG}`, `find:${HONG}`]); + expect(engine.resetPasswords).toEqual([]); + expect(sessions.output).toContain( + '[못 함] 웹메일 로그인을 끊지 못했습니다(이전용 비밀번호 그대로)', + ); + expect(audit.entries.map((entry) => entry.result)).toEqual([ + 'failure', + 'failure', + ]); + }); + + it('⑤ 그사이 생긴 세션을 다시 끊지 못하면 partial — 표에 임시 비밀번호와 "참고"', async () => { + engine.employees.set(HONG, migrating()); + data.revokeSessions + .mockImplementationOnce((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(1); + }) + .mockRejectedValueOnce(new Error('connection lost')); + const io = new ScriptedIo(['hong', 'y']); + await migration.finish(io, OPERATOR); + const [password] = engine.resetPasswords; + expect(linesWith(io, password)).toBe(1); + expect(io.output).toContain( + '참고: 그사이 새로 로그인한 웹메일을 확인하지 못했습니다', + ); + expect(io.output).toContain('직원용 임시 비밀번호 1명 · 못 함 0명'); + expect(audit.entries[0]).toMatchObject({ result: 'partial' }); + expect(audit.entries[0].detail).toContain('세션 다시 끊기 실패'); + expectNoSecretsInLogs(password); + }); + + it('빈 값 — 잠긴 계정은 건너뛰고(바뀔 것에 까닭), 이전 중인 계정이 없으면 알리고, 모두 막혔으면 묻지 않는다', async () => { + // 0명 + const none = new ScriptedIo(['']); + await migration.finish(none, OPERATOR); + expect(none.output).toContain('이전 중인 계정이 없습니다.'); + expect(confirmations(none)).toBe(0); + + // 모두 막힘 — 잠긴 계정뿐 + engine.employees.set(HONG, migrating({ locked: true })); + const blocked = new ScriptedIo(['']); + await migration.finish(blocked, OPERATOR); + expect(blocked.output).toContain( + '이전 중인 계정에 할 수 있는 것이 없습니다:', + ); + expect(blocked.output).toContain(` ${HONG} — 잠긴 계정입니다`); + expect(confirmations(blocked)).toBe(0); + + // 잠긴 계정은 건너뛰고 나머지만 + engine.employees.set( + KIM, + migrating({ id: 'acc-kim', email: KIM, name: '김철수' }), + ); + const io = new ScriptedIo(['', 'y']); + await migration.finish(io, OPERATOR); + expect(io.output).toContain('대상 이전 중인 계정 1명'); + expect(io.output).toContain(`건너뜀: ${HONG} — 잠긴 계정입니다`); + expect(order()).not.toContain('resetPassword:acc-hong'); + expect(order()).toContain('resetPassword:acc-kim'); + expect(audit.entries.map((entry) => entry.target)).toEqual([KIM]); + }); + + it('지울 로그인 기록 수를 읽지 못하면 바뀔 것을 보이지 않고 멈춘다', async () => { + engine.employees.set(HONG, migrating()); + data.countLoginEventsOf.mockRejectedValueOnce( + new Error('connection lost'), + ); + const io = new ScriptedIo(['hong']); + await migration.finish(io, OPERATOR); + expect(confirmations(io)).toBe(0); + expect(order()).toEqual([`find:${HONG}`]); + expect(io.output).toContain('오류 로그'); + }); + + it('앱 비밀번호가 없으면 지우기를 부르지 않는다', async () => { + engine.employees.set(HONG, migrating()); + await migration.finish(new ScriptedIo(['hong', 'y']), OPERATOR); + expect(order()).not.toContain(`deleteAppPasswords:${HONG}`); + expect(audit.entries[0].detail).toBe( + '로그인 기록 12줄 지움 · 세션 6개 끊음', + ); + }); + + it('한 사람이면 이전 중이어야 한다 — 아니면 까닭을 알리고 멈춘다', async () => { + const set = new ScriptedIo(['ara.kim']); + await migration.finish(set, OPERATOR); + expect(set.output).toContain( + '이전 중인 계정이 아닙니다 — 직원이 이미 비밀번호를 정했습니다.', + ); + + engine.employees.set( + HONG, + migrating({ passwordExpiresAt: null, passwordExpired: true }), + ); + const expired = new ScriptedIo(['hong']); + await migration.finish(expired, OPERATOR); + expect(expired.output).toContain( + '「이전용 비밀번호 다시 만들기」 뒤에 마치고, 아니면 「비밀번호 재설정」', + ); + + engine.employees.set(HONG, migrating({ locked: true })); + const locked = new ScriptedIo(['hong']); + await migration.finish(locked, OPERATOR); + expect(locked.output).toContain('잠긴 계정입니다'); + + expect(order().every((call) => call.startsWith('find:'))).toBe(true); + expect(audit.entries).toEqual([]); + }); + + it('빈 값이면 이전 중인 계정 모두 — 목록과 수를 보이고 y/N 한 번, 계정마다 차례로 · 직원마다 다른 임시 비밀번호를 표에', async () => { + engine.employees.set(HONG, migrating()); + engine.employees.set( + KIM, + migrating({ id: 'acc-kim', email: KIM, name: '김철수' }), + ); + const io = new ScriptedIo(['', 'y']); + await migration.finish(io, OPERATOR); + + expect(io.output).toContain('대상 이전 중인 계정 2명'); + expect(io.output).toContain( + ` ${HONG} (홍길동) — 이전용 ~2026년 12월 1일 (화) 오후 3:00 · 로그인 기록 12줄`, + ); + expect(io.output).toContain(` ${KIM} (김철수) — 이전용 ~`); + expect(confirmations(io)).toBe(1); + expect(order()).toEqual([ + `find:${HONG}`, + `revokeSessions:${HONG}`, + `deleteLoginEvents:${HONG}`, + 'resetPassword:acc-hong', + `revokeSessions:${HONG}`, + `find:${KIM}`, + `revokeSessions:${KIM}`, + `deleteLoginEvents:${KIM}`, + 'resetPassword:acc-kim', + `revokeSessions:${KIM}`, + ]); + const [hong, kim] = engine.resetPasswords; + expect(hong).not.toBe(kim); + expect(linesWith(io, hong)).toBe(1); + expect(linesWith(io, kim)).toBe(1); + expect(io.output).toContain('직원용 임시 비밀번호 2명 · 못 함 0명'); + expect(systemMail.notifyPasswordReset).not.toHaveBeenCalled(); + expect(audit.entries.map((entry) => entry.target)).toEqual([HONG, KIM]); + }); + + it('한 계정이 실패해도 나머지는 이어 하고, 표에 성공한 비밀번호와 못 한 까닭(결과 모름 — 다시)을 보인다', async () => { + engine.employees.set(HONG, migrating()); + engine.employees.set( + KIM, + migrating({ id: 'acc-kim', email: KIM, name: '김철수' }), + ); + engine.failures.set( + 'resetPassword', + new OpsEngineError('unavailable', 'timeout'), + ); + const io = new ScriptedIo(['', 'y']); + await migration.finish(io, OPERATOR); + + const [attempted, kim] = engine.resetPasswords; + // 결과를 모르는 첫 계정의 비밀번호는 보이지 않는다 + expect(linesWith(io, attempted)).toBe(0); + expect(linesWith(io, kim)).toBe(1); + expect(io.output).toContain( + '[못 함] 메일 엔진의 답을 받지 못해 결과를 모릅니다 (timeout) — 같은 명령을 다시 하세요.', + ); + expect(io.output).toContain('직원용 임시 비밀번호 1명 · 못 함 1명'); + expect(audit.entries.map((entry) => entry.result)).toEqual([ + 'failure', + 'success', + ]); + expect(audit.entries[0].detail).toContain('결과 모름'); + }); + + it('임시 비밀번호를 건 뒤 예외가 나도 그 비밀번호는 표에 보이고 다음 계정을 이어 한다', async () => { + engine.employees.set(HONG, migrating()); + engine.employees.set( + KIM, + migrating({ id: 'acc-kim', email: KIM, name: '김철수' }), + ); + // 첫 계정의 실행 기록에서 예상하지 못한 오류 + audit.record.mockRejectedValueOnce(new TypeError('boom')); + const io = new ScriptedIo(['', 'y']); + await migration.finish(io, OPERATOR); + + const [hong, kim] = engine.resetPasswords; + expect(linesWith(io, hong)).toBe(1); + expect(linesWith(io, kim)).toBe(1); + expect(io.output).toContain('직원용 임시 비밀번호 2명 · 못 함 0명'); + expect(errorLogs.join('\n')).toContain('[SYS_001]'); + // 모르는 오류의 로그(스택 포함)에도 비밀번호는 없다 + expectNoSecretsInLogs(hong, kim); + }); + + it('이전 중 로그인 기록을 지우지 못하면 그 계정은 비밀번호를 바꾸지 않고 멈춘다(다시 하면 된다)', async () => { + engine.employees.set(HONG, migrating()); + data.deleteLoginEventsOf.mockRejectedValueOnce( + new Error('connection lost'), + ); + const io = new ScriptedIo(['hong', 'y']); + await migration.finish(io, OPERATOR); + expect(order()).not.toContain('resetPassword:acc-hong'); + expect(io.output).toContain( + '이전 중 로그인 기록을 지우지 못해 멈춥니다', + ); + expect(audit.entries[0]).toMatchObject({ result: 'failure' }); + }); + }); + + describe('현황 · 이전 현황 · 메뉴', () => { + it('이전 현황 — 이전 중인 계정의 주소 · 이름 · 끝나는 때 · 메일함과 수, 없으면 알린다 (읽기만)', async () => { + const none = new ScriptedIo([]); + await migration.status(none); + expect(none.output).toContain('이전 중인 계정이 없습니다.'); + + engine.employees.set( + HONG, + migrating({ usedBytes: 5 * 1024 ** 3, quotaBytes: 10 * 1024 ** 3 }), + ); + const io = new ScriptedIo([]); + await migration.status(io); + expect(io.output).toContain(`${HONG} (홍길동)`); + expect(io.output).toContain( + '이전용 ~2026년 12월 1일 (화) 오후 3:00 · 메일함 5 GB / 10 GB', + ); + expect(io.output).toContain(' 이전 중 1명'); + expect(io.output).not.toContain('비밀번호가 만료된 계정'); + expect(audit.entries).toEqual([]); + }); + + it('이전 현황 — 비밀번호가 만료된 계정은 목록에 없고 수만 알린다(30일이 지난 이전 계정일 수 있다)', async () => { + engine.employees.set( + PARK, + employee({ + id: 'acc-park', + email: PARK, + name: '박하나', + passwordExpired: true, + }), + ); + const none = new ScriptedIo([]); + await migration.status(none); + expect(none.output).toContain('이전 중인 계정이 없습니다.'); + expect(none.output).toContain( + '비밀번호가 만료된 계정 1명은 여기에 없습니다', + ); + + engine.employees.set(HONG, migrating()); + const io = new ScriptedIo([]); + await migration.status(io); + expect(io.output).toContain(' 이전 중 1명'); + expect(io.output).toContain( + '비밀번호가 만료된 계정 1명은 여기에 없습니다', + ); + expect(io.output).not.toContain(`${PARK} (`); + }); + + it('현황 — 이전 중인 계정에 이전 중(~MM-DD)', async () => { + engine.employees.set(HONG, migrating()); + const io = new ScriptedIo([]); + await admin.status(io); + expect(io.output).toContain('상태 이전 중(~12-01)'); + }); + + it('메뉴 11 — 메일 이전의 하위 메뉴', async () => { + const io = new ScriptedIo([OPERATOR, 'pw', '11', '1', '0']); + await expect(cli().run(io)).resolves.toBe(0); + expect(io.output).toContain('11. 메일 이전'); + expect(io.output).toContain( + '메일 이전 — 1. 이전 현황 2. 이전용 비밀번호 다시 만들기 3. 이전 마치기 0. 돌아가기', + ); + expect(io.output).toContain('이전 중인 계정이 없습니다.'); + }); + }); + + describe('입사의 결과 모름 · 약함 거절 (리뷰 L3 · L4)', () => { + it('결과를 모르면 다시 할 때 "이미 쓰고 있습니다"에서 할 일을 알린다 — 이전용은 다시 만들기(그 주소), 임시는 재설정', async () => { + engine.failures.set( + 'createEmployee', + new OpsEngineError('unavailable', 'timeout'), + ); + const migratingHire = new ScriptedIo(['choi', '최다인', '', 'y', 'y']); + await accounts.hire(migratingHire, OPERATOR); + expect(migratingHire.output).toContain( + '다시 할 때 "이미 쓰고 있습니다"가 나오면 계정은 만들어진 것입니다 — 그때는 「메일 이전 › 이전용 비밀번호 다시 만들기」(그 주소)를 하세요.', + ); + expect(migratingHire.output).not.toContain( + '「비밀번호 재설정」 뒤 「메일 이전', + ); + expect(audit.entries[0]).toMatchObject({ + command: 'hire-migration', + result: 'failure', + }); + expect(migrationPassword.exists).toBe(false); + + // 엔진이 실제로는 만들었다면(미래 만료) — 이 실패 기록이 판정에 들어 이전 중으로 보이고, 다시 만들기(그 주소)가 바로 받는다 + const CHOI = `choi@${DOMAIN}`; + engine.employees.set( + CHOI, + migrating({ id: 'acc-choi', email: CHOI, name: '최다인' }), + ); + const renew = new ScriptedIo(['choi', 'y']); + await migration.renewPasswords(renew, OPERATOR); + expect(renew.output).toContain('지금 이전용 ~'); + expect(order()).toContain('setMigrationPassword:acc-choi'); + + engine.failures.set( + 'createEmployee', + new OpsEngineError('unavailable', 'http-503'), + ); + const tempHire = new ScriptedIo(['kim', '김철수', '', 'n', 'y']); + await accounts.hire(tempHire, OPERATOR); + expect(tempHire.output).toContain( + '그때는 「비밀번호 재설정」을 하세요.', + ); + expect( + audit.entries.map((entry) => [entry.command, entry.result]), + ).toEqual([ + ['hire-migration', 'failure'], + ['migration-password', 'success'], + ['hire', 'failure'], + ]); + }); + + it('엔진이 약하다고 거듭 받지 않으면 그 비밀번호의 이름으로 알린다', async () => { + jest + .spyOn(engine, 'createEmployee') + .mockRejectedValue(new OpsEngineError('weak-password')); + const migratingHire = new ScriptedIo(['hong', '홍길동', '', 'y', 'y']); + await accounts.hire(migratingHire, OPERATOR); + expect(migratingHire.output).toContain( + '엔진이 이전용 비밀번호를 받지 않았습니다(강도)', + ); + const tempHire = new ScriptedIo(['kim', '김철수', '', 'n', 'y']); + await accounts.hire(tempHire, OPERATOR); + expect(tempHire.output).toContain( + '엔진이 임시 비밀번호를 받지 않았습니다(강도)', + ); + }); + }); + + describe('이전 중 판정 — 엔진의 미래 만료(31일 안) + 실행 기록의 마지막 비밀번호 걸기가 이전용 (재리뷰 N1)', () => { + it('실행 기록의 마지막 비밀번호 걸기가 이전용이 아니면 만료가 미래여도 이전 중이 아니다 — 현황 · 재설정 · 「메일 이전」 모두', async () => { + // 비밀번호 기본 만료가 켜져 있던 동안 직원이 정한 비밀번호 — 엔진은 미래 만료를 남긴다 + engine.employees.set( + PARK, + migrating({ id: 'acc-park', email: PARK, name: '박하나' }), + ); + history.push({ + operator: OPERATOR, + command: 'hire', + target: PARK, + result: 'success', + }); + + const status = new ScriptedIo([]); + await admin.status(status); + expect(status.output).toContain(`${PARK} (박하나)`); + expect(status.output).not.toContain('이전 중('); + + const list = new ScriptedIo([]); + await migration.status(list); + expect(list.output).toContain('이전 중인 계정이 없습니다.'); + + const renew = new ScriptedIo(['park']); + await migration.renewPasswords(renew, OPERATOR); + expect(renew.output).toContain( + '직원이 이미 비밀번호를 정한 계정입니다', + ); + + const reset = new ScriptedIo(['park', 'n']); + await accounts.resetPassword(reset, OPERATOR); + expect(reset.output).not.toContain('메일 이전 중인 계정입니다'); + expect(reset.output).toContain('비밀번호 재설정 — 바뀔 것'); + expect( + order().some((call) => call.startsWith('setMigrationPassword')), + ).toBe(false); + }); + + it('재설정 · 이전 마치기의 기록이 마지막이면 이전 중이 아니다 — 이전용이 아닌 명령의 실패는 건너뛰고 그 앞 기록으로', async () => { + engine.employees.set(HONG, migrating()); + // 마친 뒤(엔진은 아직 미래 만료를 돌려준다고 해도) — 마지막 판정 기록이 migration-finish + await migration.finish(new ScriptedIo(['hong', 'y']), OPERATOR); + const afterFinish = new ScriptedIo([]); + await migration.status(afterFinish); + expect(afterFinish.output).toContain('이전 중인 계정이 없습니다.'); + + // 그 뒤의 다시 만들기(partial 도 걸었다고 본다) → 다시 이전 중 + audit.entries.push({ + operator: OPERATOR, + command: 'migration-password', + target: HONG, + result: 'partial', + }); + const again = new ScriptedIo([]); + await admin.status(again); + expect(again.output).toContain('상태 이전 중(~12-01)'); + + // 그 뒤의 실패한 재설정 · 마치기 · 임시 입사는 건너뛴다 — 그 앞 기록(다시 만들기)이 정한다 + for (const command of [ + 'password-reset', + 'migration-finish', + 'hire', + ] as const) { + audit.entries.push({ + operator: OPERATOR, + command, + target: HONG, + result: 'failure', + }); + } + const still = new ScriptedIo([]); + await admin.status(still); + expect(still.output).toContain('상태 이전 중(~12-01)'); + }); + + it('이전용 명령의 실패(결과 모름일 수 있다)가 마지막이면 — 엔진 만료가 미래면 이전 중, 지났으면 아니다', async () => { + engine.employees.set( + PARK, + migrating({ id: 'acc-park', email: PARK, name: '박하나' }), + ); + // 임시 입사 뒤 다시 만들기의 결과를 몰랐다(실패 기록) — 엔진에는 걸렸다(미래 만료) + history.push( + { + operator: OPERATOR, + command: 'hire', + target: PARK, + result: 'success', + }, + { + operator: OPERATOR, + command: 'migration-password', + target: PARK, + result: 'failure', + }, + ); + const migratingNow = new ScriptedIo([]); + await migration.status(migratingNow); + expect(migratingNow.output).toContain(`${PARK} (박하나)`); + expect(migratingNow.output).toContain(' 이전 중 1명'); + + // 엔진이 실제로는 거절했다 — 옛 만료(지남) 그대로라 이전 중이 아니다(만료 조건이 가른다) + engine.employees.set( + PARK, + migrating({ + id: 'acc-park', + email: PARK, + name: '박하나', + passwordExpiresAt: null, + passwordExpired: true, + }), + ); + const refused = new ScriptedIo([]); + await migration.status(refused); + expect(refused.output).toContain('이전 중인 계정이 없습니다.'); + }); + + it('다시 만들기의 결과를 모르면 "같은 명령을 다시 하세요" — 걸렸으면 이전 중으로 보여 같은 명령이 다시 된다', async () => { + // 비밀번호가 만료된 계정(이전용 입사 뒤 30일이 지남) — 한 사람 다시 만들기 + engine.employees.set( + HONG, + migrating({ passwordExpiresAt: null, passwordExpired: true }), + ); + engine.failures.set( + 'setMigrationPassword', + new OpsEngineError('unavailable', 'timeout'), + ); + const first = new ScriptedIo(['hong', 'y']); + await migration.renewPasswords(first, OPERATOR); + expect(first.output).toContain( + '이전용 비밀번호를 걸었는지 모릅니다 — 메일 엔진의 답을 받지 못해 결과를 모릅니다 (timeout) — 같은 명령을 다시 하세요.', + ); + expect(first.output).not.toContain('「비밀번호 재설정」 뒤'); + expect(audit.entries[0]).toMatchObject({ + command: 'migration-password', + result: 'failure', + }); + + // 엔진에는 걸렸다(미래 만료) — 실패 기록이 판정에 들어 이전 중, 다시 하면 받는다 + engine.employees.set(HONG, migrating()); + const again = new ScriptedIo(['hong', 'y']); + await migration.renewPasswords(again, OPERATOR); + expect(again.output).toContain('지금 이전용 ~'); + expect( + order().filter((call) => call.startsWith('setMigrationPassword')), + ).toEqual([ + 'setMigrationPassword:acc-hong', + 'setMigrationPassword:acc-hong', + ]); + expect(audit.entries.at(-1)).toMatchObject({ + command: 'migration-password', + result: 'success', + }); + }); + + it('만료가 지금 + 31일보다 멀면 이전 중이 아니다 — 31일 안이면 이전 중', async () => { + engine.employees.set( + HONG, + migrating({ + passwordExpiresAt: new Date( + NOW.getTime() + 40 * DAY_MS, + ).toISOString(), + }), + ); + const far = new ScriptedIo([]); + await migration.status(far); + expect(far.output).toContain('이전 중인 계정이 없습니다.'); + + engine.employees.set( + HONG, + migrating({ + passwordExpiresAt: new Date( + NOW.getTime() + 31 * DAY_MS - 1000, + ).toISOString(), + }), + ); + const near = new ScriptedIo([]); + await migration.status(near); + expect(near.output).toContain(' 이전 중 1명'); + }); + + it('실행 기록을 읽지 못하면 「메일 이전」은 멈추고 알린다 — 현황 · 다시 만들기 · 마치기, 한 사람 · 모두', async () => { + engine.employees.set(HONG, migrating()); + data.lastPasswordSetIsMigration.mockRejectedValue( + new Error('connection lost'), + ); + for (const run of [ + (io: ScriptedIo) => migration.status(io), + (io: ScriptedIo) => migration.renewPasswords(io, OPERATOR), + (io: ScriptedIo) => migration.finish(io, OPERATOR), + ]) { + for (const answers of [['hong'], ['']]) { + const io = new ScriptedIo([...answers]); + await run(io); + expect(io.output).toContain(MIGRATION_RECORDS_UNREADABLE); + expect(confirmations(io)).toBe(0); + } + } + expect( + order().some( + (call) => + call.startsWith('setMigrationPassword') || + call.startsWith('resetPassword'), + ), + ).toBe(false); + expect(errorLogs.join('\n')).toContain('[SYS_001]'); + }); + + it('y/N 뒤 다시 읽을 때 실행 기록을 읽지 못하면 그 계정은 하지 않는다', async () => { + engine.employees.set(HONG, migrating()); + data.lastPasswordSetIsMigration + .mockImplementationOnce(() => Promise.resolve(new Set([HONG]))) + .mockRejectedValueOnce(new Error('connection lost')); + const io = new ScriptedIo(['hong', 'y']); + await migration.finish(io, OPERATOR); + expect(order()).toEqual([`find:${HONG}`, `find:${HONG}`]); + expect(io.output).toContain( + '[못 함] 이전 중인지 다시 확인하지 못했습니다 — 실행 기록을 읽지 못했습니다', + ); + expect(audit.entries[0]).toMatchObject({ + command: 'migration-finish', + result: 'failure', + }); + }); + + it('실행 기록을 읽지 못하면 재설정 · 잠금 풀기는 엔진만 보고 보수적으로 막는다 — 만료가 미래면 하지 않고, 만료가 없으면 그대로', async () => { + data.lastPasswordSetIsMigration.mockRejectedValue( + new Error('connection lost'), + ); + // 실행 기록으로는 이전 중이 아니었을 계정(임시 입사 기록)이어도 — 확인하지 못했으니 막는다 + engine.employees.set( + PARK, + migrating({ id: 'acc-park', email: PARK, name: '박하나' }), + ); + const reset = new ScriptedIo(['park']); + await accounts.resetPassword(reset, OPERATOR); + expect(reset.output).toContain( + '메일 이전 중인지 확인하지 못했습니다 — 실행 기록을 읽지 못했고 비밀번호 만료가 미래(~2026년 12월 1일 (화) 오후 3:00)라 이전 중일 수 있어 하지 않습니다', + ); + expect(order()).toEqual([`find:${PARK}`]); + + // 만료가 없는 계정은 이전 중일 수 없다 — 재설정은 그대로 + const plain = new ScriptedIo(['ara.kim', 'n']); + await accounts.resetPassword(plain, OPERATOR); + expect(plain.output).toContain('비밀번호 재설정 — 바뀔 것'); + + // 잠금 풀기 — 재설정을 권하지 않고 까닭을 보인다 + engine.employees.set( + PARK, + migrating({ + id: 'acc-park', + email: PARK, + name: '박하나', + locked: true, + }), + ); + const unlock = new ScriptedIo(['park', 'y']); + await locks.unlock(unlock, OPERATOR); + expect(unlock.output).toContain('비밀번호 재설정은 권하지 않는다'); + expect(unlock.output).toContain('메일 이전 중인지 확인하지 못했습니다'); + expect( + unlock.asked.some((question) => + question.includes('비밀번호 재설정을'), + ), + ).toBe(false); + expect(errorLogs.join('\n')).toContain('[SYS_001]'); + }); + + it('비밀번호 기본 만료가 켜져 있어도 막지 않는다 — 첫 화면에 알림 한 줄만, 입사는 묻고 「메일 이전」 · 재설정 가드는 그대로', async () => { + engine.defaultExpiry = 'on'; + engine.employees.set(HONG, migrating()); + const io = new ScriptedIo([ + OPERATOR, + 'pw', + '11', + '1', + '2', + 'hong', + '1', + 'kim', + '김철수', + '', + 'y', + 'y', + '0', + ]); + await expect(cli().run(io)).resolves.toBe(0); + expect(engine.defaultExpiryReads).toBe(1); + expect(io.output).toContain(PASSWORD_DEFAULT_EXPIRY_NOTICE); + // 「메일 이전」이 열리고 이전 중인 계정을 보인다 + expect(io.output).toContain(' 이전 중 1명'); + // 재설정 가드는 그대로 + expect(io.output).toContain('메일 이전 중인 계정입니다'); + // 입사는 옮겨 올 메일을 묻는다 + expect(io.asked).toContain('mailcow 에서 옮겨 올 메일이 있습니까?'); + expect(engine.createdMigrationUntil).toHaveLength(1); + expect(engine.createdMigrationUntil[0]).not.toBeNull(); + }); + + it('기본 만료를 읽지 못해도(unknown) 아무것도 막지 않는다 — 첫 화면에도 말이 없다(어댑터의 경고 로그만)', async () => { + engine.defaultExpiry = 'unknown'; + engine.employees.set(HONG, migrating()); + const io = new ScriptedIo([OPERATOR, 'pw', '11', '1', '0']); + await expect(cli().run(io)).resolves.toBe(0); + expect(io.output).not.toContain('주의: 엔진에 비밀번호 기본 만료'); + expect(io.output).toContain(' 이전 중 1명'); + }); + }); + + describe('이번 실행의 이전용 비밀번호를 끝낼 때 지운다', () => { + it('입력이 끝나도(Ctrl+D) 지운다', async () => { + const io = new ScriptedIo([ + OPERATOR, + 'pw', + '1', + 'hong', + '홍길동', + '', + 'y', + 'y', + ]); + await expect(cli().run(io)).resolves.toBe(0); + expect(engine.createdMigrationUntil).toHaveLength(1); + expect(io.output).toContain('끝냅니다.'); + expect(migrationPassword.exists).toBe(false); + }); + + it('예상하지 못한 오류로 끝나도 지운다', async () => { + await migrationPassword.apply(() => Promise.resolve()); + expect(migrationPassword.exists).toBe(true); + jest + .spyOn(engine, 'passwordDefaultExpiry') + .mockRejectedValueOnce(new TypeError('boom')); + await expect( + cli().run(new ScriptedIo([OPERATOR, 'pw', '0'])), + ).rejects.toThrow('boom'); + expect(migrationPassword.exists).toBe(false); + }); + }); + + describe('다시 만들기 모두의 요약 (재리뷰 N3)', () => { + it('셋째 단계(세션 다시 끊기)가 실패하면 partial — 요약에 "참고", 못 한 계정이 있으면 같은 실행에서 한 번 더라고 알린다', async () => { + engine.employees.set(HONG, migrating()); + engine.employees.set( + KIM, + migrating({ id: 'acc-kim', email: KIM, name: '김철수' }), + ); + data.revokeSessions + // HONG — 첫 끊기는 되고, 건 뒤 다시 끊기는 실패(partial) + .mockImplementationOnce((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(1); + }) + .mockRejectedValueOnce(new Error('connection lost')) + // KIM — 첫 끊기가 실패(못 함) + .mockRejectedValueOnce(new Error('connection lost')); + const io = new ScriptedIo(['', 'y']); + await migration.renewPasswords(io, OPERATOR); + + expect( + order().filter((call) => call.startsWith('setMigrationPassword')), + ).toEqual(['setMigrationPassword:acc-hong']); + expect(io.output).toContain('걸었음 1명 · 못 함 1명'); + expect(io.output).toContain( + `${HONG} (홍길동) — 참고: 그사이 새로 로그인한 웹메일을 확인하지 못했습니다`, + ); + expect(io.output).toContain( + `${KIM} (김철수) — [못 함] 웹메일 로그인을 끊지 못했습니다(비밀번호 그대로)`, + ); + expect(io.output).toContain( + '못 한 계정은 까닭을 고친 뒤 같은 실행에서 「다시 만들기」(빈 값)를 한 번 더 — 모두 이번 값이 됩니다.', + ); + expect(audit.entries.map((entry) => entry.result)).toEqual([ + 'partial', + 'failure', + ]); + expect(audit.entries[0].detail).toContain('세션 다시 끊기 실패'); + expectNoSecretsInLogs(...engine.migrationPasswords); + }); + + it('모두 했으면 한 번 더라는 줄은 없다', async () => { + engine.employees.set(HONG, migrating()); + const io = new ScriptedIo(['', 'y']); + await migration.renewPasswords(io, OPERATOR); + expect(io.output).toContain('걸었음 1명 · 못 함 0명'); + expect(io.output).not.toContain('한 번 더 — 모두 이번 값이 됩니다'); + }); + }); + }); + + describe('도구의 차례 (OpsCli)', () => { it('운영자 이름 · 관리자 비밀번호(세 번까지)를 묻고 메뉴를 돈다 — 0 이면 끝낸다', async () => { engine.signInResults = ['rejected', 'ok']; const io = new ScriptedIo([OPERATOR, 'wrong', 'right', '9', '0']); diff --git a/backend/src/modules/ops/ops-data.service.ts b/backend/src/modules/ops/ops-data.service.ts index b06b8ae..e52065e 100644 --- a/backend/src/modules/ops/ops-data.service.ts +++ b/backend/src/modules/ops/ops-data.service.ts @@ -21,6 +21,14 @@ import { SYSTEM_SETTINGS_ROW_ID, SystemSettingsEntity, } from '@/modules/system-settings/system-settings.entity'; +import { + MIGRATION_PASSWORD_COMMANDS, + OpsAuditLogEntity, + PASSWORD_SET_RESULTS, + PASSWORD_SETTING_COMMANDS, + type OpsAuditResult, + type OpsCommand, +} from './ops-audit-log.entity'; /** * 그 주소로 남은 우리 DB 의 줄 수 — 표마다 (퇴사 마무리 · 입사 때 겹침 확인). @@ -58,6 +66,47 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** 실행 기록 한 줄의 판정에 쓰는 칸 */ +interface PasswordRecord { + readonly target: string | null; + readonly command: string; + readonly result: string; +} + +/** + * 메일 이전 중 판정에 쓰는 기록인가 — 비밀번호를 거는 명령의 성공(success · partial), 또는 이전용 명령(이전용 입사 · 다시 만들기)의 실패. + * 이전용 명령의 실패는 결과를 모르는 실패일 수 있어(엔진에 걸렸을 수 있다) 센다 — 엔진이 거절했으면 계정이 없거나 옛 만료 그대로라 + * 만료 조건(migration-state.ts)이 가른다. 이전용이 아닌 명령의 실패는 뺀다 — 상태를 바꾸지 않았거나, 바꿨다면 만료가 지나 있다 + */ +export function countsForMigration(command: string, result: string): boolean { + if (!PASSWORD_SETTING_COMMANDS.includes(command as OpsCommand)) return false; + if (PASSWORD_SET_RESULTS.includes(result as OpsAuditResult)) return true; + return ( + result === 'failure' && + MIGRATION_PASSWORD_COMMANDS.includes(command as OpsCommand) + ); +} + +/** + * 실행 기록(새것부터) → 판정에 쓰는 마지막 기록(countsForMigration)이 이전용 명령인 주소. + * 주소마다 처음 만난(가장 새) 판정 기록이 정한다 — 그 밖의 기록은 건너뛴다 + */ +export function migrationAddressesOf( + rows: Iterable, +): Set { + const decided = new Set(); + const migrating = new Set(); + for (const row of rows) { + if (row.target === null || decided.has(row.target)) continue; + if (!countsForMigration(row.command, row.result)) continue; + decided.add(row.target); + if (MIGRATION_PASSWORD_COMMANDS.includes(row.command as OpsCommand)) { + migrating.add(row.target); + } + } + return migrating; +} + /** 예외 주소 목록에서 그 주소를 뺀 것 (대소문자 · 앞뒤 공백 무시 — 서버 설정 읽기와 같은 규칙) */ export function exemptWithout( exempt: unknown, @@ -73,7 +122,8 @@ export function exemptWithout( } /** - * 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 웹메일 로그인 · 퇴사 마무리의 주소 정리. + * 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 웹메일 로그인 · 퇴사 마무리의 주소 정리 · + * 메일 이전 마치기의 이전 중 로그인 기록 지우기. * 우리 DB 는 주소로 직원을 가린다 — 지운 주소를 새 사람이 다시 쓰면 옛 로그인 기록 · 서명 · 설정 · 2단계 기록 · 2단계 필수 예외가 그대로 남으므로 * 퇴사 마무리가 그 주소의 줄을 지운다. 세션 행은 끊기만 한다(정리 cron 이 지운다). 실행 기록(ops_audit_log)은 지우지 않는다. */ @@ -127,6 +177,60 @@ export class OpsDataService { ); } + /** + * 그 주소의 로그인 기록을 지운다 → 지운 줄 수 (메일 이전 마치기 — imapsync 의 IMAP 로그인이 엔진 웹훅으로 쌓여 있다. + * 직원이 첫날 모르는 로그인 수십 줄을 보지 않게). 로그인 기록만이다 — purgeAddress 는 2단계 필수 예외 · 서명 등도 지운다 + */ + async deleteLoginEventsOf(email: string): Promise { + const result = await this.dataSource.manager.delete(LoginEventEntity, { + userEmail: email, + }); + return result.affected ?? 0; + } + + /** 그 주소의 로그인 기록 수 — 이전 마치기의 바뀔 것에 보인다(지우는 범위와 같다 — 방식 상관없이 그 주소 전부) */ + countLoginEventsOf(email: string): Promise { + return this.dataSource.manager.countBy(LoginEventEntity, { + userEmail: email, + }); + } + + /** + * 주소들 가운데 우리 실행 기록에서 판정에 쓰는 마지막 기록(countsForMigration)이 이전용(이전용 입사 · 다시 만들기)인 주소 — 쿼리 하나. + * 메일 이전 중 판정의 한쪽이다(다른 쪽은 엔진의 미래 만료 — migration-state.ts). 엔진의 만료만으로는 가를 수 없다: + * 엔진은 비밀번호 기본 만료가 켜져 있던 동안 직원이 정한 비밀번호에도 미래의 만료를 저장하고, 끈 뒤에도 남긴다(0.16.27) + */ + async lastPasswordSetIsMigration( + emails: readonly string[], + ): Promise> { + if (emails.length === 0) return new Set(); + const targets = In([...emails]); + const rows = await this.dataSource.manager.find(OpsAuditLogEntity, { + select: { + id: true, + occurredAt: true, + target: true, + command: true, + result: true, + }, + // 두 조건의 OR — 비밀번호를 거는 명령의 성공(success · partial) · 이전용 명령의 실패 + where: [ + { + target: targets, + command: In([...PASSWORD_SETTING_COMMANDS]), + result: In([...PASSWORD_SET_RESULTS]), + }, + { + target: targets, + command: In([...MIGRATION_PASSWORD_COMMANDS]), + result: 'failure', + }, + ], + order: { occurredAt: 'DESC', id: 'DESC' }, + }); + return migrationAddressesOf(rows); + } + /** 그 주소로 남은 줄 수 */ async countRowsOf(email: string): Promise { return this.rowsOf(this.dataSource.manager, email, 'count'); diff --git a/backend/src/modules/ops/ops-engine.port.ts b/backend/src/modules/ops/ops-engine.port.ts index 355da75..89162db 100644 --- a/backend/src/modules/ops/ops-engine.port.ts +++ b/backend/src/modules/ops/ops-engine.port.ts @@ -10,6 +10,9 @@ export const OPS_ENGINE = Symbol('OPS_ENGINE'); /** 관리자 로그인 결과 */ export type OpsSignInResult = 'ok' | 'rejected' | 'two-factor' | 'not-admin'; +/** 엔진의 비밀번호 기본 만료(`passwordDefaultExpiry`) — 꺼짐 · 켜짐 · 읽지 못함 */ +export type PasswordDefaultExpiry = 'off' | 'on' | 'unknown'; + /** 엔진의 직원 계정 하나 (현황 · 명령의 대상) */ export interface EngineEmployee { /** 엔진의 계정 id — 불투명 */ @@ -22,8 +25,15 @@ export interface EngineEmployee { readonly locked: boolean; /** 계정 권한이 직접 정해진(Replace) 계정 — 잠그고 푸는 것을 이 도구가 하지 않는다 */ readonly customPermissions: boolean; - /** 비밀번호가 만료됐다(임시 비밀번호 — 첫 로그인 때 바꾼다) */ + /** 비밀번호가 만료됐다(임시 비밀번호 — 첫 로그인 때 바꾼다 · 30일이 지난 이전용 비밀번호) */ readonly passwordExpired: boolean; + /** + * 비밀번호 자격의 만료 시각이 지금보다 뒤면 그 시각(ISO 8601 UTC), 아니면 null(지났으면 passwordExpired). + * 이전용 비밀번호는 그 시각까지 IMAP · SMTP · 웹메일이 그대로고(imapsync) 지나면 엔진이 스스로 만료로 다룬다. + * 이것만으로 "메일 이전 중"을 가르지 않는다 — 엔진은 비밀번호 기본 만료가 켜져 있던 동안 직원이 정한 비밀번호에도 미래의 만료를 저장한다. + * 판정은 운영 도구가 우리 실행 기록과 함께 한다(migration-state.ts) + */ + readonly passwordExpiresAt: string | null; /** 2단계 인증(OTP)이 켜져 있다 */ readonly twoFactorEnabled: boolean; /** 앱 비밀번호 수 */ @@ -63,6 +73,11 @@ export interface LockRulesState { export interface OpsEngine { /** 관리자 로그인 — 맞으면 이 프로세스가 끝날 때까지 메모리에만 둔다 */ signIn(adminPassword: string): Promise; + /** + * 엔진의 비밀번호 기본 만료가 켜져 있는가 — 관리자 로그인 직후 한 번 읽고 이 실행 동안 기억한다(던지지 않는다 — 읽지 못하면 'unknown', 경고 로그). + * 아무것도 막지 않는다 — 켜져 있으면 도구가 첫 화면에 알리기만 한다(메일 이전 중 판정은 실행 기록으로 가르므로 영향이 없다) + */ + passwordDefaultExpiry(): Promise; /** * 직원으로 다룰 수 있는 주소인가 — 사내 도메인이고 관리자 · 게이트웨이 · 사내 목록 · 막은 계정이 아니다 (엔진에 묻지 않는다). @@ -76,15 +91,26 @@ export interface OpsEngine { /** 그 주소를 쓰는 계정 · 목록이 엔진에 있는가 (직원이 아닌 것 포함 — 입사 때 겹침 확인) */ addressInUse(email: string): Promise; - /** 입사 — 직원 역할 · 임시 비밀번호 · 만료(첫 로그인 때 바꾸기) · 메일함 용량(없으면 무제한) */ + /** + * 입사 — 직원 역할 · 처음 비밀번호 · 메일함 용량(없으면 무제한). + * `migrationUntil` 이 null 이면 임시 비밀번호 + 지난 만료(첫 로그인 때 바꾸기), + * 시각이면 이전용 비밀번호 + 그 시각의 만료(그때까지 IMAP 등이 그대로 — imapsync, 지나면 엔진이 막는다) + */ createEmployee(input: { readonly localPart: string; readonly name: string; readonly quotaBytes: number | null; - readonly tempPassword: string; + readonly password: string; + readonly migrationUntil: Date | null; }): Promise; /** 임시 비밀번호 + 만료 — 2단계 인증 · 앱 비밀번호는 그대로 둔다 */ resetPassword(id: string, tempPassword: string): Promise; + /** 이전용 비밀번호 + 만료 시각 `until`(미래 — 그때까지 IMAP 등이 그대로) — 2단계 인증 · 앱 비밀번호는 그대로 둔다 */ + setMigrationPassword( + id: string, + password: string, + until: Date, + ): Promise; /** 2단계 인증(OTP)을 지운다 — 비밀번호는 그대로 */ removeTwoFactor(id: string): Promise; /** 로그인 권한(authenticate) 빼기 · 되돌리기 — 메일은 계속 받는다 */ @@ -122,7 +148,7 @@ export const LOCK_FORWARD_RULE_NAME = '잠금 — 받는 메일 넘기기 (운 /** 엔진이 받지 않은 까닭 — 명령이 한국어로 알린다 */ export type OpsEngineErrorKind = - /** 엔진이 임시 비밀번호를 약하다고 받지 않음 — 새로 만들어 다시 한다 */ + /** 엔진이 임시 · 이전용 비밀번호를 약하다고 받지 않음 — 새로 만들어 다시 한다 */ | 'weak-password' /** 같은 이름(주소)이 이미 있다 */ | 'exists' diff --git a/backend/src/modules/ops/ops-helpers.spec.ts b/backend/src/modules/ops/ops-helpers.spec.ts index 42d7469..b597c79 100644 --- a/backend/src/modules/ops/ops-helpers.spec.ts +++ b/backend/src/modules/ops/ops-helpers.spec.ts @@ -1,7 +1,23 @@ +import { Logger } from '@nestjs/common'; import { PassThrough } from 'stream'; import { BusinessException } from '@/common/errors/business.exception'; -import { OpsEngineError } from './ops-engine.port'; +import { + MigrationPasswordSession, + migrationAuditText, + migrationUntilFrom, +} from './migration-password'; +import { + MIGRATION_WINDOW_DAYS, + migrationUntilOf, + withMigrationState, +} from './migration-state'; +import { + MIGRATION_PASSWORD_COMMANDS, + PASSWORD_SETTING_COMMANDS, +} from './ops-audit-log.entity'; +import { countsForMigration, migrationAddressesOf } from './ops-data.service'; +import { OpsEngineError, type EngineEmployee } from './ops-engine.port'; import { OpsInputClosed, TerminalIo, isYes } from './ops-io'; import { failureDetail, @@ -10,7 +26,11 @@ import { toDisplayName, toQuotaBytes, } from './ops-text'; -import { TEMP_PASSWORD_LENGTH, generateTempPassword } from './temp-password'; +import { + TEMP_PASSWORD_LENGTH, + applyFreshPassword, + generateTempPassword, +} from './temp-password'; describe('임시 비밀번호 (13단계)', () => { it('16자 · 헷갈리는 글자 없음 · 대문자 · 소문자 · 숫자가 하나씩은 있다', () => { @@ -35,6 +55,220 @@ describe('임시 비밀번호 (13단계)', () => { }); }); +describe('새 비밀번호 걸기 · 이번 실행의 이전용 비밀번호 (메일 이전)', () => { + const weak = () => Promise.reject(new OpsEngineError('weak-password')); + + it('applyFreshPassword — 엔진이 약하다고 받지 않으면 새로 만들어 두 번까지 다시, 그 밖의 실패는 다시 하지 않는다', async () => { + const tried: string[] = []; + let refusals = 2; + const password = await applyFreshPassword((candidate) => { + tried.push(candidate); + return refusals-- > 0 ? weak() : Promise.resolve(); + }); + expect(tried).toHaveLength(3); + expect(new Set(tried).size).toBe(3); + expect(password).toBe(tried[2]); + + await expect(applyFreshPassword(weak)).rejects.toMatchObject({ + kind: 'weak-password', + }); + const unavailable = jest + .fn() + .mockRejectedValue(new OpsEngineError('unavailable', 'timeout')); + await expect(applyFreshPassword(unavailable)).rejects.toMatchObject({ + kind: 'unavailable', + }); + expect(unavailable).toHaveBeenCalledTimes(1); + }); + + it('이번 실행의 값 — 처음엔 만들고(보인다) 다음엔 같은 값(보이지 않는다), 지우면(새 실행) 새 값', async () => { + const session = new MigrationPasswordSession(); + const applied: string[] = []; + const apply = (password: string) => { + applied.push(password); + return Promise.resolve(); + }; + expect(session.exists).toBe(false); + const first = await session.apply(apply); + expect(first).toEqual({ kind: 'created', password: applied[0] }); + expect(session.exists).toBe(true); + await expect(session.apply(apply)).resolves.toEqual({ kind: 'reused' }); + expect(applied[1]).toBe(applied[0]); + + session.clear(); + expect(session.exists).toBe(false); + const again = await session.apply(apply); + expect(again.kind).toBe('created'); + expect(applied[2]).not.toBe(applied[0]); + }); + + it('엔진이 이번 실행의 값을 약하다고 받지 않으면 새로 만들어 바꾼다(replaced) · 다른 실패면 값은 그대로', async () => { + const session = new MigrationPasswordSession(); + const applied: string[] = []; + await session.apply((password) => { + applied.push(password); + return Promise.resolve(); + }); + let refuseOld = true; + const replaced = await session.apply((password) => { + applied.push(password); + if (refuseOld) { + refuseOld = false; + return weak(); + } + return Promise.resolve(); + }); + expect(applied[1]).toBe(applied[0]); + expect(replaced).toEqual({ kind: 'replaced', password: applied[2] }); + expect(applied[2]).not.toBe(applied[0]); + + // 다른 실패 — 던지고 이번 실행의 값은 바뀌지 않는다 + await expect( + session.apply(() => + Promise.reject(new OpsEngineError('unavailable', 'timeout')), + ), + ).rejects.toMatchObject({ kind: 'unavailable' }); + const reused: string[] = []; + await expect( + session.apply((password) => { + reused.push(password); + return Promise.resolve(); + }), + ).resolves.toEqual({ kind: 'reused' }); + expect(reused).toEqual([applied[2]]); + }); + + it('이전용 비밀번호의 끝은 지금부터 30일 · 실행 기록 글은 끝나는 한국 날짜만(비밀 없음)', () => { + // 한국 2026-11-03 01:30 — 날짜가 UTC 와 다른 때 + const until = migrationUntilFrom(new Date('2026-11-02T16:30:00Z')); + expect(until.toISOString()).toBe('2026-12-02T16:30:00.000Z'); + expect(migrationAuditText(until, { kind: 'reused' })).toBe( + '이전용(이번 실행 공통 · ~2026-12-03)', + ); + expect( + migrationAuditText(until, { kind: 'replaced', password: 'Secret12' }), + ).toBe('이전용(이번 실행 공통 · 값 바꿈 · ~2026-12-03)'); + }); +}); + +describe('메일 이전 중 판정 (재리뷰 N1)', () => { + const NOW = new Date('2026-11-02T06:00:00.000Z'); + const DAY_MS = 24 * 60 * 60 * 1000; + const HONG = 'hong@arasystem.test'; + const KIM = 'kim@arasystem.test'; + const LEE = 'lee@arasystem.test'; + const at = (days: number) => + new Date(NOW.getTime() + days * DAY_MS).toISOString(); + const account = (passwordExpiresAt: string | null): EngineEmployee => ({ + id: 'acc-hong', + email: HONG, + name: '홍길동', + locked: false, + customPermissions: false, + passwordExpired: false, + passwordExpiresAt, + twoFactorEnabled: false, + appPasswords: 0, + usedBytes: null, + quotaBytes: null, + }); + + it('비밀번호를 거는 명령은 모두 판정에 든다 — 이전용은 이전용 입사 · 다시 만들기 둘', () => { + expect([...PASSWORD_SETTING_COMMANDS].sort()).toEqual( + [ + 'hire', + 'hire-migration', + 'migration-finish', + 'migration-password', + 'password-reset', + ].sort(), + ); + expect([...MIGRATION_PASSWORD_COMMANDS].sort()).toEqual( + ['hire-migration', 'migration-password'].sort(), + ); + expect(MIGRATION_WINDOW_DAYS).toBe(31); + }); + + it('판정에 쓰는 기록 — 비밀번호를 거는 명령의 성공(success · partial), 이전용 명령의 실패. 이전용이 아닌 명령의 실패 · 다른 명령은 아니다', () => { + for (const command of PASSWORD_SETTING_COMMANDS) { + expect(countsForMigration(command, 'success')).toBe(true); + expect(countsForMigration(command, 'partial')).toBe(true); + } + expect(countsForMigration('hire-migration', 'failure')).toBe(true); + expect(countsForMigration('migration-password', 'failure')).toBe(true); + expect(countsForMigration('hire', 'failure')).toBe(false); + expect(countsForMigration('password-reset', 'failure')).toBe(false); + expect(countsForMigration('migration-finish', 'failure')).toBe(false); + expect(countsForMigration('lock', 'success')).toBe(false); + }); + + it('실행 기록(새것부터) — 주소마다 판정에 쓰는 마지막 기록이 이전용 명령인 주소만', () => { + const rows = [ + // 새것부터 — HONG: 재설정 실패 · 잠금은 건너뛰고 partial 다시 만들기가 마지막 → 이전 중 + { target: HONG, command: 'password-reset', result: 'failure' }, + { target: HONG, command: 'lock', result: 'success' }, + { target: HONG, command: 'migration-password', result: 'partial' }, + { target: HONG, command: 'migration-finish', result: 'success' }, + // KIM: 마치기 실패는 건너뛰고 재설정 성공이 마지막 → 아니다(그 앞의 이전용 입사는 보지 않는다) + { target: KIM, command: 'migration-finish', result: 'failure' }, + { target: KIM, command: 'password-reset', result: 'success' }, + { target: KIM, command: 'hire-migration', result: 'success' }, + // LEE: 결과를 모르는 이전용 입사(실패)가 마지막 → 이전 중으로 센다(엔진에 걸렸을 수 있다 — 만료 조건이 가른다) + { target: LEE, command: 'hire-migration', result: 'failure' }, + { target: LEE, command: 'hire', result: 'success' }, + { target: null, command: 'support-contact', result: 'success' }, + ]; + expect(migrationAddressesOf(rows)).toEqual(new Set([HONG, LEE])); + expect( + migrationAddressesOf([ + { target: KIM, command: 'hire', result: 'success' }, + ]), + ).toEqual(new Set()); + }); + + it('이전 중 = 기록이 이전용 + 엔진의 만료가 미래(지금 + 31일 안)', () => { + expect(migrationUntilOf(account(at(30)), true, NOW)).toBe(at(30)); + expect(migrationUntilOf(account(at(31)), true, NOW)).toBe(at(31)); + // 기록이 이전용이 아니면 · 31일보다 멀면 · 지났거나 없으면 아니다 + expect(migrationUntilOf(account(at(30)), false, NOW)).toBeNull(); + expect(migrationUntilOf(account(at(31.01)), true, NOW)).toBeNull(); + expect(migrationUntilOf(account(at(-1)), true, NOW)).toBeNull(); + expect(migrationUntilOf(account(null), true, NOW)).toBeNull(); + }); + + it('여러 계정에 쿼리 하나로 판정을 붙이고, 실행 기록을 읽지 못하면 null', async () => { + const lookup = jest.fn().mockResolvedValue(new Set([HONG])); + const judged = await withMigrationState( + { lastPasswordSetIsMigration: lookup }, + [account(at(10)), { ...account(at(10)), id: 'acc-kim', email: KIM }], + NOW, + ); + expect(lookup).toHaveBeenCalledTimes(1); + expect(lookup).toHaveBeenCalledWith([HONG, KIM]); + expect(judged?.map((employee) => employee.migrationUntil)).toEqual([ + at(10), + null, + ]); + + const error = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + await expect( + withMigrationState( + { + lastPasswordSetIsMigration: jest + .fn() + .mockRejectedValue(new Error('connection lost')), + }, + [account(at(10))], + NOW, + ), + ).resolves.toBeNull(); + expect(error).toHaveBeenCalled(); + error.mockRestore(); + }); +}); + describe('운영 도구의 값 읽기 · 글', () => { it('주소 — @도메인 을 붙이고 소문자로, 다른 도메인 · 모양이 틀린 것은 null', () => { expect(toAddress('Hong.Gildong', 'arasystem.test')).toBe( diff --git a/backend/src/modules/ops/ops-prompts.ts b/backend/src/modules/ops/ops-prompts.ts index 22325e4..ba391da 100644 --- a/backend/src/modules/ops/ops-prompts.ts +++ b/backend/src/modules/ops/ops-prompts.ts @@ -7,7 +7,13 @@ import { type OpsEngine, } from './ops-engine.port'; import type { OpsIo } from './ops-io'; -import { failureDetail, failureText, toAddress } from './ops-text'; +import { + failureDetail, + failureText, + timeText, + toAddress, + type PasswordName, +} from './ops-text'; /** 운영 도구의 공통 묻기 · 보이기 — 명령들이 함께 쓴다 */ @@ -28,9 +34,12 @@ export function logUnknownFailure(error: unknown): void { } /** 실패 → 운영자에게 보일 글 — 모르는 오류는 오류 로그에도 남긴다 (명령은 failureText 대신 이것을 쓴다) */ -export function failureLine(error: unknown): string { +export function failureLine( + error: unknown, + passwordName?: PasswordName, +): string { logUnknownFailure(error); - return failureText(error); + return failureText(error, passwordName); } /** 구분선 */ @@ -70,6 +79,15 @@ export async function askEmployee( ): Promise { const email = await askAddress(io, domain, label); if (email === null) return null; + return lookUpEmployee(io, engine, email); +} + +/** 정식 주소로 직원 계정을 찾는다 — 직원 주소가 아님 · 직원 계정이 아님 · 엔진에 없음을 갈라 알리고 null (askEmployee 의 뒤쪽) */ +export async function lookUpEmployee( + io: OpsIo, + engine: OpsEngine, + email: string, +): Promise { if (!engine.isEmployeeAddress(email)) { io.print(NOT_EMPLOYEE_ADDRESS); return null; @@ -122,6 +140,70 @@ export function showTempPassword( io.print(RULE); } +/** + * 이번 실행의 이전용 비밀번호를 한 번만 보인다 — imapsync 에만 쓴다(직원에게 주지 않는다). 어디에도 남기지 않는다. + * `replaced` 면 엔진이 앞 값을 받지 않아 새로 만든 것이다 — 앞에서 건 계정들은 옛 값 그대로라고 알린다 + */ +export function showMigrationPassword( + io: OpsIo, + email: string, + password: string, + until: Date, + replaced = false, +): void { + io.print(''); + io.print(RULE); + io.print( + ' 이전용 비밀번호 — 지금 한 번만 보입니다. imapsync 의 비밀번호 파일(--passfile2)에만 넣고 직원에게 주지 마세요.', + ); + io.print(` 주소 ${email}`); + io.print(` 이전용 비밀번호 ${password}`); + io.print(` 쓸 수 있는 때 ${timeText(until)} 까지 (한국 시각)`); + if (replaced) { + io.print( + ' 엔진이 이번 실행의 앞 값을 받지 않아 새로 만들었습니다 — 앞에서 이전용 비밀번호를 건 계정은 옛 값 그대로입니다. 같은 실행에서 「다시 만들기」(빈 값)를 한 번 하면 모두 새 값으로 맞춰집니다.', + ); + } + io.print( + ' 이번 실행에서 옮겨 올 메일이 있는 계정을 더 만들거나 다시 만들면 모두 이 비밀번호를 씁니다 — imapsync 비밀번호 파일 하나로 모두 옮길 수 있습니다.', + ); + io.print( + ' 마지막 imapsync 뒤 「메일 이전 › 이전 마치기」로 직원용 임시 비밀번호를 만드세요.', + ); + io.print(RULE); +} + +/** 이번 실행의 이전용 비밀번호를 다시 걸었을 때 — 값은 다시 보이지 않는다 (엔진이 앞 값을 받지 않아 바꿨으면 바꾼 값) */ +export const MIGRATION_PASSWORD_REUSED = + '이번 실행의 이전용 비밀번호(이번 실행에서 마지막으로 보인 값)를 걸었습니다.'; + +/** 같은 실행 안에서는 이전용 비밀번호를 다시 보이지 않는다 — 잃었으면 새 실행에서 새 값으로 */ +export const MIGRATION_PASSWORD_LOST = + '잃었으면 이 도구를 끝내고 다시 시작한 뒤 「다시 만들기」(빈 값 = 모두)'; + +/** + * 메일 이전 중인 계정의 안내 — 비밀번호 재설정(잠금 풀기가 권하는 것 포함)은 하지 않는다: + * 이전용 비밀번호가 막혀 imapsync 가 끊기고, 이전 중 로그인 기록이 직원에게 남는다 + */ +export function migratingNotice(until: string): string { + return `메일 이전 중인 계정입니다(이전용 비밀번호 ~${timeText(new Date(until))}). 직원에게 비밀번호를 줄 때는 「메일 이전 › 이전 마치기」, 이전용 비밀번호를 잃었으면 이 도구를 끝내고 다시 시작한 뒤 「메일 이전 › 이전용 비밀번호 다시 만들기」(빈 값 = 모두)를 하세요.`; +} + +/** + * 실행 기록을 읽지 못해 이전 중인지 가르지 못했고, 엔진의 비밀번호 만료가 미래다 — 이전 중일 수 있어 비밀번호 재설정을 하지 않는다(보수적으로) + */ +export function unverifiedMigrationNotice(expiresAt: string): string { + return `메일 이전 중인지 확인하지 못했습니다 — 실행 기록을 읽지 못했고 비밀번호 만료가 미래(~${timeText(new Date(expiresAt))})라 이전 중일 수 있어 하지 않습니다. 잠시 뒤 다시 하세요(오류 로그 참고).`; +} + +/** 실행 기록을 읽지 못해 이전 중인 계정을 가를 수 없다 — 「메일 이전」은 멈춘다 */ +export const MIGRATION_RECORDS_UNREADABLE = + '실행 기록을 읽지 못해 메일 이전 중인 계정을 가를 수 없습니다 — 잠시 뒤 다시 하세요(오류 로그 참고).'; + +/** 엔진의 비밀번호 기본 만료가 켜져 있을 때 첫 화면의 알림 — 막지는 않는다(이전 중 판정은 실행 기록으로 가른다) */ +export const PASSWORD_DEFAULT_EXPIRY_NOTICE = + '주의: 엔진에 비밀번호 기본 만료가 켜져 있습니다 — 직원 비밀번호가 주기적으로 만료됩니다. 운영 도구의 이전 중 판정은 실행 기록으로 가르므로 영향이 없습니다.'; + /** 한 단계의 결과 줄 */ export function step(io: OpsIo, ok: boolean, text: string): void { io.print(` ${ok ? '[완료]' : '[못 함]'} ${text}`); diff --git a/backend/src/modules/ops/ops-text.ts b/backend/src/modules/ops/ops-text.ts index ffbe6b8..25338f3 100644 --- a/backend/src/modules/ops/ops-text.ts +++ b/backend/src/modules/ops/ops-text.ts @@ -57,6 +57,16 @@ export function bytesText(bytes: number | null): string { return bytes === null ? '—' : formatFileSize(bytes); } +/** 메일함 사용량 — 한도가 있으면 "사용 / 한도" */ +export function usageText( + usedBytes: number | null, + quotaBytes: number | null, +): string { + return quotaBytes === null + ? bytesText(usedBytes) + : `${bytesText(usedBytes)} / ${bytesText(quotaBytes)}`; +} + /** 한국 시각 — 없으면 "—" */ export function timeText(at: Date | null | undefined): string { return at ? formatKstDateTime(at) : '—'; @@ -81,15 +91,24 @@ export function isUnknownOutcome(error: unknown): boolean { return UNKNOWN_OUTCOME_DETAILS.has(detail) || /^http-5\d\d$/.test(detail); } -/** 실패 → 운영자에게 보일 글 (엔진의 설명 문장 · 비밀은 넣지 않는다 — 종류 · 코드만) */ -export function failureText(error: unknown): string { +/** 엔진에 거는 새 비밀번호의 이름 — 약하다는 거절 글에 쓴다 */ +export type PasswordName = '임시 비밀번호' | '이전용 비밀번호'; + +/** + * 실패 → 운영자에게 보일 글 (엔진의 설명 문장 · 비밀은 넣지 않는다 — 종류 · 코드만). + * `passwordName` 은 그 명령이 거는 비밀번호 — 약하다는 거절 글에만 쓴다(메일 이전은 '이전용 비밀번호') + */ +export function failureText( + error: unknown, + passwordName: PasswordName = '임시 비밀번호', +): string { if (isUnknownOutcome(error)) { return `메일 엔진의 답을 받지 못해 결과를 모릅니다 (${(error as OpsEngineError).detail ?? '까닭 모름'}) — 같은 명령을 다시 하세요.`; } if (error instanceof OpsEngineError) { switch (error.kind) { case 'weak-password': - return '엔진이 임시 비밀번호를 받지 않았습니다(강도). 다시 해 주세요.'; + return `엔진이 ${passwordName}를 받지 않았습니다(강도). 다시 해 주세요.`; case 'exists': return '같은 이름(주소)이 엔진에 이미 있습니다.'; case 'not-found': diff --git a/backend/src/modules/ops/ops.module.ts b/backend/src/modules/ops/ops.module.ts index 5f16e2a..4f0e07a 100644 --- a/backend/src/modules/ops/ops.module.ts +++ b/backend/src/modules/ops/ops.module.ts @@ -40,6 +40,8 @@ import { validateEnv } from '@/shared/config/env.validation'; import { AccountCommands } from './account-commands'; import { AdminCommands } from './admin-commands'; import { LockCommands } from './lock-commands'; +import { MigrationCommands } from './migration-commands'; +import { MigrationPasswordSession } from './migration-password'; import { OpsAuditLogEntity } from './ops-audit-log.entity'; import { OpsAuditService } from './ops-audit.service'; import { OpsCli } from './ops-cli'; @@ -132,9 +134,12 @@ const OPS_DB_POOL = { }, OpsDataService, OpsAuditService, + // 이번 실행의 이전용 비밀번호 — 이 프로세스의 메모리에만(한 실행 = 한 프로세스), 끝낼 때 OpsCli 가 지운다 + MigrationPasswordSession, AccountCommands, LockCommands, AdminCommands, + MigrationCommands, OpsCli, ], }) diff --git a/backend/src/modules/ops/temp-password.ts b/backend/src/modules/ops/temp-password.ts index 2682b32..d5ab42c 100644 --- a/backend/src/modules/ops/temp-password.ts +++ b/backend/src/modules/ops/temp-password.ts @@ -1,5 +1,7 @@ import { randomInt } from 'crypto'; +import { OpsEngineError } from './ops-engine.port'; + /** * 임시 비밀번호의 글자 — 헷갈리는 글자(0 · O · o · 1 · l · I)를 뺀 영문 대 · 소문자와 숫자. * 직원이 화면에서 옮겨 적는다. 영문 · 숫자만이라 셸 · 메신저에서 깨지지 않는다 @@ -9,9 +11,13 @@ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'; /** 길이 — 16자(약 93비트). 엔진의 강도 검사(passwordMinStrength)를 넉넉히 넘는다 (2026-11-02 개발 엔진에서 확인) */ export const TEMP_PASSWORD_LENGTH = 16; +/** 엔진이 새 비밀번호를 약하다고 받지 않을 때 새로 만들어 다시 하는 횟수 */ +const WEAK_PASSWORD_RETRIES = 2; + /** * 임시 비밀번호 (13단계 — 계약 6절 "운영 도구": 무작위 · 한 번만 보여 준다) — crypto 의 고른 난수. - * 대문자 · 소문자 · 숫자가 하나씩은 들어간다(옮겨 적을 때 섞인 것을 알아보기 쉽게). 저장하지 않고 로그에 남기지 않는다 + * 대문자 · 소문자 · 숫자가 하나씩은 들어간다(옮겨 적을 때 섞인 것을 알아보기 쉽게). 저장하지 않고 로그에 남기지 않는다. + * 메일 이전의 이전용 비밀번호도 이것으로 만든다 */ export function generateTempPassword( pick: (max: number) => number = randomInt, @@ -30,3 +36,28 @@ export function generateTempPassword( } } } + +/** 엔진이 비밀번호를 약하다고 받지 않았는가 */ +export function isWeakPasswordRefusal(error: unknown): boolean { + return error instanceof OpsEngineError && error.kind === 'weak-password'; +} + +/** + * 새 무작위 비밀번호로 `apply`(엔진에 걸기)를 한다 → 건 비밀번호. + * 엔진이 약하다고 받지 않으면 새로 만들어 `WEAK_PASSWORD_RETRIES` 번까지 다시 한다 — 그 밖의 실패는 그대로 던진다 + */ +export async function applyFreshPassword( + apply: (password: string) => Promise, +): Promise { + for (let attempt = 0; ; attempt += 1) { + const password = generateTempPassword(); + try { + await apply(password); + return password; + } catch (error) { + if (!isWeakPasswordRefusal(error) || attempt >= WEAK_PASSWORD_RETRIES) { + throw error; + } + } + } +}