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