From c0edff10d1fa697160051402a2718abe932b622a Mon Sep 17 00:00:00 2001 From: ttipo Date: Sun, 8 Nov 2026 00:49:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=95=B1=20=EC=83=88=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=95=8C=EB=A6=BC(FCM)=EA=B3=BC=20?= =?UTF-8?q?=EC=95=B1=20=EC=B5=9C=EC=86=8C=20=ED=8C=90=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 첫 판의 새 메일 알림을 서버가 보낸다. 알림 글은 "새 메일 N통"뿐이라 보낸 사람 · 제목 · 메일 id 는 Google 을 거치지 않는다. - 기기 등록: PUT · DELETE /api/push-registrations/current, 봤음 DELETE /api/push-registrations/current/unseen. 앱 토큰만 받고(AppClientGuard — 웹 세션은 AUTH_003), 세션마다 한 줄, 같은 FID 는 한 줄만 둔다 (같은 휴대폰에 다른 직원이 로그인하면 앞 직원의 줄을 지운다). 마이그레이션 AddPushRegistrations. - 새 메일 알아채기: 5단계 엔진 연결을 푸시를 켠 직원에게 붙잡아 둔다(MailEventsService.hold). 웹 실시간 알림과 같은 판정이고, 같은 직원은 30초에 한 번까지, N 은 보낼 때 엔진에서 센다(웹에서 읽은 메일은 빠진다). Stalwart 의 Email/query after 는 그 초를 넣지 않아 어댑터가 한 초 앞을 넘긴다(dev 엔진에서 확인). - 보내기는 새 의존성 없이 FCM HTTP v1 의 fid 대상으로(RS256 JWT · OAuth 토큰 기억 · 다시 하기 · 보내기 직전 다시 보기). - 직원이 그 휴대폰을 건드리지 않았는데 앱 세션이 끝나면(저절로 끝남 · 다른 곳에서 비밀번호 바꿈 등) 그 기기에 "새 메일 알림이 멈췄습니다"를 한 번 보낸다(SESSION_ENDED_NOTICE). - GET /api/app-config(로그인 없음 · no-store) — 최소 판과 알림을 쓸 수 있는지. - 서비스 계정 키 FCM_SERVICE_ACCOUNT_KEY(backend env — 없으면 기동하고 알림만 끈다), 퇴사 마무리에 알림 등록을 더한다. Co-Authored-By: Claude Opus 5.5 --- backend/.env.development.example | 8 + backend/.env.production.example | 8 + backend/src/app.module.ts | 6 + .../common/guards/app-client.guard.spec.ts | 49 ++ backend/src/common/guards/app-client.guard.ts | 35 + .../1794057659283-AddPushRegistrations.ts | 32 + .../app-config/app-config.constants.ts | 14 + .../app-config/app-config.controller.spec.ts | 26 + .../app-config/app-config.controller.ts | 38 + .../modules/app-config/app-config.module.ts | 14 + .../modules/app-config/dto/app-config.dto.ts | 35 + .../mail/jmap/jmap-mail.provider.spec.ts | 121 +++ .../modules/mail/jmap/jmap-mail.provider.ts | 83 ++ .../modules/mail/mail-events.service.spec.ts | 145 ++++ .../src/modules/mail/mail-events.service.ts | 155 +++- backend/src/modules/mail/mail.module.ts | 6 + backend/src/modules/mail/mail.provider.ts | 11 + backend/src/modules/mail/mail.service.spec.ts | 1 + .../mail/new-mail-push.service.spec.ts | 646 +++++++++++++++ .../src/modules/mail/new-mail-push.service.ts | 570 +++++++++++++ backend/src/modules/ops/lock-commands.ts | 1 + backend/src/modules/ops/ops-commands.spec.ts | 13 + backend/src/modules/ops/ops-data.service.ts | 12 +- .../push/configured-push-sender.spec.ts | 85 ++ .../modules/push/configured-push-sender.ts | 87 ++ .../push/dto/push-registration.dto.spec.ts | 78 ++ .../modules/push/dto/push-registration.dto.ts | 83 ++ backend/src/modules/push/fcm/fcm-auth.spec.ts | 278 +++++++ backend/src/modules/push/fcm/fcm-auth.ts | 200 +++++ .../modules/push/fcm/fcm-credentials.spec.ts | 133 ++++ .../src/modules/push/fcm/fcm-credentials.ts | 139 ++++ .../src/modules/push/fcm/fcm-message.spec.ts | 87 ++ backend/src/modules/push/fcm/fcm-message.ts | 90 +++ .../src/modules/push/fcm/fcm-sender.spec.ts | 512 ++++++++++++ backend/src/modules/push/fcm/fcm-sender.ts | 434 ++++++++++ backend/src/modules/push/fcm/fcm-timeout.ts | 38 + .../modules/push/fcm/fcm-transport.spec.ts | 155 ++++ backend/src/modules/push/fcm/fcm-transport.ts | 131 +++ backend/src/modules/push/fcm/fcm.constants.ts | 53 ++ .../modules/push/push-cleanup.service.spec.ts | 184 +++++ .../src/modules/push/push-cleanup.service.ts | 97 +++ .../modules/push/push-registration.entity.ts | 70 ++ .../push/push-registrations.controller.ts | 120 +++ .../push/push-registrations.repository.ts | 364 +++++++++ .../push/push-registrations.service.spec.ts | 190 +++++ .../push/push-registrations.service.ts | 143 ++++ backend/src/modules/push/push-sender.port.ts | 36 + backend/src/modules/push/push-text.ts | 23 + backend/src/modules/push/push.constants.ts | 74 ++ backend/src/modules/push/push.module.ts | 37 + .../src/shared/config/env.validation.spec.ts | 63 ++ backend/src/shared/config/env.validation.ts | 25 + backend/test/app.e2e-spec.ts | 747 +++++++++++++++++- backend/test/fcm-test-key.ts | 58 ++ 54 files changed, 6822 insertions(+), 21 deletions(-) create mode 100644 backend/src/common/guards/app-client.guard.spec.ts create mode 100644 backend/src/common/guards/app-client.guard.ts create mode 100644 backend/src/migrations/1794057659283-AddPushRegistrations.ts create mode 100644 backend/src/modules/app-config/app-config.constants.ts create mode 100644 backend/src/modules/app-config/app-config.controller.spec.ts create mode 100644 backend/src/modules/app-config/app-config.controller.ts create mode 100644 backend/src/modules/app-config/app-config.module.ts create mode 100644 backend/src/modules/app-config/dto/app-config.dto.ts create mode 100644 backend/src/modules/mail/new-mail-push.service.spec.ts create mode 100644 backend/src/modules/mail/new-mail-push.service.ts create mode 100644 backend/src/modules/push/configured-push-sender.spec.ts create mode 100644 backend/src/modules/push/configured-push-sender.ts create mode 100644 backend/src/modules/push/dto/push-registration.dto.spec.ts create mode 100644 backend/src/modules/push/dto/push-registration.dto.ts create mode 100644 backend/src/modules/push/fcm/fcm-auth.spec.ts create mode 100644 backend/src/modules/push/fcm/fcm-auth.ts create mode 100644 backend/src/modules/push/fcm/fcm-credentials.spec.ts create mode 100644 backend/src/modules/push/fcm/fcm-credentials.ts create mode 100644 backend/src/modules/push/fcm/fcm-message.spec.ts create mode 100644 backend/src/modules/push/fcm/fcm-message.ts create mode 100644 backend/src/modules/push/fcm/fcm-sender.spec.ts create mode 100644 backend/src/modules/push/fcm/fcm-sender.ts create mode 100644 backend/src/modules/push/fcm/fcm-timeout.ts create mode 100644 backend/src/modules/push/fcm/fcm-transport.spec.ts create mode 100644 backend/src/modules/push/fcm/fcm-transport.ts create mode 100644 backend/src/modules/push/fcm/fcm.constants.ts create mode 100644 backend/src/modules/push/push-cleanup.service.spec.ts create mode 100644 backend/src/modules/push/push-cleanup.service.ts create mode 100644 backend/src/modules/push/push-registration.entity.ts create mode 100644 backend/src/modules/push/push-registrations.controller.ts create mode 100644 backend/src/modules/push/push-registrations.repository.ts create mode 100644 backend/src/modules/push/push-registrations.service.spec.ts create mode 100644 backend/src/modules/push/push-registrations.service.ts create mode 100644 backend/src/modules/push/push-sender.port.ts create mode 100644 backend/src/modules/push/push-text.ts create mode 100644 backend/src/modules/push/push.constants.ts create mode 100644 backend/src/modules/push/push.module.ts create mode 100644 backend/test/fcm-test-key.ts diff --git a/backend/.env.development.example b/backend/.env.development.example index 2d7ea4d..f535c00 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -20,3 +20,11 @@ JWT_SECRET=change-me-generate-a-long-random-string # 세션 절대 만료(로그인 후 30일)는 코드 상수다. JWT_ACCESS_EXPIRES_IN=1h JWT_REFRESH_EXPIRES_IN=14d + +# 앱 새 메일 알림 (FCM — 앱 1단계, 선택) — Firebase 서비스 계정 키 JSON 파일을 통째로 base64 한 줄로 넣는다. +# 비우면 앱 알림만 끈다(기동은 한다 — app-config 의 pushAvailable: false). 실제 키 값은 이 예시 파일에 적지 않고 커밋하지 않는다. +# 알림 전용 서비스 계정(역할 "Firebase Cloud Messaging API Admin" 하나)의 키를 개발 · 운영 따로 만든다(하나만 막을 수 있게). +# 만드는 법: base64 -w0 key.json +# (PowerShell) [Convert]::ToBase64String([IO.File]::ReadAllBytes("key.json")) +# 모양이 틀리면 기동하지 않는다 (src/shared/config/env.validation.ts — 어느 칸이 틀렸는지만 알린다). 키가 새면 Google Cloud 콘솔에서 지우고 새 키로. +FCM_SERVICE_ACCOUNT_KEY= diff --git a/backend/.env.production.example b/backend/.env.production.example index c90c8ee..64ff349 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -20,3 +20,11 @@ JWT_SECRET=change-me-generate-a-long-random-string # 세션 절대 만료(로그인 후 30일)는 코드 상수다. JWT_ACCESS_EXPIRES_IN=1h JWT_REFRESH_EXPIRES_IN=14d + +# 앱 새 메일 알림 (FCM — 앱 1단계, 선택) — Firebase 서비스 계정 키 JSON 파일을 통째로 base64 한 줄로 넣는다. +# 비우면 앱 알림만 끈다(기동은 한다 — app-config 의 pushAvailable: false). 실제 키 값은 이 예시 파일에 적지 않고 커밋하지 않는다. +# 알림 전용 서비스 계정(역할 "Firebase Cloud Messaging API Admin" 하나)의 키를 개발 · 운영 따로 만든다(하나만 막을 수 있게). +# 만드는 법: base64 -w0 key.json +# (PowerShell) [Convert]::ToBase64String([IO.File]::ReadAllBytes("key.json")) +# 모양이 틀리면 기동하지 않는다 (src/shared/config/env.validation.ts — 어느 칸이 틀렸는지만 알린다). 키가 새면 Google Cloud 콘솔에서 지우고 새 키로. +FCM_SERVICE_ACCOUNT_KEY= diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 54c0ef6..fcc0aa9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,12 +9,14 @@ import { APP_GUARD } from '@nestjs/core'; import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard'; import { OriginGuard } from '@/common/guards/origin.guard'; import { AccountSecurityModule } from '@/modules/account-security/account-security.module'; +import { AppConfigModule } from '@/modules/app-config/app-config.module'; import { AuthModule } from '@/modules/auth/auth.module'; import { ContactsModule } from '@/modules/contacts/contacts.module'; import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module'; import { FileStorageModule } from '@/modules/file-storage/file-storage.module'; import { HealthModule } from '@/modules/health/health.module'; import { MailModule } from '@/modules/mail/mail.module'; +import { PushModule } from '@/modules/push/push.module'; import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module'; import { UploadModule } from '@/modules/upload/upload.module'; import { UserModule } from '@/modules/user/user.module'; @@ -80,6 +82,10 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 FileSharingModule, // 서버 설정 (13단계) — 2단계 인증 필수 정책 · 문의 연락처(GET /api/support-contact). 운영 도구가 바꾼다 SystemSettingsModule, + // 앱 알림 (앱 1단계) — 등록(/api/push-registrations/current) · 끝난 세션 정리 · 보내기(FCM). 새 메일을 알아채는 쪽은 메일 모듈이다 + PushModule, + // 앱 설정 (앱 1단계) — GET /api/app-config (로그인 없음 — 최소 판 · 알림을 보낼 수 있는지) + AppConfigModule, UploadModule, UserModule, ], diff --git a/backend/src/common/guards/app-client.guard.spec.ts b/backend/src/common/guards/app-client.guard.spec.ts new file mode 100644 index 0000000..d4a9416 --- /dev/null +++ b/backend/src/common/guards/app-client.guard.spec.ts @@ -0,0 +1,49 @@ +import type { ExecutionContext } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { BusinessException } from '@/common/errors/business.exception'; +import { AppClientGuard } from './app-client.guard'; + +const contextOf = (user: AuthenticatedUser | undefined): ExecutionContext => + ({ + switchToHttp: () => ({ getRequest: () => ({ user }) }), + }) as unknown as ExecutionContext; + +function codeOf(run: () => unknown): string | undefined { + try { + run(); + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } + return undefined; +} + +describe('AppClientGuard (앱 토큰만 받는 경로)', () => { + const guard = new AppClientGuard(); + const base = { email: 'ara.kim@arasystem.test', name: '김아라' }; + + it('앱 토큰(cli: app · 세션 id)이면 지난다', () => { + expect( + guard.canActivate( + contextOf({ ...base, client: 'app', sessionId: 'sid-1' }), + ), + ).toBe(true); + }); + + it('웹 쿠키 세션 · 세션 id 없는 토큰은 403 AUTH_003', () => { + expect( + codeOf(() => + guard.canActivate(contextOf({ ...base, sessionId: 'sid-1' })), + ), + ).toBe('AUTH_003'); + expect( + codeOf(() => guard.canActivate(contextOf({ ...base, client: 'app' }))), + ).toBe('AUTH_003'); + }); + + it('JwtAuthGuard 없이 걸어 사용자가 없으면 AUTH_001 — 조용히 지나가지 않는다', () => { + expect(codeOf(() => guard.canActivate(contextOf(undefined)))).toBe( + 'AUTH_001', + ); + }); +}); diff --git a/backend/src/common/guards/app-client.guard.ts b/backend/src/common/guards/app-client.guard.ts new file mode 100644 index 0000000..d2483b5 --- /dev/null +++ b/backend/src/common/guards/app-client.guard.ts @@ -0,0 +1,35 @@ +import { + type CanActivate, + type ExecutionContext, + Injectable, +} from '@nestjs/common'; +import type { Request } from 'express'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { BusinessException } from '@/common/errors/business.exception'; + +/** + * 앱 토큰만 받는 경로 (계약 1절 "앱 새 메일 알림 · 앱 설정") — `JwtAuthGuard` **뒤에** 건다: `@UseGuards(JwtAuthGuard, AppClientGuard)`. + * 앱 토큰(Bearer · 표시 cli: app · 세션 id)이 아니면 403 AUTH_003 — 토큰은 맞지만 이 경로를 쓸 수 없는 클라이언트(웹 쿠키 세션)다. + * 웹 화면이 부를 일이 없어 사용자가 볼 일도 없다. 제한된 세션(AUTH_010) · 토큰 없음(AUTH_001)은 앞의 JwtAuthGuard 가 먼저 막는다 + */ +@Injectable() +export class AppClientGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const user = context.switchToHttp().getRequest().user as + | AuthenticatedUser + | undefined; + if (!user) { + // JwtAuthGuard 없이 걸었다 — 조용히 통과하지 않게 + throw new BusinessException('AUTH_001', { + context: { reason: 'app-client-guard-without-jwt' }, + }); + } + if (user.client !== 'app' || !user.sessionId) { + throw new BusinessException('AUTH_003', { + context: { reason: 'app-client-required' }, + }); + } + return true; + } +} diff --git a/backend/src/migrations/1794057659283-AddPushRegistrations.ts b/backend/src/migrations/1794057659283-AddPushRegistrations.ts new file mode 100644 index 0000000..cfc6172 --- /dev/null +++ b/backend/src/migrations/1794057659283-AddPushRegistrations.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 앱 알림 등록 표 (docs/mail/api-contract.md 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계). + * - push_registrations: 앱 세션(sid)마다 한 줄 — 휴대폰 주소(FID) · 플랫폼 · 앱 판 · N 을 세는 시작(seen_at) · 마지막으로 보낸 때 · 만든 때 · 바꾼 때. + * 직원 주소 인덱스(직원마다 읽기 · 상한 · 퇴사 마무리), FID 유일 인덱스(같은 휴대폰은 한 줄에만). 세션 표와 외래 키로 묶지 않는다 + * 기존 표의 칸 · 행은 건드리지 않는다 — 표를 새로 만들기만 한다. + * (엔티티: modules/push/push-registration.entity.ts — migration:generate 의 결과(이 표의 세 문장뿐이었다)를 그대로 옮겼다) + */ +export class AddPushRegistrations1794057659283 implements MigrationInterface { + name = 'AddPushRegistrations1794057659283'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "push_registrations" ("session_id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "fid" character varying(64) NOT NULL, "platform" character varying(16) NOT NULL, "app_version" character varying(16) NOT NULL, "seen_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_pushed_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_6f35810b223ea06c6d65e6f4219" PRIMARY KEY ("session_id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_push_registrations_user_email" ON "push_registrations" ("user_email") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_push_registrations_fid" ON "push_registrations" ("fid") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."UQ_push_registrations_fid"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_push_registrations_user_email"`, + ); + await queryRunner.query(`DROP TABLE "push_registrations"`); + } +} diff --git a/backend/src/modules/app-config/app-config.constants.ts b/backend/src/modules/app-config/app-config.constants.ts new file mode 100644 index 0000000..3de73a7 --- /dev/null +++ b/backend/src/modules/app-config/app-config.constants.ts @@ -0,0 +1,14 @@ +/** + * 앱 설정 (계약 1절 "앱 새 메일 알림 · 앱 설정" — `GET /api/app-config`). + * + * 앱이 쓸 수 있는 가장 낮은 판 — 플랫폼마다. 이보다 낮은 앱은 어느 화면에서든 업데이트 화면을 보인다(앱 몫). + * 옛 앱이 쓸 수 없게 서버 API 를 바꾸는 배포가 이 값을 올린다 — **같은 커밋에서** 올린다(배포와 함께 움직이게 env 가 아니라 코드 상수다). + * ios 가 null 이면 아직 iOS 앱이 없다는 뜻이다(막지 않는다) + */ +export const MIN_SUPPORTED_APP_VERSION = { + android: '1.0.0', + ios: null, +} as const satisfies Readonly>; + +/** 앱 설정 응답의 캐시 — 공유 캐시 · 휴대폰에 남기지 않는다(판을 올린 배포가 바로 보이게) */ +export const APP_CONFIG_CACHE_CONTROL = 'no-store'; diff --git a/backend/src/modules/app-config/app-config.controller.spec.ts b/backend/src/modules/app-config/app-config.controller.spec.ts new file mode 100644 index 0000000..b8d1ab5 --- /dev/null +++ b/backend/src/modules/app-config/app-config.controller.spec.ts @@ -0,0 +1,26 @@ +import type { PushSender } from '@/modules/push/push-sender.port'; +import { AppConfigController } from './app-config.controller'; + +const senderOf = (available: boolean): PushSender => ({ + available, + send: () => Promise.resolve('failed'), +}); + +describe('AppConfigController (GET /api/app-config — 로그인 없음)', () => { + it('플랫폼마다 가장 낮은 판(ios 는 아직 null) · 알림을 보낼 수 있는지', () => { + expect(new AppConfigController(senderOf(true)).get()).toEqual({ + minSupportedVersion: { android: '1.0.0', ios: null }, + pushAvailable: true, + }); + expect(new AppConfigController(senderOf(false)).get().pushAvailable).toBe( + false, + ); + }); + + it('응답은 상수를 고치지 못하는 새 객체다', () => { + const controller = new AppConfigController(senderOf(true)); + const first = controller.get(); + (first.minSupportedVersion as { android: string }).android = '9.9.9'; + expect(controller.get().minSupportedVersion.android).toBe('1.0.0'); + }); +}); diff --git a/backend/src/modules/app-config/app-config.controller.ts b/backend/src/modules/app-config/app-config.controller.ts new file mode 100644 index 0000000..03864d4 --- /dev/null +++ b/backend/src/modules/app-config/app-config.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Header, Inject } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { ApiStandardResponse } from '@/common/decorators/api-standard-response.decorator'; +import { PUSH_SENDER, type PushSender } from '@/modules/push/push-sender.port'; +import { + APP_CONFIG_CACHE_CONTROL, + MIN_SUPPORTED_APP_VERSION, +} from './app-config.constants'; +import { AppConfigDto } from './dto/app-config.dto'; + +/** + * 앱 설정 (계약 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계) — 로그인 없이 받는다(로그인 화면 전에 본다). + * 가드가 없어 제한된 세션에서도 그대로 부른다. 요청 수는 전역 기본 한도(경로마다 IP 당 1분 100회 — 문의 연락처와 같다). + * 응답은 Cache-Control: no-store — 판을 올린 배포가 바로 보이게 + */ +@ApiTags('App') +@Controller('app-config') +export class AppConfigController { + constructor(@Inject(PUSH_SENDER) private readonly sender: PushSender) {} + + @Get() + @Header('Cache-Control', APP_CONFIG_CACHE_CONTROL) + @ApiOperation({ + summary: + '앱 설정 (로그인 없음) — 앱이 쓸 수 있는 가장 낮은 판 · 앱 알림을 보낼 수 있는지', + description: + 'minSupportedVersion 은 플랫폼마다(ios 는 아직 null — 막지 않는다). pushAvailable 은 서버가 FCM 키를 읽었는지다. ' + + '앱은 띄울 때 한 번 · 앞으로 올 때 1시간이 지났으면 부른다. 실패 · 모양 틀림이면 막지 않는다. 응답은 Cache-Control: no-store.', + }) + @ApiStandardResponse(AppConfigDto, { description: '앱 설정' }) + get(): AppConfigDto { + return { + minSupportedVersion: { ...MIN_SUPPORTED_APP_VERSION }, + pushAvailable: this.sender.available, + }; + } +} diff --git a/backend/src/modules/app-config/app-config.module.ts b/backend/src/modules/app-config/app-config.module.ts new file mode 100644 index 0000000..f02a612 --- /dev/null +++ b/backend/src/modules/app-config/app-config.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { PushModule } from '@/modules/push/push.module'; +import { AppConfigController } from './app-config.controller'; + +/** + * 앱 설정 모듈 (계약 1절 "앱 새 메일 알림 · 앱 설정" — `GET /api/app-config`) — 최소 판(코드 상수)과 앱 알림을 보낼 수 있는지(PUSH_SENDER). + * 이 모듈은 푸시 모듈만 가져온다 + */ +@Module({ + imports: [PushModule], + controllers: [AppConfigController], +}) +export class AppConfigModule {} diff --git a/backend/src/modules/app-config/dto/app-config.dto.ts b/backend/src/modules/app-config/dto/app-config.dto.ts new file mode 100644 index 0000000..2e06963 --- /dev/null +++ b/backend/src/modules/app-config/dto/app-config.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** 플랫폼마다 앱이 쓸 수 있는 가장 낮은 판 */ +export class MinSupportedVersionDto { + @ApiProperty({ + description: + '안드로이드 앱의 가장 낮은 판 — 앱은 판(major.minor.patch)을 칸마다 숫자로 견줘 이보다 낮으면 업데이트 화면을 보인다', + example: '1.0.0', + }) + android!: string; + + @ApiProperty({ + description: 'iOS 앱의 가장 낮은 판 — 아직 iOS 앱이 없어 null(막지 않는다)', + type: String, + nullable: true, + example: null, + }) + ios!: string | null; +} + +/** GET /api/app-config 의 응답 */ +export class AppConfigDto { + @ApiProperty({ + description: '플랫폼마다 앱이 쓸 수 있는 가장 낮은 판', + type: MinSupportedVersionDto, + }) + minSupportedVersion!: MinSupportedVersionDto; + + @ApiProperty({ + description: + '서버가 앱 알림을 보낼 수 있다(FCM 키가 있다). false 면 앱은 알림 칸을 "쓸 수 없음"으로 보인다', + example: true, + }) + pushAvailable!: boolean; +} diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 6578061..c48ce26 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -1943,6 +1943,127 @@ describe('JmapMailProvider', () => { }); }); + describe('앱 새 메일 알림의 N (countInboxUnreadSince)', () => { + const mailboxes: JmapInvocation[] = [ + ['Mailbox/get', { state: 'sb1', list: stalwartMailboxes }, 'm'], + ]; + + it('받은편지함을 찾은 뒤 시각마다 Email/query 하나(받은편지함 · $seen 아님 · $draft 아님 · after 는 그 초의 한 초 앞 — 엔진의 after 는 그 초를 넣지 않아서, 같은 초에 받은 메일도 세려고. limit 1 · calculateTotal) — 같은 차례로 total', async () => { + const { provider, recorded } = fakeClient([ + mailboxes, + [ + ['Email/query', { ids: ['m9'], total: 3 }, 'c0'], + ['Email/query', { ids: [], total: 0 }, 'c1'], + ], + ]); + + const counts = await provider.countInboxUnreadSince(ARA, [ + new Date('2026-11-07T04:00:00.750Z'), + new Date('2026-11-07T05:00:00.000Z'), + ]); + + expect(counts).toEqual([3, 0]); + expect(recorded).toHaveLength(2); + expect(recorded[0].calls[0][0]).toBe('Mailbox/get'); + expect(recorded[1].calls).toEqual([ + [ + 'Email/query', + { + accountId: 'd', + filter: { + operator: 'AND', + conditions: [ + { inMailbox: 'a' }, + { notKeyword: '$seen' }, + { notKeyword: '$draft' }, + { after: '2026-11-07T03:59:59Z' }, + ], + }, + limit: 1, + calculateTotal: true, + }, + 'c0', + ], + [ + 'Email/query', + expect.objectContaining({ + filter: expect.objectContaining({ + conditions: expect.arrayContaining([ + { after: '2026-11-07T04:59:59Z' }, + ]) as unknown, + }) as unknown, + }), + 'c1', + ], + ]); + }); + + it('시각이 많으면 한 요청에 8개씩 · 빈 배열이면 엔진에 묻지 않는다 · 받은편지함이 없으면 모두 0', async () => { + const answers = (count: number): JmapInvocation[] => + Array.from( + { length: count }, + (_, index): JmapInvocation => [ + 'Email/query', + { ids: [], total: index }, + `c${index}`, + ], + ); + const { provider, recorded } = fakeClient([ + mailboxes, + answers(8), + answers(2), + ]); + const since = Array.from( + { length: 10 }, + (_, index) => new Date(Date.UTC(2026, 10, 7, 5, index)), + ); + const counts = await provider.countInboxUnreadSince(ARA, since); + expect(counts).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 0, 1]); + expect(recorded.map(({ calls }) => calls.length)).toEqual([1, 8, 2]); + + const none = fakeClient([]); + await expect( + none.provider.countInboxUnreadSince(ARA, []), + ).resolves.toEqual([]); + expect(none.recorded).toHaveLength(0); + + const noInbox = fakeClient([ + [ + [ + 'Mailbox/get', + { list: stalwartMailboxes.filter(({ role }) => role !== 'inbox') }, + 'm', + ], + ], + ]); + await expect( + noInbox.provider.countInboxUnreadSince(ARA, since.slice(0, 2)), + ).resolves.toEqual([0, 0]); + expect(noInbox.recorded).toHaveLength(1); + }); + + it('total 을 주지 않으면 받은 id 수로(0 · 1) — 0 인지는 맞다 · 메서드 오류는 그 코드로 던진다', async () => { + const { provider } = fakeClient([ + mailboxes, + [ + ['Email/query', { ids: ['m1'] }, 'c0'], + ['Email/query', { ids: [] }, 'c1'], + ], + ]); + await expect( + provider.countInboxUnreadSince(ARA, [new Date(), new Date()]), + ).resolves.toEqual([1, 0]); + + const failed = fakeClient([ + mailboxes, + [['error', { type: 'serverFail' }, 'c0']], + ]); + expect( + await codeOf(failed.provider.countInboxUnreadSince(ARA, [new Date()])), + ).toBe('MAIL_001'); + }); + }); + describe('실시간 알림 (watchMail)', () => { /** 처음 상태 응답 — 모든 메일함 · 메일 상태 · 발신 ID */ const snapshotAnswer = ( diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index 1079181..c32e2e3 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -689,6 +689,36 @@ function findInbox(mailboxes: readonly JmapMailbox[]): JmapMailbox | undefined { return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === 'inbox'); } +/** + * 앱 새 메일 알림의 N 을 셀 때 한 요청에 넣는 Email/query 수 — 엔진의 한 요청 메서드 수 한도(Stalwart 기본 16)보다 넉넉히 적게. + * 한 직원의 기기는 10대까지고(등록 상한) 따라잡기는 기기마다 둘을 센다 + */ +const COUNT_QUERIES_PER_REQUEST = 8; + +/** 엔진의 받은 시각 단위 — 초 (Stalwart 는 receivedAt 을 초로 둔다) */ +const RECEIVED_AT_RESOLUTION_MS = 1_000; + +/** + * 받은편지함의 안 읽은 메일(초안 빼고) 가운데 그 시각의 초부터(같은 초 포함) 받은 것. + * 엔진의 after 는 그 초를 넣지 않는다(2026-11-08 확인 — countInboxUnreadSince) — 그 초의 한 초 앞을 넘긴다. + * ⚠️ 엔진이 RFC 대로 같은 초를 넣게 바뀌면 한 초 앞의 메일까지 세어, 따라잡기가 이미 알린 메일로 한 번 더 울린다 — 엔진 판을 올릴 때 다시 본다 + */ +function inboxUnreadSinceFilter(inboxId: string, since: Date): JmapEmailFilter { + const secondBefore = + Math.floor(since.getTime() / RECEIVED_AT_RESOLUTION_MS) * + RECEIVED_AT_RESOLUTION_MS - + RECEIVED_AT_RESOLUTION_MS; + return { + operator: 'AND', + conditions: [ + { inMailbox: inboxId }, + { notKeyword: '$seen' }, + { notKeyword: '$draft' }, + { after: toJmapUtcDate(new Date(secondBefore).toISOString()) }, + ], + }; +} + /** 실시간 알림의 상태 — 받은편지함이 없으면 안 읽은 수 0 */ function toLiveState( states: MailStatesRead, @@ -1474,6 +1504,59 @@ export class JmapMailProvider implements MailProvider { ); } + /** + * 앱 새 메일 알림의 N (계약 1절 "앱 새 메일 알림 · 앱 설정") — 받은편지함 찾기(Mailbox/get) 한 번 뒤 시각마다 Email/query + * (받은편지함 · `$seen` 아님 · `$draft` 아님 · 받은 시각 after, limit 1 · calculateTotal). 한 요청에 COUNT_QUERIES_PER_REQUEST 개씩. + * 세는 시작과 같은 초에 받은 메일도 센다(덜 세지 않게 — 포트의 약속). 엔진은 받은 시각을 초로 두고 after 는 **그 초를 넣지 않으므로** + * ("그 초보다 뒤" — RFC 8621 은 "같거나 뒤"라 적지만 Stalwart 0.16.27 은 아니다, 2026-11-08 개발 엔진에서 확인) 한 초 앞을 넘긴다(inboxUnreadSinceFilter). + * total 이 없으면 받은 id 수(0 · 1)로 — 적게 세더라도 0 인지는 맞다 + */ + async countInboxUnreadSince( + user: string, + since: readonly Date[], + ): Promise { + if (since.length === 0) return []; + const inbox = findInbox(await this.loadMailboxes(user)); + if (!inbox) return since.map(() => 0); + + const counts: number[] = []; + for ( + let start = 0; + start < since.length; + start += COUNT_QUERIES_PER_REQUEST + ) { + const chunk = since.slice(start, start + COUNT_QUERIES_PER_REQUEST); + const responses = await this.jmap.call(user, (accountId) => + chunk.map( + (at, index): JmapInvocation => [ + 'Email/query', + { + accountId, + filter: inboxUnreadSinceFilter(inbox.id, at), + limit: 1, + calculateTotal: true, + }, + `c${index}`, + ], + ), + ); + chunk.forEach((_, index) => { + const result = expectResult( + responses, + `c${index}`, + 'Email/query', + ); + const received = result.ids?.length ?? 0; + counts.push( + typeof result.total === 'number' && result.total >= received + ? result.total + : received, + ); + }); + } + return counts; + } + async listMessages( user: string, query: MessageListQuery, diff --git a/backend/src/modules/mail/mail-events.service.spec.ts b/backend/src/modules/mail/mail-events.service.spec.ts index b306bec..4b3254b 100644 --- a/backend/src/modules/mail/mail-events.service.spec.ts +++ b/backend/src/modules/mail/mail-events.service.spec.ts @@ -619,4 +619,149 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => { await connected(ARA); expect(fake.watchMail).toHaveBeenCalledTimes(2); }); + + describe('붙잡기 (앱 새 메일 알림 — hold)', () => { + /** 붙잡은 쪽 대역 — 받은 신호를 센다 */ + const holder = () => { + const signals: string[] = []; + return { + signals, + holder: { + newMail: () => signals.push('newMail'), + ended: (cause: string) => signals.push(`ended:${cause}`), + }, + }; + }; + + it('SSE 연결이 없어도 엔진 연결을 열어 두고 새 메일 신호를 받는다 — 보낸 사람 · 제목은 넘기지 않는다(신호뿐)', async () => { + const held = holder(); + await service.hold(ARA, held.holder); + expect(fake.watchMail).toHaveBeenCalledTimes(1); + + fake.opened[0].listener.newMail(NEW_MAIL); + expect(held.signals).toEqual(['newMail']); + + // 붙잡은 동안은 닫지 않는다 — 머무는 시간이 지나도 + await advance(MAIL_EVENTS_LINGER_MS * 3); + expect(fake.opened[0].close).not.toHaveBeenCalled(); + }); + + it('SSE 연결과 엔진 연결 하나를 함께 쓴다 — 새 메일은 둘 다 받고, 붙잡기는 연결 상한에 세지 않는다', async () => { + const held = holder(); + await service.hold(ARA, held.holder); + const connections: Client[] = []; + for (let i = 0; i < MAX_MAIL_EVENT_CONNECTIONS; i += 1) { + connections.push(await connected(ARA)); + } + expect(fake.watchMail).toHaveBeenCalledTimes(1); + + fake.opened[0].listener.newMail(NEW_MAIL); + expect(held.signals).toEqual(['newMail']); + expect(connections[0].events.at(-1)).toEqual({ + type: 'new-mail', + data: NEW_MAIL, + }); + }); + + it(`놓으면 남은 연결이 없을 때 ${MAIL_EVENTS_LINGER_MS / 1000}초 뒤 닫는다 — SSE 가 남아 있으면 그대로, 놓기는 몇 번 불러도 한 번`, async () => { + const release = await service.hold(ARA, holder().holder); + const sse = await connected(ARA); + release(); + release(); + await advance(MAIL_EVENTS_LINGER_MS); + expect(fake.opened[0].close).not.toHaveBeenCalled(); + + sse.leave(); + await advance(MAIL_EVENTS_LINGER_MS); + expect(fake.opened[0].close).toHaveBeenCalledTimes(1); + }); + + it('웹(SSE) 연결이 모두 떠나도 붙잡은 엔진 연결은 머무는 시간이 지나도 닫지 않고, 새 메일 신호가 그대로 온다 (2026-11-07 리뷰 M2)', async () => { + const held = holder(); + await service.hold(ARA, held.holder); + const first = await connected(ARA); + const second = await connected(ARA); + first.leave(); + second.leave(); + await advance(MAIL_EVENTS_LINGER_MS * 3); + + expect(fake.opened[0].close).not.toHaveBeenCalled(); + fake.opened[0].listener.newMail(NEW_MAIL); + expect(held.signals).toEqual(['newMail']); + // 다시 들어온 웹 연결도 같은 엔진 연결을 쓴다 + await connected(ARA); + expect(fake.watchMail).toHaveBeenCalledTimes(1); + }); + + it('서버 종료가 아닌데 허브가 닫히면 붙잡은 쪽에 ended(closed) 를 알린다(방어 — 붙잡은 줄로 알고 소리 없이 멈추지 않게), 서버 종료로 닫힐 때는 알리지 않는다', async () => { + const held = holder(); + await service.hold(ARA, held.holder); + // 지금 코드에는 이 길이 없다(머무는 시간은 붙잡은 쪽이 없을 때만 돈다) — 허브 닫기를 직접 불러 방어를 본다 + const internals = service as unknown as { + hubs: Map; + closeHub(hub: unknown): void; + }; + internals.closeHub(internals.hubs.get(ARA)); + expect(held.signals).toEqual(['ended:closed']); + expect(fake.opened[0].close).toHaveBeenCalledTimes(1); + + const atShutdown = holder(); + await service.hold(DOYUN, atShutdown.holder); + service.beforeApplicationShutdown(); + expect(atShutdown.signals).toEqual([]); + }); + + it('계정을 더 쓸 수 없어 끝나면(ended) 붙잡은 쪽에도 알린다 — 그 뒤 신호는 오지 않는다', async () => { + const held = holder(); + const release = await service.hold(ARA, held.holder); + fake.opened[0].listener.ended(); + expect(held.signals).toEqual(['ended:account-unusable']); + release(); + + await service.hold(ARA, holder().holder); + expect(fake.watchMail).toHaveBeenCalledTimes(2); + }); + + it('처음 엔진 연결이 실패하면 던지고 허브를 남기지 않는다 — 다음 붙잡기는 처음부터 다시 연다 · 서버 종료 중이면 MAIL_001', async () => { + fake.holdNext(); + const holding = service.hold(ARA, holder().holder); + await flush(); + fake.fail( + new BusinessException('AUTH_001', { context: { reason: 'gone' } }), + ); + await expect(holding).rejects.toMatchObject({ code: 'AUTH_001' }); + + await service.hold(ARA, holder().holder); + expect(fake.watchMail).toHaveBeenCalledTimes(2); + + service.beforeApplicationShutdown(); + await expect(service.hold(DOYUN, holder().holder)).rejects.toMatchObject({ + code: 'MAIL_001', + }); + }); + + it('붙잡은 쪽이 던져도 엔진 연결 · 다른 쪽의 흐름은 이어진다 (error 로그 — 가린 주소)', async () => { + const errors: string[] = []; + jest + .spyOn(Logger.prototype, 'error') + .mockImplementation((message: unknown) => { + errors.push(String(message)); + }); + await service.hold(ARA, { + newMail: () => { + throw new Error('boom'); + }, + ended: () => undefined, + }); + const held = holder(); + await service.hold(ARA, held.holder); + const sse = await connected(ARA); + + expect(() => fake.opened[0].listener.newMail(NEW_MAIL)).not.toThrow(); + expect(held.signals).toEqual(['newMail']); + expect(sse.events.at(-1)?.type).toBe('new-mail'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('a***@arasystem.test'); + }); + }); }); diff --git a/backend/src/modules/mail/mail-events.service.ts b/backend/src/modules/mail/mail-events.service.ts index 8a14ae0..5bfce8e 100644 --- a/backend/src/modules/mail/mail-events.service.ts +++ b/backend/src/modules/mail/mail-events.service.ts @@ -97,6 +97,23 @@ function expiryDelay(tokenExpiresAt: number | null): number { : remaining; } +/** + * 붙잡기가 끝난 까닭 — account-unusable: 계정을 더 쓸 수 없어 엔진 연결이 끝났다(금방 풀리지 않는다) · + * closed: 서버 종료가 아닌데 허브가 닫혔다(지금 코드에는 이 길이 없다 — 바뀌어도 붙잡은 쪽이 소리 없이 멈추지 않게 하는 방어) + */ +export type MailEventsHoldEnd = 'account-unusable' | 'closed'; + +/** + * 엔진 연결을 붙잡는 쪽 (앱 새 메일 알림 — 계약 1절 "앱 새 메일 알림 · 앱 설정") — SSE 연결이 없어도 그 직원의 엔진 연결을 열어 둔다. + * 받는 것은 신호뿐이다 — 새 메일의 보낸 사람 · 제목을 넘기지 않는다(붙잡는 쪽은 수를 엔진에 따로 센다). 어느 것도 던지지 않아야 한다 + */ +export interface MailEventsHolder { + /** 받은편지함에 알릴 새 메일이 왔다 — 웹의 `new-mail` 과 같은 판정(jmap/new-mail.ts 의 selectNewMail) */ + newMail(): void; + /** 붙잡기가 끝났다 — 다시 붙잡으려면 hold 를 새로 부른다. 서버 종료로 닫힐 때는 부르지 않는다 */ + ended(cause: MailEventsHoldEnd): void; +} + interface Client { readonly sink: MailEventSink; /** 이 연결의 로그인 세션 id — 모르면 null */ @@ -105,10 +122,20 @@ interface Client { removed: boolean; } -/** 한 직원의 엔진 연결과 그것을 나눠 쓰는 클라이언트 연결들 */ +/** 붙잡은 쪽 하나 */ +interface Holder { + readonly holder: MailEventsHolder; + /** hold 가 돌아갔다 — 그 전에 엔진 연결이 끝나면 ended 대신 hold 가 던진다(한 번만 알리게) */ + active: boolean; + removed: boolean; +} + +/** 한 직원의 엔진 연결과 그것을 나눠 쓰는 클라이언트 연결들 · 붙잡은 쪽들 */ interface Hub { readonly user: string; readonly clients: Set; + /** 엔진 연결을 붙잡은 쪽 — 연결 상한에 세지 않는다. 하나라도 있으면 클라이언트 연결이 없어도 닫지 않는다 */ + readonly holders: Set; /** 엔진 연결을 기다리는 연결 수 — 연결 상한에 함께 센다 */ pending: number; watch: MailWatch | null; @@ -137,9 +164,11 @@ interface Hub { * - 끊은 세션(로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊기 — 12단계)의 연결은 다음 ping 때 `end` 없이 닫는다 — * 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다 (계정을 더 쓸 수 없을 때와 같은 길 — 계약 2절 "실시간 알림") * - 열고 닫기는 로그에 남기지 않는다 (반복되는 정상 흐름) + * - 붙잡기(hold — 앱 새 메일 알림): 앱 알림을 켠 직원은 SSE 연결이 없어도 엔진 연결을 붙잡아 둔다. 엔진 연결은 SSE 와 함께 쓴다(직원마다 여전히 하나), + * 연결 상한에 세지 않는다. 붙잡은 쪽은 새 메일 신호와 끝남(ended)만 받는다. 놓으면 남은 연결이 없을 때 지금처럼 잠시 뒤 닫는다 * * ⚠️ 연결 상한 · 엔진 연결은 서버 인스턴스마다다 — 인스턴스를 늘려도 기능은 맞다(인스턴스마다 엔진 연결 하나), - * 다만 한 직원이 인스턴스마다 5개까지 열 수 있다. + * 다만 한 직원이 인스턴스마다 5개까지 열 수 있다. 붙잡기도 인스턴스마다다(붙잡는 쪽이 한 인스턴스만 붙잡게 한다 — nestjs 스킬의 Redis 목록). */ @Injectable() export class MailEventsService implements BeforeApplicationShutdown { @@ -186,14 +215,7 @@ export class MailEventsService implements BeforeApplicationShutdown { } catch (error) { hub.pending -= 1; // 엔진 연결을 열지 못했다 — 함께 기다린 연결이 모두 끝나면 허브를 버린다 (다음 연결이 처음부터 다시 연다) - if ( - !hub.watch && - !hub.starting && - hub.clients.size === 0 && - hub.pending === 0 - ) { - this.closeHub(hub); - } + this.closeIfUnused(hub); throw error; } hub.pending -= 1; @@ -236,6 +258,39 @@ export class MailEventsService implements BeforeApplicationShutdown { }); } + /** + * 엔진 연결을 붙잡는다 (앱 새 메일 알림) — SSE 연결이 없어도 그 직원의 엔진 연결을 열어 두고 새 메일 신호를 받는다 → 놓기 함수(몇 번 불러도 한 번). + * 엔진 연결은 SSE 와 함께 쓴다(이미 열렸으면 그대로 — 여는 중이면 함께 기다린다). 연결 상한에 세지 않는다. + * 처음 엔진 연결의 실패는 던진다(MAIL_001 · AUTH_001 · REQ_001 — 부른 쪽이 나중에 다시), 서버 종료 중이면 MAIL_001. + * 놓으면 남은 연결 · 붙잡은 쪽이 없을 때 지금처럼 MAIL_EVENTS_LINGER_MS 뒤 닫는다. 계정을 더 쓸 수 없어 끝나면 holder.ended() + */ + async hold(user: string, holder: MailEventsHolder): Promise<() => void> { + if (this.shuttingDown) throw this.shuttingDownFailure(); + const hub = this.hubFor(user); + const entry: Holder = { holder, active: false, removed: false }; + hub.holders.add(entry); + this.cancelLinger(hub); + try { + await this.ensureWatch(hub); + } catch (error) { + this.dropHolder(hub, entry); + throw error; + } + if (this.shuttingDown) { + this.dropHolder(hub, entry); + throw this.shuttingDownFailure(); + } + if (entry.removed || hub.closed || !hub.watch) { + // 기다리는 사이 엔진 연결이 끝났다 (계정을 더 쓸 수 없음 등) + this.dropHolder(hub, entry); + throw new BusinessException('MAIL_001', { + context: { reason: 'mail-watch-ended', user: maskEmail(user) }, + }); + } + entry.active = true; + return () => this.dropHolder(hub, entry); + } + /** * 한 직원의 모든 연결에 이벤트 하나를 보낸다 — 연결이 없으면 아무것도 하지 않는다(그 직원은 목록 상태로 안다). * 메일 엔진 밖의 소식(대용량 첨부의 위험 판정 `large-file` — 11단계)이 쓴다. 던지지 않는다 @@ -271,6 +326,7 @@ export class MailEventsService implements BeforeApplicationShutdown { const hub: Hub = { user, clients: new Set(), + holders: new Set(), pending: 0, watch: null, starting: null, @@ -309,17 +365,20 @@ export class MailEventsService implements BeforeApplicationShutdown { return hub.starting; } - /** 엔진 연결이 알리는 것을 그 직원의 모든 연결에 나눈다 */ + /** 엔진 연결이 알리는 것을 그 직원의 모든 연결 · 붙잡은 쪽에 나눈다 */ private listenerFor(hub: Hub): MailWatchListener { return { state: (live) => { hub.live = live; this.broadcast(hub, { type: 'state', data: live }); - // 메일이 바뀌었다 — 검색 색인도 맞춘다 (연결이 있는 동안만 오는 소식이다 — 없을 때는 5분 맞추기가 한다) + // 메일이 바뀌었다 — 검색 색인도 맞춘다 (연결이 있거나 붙잡힌 동안만 오는 소식이다 — 없을 때는 5분 맞추기가 한다) this.searchSync.requestSync(hub.user); }, - newMail: (notice) => - this.broadcast(hub, { type: 'new-mail', data: notice }), + newMail: (notice) => { + this.broadcast(hub, { type: 'new-mail', data: notice }); + // 붙잡은 쪽에는 신호만 — 보낸 사람 · 제목은 넘기지 않는다 + this.signalHolders(hub, 'newMail'); + }, upstream: (connected) => { if (hub.upstream === connected) return; hub.upstream = connected; @@ -327,14 +386,59 @@ export class MailEventsService implements BeforeApplicationShutdown { this.broadcast(hub, { type: 'ping', data: { upstream: connected } }); }, ended: () => { - // 계정을 더 쓸 수 없다 — 연결을 닫는다. 클라이언트가 다시 이으면 원인 코드(AUTH_001 등)를 받는다 + // 계정을 더 쓸 수 없다 — 연결을 닫는다. 클라이언트가 다시 이으면 원인 코드(AUTH_001 등)를 받는다. + // 붙잡은 쪽에도 끝났다고 알린다 — closeHub 가 한다(붙잡는 중이던 쪽은 hold 가 던진다) hub.watch = null; for (const client of [...hub.clients]) this.closeClient(hub, client); - this.closeHub(hub); + this.closeHub(hub, 'account-unusable'); }, }; } + /** 붙잡은 쪽에 새 메일 신호 — 던져도 엔진 연결 · 다른 쪽의 흐름을 끊지 않는다 */ + private signalHolders(hub: Hub, signal: 'newMail'): void { + for (const entry of [...hub.holders]) { + if (entry.active && !entry.removed) { + this.callHolder(hub, signal, () => entry.holder.newMail()); + } + } + } + + private callHolder(hub: Hub, signal: string, call: () => void): void { + try { + call(); + } catch (error) { + this.logger.error( + `[SYS_001] 엔진 연결을 붙잡은 쪽에 소식을 전하지 못했습니다: user=${maskEmail(hub.user)}, signal=${signal}`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + /** 붙잡은 쪽을 뺀다 (몇 번 불러도 한 번만) — 엔진 연결이 없으면 바로, 있으면 남은 연결이 없을 때 잠시 뒤 닫는다 */ + private dropHolder(hub: Hub, entry: Holder): void { + if (entry.removed) return; + entry.removed = true; + hub.holders.delete(entry); + if (!this.closeIfUnused(hub)) this.lingerIfIdle(hub); + } + + /** 엔진 연결도 · 여는 중도 · 쓰는 쪽도 없으면 허브를 버린다 → 버렸으면 true (다음에 쓰는 쪽이 처음부터 다시 연다) */ + private closeIfUnused(hub: Hub): boolean { + if ( + hub.closed || + hub.watch || + hub.starting || + hub.clients.size > 0 || + hub.pending > 0 || + hub.holders.size > 0 + ) { + return false; + } + this.closeHub(hub); + return true; + } + private broadcast(hub: Hub, event: MailEvent): void { for (const client of [...hub.clients]) this.send(hub, client, event); } @@ -389,6 +493,7 @@ export class MailEventsService implements BeforeApplicationShutdown { hub.closed || hub.clients.size > 0 || hub.pending > 0 || + hub.holders.size > 0 || hub.lingerTimer ) { return; @@ -429,8 +534,12 @@ export class MailEventsService implements BeforeApplicationShutdown { } } - /** 허브를 닫는다 — 엔진 연결 · 타이머를 정리하고 맵에서 뺀다 (남은 연결은 부른 쪽이 먼저 닫는다) */ - private closeHub(hub: Hub): void { + /** + * 허브를 닫는다 — 엔진 연결 · 타이머를 정리하고 맵에서 뺀다 (남은 연결은 부른 쪽이 먼저 닫는다). + * 남은 붙잡은 쪽은 빼고, 서버 종료가 아니면 붙잡기가 끝났다고 알린다(`cause` — 계정 문제가 아니면 closed). + * 붙잡는 중이던 쪽(hold 가 아직 돌아가지 않음)에는 알리지 않는다 — hold 가 던진다 + */ + private closeHub(hub: Hub, cause: MailEventsHoldEnd = 'closed'): void { if (hub.closed) return; hub.closed = true; this.cancelLinger(hub); @@ -439,5 +548,15 @@ export class MailEventsService implements BeforeApplicationShutdown { hub.watch?.close(); hub.watch = null; if (this.hubs.get(hub.user) === hub) this.hubs.delete(hub.user); + + const holders = [...hub.holders]; + hub.holders.clear(); + for (const entry of holders) entry.removed = true; + if (this.shuttingDown) return; + for (const entry of holders) { + if (entry.active) { + this.callHolder(hub, 'ended', () => entry.holder.ended(cause)); + } + } } } diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index d97c3ae..e2b6c7e 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -13,6 +13,7 @@ import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.mod import { MailSearchModule } from '@/modules/mail-search/mail-search.module'; import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module'; import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module'; +import { PushModule } from '@/modules/push/push.module'; import { SystemMailModule } from '@/modules/system-mail/system-mail.module'; import { isAllowedAttachment } from './attachment.policy'; import { DraftController } from './draft.controller'; @@ -58,6 +59,7 @@ import { } from './mailbox.controller'; import { MessageDeletionController } from './message-deletion.controller'; import { MessageListService } from './message-list.service'; +import { NewMailPushService } from './new-mail-push.service'; import { ConversationController } from './conversation.controller'; import { MailConversationService } from './conversation.service'; import { MessageController } from './message.controller'; @@ -134,6 +136,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { SystemMailModule, // 검색 색인 (14단계) — 실시간 소식이 오면 맞춘다. 이 모듈은 메일을 가져오지 않는다(엔진 연결 MailEngineModule 만) MailSearchModule, + // 앱 알림 (앱 1단계) — 등록 · 보내기(FCM). 이 모듈은 메일을 가져오지 않는다(메일 → 푸시 한 방향 — 사내 도메인만 MailEngineModule 에서) + PushModule, ], controllers: [ MailboxController, @@ -179,6 +183,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailConversationService, // 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown) MailEventsService, + // 앱 새 메일 알림 (앱 1단계) — 앱 알림을 켠 직원의 엔진 연결을 붙잡고, 새 메일이면 휴대폰에 "새 메일 N통" + NewMailPushService, // 대용량 첨부(11단계) — 발송의 덩이 · 링크, 임시 저장의 파일, 읽기의 덩이 알아보기, 위험 판정 알림(large-file) MailLargeFilesService, RecipientSuggestionService, diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index 76cc46a..ec33dcf 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -251,6 +251,17 @@ export interface MailProvider { */ watchMail(user: string, listener: MailWatchListener): Promise; + /** + * 앱 새 메일 알림의 N (계약 1절 "앱 새 메일 알림 · 앱 설정") — 받은편지함의 안 읽은 메일(초안 빼고) 가운데 시각마다 그 초부터(같은 초 포함 — 받은 시각은 초) 받은 수. + * 같은 초를 넣는지는 엔진마다 다르다 — 어댑터가 맞춘다(Stalwart 의 after 는 그 초를 넣지 않는다 — jmap-mail.provider.ts). + * 시각 여럿을 한꺼번에 센다(같은 차례로 돌려준다 — 기기마다 세는 시작이 다르다). 받은편지함이 없으면 모두 0, 빈 배열이면 엔진에 묻지 않는다. + * 엔진에 묻지 못하면 그 코드로 던진다(MAIL_001 · AUTH_001 · REQ_001) + */ + countInboxUnreadSince( + user: string, + since: readonly Date[], + ): Promise; + /** * 메일 목록 (받은 시각 내림차순). `mailboxId` 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (검색). * `text` 는 엔진의 낱말 검색 규칙에 맞춰 넓혀 찾는다 (한국어 조사 — 계약 2절 "검색"). 없는 메일함은 RES_001. diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index f55a7f1..5d44722 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -98,6 +98,7 @@ function fakeProvider(): ProviderMocks { mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024), getMailState: mockOf<'getMailState'>(), watchMail: mockOf<'watchMail'>(), + countInboxUnreadSince: mockOf<'countInboxUnreadSince'>(), listMessages: mockOf<'listMessages'>(), searchScope: mockOf<'searchScope'>(), conversationScope: mockOf<'conversationScope'>(), diff --git a/backend/src/modules/mail/new-mail-push.service.spec.ts b/backend/src/modules/mail/new-mail-push.service.spec.ts new file mode 100644 index 0000000..ed012c8 --- /dev/null +++ b/backend/src/modules/mail/new-mail-push.service.spec.ts @@ -0,0 +1,646 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { PushTarget } from '@/modules/push/push-registrations.repository'; +import type { PushRegistrationsService } from '@/modules/push/push-registrations.service'; +import type { + PushNotice, + PushSendOutcome, + PushSender, +} from '@/modules/push/push-sender.port'; +import type { + MailEventsHolder, + MailEventsService, +} from './mail-events.service'; +import type { MailProvider } from './mail.provider'; +import { + CATCH_UP_WINDOW_MS, + HOLD_RETRY_DELAYS_MS, + NEW_MAIL_PUSH_INTERVAL_MS, + NewMailPushService, +} from './new-mail-push.service'; + +const DOMAIN = 'arasystem.test'; +const ARA = `ara.kim@${DOMAIN}`; +const DOYUN = `doyun.lee@${DOMAIN}`; +const OUTSIDER = 'someone@e2e.arasystem.test'; +const NOW = Date.parse('2026-11-07T05:00:00Z'); + +const target = ( + sessionId: string, + overrides: Partial = {}, +): PushTarget => ({ + sessionId, + fid: `fid-${sessionId}`, + seenAt: new Date(NOW - 60 * 60_000), + lastPushedAt: null, + ...overrides, +}); + +async function flush(): Promise { + for (let round = 0; round < 10; round += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +/** 붙잡기 대역 — 붙잡은 쪽(holder)을 모으고, 열기를 붙잡거나 실패시킬 수 있다 */ +function fakeEvents() { + const holds: { user: string; holder: MailEventsHolder; released: boolean }[] = + []; + const failures = new Map(); + let gate: Promise | null = null; + const hold = jest.fn(async (user: string, holder: MailEventsHolder) => { + if (gate) await gate; + const queued = failures.get(user); + if (queued && queued.length > 0) throw queued.shift(); + const entry = { user, holder, released: false }; + holds.push(entry); + return () => { + entry.released = true; + }; + }); + return { + events: { hold } as unknown as MailEventsService, + hold, + holds, + /** 그 직원의 다음 붙잡기들을 이 실패로 */ + failNext: (user: string, ...errors: unknown[]) => { + failures.set(user, [...(failures.get(user) ?? []), ...errors]); + }, + /** 다음 붙잡기들을 문이 열릴 때까지 붙잡는다 */ + block: () => { + let open!: () => void; + gate = new Promise((resolve) => { + open = resolve; + }); + return () => { + gate = null; + open(); + }; + }, + active: (user: string) => + holds.filter((entry) => entry.user === user && !entry.released), + }; +} + +function setup( + options: { + available?: boolean; + users?: string[]; + targets?: Record; + } = {}, +) { + const fake = fakeEvents(); + let users = options.users ?? [ARA]; + const targets: Record = options.targets ?? { + [ARA]: [target('s1')], + }; + /** 엔진의 받은편지함 안 읽은 메일의 받은 시각 (직원마다 — 엔진처럼 초로 둔다) */ + const unread: Record = {}; + const counts: { user: string; since: number[] }[] = []; + const provider = { + // 포트처럼 — 받은 시각은 초, 세는 시작의 초부터 같은 초를 넣는다 (MailProvider.countInboxUnreadSince — 엔진의 after 는 그 초를 넣지 않아 어댑터가 한 초 앞을 넘긴다) + countInboxUnreadSince: jest.fn((user: string, since: readonly Date[]) => { + counts.push({ user, since: since.map((at) => at.getTime()) }); + return Promise.resolve( + since.map((at) => { + const after = Math.floor(at.getTime() / 1000) * 1000; + return (unread[user] ?? []).filter( + (time) => Math.floor(time / 1000) * 1000 >= after, + ).length; + }), + ); + }), + }; + let listener: ((user: string) => void) | null = null; + /** 보내기 직전 확인에서 이제 받을 자리가 아닌 (세션, FID) — 그 휴대폰이 다른 세션으로 옮겨 갔다 */ + const moved = new Set(); + const registrations = { + usersWithTargets: jest.fn(() => Promise.resolve(users)), + hasTargets: jest.fn((user: string) => + Promise.resolve((targets[user] ?? []).length > 0), + ), + targetsOf: jest.fn((user: string) => Promise.resolve(targets[user] ?? [])), + isDeliverable: jest.fn((sessionId: string, fid: string) => + Promise.resolve(!moved.has(`${sessionId}/${fid}`)), + ), + recordPushed: jest.fn(() => Promise.resolve()), + forgetDevice: jest.fn(() => Promise.resolve()), + onChanged: jest.fn((next: (user: string) => void) => { + listener = next; + return () => { + listener = null; + }; + }), + }; + const sent: { fid: string; notice: PushNotice }[] = []; + const outcomes = new Map(); + const sender: PushSender = { + available: options.available ?? true, + // 보내는 쪽처럼 — 보내기 직전에 부른 쪽의 확인을 본다 + send: async (fid, notice, sendOptions) => { + if (sendOptions?.stillValid && !(await sendOptions.stillValid())) { + return 'skipped'; + } + sent.push({ fid, notice }); + return outcomes.get(fid) ?? 'sent'; + }, + }; + const service = new NewMailPushService( + fake.events, + provider as unknown as MailProvider, + registrations as unknown as PushRegistrationsService, + sender, + { domain: DOMAIN }, + ); + return { + service, + fake, + provider, + registrations, + sent, + counts, + outcomes, + unread, + targets, + moved, + setUsers: (next: string[]) => { + users = next; + }, + changed: (user: string) => listener?.(user), + /** 그 직원의 붙잡은 쪽에 새 메일 신호 */ + newMail: (user: string) => { + for (const entry of fake.active(user)) entry.holder.newMail(); + }, + }; +} + +describe('NewMailPushService (앱 새 메일 알림 — 붙잡기 · 간격 · N · 따라잡기)', () => { + let logs: { level: string; text: string }[]; + let current: ReturnType | null; + + const advance = async (ms: number) => { + await jest.advanceTimersByTimeAsync(ms); + await flush(); + }; + + const start = async (options?: Parameters[0]) => { + current = setup(options); + current.service.onApplicationBootstrap(); + await flush(); + return current; + }; + + beforeEach(() => { + jest.useFakeTimers({ + now: NOW, + doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'], + }); + logs = []; + for (const level of ['error', 'warn', 'log', 'debug'] as const) { + jest + .spyOn(Logger.prototype, level) + .mockImplementation((...args: unknown[]) => { + logs.push({ level, text: String(args[0]) }); + }); + } + current = null; + }); + + afterEach(() => { + current?.service.beforeApplicationShutdown(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('서버가 뜨면 보낼 기기가 있는 사내 직원의 엔진 연결을 붙잡는다 — 사내 도메인 밖은 붙잡지 않는다', async () => { + const { fake, registrations } = await start({ + users: [ARA, DOYUN, OUTSIDER], + targets: { [ARA]: [target('s1')], [DOYUN]: [target('s2')] }, + }); + + expect(registrations.usersWithTargets).toHaveBeenCalledWith(`@${DOMAIN}`); + expect(fake.holds.map(({ user }) => user).sort()).toEqual([ARA, DOYUN]); + }); + + it('FCM 키가 없으면 아무도 붙잡지 않는다 — 엔진 연결을 늘리지 않는다', async () => { + const { fake, registrations } = await start({ available: false }); + expect(registrations.usersWithTargets).not.toHaveBeenCalled(); + expect(fake.hold).not.toHaveBeenCalled(); + }); + + it('함께 여는 엔진 연결은 4개까지 — 나머지는 앞의 것이 끝나면', async () => { + current = setup({ + users: ['a1', 'a2', 'a3', 'a4', 'a5', 'a6'].map( + (id) => `${id}@${DOMAIN}`, + ), + targets: {}, + }); + const open = current.fake.block(); + current.service.onApplicationBootstrap(); + await flush(); + expect(current.fake.hold).toHaveBeenCalledTimes(4); + + open(); + await flush(); + expect(current.fake.hold).toHaveBeenCalledTimes(6); + }); + + it('5분 맞추기 — 등록이 빠진 직원은 놓고, 새 직원은 붙잡는다', async () => { + const env = await start({ users: [ARA] }); + expect(env.fake.active(ARA)).toHaveLength(1); + + env.setUsers([DOYUN]); + await env.service.syncAll(); + await flush(); + expect(env.fake.active(ARA)).toHaveLength(0); + expect(env.fake.active(DOYUN)).toHaveLength(1); + }); + + it('등록이 바뀐 소식 — 그 직원만 다시 본다(기기가 생기면 붙잡고, 없어지면 놓는다)', async () => { + const env = await start({ users: [], targets: {} }); + env.targets[DOYUN] = [target('s2')]; + env.changed(DOYUN); + await flush(); + expect(env.fake.active(DOYUN)).toHaveLength(1); + + env.targets[DOYUN] = []; + env.changed(DOYUN); + await flush(); + expect(env.fake.active(DOYUN)).toHaveLength(0); + + env.changed(OUTSIDER); + await flush(); + expect(env.registrations.hasTargets).not.toHaveBeenCalledWith(OUTSIDER); + }); + + it('새 메일 신호 → 기기마다 N(그 기기에서 본 뒤 받은 안 읽은 메일)을 엔진에 세어 1 이상인 기기에만 "새 메일 N통" — 보낸 기기는 센 때를 적는다', async () => { + const seenPhone = NOW - 10 * 60_000; + const seenTablet = NOW - 1_000; + const env = await start({ + targets: { + [ARA]: [ + target('phone', { seenAt: new Date(seenPhone) }), + target('tablet', { seenAt: new Date(seenTablet) }), + ], + }, + }); + env.unread[ARA] = [NOW - 5 * 60_000, NOW - 2 * 60_000]; + + env.newMail(ARA); + await flush(); + + expect(env.counts.at(-1)).toEqual({ + user: ARA, + since: [seenPhone, seenTablet], + }); + expect(env.sent).toEqual([ + { fid: 'fid-phone', notice: { kind: 'new-mail', count: 2 } }, + ]); + expect(env.registrations.recordPushed).toHaveBeenCalledWith( + ['phone'], + new Date(NOW), + ); + // 보낸 것은 debug — 가린 주소 · 기기 수 · N + expect(logs.find((log) => log.level === 'debug')?.text).toBe( + '앱 알림: user=a***@arasystem.test, 기기 1, N 2', + ); + }); + + it('N 이 0 이면(그사이 웹에서 읽음) 보내지 않는다', async () => { + const env = await start(); + env.unread[ARA] = []; + env.newMail(ARA); + await flush(); + expect(env.sent).toEqual([]); + expect(env.registrations.recordPushed).not.toHaveBeenCalled(); + }); + + it(`같은 직원은 ${NEW_MAIL_PUSH_INTERVAL_MS / 1000}초에 한 번까지 — 첫 알림은 바로, 그사이 온 것은 끝에 그때의 N 으로 한 번`, async () => { + const env = await start(); + env.unread[ARA] = [NOW]; + env.newMail(ARA); + await flush(); + expect(env.sent.map(({ notice }) => notice)).toEqual([ + { kind: 'new-mail', count: 1 }, + ]); + + await advance(5_000); + env.unread[ARA].push(Date.now()); + env.newMail(ARA); + await advance(5_000); + env.unread[ARA].push(Date.now()); + env.newMail(ARA); + await flush(); + expect(env.sent).toHaveLength(1); + + await advance(NEW_MAIL_PUSH_INTERVAL_MS - 10_000 - 1); + expect(env.sent).toHaveLength(1); + await advance(1); + expect(env.sent.map(({ notice }) => notice)).toEqual([ + { kind: 'new-mail', count: 1 }, + { kind: 'new-mail', count: 3 }, + ]); + + // 간격이 지난 뒤의 신호는 다시 바로 + await advance(NEW_MAIL_PUSH_INTERVAL_MS); + env.newMail(ARA); + await flush(); + expect(env.sent).toHaveLength(3); + }); + + it('FCM 이 받지 않는 기기는 등록을 지운다(FID 가 그대로일 때만 — 지우는 쪽이 본다)', async () => { + const env = await start({ targets: { [ARA]: [target('s1')] } }); + env.unread[ARA] = [NOW]; + env.outcomes.set('fid-s1', 'unregistered'); + env.newMail(ARA); + await flush(); + expect(env.registrations.forgetDevice).toHaveBeenCalledWith('s1', 'fid-s1'); + expect(env.registrations.recordPushed).not.toHaveBeenCalled(); + }); + + it('보내는 알림은 종류와 개수뿐이다 — 붙잡은 쪽은 신호만 받으므로 메일의 보낸 사람 · 제목 · id 가 오지도 않는다', async () => { + const env = await start(); + env.unread[ARA] = [NOW]; + env.newMail(ARA); + await flush(); + expect(env.fake.holds[0].holder.newMail.length).toBe(0); + for (const { notice } of env.sent) { + expect(Object.keys(notice).sort()).toEqual(['count', 'kind']); + } + }); + + it(`붙잡을 때마다 따라잡는다 — 그 전 ${CATCH_UP_WINDOW_MS / 60_000}분 안 · 마지막으로 보낸 뒤 · 본 뒤에 온 메일이 있으면 그때의 N 으로 한 번 (이미 알린 메일은 다시 알리지 않는다)`, async () => { + const seenAt = NOW - 30 * 60_000; + const lastPushedAt = NOW - 3 * 60_000; + const targets = { + [ARA]: [ + target('fresh', { + seenAt: new Date(seenAt), + lastPushedAt: new Date(lastPushedAt), + }), + ], + }; + // 본 뒤 받은 안 읽은 메일 셋 — 그 가운데 마지막으로 보낸 뒤에 받은 것은 하나 + current = setup({ targets }); + current.unread[ARA] = [NOW - 20 * 60_000, NOW - 5 * 60_000, NOW - 60_000]; + current.service.onApplicationBootstrap(); + await flush(); + + // 마지막으로 보낸 때는 다음 초부터 — 그 초에 받은 메일은 그 알림에 이미 들었다 + expect(current.counts[0]).toEqual({ + user: ARA, + since: [lastPushedAt + 1_000, seenAt], + }); + expect(current.sent).toEqual([ + { fid: 'fid-fresh', notice: { kind: 'new-mail', count: 3 } }, + ]); + + // 마지막으로 보낸 뒤 · 10분 안에 온 메일이 없으면 보내지 않는다 + const quiet = setup({ + targets: { + [DOYUN]: [ + target('old', { + seenAt: new Date(seenAt), + lastPushedAt: new Date(NOW - 60_000), + }), + ], + }, + users: [DOYUN], + }); + quiet.unread[DOYUN] = [NOW - 20 * 60_000]; + quiet.service.onApplicationBootstrap(); + await flush(); + expect(quiet.sent).toEqual([]); + quiet.service.beforeApplicationShutdown(); + }); + + it(`붙잡기에 실패하면 ${HOLD_RETRY_DELAYS_MS[0] / 60_000}분 뒤 다시 — 이어서 실패하면 warn 한 번(코드 · 사유 · 가린 주소), 다시 되면 info 한 번`, async () => { + current = setup(); + const down = () => + new BusinessException('MAIL_001', { + context: { reason: 'engine-down' }, + }); + current.fake.failNext(ARA, down(), down()); + current.service.onApplicationBootstrap(); + await flush(); + expect(current.fake.hold).toHaveBeenCalledTimes(1); + expect(logs.filter((log) => log.level === 'warn')).toHaveLength(0); + + await advance(HOLD_RETRY_DELAYS_MS[0]); + expect(current.fake.hold).toHaveBeenCalledTimes(2); + const warns = logs.filter((log) => log.level === 'warn'); + expect(warns).toHaveLength(1); + expect(warns[0].text).toContain('[MAIL_001]'); + expect(warns[0].text).toContain('user=a***@arasystem.test'); + expect(warns[0].text).toContain('reason=MAIL_001/engine-down'); + + // 두 번째 실패 뒤에는 5분 — 5분 맞추기가 그 전에 와도 기다린다 + await advance(HOLD_RETRY_DELAYS_MS[1] - 1); + await current.service.syncAll(); + expect(current.fake.hold).toHaveBeenCalledTimes(2); + await advance(1); + expect(current.fake.active(ARA)).toHaveLength(1); + expect( + logs.filter((log) => log.level === 'log').map((log) => log.text), + ).toEqual([ + '앱 새 메일 알림 — 메일 엔진 연결을 다시 붙잡았습니다: user=a***@arasystem.test', + ]); + }); + + it('계정을 쓸 수 없으면(AUTH_001) 60분부터 — 처음 실패에 warn', async () => { + current = setup(); + current.fake.failNext( + ARA, + new BusinessException('AUTH_001', { + context: { reason: 'account-gone' }, + }), + ); + current.service.onApplicationBootstrap(); + await flush(); + expect(logs.filter((log) => log.level === 'warn')).toHaveLength(1); + + await advance(HOLD_RETRY_DELAYS_MS[3] - 1); + expect(current.fake.hold).toHaveBeenCalledTimes(1); + await advance(1); + expect(current.fake.hold).toHaveBeenCalledTimes(2); + }); + + it('붙잡은 엔진 연결이 끝나면(ended — 계정을 더 쓸 수 없음) 60분 뒤 다시 붙잡는다', async () => { + const env = await start(); + env.fake.holds[0].holder.ended('account-unusable'); + await flush(); + + await advance(HOLD_RETRY_DELAYS_MS[3] - 1); + expect(env.fake.hold).toHaveBeenCalledTimes(1); + await advance(1); + expect(env.fake.hold).toHaveBeenCalledTimes(2); + }); + + it(`허브가 서버 종료가 아닌데 닫혔다면(ended closed — 방어) ${HOLD_RETRY_DELAYS_MS[0] / 60_000}분 뒤 다시 붙잡는다 — 붙잡은 줄로 알고 건너뛰지 않는다`, async () => { + const env = await start(); + env.fake.holds[0].holder.ended('closed'); + await flush(); + // 5분 맞추기가 와도 기다림 안이면 건너뛰고, 1분 뒤 다시 붙잡는다 + await env.service.syncAll(); + expect(env.fake.hold).toHaveBeenCalledTimes(1); + await advance(HOLD_RETRY_DELAYS_MS[0]); + expect(env.fake.hold).toHaveBeenCalledTimes(2); + }); + + it('따라잡기는 마지막 알림에 든 메일(보낸 때와 같은 초에 받은 메일)을 다시 알리지 않는다 — 받은 시각은 초이고 세기는 같은 초를 넣는다 (2026-11-07 리뷰 M1)', async () => { + /** 그 메일이 들어온 초 · 그 메일의 신호로 N 을 센 때(같은 초 안 0.9초 뒤) */ + const received = NOW - 3 * 60_000; + const lastPushedAt = new Date(received + 900); + const seenAt = new Date(received - 30 * 60_000); + current = setup({ + targets: { [ARA]: [target('s1', { seenAt, lastPushedAt })] }, + }); + current.unread[ARA] = [received]; + current.service.onApplicationBootstrap(); + await flush(); + expect(current.fake.holds).toHaveLength(1); + expect(current.sent).toEqual([]); + + // 그 다음 초에 받은 메일은 따라잡는다 — 그때의 N(본 뒤의 안 읽은 메일 둘)으로 + const next = setup({ + targets: { [DOYUN]: [target('s2', { seenAt, lastPushedAt })] }, + users: [DOYUN], + }); + next.unread[DOYUN] = [received, received + 1_000]; + next.service.onApplicationBootstrap(); + await flush(); + expect(next.sent).toEqual([ + { fid: 'fid-s2', notice: { kind: 'new-mail', count: 2 } }, + ]); + next.service.beforeApplicationShutdown(); + }); + + it('따라잡기는 본 때(seen_at)와 같은 초에 받은 메일로 울리지 않는다 — 이미 본 메일이다 · 그 다음 초에 받은 메일은 따라잡는다 (리뷰 M1 과 같은 까닭)', async () => { + const received = NOW - 3 * 60_000; + const seenAt = new Date(received + 400); + current = setup({ targets: { [ARA]: [target('s1', { seenAt })] } }); + current.unread[ARA] = [received]; + current.service.onApplicationBootstrap(); + await flush(); + expect(current.fake.holds).toHaveLength(1); + expect(current.sent).toEqual([]); + + // N 은 본 때의 초부터 그대로 센다(덜 세지 않게) — 다음 초의 메일로 울리면 같은 초의 메일도 N 에 든다 + const next = setup({ + targets: { [DOYUN]: [target('s2', { seenAt })] }, + users: [DOYUN], + }); + next.unread[DOYUN] = [received, received + 1_000]; + next.service.onApplicationBootstrap(); + await flush(); + expect(next.sent).toEqual([ + { fid: 'fid-s2', notice: { kind: 'new-mail', count: 2 } }, + ]); + next.service.beforeApplicationShutdown(); + }); + + it('따라잡기도 간격에 든다 — 따라잡기로 보낸 바로 뒤의 새 메일은 간격 끝에 그때의 N 으로 (리뷰 L8)', async () => { + current = setup({ + targets: { + [ARA]: [target('s1', { seenAt: new Date(NOW - 30 * 60_000) })], + }, + }); + current.unread[ARA] = [NOW - 60_000]; + current.service.onApplicationBootstrap(); + await flush(); + expect(current.sent.map(({ notice }) => notice)).toEqual([ + { kind: 'new-mail', count: 1 }, + ]); + + current.unread[ARA].push(NOW); + current.newMail(ARA); + await flush(); + expect(current.sent).toHaveLength(1); + await advance(NEW_MAIL_PUSH_INTERVAL_MS); + expect(current.sent.map(({ notice }) => notice)).toEqual([ + { kind: 'new-mail', count: 1 }, + { kind: 'new-mail', count: 2 }, + ]); + }); + + it('보내기 직전 그 휴대폰이 다른 세션으로 옮겨 갔으면(isDeliverable false) 그 기기에는 보내지 않는다 — 보낸 때도 적지 않는다 (리뷰 L1)', async () => { + const env = await start({ + targets: { [ARA]: [target('phone'), target('moved')] }, + }); + env.unread[ARA] = [NOW]; + env.moved.add('moved/fid-moved'); + env.newMail(ARA); + await flush(); + + expect(env.sent.map(({ fid }) => fid)).toEqual(['fid-phone']); + expect(env.registrations.isDeliverable).toHaveBeenCalledWith( + 'moved', + 'fid-moved', + ); + expect(env.registrations.recordPushed).toHaveBeenCalledWith( + ['phone'], + new Date(NOW), + ); + expect(env.registrations.forgetDevice).not.toHaveBeenCalled(); + }); + + it('붙잡기가 끝나면 간격 끝에 보낼 차례도 지운다 — 끝난 직원에게 기다리던 보내기가 돌지 않는다', async () => { + const env = await start(); + env.unread[ARA] = [NOW]; + env.newMail(ARA); + await flush(); + env.newMail(ARA); + env.fake.holds[0].holder.ended('account-unusable'); + await advance(NEW_MAIL_PUSH_INTERVAL_MS * 2); + expect(env.sent).toHaveLength(1); + expect(env.provider.countInboxUnreadSince).toHaveBeenCalledTimes(2); + }); + + it('계정 문제로 60분을 기다리는 중에 등록이 바뀐 소식(다시 로그인 · 등록)이 오면 기다리지 않고 바로 붙잡아 본다', async () => { + current = setup(); + current.fake.failNext( + ARA, + new BusinessException('AUTH_001', { context: { reason: 'expired' } }), + ); + current.service.onApplicationBootstrap(); + await flush(); + expect(current.fake.hold).toHaveBeenCalledTimes(1); + + current.changed(ARA); + await flush(); + expect(current.fake.hold).toHaveBeenCalledTimes(2); + expect(current.fake.active(ARA)).toHaveLength(1); + // warn 한 번 뒤 다시 붙잡았다 — info 한 번 + expect(logs.filter((log) => log.level === 'log')).toHaveLength(1); + }); + + it('붙잡는 사이 등록이 빠졌으면 바로 놓는다', async () => { + current = setup(); + const open = current.fake.block(); + current.service.onApplicationBootstrap(); + await flush(); + + current.targets[ARA] = []; + current.changed(ARA); + await flush(); + open(); + await flush(); + expect(current.fake.holds).toHaveLength(1); + expect(current.fake.active(ARA)).toHaveLength(0); + }); + + it('서버 종료 — 붙잡은 연결을 놓고, 간격 끝에 보낼 차례 · 다시 해 볼 차례를 지운다', async () => { + const env = await start(); + env.unread[ARA] = [NOW]; + env.newMail(ARA); + await flush(); + env.newMail(ARA); + env.service.beforeApplicationShutdown(); + expect(env.fake.active(ARA)).toHaveLength(0); + + await advance(NEW_MAIL_PUSH_INTERVAL_MS * 2); + expect(env.sent).toHaveLength(1); + current = null; + }); +}); diff --git a/backend/src/modules/mail/new-mail-push.service.ts b/backend/src/modules/mail/new-mail-push.service.ts new file mode 100644 index 0000000..6c69454 --- /dev/null +++ b/backend/src/modules/mail/new-mail-push.service.ts @@ -0,0 +1,570 @@ +import { + type BeforeApplicationShutdown, + Inject, + Injectable, + Logger, + type OnApplicationBootstrap, +} from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { PushTarget } from '@/modules/push/push-registrations.repository'; +import { PushRegistrationsService } from '@/modules/push/push-registrations.service'; +import { PUSH_SENDER, type PushSender } from '@/modules/push/push-sender.port'; +import { ConcurrencyGate } from '@/shared/utils/concurrency-gate'; +import { KeyedQueue } from '@/shared/utils/keyed-queue'; +import { maskEmail } from '@/shared/utils/mask'; +import { NEW_MAIL_MAX_AGE_MS } from './jmap/new-mail'; +import { + MailEventsService, + type MailEventsHoldEnd, + type MailEventsHolder, +} from './mail-events.service'; +import { + AccountLoginDisabledException, + MAIL_PROVIDER, + type MailProvider, +} from './mail.provider'; +import { MAIL_SETTINGS, type MailSettings } from './mail.settings'; + +/** 같은 직원에게는 이 간격에 한 번까지 보낸다 — 첫 알림은 바로, 그사이 온 새 메일은 끝에 그때의 N 으로 한 번 */ +export const NEW_MAIL_PUSH_INTERVAL_MS = 30_000; +/** + * 붙잡기에 실패하면 다시 해 보는 간격 — 1 · 5 · 15 · 60분(끝 값을 계속 쓴다). + * 계정을 쓸 수 없음(AUTH_001 · 비밀번호 만료 · 잠김 · 엔진 연결이 끝남)은 60분부터 — 금방 풀리지 않는다 + */ +export const HOLD_RETRY_DELAYS_MS: readonly number[] = [ + 60_000, + 5 * 60_000, + 15 * 60_000, + 60 * 60_000, +]; +const ACCOUNT_PROBLEM_RETRY_STEP = HOLD_RETRY_DELAYS_MS.length - 1; +/** 이만큼 이어서 붙잡지 못하면 warn 한 번 (계정을 쓸 수 없음은 처음에) — 엔진 재시작 같은 잠깐의 실패는 남기지 않는다 */ +const HOLD_WARN_AFTER_FAILURES = 2; +/** 함께 여는 엔진 연결 수 — 서버가 뜰 때 직원 수만큼 엔진에 한꺼번에 몰리지 않게 */ +const HOLD_CONCURRENCY = 4; +/** + * 붙잡기 전에 온 메일을 따라잡는 범위 — 웹의 새 메일 규칙(jmap/new-mail.ts — 변화를 본 때보다 10분 넘게 앞서 받은 메일은 알리지 않는다)과 같다. + * 서버를 다시 띄운 사이 · 다시 붙잡기까지의 사이에 온 메일이 이 안에 있으면 한 번 알린다 + */ +export const CATCH_UP_WINDOW_MS = NEW_MAIL_MAX_AGE_MS; +/** 붙잡을 직원 맞추기 — 5분마다(분의 15초 — 다른 5분 차례와 겹치지 않게). e2e 는 이 이름으로 멈춘다 */ +export const NEW_MAIL_PUSH_HOLDS_JOB = 'new-mail-push-holds'; +const NEW_MAIL_PUSH_HOLDS_CRON = '15 */5 * * * *'; +/** 붙잡기 맞추기의 차례 열쇠 — 5분 맞추기와 직원 한 사람 맞추기가 섞이지 않게 한 줄에 선다 */ +const SYNC_QUEUE_KEY = 'holds'; + +/** 직원마다 보내기 간격 */ +interface Pacing { + lastFlushAt: number | null; + /** 간격 끝에 보낼 차례 */ + timer: NodeJS.Timeout | null; +} + +/** 붙잡기에 실패한 직원 — 다음 시도 */ +interface HoldRetry { + readonly failures: number; + readonly dueAt: number; + timer: NodeJS.Timeout | null; + /** warn 을 남겼다 — 다시 붙잡으면 info 한 번 */ + readonly warned: boolean; +} + +/** 로그에 남길 실패 요약 — 코드 · 사유 · 엔진 오류 종류만 (엔진 설명 문장 · 주소 · 메일 글은 넣지 않는다) */ +function failureSummary(error: unknown): string { + if (error instanceof BusinessException) { + const reason = error.context?.reason; + const jmapType = error.context?.jmapType; + return [ + error.code, + typeof reason === 'string' ? reason : null, + typeof jmapType === 'string' ? jmapType : null, + ] + .filter(Boolean) + .join('/'); + } + // DB 오류의 문장에는 값(주소 등)이 들어갈 수 있다 — 종류와 코드만 + const name = error instanceof Error ? error.name : typeof error; + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? `${name}/${code}` : name; +} + +/** 로그 줄 앞의 코드 — 실패의 코드, 모르면 SYS_001 */ +function codeOf(error: unknown): string { + return error instanceof BusinessException ? error.code : 'SYS_001'; +} + +/** 계정을 쓸 수 없는 실패 — 금방 풀리지 않는다 (계정 없음 · 비밀번호 만료 AUTH_001 · 잠김) */ +function isAccountProblem(error: unknown): boolean { + return ( + error instanceof AccountLoginDisabledException || + (error instanceof BusinessException && error.code === 'AUTH_001') + ); +} + +/** 엔진의 받은 시각 단위 — 초 (Stalwart 는 receivedAt 을 초로 둔다) */ +const RECEIVED_AT_RESOLUTION_MS = 1_000; + +/** + * 그 시각의 다음 초(ms) — 따라잡기가 "새로 온 것"을 볼 시작. 없으면 0. + * 세기는 그 초부터 같은 초를 넣으므로(MailProvider.countInboxUnreadSince — N 을 덜 세지 않게) 그대로면 그 초에 받은 메일 + * — 그 알림의 N 에 이미 들었거나(last_pushed_at) 이미 본(seen_at) — 이 "새로 온 것"으로 다시 세져 따라잡기가 한 번 더 울린다(2026-11-07 리뷰 M1). + * 센 · 본 뒤 같은 초에 들어온 메일은 붙잡은 연결이 따로 새 메일 신호를 냈으므로 놓치는 것은 그 1초 안에 서버가 멈춘 경우뿐이다 + */ +function nextSecondOf(time: Date | null): number { + if (time === null) return 0; + return ( + Math.floor(time.getTime() / RECEIVED_AT_RESOLUTION_MS) * + RECEIVED_AT_RESOLUTION_MS + + RECEIVED_AT_RESOLUTION_MS + ); +} + +/** 프로세스를 붙잡지 않는 타이머 — 종료를 늦추지 않게 */ +function timer(callback: () => void, ms: number): NodeJS.Timeout { + const handle = setTimeout(callback, ms); + handle.unref(); + return handle; +} + +/** + * 앱 새 메일 알림 (계약 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계) — 메일 → 푸시 방향만이다(푸시 모듈은 메일을 모른다). + * + * - **붙잡기**: 보낼 기기(살아 있는 · 제한 없는 앱 세션의 등록)가 있는 사내 직원의 엔진 연결을 붙잡아 둔다(MailEventsService.hold — 5단계 실시간 알림과 같은 연결 · + * 같은 새 메일 판정). 서버가 뜰 때 · 5분마다 · 등록이 바뀐 직원은 그때 맞춘다. 함께 여는 수는 HOLD_CONCURRENCY. 실패하면 HOLD_RETRY_DELAYS_MS 뒤 다시. + * FCM 키가 없으면 아무도 붙잡지 않는다(엔진 연결을 늘리지 않는다) + * - **보내기**: 새 메일 신호 → 같은 직원은 NEW_MAIL_PUSH_INTERVAL_MS 에 한 번까지(첫 알림은 바로, 그사이 온 것은 끝에 한 번) → 그때 보낼 기기마다 + * N(받은편지함의 안 읽은 메일 가운데 그 기기에서 마지막으로 앱을 본 뒤 받은 수 — 엔진에 센다. 웹에서 읽은 메일은 빠진다) → N 이 1 이상인 기기에 "새 메일 N통". + * FCM 이 받지 않는 기기는 등록을 지운다(FID 가 그대로일 때만). 같은 직원의 보내기는 차례로(KeyedQueue) + * - **따라잡기**: 새로 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 CATCH_UP_WINDOW_MS 안에 온 · 마지막으로 보낸 뒤 · 본 뒤의 안 읽은 메일이 있으면 + * 그 기기에 그때의 N 으로 한 번 — 이미 알린 메일은 다시 알리지 않는다(last_pushed_at) + * - 보내는 글에는 개수만 — 새 메일의 보낸 사람 · 제목 · id 는 이 서비스에 오지도 않는다(붙잡은 쪽은 신호만 받는다) + * 로그: 보낸 것은 debug(가린 주소 · 기기 수 · N), 붙잡기가 이어서 실패하면 warn 한 번 · 다시 되면 info 한 번. 메일 글 · FID · 토큰은 남기지 않는다 + * + * ⚠️ 붙잡기 · 간격 · 차례는 프로세스 메모리다 — 인스턴스마다 붙잡으면 같은 새 메일을 인스턴스 수만큼 보낸다(N 은 엔진에서 세므로 수는 같고 알림만 겹친다). + * 인스턴스를 늘리면 한 인스턴스만 붙잡게 한다 (nestjs 스킬의 Redis 목록) + */ +@Injectable() +export class NewMailPushService + implements OnApplicationBootstrap, BeforeApplicationShutdown +{ + private readonly logger = new Logger(NewMailPushService.name); + /** 붙잡은 직원 → 놓기 */ + private readonly held = new Map void>(); + /** 붙잡는 중인 직원 */ + private readonly holding = new Set(); + /** 붙잡을 직원 — 맞추기가 정한다 */ + private wanted = new Set(); + private readonly retries = new Map(); + private readonly pacing = new Map(); + private readonly holdGate = new ConcurrencyGate(HOLD_CONCURRENCY); + /** 같은 직원의 보내기 · 따라잡기는 차례로 */ + private readonly sends = new KeyedQueue(); + /** 붙잡기 맞추기(5분 · 한 사람)는 한 줄에 — 읽고 정하는 사이 서로의 결정을 덮지 않게 */ + private readonly syncs = new KeyedQueue(); + /** 붙잡는 주소의 끝 — `@사내 도메인` (설정이 없으면 null — 아무도 붙잡지 않는다) */ + private readonly addressSuffix: string | null; + private stopping = false; + private syncingAll = false; + private unsubscribe: (() => void) | null = null; + + constructor( + private readonly events: MailEventsService, + @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + private readonly registrations: PushRegistrationsService, + @Inject(PUSH_SENDER) private readonly sender: PushSender, + @Inject(MAIL_SETTINGS) settings: Pick, + ) { + const domain = settings.domain?.trim().toLowerCase(); + this.addressSuffix = domain ? `@${domain}` : null; + } + + /** + * 서버가 뜬 뒤 — 등록이 바뀐 소식을 듣고, 붙잡을 직원을 맞춘다(기다리지 않는다 — 엔진 연결은 뒤에서 연다). + * 등록이 바뀐 소식은 다시 해 볼 때를 기다리지 않는다 — 계정 문제로 60분 기다리던 중에 다시 로그인 · 등록했으면 바로 붙잡아 본다 + */ + onApplicationBootstrap(): void { + this.unsubscribe = this.registrations.onChanged((user) => { + void this.syncUser(user, { resetRetry: true }); + }); + void this.syncAll(); + } + + /** 붙잡을 직원을 모두 맞춘다 — 없는 직원은 붙잡고, 빠진 직원은 놓는다 (던지지 않는다) */ + @Cron(NEW_MAIL_PUSH_HOLDS_CRON, { name: NEW_MAIL_PUSH_HOLDS_JOB }) + async syncAll(): Promise { + if (this.stopping || this.syncingAll) return; + this.syncingAll = true; + try { + await this.syncs.run(SYNC_QUEUE_KEY, async () => { + const users = + this.sender.available && this.addressSuffix !== null + ? await this.registrations.usersWithTargets(this.addressSuffix) + : []; + if (this.stopping) return; + this.wanted = new Set(users.filter((user) => this.isCompany(user))); + for (const user of [...this.held.keys()]) { + if (!this.wanted.has(user)) this.release(user); + } + for (const user of [...this.retries.keys()]) { + if (!this.wanted.has(user)) this.clearRetry(user); + } + for (const user of this.wanted) this.ensureHeld(user); + }); + } catch (error) { + this.logger.error( + `[SYS_001] 앱 새 메일 알림 — 붙잡을 직원을 읽지 못했습니다: ${failureSummary(error)}`, + ); + } finally { + this.syncingAll = false; + } + } + + /** + * 한 직원만 맞춘다 — 등록이 바뀌었을 때 · 다시 해 볼 때 (던지지 않는다). + * `resetRetry` 면 붙잡기 실패의 기다림을 지우고 바로 해 본다(등록이 바뀐 소식 — 다시 로그인했을 수 있다) + */ + async syncUser( + user: string, + options: { readonly resetRetry?: boolean } = {}, + ): Promise { + if (this.stopping) return; + try { + await this.syncs.run(SYNC_QUEUE_KEY, async () => { + const want = + this.sender.available && + this.isCompany(user) && + (await this.registrations.hasTargets(user)); + if (this.stopping) return; + if (want) { + this.wanted.add(user); + if (options.resetRetry) this.allowRetryNow(user); + this.ensureHeld(user); + return; + } + this.wanted.delete(user); + this.release(user); + this.clearRetry(user); + }); + } catch (error) { + this.logger.error( + `[SYS_001] 앱 새 메일 알림 — 직원의 등록을 읽지 못했습니다: user=${maskEmail(user)}, ${failureSummary(error)}`, + ); + } + } + + /** 서버 종료 — 타이머를 멈추고 붙잡은 연결을 놓는다(엔진 연결은 MailEventsService 가 닫는다). 보내는 중인 요청은 보내는 쪽이 끊는다 */ + beforeApplicationShutdown(): void { + this.stopping = true; + this.unsubscribe?.(); + this.unsubscribe = null; + for (const pacing of this.pacing.values()) { + if (pacing.timer) clearTimeout(pacing.timer); + } + this.pacing.clear(); + for (const retry of this.retries.values()) { + if (retry.timer) clearTimeout(retry.timer); + } + this.retries.clear(); + for (const release of this.held.values()) release(); + this.held.clear(); + } + + private isCompany(user: string): boolean { + return ( + this.addressSuffix !== null && + user.trim().toLowerCase().endsWith(this.addressSuffix) + ); + } + + /** 붙잡혀 있지 않으면 붙잡기를 시작한다 — 다시 해 볼 때가 아니면 기다린다 */ + private ensureHeld(user: string): void { + if (this.stopping || this.held.has(user) || this.holding.has(user)) return; + const retry = this.retries.get(user); + if (retry && retry.dueAt > Date.now()) return; + this.holding.add(user); + void this.holdGate.run(async () => { + try { + await this.hold(user); + } catch (error) { + this.logger.error( + `[SYS_001] 앱 새 메일 알림 — 붙잡기를 마치지 못했습니다: user=${maskEmail(user)}, ${failureSummary(error)}`, + ); + } finally { + this.holding.delete(user); + } + }); + } + + private async hold(user: string): Promise { + let release: () => void; + try { + release = await this.events.hold(user, this.holderFor(user)); + } catch (error) { + if (!this.stopping && this.wanted.has(user)) { + this.scheduleRetry(user, error); + } + return; + } + if (this.stopping || !this.wanted.has(user)) { + // 붙잡는 사이 등록이 빠졌다 · 서버가 닫힌다 + release(); + return; + } + this.held.set(user, release); + this.recovered(user); + // 붙잡기 전에 온 메일 — 서버를 다시 띄운 사이 · 다시 붙잡기까지의 사이 + void this.sends.run(user, () => this.catchUp(user)); + } + + /** 붙잡은 연결의 신호 — 새 메일 · 끝남 */ + private holderFor(user: string): MailEventsHolder { + return { + newMail: () => this.onNewMail(user), + ended: (cause) => this.onHoldEnded(user, cause), + }; + } + + /** + * 붙잡기가 끝났다 — 간격 끝에 보낼 차례도 지우고 다시 해 본다: 계정을 더 쓸 수 없음은 60분 뒤, + * 그 밖(서버 종료가 아닌데 허브가 닫힘 — 방어)은 처음 실패처럼 1분 뒤 + */ + private onHoldEnded(user: string, cause: MailEventsHoldEnd): void { + this.held.delete(user); + this.clearPacing(user); + if (this.stopping || !this.wanted.has(user)) return; + this.scheduleRetry( + user, + cause === 'account-unusable' + ? new BusinessException('AUTH_001', { + context: { reason: 'mail-watch-ended' }, + }) + : new BusinessException('MAIL_001', { + context: { reason: 'mail-watch-closed' }, + }), + ); + } + + /** 놓는다 — 간격 끝에 보낼 차례도 지운다 */ + private release(user: string): void { + const release = this.held.get(user); + this.held.delete(user); + release?.(); + this.clearPacing(user); + } + + private clearPacing(user: string): void { + const pacing = this.pacing.get(user); + if (pacing?.timer) clearTimeout(pacing.timer); + this.pacing.delete(user); + } + + private scheduleRetry(user: string, error: unknown): void { + const previous = this.retries.get(user); + if (previous?.timer) clearTimeout(previous.timer); + const failures = (previous?.failures ?? 0) + 1; + const account = isAccountProblem(error); + const step = Math.min( + HOLD_RETRY_DELAYS_MS.length - 1, + (account ? ACCOUNT_PROBLEM_RETRY_STEP : 0) + failures - 1, + ); + const delay = HOLD_RETRY_DELAYS_MS[step]; + const warn = + previous?.warned !== true && + (account || failures >= HOLD_WARN_AFTER_FAILURES); + const entry: HoldRetry = { + failures, + dueAt: Date.now() + delay, + warned: previous?.warned === true || warn, + timer: timer(() => { + entry.timer = null; + void this.syncUser(user); + }, delay), + }; + this.retries.set(user, entry); + if (warn) { + this.logger.warn( + `[${codeOf(error)}] 앱 새 메일 알림 — 메일 엔진 연결을 붙잡지 못했습니다: user=${maskEmail(user)}, reason=${failureSummary(error)}, failures=${failures} — ${Math.round(delay / 60_000)}분 뒤 다시 합니다`, + ); + } + } + + /** 붙잡았다 — 실패 기록을 지운다(warn 을 남겼으면 info 한 번) */ + private recovered(user: string): void { + const retry = this.retries.get(user); + if (!retry) return; + this.clearRetry(user); + if (retry.warned) { + this.logger.log( + `앱 새 메일 알림 — 메일 엔진 연결을 다시 붙잡았습니다: user=${maskEmail(user)}`, + ); + } + } + + private clearRetry(user: string): void { + const retry = this.retries.get(user); + if (retry?.timer) clearTimeout(retry.timer); + this.retries.delete(user); + } + + /** 기다림만 지운다 — 실패 수 · warn 여부는 남긴다(다시 실패하면 warn 을 겹쳐 남기지 않고, 붙잡으면 info 한 번) */ + private allowRetryNow(user: string): void { + const retry = this.retries.get(user); + if (!retry) return; + if (retry.timer) clearTimeout(retry.timer); + this.retries.set(user, { ...retry, dueAt: 0, timer: null }); + } + + private pacingOf(user: string): Pacing { + let pacing = this.pacing.get(user); + if (!pacing) { + pacing = { lastFlushAt: null, timer: null }; + this.pacing.set(user, pacing); + } + return pacing; + } + + /** 새 메일 신호 — 첫 알림은 바로, 간격 안이면 끝에 한 번 (이미 기다리는 차례가 있으면 그것이 그때의 N 으로 보낸다) */ + private onNewMail(user: string): void { + if (this.stopping) return; + const pacing = this.pacingOf(user); + if (pacing.timer) return; + const wait = + pacing.lastFlushAt === null + ? 0 + : pacing.lastFlushAt + NEW_MAIL_PUSH_INTERVAL_MS - Date.now(); + if (wait <= 0) { + this.flush(user, pacing); + return; + } + pacing.timer = timer(() => { + pacing.timer = null; + if (!this.stopping) this.flush(user, pacing); + }, wait); + } + + private flush(user: string, pacing: Pacing): void { + pacing.lastFlushAt = Date.now(); + void this.sends.run(user, () => this.pushNewMail(user)); + } + + /** 보낼 기기마다 N 을 세어 보낸다 (던지지 않는다) */ + private async pushNewMail(user: string): Promise { + try { + const targets = await this.registrations.targetsOf(user); + if (targets.length === 0) return; + const countedAt = new Date(); + const counts = await this.provider.countInboxUnreadSince( + user, + targets.map((target) => target.seenAt), + ); + await this.deliver(user, targets, counts, countedAt); + } catch (error) { + this.reportFailure(user, error); + } + } + + /** + * 따라잡기 — 그 전 CATCH_UP_WINDOW_MS 안에 온 · 마지막으로 보낸 뒤 · 본 뒤의 안 읽은 메일이 있는 기기에 그때의 N 으로 한 번 (던지지 않는다). + * 엔진에는 기기마다 두 시각(새로 온 것을 볼 시작 · N 의 시작)을 한 번에 묻는다(같은 시각은 한 번만). + * **새로 온 것은 본 때 · 마지막으로 보낸 때의 다음 초부터 본다**(nextSecondOf) — 세기는 같은 초를 넣으므로, 그대로면 그 초에 받은 + * (이미 보았거나 그 알림의 N 에 이미 든) 메일이 "새로 온 것"으로 다시 세진다(2026-11-07 리뷰 M1). N 은 본 때의 초부터 그대로 센다(덜 세지 않게) + */ + private async catchUp(user: string): Promise { + try { + const targets = await this.registrations.targetsOf(user); + if (targets.length === 0) return; + const countedAt = new Date(); + const windowStart = countedAt.getTime() - CATCH_UP_WINDOW_MS; + const freshSince = targets.map((target) => + Math.max( + nextSecondOf(target.seenAt), + nextSecondOf(target.lastPushedAt), + windowStart, + ), + ); + const times = [ + ...new Set([ + ...freshSince, + ...targets.map((target) => target.seenAt.getTime()), + ]), + ]; + const counted = await this.provider.countInboxUnreadSince( + user, + times.map((time) => new Date(time)), + ); + const countAt = new Map( + times.map((time, index) => [time, counted[index] ?? 0]), + ); + const counts = targets.map((target, index) => + (countAt.get(freshSince[index]) ?? 0) > 0 + ? (countAt.get(target.seenAt.getTime()) ?? 0) + : 0, + ); + if (counts.every((count) => count < 1)) return; + // 따라잡기도 간격에 든다 — 바로 뒤의 새 메일은 간격 끝에 한 번 + this.pacingOf(user).lastFlushAt = Date.now(); + await this.deliver(user, targets, counts, countedAt); + } catch (error) { + this.reportFailure(user, error); + } + } + + /** + * N 이 1 이상인 기기에 보낸다 — 보낸 기기는 그 N 을 센 때를 적고, 받지 않는 기기는 등록을 지운다. + * 보내기 직전(다시 하기마다) 그 세션의 등록이 아직 그 FID 이고 세션이 살아 있는지 다시 본다 — 그사이 그 휴대폰이 다른 직원에게 옮겨 갔으면 보내지 않는다 + */ + private async deliver( + user: string, + targets: readonly PushTarget[], + counts: readonly number[], + countedAt: Date, + ): Promise { + const pushed: string[] = []; + const pushedCounts: number[] = []; + await Promise.all( + targets.map(async (target, index) => { + const count = counts[index] ?? 0; + if (count < 1) return; + const outcome = await this.sender.send( + target.fid, + { kind: 'new-mail', count }, + { + stillValid: () => + this.registrations.isDeliverable(target.sessionId, target.fid), + }, + ); + if (outcome === 'sent') { + pushed.push(target.sessionId); + pushedCounts.push(count); + return; + } + if (outcome === 'unregistered') { + await this.forget(user, target); + } + }), + ); + if (pushed.length === 0) return; + await this.registrations.recordPushed(pushed, countedAt); + this.logger.debug( + `앱 알림: user=${maskEmail(user)}, 기기 ${pushed.length}, N ${pushedCounts.join('·')}`, + ); + } + + private async forget(user: string, target: PushTarget): Promise { + try { + await this.registrations.forgetDevice(target.sessionId, target.fid); + } catch (error) { + this.reportFailure(user, error); + } + } + + /** 보내지 못함 — warn (서버가 닫히는 중이면 남기지 않는다 — 그때의 실패는 종료 때문이다) */ + private reportFailure(user: string, error: unknown): void { + if (this.stopping) return; + this.logger.warn( + `[${codeOf(error)}] 앱 새 메일 알림을 보내지 못했습니다: user=${maskEmail(user)}, reason=${failureSummary(error)}`, + ); + } +} diff --git a/backend/src/modules/ops/lock-commands.ts b/backend/src/modules/ops/lock-commands.ts index cca3993..29ec90c 100644 --- a/backend/src/modules/ops/lock-commands.ts +++ b/backend/src/modules/ops/lock-commands.ts @@ -54,6 +54,7 @@ const ROW_LABELS: Record = { shareLinks: '대용량 첨부 링크', searchStates: '검색 색인 상태', searchMessages: '검색 색인(메일)', + pushRegistrations: '앱 알림 등록', }; /** 남은 줄을 보이는 글 */ diff --git a/backend/src/modules/ops/ops-commands.spec.ts b/backend/src/modules/ops/ops-commands.spec.ts index e2e6230..3e14bc5 100644 --- a/backend/src/modules/ops/ops-commands.spec.ts +++ b/backend/src/modules/ops/ops-commands.spec.ts @@ -57,6 +57,7 @@ const NO_ROWS: AddressRows = { shareLinks: 0, searchStates: 0, searchMessages: 0, + pushRegistrations: 0, }; function employee(overrides: Partial = {}): EngineEmployee { @@ -1106,6 +1107,18 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () = expect(order()).toEqual([`find:${ARA}`, `purge:${ARA}`]); }); + it('앱 알림 등록(휴대폰 주소)도 그 주소의 기록으로 보이고 지운다 (앱 1단계)', async () => { + engine.employees.delete(ARA); + data.countRowsOf.mockResolvedValueOnce({ + ...NO_ROWS, + pushRegistrations: 2, + }); + const io = new ScriptedIo(['ara.kim', 'y', ARA]); + await locks.offboard(io, OPERATOR); + expect(io.output).toContain('앱 알림 등록 2줄'); + expect(order()).toEqual([`find:${ARA}`, `purge:${ARA}`]); + }); + it('엔진 계정도 남은 것도 없으면 할 것이 없다 — 예외 주소만 남았어도 지운다', async () => { engine.employees.delete(ARA); const nothing = new ScriptedIo(['ara.kim']); diff --git a/backend/src/modules/ops/ops-data.service.ts b/backend/src/modules/ops/ops-data.service.ts index aaa25cc..9667379 100644 --- a/backend/src/modules/ops/ops-data.service.ts +++ b/backend/src/modules/ops/ops-data.service.ts @@ -22,6 +22,7 @@ import { MailSignatureImageEntity } from '@/modules/mail-signature/mail-signatur import { MailSignatureEntity } from '@/modules/mail-signature/mail-signature.entity'; import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage'; import { MailUserSettingsEntity } from '@/modules/mail-user-settings/mail-user-settings.entity'; +import { PushRegistrationEntity } from '@/modules/push/push-registration.entity'; import { SYSTEM_SETTINGS_ROW_ID, SystemSettingsEntity, @@ -53,6 +54,8 @@ export interface AddressRows { /** 14단계 — 검색 색인의 상태 줄 · 메일 줄 (메일 글이 한 벌 더 든다) */ readonly searchStates: number; readonly searchMessages: number; + /** 앱 1단계 — 앱 알림 등록(휴대폰 주소). 세션을 끊으면 보내지 않지만, 같은 주소의 새 사람에게 옛 휴대폰이 남지 않게 지운다 */ + readonly pushRegistrations: number; } /** 지운 결과 — 줄 수 · 2단계 필수의 예외 주소에서 뺐는지 · 끊은 세션 수 · 지우지 못해 정리 작업에 맡긴 디스크 파일 수 */ @@ -260,7 +263,7 @@ export class OpsDataService { /** * 퇴사 마무리 — 살아 있는 세션을 끊고(행은 남긴다), 그 주소의 줄을 한 트랜잭션에 지운다 - * (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 + 2단계 필수의 예외 주소에서 빼기). + * (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 · 앱 알림 등록 + 2단계 필수의 예외 주소에서 빼기). * 지운 뒤 디스크의 파일(대용량 파일 · 서명 그림)도 지운다 — 지우지 못한 것은 정리 작업이 표에 없는 파일로 지운다 */ async purgeAddress(email: string): Promise { @@ -385,7 +388,8 @@ export class OpsDataService { | typeof StoredFileEntity | typeof ShareLinkEntity | typeof MailSearchStateEntity - | typeof MailSearchMessageEntity, + | typeof MailSearchMessageEntity + | typeof PushRegistrationEntity, where: Record, ): Promise => { if (mode === 'count') return manager.countBy(entity, where); @@ -413,6 +417,10 @@ export class OpsDataService { // 메일 줄을 되살리지 않는다 (지우기는 잠금이 풀릴 때까지 기다리고, 다음 문장은 그 쓰기가 넣은 줄까지 본다) searchStates: await run(MailSearchStateEntity, { userEmail: email }), searchMessages: await run(MailSearchMessageEntity, { userEmail: email }), + // 앱 1단계 — 세션은 위에서 끊었다(서버는 보낼 때 세션을 보므로 이미 보내지 않는다). 휴대폰 주소를 남기지 않는다 + pushRegistrations: await run(PushRegistrationEntity, { + userEmail: email, + }), }; } } diff --git a/backend/src/modules/push/configured-push-sender.spec.ts b/backend/src/modules/push/configured-push-sender.spec.ts new file mode 100644 index 0000000..f851a97 --- /dev/null +++ b/backend/src/modules/push/configured-push-sender.spec.ts @@ -0,0 +1,85 @@ +import { Logger } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; + +import { testServiceAccount } from '@/../test/fcm-test-key'; +import { ConfiguredPushSender } from './configured-push-sender'; +import type { FcmTransport } from './fcm/fcm-transport'; + +const transport: FcmTransport = { + requestToken: () => Promise.reject(new Error('보내지 않는다')), + sendMessage: () => Promise.reject(new Error('보내지 않는다')), +}; + +function configOf(values: Record): ConfigService { + return { get: (key: string) => values[key] } as unknown as ConfigService; +} + +describe('ConfiguredPushSender (PUSH_SENDER — env 의 FCM 키)', () => { + let logs: { level: string; text: string }[]; + + beforeEach(() => { + logs = []; + for (const level of ['error', 'warn', 'log'] as const) { + jest + .spyOn(Logger.prototype, level) + .mockImplementation((...args: unknown[]) => { + logs.push({ level, text: String(args[0]) }); + }); + } + }); + + afterEach(() => jest.restoreAllMocks()); + + it('키가 있으면 보낼 수 있다 — 기동 로그는 프로젝트 id 한 줄(키 · 주소 · 개인 키 없음)', () => { + const account = testServiceAccount(); + const sender = new ConfiguredPushSender( + configOf({ FCM_SERVICE_ACCOUNT_KEY: account.base64 }), + transport, + ); + sender.onModuleInit(); + + expect(sender.available).toBe(true); + expect(logs).toEqual([ + { + level: 'log', + text: '앱 새 메일 알림: 켬 (Firebase 프로젝트 arasystem-push-test)', + }, + ]); + expect(logs[0].text).not.toContain('gserviceaccount'); + sender.beforeApplicationShutdown(); + }); + + it('키가 없으면 보내지 않는다(기동은 한다) — 운영은 warn, 개발은 log · send 는 failed', async () => { + const production = new ConfiguredPushSender( + configOf({ NODE_ENV: 'production', FCM_SERVICE_ACCOUNT_KEY: '' }), + transport, + ); + production.onModuleInit(); + const development = new ConfiguredPushSender(configOf({}), transport); + development.onModuleInit(); + + expect(production.available).toBe(false); + expect(logs.map(({ level }) => level)).toEqual(['warn', 'log']); + expect(logs[0].text).toContain( + 'FCM 키(FCM_SERVICE_ACCOUNT_KEY)가 없습니다', + ); + await expect( + production.send('fid', { kind: 'new-mail', count: 1 }), + ).resolves.toBe('failed'); + }); + + it('키의 모양이 틀리면(env 검사를 비켜 왔을 때) 끄고 error — 까닭만, 값은 싣지 않는다', () => { + const broken = testServiceAccount({ type: 'authorized_user' }).base64; + const sender = new ConfiguredPushSender( + configOf({ FCM_SERVICE_ACCOUNT_KEY: broken }), + transport, + ); + sender.onModuleInit(); + + expect(sender.available).toBe(false); + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe('error'); + expect(logs[0].text).toContain('type 이 service_account 가 아닙니다'); + expect(logs[0].text).not.toContain(broken.slice(0, 24)); + }); +}); diff --git a/backend/src/modules/push/configured-push-sender.ts b/backend/src/modules/push/configured-push-sender.ts new file mode 100644 index 0000000..bdd0488 --- /dev/null +++ b/backend/src/modules/push/configured-push-sender.ts @@ -0,0 +1,87 @@ +import { + type BeforeApplicationShutdown, + Inject, + Injectable, + Logger, + type OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { + type FcmKeyReading, + readFcmServiceAccountKey, +} from './fcm/fcm-credentials'; +import { FcmPushSender } from './fcm/fcm-sender'; +import { FCM_TRANSPORT, type FcmTransport } from './fcm/fcm-transport'; +import { FCM_SERVICE_ACCOUNT_KEY_ENV } from './fcm/fcm.constants'; +import type { + PushNotice, + PushSendOptions, + PushSendOutcome, + PushSender, +} from './push-sender.port'; + +/** + * 앱 알림 보내기 (PUSH_SENDER) — env 의 FCM 키를 읽어 FCM 으로 보낸다. 키가 없으면 보내지 않는다(기동은 한다 — 계약 1절 "환경 변수"). + * 키의 모양이 틀리면 env 검사(env.validation.ts — 같은 함수)가 기동을 막으므로 여기까지 오지 않는다. + * 기동 로그 한 줄: 켜짐(Firebase 프로젝트 id — 비밀이 아니다) · 꺼짐(운영은 warn). 서버 종료 때 진행 중인 보내기를 끊는다 + */ +@Injectable() +export class ConfiguredPushSender + implements PushSender, OnModuleInit, BeforeApplicationShutdown +{ + private readonly logger = new Logger('PushSender'); + private readonly reading: FcmKeyReading; + private readonly fcm: FcmPushSender | null; + private readonly isProduction: boolean; + + constructor( + config: ConfigService, + @Inject(FCM_TRANSPORT) transport: FcmTransport, + ) { + this.reading = readFcmServiceAccountKey( + config.get(FCM_SERVICE_ACCOUNT_KEY_ENV), + ); + this.isProduction = config.get('NODE_ENV') === 'production'; + this.fcm = + this.reading.status === 'valid' + ? new FcmPushSender(this.reading.credentials, transport) + : null; + } + + get available(): boolean { + return this.fcm !== null; + } + + onModuleInit(): void { + if (this.reading.status === 'valid') { + this.logger.log( + `앱 새 메일 알림: 켬 (Firebase 프로젝트 ${this.reading.credentials.projectId})`, + ); + return; + } + if (this.reading.status === 'invalid') { + this.logger.error( + `[SYS_001] 앱 새 메일 알림을 끕니다. ${FCM_SERVICE_ACCOUNT_KEY_ENV} 를 읽지 못했습니다: ${this.reading.problem}`, + ); + return; + } + const message = `앱 새 메일 알림을 끕니다. FCM 키(${FCM_SERVICE_ACCOUNT_KEY_ENV})가 없습니다.`; + if (this.isProduction) this.logger.warn(message); + else this.logger.log(message); + } + + send( + fid: string, + notice: PushNotice, + options?: PushSendOptions, + ): Promise { + return this.fcm + ? this.fcm.send(fid, notice, options) + : Promise.resolve('failed'); + } + + beforeApplicationShutdown(): void { + this.fcm?.shutdown(); + } +} diff --git a/backend/src/modules/push/dto/push-registration.dto.spec.ts b/backend/src/modules/push/dto/push-registration.dto.spec.ts new file mode 100644 index 0000000..e570d4a --- /dev/null +++ b/backend/src/modules/push/dto/push-registration.dto.spec.ts @@ -0,0 +1,78 @@ +import { BadRequestException } from '@nestjs/common'; + +import { createValidationPipe } from '@/app.setup'; +import { PushRegistrationDto } from './push-registration.dto'; + +/** 전역 ValidationPipe 그대로(createValidationPipe) — 통과하면 변환된 본문, 아니면 400 (전역 필터가 VAL_001 로 바꾼다) */ +async function through(body: unknown): Promise { + try { + return (await createValidationPipe().transform(body, { + type: 'body', + metatype: PushRegistrationDto, + })) as object; + } catch (error) { + if (error instanceof BadRequestException) return 'VAL_001'; + throw error; + } +} + +const VALID = { + fid: 'dQw4w9WgXcQ-ExampleFid', + platform: 'android', + appVersion: '1.0.0', +}; + +describe('앱 알림 등록 본문 (PUT /api/push-registrations/current)', () => { + it('fid · platform · appVersion 을 받는다', async () => { + await expect(through(VALID)).resolves.toEqual(VALID); + await expect( + through({ ...VALID, fid: 'a'.repeat(64), appVersion: '1234.0.12' }), + ).resolves.toMatchObject({ appVersion: '1234.0.12' }); + }); + + it('fid — 글 · 64자 이하 · 영문 · 숫자 · - · _ 만 (등록 토큰의 : · 공백 · 빈 글은 400)', async () => { + for (const fid of [ + '', + 'a'.repeat(65), + 'fGx:APA91bExample', + 'has space', + '한글주소', + 12345, + null, + ]) { + await expect(through({ ...VALID, fid })).resolves.toBe('VAL_001'); + } + }); + + it('platform — android 만', async () => { + for (const platform of ['ios', 'Android', '', null]) { + await expect(through({ ...VALID, platform })).resolves.toBe('VAL_001'); + } + }); + + it('appVersion — 숫자 셋을 점으로(칸마다 1~4자리 · 앞자리 0 없이)', async () => { + for (const appVersion of [ + '1.0', + '1.0.0.1', + '01.0.0', + '1.0.0-beta', + '12345.0.0', + 'v1.0.0', + '', + 100, + ]) { + await expect(through({ ...VALID, appVersion })).resolves.toBe('VAL_001'); + } + }); + + it('빠진 칸 · 모르는 칸(예전 계획의 token · isEnabled)은 400', async () => { + for (const body of [ + {}, + { fid: VALID.fid, platform: 'android' }, + { ...VALID, token: 'x' }, + { ...VALID, isEnabled: true }, + ]) { + await expect(through(body)).resolves.toBe('VAL_001'); + } + }); +}); diff --git a/backend/src/modules/push/dto/push-registration.dto.ts b/backend/src/modules/push/dto/push-registration.dto.ts new file mode 100644 index 0000000..f20c14c --- /dev/null +++ b/backend/src/modules/push/dto/push-registration.dto.ts @@ -0,0 +1,83 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsString, Matches } from 'class-validator'; + +import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; +import { + APP_VERSION_MAX_LENGTH, + APP_VERSION_PATTERN, + PUSH_FID_MAX_LENGTH, + PUSH_FID_PATTERN, + PUSH_PLATFORMS, + type PushPlatform, +} from '@/modules/push/push.constants'; + +/** + * 앱 알림 등록의 본문 · 응답 (계약 1절 "앱 새 메일 알림 · 앱 설정" — `PUT /api/push-registrations/current`). + * 칸의 검사는 applyDecorators 로 묶어 적은 차례대로 돈다 — 형식 → 길이 → 모양 (전역 파이프가 stopAtFirstError 라 긴 글은 길이에서 멈춘다 — contact.dto.ts 와 같은 결). + * FID 는 검사 사유 · 로그에 남지 않는다(검사 실패의 응답은 VAL_001 하나 — 필터가 사유를 싣지 않는다) + */ + +const FID_MESSAGE = + '휴대폰 주소(fid)는 영문 · 숫자 · - · _ 로 된 64자 이하 글이어야 합니다.'; +const PLATFORM_MESSAGE = `플랫폼(platform)은 ${PUSH_PLATFORMS.join(' · ')} 가운데 하나여야 합니다.`; +const APP_VERSION_MESSAGE = + '앱 판(appVersion)은 1.0.0 처럼 숫자 셋을 점으로 이은 글이어야 합니다.'; + +/** FID — 글 → 64자 → base64url 글자 */ +function FidField(): PropertyDecorator { + return applyDecorators( + IsString({ message: FID_MESSAGE }), + MaxCodeUnits(PUSH_FID_MAX_LENGTH, { message: FID_MESSAGE }), + Matches(PUSH_FID_PATTERN, { message: FID_MESSAGE }), + ); +} + +/** 앱 판 — 글 → 14자 → 숫자.숫자.숫자 */ +function AppVersionField(): PropertyDecorator { + return applyDecorators( + IsString({ message: APP_VERSION_MESSAGE }), + MaxCodeUnits(APP_VERSION_MAX_LENGTH, { message: APP_VERSION_MESSAGE }), + Matches(APP_VERSION_PATTERN, { message: APP_VERSION_MESSAGE }), + ); +} + +/** PUT /api/push-registrations/current 의 본문 */ +export class PushRegistrationDto { + @ApiProperty({ + description: + '이 휴대폰의 주소 — Firebase 설치 id(FID — firebase_messaging 의 onRegistered 가 준 값). 영문 · 숫자 · `-` · `_` 64자 이하', + example: 'dQw4w9WgXcQ-ExampleFid', + maxLength: PUSH_FID_MAX_LENGTH, + }) + @FidField() + fid!: string; + + @ApiProperty({ + description: '플랫폼 — 지금은 android 만', + enum: PUSH_PLATFORMS, + example: 'android', + }) + @IsIn(PUSH_PLATFORMS, { message: PLATFORM_MESSAGE }) + platform!: PushPlatform; + + @ApiProperty({ + description: + '앱 판 (PackageInfo.version) — 숫자.숫자.숫자, 칸마다 1~4자리 · 앞자리 0 없이', + example: '1.0.0', + maxLength: APP_VERSION_MAX_LENGTH, + }) + @AppVersionField() + appVersion!: string; +} + +/** PUT /api/push-registrations/current 의 응답 */ +export class PushRegistrationResultDto { + @ApiProperty({ + description: '등록을 바꾼 때 (ISO 8601 UTC — 서버 시각)', + type: String, + format: 'date-time', + example: '2026-11-07T05:00:00.000Z', + }) + updatedAt!: string; +} diff --git a/backend/src/modules/push/fcm/fcm-auth.spec.ts b/backend/src/modules/push/fcm/fcm-auth.spec.ts new file mode 100644 index 0000000..f022210 --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-auth.spec.ts @@ -0,0 +1,278 @@ +import { verify } from 'crypto'; + +import { testServiceAccount } from '@/../test/fcm-test-key'; +import { + FcmAccessTokens, + FcmAuthError, + signServiceAccountAssertion, +} from './fcm-auth'; +import { + type FcmCredentials, + readFcmServiceAccountKey, +} from './fcm-credentials'; +import type { FcmHttpResponse, FcmTransport } from './fcm-transport'; +import { FCM_REQUEST_TIMEOUT_MS } from './fcm.constants'; + +const account = testServiceAccount(); + +function credentialsOf(): FcmCredentials { + const reading = readFcmServiceAccountKey(account.base64); + if (reading.status !== 'valid') throw new Error('시험 키를 읽지 못했다'); + return reading.credentials; +} + +const credentials = credentialsOf(); +const NOW = Date.parse('2026-11-07T05:00:00Z'); + +function decodePart(part: string): Record { + return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as Record< + string, + unknown + >; +} + +const grant = (token: string, expiresIn = 3599): FcmHttpResponse => ({ + status: 200, + retryAfter: null, + body: JSON.stringify({ + access_token: token, + expires_in: expiresIn, + token_type: 'Bearer', + }), +}); + +/** 토큰 교환 대역 — 준비한 답을 차례로, 받은 form 을 모은다 */ +function fakeTransport(answers: (FcmHttpResponse | Error | 'hang')[]): { + transport: FcmTransport; + forms: string[]; + signals: AbortSignal[]; +} { + const forms: string[] = []; + const signals: AbortSignal[] = []; + const queue = [...answers]; + const transport: FcmTransport = { + requestToken: (form, signal) => { + forms.push(form); + signals.push(signal); + const next = queue.shift(); + if (next === undefined) + return Promise.reject(new Error('준비한 답이 없다')); + if (next === 'hang') { + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }); + }); + } + return next instanceof Error + ? Promise.reject(next) + : Promise.resolve(next); + }, + sendMessage: () => Promise.reject(new Error('토큰 교환만 시험한다')), + }; + return { transport, forms, signals }; +} + +async function authErrorOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + if (error instanceof FcmAuthError) return error; + throw error; + } + throw new Error('FcmAuthError 로 끝나야 한다'); +} + +describe('signServiceAccountAssertion (서비스 계정 JWT — RS256)', () => { + it('머리 kid = 키 id · alg RS256, 클레임 iss · scope · aud(Google 의 토큰 주소) · iat · exp(1시간) — 공개 키로 서명이 맞는다', () => { + const jwt = signServiceAccountAssertion(credentials, 1_794_000_000); + const [header, claims, signature] = jwt.split('.'); + + expect(decodePart(header)).toEqual({ + alg: 'RS256', + typ: 'JWT', + kid: '0123456789abcdef0123456789abcdef01234567', + }); + expect(decodePart(claims)).toEqual({ + iss: 'arasystem-push@arasystem-push-test.iam.gserviceaccount.com', + scope: 'https://www.googleapis.com/auth/firebase.messaging', + aud: 'https://oauth2.googleapis.com/token', + iat: 1_794_000_000, + exp: 1_794_003_600, + }); + expect( + verify( + 'sha256', + Buffer.from(`${header}.${claims}`), + account.publicKey, + Buffer.from(signature, 'base64url'), + ), + ).toBe(true); + }); +}); + +describe('FcmAccessTokens (OAuth access token — 기억 · 한 번만 교환)', () => { + let now: number; + const clock = () => now; + const stop = new AbortController(); + + beforeEach(() => { + now = NOW; + }); + + it('JWT 승인으로 교환한다 — form 은 grant_type(jwt-bearer) · assertion(서비스 계정 JWT)', async () => { + const fake = fakeTransport([grant('ya29.first')]); + const tokens = new FcmAccessTokens( + credentials, + fake.transport, + stop.signal, + clock, + ); + + await expect(tokens.get()).resolves.toBe('ya29.first'); + const form = new URLSearchParams(fake.forms[0]); + expect(form.get('grant_type')).toBe( + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + ); + const claims = decodePart(form.get('assertion')?.split('.')[1] ?? ''); + expect(claims).toMatchObject({ iat: Math.floor(NOW / 1000) }); + }); + + it('만료 5분 전까지 기억한다 — 그 뒤에는 새로 교환', async () => { + const fake = fakeTransport([ + grant('ya29.first', 3600), + grant('ya29.second'), + ]); + const tokens = new FcmAccessTokens( + credentials, + fake.transport, + stop.signal, + clock, + ); + + await tokens.get(); + now = NOW + 54 * 60_000; + await expect(tokens.get()).resolves.toBe('ya29.first'); + expect(fake.forms).toHaveLength(1); + + now = NOW + 55 * 60_000; + await expect(tokens.get()).resolves.toBe('ya29.second'); + expect(fake.forms).toHaveLength(2); + }); + + it('함께 부르면 교환은 한 번이다', async () => { + const fake = fakeTransport([grant('ya29.shared')]); + const tokens = new FcmAccessTokens( + credentials, + fake.transport, + stop.signal, + clock, + ); + + const results = await Promise.all([ + tokens.get(), + tokens.get(), + tokens.get(), + ]); + expect(results).toEqual(['ya29.shared', 'ya29.shared', 'ya29.shared']); + expect(fake.forms).toHaveLength(1); + }); + + it('보내기가 401 을 받은 토큰을 버리면 다음에 새로 교환한다 — 그사이 바뀐 토큰이면 버리지 않는다', async () => { + const fake = fakeTransport([grant('ya29.old'), grant('ya29.new')]); + const tokens = new FcmAccessTokens( + credentials, + fake.transport, + stop.signal, + clock, + ); + + await tokens.get(); + tokens.invalidate('ya29.other'); + await expect(tokens.get()).resolves.toBe('ya29.old'); + tokens.invalidate('ya29.old'); + await expect(tokens.get()).resolves.toBe('ya29.new'); + expect(fake.forms).toHaveLength(2); + }); + + it('Google 이 거절하면 rejected — OAuth 의 error 코드만 싣고 설명 글은 버린다', async () => { + const fake = fakeTransport([ + { + status: 400, + retryAfter: null, + body: JSON.stringify({ + error: 'invalid_grant', + error_description: 'Invalid JWT Signature. key-id 0123456789abcdef', + }), + }, + ]); + const tokens = new FcmAccessTokens( + credentials, + fake.transport, + stop.signal, + clock, + ); + + const error = await authErrorOf(tokens.get()); + expect(error.kind).toBe('rejected'); + expect(error.reason).toBe('http-400/invalid_grant'); + expect(`${error.message} ${error.reason}`).not.toContain('Signature'); + }); + + it('잠깐의 장애는 unavailable — 5xx · 429 · 연결 오류 · 모양이 틀린 200 · 시간 초과', async () => { + const tokens = (answer: FcmHttpResponse | Error) => + new FcmAccessTokens( + credentials, + fakeTransport([answer]).transport, + stop.signal, + clock, + ); + for (const [answer, reason] of [ + [{ status: 503, retryAfter: null, body: '' }, 'http-503'], + [{ status: 429, retryAfter: '10', body: '' }, 'http-429'], + [new Error('ECONNRESET'), 'network'], + [ + { + status: 200, + retryAfter: null, + body: '{"access_token": "has space"}', + }, + 'malformed-response', + ], + ] as const) { + const error = await authErrorOf(tokens(answer).get()); + expect([error.kind, error.reason]).toEqual(['unavailable', reason]); + } + + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'], + }); + try { + const hanging = new FcmAccessTokens( + credentials, + fakeTransport(['hang']).transport, + stop.signal, + clock, + ); + const pending = authErrorOf(hanging.get()); + await jest.advanceTimersByTimeAsync(FCM_REQUEST_TIMEOUT_MS); + const error = await pending; + expect([error.kind, error.reason]).toEqual(['unavailable', 'timeout']); + } finally { + jest.useRealTimers(); + } + }); + + it('실패한 교환은 기억하지 않는다 — 다음 get 이 다시 교환한다', async () => { + const fake = fakeTransport([new Error('ECONNRESET'), grant('ya29.after')]); + const tokens = new FcmAccessTokens( + credentials, + fake.transport, + stop.signal, + clock, + ); + + await authErrorOf(tokens.get()); + await expect(tokens.get()).resolves.toBe('ya29.after'); + }); +}); diff --git a/backend/src/modules/push/fcm/fcm-auth.ts b/backend/src/modules/push/fcm/fcm-auth.ts new file mode 100644 index 0000000..cf1636c --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-auth.ts @@ -0,0 +1,200 @@ +import { sign } from 'crypto'; + +import type { FcmCredentials } from './fcm-credentials'; +import { deadlineSignal } from './fcm-timeout'; +import type { FcmTransport } from './fcm-transport'; +import { + FCM_ACCESS_TOKEN_MAX_LENGTH, + FCM_ACCESS_TOKEN_REFRESH_MARGIN_MS, + FCM_ASSERTION_LIFETIME_SECONDS, + FCM_OAUTH_TOKEN_URL, + FCM_REQUEST_TIMEOUT_MS, + FCM_SCOPE, +} from './fcm.constants'; + +/** + * FCM 보내기의 OAuth access token (서비스 계정 JWT → `POST https://oauth2.googleapis.com/token`) — 새 의존성 없이 node crypto 의 RS256. + * - 받은 토큰은 만료 FCM_ACCESS_TOKEN_REFRESH_MARGIN_MS 전까지 기억한다. 함께 부르면 교환은 한 번이다 + * - 보내기가 401 을 받으면 그 토큰을 버린다(invalidate) — 다음 get 이 새로 받는다 + * JWT · access token · 개인 키 · Google 의 오류 설명 글은 로그 · 오류에 싣지 않는다 — 실패는 종류(OAuth 의 error 코드 · 상태)만 + */ + +/** OAuth 의 JWT 승인 방식 (RFC 7523) */ +const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; +/** OAuth 오류 코드의 모양 — `invalid_grant` 처럼 (그 밖의 글은 버린다) */ +const OAUTH_ERROR_CODE = /^[a-z_]{1,64}$/; +/** access token 의 모양 — 공백 · 제어 문자 없는 글 */ +const ACCESS_TOKEN_PATTERN = /^[\x21-\x7e]+$/; + +/** + * 토큰을 받지 못함 — rejected: Google 이 거절했다(키를 지움 · 막음 · 서버 시계가 틀림 · 계정 권한 — 다시 해도 같다), + * unavailable: 잠깐의 장애(429 · 5xx · 연결 오류 · 시간 초과 · 모양이 틀린 응답) + */ +export class FcmAuthError extends Error { + constructor( + readonly kind: 'rejected' | 'unavailable', + /** 로그에 남길 종류 — 상태 · OAuth 오류 코드 (설명 글 없음) */ + readonly reason: string, + ) { + super(`fcm-auth-${kind}`); + this.name = 'FcmAuthError'; + } +} + +function base64UrlJson(value: object): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +/** + * 서비스 계정 JWT (RS256) — 머리 kid = 키 id, iss = 서비스 계정 주소, scope = 보내기, aud = 토큰 주소, iat · exp(1시간). + * aud 는 늘 Google 의 토큰 주소다(시험의 가짜 서버로 보내도) — 키 파일의 token_uri 는 쓰지 않는다 + */ +export function signServiceAccountAssertion( + credentials: FcmCredentials, + issuedAtSeconds: number, +): string { + const header = base64UrlJson({ + alg: 'RS256', + typ: 'JWT', + kid: credentials.privateKeyId, + }); + const claims = base64UrlJson({ + iss: credentials.clientEmail, + scope: FCM_SCOPE, + aud: FCM_OAUTH_TOKEN_URL, + iat: issuedAtSeconds, + exp: issuedAtSeconds + FCM_ASSERTION_LIFETIME_SECONDS, + }); + const signingInput = `${header}.${claims}`; + const signature = sign( + 'sha256', + Buffer.from(signingInput), + credentials.privateKey, + ).toString('base64url'); + return `${signingInput}.${signature}`; +} + +/** OAuth 오류 본문 `{ error, error_description }` 의 코드만 (설명 글은 버린다) */ +function oauthErrorCode(body: string): string | null { + try { + const parsed: unknown = JSON.parse(body); + const code = + typeof parsed === 'object' && parsed !== null + ? (parsed as { error?: unknown }).error + : undefined; + return typeof code === 'string' && OAUTH_ERROR_CODE.test(code) + ? code + : null; + } catch { + return null; + } +} + +/** 성공 본문 → { 토큰, 수명(초) } — 모양이 틀리면 null */ +function readGrant(body: string): { token: string; expiresIn: number } | null { + try { + const parsed = JSON.parse(body) as { + access_token?: unknown; + expires_in?: unknown; + }; + const token = parsed.access_token; + const expiresIn = parsed.expires_in; + if ( + typeof token !== 'string' || + token.length > FCM_ACCESS_TOKEN_MAX_LENGTH || + !ACCESS_TOKEN_PATTERN.test(token) || + typeof expiresIn !== 'number' || + !Number.isFinite(expiresIn) || + expiresIn <= 0 + ) { + return null; + } + return { token, expiresIn }; + } catch { + return null; + } +} + +export class FcmAccessTokens { + private cached: { + readonly token: string; + readonly refreshAt: number; + } | null = null; + private inflight: Promise | null = null; + + constructor( + private readonly credentials: FcmCredentials, + private readonly transport: FcmTransport, + /** 서버 종료 — 진행 중인 교환을 끊는다 */ + private readonly stopSignal: AbortSignal, + private readonly now: () => number = () => Date.now(), + ) {} + + /** 쓸 수 있는 access token — 기억한 것이 있으면 그것, 없으면 교환(함께 부르면 한 번). 받지 못하면 FcmAuthError */ + get(): Promise { + if (this.cached && this.now() < this.cached.refreshAt) { + return Promise.resolve(this.cached.token); + } + this.inflight ??= this.exchange().finally(() => { + this.inflight = null; + }); + return this.inflight; + } + + /** 보내기가 401 을 받은 토큰 — 지금 기억한 것이 그것이면 버린다 */ + invalidate(token: string): void { + if (this.cached?.token === token) this.cached = null; + } + + private async exchange(): Promise { + const startedAt = this.now(); + const assertion = signServiceAccountAssertion( + this.credentials, + Math.floor(startedAt / 1000), + ); + const form = new URLSearchParams({ + grant_type: JWT_BEARER_GRANT, + assertion, + }).toString(); + + const deadline = deadlineSignal(FCM_REQUEST_TIMEOUT_MS, this.stopSignal); + let status: number; + let body: string; + try { + ({ status, body } = await this.transport.requestToken( + form, + deadline.signal, + )); + } catch { + throw new FcmAuthError( + 'unavailable', + deadline.timedOut() ? 'timeout' : 'network', + ); + } finally { + deadline.release(); + } + + if (status === 200) { + const grant = readGrant(body); + if (!grant) throw new FcmAuthError('unavailable', 'malformed-response'); + this.cached = { + token: grant.token, + refreshAt: Math.max( + startedAt, + startedAt + + grant.expiresIn * 1000 - + FCM_ACCESS_TOKEN_REFRESH_MARGIN_MS, + ), + }; + return grant.token; + } + if (status === 429 || status >= 500) { + throw new FcmAuthError('unavailable', `http-${status}`); + } + const code = oauthErrorCode(body); + throw new FcmAuthError( + 'rejected', + code ? `http-${status}/${code}` : `http-${status}`, + ); + } +} diff --git a/backend/src/modules/push/fcm/fcm-credentials.spec.ts b/backend/src/modules/push/fcm/fcm-credentials.spec.ts new file mode 100644 index 0000000..fa36537 --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-credentials.spec.ts @@ -0,0 +1,133 @@ +import { generateKeyPairSync } from 'crypto'; + +import { testServiceAccount, toEnvValue } from '@/../test/fcm-test-key'; +import { readFcmServiceAccountKey } from './fcm-credentials'; +import { FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH } from './fcm.constants'; + +/** 틀린 까닭 — 아니면 시험이 틀렸다 */ +function problemOf(raw: string | undefined): string { + const reading = readFcmServiceAccountKey(raw); + if (reading.status !== 'invalid') { + throw new Error(`invalid 여야 한다 (지금 ${reading.status})`); + } + return reading.problem; +} + +describe('readFcmServiceAccountKey (FCM 서비스 계정 키 — env FCM_SERVICE_ACCOUNT_KEY)', () => { + it('키 JSON 을 통째로 base64 한 줄 — 프로젝트 id · 서비스 계정 주소 · 키 id · RSA 개인 키를 읽는다 (앞뒤 공백은 지운다)', () => { + const account = testServiceAccount(); + const reading = readFcmServiceAccountKey(` ${account.base64}\n`); + + expect(reading.status).toBe('valid'); + if (reading.status !== 'valid') return; + expect(reading.credentials).toMatchObject({ + projectId: 'arasystem-push-test', + clientEmail: 'arasystem-push@arasystem-push-test.iam.gserviceaccount.com', + privateKeyId: '0123456789abcdef0123456789abcdef01234567', + }); + expect(reading.credentials.privateKey.asymmetricKeyType).toBe('rsa'); + }); + + it('비었으면 absent — 알림만 끈다(기동은 한다)', () => { + for (const raw of [undefined, '', ' ']) { + expect(readFcmServiceAccountKey(raw)).toEqual({ status: 'absent' }); + } + }); + + it('base64 한 줄이 아니면 — 줄을 나눈 base64(-w0 없이) · 다른 글자 · 패딩이 틀린 길이', () => { + const { base64 } = testServiceAccount(); + const wrapped = base64.replace(/(.{76})/g, '$1\n'); + for (const raw of [ + wrapped, + 'not*base64!', + 'abc', + '{"type":"service_account"}', + ]) { + expect(problemOf(raw)).toBe('base64 한 줄이 아닙니다'); + } + }); + + it(`${FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH.toLocaleString('en-US')}자를 넘으면 읽지 않는다`, () => { + expect( + problemOf('A'.repeat(FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH + 4)), + ).toContain('넘습니다'); + }); + + it('JSON · 객체가 아니면', () => { + expect(problemOf(Buffer.from('키가 아님').toString('base64'))).toBe( + 'base64 를 풀면 JSON 이 아닙니다', + ); + expect(problemOf(toEnvValue(['service_account']))).toBe( + 'base64 를 풀면 JSON 객체가 아닙니다', + ); + }); + + it('칸이 틀리면 그 칸의 이름만 알린다 — type · project_id · client_email · private_key_id · token_uri · private_key', () => { + const cases: [Record, string][] = [ + [{ type: 'authorized_user' }, 'type 이 service_account 가 아닙니다'], + [ + { project_id: 'Bad_Project' }, + 'project_id 가 프로젝트 id 모양이 아닙니다', + ], + [{ project_id: undefined }, 'project_id 가 프로젝트 id 모양이 아닙니다'], + [ + { client_email: 'someone@gmail.com' }, + 'client_email 이 서비스 계정 주소(…@….iam.gserviceaccount.com)가 아닙니다', + ], + [{ private_key_id: 'xyz' }, 'private_key_id 가 키 id 모양이 아닙니다'], + [ + { token_uri: 'https://evil.example/token' }, + 'token_uri 가 https://oauth2.googleapis.com/token 이 아닙니다', + ], + [{ private_key: undefined }, 'private_key 가 없습니다'], + [ + { + private_key: + '-----BEGIN PRIVATE KEY-----\nnope\n-----END PRIVATE KEY-----\n', + }, + 'private_key 가 PEM 개인 키가 아닙니다', + ], + ]; + for (const [overrides, problem] of cases) { + expect(problemOf(testServiceAccount(overrides).base64)).toBe(problem); + } + }); + + it('token_uri 가 없어도 된다 — 주소는 서버의 상수를 쓴다', () => { + expect( + readFcmServiceAccountKey( + testServiceAccount({ token_uri: undefined }).base64, + ).status, + ).toBe('valid'); + }); + + it('RSA 가 아니거나 2048비트보다 짧은 키는 받지 않는다', () => { + const ec = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) + .privateKey.export({ type: 'pkcs8', format: 'pem' }) + .toString(); + expect(problemOf(testServiceAccount({ private_key: ec }).base64)).toBe( + 'private_key 가 RSA 키가 아닙니다', + ); + const short = generateKeyPairSync('rsa', { modulusLength: 1024 }) + .privateKey.export({ type: 'pkcs8', format: 'pem' }) + .toString(); + expect(problemOf(testServiceAccount({ private_key: short }).base64)).toBe( + 'private_key 가 2048비트보다 짧습니다', + ); + }); + + it('틀린 까닭에 키의 값이 실리지 않는다 — 개인 키 · 주소 · 키 id · base64 원문', () => { + const account = testServiceAccount({ type: 'nope' }); + const problems = [ + problemOf(account.base64), + problemOf( + testServiceAccount({ client_email: 'leak.me@gmail.com' }).base64, + ), + problemOf(testServiceAccount({ private_key_id: 'leak-me-id' }).base64), + ].join('\n'); + expect(problems).not.toContain('BEGIN'); + expect(problems).not.toContain('leak'); + expect(problems).not.toContain(account.base64.slice(0, 16)); + expect(problems).not.toContain('arasystem-push-test'); + }); +}); diff --git a/backend/src/modules/push/fcm/fcm-credentials.ts b/backend/src/modules/push/fcm/fcm-credentials.ts new file mode 100644 index 0000000..3c626d1 --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-credentials.ts @@ -0,0 +1,139 @@ +import { createPrivateKey, type KeyObject } from 'crypto'; + +import { + FCM_OAUTH_TOKEN_URL, + FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH, +} from './fcm.constants'; + +/** + * FCM 서비스 계정 키 읽기 (계약 1절 "앱 새 메일 알림 · 앱 설정" — env `FCM_SERVICE_ACCOUNT_KEY`) — 순수 함수. + * 값은 서비스 계정 JSON 키 파일을 통째로 base64 한 줄로 바꾼 것이다(`backend/.env.{env}` — JWT_SECRET 과 같은 자리). + * env 검사(env.validation.ts)와 보내는 쪽(configured-push-sender.ts)이 이 함수 하나를 쓴다 — 규칙이 한 곳이다. + * 틀린 까닭(problem)에는 어느 칸이 틀렸는지만 적는다 — 키의 값 · 개인 키는 어디에도 싣지 않는다 + */ + +/** 보내기에 쓰는 키의 칸 */ +export interface FcmCredentials { + /** Firebase(Google Cloud) 프로젝트 id — 보내기 주소에 들어간다. 비밀이 아니다(기동 로그에 남긴다) */ + readonly projectId: string; + /** 서비스 계정 주소 — JWT 의 iss */ + readonly clientEmail: string; + /** 키 id — JWT 머리의 kid */ + readonly privateKeyId: string; + /** RSA 개인 키 */ + readonly privateKey: KeyObject; +} + +/** 키를 읽은 결과 — 없음(알림 끔) · 읽음 · 틀림(까닭 — 값은 싣지 않는다) */ +export type FcmKeyReading = + | { readonly status: 'absent' } + | { readonly status: 'valid'; readonly credentials: FcmCredentials } + | { readonly status: 'invalid'; readonly problem: string }; + +/** 표준 base64 한 줄 (패딩 포함 — `base64 -w0` · PowerShell 의 ToBase64String) */ +const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; +/** Google Cloud 프로젝트 id — 소문자로 시작하는 소문자 · 숫자 · `-` 6~30자, `-` 로 끝나지 않는다 */ +const PROJECT_ID_PATTERN = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/; +/** 서비스 계정 주소 — `<이름>@<프로젝트>.iam.gserviceaccount.com` (Firebase 의 기본 계정 · 따로 만든 계정 모두 이 꼴) */ +const CLIENT_EMAIL_PATTERN = + /^[a-z0-9][a-z0-9-]{0,62}@[a-z0-9][a-z0-9-]{0,62}\.iam\.gserviceaccount\.com$/; +/** 키 id — 16진수 (Google 은 40자) */ +const PRIVATE_KEY_ID_PATTERN = /^[0-9a-fA-F]{16,128}$/; +/** RSA 키의 최소 크기(비트) — Google 이 만드는 키는 2048 */ +const MIN_RSA_MODULUS_BITS = 2_048; + +function invalid(problem: string): FcmKeyReading { + return { status: 'invalid', problem }; +} + +/** 칸 하나 — 글이 아니거나 비었으면 undefined */ +function textField( + source: Record, + key: string, +): string | undefined { + const value = source[key]; + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** PEM 개인 키 → RSA KeyObject (아니면 까닭) */ +function readPrivateKey(pem: string): KeyObject | string { + let key: KeyObject; + try { + key = createPrivateKey({ key: pem, format: 'pem' }); + } catch { + return 'private_key 가 PEM 개인 키가 아닙니다'; + } + if (key.asymmetricKeyType !== 'rsa') { + return 'private_key 가 RSA 키가 아닙니다'; + } + const bits = key.asymmetricKeyDetails?.modulusLength ?? 0; + if (bits < MIN_RSA_MODULUS_BITS) { + return `private_key 가 ${MIN_RSA_MODULUS_BITS}비트보다 짧습니다`; + } + return key; +} + +/** + * env 값 → 키. 비었으면 absent(알림 끔 — 기동은 한다). + * base64 → UTF-8 JSON → `type: service_account` · `project_id` · `client_email` · `private_key_id` · `private_key`(PEM · RSA 2048 이상) · + * `token_uri`(있으면 Google 의 토큰 주소와 같아야 — 다른 곳으로 키를 보내지 않는다) + */ +export function readFcmServiceAccountKey( + raw: string | undefined, +): FcmKeyReading { + const value = raw?.trim() ?? ''; + if (value === '') return { status: 'absent' }; + if (value.length > FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH) { + return invalid( + `${FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH.toLocaleString('en-US')}자를 넘습니다`, + ); + } + if (!BASE64_PATTERN.test(value) || value.length % 4 !== 0) { + return invalid('base64 한 줄이 아닙니다'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(value, 'base64').toString('utf8')); + } catch { + return invalid('base64 를 풀면 JSON 이 아닙니다'); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return invalid('base64 를 풀면 JSON 객체가 아닙니다'); + } + const source = parsed as Record; + + if (source.type !== 'service_account') { + return invalid('type 이 service_account 가 아닙니다'); + } + const projectId = textField(source, 'project_id'); + if (projectId === undefined || !PROJECT_ID_PATTERN.test(projectId)) { + return invalid('project_id 가 프로젝트 id 모양이 아닙니다'); + } + const clientEmail = textField(source, 'client_email'); + if (clientEmail === undefined || !CLIENT_EMAIL_PATTERN.test(clientEmail)) { + return invalid( + 'client_email 이 서비스 계정 주소(…@….iam.gserviceaccount.com)가 아닙니다', + ); + } + const privateKeyId = textField(source, 'private_key_id'); + if ( + privateKeyId === undefined || + !PRIVATE_KEY_ID_PATTERN.test(privateKeyId) + ) { + return invalid('private_key_id 가 키 id 모양이 아닙니다'); + } + const tokenUri = source.token_uri; + if (tokenUri !== undefined && tokenUri !== FCM_OAUTH_TOKEN_URL) { + return invalid(`token_uri 가 ${FCM_OAUTH_TOKEN_URL} 이 아닙니다`); + } + const pem = textField(source, 'private_key'); + if (pem === undefined) return invalid('private_key 가 없습니다'); + const privateKey = readPrivateKey(pem); + if (typeof privateKey === 'string') return invalid(privateKey); + + return { + status: 'valid', + credentials: { projectId, clientEmail, privateKeyId, privateKey }, + }; +} diff --git a/backend/src/modules/push/fcm/fcm-message.spec.ts b/backend/src/modules/push/fcm/fcm-message.spec.ts new file mode 100644 index 0000000..c5508d3 --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-message.spec.ts @@ -0,0 +1,87 @@ +import { fcmMessageOf } from './fcm-message'; + +const FID = 'dQw4w9WgXcQ-ExampleFid'; + +/** JSON 의 모든 키 경로 (배열은 없다) — 메시지에 정해진 칸 밖이 끼지 않는지 본다 */ +function keyPaths(value: unknown, prefix = ''): string[] { + if (typeof value !== 'object' || value === null) return [prefix]; + return Object.entries(value as Record).flatMap( + ([key, child]) => keyPaths(child, prefix ? `${prefix}.${key}` : key), + ); +} + +describe('fcmMessageOf (앱 알림의 FCM 메시지 — 계약 1절 "푸시 모양")', () => { + it('새 메일 — 제목 "새 메일 N통"만, data 는 모듈 · 종류, 안드로이드는 같은 tag · 채널 · 높은 우선순위 · 하루 수명 · 개수', () => { + expect(fcmMessageOf(FID, { kind: 'new-mail', count: 3 })).toEqual({ + fid: FID, + notification: { title: '새 메일 3통' }, + data: { module: 'mail', kind: 'new-mail' }, + android: { + collapse_key: 'new-mail', + priority: 'HIGH', + ttl: '86400s', + notification: { + tag: 'new-mail', + channel_id: 'new_mail', + notification_priority: 'PRIORITY_HIGH', + visibility: 'PUBLIC', + notification_count: 3, + }, + }, + }); + }); + + it('개수는 천 단위 쉼표 — 1통 · 12통 · 1,234통', () => { + const titleOf = (count: number) => + fcmMessageOf(FID, { kind: 'new-mail', count }).notification.title; + expect(titleOf(1)).toBe('새 메일 1통'); + expect(titleOf(12)).toBe('새 메일 12통'); + expect(titleOf(1234)).toBe('새 메일 1,234통'); + }); + + it('칸은 정해진 것뿐이다 — 받는 기기(fid) 밖에 주소 · 이름 · 제목 · 메일 id · 세션 id 를 담을 칸이 없다', () => { + expect(keyPaths(fcmMessageOf(FID, { kind: 'new-mail', count: 2 }))).toEqual( + [ + 'fid', + 'notification.title', + 'data.module', + 'data.kind', + 'android.collapse_key', + 'android.priority', + 'android.ttl', + 'android.notification.tag', + 'android.notification.channel_id', + 'android.notification.notification_priority', + 'android.notification.visibility', + 'android.notification.notification_count', + ], + ); + // data 의 값은 FCM 이 글만 받는다 + const { data } = fcmMessageOf(FID, { kind: 'session-ended' }); + expect( + Object.values(data).every((value) => typeof value === 'string'), + ).toBe(true); + }); + + it('로그인이 저절로 끝남 — 같은 tag · 채널 · collapse_key 로 옛 "새 메일 N통"을 바꿔 쓴다, 제목 · 본문은 초안 글, 개수 칸 없음', () => { + expect(fcmMessageOf(FID, { kind: 'session-ended' })).toEqual({ + fid: FID, + notification: { + title: '새 메일 알림이 멈췄습니다', + body: '안전을 위해 로그아웃했습니다. 앱을 열어 다시 로그인해 주세요.', + }, + data: { module: 'mail', kind: 'session-ended' }, + android: { + collapse_key: 'new-mail', + priority: 'NORMAL', + ttl: '86400s', + notification: { + tag: 'new-mail', + channel_id: 'new_mail', + notification_priority: 'PRIORITY_DEFAULT', + visibility: 'PUBLIC', + }, + }, + }); + }); +}); diff --git a/backend/src/modules/push/fcm/fcm-message.ts b/backend/src/modules/push/fcm/fcm-message.ts new file mode 100644 index 0000000..476ea00 --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-message.ts @@ -0,0 +1,90 @@ +import type { PushNotice } from '@/modules/push/push-sender.port'; +import { SESSION_ENDED_TEXT, newMailTitle } from '@/modules/push/push-text'; +import { + FCM_CHANNEL_ID, + FCM_COLLAPSE_KEY, + FCM_NOTIFICATION_TAG, + FCM_TTL, + PUSH_MODULE_MAIL, +} from './fcm.constants'; + +/** + * FCM HTTP v1 `messages:send` 의 `message` (계약 1절 "푸시 모양") — 순수 함수. + * 받는 것은 기기 주소(FID)와 알림(종류 · 개수)뿐이다 — 주소 · 이름 · 제목 · 메일 id · 세션 id 를 받지 않으므로 싣지도 못한다. + * 칸 이름은 FCM v1 JSON 의 snake_case 다(android.*). 안드로이드 알림 하나를 같은 tag 로 바꿔 쓴다 — 로그인이 끝난 알림도 같은 자리에 + */ + +/** 시스템이 그리는 알림 칸 */ +export interface FcmNotification { + readonly title: string; + readonly body?: string; +} + +/** 안드로이드 알림 칸 (AndroidNotification) */ +export interface FcmAndroidNotification { + readonly tag: string; + readonly channel_id: string; + readonly notification_priority: 'PRIORITY_HIGH' | 'PRIORITY_DEFAULT'; + readonly visibility: 'PUBLIC'; + readonly notification_count?: number; +} + +/** 안드로이드 칸 (AndroidConfig) */ +export interface FcmAndroidConfig { + readonly collapse_key: string; + readonly priority: 'HIGH' | 'NORMAL'; + readonly ttl: string; + readonly notification: FcmAndroidNotification; +} + +/** 보내는 메시지 — 앱이 누른 뒤 갈 곳은 data(모듈 · 종류)로만 안다 */ +export interface FcmMessage { + readonly fid: string; + readonly notification: FcmNotification; + readonly data: { readonly module: string; readonly kind: PushNotice['kind'] }; + readonly android: FcmAndroidConfig; +} + +/** 알림 하나 → 메시지 (FID 는 받는 기기 — 내용에는 들지 않는다) */ +export function fcmMessageOf(fid: string, notice: PushNotice): FcmMessage { + if (notice.kind === 'new-mail') { + return { + fid, + notification: { title: newMailTitle(notice.count) }, + data: { module: PUSH_MODULE_MAIL, kind: notice.kind }, + android: { + collapse_key: FCM_COLLAPSE_KEY, + priority: 'HIGH', + ttl: FCM_TTL, + notification: { + tag: FCM_NOTIFICATION_TAG, + channel_id: FCM_CHANNEL_ID, + notification_priority: 'PRIORITY_HIGH', + visibility: 'PUBLIC', + // 런처 배지의 수 — 기종마다 보이는지 다르다(확인 필요) + notification_count: notice.count, + }, + }, + }; + } + // 로그인이 저절로 끝났다 — 같은 tag · 채널로 옛 "새 메일 N통"을 바꿔 쓴다. 급하지 않아 보통 우선순위 + return { + fid, + notification: { + title: SESSION_ENDED_TEXT.title, + body: SESSION_ENDED_TEXT.body, + }, + data: { module: PUSH_MODULE_MAIL, kind: notice.kind }, + android: { + collapse_key: FCM_COLLAPSE_KEY, + priority: 'NORMAL', + ttl: FCM_TTL, + notification: { + tag: FCM_NOTIFICATION_TAG, + channel_id: FCM_CHANNEL_ID, + notification_priority: 'PRIORITY_DEFAULT', + visibility: 'PUBLIC', + }, + }, + }; +} diff --git a/backend/src/modules/push/fcm/fcm-sender.spec.ts b/backend/src/modules/push/fcm/fcm-sender.spec.ts new file mode 100644 index 0000000..bde59ee --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-sender.spec.ts @@ -0,0 +1,512 @@ +import { Logger } from '@nestjs/common'; + +import { testServiceAccount } from '@/../test/fcm-test-key'; +import { + type FcmCredentials, + readFcmServiceAccountKey, +} from './fcm-credentials'; +import { fcmMessageOf } from './fcm-message'; +import { + FcmPushSender, + classifyFcmResponse, + readFcmError, + retryAfterMs, +} from './fcm-sender'; +import type { FcmHttpResponse, FcmTransport } from './fcm-transport'; +import { FCM_REQUEST_TIMEOUT_MS, FCM_SEND_DEADLINE_MS } from './fcm.constants'; + +const account = testServiceAccount(); +const credentials = ((): FcmCredentials => { + const reading = readFcmServiceAccountKey(account.base64); + if (reading.status !== 'valid') throw new Error('시험 키를 읽지 못했다'); + return reading.credentials; +})(); + +const FID = 'dQw4w9WgXcQ-SecretFid01'; +const NOW = Date.parse('2026-11-07T05:00:00Z'); + +/** FCM 의 오류 본문 (v1 — google.rpc.Status + FcmError · BadRequest 상세) */ +function fcmError( + code: number, + status: string, + errorCode: string | null, + fields: string[] = [], +): FcmHttpResponse { + const details: Record[] = []; + if (errorCode) { + details.push({ + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + errorCode, + }); + } + if (fields.length > 0) { + details.push({ + '@type': 'type.googleapis.com/google.rpc.BadRequest', + fieldViolations: fields.map((field) => ({ + field, + description: `Invalid value at '${field}' (${FID})`, + })), + }); + } + return { + status: code, + retryAfter: null, + body: JSON.stringify({ + error: { + code, + message: `Requested entity was not found. ${FID}`, + status, + details, + }, + }), + }; +} + +const ok: FcmHttpResponse = { + status: 200, + retryAfter: null, + body: JSON.stringify({ name: 'projects/arasystem-push-test/messages/1' }), +}; +const transient = (status: number, retryAfter: string | null = null) => ({ + status, + retryAfter, + body: '', +}); + +type Answer = FcmHttpResponse | Error | 'hang'; + +/** FCM 대역 — 토큰 교환 · 보내기에 준비한 답을 차례로 준다 */ +function fakeFcm(sends: Answer[], tokens: Answer[] = []) { + const sendQueue = [...sends]; + const tokenQueue = [...tokens]; + const sent: { projectId: string; token: string; body: string }[] = []; + let tokenRequests = 0; + let tokenSequence = 0; + const answer = (next: Answer | undefined, signal: AbortSignal) => { + if (next === undefined) + return Promise.reject(new Error('준비한 답이 없다')); + if (next === 'hang') { + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }); + }); + } + return next instanceof Error ? Promise.reject(next) : Promise.resolve(next); + }; + const transport: FcmTransport = { + requestToken: (_form, signal) => { + tokenRequests += 1; + const next = tokenQueue.shift(); + if (next !== undefined) return answer(next, signal); + tokenSequence += 1; + return Promise.resolve({ + status: 200, + retryAfter: null, + body: JSON.stringify({ + access_token: `ya29.token-${tokenSequence}`, + expires_in: 3599, + }), + }); + }, + sendMessage: (projectId, token, body, signal) => { + sent.push({ projectId, token, body }); + return answer(sendQueue.shift(), signal); + }, + }; + return { + transport, + sent, + get tokenRequests() { + return tokenRequests; + }, + }; +} + +async function flush(): Promise { + for (let round = 0; round < 10; round += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +describe('FCM 응답 읽기 (계약 "실패" 표)', () => { + it('오류 본문에서 상태 · FCM 오류 코드 · 틀린 칸만 읽는다 (설명 글은 읽지 않는다)', () => { + expect( + readFcmError( + fcmError(400, 'INVALID_ARGUMENT', 'INVALID_ARGUMENT', ['message.fid']) + .body, + ), + ).toEqual({ + status: 'INVALID_ARGUMENT', + errorCode: 'INVALID_ARGUMENT', + fields: ['message.fid'], + }); + expect(readFcmError('bad gateway')).toEqual({ + status: null, + errorCode: null, + fields: [], + }); + }); + + it('Retry-After 는 초 · HTTP-date 를 읽는다', () => { + expect(retryAfterMs('7', NOW)).toBe(7_000); + expect(retryAfterMs(new Date(NOW + 3_000).toUTCString(), NOW)).toBe(3_000); + expect(retryAfterMs('soon', NOW)).toBeNull(); + expect(retryAfterMs(null, NOW)).toBeNull(); + }); + + it('답마다의 갈래 — 지울 기기 · 토큰 다시 · 설정 오류 · 다시 하기', () => { + const kind = (response: FcmHttpResponse) => + classifyFcmResponse(response, NOW).kind; + expect(kind(ok)).toBe('sent'); + expect(kind(fcmError(404, 'NOT_FOUND', 'UNREGISTERED'))).toBe('remove'); + expect(kind(fcmError(403, 'PERMISSION_DENIED', 'SENDER_ID_MISMATCH'))).toBe( + 'remove', + ); + expect( + kind( + fcmError(400, 'INVALID_ARGUMENT', 'INVALID_ARGUMENT', ['message.fid']), + ), + ).toBe('remove'); + expect(kind(fcmError(401, 'UNAUTHENTICATED', null))).toBe('renew-token'); + for (const response of [ + fcmError(400, 'INVALID_ARGUMENT', 'INVALID_ARGUMENT', [ + 'message.android.ttl', + ]), + fcmError(403, 'PERMISSION_DENIED', null), + // 프로젝트 id 가 틀린 404 — 오류 코드가 없다(모든 등록을 지우지 않게) + fcmError(404, 'NOT_FOUND', null), + fcmError(401, 'UNAUTHENTICATED', 'THIRD_PARTY_AUTH_ERROR'), + { status: 302, retryAfter: null, body: '' }, + ]) { + expect(kind(response)).toBe('config'); + } + for (const status of [500, 502, 503]) { + expect(kind(transient(status))).toBe('retry'); + } + // 429 는 따로 — Retry-After 가 있을 때만 그때 한 번 다시 + expect(kind(transient(429))).toBe('quota'); + }); +}); + +describe('FcmPushSender (기기 하나에 보내기 — 다시 하기 · 시간 한도 · 로그)', () => { + let logs: { level: string; text: string }[]; + + beforeEach(() => { + jest.useFakeTimers({ + now: NOW, + doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'], + }); + logs = []; + for (const level of ['error', 'warn', 'log', 'debug'] as const) { + jest + .spyOn(Logger.prototype, level) + .mockImplementation((...args: unknown[]) => { + logs.push({ level, text: String(args[0]) }); + }); + } + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + /** 가짜 시계를 넘기고 이어진 일(약속)을 마저 돌린다 */ + const advance = async (ms: number) => { + await jest.advanceTimersByTimeAsync(ms); + await flush(); + }; + + /** 난수 0.5 — 흔들림 없이 1 · 4초 */ + const senderOf = (fake: ReturnType) => + new FcmPushSender(credentials, fake.transport, { random: () => 0.5 }); + + it('200 이면 보냄 — 본문은 { message: fcmMessageOf } · 주소는 프로젝트 · Bearer 는 교환한 토큰', async () => { + const fake = fakeFcm([ok]); + const outcome = await senderOf(fake).send(FID, { + kind: 'new-mail', + count: 2, + }); + + expect(outcome).toBe('sent'); + expect(fake.sent).toEqual([ + { + projectId: 'arasystem-push-test', + token: 'ya29.token-1', + body: JSON.stringify({ + message: fcmMessageOf(FID, { kind: 'new-mail', count: 2 }), + }), + }, + ]); + }); + + it('같은 보내는 쪽은 토큰을 기억한다 — 두 번 보내도 교환은 한 번', async () => { + const fake = fakeFcm([ok, ok]); + const sender = senderOf(fake); + await sender.send(FID, { kind: 'new-mail', count: 1 }); + await sender.send(FID, { kind: 'new-mail', count: 2 }); + expect(fake.tokenRequests).toBe(1); + }); + + it('그 기기가 더는 받지 않으면 unregistered — 다시 하지 않는다 (UNREGISTERED · SENDER_ID_MISMATCH · message.fid 의 INVALID_ARGUMENT)', async () => { + for (const response of [ + fcmError(404, 'NOT_FOUND', 'UNREGISTERED'), + fcmError(403, 'PERMISSION_DENIED', 'SENDER_ID_MISMATCH'), + fcmError(400, 'INVALID_ARGUMENT', 'INVALID_ARGUMENT', ['message.fid']), + ]) { + const fake = fakeFcm([response]); + await expect( + senderOf(fake).send(FID, { kind: 'new-mail', count: 1 }), + ).resolves.toBe('unregistered'); + expect(fake.sent).toHaveLength(1); + } + expect(logs.filter((log) => log.level === 'debug')).toHaveLength(3); + }); + + it('401 이면 토큰을 버리고 새 토큰으로 한 번 더 — 또 401 이면 설정 오류(error)', async () => { + const renewed = fakeFcm([fcmError(401, 'UNAUTHENTICATED', null), ok]); + await expect( + senderOf(renewed).send(FID, { kind: 'new-mail', count: 1 }), + ).resolves.toBe('sent'); + expect(renewed.sent.map(({ token }) => token)).toEqual([ + 'ya29.token-1', + 'ya29.token-2', + ]); + + const twice = fakeFcm([ + fcmError(401, 'UNAUTHENTICATED', null), + fcmError(401, 'UNAUTHENTICATED', null), + ]); + await expect( + senderOf(twice).send(FID, { kind: 'new-mail', count: 1 }), + ).resolves.toBe('failed'); + expect( + logs.filter((log) => log.level === 'error').map((log) => log.text), + ).toEqual([ + expect.stringContaining('reason=http-401') as unknown as string, + ]); + }); + + it('잠깐의 장애(503)는 1초 뒤 다시 — 그 전에는 보내지 않는다', async () => { + const fake = fakeFcm([transient(503), ok]); + const pending = senderOf(fake).send(FID, { kind: 'new-mail', count: 1 }); + await flush(); + expect(fake.sent).toHaveLength(1); + + await advance(999); + expect(fake.sent).toHaveLength(1); + await advance(1); + await expect(pending).resolves.toBe('sent'); + expect(fake.sent).toHaveLength(2); + }); + + it('시도 3번(1 · 4초 쉼)으로도 안 되면 failed — warn 한 번(상태 · 시도 수만)', async () => { + const fake = fakeFcm([transient(500), transient(500), transient(500)]); + const pending = senderOf(fake).send(FID, { kind: 'new-mail', count: 1 }); + await advance(1_000); + expect(fake.sent).toHaveLength(2); + await advance(4_000); + await expect(pending).resolves.toBe('failed'); + expect(fake.sent).toHaveLength(3); + expect( + logs.filter((log) => log.level === 'warn').map((log) => log.text), + ).toEqual([ + expect.stringContaining( + 'reason=http-500, attempts=3', + ) as unknown as string, + ]); + }); + + it('429 는 바로 다시 보내지 않는다 — Retry-After 가 있고 남은 시간 안이면 그때 한 번, 없거나 넘으면 이번 알림은 버린다 (리뷰 L5)', async () => { + const fake = fakeFcm([transient(429, '2'), ok]); + const pending = senderOf(fake).send(FID, { kind: 'new-mail', count: 1 }); + await advance(1_000); + expect(fake.sent).toHaveLength(1); + await advance(1_000); + await expect(pending).resolves.toBe('sent'); + + const noRetryAfter = fakeFcm([transient(429), ok]); + await expect( + senderOf(noRetryAfter).send(FID, { kind: 'new-mail', count: 1 }), + ).resolves.toBe('failed'); + expect(noRetryAfter.sent).toHaveLength(1); + expect(logs.filter((log) => log.level === 'warn').at(-1)?.text).toContain( + 'reason=http-429', + ); + + // 다시 보낸 것이 또 429 면 그만둔다(한 번만) + const twice = fakeFcm([transient(429, '1'), transient(429, '1'), ok]); + const again = senderOf(twice).send(FID, { kind: 'new-mail', count: 1 }); + await advance(1_000); + await expect(again).resolves.toBe('failed'); + expect(twice.sent).toHaveLength(2); + + const tooLong = fakeFcm([transient(429, '60'), ok]); + await expect( + senderOf(tooLong).send(FID, { kind: 'new-mail', count: 1 }), + ).resolves.toBe('failed'); + expect(tooLong.sent).toHaveLength(1); + }); + + it('5xx 의 Retry-After 도 남은 시간을 넘으면 기다리지 않고 그만둔다', async () => { + const tooLong = fakeFcm([transient(503, '60'), ok]); + await expect( + senderOf(tooLong).send(FID, { kind: 'new-mail', count: 1 }), + ).resolves.toBe('failed'); + expect(tooLong.sent).toHaveLength(1); + }); + + it('보내기 직전(다시 하기마다) 부른 쪽의 확인을 본다 — false 면 보내지 않고 skipped, 던지면 보내지 않고 failed (리뷰 L1)', async () => { + const never = fakeFcm([ok]); + await expect( + senderOf(never).send( + FID, + { kind: 'new-mail', count: 1 }, + { stillValid: () => Promise.resolve(false) }, + ), + ).resolves.toBe('skipped'); + expect(never.sent).toHaveLength(0); + + // 첫 시도 뒤 503 — 쉬는 사이 그 휴대폰이 옮겨 갔다 + const checks = [true, false]; + const moved = fakeFcm([transient(503), ok]); + const pending = senderOf(moved).send( + FID, + { kind: 'new-mail', count: 1 }, + { stillValid: () => Promise.resolve(checks.shift() ?? false) }, + ); + await advance(1_000); + await expect(pending).resolves.toBe('skipped'); + expect(moved.sent).toHaveLength(1); + + const broken = fakeFcm([ok]); + await expect( + senderOf(broken).send( + FID, + { kind: 'new-mail', count: 1 }, + { stillValid: () => Promise.reject(new Error('db down')) }, + ), + ).resolves.toBe('failed'); + expect(broken.sent).toHaveLength(0); + }); + + it('연결 오류 뒤 다시 — 응답이 오지 않으면 시도마다 10초에서 끊고, 한 번 보내기는 20초 안', async () => { + const network = fakeFcm([new Error('ECONNRESET'), ok]); + const first = senderOf(network).send(FID, { kind: 'new-mail', count: 1 }); + await advance(1_000); + await expect(first).resolves.toBe('sent'); + + // 0초 시도 1 → 10초에 끊김 → 1초 쉼 → 11초 시도 2(남은 9초) → 20초에 끊김 → 4초를 더 쉴 시간이 없다 → 그만둔다 + const hanging = fakeFcm(['hang', 'hang', 'hang']); + let outcome: string | null = null; + void senderOf(hanging) + .send(FID, { kind: 'new-mail', count: 1 }) + .then((result) => { + outcome = result; + }); + await advance(FCM_REQUEST_TIMEOUT_MS); + await flush(); + expect(hanging.sent).toHaveLength(1); + await advance(FCM_SEND_DEADLINE_MS - FCM_REQUEST_TIMEOUT_MS - 1); + await flush(); + expect(hanging.sent).toHaveLength(2); + expect(outcome).toBeNull(); + await advance(1); + await flush(); + expect(outcome).toBe('failed'); + }); + + it('우리 설정 · 메시지가 틀린 답은 다시 하지 않고 error — 같은 종류는 한 시간에 한 번(묻힌 수를 다음 줄에)', async () => { + const fake = fakeFcm([ + fcmError(403, 'PERMISSION_DENIED', null), + fcmError(403, 'PERMISSION_DENIED', null), + fcmError(404, 'NOT_FOUND', null), + fcmError(403, 'PERMISSION_DENIED', null), + ]); + const sender = senderOf(fake); + for (let round = 0; round < 3; round += 1) { + await expect( + sender.send(FID, { kind: 'new-mail', count: 1 }), + ).resolves.toBe('failed'); + } + expect(fake.sent).toHaveLength(3); + expect(logs.filter((log) => log.level === 'error')).toHaveLength(2); + + await advance(60 * 60_000); + await sender.send(FID, { kind: 'new-mail', count: 1 }); + const errors = logs.filter((log) => log.level === 'error'); + expect(errors).toHaveLength(3); + expect(errors[2].text).toContain('같은 실패 1건 더'); + }); + + it('OAuth 가 거절하면 보내지 않는다 — error(OAuth 의 error 코드만)', async () => { + const fake = fakeFcm( + [ok], + [ + { + status: 400, + retryAfter: null, + body: JSON.stringify({ + error: 'invalid_grant', + error_description: 'Invalid JWT', + }), + }, + ], + ); + await expect( + senderOf(fake).send(FID, { kind: 'new-mail', count: 1 }), + ).resolves.toBe('failed'); + expect(fake.sent).toHaveLength(0); + expect(logs.find((log) => log.level === 'error')?.text).toContain( + 'reason=oauth-http-400/invalid_grant', + ); + }); + + it('서버 종료 — 쉬는 중 · 보내는 중이면 바로 그만두고(로그 없이 — 배포마다 잡음이 되지 않게), 그 뒤로는 보내지 않는다 (리뷰 L4)', async () => { + const fake = fakeFcm([transient(503), ok, ok]); + const sender = senderOf(fake); + const pending = sender.send(FID, { kind: 'new-mail', count: 1 }); + await flush(); + sender.shutdown(); + await expect(pending).resolves.toBe('failed'); + await expect( + sender.send(FID, { kind: 'new-mail', count: 1 }), + ).resolves.toBe('failed'); + expect(fake.sent).toHaveLength(1); + + const inFlight = fakeFcm(['hang']); + const sending = senderOf(inFlight); + const hanging = sending.send(FID, { kind: 'new-mail', count: 1 }); + await flush(); + sending.shutdown(); + await expect(hanging).resolves.toBe('failed'); + expect(logs.filter((log) => log.level === 'warn')).toEqual([]); + }); + + it('로그에 FID · access token · 개인 키 · FCM 의 설명 글이 없다', async () => { + const fake = fakeFcm([ + fcmError(404, 'NOT_FOUND', 'UNREGISTERED'), + fcmError(400, 'INVALID_ARGUMENT', 'INVALID_ARGUMENT', [ + 'message.android', + ]), + transient(503), + transient(503), + transient(503), + ]); + const sender = senderOf(fake); + await sender.send(FID, { kind: 'new-mail', count: 1 }); + await sender.send(FID, { kind: 'new-mail', count: 1 }); + const third = sender.send(FID, { kind: 'new-mail', count: 1 }); + await advance(5_000); + await third; + + const text = logs.map((log) => log.text).join('\n'); + expect(logs.length).toBeGreaterThanOrEqual(3); + expect(text).not.toContain(FID); + expect(text).not.toContain('ya29'); + expect(text).not.toContain('BEGIN'); + expect(text).not.toContain('Requested entity'); + expect(text).not.toContain('Invalid value'); + }); +}); diff --git a/backend/src/modules/push/fcm/fcm-sender.ts b/backend/src/modules/push/fcm/fcm-sender.ts new file mode 100644 index 0000000..b7529d7 --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-sender.ts @@ -0,0 +1,434 @@ +import { Logger } from '@nestjs/common'; + +import type { + PushNotice, + PushSendOptions, + PushSendOutcome, +} from '@/modules/push/push-sender.port'; +import { ConcurrencyGate } from '@/shared/utils/concurrency-gate'; +import { FcmAccessTokens, FcmAuthError } from './fcm-auth'; +import type { FcmCredentials } from './fcm-credentials'; +import { fcmMessageOf } from './fcm-message'; +import { deadlineSignal } from './fcm-timeout'; +import type { FcmHttpResponse, FcmTransport } from './fcm-transport'; +import { + FCM_FAILURE_LOG_INTERVAL_MS, + FCM_REQUEST_TIMEOUT_MS, + FCM_RETRY_DELAYS_MS, + FCM_RETRY_JITTER, + FCM_SEND_ATTEMPTS, + FCM_SEND_CONCURRENCY, + FCM_SEND_DEADLINE_MS, +} from './fcm.constants'; + +/** + * FCM HTTP v1 으로 기기 하나에 보낸다 (계약 1절 "앱 새 메일 알림 · 앱 설정"의 "실패") — 새 의존성 없이(fcm-auth.ts 의 RS256 + 전역 fetch). + * + * | 답 | 할 일 | + * | --- | --- | + * | 2xx | 보냄 | + * | 404 UNREGISTERED · 403 SENDER_ID_MISMATCH · 400 INVALID_ARGUMENT 의 칸 `message.fid` | 그 기기는 더 받지 않는다 → unregistered (부른 쪽이 FID 가 그대로일 때만 등록을 지운다) | + * | 401(THIRD_PARTY_AUTH_ERROR 밖) | access token 을 버리고 새 토큰으로 한 번 더 | + * | 429 (QUOTA_EXCEEDED) | 바로 다시 보내지 않는다 — Retry-After 가 있고 남은 시간 안이면 그때 한 번, 아니면 이번 알림은 버린다(다음 새 메일이 그때의 N 으로 다시 간다) | + * | 5xx · 연결 오류 · 시간 초과 · OAuth 의 잠깐 장애 | 다시 — 1 · 4초(±20%) 쉬고, Retry-After 가 있으면 그것을(남은 시간 안에서만). 시도 3번 · 한 번 보내기 20초 안 | + * | 그 밖(400 · 403 PERMISSION_DENIED · 오류 코드 없는 404 · 3xx · OAuth 거절) | 우리 설정 · 메시지가 틀렸다 — 등록을 지우지 않는다(프로젝트 id 가 틀린 404 로 모든 등록을 지우지 않게) | + * + * 서버 전체에서 함께 보내는 수는 FCM_SEND_CONCURRENCY(시도 한 번이 자리 하나 — 쉬는 동안은 자리를 잡지 않는다). + * 로그: FID · 키 · JWT · access token · FCM 오류의 설명 글은 남기지 않는다 — 상태 · 오류 코드만. 설정 오류는 error, 끝내 못 보냄은 warn — + * 종류마다 FCM_FAILURE_LOG_INTERVAL_MS 에 한 번(그사이 묻힌 수를 함께). 기기가 받지 않음은 debug(반복되는 정상 흐름). + * 서버 종료(shutdown) 때 진행 중인 요청 · 쉬기를 끊는다 — 그때 끊긴 보내기는 로그 없이 failed 다(배포마다 남는 잡음이 되지 않게). + * 보내기 직전(다시 하기마다) 부른 쪽의 `stillValid` 를 본다 — false 면 보내지 않고 skipped (그 휴대폰이 다른 세션으로 옮겨 갔다 등) + */ + +/** FCM 오류 본문에서 읽는 것 — 상태 이름 · FCM 오류 코드 · 틀린 칸 (설명 글은 읽지 않는다) */ +export interface FcmErrorShape { + readonly status: string | null; + readonly errorCode: string | null; + readonly fields: readonly string[]; +} + +/** 시도 한 번의 갈래 */ +export type FcmAttemptResult = + | { readonly kind: 'sent' } + | { readonly kind: 'remove'; readonly reason: string } + | { readonly kind: 'renew-token' } + | { readonly kind: 'config'; readonly reason: string } + | { + /** 429 — Retry-After 가 있을 때만 그때 한 번 다시 */ + readonly kind: 'quota'; + readonly reason: string; + readonly retryAfterMs: number | null; + } + | { + readonly kind: 'retry'; + readonly reason: string; + readonly retryAfterMs: number | null; + }; + +/** 상태 · 오류 코드 이름의 모양 — 그 밖의 글은 버린다 */ +const ERROR_NAME = /^[A-Z][A-Z_]{0,63}$/; +/** 틀린 칸 이름의 모양 (`message.fid` 등) */ +const FIELD_NAME = /^[A-Za-z0-9_.[\]]{1,128}$/; +/** Retry-After 의 초 (HTTP-date 는 Date.parse 로) */ +const RETRY_AFTER_SECONDS = /^\d{1,6}$/; +/** 그 기기를 지우는 400 의 칸 — 대상 FID 가 틀림 */ +const TARGET_FIELD = 'message.fid'; + +export function readFcmError(body: string): FcmErrorShape { + const empty: FcmErrorShape = { status: null, errorCode: null, fields: [] }; + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return empty; + } + const error = + typeof parsed === 'object' && parsed !== null + ? (parsed as { error?: unknown }).error + : undefined; + if (typeof error !== 'object' || error === null) return empty; + const { status, details } = error as { status?: unknown; details?: unknown }; + let errorCode: string | null = null; + const fields: string[] = []; + if (Array.isArray(details)) { + for (const detail of details as unknown[]) { + if (typeof detail !== 'object' || detail === null) continue; + const { errorCode: code, fieldViolations } = detail as { + errorCode?: unknown; + fieldViolations?: unknown; + }; + if ( + errorCode === null && + typeof code === 'string' && + ERROR_NAME.test(code) + ) { + errorCode = code; + } + if (!Array.isArray(fieldViolations)) continue; + for (const violation of fieldViolations as unknown[]) { + const field = + typeof violation === 'object' && violation !== null + ? (violation as { field?: unknown }).field + : undefined; + if (typeof field === 'string' && FIELD_NAME.test(field)) { + fields.push(field); + } + } + } + } + return { + status: + typeof status === 'string' && ERROR_NAME.test(status) ? status : null, + errorCode, + fields, + }; +} + +/** Retry-After → 기다릴 ms (초 · HTTP-date). 읽지 못하면 null */ +export function retryAfterMs(value: string | null, now: number): number | null { + if (value === null) return null; + const trimmed = value.trim(); + if (RETRY_AFTER_SECONDS.test(trimmed)) return Number(trimmed) * 1000; + const at = Date.parse(trimmed); + return Number.isNaN(at) ? null : Math.max(0, at - now); +} + +/** 응답 하나의 갈래 (계약의 "실패" 표) */ +export function classifyFcmResponse( + response: FcmHttpResponse, + now: number, +): FcmAttemptResult { + const { status } = response; + if (status >= 200 && status < 300) return { kind: 'sent' }; + if (status === 429) { + return { + kind: 'quota', + reason: 'http-429', + retryAfterMs: retryAfterMs(response.retryAfter, now), + }; + } + if (status >= 500) { + return { + kind: 'retry', + reason: `http-${status}`, + retryAfterMs: retryAfterMs(response.retryAfter, now), + }; + } + const error = readFcmError(response.body); + const name = error.errorCode ?? error.status ?? '-'; + if (status === 404 && error.errorCode === 'UNREGISTERED') { + return { kind: 'remove', reason: 'UNREGISTERED' }; + } + if (status === 403 && error.errorCode === 'SENDER_ID_MISMATCH') { + return { kind: 'remove', reason: 'SENDER_ID_MISMATCH' }; + } + if ( + status === 400 && + (error.errorCode === 'INVALID_ARGUMENT' || + error.status === 'INVALID_ARGUMENT') && + error.fields.includes(TARGET_FIELD) + ) { + return { kind: 'remove', reason: `INVALID_ARGUMENT/${TARGET_FIELD}` }; + } + if (status === 401 && error.errorCode !== 'THIRD_PARTY_AUTH_ERROR') { + return { kind: 'renew-token' }; + } + return { kind: 'config', reason: `http-${status}/${name}` }; +} + +/** 쉬기 — 서버 종료 신호가 오면 바로 끝난다 (타이머는 프로세스를 붙잡지 않는다) */ +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + timer.unref(); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** 종류마다 정한 간격에 한 번만 남기는 로그 — 묻힌 수를 다음 줄에 함께 적는다 */ +class ThrottledLog { + private readonly entries = new Map< + string, + { at: number; suppressed: number } + >(); + + constructor( + private readonly intervalMs: number, + private readonly now: () => number, + ) {} + + /** 남길 차례면 그사이 묻힌 수(0 이상), 아니면 null */ + take(kind: string): number | null { + const now = this.now(); + const entry = this.entries.get(kind); + if (entry && now - entry.at < this.intervalMs) { + entry.suppressed += 1; + return null; + } + this.entries.set(kind, { at: now, suppressed: 0 }); + return entry?.suppressed ?? 0; + } +} + +export interface FcmSenderOptions { + /** [0, 1) 난수 — 쉬는 시간의 흔들림 (시험이 정해 넣는다) */ + readonly random?: () => number; +} + +export class FcmPushSender { + private readonly logger = new Logger('FcmPushSender'); + private readonly stop = new AbortController(); + private readonly tokens: FcmAccessTokens; + private readonly gate = new ConcurrencyGate(FCM_SEND_CONCURRENCY); + private readonly failureLog = new ThrottledLog( + FCM_FAILURE_LOG_INTERVAL_MS, + () => Date.now(), + ); + private readonly random: () => number; + + constructor( + private readonly credentials: FcmCredentials, + private readonly transport: FcmTransport, + options: FcmSenderOptions = {}, + ) { + this.random = options.random ?? Math.random; + this.tokens = new FcmAccessTokens(credentials, transport, this.stop.signal); + } + + /** 기기 하나(FID)에 알림 하나 — 던지지 않는다 */ + async send( + fid: string, + notice: PushNotice, + options: PushSendOptions = {}, + ): Promise { + if (this.stop.signal.aborted) return 'failed'; + const body = JSON.stringify({ message: fcmMessageOf(fid, notice) }); + const deadline = Date.now() + FCM_SEND_DEADLINE_MS; + let attempts = 0; + let renewedToken = false; + let quotaRetried = false; + + for (;;) { + // 보내기 직전(다시 하기마다) — 그 기기가 아직 이 알림을 받을 자리인가 (읽지 못하면 보내지 않는다 — 닫힌 쪽) + if (options.stillValid) { + let valid: boolean; + try { + valid = await options.stillValid(); + } catch { + return this.giveUp('still-valid-check', attempts); + } + if (!valid) return 'skipped'; + if (this.stop.signal.aborted) return 'failed'; + } + + let token: string; + try { + token = await this.tokens.get(); + } catch (error) { + if (error instanceof FcmAuthError && error.kind === 'rejected') { + this.reportConfig(`oauth-${error.reason}`); + return 'failed'; + } + // 토큰을 받지 못한 잠깐의 장애 — 보내기 시도 한 번으로 센다 + attempts += 1; + const reason = + error instanceof FcmAuthError ? `oauth-${error.reason}` : 'oauth'; + if (!(await this.pauseBeforeRetry(attempts, null, deadline))) { + return this.giveUp(reason, attempts); + } + continue; + } + + attempts += 1; + const result = await this.gate.run(() => + this.attempt(token, body, deadline), + ); + switch (result.kind) { + case 'sent': + return 'sent'; + case 'remove': + this.logger.debug( + `앱 알림 — 그 기기는 더 받지 않습니다(등록을 지웁니다): reason=${result.reason}`, + ); + return 'unregistered'; + case 'renew-token': + this.tokens.invalidate(token); + if (!renewedToken) { + // 토큰이 끝났다 — 새 토큰으로 한 번 더 (시도 수에 넣지 않는다) + renewedToken = true; + attempts -= 1; + continue; + } + this.reportConfig('http-401'); + return 'failed'; + case 'config': + this.reportConfig(result.reason); + return 'failed'; + case 'quota': + // 바로 다시 보내지 않는다 — Retry-After 가 있고 남은 시간 안이면 그때 한 번, 아니면 이번 알림은 버린다 + if (quotaRetried || result.retryAfterMs === null) { + return this.giveUp(result.reason, attempts); + } + quotaRetried = true; + if ( + !(await this.pauseBeforeRetry( + attempts, + result.retryAfterMs, + deadline, + )) + ) { + return this.giveUp(result.reason, attempts); + } + break; + case 'retry': + if ( + !(await this.pauseBeforeRetry( + attempts, + result.retryAfterMs, + deadline, + )) + ) { + return this.giveUp(result.reason, attempts); + } + } + } + } + + /** 서버 종료 — 진행 중인 요청 · 쉬기를 끊고 더 보내지 않는다 */ + shutdown(): void { + this.stop.abort(); + } + + /** 시도 한 번 — 남은 시간과 FCM_REQUEST_TIMEOUT_MS 가운데 짧은 것 안에서 */ + private async attempt( + token: string, + body: string, + deadline: number, + ): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return { kind: 'retry', reason: 'deadline', retryAfterMs: null }; + } + const timer = deadlineSignal( + Math.min(FCM_REQUEST_TIMEOUT_MS, remaining), + this.stop.signal, + ); + try { + const response = await this.transport.sendMessage( + this.credentials.projectId, + token, + body, + timer.signal, + ); + return classifyFcmResponse(response, Date.now()); + } catch { + const reason = timer.timedOut() + ? 'timeout' + : this.stop.signal.aborted + ? 'shutdown' + : 'network'; + return { kind: 'retry', reason, retryAfterMs: null }; + } finally { + timer.release(); + } + } + + /** 다시 하기 전에 쉰다 → 다시 해도 되면 true (시도 수 · 남은 시간 · 서버 종료). Retry-After 가 남은 시간을 넘으면 그만둔다 */ + private async pauseBeforeRetry( + attempts: number, + retryAfter: number | null, + deadline: number, + ): Promise { + if (attempts >= FCM_SEND_ATTEMPTS || this.stop.signal.aborted) { + return false; + } + const base = + FCM_RETRY_DELAYS_MS[attempts - 1] ?? + FCM_RETRY_DELAYS_MS[FCM_RETRY_DELAYS_MS.length - 1]; + const delay = + retryAfter ?? + Math.round(base * (1 + (this.random() * 2 - 1) * FCM_RETRY_JITTER)); + if (Date.now() + delay >= deadline) return false; + await sleep(delay, this.stop.signal); + return !this.stop.signal.aborted; + } + + /** 우리 설정 · 메시지가 틀렸다 — error, 종류마다 한 시간에 한 번 */ + private reportConfig(reason: string): void { + const suppressed = this.failureLog.take(`config:${reason}`); + if (suppressed === null) return; + this.logger.error( + `[SYS_001] 앱 알림을 보낼 수 없습니다 — FCM 설정(서비스 계정 키 · 권한 · 프로젝트)을 확인해 주세요: reason=${reason}${suppressedText(suppressed)}`, + ); + } + + /** 그만둔다 → failed — 서버 종료로 끊긴 것이면 로그 없이 (배포마다 남는 잡음이 되지 않게), 아니면 warn */ + private giveUp(reason: string, attempts: number): PushSendOutcome { + if (!this.stop.signal.aborted) this.reportGaveUp(reason, attempts); + return 'failed'; + } + + /** 잠깐의 장애가 끝나지 않았다 — warn, 종류마다 한 시간에 한 번 */ + private reportGaveUp(reason: string, attempts: number): void { + const suppressed = this.failureLog.take(`gave-up:${reason}`); + if (suppressed === null) return; + this.logger.warn( + `[SYS_001] 앱 알림을 보내지 못했습니다: reason=${reason}, attempts=${attempts}${suppressedText(suppressed)}`, + ); + } +} + +function suppressedText(count: number): string { + return count > 0 ? ` (앞 로그 뒤로 같은 실패 ${count}건 더)` : ''; +} diff --git a/backend/src/modules/push/fcm/fcm-timeout.ts b/backend/src/modules/push/fcm/fcm-timeout.ts new file mode 100644 index 0000000..448a00f --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-timeout.ts @@ -0,0 +1,38 @@ +/** + * 제한 시간이 있는 끊기 신호 — 시간이 다 되거나 부모 신호(서버 종료)가 끊기면 끊긴다. + * 타이머는 프로세스를 붙잡지 않는다(unref). 다 쓰면 `release()` 로 타이머 · 듣기를 치운다. + * AbortSignal.timeout 을 쓰지 않는 까닭: 시험의 가짜 시계(jest)로 시간을 넘기려고 — 전역 setTimeout 을 쓴다 + */ +export interface DeadlineSignal { + readonly signal: AbortSignal; + /** 시간이 다 되어 끊겼는가 (부모가 끊은 것과 가른다) */ + readonly timedOut: () => boolean; + release(): void; +} + +export function deadlineSignal( + timeoutMs: number, + parent: AbortSignal, +): DeadlineSignal { + const controller = new AbortController(); + let expired = false; + const timer = setTimeout( + () => { + expired = true; + controller.abort(); + }, + Math.max(0, timeoutMs), + ); + timer.unref(); + const follow = () => controller.abort(); + if (parent.aborted) controller.abort(); + else parent.addEventListener('abort', follow, { once: true }); + return { + signal: controller.signal, + timedOut: () => expired, + release: () => { + clearTimeout(timer); + parent.removeEventListener('abort', follow); + }, + }; +} diff --git a/backend/src/modules/push/fcm/fcm-transport.spec.ts b/backend/src/modules/push/fcm/fcm-transport.spec.ts new file mode 100644 index 0000000..2abfe84 --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-transport.spec.ts @@ -0,0 +1,155 @@ +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'http'; +import type { AddressInfo } from 'net'; + +import { FetchFcmTransport } from './fcm-transport'; +import { FCM_RESPONSE_MAX_BYTES } from './fcm.constants'; + +interface Received { + readonly method: string | undefined; + readonly url: string | undefined; + readonly headers: IncomingMessage['headers']; + readonly body: string; +} + +/** 127.0.0.1 의 가짜 Google — 받은 요청을 모으고 `respond` 로 답한다 */ +async function fakeGoogle( + respond: (request: Received, response: ServerResponse) => void, +): Promise<{ server: Server; base: string; received: Received[] }> { + const received: Received[] = []; + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + const entry: Received = { + method: request.method, + url: request.url, + headers: request.headers, + body: Buffer.concat(chunks).toString('utf8'), + }; + received.push(entry); + respond(entry, response); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { server, base: `http://127.0.0.1:${port}`, received }; +} + +function close(server: Server): Promise { + server.closeAllConnections(); + return new Promise((resolve) => server.close(() => resolve())); +} + +describe('FetchFcmTransport (FCM 의 HTTP — 시험은 127.0.0.1 의 가짜 서버로)', () => { + it('토큰 교환은 form 으로 · 보내기는 /v1/projects/{id}/messages:send 에 Bearer · JSON 으로 — Retry-After 를 그대로 넘긴다', async () => { + const google = await fakeGoogle((request, response) => { + if (request.url === '/token') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"access_token":"ya29.t","expires_in":3599}'); + return; + } + response.writeHead(503, { 'retry-after': '7' }); + response.end('{}'); + }); + try { + const transport = FetchFcmTransport.forTesting({ + tokenUrl: `${google.base}/token`, + sendBaseUrl: google.base, + }); + const signal = new AbortController().signal; + + const token = await transport.requestToken( + 'grant_type=x&assertion=y', + signal, + ); + expect(token).toEqual({ + status: 200, + retryAfter: null, + body: '{"access_token":"ya29.t","expires_in":3599}', + }); + const sent = await transport.sendMessage( + 'arasystem-push-test', + 'ya29.t', + '{"message":{}}', + signal, + ); + expect(sent).toEqual({ status: 503, retryAfter: '7', body: '{}' }); + + const [tokenRequest, sendRequest] = google.received; + expect(tokenRequest).toMatchObject({ + method: 'POST', + url: '/token', + body: 'grant_type=x&assertion=y', + }); + expect(tokenRequest.headers['content-type']).toBe( + 'application/x-www-form-urlencoded', + ); + expect(sendRequest).toMatchObject({ + method: 'POST', + url: '/v1/projects/arasystem-push-test/messages:send', + body: '{"message":{}}', + }); + expect(sendRequest.headers.authorization).toBe('Bearer ya29.t'); + expect(sendRequest.headers['content-type']).toBe( + 'application/json; charset=utf-8', + ); + } finally { + await close(google.server); + } + }); + + it(`응답 본문은 ${FCM_RESPONSE_MAX_BYTES / 1024}KB 까지만 읽는다 · 다른 곳으로 보내는 응답(3xx)은 따라가지 않는다`, async () => { + const google = await fakeGoogle((request, response) => { + if (request.url === '/token') { + response.writeHead(302, { location: 'http://127.0.0.1:1/elsewhere' }); + response.end(); + return; + } + response.writeHead(400, { 'content-type': 'application/json' }); + response.end('x'.repeat(FCM_RESPONSE_MAX_BYTES * 3)); + }); + try { + const transport = FetchFcmTransport.forTesting({ + tokenUrl: `${google.base}/token`, + sendBaseUrl: google.base, + }); + const signal = new AbortController().signal; + const sent = await transport.sendMessage('p-123456', 't', '{}', signal); + expect(sent.status).toBe(400); + expect(Buffer.byteLength(sent.body)).toBe(FCM_RESPONSE_MAX_BYTES); + + const redirected = await transport.requestToken('a=b', signal); + expect(redirected.status).toBe(302); + expect(google.received).toHaveLength(2); + } finally { + await close(google.server); + } + }); + + it('끊기 신호가 오면 기다리지 않고 실패한다', async () => { + const google = await fakeGoogle(() => { + // 답하지 않는다 + }); + try { + const transport = FetchFcmTransport.forTesting({ + sendBaseUrl: google.base, + }); + const controller = new AbortController(); + const pending = transport.sendMessage( + 'p-123456', + 't', + '{}', + controller.signal, + ); + controller.abort(); + await expect(pending).rejects.toThrow(); + } finally { + await close(google.server); + } + }); +}); diff --git a/backend/src/modules/push/fcm/fcm-transport.ts b/backend/src/modules/push/fcm/fcm-transport.ts new file mode 100644 index 0000000..ad986dc --- /dev/null +++ b/backend/src/modules/push/fcm/fcm-transport.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@nestjs/common'; + +import { + FCM_OAUTH_TOKEN_URL, + FCM_RESPONSE_MAX_BYTES, + FCM_SEND_BASE_URL, +} from './fcm.constants'; + +/** + * FCM 의 HTTP 두 가지 — OAuth 토큰 교환 · 메시지 보내기 (fcm-auth.ts · fcm-sender.ts 가 주입받는다). + * 앱은 전역 fetch 로 Google 의 두 주소(443)에만 간다. 단위 시험은 가짜를, 이 파일의 시험은 127.0.0.1 에 띄운 서버를 쓴다 — + * 그래서 시험 전용 자리(forTesting)가 있다. 앱(Nest 의 useClass)은 쓰지 않는다: 주소는 상수이고 env · 설정으로 열지 않는다 + */ +export const FCM_TRANSPORT = Symbol('FCM_TRANSPORT'); + +/** 받은 응답 — 본문은 FCM_RESPONSE_MAX_BYTES 까지만 읽는다 (오류 모양을 가르는 데만 쓴다) */ +export interface FcmHttpResponse { + readonly status: number; + /** Retry-After 머리 그대로 — 없으면 null */ + readonly retryAfter: string | null; + readonly body: string; +} + +export interface FcmTransport { + /** OAuth 토큰 교환 — form 은 `application/x-www-form-urlencoded` 본문 */ + requestToken(form: string, signal: AbortSignal): Promise; + /** `POST /v1/projects/{projectId}/messages:send` — body 는 `{ message }` JSON */ + sendMessage( + projectId: string, + accessToken: string, + body: string, + signal: AbortSignal, + ): Promise; +} + +/** 가는 곳 — 앱은 Google 의 두 주소 고정 */ +export interface FcmEndpoints { + readonly tokenUrl: string; + readonly sendBaseUrl: string; +} + +const DEFAULT_ENDPOINTS: FcmEndpoints = Object.freeze({ + tokenUrl: FCM_OAUTH_TOKEN_URL, + sendBaseUrl: FCM_SEND_BASE_URL, +}); + +/** 본문을 maxBytes 까지만 읽는다 — 넘는 뒤는 버리고 연결을 닫는다(UTF-8 이 중간에서 잘려도 오류 모양을 보는 데는 상관없다) */ +async function readLimited( + response: Response, + maxBytes: number, +): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + try { + while (received < maxBytes) { + const { done, value } = await reader.read(); + if (done) break; + const room = maxBytes - received; + const chunk = value.byteLength > room ? value.subarray(0, room) : value; + chunks.push(chunk); + received += chunk.byteLength; + } + } finally { + // 다 읽지 않았으면 나머지를 받지 않는다 (다 읽었으면 아무 일도 하지 않는다) + await reader.cancel().catch(() => undefined); + } + return Buffer.concat(chunks).toString('utf8'); +} + +/** + * 전역 fetch 로 보낸다 — 다른 곳으로 보내는 응답(3xx)은 따라가지 않는다(Authorization 머리가 다른 호스트로 가지 않게 — 부른 쪽이 설정 오류로 본다) + */ +@Injectable() +export class FetchFcmTransport implements FcmTransport { + private endpoints: FcmEndpoints = DEFAULT_ENDPOINTS; + + /** 시험 전용 — 127.0.0.1 에 띄운 가짜 서버로 보낸다 (앱은 Google 주소 고정) */ + static forTesting(endpoints: Partial): FetchFcmTransport { + const transport = new FetchFcmTransport(); + transport.endpoints = { ...DEFAULT_ENDPOINTS, ...endpoints }; + return transport; + } + + requestToken(form: string, signal: AbortSignal): Promise { + return this.post( + this.endpoints.tokenUrl, + { 'content-type': 'application/x-www-form-urlencoded' }, + form, + signal, + ); + } + + sendMessage( + projectId: string, + accessToken: string, + body: string, + signal: AbortSignal, + ): Promise { + return this.post( + `${this.endpoints.sendBaseUrl}/v1/projects/${encodeURIComponent(projectId)}/messages:send`, + { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json; charset=utf-8', + }, + body, + signal, + ); + } + + private async post( + url: string, + headers: Record, + body: string, + signal: AbortSignal, + ): Promise { + const response = await fetch(url, { + method: 'POST', + headers, + body, + redirect: 'manual', + signal, + }); + return { + status: response.status, + retryAfter: response.headers.get('retry-after'), + body: await readLimited(response, FCM_RESPONSE_MAX_BYTES), + }; + } +} diff --git a/backend/src/modules/push/fcm/fcm.constants.ts b/backend/src/modules/push/fcm/fcm.constants.ts new file mode 100644 index 0000000..0318113 --- /dev/null +++ b/backend/src/modules/push/fcm/fcm.constants.ts @@ -0,0 +1,53 @@ +/** + * FCM HTTP v1 보내기의 값 — 주소 · 시간 · 메시지 칸 (계약 1절 "앱 새 메일 알림 · 앱 설정"의 "푸시 모양"). + * 주소는 상수다 — 키 파일의 token_uri 는 쓰지 않는다(같은 값인지만 본다). 시험만 forTesting 으로 바꾼다(fcm-transport.ts — env · 설정으로는 열지 않는다) + */ + +/** OAuth 토큰 교환 주소 (서비스 계정 JWT → access token) */ +export const FCM_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +/** 보내기 주소의 앞 — `/v1/projects/{projectId}/messages:send` 를 붙인다 */ +export const FCM_SEND_BASE_URL = 'https://fcm.googleapis.com'; +/** 보내기 권한 범위 */ +export const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'; + +/** 서비스 계정 JWT 의 수명(초) — Google 이 받는 최대 1시간 */ +export const FCM_ASSERTION_LIFETIME_SECONDS = 3_600; +/** access token 을 만료 이만큼 전까지만 쓴다 — 보내는 사이 끝나지 않게 */ +export const FCM_ACCESS_TOKEN_REFRESH_MARGIN_MS = 5 * 60_000; +/** access token 의 모양 — 공백 없는 글 · 이 길이까지 (Google 은 수백 자) */ +export const FCM_ACCESS_TOKEN_MAX_LENGTH = 4_096; + +/** HTTP 한 번(토큰 교환 · 보내기 시도)의 제한 시간 */ +export const FCM_REQUEST_TIMEOUT_MS = 10_000; +/** 기기 하나에 보내기 한 번 — 다시 하기까지 이 안에서 */ +export const FCM_SEND_DEADLINE_MS = 20_000; +/** 시도 수 — 잠깐의 장애(429 · 5xx · 연결 오류 · 시간 초과)만 다시 한다 */ +export const FCM_SEND_ATTEMPTS = 3; +/** 다시 하기 전 쉬는 시간 — 첫 실패 뒤 1초, 두 번째 뒤 4초 (± 흔들림). 응답의 Retry-After 가 있으면 그것을 따른다(남은 시간 안에서만) */ +export const FCM_RETRY_DELAYS_MS: readonly number[] = [1_000, 4_000]; +/** 쉬는 시간의 흔들림 (±20%) — 장애가 풀린 순간 모두 함께 다시 보내지 않게 */ +export const FCM_RETRY_JITTER = 0.2; +/** 응답 본문은 이만큼까지만 읽는다 (오류 모양을 가르는 데만 쓴다) */ +export const FCM_RESPONSE_MAX_BYTES = 64 * 1024; +/** 서버 전체에서 함께 보내는 수 — 새 메일이 여러 직원에게 한꺼번에 와도 */ +export const FCM_SEND_CONCURRENCY = 4; +/** 설정 오류 · 끝내 못 보냄 로그는 종류마다 이 간격에 한 번 (사이에 묻힌 수를 함께 적는다) */ +export const FCM_FAILURE_LOG_INTERVAL_MS = 60 * 60_000; + +/** + * 서비스 계정 키 env — backend/.env.{env} (JWT_SECRET 과 같은 자리 — compose 의 env_file). 키 JSON 파일을 통째로 base64 한 줄. + * 비면 앱 알림을 끈다(기동은 한다). 모양은 env 검사 · 보내는 쪽이 같은 함수(fcm-credentials.ts)로 본다 + */ +export const FCM_SERVICE_ACCOUNT_KEY_ENV = 'FCM_SERVICE_ACCOUNT_KEY'; +/** 서비스 계정 키 env 의 길이 상한 — 키 파일(2KB 남짓)의 base64 에 넉넉히 */ +export const FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH = 16 * 1024; + +/** 메시지 칸 (계약의 "푸시 모양") — 안드로이드 알림 하나를 같은 tag 로 바꿔 쓴다 */ +export const FCM_NOTIFICATION_TAG = 'new-mail'; +export const FCM_COLLAPSE_KEY = 'new-mail'; +/** 앱이 만드는 알림 채널 id (중요도 높음 · 이름 "새 메일") */ +export const FCM_CHANNEL_ID = 'new_mail'; +/** 꺼져 있던 휴대폰이 이 안에 켜지면 마지막 것 하나를 받는다 — 넘으면 버린다 */ +export const FCM_TTL = '86400s'; +/** 누르면 갈 모듈 (앱 README — 알림에는 모듈 이름과 알림 종류만 싣는다) */ +export const PUSH_MODULE_MAIL = 'mail'; diff --git a/backend/src/modules/push/push-cleanup.service.spec.ts b/backend/src/modules/push/push-cleanup.service.spec.ts new file mode 100644 index 0000000..d839298 --- /dev/null +++ b/backend/src/modules/push/push-cleanup.service.spec.ts @@ -0,0 +1,184 @@ +import { Logger } from '@nestjs/common'; + +import { + SESSION_ENDED_NOTICE, + SESSION_ENDED_NOTICE_REASONS, +} from './push.constants'; +import { PushCleanupService } from './push-cleanup.service'; +import type { + EndedPushRegistration, + PushRegistrationsRepository, +} from './push-registrations.repository'; +import type { PushRegistrationsService } from './push-registrations.service'; +import type { PushNotice, PushSender } from './push-sender.port'; + +const ARA = 'ara.kim@arasystem.test'; +const DOYUN = 'doyun.lee@arasystem.test'; + +const ended = ( + overrides: Partial, +): EndedPushRegistration => ({ + sessionId: '0b6f4a1e-7c43-4c39-9a51-0d2f3c7b8e10', + userEmail: ARA, + fid: 'fid-ara-phone', + notify: true, + ...overrides, +}); + +function setup( + rows: EndedPushRegistration[] | Error, + options: { + available?: boolean; + domain?: string | null; + /** 그사이 다른 등록이 쓰고 있는 FID (그 휴대폰에서 다른 직원이 로그인했다) */ + takenFids?: string[]; + } = {}, +) { + const taken = new Set(options.takenFids ?? []); + const repository = { + takeEnded: jest.fn(() => + rows instanceof Error ? Promise.reject(rows) : Promise.resolve(rows), + ), + isFidFree: jest.fn((fid: string) => Promise.resolve(!taken.has(fid))), + }; + const registrations = { notifyChanged: jest.fn() }; + const sent: { fid: string; notice: PushNotice }[] = []; + const sender: PushSender = { + available: options.available ?? true, + // 보내는 쪽처럼 — 보내기 직전에 부른 쪽의 확인을 본다 + send: async (fid, notice, sendOptions) => { + if (sendOptions?.stillValid && !(await sendOptions.stillValid())) { + return 'skipped'; + } + sent.push({ fid, notice }); + return 'sent'; + }, + }; + const service = new PushCleanupService( + repository as unknown as PushRegistrationsRepository, + registrations as unknown as PushRegistrationsService, + sender, + { + domain: options.domain === undefined ? 'arasystem.test' : options.domain, + }, + ); + return { service, repository, registrations, sent }; +} + +describe('세션 끝 알림을 보내는 까닭 (SESSION_ENDED_NOTICE — 표 한 곳)', () => { + it('그 휴대폰을 직원이 건드리지 않고 끝난 까닭만 보낸다 — 다른 곳의 비밀번호 바꾸기 · 결과 모름 · 앱 갱신 유예 · 토큰 재사용', () => { + expect([...SESSION_ENDED_NOTICE_REASONS].sort()).toEqual([ + 'app-refresh-grace', + 'password-change-uncertain', + 'password-changed', + 'refresh-token-reused', + ]); + }); + + it('직원 · 운영자가 끊었거나 계정을 쓸 수 없거나 제한된 세션이면 보내지 않는다 — 로그아웃 · 기기 끊기 · 운영 도구 · 계정 문제 · 2단계 켜기', () => { + const silent = Object.entries(SESSION_ENDED_NOTICE) + .filter(([, notify]) => !notify) + .map(([reason]) => reason) + .sort(); + expect(silent).toEqual([ + 'account-unavailable', + 'ended-by-operator', + 'ended-by-user', + 'logout', + 'two-factor-not-verified', + ]); + }); +}); + +describe('PushCleanupService (끝난 세션의 앱 알림 등록 정리 — 5분마다)', () => { + /** 지운 수는 debug — 시험 출력에 섞지 않고 모아서 본다(FID 가 없는지) */ + let debugLines: string[]; + + beforeEach(() => { + debugLines = []; + jest + .spyOn(Logger.prototype, 'debug') + .mockImplementation((message: unknown) => { + debugLines.push(String(message)); + }); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('사내 도메인의 끝난 세션 줄을 지우고, 알릴 세션(notify — 저절로 끝남 · 보내는 까닭)의 휴대폰에만 한 번 알린다(session-ended) — 등록이 바뀐 직원을 알린다', async () => { + const now = new Date('2026-11-07T05:00:00Z'); + const { service, repository, registrations, sent } = setup([ + ended({ fid: 'fid-expired' }), + ended({ fid: 'fid-revoked', notify: false }), + ended({ userEmail: DOYUN, fid: 'fid-doyun', notify: true }), + ]); + + await expect(service.sweep(now)).resolves.toEqual({ + removed: 3, + notified: 2, + }); + expect(repository.takeEnded).toHaveBeenCalledWith('@arasystem.test', now); + expect(sent).toEqual([ + { fid: 'fid-expired', notice: { kind: 'session-ended' } }, + { fid: 'fid-doyun', notice: { kind: 'session-ended' } }, + ]); + expect(registrations.notifyChanged.mock.calls).toEqual([[ARA], [DOYUN]]); + // 로그는 수만 — FID 는 남기지 않는다 + expect(debugLines).toHaveLength(1); + expect(debugLines[0]).not.toMatch(/fid-/); + }); + + it('알리기 직전 그 휴대폰(FID)을 다른 등록이 쓰고 있으면 알리지 않는다 — 그 휴대폰에서 로그인한 다른 직원에게 "로그인이 끝났다"가 가지 않게', async () => { + const { service, repository, sent } = setup( + [ended({ fid: 'fid-taken' }), ended({ fid: 'fid-free' })], + { takenFids: ['fid-taken'] }, + ); + await expect(service.sweep()).resolves.toEqual({ removed: 2, notified: 1 }); + expect(repository.isFidFree.mock.calls).toEqual([ + ['fid-taken'], + ['fid-free'], + ]); + expect(sent.map(({ fid }) => fid)).toEqual(['fid-free']); + }); + + it('FCM 키가 없으면 지우기만 한다', async () => { + const { service, sent } = setup([ended({})], { available: false }); + await expect(service.sweep()).resolves.toEqual({ removed: 1, notified: 0 }); + expect(sent).toEqual([]); + }); + + it('지울 것이 없으면 아무것도 보내지 않는다 · 사내 도메인 설정이 없으면 표를 묻지도 않는다', async () => { + const empty = setup([]); + await expect(empty.service.sweep()).resolves.toEqual({ + removed: 0, + notified: 0, + }); + expect(empty.registrations.notifyChanged).not.toHaveBeenCalled(); + + const noDomain = setup([ended({})], { domain: null }); + await expect(noDomain.service.sweep()).resolves.toEqual({ + removed: 0, + notified: 0, + }); + expect(noDomain.repository.takeEnded).not.toHaveBeenCalled(); + }); + + it('정리가 실패하면 error 한 줄(종류만 — DB 문장의 값은 남기지 않는다) — 다음 차례에 다시 한다', async () => { + const errors: string[] = []; + jest + .spyOn(Logger.prototype, 'error') + .mockImplementation((message: unknown) => { + errors.push(String(message)); + }); + const failure = Object.assign(new Error(`relation broke near ${ARA}`), { + code: '57P01', + }); + const { service } = setup(failure); + + await expect(service.sweep()).resolves.toEqual({ removed: 0, notified: 0 }); + expect(errors).toEqual([ + expect.stringContaining('Error/57P01') as unknown as string, + ]); + expect(errors[0]).not.toContain(ARA); + }); +}); diff --git a/backend/src/modules/push/push-cleanup.service.ts b/backend/src/modules/push/push-cleanup.service.ts new file mode 100644 index 0000000..6c13dcd --- /dev/null +++ b/backend/src/modules/push/push-cleanup.service.ts @@ -0,0 +1,97 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; + +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { PUSH_CLEANUP_CRON, PUSH_CLEANUP_JOB } from './push.constants'; +import { PushRegistrationsRepository } from './push-registrations.repository'; +import { PushRegistrationsService } from './push-registrations.service'; +import { PUSH_SENDER, type PushSender } from './push-sender.port'; + +/** 한 번의 정리 결과 (시험 · 로그) */ +export interface PushCleanupResult { + readonly removed: number; + /** 로그인이 끝났다고 알린 휴대폰 수 (세션 끝 알림 — 보내는 까닭은 SESSION_ENDED_NOTICE) */ + readonly notified: number; +} + +/** DB 오류의 종류만 — 문장에는 값(주소 등)이 들어갈 수 있다 */ +function failureSummary(error: unknown): string { + const name = error instanceof Error ? error.name : typeof error; + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? `${name}/${code}` : name; +} + +/** + * 끝난 세션의 앱 알림 등록 정리 (계약 1절 "앱 새 메일 알림 · 앱 설정") — 5분마다. + * - 끊긴 세션(로그아웃 · 로그인된 기기에서 끊기 · 비밀번호 바꾸기 · 운영 도구) · 끝난 세션 · 세션 줄이 없는 등록을 지운다. + * 보낼 때는 이미 살아 있는 세션만 고르므로(push-registrations.repository.ts) 정리가 늦어도 틀리지 않는다 + * - **그 휴대폰을 직원이 건드리지 않았는데** 끝난 세션 — 저절로 끝남(미사용 14일 · 절대 30일) · 다른 곳에서 비밀번호를 바꿈 · 앱 갱신 유예 · 토큰 재사용 감지, + * 제한 없던 세션만(까닭 표 push.constants.ts 의 SESSION_ENDED_NOTICE) — 의 휴대폰에는 지운 뒤 한 번 알린다(session-ended — 같은 tag 로 + * 옛 "새 메일 N통"을 바꿔 쓴다). 직원 · 운영자가 끊었거나 계정을 쓸 수 없으면 알리지 않는다. + * 보내기 직전 그 FID 를 다른 등록이 쓰고 있으면(그 휴대폰에서 다른 직원이 로그인했다) 알리지 않는다. + * 보내지 못해도 다시 하지 않는다(줄은 이미 없다). FCM 키가 없으면 지우기만 한다 + * - 사내 도메인(MAIL_DOMAIN) 주소만 — 같은 DB 의 다른 도메인 줄(e2e 등)은 그쪽 서버가 한다 + * 반복되는 정상 흐름이라 지운 수는 debug 로만 남긴다. 실패는 error 한 줄 — 다음 차례에 다시 한다 + * + * ⚠️ 인스턴스를 늘려도 알림은 겹치지 않는다 — 지운 인스턴스만 그 줄을 받는다(DELETE … RETURNING) + */ +@Injectable() +export class PushCleanupService { + private readonly logger = new Logger(PushCleanupService.name); + /** 정리하는 주소의 끝 — `@사내 도메인` (설정이 없으면 null — 아무것도 하지 않는다) */ + private readonly addressSuffix: string | null; + private running = false; + + constructor( + private readonly repository: PushRegistrationsRepository, + private readonly registrations: PushRegistrationsService, + @Inject(PUSH_SENDER) private readonly sender: PushSender, + @Inject(MAIL_SETTINGS) settings: Pick, + ) { + const domain = settings.domain?.trim().toLowerCase(); + this.addressSuffix = domain ? `@${domain}` : null; + } + + @Cron(PUSH_CLEANUP_CRON, { name: PUSH_CLEANUP_JOB }) + async sweep(now: Date = new Date()): Promise { + if (this.running || this.addressSuffix === null) { + return { removed: 0, notified: 0 }; + } + this.running = true; + try { + const ended = await this.repository.takeEnded(this.addressSuffix, now); + if (ended.length === 0) return { removed: 0, notified: 0 }; + + const notices = this.sender.available + ? ended.filter((row) => row.notify) + : []; + // 보내기 직전 그 휴대폰에서 다른 직원이 등록했으면 알리지 않는다 — 그 직원에게 "로그인이 끝났다"가 가지 않게 + const outcomes = await Promise.all( + notices.map((row) => + this.sender.send( + row.fid, + { kind: 'session-ended' }, + { stillValid: () => this.repository.isFidFree(row.fid) }, + ), + ), + ); + const notified = outcomes.filter((outcome) => outcome === 'sent').length; + + for (const user of new Set(ended.map((row) => row.userEmail))) { + this.registrations.notifyChanged(user); + } + this.logger.debug( + `끝난 세션의 앱 알림 등록 ${ended.length}건을 지웠습니다 (로그인이 끝났다고 알림 ${notified}건).`, + ); + return { removed: ended.length, notified }; + } catch (error) { + // 정리가 실패해도 앱은 계속 돈다 — 다음 차례에 다시 한다 + this.logger.error( + `[SYS_001] 끝난 세션의 앱 알림 등록을 정리하지 못했습니다: ${failureSummary(error)}`, + ); + return { removed: 0, notified: 0 }; + } finally { + this.running = false; + } + } +} diff --git a/backend/src/modules/push/push-registration.entity.ts b/backend/src/modules/push/push-registration.entity.ts new file mode 100644 index 0000000..24c517b --- /dev/null +++ b/backend/src/modules/push/push-registration.entity.ts @@ -0,0 +1,70 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { + PUSH_FID_MAX_LENGTH, + PUSH_REGISTRATIONS_TABLE, + type PushPlatform, +} from './push.constants'; + +/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — auth-session.entity.ts 와 같다 */ +const EMAIL_COLUMN_LENGTH = 254; +/** 플랫폼 이름 길이 — 'android' (여유를 둔다) */ +const PLATFORM_COLUMN_LENGTH = 16; +/** 앱 판 길이 — 'NNNN.NNNN.NNNN' 까지 (push.constants 의 APP_VERSION_MAX_LENGTH 에 여유) */ +const APP_VERSION_COLUMN_LENGTH = 16; + +/** + * 앱 알림 등록 (계약 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계) — 앱 세션(sid)마다 한 줄, 그 세션의 휴대폰 주소(FID)를 둔다. + * + * - 같은 FID 는 한 줄에만 있다(유일 색인) — 같은 휴대폰에서 다른 직원이 로그인하면 앞 직원의 줄을 지운다(그 알림이 다른 사람 휴대폰으로 가지 않게) + * - 세션 표와 외래 키로 묶지 않는다 — 두 모듈의 정리 작업이 서로를 기다리지 않게(share-link-file.entity.ts 와 같은 결). + * 보낼 때는 살아 있는 · 제한 없는 앱 세션의 줄만 고르고, 끝난 세션의 줄은 5분 정리(push-cleanup.service.ts)가 지운다 + * - 켬 · 끔은 휴대폰이 들고 있다 — 끄면 앱이 줄을 지운다(DELETE). 서버의 칸에는 없다 + * - FID 원문은 보낼 때만 쓴다 — 로그에 남기지 않는다(지문도 — 이어 붙여 기기를 좇지 못하게) + */ +@Entity({ name: PUSH_REGISTRATIONS_TABLE }) +export class PushRegistrationEntity { + /** 등록한 앱 세션 id (auth_sessions.id — 액세스 토큰의 sid) */ + @PrimaryColumn({ name: 'session_id', type: 'uuid' }) + sessionId!: string; + + /** 직원 주소 — 세션 줄의 주소를 그대로 옮겨 적는다. 직원마다 읽기 · 상한 · 퇴사 마무리가 이 칸으로 찾는다 */ + @Index('IDX_push_registrations_user_email') + @Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH }) + userEmail!: string; + + /** 휴대폰 주소 — Firebase 설치 id(FID). FCM v1 의 `message.fid` 로 보낸다 */ + @Index('UQ_push_registrations_fid', { unique: true }) + @Column({ type: 'varchar', length: PUSH_FID_MAX_LENGTH }) + fid!: string; + + /** 플랫폼 — 지금은 android 만 */ + @Column({ type: 'varchar', length: PLATFORM_COLUMN_LENGTH }) + platform!: PushPlatform; + + /** 앱 판 (`1.0.0`) — 문의 대응 · 옛 판 확인 */ + @Column({ + name: 'app_version', + type: 'varchar', + length: APP_VERSION_COLUMN_LENGTH, + }) + appVersion!: string; + + /** + * 이 휴대폰에서 마지막으로 앱을 본 때 — 알림의 N(받은편지함의 안 읽은 메일 가운데 이 뒤에 받은 수)을 세는 시작. + * 처음 등록할 때 지금, 앱이 `DELETE …/current/unseen` 을 부르면 지금. 등록을 다시 해도(PUT) 바꾸지 않는다 + */ + @Column({ name: 'seen_at', type: 'timestamptz' }) + seenAt!: Date; + + /** 마지막으로 보낸 때(그 알림의 N 을 센 때) — 서버를 다시 띄울 때 그사이 온 메일을 한 번 알리되, 이미 알린 메일은 다시 알리지 않는다 */ + @Column({ name: 'last_pushed_at', type: 'timestamptz', nullable: true }) + lastPushedAt!: Date | null; + + @Column({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + /** 마지막으로 등록(PUT)한 때 — 직원마다 상한을 넘으면 이것이 가장 오래된 줄부터 지운다 */ + @Column({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/push/push-registrations.controller.ts b/backend/src/modules/push/push-registrations.controller.ts new file mode 100644 index 0000000..27c3808 --- /dev/null +++ b/backend/src/modules/push/push-registrations.controller.ts @@ -0,0 +1,120 @@ +import { + Body, + Controller, + Delete, + HttpCode, + HttpStatus, + Put, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + type AuthenticatedUser, + CurrentUser, +} from '@/common/decorators/current-user.decorator'; +import { NoStore } from '@/common/decorators/no-store.decorator'; +import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; +import { AppClientGuard } from '@/common/guards/app-client.guard'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + PushRegistrationDto, + PushRegistrationResultDto, +} from './dto/push-registration.dto'; +import { MAX_PUSH_REGISTRATIONS_PER_USER } from './push.constants'; +import { PushRegistrationsService } from './push-registrations.service'; + +/** + * 앱 알림 등록 (계약 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계) — 앱 토큰(Bearer)만. 지금 세션(액세스 토큰의 sid)의 등록 하나를 가리킨다(`current`). + * 제한된 세션은 부를 수 없다(@AllowRestrictedSession 을 달지 않는다 — 403 AUTH_010). 웹 쿠키 세션은 403 AUTH_003(AppClientGuard). + * 판단은 모두 PushRegistrationsService 가 한다. FID 는 로그에 남기지 않는다 + */ +@ApiTags('Push') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, AppClientGuard) +@ApiErrorResponse( + 'AUTH_001', + '로그인 필요 (앱의 Bearer 토큰 없음 · 만료 · 끊은 세션 · 그사이 끝난 세션) — 앱은 갱신(/auth/app/refresh)을 한 번 시도한다', +) +// 같은 403 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다) +@ApiErrorResponse( + 'AUTH_003', + 'AUTH_003 웹(쿠키) 세션 — 앱 토큰만 받는다 / AUTH_010 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전) (모두 403)', +) +@Controller('push-registrations') +export class PushRegistrationsController { + constructor(private readonly registrations: PushRegistrationsService) {} + + @Put('current') + @HttpCode(HttpStatus.OK) + @NoStore() + @ApiOperation({ + summary: '이 앱 세션의 알림 등록 — 휴대폰 주소(FID)를 넣거나 바꾼다', + description: + '세션마다 하나다. 같은 FID 의 다른 등록(같은 휴대폰에서 다른 직원이 로그인 · 다시 로그인)은 지운다. ' + + `한 직원 ${MAX_PUSH_REGISTRATIONS_PER_USER}개까지 — 넘으면 가장 오래 바꾸지 않은 등록부터 지운다. ` + + '새 등록이면 알림의 수(N)를 지금부터 세고, 있던 등록은 세는 시작을 바꾸지 않는다(봤음은 DELETE …/current/unseen). ' + + 'FCM 키가 없어도(app-config 의 pushAvailable: false) 저장한다. 앱은 FID 를 받았을 때 · 바뀌었을 때 · 등록된 채 앱을 띄울 때 한 번 · 알림을 켤 때 부른다.', + }) + @ApiStandardResponse(PushRegistrationResultDto, { + description: '등록함 (updatedAt)', + }) + @ApiErrorResponse( + 'VAL_001', + '본문 형식 오류 (fid · platform · appVersion 모양 · 모르는 칸)', + ) + register( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: PushRegistrationDto, + ): Promise { + return this.registrations.register(user, { + fid: dto.fid, + platform: dto.platform, + appVersion: dto.appVersion, + }); + } + + @Delete('current') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() + @ApiOperation({ + summary: '이 앱 세션의 알림 등록을 지운다 (없어도 204)', + description: + '앱이 알림을 껐을 때 · 휴대폰에서 알림 권한이나 채널이 꺼진 것을 알았을 때 부른다. ' + + '로그아웃 때는 부르지 않아도 된다 — 끝난 세션에는 보내지 않고 5분 정리가 지운다.', + }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: '지움 — 본문 없음', + }) + async unregister(@CurrentUser() user: AuthenticatedUser): Promise { + await this.registrations.unregister(user); + } + + @Delete('current/unseen') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() + @ApiOperation({ + summary: + '이 휴대폰에서 앱을 봤다 — 다음 알림의 수(N)를 지금부터 센다 (없어도 204)', + description: + 'N 은 받은편지함의 안 읽은 메일 가운데 이 휴대폰에서 마지막으로 앱을 본 뒤 받은 수다(서버 시각). ' + + '앱은 앞으로 올 때 · 앞에 있는 동안 새 메일 소식이 올 때 부른다(30초에 한 번까지).', + }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: '적음 — 본문 없음', + }) + async markSeen(@CurrentUser() user: AuthenticatedUser): Promise { + await this.registrations.markSeen(user); + } +} diff --git a/backend/src/modules/push/push-registrations.repository.ts b/backend/src/modules/push/push-registrations.repository.ts new file mode 100644 index 0000000..2535d3c --- /dev/null +++ b/backend/src/modules/push/push-registrations.repository.ts @@ -0,0 +1,364 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import type { DataSource, EntityManager } from 'typeorm'; + +import { + APP_SESSION_CLIENT, + AUTH_SESSIONS_TABLE, + MAX_PUSH_REGISTRATIONS_PER_USER, + PUSH_REGISTRATIONS_TABLE, + type PushPlatform, + SESSION_ENDED_NOTICE_REASONS, +} from './push.constants'; + +/** 등록 한 번의 값 — 세션은 액세스 토큰의 sid */ +export interface PushRegistrationInput { + readonly sessionId: string; + readonly fid: string; + readonly platform: PushPlatform; + readonly appVersion: string; +} + +/** 등록한 줄 — 직원 주소는 세션 줄에서 옮겨 적은 값 */ +export interface PushRegistered { + readonly userEmail: string; + readonly updatedAt: Date; + /** 이 등록 때문에 줄이 지워진 직원 — 같은 FID 의 다른 세션 · 상한을 넘은 줄 (등록이 바뀐 소식을 낸다) */ + readonly displacedUsers: readonly string[]; +} + +/** 보낼 기기 하나 — 살아 있는 · 제한 없는 앱 세션의 등록 */ +export interface PushTarget { + readonly sessionId: string; + readonly fid: string; + readonly seenAt: Date; + readonly lastPushedAt: Date | null; +} + +/** 정리가 지운 등록 */ +export interface EndedPushRegistration { + readonly sessionId: string; + readonly userEmail: string; + readonly fid: string; + /** + * 그 휴대폰에 한 번 알린다(session-ended) — 제한 없던 앱 세션이 저절로 끝났거나(미사용 14일 · 절대 30일 — 끊기지 않음) + * 보내는 까닭(push.constants.ts 의 SESSION_ENDED_NOTICE)으로 끊겼다. 그 밖(직원 · 운영자가 끊음 · 계정 문제 · 제한 · 세션 줄 없음)은 false + */ + readonly notify: boolean; +} + +/** + * 같은 FID 의 등록을 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(FID))` (트랜잭션이 끝나면 풀린다). 두 세션이 같은 FID 를 함께 넣어도 + * 뒤의 것이 앞의 줄을 보고 지운 뒤 넣는다(유일 색인에 걸리지 않게). 이름표 — 'PSH1' 의 ASCII (서명 'SIG1' · 파일 'FIL1' 과 섞이지 않게) + */ +const PUSH_FID_LOCK = 0x50534831; +/** 그래도 유일 색인에 걸리면(잠금 밖에서 넣은 줄) 한 번 더 한다 */ +const REGISTER_ATTEMPTS = 2; +/** PostgreSQL 의 unique_violation */ +const UNIQUE_VIOLATION = '23505'; + +/** + * 살아 있는 · 제한 없는 앱 세션 — 별칭 `s` 의 auth_sessions 줄, 지금은 `now` 매개변수 (보낼 대상 · 등록 · 붙잡을 직원이 같은 조건을 쓴다). + * 끊은 세션은 revoked_at 으로 바로 빠진다 — 서버의 끊은 세션 기억(15초 동기화)과 상관없이, 운영 도구가 끊은 것도 + */ +function aliveAppSession(now: string): string { + return `s."client" = '${APP_SESSION_CLIENT}' AND s."revoked_at" IS NULL AND s."expires_at" > ${now} AND s."absolute_expires_at" > ${now} AND s."restriction" IS NULL`; +} + +/** 주소가 그 끝(`@사내 도메인` — 소문자)으로 끝나는가 (LIKE 의 특수 글자를 신경 쓰지 않게 right 로 견준다) */ +function endsWithSuffix(column: string, suffix: string): string { + return `right(lower(${column}), char_length(${suffix})) = ${suffix}`; +} + +function isUniqueViolation(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const { code, driverError } = error as { + code?: unknown; + driverError?: { code?: unknown }; + }; + return code === UNIQUE_VIOLATION || driverError?.code === UNIQUE_VIOLATION; +} + +/** UPDATE · DELETE 의 결과는 [줄들, 바꾼 수] (TypeORM 의 postgres 드라이버) — INSERT 는 줄들 */ +type ChangedRows = [T[], number]; + +/** + * 앱 알림 등록 표 접근 (push_registrations — 계약 1절 "앱 새 메일 알림 · 앱 설정"). 판단(누가 · 언제)은 서비스가 한다. + * 세션 표(auth_sessions)는 읽기만 한다 — 외래 키는 없다(push-registration.entity.ts) + */ +@Injectable() +export class PushRegistrationsRepository { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * 등록 — 한 트랜잭션: ① 같은 FID 의 다른 줄(다른 세션 · 다른 직원)을 지운다 → ② 세션 줄이 살아 있는 · 제한 없는 앱 세션이면 넣거나 바꾼다 + * (FID · 플랫폼 · 판 · updated_at — 새 줄이면 seen_at = 지금, 있던 줄의 seen_at · last_pushed_at 은 그대로) → ③ 그 직원의 상한을 넘으면 + * 가장 오래 바꾸지 않은 줄부터 지운다. 세션이 그런 세션이 아니면(그사이 끝남 · 끊김 · 웹 · 제한) null — 아무것도 바꾸지 않는다 + */ + async register( + input: PushRegistrationInput, + now: Date, + ): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await this.dataSource.transaction((manager) => + this.registerOnce(manager, input, now), + ); + } catch (error) { + if (attempt < REGISTER_ATTEMPTS && isUniqueViolation(error)) continue; + throw error; + } + } + } + + /** + * 등록하지 못한 세션의 까닭 — 살아 있는 앱 세션인데 제한이 붙었으면 restricted(토큰을 받은 뒤 갱신에서 제한이 붙었다), + * 그 밖(줄 없음 · 끝남 · 끊김 · 웹 세션)은 ended + */ + async refusalOf( + sessionId: string, + now: Date, + ): Promise<'restricted' | 'ended'> { + const rows: unknown[] = await this.dataSource.query( + `SELECT 1 FROM "${AUTH_SESSIONS_TABLE}" s + WHERE s."id" = $1 AND s."client" = '${APP_SESSION_CLIENT}' AND s."revoked_at" IS NULL + AND s."expires_at" > $2 AND s."absolute_expires_at" > $2 AND s."restriction" IS NOT NULL`, + [sessionId, now], + ); + return rows.length > 0 ? 'restricted' : 'ended'; + } + + /** 세션의 등록을 지운다 → 지운 줄의 직원 주소 (없으면 null) */ + async remove(sessionId: string): Promise { + const [rows] = await this.dataSource.query< + ChangedRows<{ user_email: string }> + >( + `DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" WHERE "session_id" = $1 RETURNING "user_email"`, + [sessionId], + ); + return rows[0]?.user_email ?? null; + } + + /** 이 휴대폰에서 앱을 봤다 — N 을 세는 시작을 지금으로 → 바꿨으면 true */ + async markSeen(sessionId: string, now: Date): Promise { + const [, changed] = await this.dataSource.query>( + `UPDATE "${PUSH_REGISTRATIONS_TABLE}" SET "seen_at" = $2 WHERE "session_id" = $1`, + [sessionId, now], + ); + return changed > 0; + } + + /** 그 직원에게 보낼 기기 — 살아 있는 · 제한 없는 앱 세션의 등록 (먼저 등록한 차례) */ + async targetsOf(userEmail: string, now: Date): Promise { + const rows: { + session_id: string; + fid: string; + seen_at: Date; + last_pushed_at: Date | null; + }[] = await this.dataSource.query( + `SELECT r."session_id", r."fid", r."seen_at", r."last_pushed_at" + FROM "${PUSH_REGISTRATIONS_TABLE}" r + JOIN "${AUTH_SESSIONS_TABLE}" s ON s."id" = r."session_id" AND s."user_email" = r."user_email" + WHERE r."user_email" = $1 AND ${aliveAppSession('$2::timestamptz')} + ORDER BY r."created_at", r."session_id"`, + [userEmail, now], + ); + return rows.map((row) => ({ + sessionId: row.session_id, + fid: row.fid, + seenAt: new Date(row.seen_at), + lastPushedAt: + row.last_pushed_at === null ? null : new Date(row.last_pushed_at), + })); + } + + /** 보낼 기기가 하나라도 있는 직원 — `addressSuffix`(`@사내 도메인`)로 끝나는 주소만 */ + async usersWithTargets(addressSuffix: string, now: Date): Promise { + const rows: { user_email: string }[] = await this.dataSource.query( + `SELECT DISTINCT r."user_email" + FROM "${PUSH_REGISTRATIONS_TABLE}" r + JOIN "${AUTH_SESSIONS_TABLE}" s ON s."id" = r."session_id" AND s."user_email" = r."user_email" + WHERE ${endsWithSuffix('r."user_email"', '$1::text')} AND ${aliveAppSession('$2::timestamptz')} + ORDER BY r."user_email"`, + [addressSuffix, now], + ); + return rows.map((row) => row.user_email); + } + + /** 그 직원에게 보낼 기기가 있는가 */ + async hasTargets(userEmail: string, now: Date): Promise { + const rows: unknown[] = await this.dataSource.query( + `SELECT 1 + FROM "${PUSH_REGISTRATIONS_TABLE}" r + JOIN "${AUTH_SESSIONS_TABLE}" s ON s."id" = r."session_id" AND s."user_email" = r."user_email" + WHERE r."user_email" = $1 AND ${aliveAppSession('$2::timestamptz')} + LIMIT 1`, + [userEmail, now], + ); + return rows.length > 0; + } + + /** 보낸 기기 — 마지막으로 보낸 때(그 알림의 N 을 센 때)를 적는다 */ + async recordPushed(sessionIds: readonly string[], at: Date): Promise { + if (sessionIds.length === 0) return; + await this.dataSource.query( + `UPDATE "${PUSH_REGISTRATIONS_TABLE}" SET "last_pushed_at" = $2 WHERE "session_id" = ANY($1::uuid[])`, + [[...sessionIds], at], + ); + } + + /** FCM 이 받지 않는 기기 — 그 세션의 FID 가 그대로일 때만 지운다(그사이 등록이 새 FID 로 바꿨으면 둔다) → 지운 줄의 직원 주소 */ + async removeIfFid(sessionId: string, fid: string): Promise { + const [rows] = await this.dataSource.query< + ChangedRows<{ user_email: string }> + >( + `DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" WHERE "session_id" = $1 AND "fid" = $2 RETURNING "user_email"`, + [sessionId, fid], + ); + return rows[0]?.user_email ?? null; + } + + /** + * 보내기 직전 — 그 세션의 등록이 아직 그 FID 이고 세션이 살아 있는 · 제한 없는 앱 세션인가. + * 읽은 뒤 · 엔진에 센 뒤 · 다시 하기를 기다리는 사이 그 휴대폰이 다른 세션으로 옮겨 갔으면(같은 FID 의 PUT 이 이 줄을 지웠다) false + */ + async isDeliverable( + sessionId: string, + fid: string, + now: Date, + ): Promise { + const rows: unknown[] = await this.dataSource.query( + `SELECT 1 + FROM "${PUSH_REGISTRATIONS_TABLE}" r + JOIN "${AUTH_SESSIONS_TABLE}" s ON s."id" = r."session_id" AND s."user_email" = r."user_email" + WHERE r."session_id" = $1 AND r."fid" = $2 AND ${aliveAppSession('$3::timestamptz')}`, + [sessionId, fid, now], + ); + return rows.length > 0; + } + + /** 그 FID 를 쓰는 등록이 없는가 — 로그인이 끝난 휴대폰에 알리기 직전(그사이 그 휴대폰에서 다른 직원이 등록했으면 알리지 않는다) */ + async isFidFree(fid: string): Promise { + const rows: unknown[] = await this.dataSource.query( + `SELECT 1 FROM "${PUSH_REGISTRATIONS_TABLE}" WHERE "fid" = $1 LIMIT 1`, + [fid], + ); + return rows.length === 0; + } + + /** + * 끝난 세션의 등록을 지운다 → 지운 줄 (`addressSuffix` 로 끝나는 주소만 — 같은 DB 의 다른 도메인 줄은 그쪽 서버가 한다). + * 끝남 = 세션이 끊겼거나(revoked_at) · 저절로 끝났거나(미사용 · 절대 만료) · 앱 세션이 아니거나 · 세션 줄이 없다(정리 cron 이 지웠다). + * `notify` — 제한 없던 앱 세션이 저절로 끝났거나(revoked_at 없음) 보내는 까닭(SESSION_ENDED_NOTICE)으로 끊겼다: 그 휴대폰에 한 번 알린다. + * 까닭이 없거나 표에 없는 값이면 알리지 않는다. 지운 인스턴스만 이 줄을 받는다(DELETE … RETURNING — 두 번 알리지 않는다) + */ + async takeEnded( + addressSuffix: string, + now: Date, + ): Promise { + const [ended] = await this.dataSource.query< + ChangedRows<{ + session_id: string; + user_email: string; + fid: string; + notify: boolean; + }> + >( + `DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" r + USING "${AUTH_SESSIONS_TABLE}" s + WHERE s."id" = r."session_id" AND ${endsWithSuffix('r."user_email"', '$1::text')} + AND (s."revoked_at" IS NOT NULL OR s."expires_at" <= $2::timestamptz OR s."absolute_expires_at" <= $2::timestamptz + OR s."client" <> '${APP_SESSION_CLIENT}' OR s."user_email" <> r."user_email") + RETURNING r."session_id", r."user_email", r."fid", + (s."restriction" IS NULL AND s."client" = '${APP_SESSION_CLIENT}' AND s."user_email" = r."user_email" + AND (s."revoked_at" IS NULL + OR (s."revoked_reason" IS NOT NULL AND s."revoked_reason" = ANY($3::text[])))) AS "notify"`, + [addressSuffix, now, SESSION_ENDED_NOTICE_REASONS], + ); + const [orphans] = await this.dataSource.query< + ChangedRows<{ session_id: string; user_email: string; fid: string }> + >( + `DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" r + WHERE ${endsWithSuffix('r."user_email"', '$1::text')} + AND NOT EXISTS (SELECT 1 FROM "${AUTH_SESSIONS_TABLE}" s WHERE s."id" = r."session_id") + RETURNING r."session_id", r."user_email", r."fid"`, + [addressSuffix], + ); + return [ + ...ended.map((row) => ({ + sessionId: row.session_id, + userEmail: row.user_email, + fid: row.fid, + notify: row.notify === true, + })), + // 세션 줄이 없다(정리 cron 이 지웠다) — 까닭을 알 수 없어 알리지 않는다 + ...orphans.map((row) => ({ + sessionId: row.session_id, + userEmail: row.user_email, + fid: row.fid, + notify: false, + })), + ]; + } + + private async registerOnce( + manager: EntityManager, + input: PushRegistrationInput, + now: Date, + ): Promise { + // 그 세션이 살아 있는 · 제한 없는 앱 세션일 때만 — 아니면 아무것도 바꾸지 않는다(다른 세션의 줄도 지우지 않는다). + // 세션 줄은 잠그지 않는다 — 그사이 끊겨도 보낼 때 거르고 5분 정리가 지운다 + const sessions: { user_email: string }[] = await manager.query( + `SELECT s."user_email" FROM "${AUTH_SESSIONS_TABLE}" s WHERE s."id" = $1 AND ${aliveAppSession('$2::timestamptz')}`, + [input.sessionId, now], + ); + const userEmail = sessions[0]?.user_email; + if (userEmail === undefined) return null; + await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [ + PUSH_FID_LOCK, + input.fid, + ]); + // ① 같은 휴대폰(FID)의 다른 줄 — 같은 휴대폰에서 다른 직원이 로그인했다 · 다시 로그인했다 (지운 줄의 직원에게도 바뀐 소식을 낸다) + const [sameFid] = await manager.query>( + `DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" WHERE "fid" = $1 AND "session_id" <> $2 RETURNING "user_email"`, + [input.fid, input.sessionId], + ); + // ② 넣거나 바꾼다 — 새 줄이면 seen_at = 지금, 있던 줄의 seen_at · last_pushed_at · created_at 은 그대로 + const rows: { updated_at: Date }[] = await manager.query( + `INSERT INTO "${PUSH_REGISTRATIONS_TABLE}" + ("session_id", "user_email", "fid", "platform", "app_version", "seen_at", "last_pushed_at", "created_at", "updated_at") + VALUES ($1, $2, $3, $4, $5, $6, NULL, $6, $6) + ON CONFLICT ("session_id") DO UPDATE + SET "fid" = EXCLUDED."fid", "platform" = EXCLUDED."platform", "app_version" = EXCLUDED."app_version", "updated_at" = EXCLUDED."updated_at" + RETURNING "updated_at"`, + [ + input.sessionId, + userEmail, + input.fid, + input.platform, + input.appVersion, + now, + ], + ); + // ③ 직원마다 상한 — 가장 오래 바꾸지 않은 줄부터 (방금 바꾼 줄은 가장 새것이다) + const [overLimit] = await manager.query< + ChangedRows<{ user_email: string }> + >( + `DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" + WHERE "session_id" IN ( + SELECT "session_id" FROM "${PUSH_REGISTRATIONS_TABLE}" + WHERE "user_email" = $1 + ORDER BY "updated_at" DESC, "session_id" DESC + OFFSET $2) + RETURNING "user_email"`, + [userEmail, MAX_PUSH_REGISTRATIONS_PER_USER], + ); + return { + userEmail, + updatedAt: new Date(rows[0]?.updated_at ?? now), + displacedUsers: [ + ...new Set([...sameFid, ...overLimit].map((row) => row.user_email)), + ], + }; + } +} diff --git a/backend/src/modules/push/push-registrations.service.spec.ts b/backend/src/modules/push/push-registrations.service.spec.ts new file mode 100644 index 0000000..0887884 --- /dev/null +++ b/backend/src/modules/push/push-registrations.service.spec.ts @@ -0,0 +1,190 @@ +import { Logger } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { BusinessException } from '@/common/errors/business.exception'; +import type { PushRegistrationsRepository } from './push-registrations.repository'; +import { PushRegistrationsService } from './push-registrations.service'; + +const ARA = 'ara.kim@arasystem.test'; +const SESSION = '0b6f4a1e-7c43-4c39-9a51-0d2f3c7b8e10'; +const FID = 'dQw4w9WgXcQ-ExampleFid'; +const APP_USER: AuthenticatedUser = { + email: ARA, + name: '김아라', + client: 'app', + sessionId: SESSION, +}; + +function fakeRepository() { + return { + register: jest.fn(), + refusalOf: jest.fn(), + remove: jest.fn(), + markSeen: jest.fn().mockResolvedValue(true), + targetsOf: jest.fn().mockResolvedValue([]), + usersWithTargets: jest.fn().mockResolvedValue([]), + hasTargets: jest.fn().mockResolvedValue(false), + recordPushed: jest.fn().mockResolvedValue(undefined), + removeIfFid: jest.fn(), + isDeliverable: jest.fn().mockResolvedValue(true), + }; +} + +async function codeOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } + return undefined; +} + +describe('PushRegistrationsService (앱 알림 등록 — 이 앱 세션의 등록만)', () => { + let repository: ReturnType; + let service: PushRegistrationsService; + let changed: string[]; + + beforeEach(() => { + repository = fakeRepository(); + service = new PushRegistrationsService( + repository as unknown as PushRegistrationsRepository, + ); + changed = []; + service.onChanged((user) => changed.push(user)); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('등록 — 토큰의 세션(sid)으로 넣고 바꾼 때를 ISO 로 돌려준다, 그 직원의 등록이 바뀌었다고 알린다', async () => { + const updatedAt = new Date('2026-11-07T05:00:00.000Z'); + repository.register.mockResolvedValue({ + userEmail: ARA, + updatedAt, + displacedUsers: [], + }); + + await expect( + service.register(APP_USER, { + fid: FID, + platform: 'android', + appVersion: '1.0.0', + }), + ).resolves.toEqual({ updatedAt: '2026-11-07T05:00:00.000Z' }); + expect(repository.register).toHaveBeenCalledWith( + { + sessionId: SESSION, + fid: FID, + platform: 'android', + appVersion: '1.0.0', + }, + expect.any(Date), + ); + expect(changed).toEqual([ARA]); + }); + + it('같은 휴대폰의 앞 직원 줄을 지웠으면 그 직원에게도 바뀐 소식을 낸다 — 한 직원에 한 번 (리뷰 L2)', async () => { + const DOYUN = 'doyun.lee@arasystem.test'; + repository.register.mockResolvedValue({ + userEmail: ARA, + updatedAt: new Date('2026-11-07T05:00:00.000Z'), + displacedUsers: [DOYUN, ARA], + }); + await service.register(APP_USER, { + fid: FID, + platform: 'android', + appVersion: '1.0.0', + }); + expect(changed).toEqual([ARA, DOYUN]); + }); + + it('세션이 등록할 수 없는 세션이면 — 제한이 붙었으면 403 AUTH_010, 그 밖(끝남 · 끊김)은 401 AUTH_001 — 바뀐 것이 없다', async () => { + repository.register.mockResolvedValue(null); + const request = { + fid: FID, + platform: 'android' as const, + appVersion: '1.0.0', + }; + + repository.refusalOf.mockResolvedValueOnce('restricted'); + expect(await codeOf(service.register(APP_USER, request))).toBe('AUTH_010'); + repository.refusalOf.mockResolvedValueOnce('ended'); + expect(await codeOf(service.register(APP_USER, request))).toBe('AUTH_001'); + expect(changed).toEqual([]); + }); + + it('앱 토큰이 아니면(웹 쿠키 세션 · 세션 id 없음) 403 AUTH_003 — 표에 닿지 않는다', async () => { + const web: AuthenticatedUser = { + email: ARA, + name: '김아라', + sessionId: SESSION, + }; + const noSession: AuthenticatedUser = { + email: ARA, + name: '김아라', + client: 'app', + }; + const request = { + fid: FID, + platform: 'android' as const, + appVersion: '1.0.0', + }; + for (const user of [web, noSession]) { + expect(await codeOf(service.register(user, request))).toBe('AUTH_003'); + expect(await codeOf(service.unregister(user))).toBe('AUTH_003'); + expect(await codeOf(service.markSeen(user))).toBe('AUTH_003'); + } + expect(repository.register).not.toHaveBeenCalled(); + expect(repository.remove).not.toHaveBeenCalled(); + expect(repository.markSeen).not.toHaveBeenCalled(); + }); + + it('지우기 — 이 세션의 등록만, 지운 줄이 있을 때만 알린다 (없어도 실패가 아니다)', async () => { + repository.remove.mockResolvedValueOnce(ARA).mockResolvedValueOnce(null); + await service.unregister(APP_USER); + await service.unregister(APP_USER); + expect(repository.remove.mock.calls).toEqual([[SESSION], [SESSION]]); + expect(changed).toEqual([ARA]); + }); + + it('봤음 — 이 세션의 세는 시작을 지금(서버 시각)으로', async () => { + const before = Date.now(); + await service.markSeen(APP_USER); + const [sessionId, at] = repository.markSeen.mock.calls[0] as [string, Date]; + expect(sessionId).toBe(SESSION); + expect(at.getTime()).toBeGreaterThanOrEqual(before); + }); + + it('FCM 이 받지 않는 기기 — FID 가 그대로일 때만 지우고 그때 알린다', async () => { + repository.removeIfFid + .mockResolvedValueOnce(ARA) + .mockResolvedValueOnce(null); + await service.forgetDevice(SESSION, FID); + await service.forgetDevice(SESSION, FID); + expect(repository.removeIfFid).toHaveBeenCalledWith(SESSION, FID); + expect(changed).toEqual([ARA]); + }); + + it('듣기를 그만둘 수 있고, 듣는 쪽 하나가 던져도 다른 쪽은 받는다 (error 로그 — 주소는 가린다)', () => { + const errors: string[] = []; + jest + .spyOn(Logger.prototype, 'error') + .mockImplementation((message: unknown) => { + errors.push(String(message)); + }); + const stop = service.onChanged(() => { + throw new Error('boom'); + }); + const later: string[] = []; + service.onChanged((user) => later.push(user)); + + service.notifyChanged(ARA); + stop(); + service.notifyChanged(ARA); + + expect(changed).toEqual([ARA, ARA]); + expect(later).toEqual([ARA, ARA]); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('a***@arasystem.test'); + expect(errors[0]).not.toContain(ARA); + }); +}); diff --git a/backend/src/modules/push/push-registrations.service.ts b/backend/src/modules/push/push-registrations.service.ts new file mode 100644 index 0000000..ea03213 --- /dev/null +++ b/backend/src/modules/push/push-registrations.service.ts @@ -0,0 +1,143 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { BusinessException } from '@/common/errors/business.exception'; +import { maskEmail } from '@/shared/utils/mask'; +import type { PushPlatform } from './push.constants'; +import { + PushRegistrationsRepository, + type PushTarget, +} from './push-registrations.repository'; + +/** 등록 본문 — 앱 세션(sid)은 토큰에서 */ +export interface PushRegistrationRequest { + readonly fid: string; + readonly platform: PushPlatform; + readonly appVersion: string; +} + +/** 등록이 바뀐 직원의 소식 — 메일 쪽(new-mail-push.service.ts)이 엔진 연결을 붙잡거나 놓는다 */ +export type PushRegistrationsListener = (userEmail: string) => void; + +/** 앱 세션의 id — 앱 토큰(Bearer · cli: app)으로 온 요청만. AppClientGuard 가 먼저 막는다(두 겹) */ +function appSessionOf(user: AuthenticatedUser): string { + if (user.client !== 'app' || !user.sessionId) { + throw new BusinessException('AUTH_003', { + context: { reason: 'push-registration-requires-app-session' }, + }); + } + return user.sessionId; +} + +/** + * 앱 알림 등록 (계약 1절 "앱 새 메일 알림 · 앱 설정") — 메일을 모른다. 나중의 캘린더 등도 이 등록을 쓴다. + * - 등록 · 지우기 · "봤음"은 그 요청의 앱 세션(액세스 토큰의 sid)에만 한다 — 다른 세션의 등록을 고르는 길이 없다 + * - 등록이 바뀌면(넣음 · 지움 · FCM 이 받지 않아 지움 · 끝난 세션 정리) 소식을 낸다 — 붙잡을 직원을 메일 쪽이 다시 고른다 + * FID 는 로그 · 오류 context 에 싣지 않는다 + */ +@Injectable() +export class PushRegistrationsService { + private readonly logger = new Logger(PushRegistrationsService.name); + private readonly listeners = new Set(); + + constructor(private readonly repository: PushRegistrationsRepository) {} + + /** + * 이 앱 세션의 등록 — 같은 FID 의 다른 등록은 지운다(같은 휴대폰에서 다른 직원이 로그인), 직원마다 상한을 넘으면 가장 오래된 등록부터 지운다. + * 그사이 끝난 · 끊긴 세션 401 AUTH_001, 제한이 붙은 세션 403 AUTH_010 (가드가 토큰으로 못 본 경우 — 갱신에서 제한이 붙었다) + */ + async register( + user: AuthenticatedUser, + request: PushRegistrationRequest, + ): Promise<{ updatedAt: string }> { + const sessionId = appSessionOf(user); + const now = new Date(); + const registered = await this.repository.register( + { sessionId, ...request }, + now, + ); + if (!registered) { + const refusal = await this.repository.refusalOf(sessionId, now); + if (refusal === 'restricted') { + throw new BusinessException('AUTH_010', { + context: { reason: 'push-registration-restricted-session' }, + }); + } + throw new BusinessException('AUTH_001', { + context: { reason: 'push-registration-session-ended', sid: sessionId }, + }); + } + // 같은 휴대폰의 앞 직원 · 상한을 넘어 줄이 지워진 직원도 — 그 직원의 붙잡기를 메일 쪽이 다시 고른다 + for (const user of new Set([ + registered.userEmail, + ...registered.displacedUsers, + ])) { + this.notifyChanged(user); + } + return { updatedAt: registered.updatedAt.toISOString() }; + } + + /** 이 앱 세션의 등록을 지운다 — 없어도 된다 */ + async unregister(user: AuthenticatedUser): Promise { + const removed = await this.repository.remove(appSessionOf(user)); + if (removed !== null) this.notifyChanged(removed); + } + + /** 이 휴대폰에서 앱을 봤다 — 다음 알림의 N 을 지금부터 센다(서버 시각). 등록이 없어도 된다 */ + async markSeen(user: AuthenticatedUser): Promise { + await this.repository.markSeen(appSessionOf(user), new Date()); + } + + /** 그 직원에게 보낼 기기 — 살아 있는 · 제한 없는 앱 세션의 등록 */ + targetsOf(userEmail: string): Promise { + return this.repository.targetsOf(userEmail, new Date()); + } + + /** 보낼 기기가 있는 직원 — `addressSuffix`(`@사내 도메인`)로 끝나는 주소만 */ + usersWithTargets(addressSuffix: string): Promise { + return this.repository.usersWithTargets(addressSuffix, new Date()); + } + + /** 그 직원에게 보낼 기기가 있는가 */ + hasTargets(userEmail: string): Promise { + return this.repository.hasTargets(userEmail, new Date()); + } + + /** 보낸 기기 — 그 알림의 N 을 센 때를 적는다 */ + recordPushed(sessionIds: readonly string[], countedAt: Date): Promise { + return this.repository.recordPushed(sessionIds, countedAt); + } + + /** 보내기 직전 — 그 세션의 등록이 아직 그 FID 이고 세션이 살아 있는 · 제한 없는 앱 세션인가 */ + isDeliverable(sessionId: string, fid: string): Promise { + return this.repository.isDeliverable(sessionId, fid, new Date()); + } + + /** FCM 이 더는 받지 않는 기기 — 그 세션의 FID 가 그대로일 때만 지운다 */ + async forgetDevice(sessionId: string, fid: string): Promise { + const removed = await this.repository.removeIfFid(sessionId, fid); + if (removed !== null) this.notifyChanged(removed); + } + + /** 등록이 바뀐 직원의 소식을 듣는다 → 그만 듣기 */ + onChanged(listener: PushRegistrationsListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** 등록이 바뀌었다 — 듣는 쪽에 알린다 (던지지 않는다) */ + notifyChanged(userEmail: string): void { + for (const listener of [...this.listeners]) { + try { + listener(userEmail); + } catch (error) { + this.logger.error( + `[SYS_001] 앱 알림 등록이 바뀐 소식을 전하지 못했습니다: user=${maskEmail(userEmail)}`, + error instanceof Error ? error.stack : undefined, + ); + } + } + } +} diff --git a/backend/src/modules/push/push-sender.port.ts b/backend/src/modules/push/push-sender.port.ts new file mode 100644 index 0000000..9243f86 --- /dev/null +++ b/backend/src/modules/push/push-sender.port.ts @@ -0,0 +1,36 @@ +/** + * 앱 알림 보내기 포트 (계약 1절 "앱 새 메일 알림 · 앱 설정") — 서비스는 이 포트만 안다. 지금 구현은 FCM HTTP v1 하나(configured-push-sender.ts). + * 알림의 내용은 종류와 개수뿐이다 — 주소 · 이름 · 제목 · 메일 id · 세션 id 를 받는 칸이 없다(그래서 밖으로 보낼 수도 없다). + */ +export const PUSH_SENDER = Symbol('PUSH_SENDER'); + +/** 보낼 알림 — 새 메일 N통 · 로그인이 저절로 끝남 */ +export type PushNotice = + | { readonly kind: 'new-mail'; readonly count: number } + | { readonly kind: 'session-ended' }; + +/** + * 보낸 결과 — sent 보냄 · unregistered 그 기기는 더 받지 않는다(앱을 지움 · 다른 프로젝트의 기기 · 틀린 주소 — 등록을 지운다) · + * failed 보내지 못함(잠깐의 장애 · 우리 설정 · 서버 종료 — 등록은 그대로, 로그는 보내는 쪽이 남긴다) · + * skipped 보내기 직전에 그 기기가 이 알림을 받을 자리가 아니게 됐다(`stillValid` 가 false — 그 휴대폰이 다른 세션으로 옮겨 갔다 등) + */ +export type PushSendOutcome = 'sent' | 'unregistered' | 'failed' | 'skipped'; + +export interface PushSendOptions { + /** + * 보내기 직전(다시 하기마다) 아직 이 기기에 보내도 되는가 — false 면 보내지 않고 skipped, 던지면 보내지 않고 failed. + * 읽은 뒤 · 엔진에 센 뒤 · 다시 하기를 기다리는 사이 그 휴대폰(FID)이 다른 직원의 세션으로 옮겨 가도 앞 직원의 알림이 가지 않게 + */ + readonly stillValid?: () => Promise; +} + +export interface PushSender { + /** 보낼 수 있다 — FCM 키를 읽었다 (없으면 등록은 받되 보내지 않는다 — app-config 의 pushAvailable) */ + readonly available: boolean; + /** 기기 하나(FID)에 보낸다 — 던지지 않는다. 다시 하기 · 시간 한도는 보내는 쪽 몫이다 */ + send( + fid: string, + notice: PushNotice, + options?: PushSendOptions, + ): Promise; +} diff --git a/backend/src/modules/push/push-text.ts b/backend/src/modules/push/push-text.ts new file mode 100644 index 0000000..0669e94 --- /dev/null +++ b/backend/src/modules/push/push-text.ts @@ -0,0 +1,23 @@ +/** + * 앱 알림의 글 (계약 1절 "앱 새 메일 알림 · 앱 설정") — 휴대폰이 그대로 그리므로 원본은 여기 한 곳이다(앱 상수에는 없다). + * 앱 README · 보드에 옮겨 적는다. 화면 문구 규칙(디자인 README 「화면 문구 — 사용자에게 말하듯」)을 따른다. + * 글에는 메일 내용 · 주소 · 이름을 넣지 않는다 — 개수만. + */ + +/** 천 단위 쉼표 — 1,234 (시간대 · 기계 설정과 상관없이 같은 글) */ +const COUNT_FORMAT = new Intl.NumberFormat('en-US'); + +/** 새 메일 알림의 제목 — "새 메일 N통" (내용 칸은 없다 — 앱 이름은 안드로이드가 머리에 붙인다) */ +export function newMailTitle(count: number): string { + return `새 메일 ${COUNT_FORMAT.format(count)}통`; +} + +/** + * 그 휴대폰을 직원이 건드리지 않았는데 앱 세션이 끝났을 때(저절로 끝남 · 다른 곳에서 비밀번호를 바꿈 · 앱 갱신 유예 · 토큰 재사용 감지) + * 그 휴대폰에 한 번 보내는 알림 — 초안이다(보드 `App1Push` ⑦ 에서 확정되면 바꾼다). 보내는 까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE 한 곳. + * 직원 · 운영자가 끊은 세션 · 계정을 쓸 수 없는 세션 · 제한된 세션에는 보내지 않는다 + */ +export const SESSION_ENDED_TEXT = { + title: '새 메일 알림이 멈췄습니다', + body: '안전을 위해 로그아웃했습니다. 앱을 열어 다시 로그인해 주세요.', +} as const; diff --git a/backend/src/modules/push/push.constants.ts b/backend/src/modules/push/push.constants.ts new file mode 100644 index 0000000..11d7a30 --- /dev/null +++ b/backend/src/modules/push/push.constants.ts @@ -0,0 +1,74 @@ +/** + * 앱 알림(푸시) 상수 — 계약 1절 "앱 새 메일 알림 · 앱 설정"(앱 1단계)의 한도 · 모양을 한 곳에 모은다. + * 글은 push-text.ts, FCM 쪽 값(주소 · 시간 · 메시지 칸)은 fcm/fcm.constants.ts 에 있다. + */ + +import type { SessionRevokedReason } from '@/modules/auth/auth-session.entity'; + +/** 등록 표 이름 — push-registration.entity.ts 와 같다 (날 SQL 이 쓴다) */ +export const PUSH_REGISTRATIONS_TABLE = 'push_registrations'; + +/** 세션 표 이름 — modules/auth/auth-session.entity.ts 의 @Entity 이름과 같다 (보낼 대상을 세션과 함께 고른다) */ +export const AUTH_SESSIONS_TABLE = 'auth_sessions'; + +/** 앱 세션의 클라이언트 이름 — auth_sessions.client (계약 1절 "앱 로그인") */ +export const APP_SESSION_CLIENT = 'app'; + +/** + * 한 직원의 등록 수 상한 — 넘으면 가장 오래 바꾸지 않은(updated_at) 등록부터 지운다(같은 트랜잭션). + * 실제로 10대를 넘게 쓸 일은 없다 — 꾸민 FID 를 잔뜩 넣어 새 메일마다 FCM 요청을 늘리지 못하게 + */ +export const MAX_PUSH_REGISTRATIONS_PER_USER = 10; + +/** + * 기기 주소(FID — Firebase 설치 id) — base64url 글자(영문 · 숫자 · `-` · `_`)만, 64자까지. + * Firebase 의 FID 는 22자다 — 여유를 둔다. FCM 등록 토큰(`:` 가 섞이고 더 길다)으로 돌아가면 이 모양과 표의 칸을 함께 바꾼다 + */ +export const PUSH_FID_MAX_LENGTH = 64; +export const PUSH_FID_PATTERN = /^[A-Za-z0-9_-]+$/; + +/** 등록하는 플랫폼 — 지금은 안드로이드만 (iOS 앱이 나오면 더한다) */ +export const PUSH_PLATFORMS = ['android'] as const; +export type PushPlatform = (typeof PUSH_PLATFORMS)[number]; + +/** 앱 판 — `숫자.숫자.숫자`(칸마다 1~4자리 · 앞자리 0 없이 — 0 하나는 된다), 앱의 PackageInfo.version */ +export const APP_VERSION_PATTERN = + /^(?:0|[1-9][0-9]{0,3})\.(?:0|[1-9][0-9]{0,3})\.(?:0|[1-9][0-9]{0,3})$/; +/** 앱 판 글자 수 상한 — 4 + 1 + 4 + 1 + 4 (정규식보다 먼저 본다) */ +export const APP_VERSION_MAX_LENGTH = 14; + +/** + * 앱 세션을 끊은 까닭마다 그 휴대폰에 세션 끝 알림(session-ended — "새 메일 알림이 멈췄습니다")을 한 번 보내는가 (계약 1절 "앱 새 메일 알림 · 앱 설정"). + * 기준 — **그 휴대폰을 직원이 건드리지 않았는데** 세션이 끝났으면 보낸다. 직원이 그 휴대폰 · 다른 기기에서 끊었거나, 운영자가 끊었거나, 계정을 쓸 수 없으면 보내지 않는다. + * 저절로 끝난 세션(끊기지 않음 — 미사용 14일 · 절대 30일)은 까닭이 없고 늘 보낸다. 제한된 세션 · 까닭이 없거나 이 표에 없는 값(다른 판이 넣은 줄)이면 보내지 않는다. + * 까닭(auth-session.entity.ts 의 SessionRevokedReason)을 더하거나 빼면 여기서도 정해야 컴파일된다 — 보내는 자리는 5분 정리(push-cleanup.service.ts) 하나다 + */ +export const SESSION_ENDED_NOTICE = { + /** 그 휴대폰에서 로그아웃 — 직원이 했다 (웹 로그아웃은 등록이 없다) */ + logout: false, + /** 로그인된 기기 목록에서 끊기 · 다른 곳 모두 로그아웃 — 직원이 했다 */ + 'ended-by-user': false, + /** 운영 도구 — 비밀번호 재설정 · 잠금 · 이전용 비밀번호 다시 만들기 · 이전 마치기 · 퇴사 마무리 (안내는 운영자가 한다) */ + 'ended-by-operator': false, + /** 계정을 쓸 수 없다(갱신 때 엔진이 메일함을 내주지 않았다) */ + 'account-unavailable': false, + /** 2단계를 켜 끊긴 세션 — 제한된 세션(twoFactorSetup)이라 원래 보내지 않는다 */ + 'two-factor-not-verified': false, + /** 다른 곳에서 비밀번호를 바꿨다 */ + 'password-changed': true, + /** 다른 곳에서 비밀번호를 바꾸다 결과를 알 수 없게 됐다 — 바뀌었을 수 있어 끊었다 */ + 'password-change-uncertain': true, + /** 앱 갱신 유예 — 회전 응답을 잃었거나 리프레시 토큰이 두 번 쓰였다(앱에 이어 쓸 토큰이 없다) */ + 'app-refresh-grace': true, + /** 이미 쓴 리프레시 토큰이 다시 왔다(탈취 의심) */ + 'refresh-token-reused': true, +} as const satisfies Record; + +/** 세션 끝 알림을 보내는 끊은 까닭 — 위 표에서 true 인 것 (5분 정리 SQL 의 매개변수) */ +export const SESSION_ENDED_NOTICE_REASONS: readonly SessionRevokedReason[] = ( + Object.keys(SESSION_ENDED_NOTICE) as SessionRevokedReason[] +).filter((reason) => SESSION_ENDED_NOTICE[reason]); + +/** 끝난 세션의 등록 정리 — 5분마다(분의 30초 — 다른 5분 차례와 겹치지 않게). e2e 는 이 이름으로 멈춘다 */ +export const PUSH_CLEANUP_CRON = '30 */5 * * * *'; +export const PUSH_CLEANUP_JOB = 'push-registrations-cleanup'; diff --git a/backend/src/modules/push/push.module.ts b/backend/src/modules/push/push.module.ts new file mode 100644 index 0000000..27354d3 --- /dev/null +++ b/backend/src/modules/push/push.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { MailEngineModule } from '@/modules/mail/mail-engine.module'; +import { ConfiguredPushSender } from './configured-push-sender'; +import { FCM_TRANSPORT, FetchFcmTransport } from './fcm/fcm-transport'; +import { PushCleanupService } from './push-cleanup.service'; +import { PushRegistrationEntity } from './push-registration.entity'; +import { PushRegistrationsController } from './push-registrations.controller'; +import { PushRegistrationsRepository } from './push-registrations.repository'; +import { PushRegistrationsService } from './push-registrations.service'; +import { PUSH_SENDER } from './push-sender.port'; + +/** + * 앱 알림(푸시) 모듈 (계약 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계) — 등록 표 · 경로 · 끝난 세션 정리 · 보내기(PUSH_SENDER — FCM). + * 메일을 모른다 — 새 메일을 알아채고 보내는 일은 메일 모듈(new-mail-push.service.ts)이 이 모듈을 가져다 한다(메일 → 푸시 방향만). + * 사내 도메인(정리 범위)만 메일 엔진 설정(MailEngineModule 의 MAIL_SETTINGS)에서 읽는다 — 이 모듈은 메일 모듈을 가져오지 않는다 (순환 없음). + * 보내는 곳을 바꿀 때는 PUSH_SENDER 의 구현만 바꾼다. 운영 도구(다른 프로세스)는 이 모듈을 가져오지 않는다 — 퇴사 마무리는 표만 지운다 + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([PushRegistrationEntity]), + MailEngineModule, + ], + controllers: [PushRegistrationsController], + providers: [ + PushRegistrationsRepository, + PushRegistrationsService, + PushCleanupService, + // FCM 의 HTTP — 앱은 전역 fetch 로 Google 의 두 주소에만 (시험은 forTesting 을 쓴다) + { provide: FCM_TRANSPORT, useClass: FetchFcmTransport }, + // env 의 FCM 키를 읽어 보낸다 — 없으면 보내지 않는다(available false). 서버 종료 때 진행 중인 보내기를 끊는다 + { provide: PUSH_SENDER, useClass: ConfiguredPushSender }, + ], + exports: [PUSH_SENDER, PushRegistrationsService], +}) +export class PushModule {} diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index a465e90..26a197a 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -1,3 +1,4 @@ +import { testServiceAccount } from '@/../test/fcm-test-key'; import { validateEnv } from './env.validation'; /** 지정한 키를 제외한 복사본 (구조분해로 버리면 lint 의 no-unused-vars 에 걸린다) */ @@ -481,6 +482,68 @@ describe('validateEnv', () => { }); }); + describe('앱 새 메일 알림의 FCM 키 (앱 1단계 — 선택)', () => { + const dev = (extra: Record) => ({ + NODE_ENV: 'development', + ...extra, + }); + /** 그 값으로 기동하면 나는 메시지 — 기동하면 null */ + const failureOf = (config: Record): string | null => { + try { + validateEnv(config); + return null; + } catch (error) { + return (error as Error).message; + } + }; + + it('비거나 없으면 기동한다 — 운영도(알림만 끈다)', () => { + expect(() => + validateEnv(dev({ FCM_SERVICE_ACCOUNT_KEY: '' })), + ).not.toThrow(); + expect(() => validateEnv(prod())).not.toThrow(); + expect(() => + validateEnv(prod({ FCM_SERVICE_ACCOUNT_KEY: ' ' })), + ).not.toThrow(); + }); + + it('서비스 계정 키 JSON 을 통째로 base64 한 줄이면 기동한다', () => { + const { base64 } = testServiceAccount(); + expect(() => + validateEnv(dev({ FCM_SERVICE_ACCOUNT_KEY: base64 })), + ).not.toThrow(); + expect(() => + validateEnv(prod({ FCM_SERVICE_ACCOUNT_KEY: base64 })), + ).not.toThrow(); + }); + + it('모양이 틀리면 모든 환경에서 기동하지 않는다 — 어느 칸이 틀렸는지만, 값은 싣지 않는다 (예시 글도 이 검사가 막는다)', () => { + const wrongType = testServiceAccount({ type: 'authorized_user' }).base64; + const cases: [string, RegExp][] = [ + ['change-me-in-local-env', /base64 한 줄이 아닙니다/], + [Buffer.from('not json').toString('base64'), /JSON 이 아닙니다/], + [wrongType, /type 이 service_account 가 아닙니다/], + [ + testServiceAccount({ client_email: 'leak@gmail.com' }).base64, + /client_email 이 서비스 계정 주소/, + ], + ]; + for (const [value, problem] of cases) { + for (const config of [ + dev({ FCM_SERVICE_ACCOUNT_KEY: value }), + prod({ FCM_SERVICE_ACCOUNT_KEY: value }), + ]) { + const message = failureOf(config); + expect(message).toMatch(/FCM_SERVICE_ACCOUNT_KEY 를 읽지 못했습니다/); + expect(message).toMatch(problem); + expect(message).not.toContain(value.slice(0, 24)); + expect(message).not.toContain('leak@gmail.com'); + expect(message).not.toContain('BEGIN'); + } + } + }); + }); + describe('대용량 첨부 (11단계)', () => { const dev = (extra: Record) => ({ NODE_ENV: 'development', diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index 9a5c6f7..81b6f9a 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -30,6 +30,8 @@ import { DEFAULT_MAIL_DIRECTORY_LOCAL_PART, parseAccountList, } from '@/modules/mail/mail.settings'; +import { readFcmServiceAccountKey } from '@/modules/push/fcm/fcm-credentials'; +import { FCM_SERVICE_ACCOUNT_KEY_ENV } from '@/modules/push/fcm/fcm.constants'; import { parseCidr } from '@/shared/utils/cidr'; import { parseDurationSeconds } from '@/shared/utils/duration'; import { isPositiveIntString } from '@/shared/utils/env'; @@ -482,6 +484,26 @@ function validateAccountSecuritySettings( } } +/** + * 앱 새 메일 알림의 FCM 키 (앱 1단계 — 계약 1절 "앱 새 메일 알림 · 앱 설정") — 값이 있을 때만, **모든 환경**에서 키 모양을 본다 + * (base64 → JSON → service_account · project_id · client_email · private_key_id · PEM RSA 개인 키 — 보내는 쪽과 같은 함수). + * 비우면 알림만 끈다 — 운영 필수가 아니다(선택 기능 하나로 메일 전체가 서지 않게). 오류 글에는 어느 칸이 틀렸는지만 — 키의 값은 싣지 않는다. + * 예시 값(change-me 등)도 이 검사가 막는다(base64 JSON 이 아니다) — 그래서 NO_PLACEHOLDER_IN_PRODUCTION 에 따로 넣지 않는다 + */ +function validateFcmSettings( + config: Record, + errors: string[], +): void { + const reading = readFcmServiceAccountKey( + readString(config, FCM_SERVICE_ACCOUNT_KEY_ENV), + ); + if (reading.status === 'invalid') { + errors.push( + `${FCM_SERVICE_ACCOUNT_KEY_ENV} 를 읽지 못했습니다 — ${reading.problem}. 서비스 계정 키 JSON 파일을 통째로 base64 한 줄로 넣습니다(비우면 앱 새 메일 알림을 끈다).`, + ); + } +} + /** 도메인 이름 형식 (예: arasystem.it.kr, arasystem.test) — '@' 나 스킴이 섞이면 거부한다 */ const DOMAIN_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i; @@ -654,6 +676,9 @@ export function validateEnv( // 1-6. 계정 보안 (12단계) validateAccountSecuritySettings(config, errors); + // 1-7. 앱 새 메일 알림의 FCM 키 (앱 1단계 — 선택) + validateFcmSettings(config, errors); + if (isProduction) { // 2. 운영 필수값 — 없으면 기동 실패 for (const key of REQUIRED_IN_PRODUCTION) { diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 12fe445..7aacc4b 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -1,4 +1,5 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; +import { Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; import { createHash, createHmac, randomBytes, randomUUID } from 'crypto'; @@ -139,6 +140,21 @@ import { import { toIndexRow } from '@/modules/mail-search/index-document'; import { planSearch } from '@/modules/mail-search/search-plan'; import { candidatesSql } from '@/modules/mail-search/search-sql'; +import { NEW_MAIL_PUSH_HOLDS_JOB } from '@/modules/mail/new-mail-push.service'; +import { type FcmMessage, fcmMessageOf } from '@/modules/push/fcm/fcm-message'; +import { PushCleanupService } from '@/modules/push/push-cleanup.service'; +import { PushRegistrationsService } from '@/modules/push/push-registrations.service'; +import { + MAX_PUSH_REGISTRATIONS_PER_USER, + PUSH_CLEANUP_JOB, + SESSION_ENDED_NOTICE, +} from '@/modules/push/push.constants'; +import { + PUSH_SENDER, + type PushNotice, + type PushSendOutcome, + type PushSender, +} from '@/modules/push/push-sender.port'; import { SystemSettingsService, type SystemSettingsPatch, @@ -963,6 +979,30 @@ describe('응답 계약 (e2e)', () => { closed: boolean; }[] = []; + /** + * 앱 1단계 — 앱 새 메일 알림의 대역: 엔진의 받은편지함 안 읽은 메일(직원마다 받은 시각 ms)과 그 수를 물은 것, + * 휴대폰으로 보낸 알림(보내는 쪽 대역 — 실제 FCM 메시지 JSON 을 fcmMessageOf 로 만들어 둔다). 실제 보내기 · 다시 하기는 fcm-*.spec.ts 가 본다 + */ + const inboxUnread = new Map(); + const unreadCounts: { user: string; since: number[] }[] = []; + const pushes: { + fid: string; + notice: PushNotice; + message: FcmMessage; + }[] = []; + const pushOutcomes = new Map(); + const fakePushSender: PushSender = { + available: true, + // 보내는 쪽처럼 — 보내기 직전에 부른 쪽의 확인(그 세션의 등록이 아직 그 FID 인가 — 실제 표)을 본다 + send: async (fid, notice, options) => { + if (options?.stillValid && !(await options.stillValid())) { + return 'skipped'; + } + pushes.push({ fid, notice, message: fcmMessageOf(fid, notice) }); + return pushOutcomes.get(fid) ?? 'sent'; + }, + }; + /** * 8단계 — 대역 엔진의 주소록(직원마다 하나) — 판(state)이 같을 때만 쓰고 쓸 때마다 판이 오른다 (ifInState). * 카드 모양(JSContact) · 엔진의 거절은 jmap-contacts.provider.spec.ts 가 본다 @@ -1324,6 +1364,16 @@ describe('응답 계약 (e2e)', () => { }, }); }, + // 앱 1단계 — 앱 새 메일 알림의 N (엔진의 세기 호출 모양은 jmap-mail.provider.spec.ts 가 본다) + countInboxUnreadSince: (user: string, since: readonly Date[]) => { + unreadCounts.push({ user, since: since.map((at) => at.getTime()) }); + const received = inboxUnread.get(user) ?? []; + return Promise.resolve( + since.map( + (at) => received.filter((time) => time >= at.getTime()).length, + ), + ); + }, listMessages: (user: string, query: MessageListQuery) => { listQueries.push({ user, query }); return Promise.resolve({ @@ -1877,6 +1927,9 @@ describe('응답 계약 (e2e)', () => { // 14단계 — 검색 색인의 엔진 읽기는 대역으로 .overrideProvider(MAIL_INDEX_SOURCE) .useValue(fakeIndexSource) + // 앱 1단계 — 앱 알림은 휴대폰으로 보내지 않고 기록만 (FCM 키 · 바깥 연결 없이) + .overrideProvider(PUSH_SENDER) + .useValue(fakePushSender) .compile(); // CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다) @@ -1896,7 +1949,13 @@ describe('응답 계약 (e2e)', () => { // 거꾸로 같은 DB 를 쓰는 개발 서버(개발 PC 에서만 — CI 에는 없다)의 cron 은 사내 도메인(arasystem.test) 주소만 맞추고 채우므로 // 이 시험의 줄(e2e.arasystem.test)을 건드리지 않는다 (MailSearchSyncService.isIndexedAddress) const scheduler = app.get(SchedulerRegistry); - for (const name of ['mail-search-sync', 'mail-search-build']) { + // 앱 1단계 — 앱 알림의 5분 정리 · 붙잡기 맞추기도 테스트가 직접 부른다(사내 도메인 = e2e 도메인만 다룬다) + for (const name of [ + 'mail-search-sync', + 'mail-search-build', + PUSH_CLEANUP_JOB, + NEW_MAIL_PUSH_HOLDS_JOB, + ]) { void scheduler.getCronJob(name).stop(); } server = app.getHttpServer(); @@ -1905,6 +1964,11 @@ describe('응답 계약 (e2e)', () => { }, APP_SETUP_TIMEOUT_MS); afterAll(async () => { + // 앱 1단계 — 앱 알림 등록(세션 줄과 외래 키가 없어 따로 지운다) + await dataSource.query( + 'DELETE FROM push_registrations WHERE user_email LIKE $1', + [TEST_DOMAIN_PATTERN], + ); await dataSource.query( 'DELETE FROM auth_sessions WHERE user_email LIKE $1', [TEST_DOMAIN_PATTERN], @@ -10189,6 +10253,687 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 앱 1단계 — 앱 새 메일 알림 · 앱 설정 (계약 1절 "앱 새 메일 알림 · 앱 설정"). 실제 PostgreSQL 의 등록 표 · 세션 표와 함께 본다. + * 엔진은 대역(새 메일 신호는 liveWatches 의 listener 로 흘려 넣는다), 보내기는 기록만(fakePushSender — 실제 FCM 메시지 JSON 을 만든다). + * 30초 간격은 단위 시험(new-mail-push.service.spec.ts)이 손으로 돌리는 시계로 본다 — 여기서는 시험마다 다른 직원을 써서 첫 알림만 본다 + */ + describe('앱 1단계 — 앱 새 메일 알림 · 앱 설정 (/api/push-registrations/current · /api/app-config)', () => { + const APP_UA = 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)'; + let ipSequence = 0; + + interface PushRow { + session_id: string; + user_email: string; + fid: string; + platform: string; + app_version: string; + seen_at: Date; + last_pushed_at: Date | null; + created_at: Date; + updated_at: Date; + } + + /** 앱 로그인 → 두 토큰 (IP 를 바꿔 로그인 요청 수 한도에 걸리지 않게) */ + const appLogin = async (email: string) => { + ipSequence += 1; + const res = await request(server) + .post('/api/auth/app/login') + .set('X-Forwarded-For', `198.18.31.${ipSequence}`) + .set('User-Agent', APP_UA) + .send({ email, password: CORRECT_PASSWORD }) + .expect(200); + const tokens = dataOf<{ + tokens: { accessToken: string; refreshToken: string } | null; + }>(res).tokens; + if (!tokens) throw new Error('tokens 가 있어야 한다'); + return { + ...tokens, + sessionId: jwt.decode<{ sid: string }>(tokens.accessToken).sid, + }; + }; + const bearer = (token: string) => `Bearer ${token}`; + const register = (accessToken: string, fid: string, appVersion = '1.0.0') => + request(server) + .put('/api/push-registrations/current') + .set('Authorization', bearer(accessToken)) + .send({ fid, platform: 'android', appVersion }); + /** 시험마다 다른 휴대폰 주소 (base64url 글자) */ + const fidOf = (label: string) => + `e2e${label}${randomUUID().replace(/-/g, '').slice(0, 12)}`; + const rowOf = async (sessionId: string): Promise => { + const rows: PushRow[] = await dataSource.query( + 'SELECT * FROM push_registrations WHERE session_id = $1', + [sessionId], + ); + return rows[0]; + }; + const rowsWithFid = async (fid: string): Promise => + dataSource.query('SELECT * FROM push_registrations WHERE fid = $1', [ + fid, + ]); + /** 꼭 있어야 하는 줄 — 없으면 시험이 틀렸다 */ + const requiredRowOf = async (sessionId: string): Promise => { + const row = await rowOf(sessionId); + if (!row) throw new Error('등록 줄이 있어야 한다'); + return row; + }; + const timeOf = (value: Date) => new Date(value).getTime(); + /** 줄이 조건이 될 때까지 짧게 다시 본다 (보내기 뒤의 기록 · 지우기는 응답 뒤에 일어난다) → 마지막으로 읽은 줄 */ + const rowWhen = async ( + sessionId: string, + condition: (row: PushRow | undefined) => boolean, + ): Promise => { + const deadline = Date.now() + GATE_DEADLINE_MS; + let row = await rowOf(sessionId); + while (!condition(row) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, GATE_POLL_MS)); + row = await rowOf(sessionId); + } + return row; + }; + const watchOf = (user: string) => + liveWatches.filter((watch) => watch.user === user && !watch.closed); + /** 등록 뒤 엔진 연결을 붙잡고 따라잡기(세기 한 번)까지 마칠 때까지 */ + const heldAndCaughtUp = async (user: string) => { + await waitUntil( + () => + watchOf(user).length > 0 && + unreadCounts.some((entry) => entry.user === user), + GATE_DEADLINE_MS, + ); + expect(watchOf(user)).toHaveLength(1); + }; + /** 그 직원의 엔진 연결에 새 메일 신호 — 웹의 new-mail 과 같은 소식(보낸 사람 · 제목이 들어 있다) */ + const deliverNewMail = (user: string) => { + const newMail = { + count: 1, + messages: [ + { + id: 'msecretmessageid', + from: { name: '비밀 보낸이', email: 'secret.sender@e2e.example' }, + subject: '아무도 몰라야 할 제목', + receivedAt: new Date().toISOString(), + }, + ], + }; + for (const watch of watchOf(user)) watch.listener.newMail(newMail); + }; + const pushesTo = (fid: string) => pushes.filter((push) => push.fid === fid); + + it('등록 — 200 { updatedAt } (no-store), 세션(sid)마다 한 줄: 새 줄이면 세는 시작(seen_at) = 지금, 다시 등록하면 FID · 판만 바꾸고 세는 시작은 그대로', async () => { + const ME = 'push.register@e2e.arasystem.test'; + const device = await appLogin(ME); + const first = fidOf('a'); + + const res = await register(device.accessToken, first).expect(200); + expect(res.headers['cache-control']).toBe('private, no-store'); + const { updatedAt } = dataOf<{ updatedAt: string }>(res); + expect(Object.keys(dataOf(res))).toEqual(['updatedAt']); + const row = await requiredRowOf(device.sessionId); + expect(row).toMatchObject({ + user_email: ME, + fid: first, + platform: 'android', + app_version: '1.0.0', + last_pushed_at: null, + }); + expect(new Date(row.updated_at).toISOString()).toBe(updatedAt); + expect(timeOf(row.seen_at)).toBe(timeOf(row.created_at)); + + const second = fidOf('b'); + await register(device.accessToken, second, '1.0.1').expect(200); + const changed = await requiredRowOf(device.sessionId); + expect(changed).toMatchObject({ fid: second, app_version: '1.0.1' }); + expect(timeOf(changed.seen_at)).toBe(timeOf(row.seen_at)); + expect(timeOf(changed.updated_at)).toBeGreaterThanOrEqual( + timeOf(row.updated_at), + ); + }); + + it('앱 토큰만 — 웹 쿠키 세션 403 AUTH_003, 토큰 없음 401 AUTH_001, 본문이 틀리면 400 VAL_001 (세 경로 모두)', async () => { + const ME = 'push.guard@e2e.arasystem.test'; + const web = await login(ME); + const device = await appLogin(ME); + + for (const [method, url] of [ + ['put', '/api/push-registrations/current'], + ['delete', '/api/push-registrations/current'], + ['delete', '/api/push-registrations/current/unseen'], + ] as const) { + const asWeb = await request(server) + [method](url) + .set('Cookie', `access_token=${web.access}`) + .send({ fid: fidOf('w'), platform: 'android', appVersion: '1.0.0' }) + .expect(403); + expect(body(asWeb).error?.code).toBe('AUTH_003'); + const anonymous = await request(server)[method](url).expect(401); + expect(body(anonymous).error?.code).toBe('AUTH_001'); + } + + for (const bad of [ + { fid: 'fGx:APA91bToken', platform: 'android', appVersion: '1.0.0' }, + { fid: fidOf('c'), platform: 'ios', appVersion: '1.0.0' }, + { fid: fidOf('c'), platform: 'android', appVersion: '1.0' }, + { + fid: fidOf('c'), + platform: 'android', + appVersion: '1.0.0', + isEnabled: true, + }, + ]) { + const res = await request(server) + .put('/api/push-registrations/current') + .set('Authorization', bearer(device.accessToken)) + .send(bad) + .expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(await rowOf(device.sessionId)).toBeUndefined(); + }); + + it('그사이 끝난 세션은 401 AUTH_001 · 제한된 세션은 403 AUTH_010 — 줄을 넣지 않는다', async () => { + const ME = 'push.ended@e2e.arasystem.test'; + const device = await appLogin(ME); + await dataSource.query( + "UPDATE auth_sessions SET expires_at = now() - interval '1 minute' WHERE id = $1", + [device.sessionId], + ); + const ended = await register(device.accessToken, fidOf('e')).expect(401); + expect(body(ended).error?.code).toBe('AUTH_001'); + expect(await rowOf(device.sessionId)).toBeUndefined(); + + const RESTRICTED = 'push.restricted@e2e.arasystem.test'; + expiredPasswords.add(RESTRICTED); + try { + const restricted = await appLogin(RESTRICTED); + const res = await register(restricted.accessToken, fidOf('r')).expect( + 403, + ); + expect(body(res).error?.code).toBe('AUTH_010'); + expect(await rowOf(restricted.sessionId)).toBeUndefined(); + } finally { + expiredPasswords.delete(RESTRICTED); + } + }); + + it('같은 휴대폰(FID)은 한 줄에만 — 다른 직원이 같은 휴대폰에서 등록하면 앞 직원의 줄을 지운다 · 함께 등록해도 한 줄만 남는다', async () => { + const shared = fidOf('s'); + const first = await appLogin('push.phone.a@e2e.arasystem.test'); + const second = await appLogin('push.phone.b@e2e.arasystem.test'); + await register(first.accessToken, shared).expect(200); + // 앞 직원에게도 등록이 바뀐 소식이 간다 — 그 직원의 붙잡기를 메일 쪽이 다시 고른다 (리뷰 L2) + const notified: string[] = []; + const stopListening = app + .get(PushRegistrationsService) + .onChanged((user) => notified.push(user)); + try { + await register(second.accessToken, shared).expect(200); + } finally { + stopListening(); + } + expect([...notified].sort()).toEqual([ + 'push.phone.a@e2e.arasystem.test', + 'push.phone.b@e2e.arasystem.test', + ]); + expect(await rowOf(first.sessionId)).toBeUndefined(); + expect((await rowsWithFid(shared)).map((row) => row.session_id)).toEqual([ + second.sessionId, + ]); + + const racing = fidOf('x'); + const one = await appLogin('push.race.a@e2e.arasystem.test'); + const two = await appLogin('push.race.b@e2e.arasystem.test'); + const [a, b] = await Promise.all([ + register(one.accessToken, racing), + register(two.accessToken, racing), + ]); + expect([a.status, b.status]).toEqual([200, 200]); + const left = await rowsWithFid(racing); + expect(left).toHaveLength(1); + expect([one.sessionId, two.sessionId]).toContain(left[0].session_id); + }); + + it(`한 직원 ${MAX_PUSH_REGISTRATIONS_PER_USER}개까지 — 넘으면 가장 오래 바꾸지 않은 등록부터 지운다`, async () => { + const ME = 'push.limit@e2e.arasystem.test'; + const sessions: { accessToken: string; sessionId: string }[] = []; + for (let i = 0; i <= MAX_PUSH_REGISTRATIONS_PER_USER; i += 1) { + const device = await appLogin(ME); + await register(device.accessToken, fidOf(`l${i}`)).expect(200); + sessions.push(device); + } + const rows: { session_id: string }[] = await dataSource.query( + 'SELECT session_id FROM push_registrations WHERE user_email = $1', + [ME], + ); + expect(rows).toHaveLength(MAX_PUSH_REGISTRATIONS_PER_USER); + expect(rows.map((row) => row.session_id)).not.toContain( + sessions[0].sessionId, + ); + }); + + it('새 메일 → 그 휴대폰에 "새 메일 N통"(N = 본 뒤 받은 안 읽은 메일) — 메시지 어디에도 보낸 사람 · 이름 · 제목 · 메일 id · 직원 주소 · 세션 id 가 없다, 보낸 때를 적는다', async () => { + const ME = 'push.newmail@e2e.arasystem.test'; + const device = await appLogin(ME); + const fid = fidOf('n'); + await register(device.accessToken, fid).expect(200); + await heldAndCaughtUp(ME); + expect(pushesTo(fid)).toEqual([]); + + inboxUnread.set(ME, [Date.now() + 1_000, Date.now() + 1_000]); + deliverNewMail(ME); + await waitUntil(() => pushesTo(fid).length > 0, GATE_DEADLINE_MS); + + const [push] = pushesTo(fid); + expect(push.notice).toEqual({ kind: 'new-mail', count: 2 }); + expect(push.message).toMatchObject({ + fid, + notification: { title: '새 메일 2통' }, + data: { module: 'mail', kind: 'new-mail' }, + android: { notification: { tag: 'new-mail', notification_count: 2 } }, + }); + const json = JSON.stringify(push.message); + for (const secret of [ + 'secret.sender', + '비밀 보낸이', + '아무도 몰라야 할 제목', + 'msecretmessageid', + ME, + device.sessionId, + ]) { + expect(json).not.toContain(secret); + } + // 보낸 뒤 그 N 을 센 때를 적는다 + const row = await rowWhen( + device.sessionId, + (found) => found?.last_pushed_at != null, + ); + expect(row?.last_pushed_at).not.toBeNull(); + }); + + it('로그아웃한 세션의 휴대폰에는 보내지 않는다 — 같은 직원의 살아 있는 다른 휴대폰에만', async () => { + const ME = 'push.logout@e2e.arasystem.test'; + const gone = await appLogin(ME); + const kept = await appLogin(ME); + const goneFid = fidOf('g'); + const keptFid = fidOf('k'); + await register(gone.accessToken, goneFid).expect(200); + await register(kept.accessToken, keptFid).expect(200); + await heldAndCaughtUp(ME); + + await request(server) + .post('/api/auth/app/logout') + .set('Authorization', bearer(gone.accessToken)) + .send({ refreshToken: gone.refreshToken }) + .expect(200); + inboxUnread.set(ME, [Date.now() + 1_000]); + deliverNewMail(ME); + await waitUntil(() => pushesTo(keptFid).length > 0, GATE_DEADLINE_MS); + expect(pushesTo(goneFid)).toEqual([]); + }); + + it('봤음(DELETE …/current/unseen) — 204, 다음 알림의 N 을 그때부터 센다 · 등록이 없어도 204', async () => { + const ME = 'push.unseen@e2e.arasystem.test'; + const device = await appLogin(ME); + const fid = fidOf('u'); + await register(device.accessToken, fid).expect(200); + await heldAndCaughtUp(ME); + inboxUnread.set(ME, [Date.now()]); + // 세는 시작을 한 시간 앞으로 돌려 둔다 — 봤음이 지금으로 옮기는 것이 보이게 + await dataSource.query( + "UPDATE push_registrations SET seen_at = seen_at - interval '1 hour' WHERE session_id = $1", + [device.sessionId], + ); + const before = await requiredRowOf(device.sessionId); + + await request(server) + .delete('/api/push-registrations/current/unseen') + .set('Authorization', bearer(device.accessToken)) + .expect(204); + const seen = timeOf((await requiredRowOf(device.sessionId)).seen_at); + expect(seen).toBeGreaterThan(timeOf(before.seen_at)); + + const asked = unreadCounts.length; + deliverNewMail(ME); + await waitUntil( + () => unreadCounts.slice(asked).some((entry) => entry.user === ME), + GATE_DEADLINE_MS, + ); + expect( + unreadCounts.slice(asked).find((entry) => entry.user === ME)?.since, + ).toEqual([seen]); + + const other = await appLogin('push.unseen.none@e2e.arasystem.test'); + await request(server) + .delete('/api/push-registrations/current/unseen') + .set('Authorization', bearer(other.accessToken)) + .expect(204); + }); + + it('지우기(DELETE …/current) — 204, 줄이 없어져 보내지 않는다 · 다시 불러도 204', async () => { + const ME = 'push.delete@e2e.arasystem.test'; + const device = await appLogin(ME); + await register(device.accessToken, fidOf('d')).expect(200); + + for (let round = 0; round < 2; round += 1) { + const res = await request(server) + .delete('/api/push-registrations/current') + .set('Authorization', bearer(device.accessToken)) + .expect(204); + expect(res.text).toBe(''); + } + expect(await rowOf(device.sessionId)).toBeUndefined(); + }); + + it('FCM 이 받지 않는 휴대폰(UNREGISTERED)의 등록은 지운다', async () => { + const ME = 'push.unregistered@e2e.arasystem.test'; + const device = await appLogin(ME); + const fid = fidOf('z'); + pushOutcomes.set(fid, 'unregistered'); + await register(device.accessToken, fid).expect(200); + await heldAndCaughtUp(ME); + + inboxUnread.set(ME, [Date.now() + 1_000]); + deliverNewMail(ME); + await waitUntil(() => pushesTo(fid).length > 0, GATE_DEADLINE_MS); + expect( + await rowWhen(device.sessionId, (row) => row === undefined), + ).toBeUndefined(); + }); + + it('5분 정리 — 끝난 세션의 등록을 지우고, 저절로 끝난 세션(만료)의 휴대폰에 한 번 "새 메일 알림이 멈췄습니다" (그 휴대폰에서 로그아웃한 세션에는 보내지 않는다)', async () => { + const EXPIRED = 'push.cleanup.expired@e2e.arasystem.test'; + const REVOKED = 'push.cleanup.revoked@e2e.arasystem.test'; + const expired = await appLogin(EXPIRED); + const revoked = await appLogin(REVOKED); + const expiredFid = fidOf('ce'); + const revokedFid = fidOf('cr'); + await register(expired.accessToken, expiredFid).expect(200); + await register(revoked.accessToken, revokedFid).expect(200); + + await dataSource.query( + "UPDATE auth_sessions SET absolute_expires_at = now() - interval '1 minute' WHERE id = $1", + [expired.sessionId], + ); + await request(server) + .post('/api/auth/app/logout') + .set('Authorization', bearer(revoked.accessToken)) + .send({ refreshToken: revoked.refreshToken }) + .expect(200); + + const result = await app.get(PushCleanupService).sweep(); + expect(result.removed).toBeGreaterThanOrEqual(2); + expect(await rowOf(expired.sessionId)).toBeUndefined(); + expect(await rowOf(revoked.sessionId)).toBeUndefined(); + expect(pushesTo(expiredFid).map((push) => push.message)).toEqual([ + fcmMessageOf(expiredFid, { kind: 'session-ended' }), + ]); + expect(pushesTo(revokedFid)).toEqual([]); + }); + + it('5분 정리 — 끊은 까닭마다(SESSION_ENDED_NOTICE): 보내는 까닭이면 그 휴대폰에 한 번, 보내지 않는 까닭 · 까닭 없음 · 모르는 값 · 제한된 세션이면 보내지 않는다 · 로그에 FID 가 없다', async () => { + const cases: { + readonly name: string; + readonly reason: string | null; + readonly restriction: string | null; + readonly notify: boolean; + }[] = [ + ...Object.entries(SESSION_ENDED_NOTICE).map(([reason, notify]) => ({ + name: reason, + reason, + restriction: null, + notify, + })), + { name: '까닭 없음', reason: null, restriction: null, notify: false }, + { + name: '모르는 까닭(다른 판)', + reason: 'from-another-build', + restriction: null, + notify: false, + }, + { + name: '제한된 세션', + reason: 'password-changed', + restriction: 'passwordChange', + notify: false, + }, + ]; + const devices: { + readonly name: string; + readonly notify: boolean; + readonly sessionId: string; + readonly fid: string; + }[] = []; + for (const [index, entry] of cases.entries()) { + const device = await appLogin( + `push.reason.${index}@e2e.arasystem.test`, + ); + const fid = fidOf(`r${index}`); + await register(device.accessToken, fid).expect(200); + // 끊는 길마다 남기는 값 그대로 — 실제 길이 그 값을 남기는지는 아래 시험 · 인증 시험이 본다 + await dataSource.query( + 'UPDATE auth_sessions SET revoked_at = now(), revoked_reason = $2, restriction = $3 WHERE id = $1', + [device.sessionId, entry.reason, entry.restriction], + ); + devices.push({ + name: entry.name, + notify: entry.notify, + sessionId: device.sessionId, + fid, + }); + } + + const lines: string[] = []; + const spies = (['log', 'warn', 'error', 'debug', 'verbose'] as const).map( + (level) => + jest + .spyOn(Logger.prototype, level) + .mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }), + ); + try { + const cleanup = app.get(PushCleanupService); + await cleanup.sweep(); + // 다음 차례에는 그 줄이 없다 — 한 번만 알린다 + await cleanup.sweep(); + } finally { + for (const spy of spies) spy.mockRestore(); + } + + for (const device of devices) { + expect(await rowOf(device.sessionId)).toBeUndefined(); + expect({ + name: device.name, + messages: pushesTo(device.fid).map((push) => push.message), + }).toEqual({ + name: device.name, + messages: device.notify + ? [fcmMessageOf(device.fid, { kind: 'session-ended' })] + : [], + }); + } + const logged = lines.join('\n'); + for (const device of devices) expect(logged).not.toContain(device.fid); + }); + + it('5분 정리 — 실제로 끊는 길: 다른 곳에서 비밀번호를 바꿔 끊긴 휴대폰(password-changed)에는 한 번 알리고, 다른 곳 모두 로그아웃으로 끊긴 휴대폰(ended-by-user)에는 알리지 않는다', async () => { + const ME = 'push.cleanup.paths@e2e.arasystem.test'; + const keeper = await appLogin(ME); + const endedByUser = await appLogin(ME); + const endedByUserFid = fidOf('pu'); + await register(endedByUser.accessToken, endedByUserFid).expect(200); + await request(server) + .delete('/api/auth/sessions') + .set('Authorization', bearer(keeper.accessToken)) + .expect(200); + + const changed = await appLogin(ME); + const changedFid = fidOf('pc'); + await register(changed.accessToken, changedFid).expect(200); + await request(server) + .put('/api/auth/password') + .set('X-Forwarded-For', '198.18.32.1') + .set('Authorization', bearer(keeper.accessToken)) + .send({ + currentPassword: CORRECT_PASSWORD, + newPassword: 'new-password-for-e2e-push', + }) + .expect(200); + + const reasons: { id: string; revoked_reason: string | null }[] = + await dataSource.query( + 'SELECT id, revoked_reason FROM auth_sessions WHERE id = ANY($1::uuid[])', + [[keeper.sessionId, endedByUser.sessionId, changed.sessionId]], + ); + expect( + Object.fromEntries(reasons.map((row) => [row.id, row.revoked_reason])), + ).toEqual({ + [keeper.sessionId]: null, + [endedByUser.sessionId]: 'ended-by-user', + [changed.sessionId]: 'password-changed', + }); + + const cleanup = app.get(PushCleanupService); + await cleanup.sweep(); + await cleanup.sweep(); + expect(pushesTo(changedFid).map((push) => push.message)).toEqual([ + fcmMessageOf(changedFid, { kind: 'session-ended' }), + ]); + expect(pushesTo(endedByUserFid)).toEqual([]); + }); + + it('쓸 수 없는 세션의 PUT 은 남의 같은 FID 줄을 지우지 않는다 — 끝난 세션 401 · DB 에서만 제한이 붙은 세션(토큰에 rst 없음) 403 AUTH_010 (리뷰 M3-a)', async () => { + const shared = fidOf('m3a'); + const owner = await appLogin('push.m3.owner@e2e.arasystem.test'); + await register(owner.accessToken, shared).expect(200); + + const ended = await appLogin('push.m3.ended@e2e.arasystem.test'); + await dataSource.query( + "UPDATE auth_sessions SET expires_at = now() - interval '1 minute' WHERE id = $1", + [ended.sessionId], + ); + const endedRes = await register(ended.accessToken, shared).expect(401); + expect(body(endedRes).error?.code).toBe('AUTH_001'); + + const restricted = await appLogin( + 'push.m3.restricted@e2e.arasystem.test', + ); + await dataSource.query( + "UPDATE auth_sessions SET restriction = 'passwordChange' WHERE id = $1", + [restricted.sessionId], + ); + const restrictedRes = await register( + restricted.accessToken, + shared, + ).expect(403); + expect(body(restrictedRes).error?.code).toBe('AUTH_010'); + + expect((await rowsWithFid(shared)).map((row) => row.session_id)).toEqual([ + owner.sessionId, + ]); + }); + + it('제한 · 만료(정리 전) · 끊긴 세션의 등록에는 보내지 않는다 — 보낼 대상 · 붙잡을 직원은 늘 세션 표와 함께 고른다 (리뷰 M3-b)', async () => { + const ME = 'push.m3.targets@e2e.arasystem.test'; + const alive = await appLogin(ME); + const restricted = await appLogin(ME); + const expired = await appLogin(ME); + const revoked = await appLogin(ME); + const fids = { + alive: fidOf('ta'), + restricted: fidOf('tr'), + expired: fidOf('te'), + revoked: fidOf('tv'), + }; + await register(alive.accessToken, fids.alive).expect(200); + await register(restricted.accessToken, fids.restricted).expect(200); + await register(expired.accessToken, fids.expired).expect(200); + await register(revoked.accessToken, fids.revoked).expect(200); + await heldAndCaughtUp(ME); + + await dataSource.query( + "UPDATE auth_sessions SET restriction = 'passwordChange' WHERE id = $1", + [restricted.sessionId], + ); + await dataSource.query( + "UPDATE auth_sessions SET expires_at = now() - interval '1 minute' WHERE id = $1", + [expired.sessionId], + ); + await request(server) + .post('/api/auth/app/logout') + .set('Authorization', bearer(revoked.accessToken)) + .send({ refreshToken: revoked.refreshToken }) + .expect(200); + + const registrations = app.get(PushRegistrationsService); + expect( + (await registrations.targetsOf(ME)).map((target) => target.sessionId), + ).toEqual([alive.sessionId]); + for (const [session, fid] of [ + [restricted, fids.restricted], + [expired, fids.expired], + [revoked, fids.revoked], + ] as const) { + expect(await registrations.isDeliverable(session.sessionId, fid)).toBe( + false, + ); + } + + inboxUnread.set(ME, [Date.now() + 1_000]); + deliverNewMail(ME); + await waitUntil(() => pushesTo(fids.alive).length > 0, GATE_DEADLINE_MS); + for (const fid of [fids.restricted, fids.expired, fids.revoked]) { + expect(pushesTo(fid)).toEqual([]); + } + + // 살아 있는 · 제한 없는 등록이 없는 직원은 붙잡을 직원에서 빠진다 + const ONLY = 'push.m3.onlyrestricted@e2e.arasystem.test'; + const only = await appLogin(ONLY); + await register(only.accessToken, fidOf('to')).expect(200); + await dataSource.query( + "UPDATE auth_sessions SET restriction = 'twoFactorSetup' WHERE id = $1", + [only.sessionId], + ); + expect(await registrations.hasTargets(ONLY)).toBe(false); + const users = await registrations.usersWithTargets('@e2e.arasystem.test'); + expect(users).toContain(ME); + expect(users).not.toContain(ONLY); + }); + + it('FCM 이 받지 않는다는 답의 지우기 · 보내기 직전 확인은 그 세션의 FID 가 그대로일 때만 — 그사이 PUT 이 바꾼 FID 의 줄은 둔다 (리뷰 M3-c · L1)', async () => { + const ME = 'push.m3.forget@e2e.arasystem.test'; + const device = await appLogin(ME); + const before = fidOf('fa'); + const after = fidOf('fb'); + await register(device.accessToken, before).expect(200); + await register(device.accessToken, after).expect(200); + + const registrations = app.get(PushRegistrationsService); + expect(await registrations.isDeliverable(device.sessionId, before)).toBe( + false, + ); + expect(await registrations.isDeliverable(device.sessionId, after)).toBe( + true, + ); + await registrations.forgetDevice(device.sessionId, before); + expect(await requiredRowOf(device.sessionId)).toMatchObject({ + fid: after, + }); + await registrations.forgetDevice(device.sessionId, after); + expect(await rowOf(device.sessionId)).toBeUndefined(); + }); + + it('앱 설정 — 로그인 없이 { minSupportedVersion: { android, ios: null }, pushAvailable } · Cache-Control: no-store', async () => { + const res = await request(server).get('/api/app-config').expect(200); + expect(res.headers['cache-control']).toBe('no-store'); + expect(body(res).data).toEqual({ + minSupportedVersion: { android: '1.0.0', ios: null }, + pushAvailable: true, + }); + }); + }); + describe('출처 검사 (상태 변경 요청)', () => { const EVIL_ORIGIN = 'https://evil.example'; diff --git a/backend/test/fcm-test-key.ts b/backend/test/fcm-test-key.ts new file mode 100644 index 0000000..c52d9e2 --- /dev/null +++ b/backend/test/fcm-test-key.ts @@ -0,0 +1,58 @@ +import { generateKeyPairSync, type KeyObject } from 'crypto'; + +/** + * 시험용 FCM 서비스 계정 키 — 프로세스마다 RSA 2048 을 한 번 만들어 쓴다(진짜 키가 아니다 — Google 에 보내지 않는다). + * 단위 시험(fcm-*.spec.ts · env.validation.spec.ts)이 env 값(base64 한 줄) · 서명 확인(공개 키)에 쓴다 + */ +export interface TestServiceAccount { + /** 키 파일 JSON — 칸을 바꿔 틀린 키를 만든다 */ + readonly json: Record; + /** env 에 넣는 값 — JSON 을 통째로 base64 한 줄 */ + readonly base64: string; + readonly publicKey: KeyObject; + readonly privateKeyPem: string; +} + +let pair: { publicKey: KeyObject; privateKeyPem: string } | null = null; + +function keyPair(): { publicKey: KeyObject; privateKeyPem: string } { + if (!pair) { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + pair = { + publicKey, + privateKeyPem: privateKey + .export({ type: 'pkcs8', format: 'pem' }) + .toString(), + }; + } + return pair; +} + +/** JSON → env 값 (base64 한 줄) */ +export function toEnvValue(json: unknown): string { + return Buffer.from(JSON.stringify(json)).toString('base64'); +} + +/** 시험용 키 — `overrides` 로 칸을 바꾸거나(undefined 면 그 칸을 뺀다) 더한다 */ +export function testServiceAccount( + overrides: Record = {}, +): TestServiceAccount { + const { publicKey, privateKeyPem } = keyPair(); + const json: Record = { + type: 'service_account', + project_id: 'arasystem-push-test', + private_key_id: '0123456789abcdef0123456789abcdef01234567', + private_key: privateKeyPem, + client_email: 'arasystem-push@arasystem-push-test.iam.gserviceaccount.com', + client_id: '100000000000000000000', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + ...overrides, + }; + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) delete json[key]; + } + return { json, base64: toEnvValue(json), publicKey, privateKeyPem }; +}