generated from ttipo/project-base
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:
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user