feat(backend): 휴대폰 앱 받기 API 와 운영 도구 올리기 · 목록 명령을 더한다

앱을 스토어 없이 사내에만 나눠 주기로 해(2026-11-08), 로그인한 직원만 최신 안드로이드 APK 의
정보와 파일을 받게 한다(GET /api/app-releases/android/latest · /latest/file — 제한된 세션은 막힌다).
올리기는 API 없이 서버의 운영 도구로 한다(파일 검사 · SHA-256 · 임시 이름에 쓴 뒤 바꾸기 · 실행 기록).
최신은 가장 늦게 올린 판이라 옛 APK 를 다시 올려 되돌릴 수 있고, app-config 의 최소 판보다 낮은 판은 올리지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 23:29:03 +09:00
parent 1f8555340e
commit f2ba81513e
24 changed files with 2869 additions and 5 deletions
+186 -1
View File
@@ -3,7 +3,7 @@ import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash, createHmac, randomBytes, randomUUID } from 'crypto';
import { existsSync, mkdtempSync, rmSync, unlinkSync } from 'fs';
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'fs';
import { get as httpGet, type IncomingMessage, type Server } from 'http';
import { connect } from 'net';
import { tmpdir } from 'os';
@@ -29,6 +29,10 @@ import {
ACCOUNT_SECURITY_SETTINGS,
type AccountSecuritySettings,
} from '@/modules/account-security/account-security.settings';
import { AppReleaseStorage } from '@/modules/app-releases/app-release-storage';
import { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
import { APK_ZIP_SIGNATURE } from '@/modules/app-releases/app-releases.constants';
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
import { MAX_REFRESH_TOKEN_LENGTH } from '@/modules/auth/auth.constants';
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants';
@@ -1930,6 +1934,9 @@ describe('응답 계약 (e2e)', () => {
// 앱 1단계 — 앱 알림은 휴대폰으로 보내지 않고 기록만 (FCM 키 · 바깥 연결 없이)
.overrideProvider(PUSH_SENDER)
.useValue(fakePushSender)
// 휴대폰 앱 받기 — APK 파일은 이 시험의 임시 폴더에 (개발 업로드 볼륨에 남기지 않는다)
.overrideProvider(AppReleaseStorage)
.useValue(new AppReleaseStorage(join(fileDir, 'app-releases')))
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
@@ -10934,6 +10941,184 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기"). 판은 운영 도구처럼 서비스로 올린다(검사 → 볼륨 · 표 — 파일은 이 시험의 임시 폴더).
* 표 app_releases 는 직원 주소로 나뉘지 않는다 — 시작할 때 있던 줄(컨테이너 안에서 돌리면 개발 DB)을 받아 두고 시험마다 비운 뒤, 끝나면 되돌린다
*/
describe('휴대폰 앱 받기 — 사내 배포 (/api/app-releases/android/latest · /file)', () => {
const ME = 'app.release@e2e.arasystem.test';
const INFO = '/api/app-releases/android/latest';
const FILE = '/api/app-releases/android/latest/file';
const APK_TYPE = 'application/vnd.android.package-archive';
const content = Buffer.concat([APK_ZIP_SIGNATURE, randomBytes(64 * 1024)]);
let releases: AppReleasesService;
/** 시작할 때 있던 줄 — 끝나면 되돌린다. 준비가 실패했으면 없다 */
let saved: AppReleaseEntity[] | undefined;
/** 운영자가 docker cp 로 넣는 자리 흉내 */
let incoming = '';
const table = () => dataSource.getRepository(AppReleaseEntity);
const clearTable = () => dataSource.query('DELETE FROM app_releases');
const as = (test: HttpTest) => test.set('Cookie', cookieOf(ME));
const download = (range?: string, ifRange?: string) => {
const test = as(request(server).get(FILE)).responseType('blob');
if (range) void test.set('Range', range);
if (ifRange) void test.set('If-Range', ifRange);
return test;
};
/** 운영 도구처럼 올린다 — 넣은 APK 를 검사해(연 파일) 볼륨 · 표에 */
const publish = async (version: string, at: Date) => {
const path = join(incoming, `ARA-SYSTEM-${version}-${randomUUID()}.apk`);
writeFileSync(path, content);
const inspection = await releases.inspect(path);
if (!inspection.ok) throw new Error(inspection.problem);
try {
return await releases.publish(
inspection.apk,
{ platform: 'android', version, uploadedBy: 'e2e 운영자' },
at,
);
} finally {
await inspection.apk.close();
}
};
beforeAll(async () => {
releases = app.get(AppReleasesService, { strict: false });
saved = await table().find();
incoming = mkdtempSync(join(tmpdir(), 'arasystem-e2e-apk-'));
});
beforeEach(clearTable);
afterAll(async () => {
if (saved) {
await clearTable();
if (saved.length > 0) await table().insert(saved);
}
if (incoming) rmSync(incoming, { recursive: true, force: true });
});
it('로그인하지 않으면 401 AUTH_001 (정보 · 파일)', async () => {
await publish('1.0.0', new Date('2026-11-01T01:00:00.000Z'));
for (const path of [INFO, FILE]) {
const res = await request(server).get(path).expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
}
});
it('올린 판이 없으면 404 RES_001 (정보 · 파일 — 파일도 표준 실패 형식)', async () => {
for (const path of [INFO, FILE]) {
const res = await as(request(server).get(path)).expect(404);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('RES_001');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
}
});
it('정보 200 { version, sizeBytes, releasedAt } · private, no-store — 최신 = 가장 늦게 올린 판(버전 번호가 아니다)', async () => {
await publish('1.2.0', new Date('2026-11-01T01:00:00.000Z'));
await publish('1.1.0', new Date('2026-11-02T01:23:45.000Z'));
const res = await as(request(server).get(INFO)).expect(200);
expect(res.headers['cache-control']).toBe('private, no-store');
expect(body(res).data).toEqual({
version: '1.1.0',
sizeBytes: content.length,
releasedAt: '2026-11-02T01:23:45.000Z',
});
});
it('파일 200 — APK 형식 · ARA-SYSTEM-<버전>.apk · 대용량 받기와 같은 머리글, 바이트 그대로 · HEAD 는 머리글만', async () => {
const release = await publish(
'1.0.0',
new Date('2026-11-01T01:00:00.000Z'),
);
const res = await download().expect(200);
expect(res.headers).toMatchObject({
'content-type': APK_TYPE,
'content-disposition': 'attachment; filename="ARA-SYSTEM-1.0.0.apk"',
'content-length': String(content.length),
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
'cache-control': 'private, no-store',
'referrer-policy': 'no-referrer',
'x-robots-tag': 'noindex',
'accept-ranges': 'bytes',
etag: `"${release.id}"`,
'last-modified': release.createdAt.toUTCString(),
'x-accel-buffering': 'no',
});
expect((res.body as Buffer).equals(content)).toBe(true);
const head = await as(request(server).head(FILE)).expect(200);
expect(head.headers['content-type']).toBe(APK_TYPE);
expect(head.headers['content-length']).toBe(String(content.length));
});
it('범위 — 하나면 206 · 끝 N 바이트 · 처음이 크기 이상이면 416(본문 없음) · If-Range 가 다르면 200 전체 · 같으면 이어 받기', async () => {
const release = await publish(
'1.0.0',
new Date('2026-11-01T01:00:00.000Z'),
);
const part = await download('bytes=0-99').expect(206);
expect(part.headers['content-range']).toBe(
`bytes 0-99/${content.length}`,
);
expect(part.headers['content-length']).toBe('100');
expect((part.body as Buffer).equals(content.subarray(0, 100))).toBe(true);
const tail = await download('bytes=-16').expect(206);
expect((tail.body as Buffer).equals(content.subarray(-16))).toBe(true);
const beyond = await download(`bytes=${content.length}-`).expect(416);
expect(beyond.headers['content-range']).toBe(`bytes */${content.length}`);
expect(beyond.headers['content-length']).toBe('0');
const stale = await download('bytes=0-99', '"other"').expect(200);
expect((stale.body as Buffer).length).toBe(content.length);
const resumed = await download('bytes=100-', `"${release.id}"`).expect(
206,
);
expect((resumed.body as Buffer).equals(content.subarray(100))).toBe(true);
});
it('표에는 있는데 볼륨에 파일이 없으면 파일은 404 RES_001 (정보는 그대로 — 운영자가 다시 올린다)', async () => {
const release = await publish(
'1.0.0',
new Date('2026-11-01T01:00:00.000Z'),
);
unlinkSync(join(fileDir, 'app-releases', `${release.fileKey}.apk`));
const res = await as(request(server).get(FILE)).expect(404);
expect(body(res).error?.code).toBe('RES_001');
await as(request(server).get(INFO)).expect(200);
});
it('제한된 세션(새 비밀번호를 정하기 전)은 403 AUTH_010 — 허용 목록에 넣지 않는다', async () => {
await publish('1.0.0', new Date('2026-11-01T01:00:00.000Z'));
const RESTRICTED = 'app.release.expired@e2e.arasystem.test';
expiredPasswords.add(RESTRICTED);
try {
const login = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.40.1')
.send({ email: RESTRICTED, password: CORRECT_PASSWORD })
.expect(200);
expect(body(login).data).toMatchObject({
restriction: 'passwordChange',
});
const cookie = `access_token=${cookieValue(setCookie(login, 'access_token'))}`;
for (const path of [INFO, FILE]) {
const res = await request(server)
.get(path)
.set('Cookie', cookie)
.expect(403);
expect(body(res).error?.code).toBe('AUTH_010');
}
} finally {
expiredPasswords.delete(RESTRICTED);
}
});
});
describe('출처 검사 (상태 변경 요청)', () => {
const EVIL_ORIGIN = 'https://evil.example';