feat(backend): 앱 새 메일 알림(FCM)과 앱 최소 판 확인을 더한다

앱 첫 판의 새 메일 알림을 서버가 보낸다. 알림 글은 "새 메일 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 00:49:18 +09:00
parent 6ecc38d927
commit c0edff10d1
54 changed files with 6822 additions and 21 deletions
+746 -1
View File
@@ -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<string, number[]>();
const unreadCounts: { user: string; since: number[] }[] = [];
const pushes: {
fid: string;
notice: PushNotice;
message: FcmMessage;
}[] = [];
const pushOutcomes = new Map<string, PushSendOutcome>();
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<PushRow | undefined> => {
const rows: PushRow[] = await dataSource.query(
'SELECT * FROM push_registrations WHERE session_id = $1',
[sessionId],
);
return rows[0];
};
const rowsWithFid = async (fid: string): Promise<PushRow[]> =>
dataSource.query('SELECT * FROM push_registrations WHERE fid = $1', [
fid,
]);
/** 꼭 있어야 하는 줄 — 없으면 시험이 틀렸다 */
const requiredRowOf = async (sessionId: string): Promise<PushRow> => {
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<PushRow | undefined> => {
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<object>(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';
+58
View File
@@ -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<string, unknown>;
/** 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<string, unknown> = {},
): TestServiceAccount {
const { publicKey, privateKeyPem } = keyPair();
const json: Record<string, unknown> = {
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 };
}