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:
@@ -10,6 +10,7 @@ import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guar
|
||||
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 { AppReleasesModule } from '@/modules/app-releases/app-releases.module';
|
||||
import { AuthModule } from '@/modules/auth/auth.module';
|
||||
import { ContactsModule } from '@/modules/contacts/contacts.module';
|
||||
import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module';
|
||||
@@ -86,6 +87,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
PushModule,
|
||||
// 앱 설정 (앱 1단계) — GET /api/app-config (로그인 없음 — 최소 판 · 알림을 보낼 수 있는지)
|
||||
AppConfigModule,
|
||||
// 휴대폰 앱 받기 (사내 배포) — 최신 판 정보 · APK 파일 (로그인 필요). 올리기는 운영 도구다
|
||||
AppReleasesModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 판 표 (docs/mail/api-contract.md 1절 "휴대폰 앱 받기" — 사내 배포, 2026-11-08).
|
||||
* - app_releases: 운영 도구 「휴대폰 앱 올리기」가 올린 판마다 한 줄 — 플랫폼 · 판 · 크기 · SHA-256 · 디스크 이름(file_key UUID) · 올린 이(운영자 이름) · 올린 때.
|
||||
* file_key 유일 인덱스(디스크 이름은 한 줄에만), (platform, created_at) 인덱스(최신 = 그 플랫폼에서 가장 늦게 올린 줄)
|
||||
* 기존 표의 칸 · 행은 건드리지 않는다 — 표를 새로 만들기만 한다.
|
||||
* (엔티티: modules/app-releases/app-release.entity.ts — migration:generate 의 결과(이 표의 세 문장뿐이었다)를 그대로 옮겼다)
|
||||
*/
|
||||
export class AddAppReleases1794140434961 implements MigrationInterface {
|
||||
name = 'AddAppReleases1794140434961';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "app_releases" ("id" uuid NOT NULL, "platform" character varying(16) NOT NULL, "version" character varying(16) NOT NULL, "size_bytes" bigint NOT NULL, "sha256" character(64) NOT NULL, "file_key" uuid NOT NULL, "uploaded_by" character varying(100) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e5b28b6fe128172639b75251cd1" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_app_releases_file_key" ON "app_releases" ("file_key") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_app_releases_platform_created_at" ON "app_releases" ("platform", "created_at") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_app_releases_platform_created_at"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_app_releases_file_key"`);
|
||||
await queryRunner.query(`DROP TABLE "app_releases"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
truncateSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { open } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { forEachChunk, inspectApk, type ApkInspection } from './apk-file';
|
||||
import {
|
||||
APK_MAX_BYTES,
|
||||
APK_MIN_BYTES,
|
||||
APK_ZIP_SIGNATURE,
|
||||
} from './app-releases.constants';
|
||||
|
||||
/** zip 머리 + 무작위 내용 */
|
||||
function apkBytes(size: number): Buffer {
|
||||
return Buffer.concat([
|
||||
APK_ZIP_SIGNATURE,
|
||||
randomBytes(size - APK_ZIP_SIGNATURE.length),
|
||||
]);
|
||||
}
|
||||
|
||||
const sha256 = (data: Buffer) =>
|
||||
createHash('sha256').update(data).digest('hex');
|
||||
|
||||
/** 검사가 막은 까닭 (통과했으면 연 파일을 닫고 'ok') */
|
||||
async function problemOf(inspection: ApkInspection): Promise<string> {
|
||||
if (inspection.ok) {
|
||||
await inspection.apk.close();
|
||||
return 'ok';
|
||||
}
|
||||
return inspection.problem;
|
||||
}
|
||||
|
||||
/** 이 시험을 root 로 돌리는가 — root 는 권한과 상관없이 읽는다 */
|
||||
const runningAsRoot = process.getuid?.() === 0;
|
||||
const onWindows = process.platform === 'win32';
|
||||
|
||||
describe('inspectApk — 운영자가 넣은 APK 검사 (휴대폰 앱 올리기)', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'apk-file-'));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const file = (name: string, data: Buffer) => {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, data);
|
||||
return path;
|
||||
};
|
||||
|
||||
it('통과하면 크기 · SHA-256 · 파일 이름과 연 파일을 준다 — 연 파일로 다시 읽을 수 있다', async () => {
|
||||
const data = apkBytes(64 * 1024);
|
||||
const path = file('ARA-SYSTEM-1.0.0.apk', data);
|
||||
|
||||
const inspection = await inspectApk(path);
|
||||
expect(inspection.ok).toBe(true);
|
||||
if (!inspection.ok) return;
|
||||
const { apk } = inspection;
|
||||
try {
|
||||
expect(apk).toMatchObject({
|
||||
path,
|
||||
fileName: 'ARA-SYSTEM-1.0.0.apk',
|
||||
sizeBytes: data.length,
|
||||
sha256: sha256(data),
|
||||
});
|
||||
const again = Buffer.alloc(data.length);
|
||||
await apk.handle.read(again, 0, again.length, 0);
|
||||
expect(again.equals(data)).toBe(true);
|
||||
} finally {
|
||||
await apk.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('이름은 .apk 로 끝나야 한다 (대소문자는 가리지 않는다) — 이름이 틀리면 열지도 않는다', async () => {
|
||||
const data = apkBytes(APK_MIN_BYTES);
|
||||
await expect(
|
||||
problemOf(await inspectApk(file('ARA-SYSTEM-1.0.0.APK', data))),
|
||||
).resolves.toBe('ok');
|
||||
for (const name of ['ARA-SYSTEM-1.0.0.zip', 'ARA-SYSTEM-1.0.0', 'apk']) {
|
||||
await expect(problemOf(await inspectApk(file(name, data)))).resolves.toBe(
|
||||
'not-apk-name',
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
problemOf(await inspectApk(join(dir, 'none.zip'))),
|
||||
).resolves.toBe('not-apk-name');
|
||||
});
|
||||
|
||||
it('없는 경로 · 폴더 · 파일이 아닌 것은 알린다', async () => {
|
||||
await expect(
|
||||
problemOf(await inspectApk(join(dir, 'none.apk'))),
|
||||
).resolves.toBe('missing');
|
||||
await expect(
|
||||
problemOf(await inspectApk(join(dir, 'no-dir', 'x.apk'))),
|
||||
).resolves.toBe('missing');
|
||||
|
||||
mkdirSync(join(dir, 'folder.apk'));
|
||||
await expect(
|
||||
problemOf(await inspectApk(join(dir, 'folder.apk'))),
|
||||
).resolves.toBe('not-file');
|
||||
});
|
||||
|
||||
(onWindows ? it.skip : it)(
|
||||
'바로가기(심볼릭 링크)는 따라가지 않는다',
|
||||
async () => {
|
||||
const target = file('real.apk', apkBytes(APK_MIN_BYTES));
|
||||
symlinkSync(target, join(dir, 'link.apk'));
|
||||
await expect(
|
||||
problemOf(await inspectApk(join(dir, 'link.apk'))),
|
||||
).resolves.toBe('symlink');
|
||||
},
|
||||
);
|
||||
|
||||
(onWindows ? it.skip : it)(
|
||||
'파이프(FIFO)를 열 때 멈추지 않는다 — 일반 파일이 아니라고 알린다',
|
||||
async () => {
|
||||
const path = join(dir, 'pipe.apk');
|
||||
try {
|
||||
execFileSync('mkfifo', [path]);
|
||||
} catch {
|
||||
// mkfifo 가 없는 환경 — 이 시험은 건너뛴다 (개발 컨테이너 · CI 에는 있다)
|
||||
return;
|
||||
}
|
||||
await expect(problemOf(await inspectApk(path))).resolves.toBe('not-file');
|
||||
},
|
||||
);
|
||||
|
||||
(onWindows || runningAsRoot ? it.skip : it)(
|
||||
'읽을 권한이 없으면 알린다 (docker cp 는 root 소유로 넣는다)',
|
||||
async () => {
|
||||
const path = file('locked.apk', apkBytes(APK_MIN_BYTES));
|
||||
chmodSync(path, 0o000);
|
||||
await expect(problemOf(await inspectApk(path))).resolves.toBe(
|
||||
'unreadable',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('크기 — 1KB 아래 · 200MB 위는 받지 않는다(큰 파일은 읽지 않고 크기로만), 경계는 받는다', async () => {
|
||||
await expect(
|
||||
problemOf(
|
||||
await inspectApk(file('small.apk', apkBytes(APK_MIN_BYTES - 1))),
|
||||
),
|
||||
).resolves.toBe('too-small');
|
||||
await expect(
|
||||
problemOf(await inspectApk(file('edge.apk', apkBytes(APK_MIN_BYTES)))),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
// 빈 곳만 있는 큰 파일(디스크를 쓰지 않는다) — 머리를 읽기 전에 크기로 막는다
|
||||
const large = join(dir, 'large.apk');
|
||||
writeFileSync(large, APK_ZIP_SIGNATURE);
|
||||
truncateSync(large, APK_MAX_BYTES + 1);
|
||||
await expect(problemOf(await inspectApk(large))).resolves.toBe('too-large');
|
||||
});
|
||||
|
||||
it('zip 머리(PK\\x03\\x04)가 아니면 APK 가 아니다', async () => {
|
||||
const notZip = Buffer.concat([
|
||||
Buffer.from('<html>'),
|
||||
randomBytes(APK_MIN_BYTES),
|
||||
]);
|
||||
await expect(
|
||||
problemOf(await inspectApk(file('page.apk', notZip))),
|
||||
).resolves.toBe('not-zip');
|
||||
// 빈 디렉터리 머리(PK\x05\x06 — 빈 zip)도 APK 가 아니다
|
||||
const emptyZip = Buffer.concat([
|
||||
Buffer.from([0x50, 0x4b, 0x05, 0x06]),
|
||||
Buffer.alloc(APK_MIN_BYTES),
|
||||
]);
|
||||
await expect(
|
||||
problemOf(await inspectApk(file('empty.apk', emptyZip))),
|
||||
).resolves.toBe('not-zip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forEachChunk — 연 파일을 처음부터 끝까지', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'apk-chunks-'));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it('조각을 차례로 넘기고 읽은 크기가 맞으면 true — 다르면(그사이 바뀜) false', async () => {
|
||||
const data = randomBytes(3 * 1024 * 1024 + 17);
|
||||
const path = join(dir, 'data.bin');
|
||||
writeFileSync(path, data);
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
const parts: Buffer[] = [];
|
||||
await expect(
|
||||
forEachChunk(handle, data.length, (chunk) => {
|
||||
parts.push(Buffer.from(chunk));
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(Buffer.concat(parts).equals(data)).toBe(true);
|
||||
expect(parts.length).toBeGreaterThan(1);
|
||||
|
||||
// 생각한 것보다 짧다(줄었다) · 길다(늘었다) — 넘게 읽히면 거기서 멈춘다(끝까지 읽지 않는다)
|
||||
await expect(
|
||||
forEachChunk(handle, data.length + 1, () => undefined),
|
||||
).resolves.toBe(false);
|
||||
let seen = 0;
|
||||
await expect(
|
||||
forEachChunk(handle, 10, (chunk) => {
|
||||
seen += chunk.length;
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(seen).toBeLessThanOrEqual(10);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { constants } from 'fs';
|
||||
import { open, type FileHandle } from 'fs/promises';
|
||||
import { basename, extname } from 'path';
|
||||
|
||||
import {
|
||||
APK_EXTENSION,
|
||||
APK_MAX_BYTES,
|
||||
APK_MIN_BYTES,
|
||||
APK_ZIP_SIGNATURE,
|
||||
} from './app-releases.constants';
|
||||
|
||||
/**
|
||||
* 운영자가 컨테이너 안에 넣은 APK 읽기 (운영 도구 「휴대폰 앱 올리기」 — 계약 1절 "휴대폰 앱 받기").
|
||||
* 파일 하나를 한 번 열고, 그 열린 파일(같은 inode)로 검사 · SHA-256 · 저장소에 들이기를 모두 한다 —
|
||||
* 검사한 뒤 경로가 다른 파일로 바뀌어도 들이는 것은 검사한 그 파일이다(내용이 바뀌면 들일 때 다시 센 SHA-256 이 막는다)
|
||||
*/
|
||||
|
||||
/** 읽을 수 없는 까닭 — 운영 도구가 한국어로 알린다 (modules/ops/app-release-commands.ts) */
|
||||
export type ApkProblem =
|
||||
/** 이름이 .apk 로 끝나지 않는다 */
|
||||
| 'not-apk-name'
|
||||
/** 그 경로에 없다 */
|
||||
| 'missing'
|
||||
/** 바로가기(심볼릭 링크)다 — 따라가지 않는다 */
|
||||
| 'symlink'
|
||||
/** 일반 파일이 아니다 (폴더 · 장치 · 파이프 · 소켓) */
|
||||
| 'not-file'
|
||||
/** 읽을 권한이 없다 (docker cp 는 root 소유로 넣는다 — 원본이 644 가 아니면 node 가 읽지 못한다) */
|
||||
| 'unreadable'
|
||||
| 'too-small'
|
||||
| 'too-large'
|
||||
/** zip 머리(PK\x03\x04)가 아니다 */
|
||||
| 'not-zip'
|
||||
/** 읽는 동안 크기가 바뀌었다 (아직 복사 중) */
|
||||
| 'changed';
|
||||
|
||||
/** 검사를 마친 APK — 다 쓰면 close() (부르는 쪽이 닫는다) */
|
||||
export interface InspectedApk {
|
||||
/** 운영자가 넣은 경로 그대로 — 실행 기록 · 바뀔 것에 보인다(감사용) */
|
||||
readonly path: string;
|
||||
/** 경로의 마지막 이름 — 판을 짐작하는 데만 쓴다(디스크 · 내려받는 이름이 되지 않는다) */
|
||||
readonly fileName: string;
|
||||
readonly sizeBytes: number;
|
||||
/** SHA-256 (소문자 16진수) */
|
||||
readonly sha256: string;
|
||||
/** 연 파일 — 저장소에 들일 때 처음부터 다시 읽는다 */
|
||||
readonly handle: FileHandle;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export type ApkInspection =
|
||||
| { readonly ok: true; readonly apk: InspectedApk }
|
||||
| { readonly ok: false; readonly problem: ApkProblem };
|
||||
|
||||
/** 한 번에 읽는 양 */
|
||||
const READ_CHUNK_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 열기 — 바로가기를 따라가지 않고(O_NOFOLLOW — 그 경로가 링크면 ELOOP), 파이프 · 장치를 열 때 멈추지 않게(O_NONBLOCK — 일반 파일 읽기에는 영향이 없다).
|
||||
* 일반 파일인지는 연 뒤 fstat 으로 본다. Windows 에는 두 표시가 없다(0)
|
||||
*/
|
||||
const OPEN_FLAGS =
|
||||
constants.O_RDONLY |
|
||||
(constants.O_NOFOLLOW ?? 0) |
|
||||
(constants.O_NONBLOCK ?? 0);
|
||||
|
||||
function problem(kind: ApkProblem): ApkInspection {
|
||||
return { ok: false, problem: kind };
|
||||
}
|
||||
|
||||
/** 열지 못한 까닭 → 알릴 것 (모르는 오류는 null — 그대로 던진다) */
|
||||
function openProblem(error: unknown): ApkProblem | null {
|
||||
switch ((error as NodeJS.ErrnoException | null)?.code) {
|
||||
case 'ENOENT':
|
||||
case 'ENOTDIR':
|
||||
return 'missing';
|
||||
case 'ELOOP':
|
||||
return 'symlink';
|
||||
case 'EACCES':
|
||||
case 'EPERM':
|
||||
return 'unreadable';
|
||||
// 소켓 등 — 열 수 없는 특수 파일
|
||||
case 'ENXIO':
|
||||
case 'EISDIR':
|
||||
return 'not-file';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연 파일을 처음부터 끝까지 차례로 읽어 조각마다 부른다 — 읽은 바이트 수가 `size` 와 다르면(그사이 파일이 바뀜) false.
|
||||
* 넘게 읽히면 거기서 멈춘다. 조각 버퍼는 다음 읽기에 다시 쓴다 — 부르는 쪽은 그 안에서 다 쓴다(해시 · 쓰기를 기다린 뒤 돌아온다)
|
||||
*/
|
||||
export async function forEachChunk(
|
||||
handle: FileHandle,
|
||||
size: number,
|
||||
onChunk: (chunk: Buffer) => Promise<void> | void,
|
||||
): Promise<boolean> {
|
||||
const buffer = Buffer.alloc(Math.max(1, Math.min(READ_CHUNK_BYTES, size)));
|
||||
let position = 0;
|
||||
for (;;) {
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
||||
if (bytesRead === 0) return position === size;
|
||||
position += bytesRead;
|
||||
if (position > size) return false;
|
||||
await onChunk(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
}
|
||||
|
||||
/** 열린 파일의 SHA-256 — 크기가 바뀌었으면 null */
|
||||
async function sha256Of(
|
||||
handle: FileHandle,
|
||||
size: number,
|
||||
): Promise<string | null> {
|
||||
const hash = createHash('sha256');
|
||||
const complete = await forEachChunk(handle, size, (chunk) => {
|
||||
hash.update(chunk);
|
||||
});
|
||||
return complete ? hash.digest('hex') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영자가 넣은 경로의 APK 를 검사한다 — 이름(.apk) · 일반 파일 · 크기(1KB ~ 200MB) · zip 머리 · SHA-256.
|
||||
* 큰 파일은 크기를 본 뒤에야 읽는다(200MB 를 넘는 파일은 읽지 않는다). 통과하면 연 파일째 돌려준다 — 부르는 쪽이 닫는다
|
||||
*/
|
||||
export async function inspectApk(path: string): Promise<ApkInspection> {
|
||||
if (extname(path).toLowerCase() !== APK_EXTENSION) {
|
||||
return problem('not-apk-name');
|
||||
}
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await open(path, OPEN_FLAGS);
|
||||
} catch (error) {
|
||||
const reason = openProblem(error);
|
||||
if (reason === null) throw error;
|
||||
return problem(reason);
|
||||
}
|
||||
let handedOver = false;
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile()) return problem('not-file');
|
||||
if (stats.size < APK_MIN_BYTES) return problem('too-small');
|
||||
if (stats.size > APK_MAX_BYTES) return problem('too-large');
|
||||
|
||||
const head = Buffer.alloc(APK_ZIP_SIGNATURE.length);
|
||||
const { bytesRead } = await handle.read(head, 0, head.length, 0);
|
||||
if (bytesRead !== head.length || !head.equals(APK_ZIP_SIGNATURE)) {
|
||||
return problem('not-zip');
|
||||
}
|
||||
|
||||
const sha256 = await sha256Of(handle, stats.size);
|
||||
if (sha256 === null) return problem('changed');
|
||||
|
||||
handedOver = true;
|
||||
const opened = handle;
|
||||
return {
|
||||
ok: true,
|
||||
apk: {
|
||||
path,
|
||||
fileName: basename(path),
|
||||
sizeBytes: stats.size,
|
||||
sha256,
|
||||
handle: opened,
|
||||
close: () => opened.close(),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
if (!handedOver) await handle.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { open, type FileHandle } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import {
|
||||
AppReleaseFileChangedError,
|
||||
AppReleaseStorage,
|
||||
} from './app-release-storage';
|
||||
|
||||
const KEY = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const OTHER_KEY = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const sha256 = (data: Buffer) =>
|
||||
createHash('sha256').update(data).digest('hex');
|
||||
|
||||
describe('AppReleaseStorage (휴대폰 앱 파일 — 업로드 볼륨의 app-releases/)', () => {
|
||||
let root: string;
|
||||
let dir: string;
|
||||
let storage: AppReleaseStorage;
|
||||
let source: FileHandle;
|
||||
const data = randomBytes(2 * 1024 * 1024 + 5);
|
||||
|
||||
beforeEach(async () => {
|
||||
root = mkdtempSync(join(tmpdir(), 'app-release-storage-'));
|
||||
// 아직 없는 하위 폴더 — 처음 들일 때 만든다
|
||||
dir = join(root, 'app-releases');
|
||||
storage = new AppReleaseStorage(dir);
|
||||
const sourcePath = join(root, 'ARA-SYSTEM-1.0.0.apk');
|
||||
writeFileSync(sourcePath, data);
|
||||
source = await open(sourcePath, 'r');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await source.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const expected = () => ({ sizeBytes: data.length, sha256: sha256(data) });
|
||||
|
||||
it('들이면 <key>.apk 만 남는다(임시 파일 없음 · 소유자만 쓴다) — 열고 · 있는지 보고 · 지운다', async () => {
|
||||
await storage.importFile(source, KEY, expected());
|
||||
|
||||
expect(readdirSync(dir)).toEqual([`${KEY}.apk`]);
|
||||
expect(readFileSync(join(dir, `${KEY}.apk`)).equals(data)).toBe(true);
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(join(dir, `${KEY}.apk`)).mode & 0o777).toBe(0o640);
|
||||
}
|
||||
|
||||
const opened = await storage.open(KEY);
|
||||
expect(opened?.sizeBytes).toBe(data.length);
|
||||
await opened?.handle.close();
|
||||
await expect(storage.exists(KEY)).resolves.toBe(true);
|
||||
|
||||
await storage.remove(KEY);
|
||||
await storage.remove(KEY);
|
||||
await expect(storage.exists(KEY)).resolves.toBe(false);
|
||||
await expect(storage.open(KEY)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('들이는 동안 다시 센 SHA-256 · 크기가 검사 때와 다르면 들이지 않고 아무것도 남기지 않는다', async () => {
|
||||
await expect(
|
||||
storage.importFile(source, KEY, {
|
||||
...expected(),
|
||||
sha256: sha256(Buffer.from('other')),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
|
||||
await expect(
|
||||
storage.importFile(source, KEY, {
|
||||
...expected(),
|
||||
sizeBytes: data.length - 1,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
|
||||
await expect(
|
||||
storage.importFile(source, KEY, {
|
||||
...expected(),
|
||||
sizeBytes: data.length + 1,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('쓰다 실패해도(원본을 읽지 못함) 반쯤 쓴 임시 파일을 남기지 않는다', async () => {
|
||||
const broken = {
|
||||
read: jest.fn().mockRejectedValue(new Error('EIO')),
|
||||
} as unknown as FileHandle;
|
||||
await expect(storage.importFile(broken, KEY, expected())).rejects.toThrow(
|
||||
'EIO',
|
||||
);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('같은 키의 임시 파일이 있으면(다른 올리기가 쓰는 중) 덮어쓰지도 지우지도 않는다', async () => {
|
||||
await storage.importFile(source, OTHER_KEY, expected());
|
||||
writeFileSync(join(dir, `${KEY}.apk.part`), 'someone else');
|
||||
|
||||
await expect(storage.importFile(source, KEY, expected())).rejects.toThrow();
|
||||
expect(readFileSync(join(dir, `${KEY}.apk.part`), 'utf8')).toBe(
|
||||
'someone else',
|
||||
);
|
||||
});
|
||||
|
||||
it('서버가 만든 키(소문자 UUID)가 아니면 경로로 쓰지 않는다', async () => {
|
||||
for (const key of [
|
||||
'../../etc/passwd',
|
||||
`../${KEY}`,
|
||||
`${KEY}/x`,
|
||||
KEY.toUpperCase(),
|
||||
`${KEY}.apk`,
|
||||
'',
|
||||
]) {
|
||||
await expect(storage.importFile(source, key, expected())).rejects.toThrow(
|
||||
'휴대폰 앱 파일 키 형식이 올바르지 않습니다.',
|
||||
);
|
||||
await expect(storage.open(key)).rejects.toThrow();
|
||||
await expect(storage.exists(key)).rejects.toThrow();
|
||||
await expect(storage.remove(key)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('멈춘 올리기의 오래된 임시 파일만 지운다 — 새 임시 파일 · 판 파일 · 다른 이름은 그대로, 폴더가 없으면 0', async () => {
|
||||
await expect(storage.removeStaleParts(new Date())).resolves.toBe(0);
|
||||
|
||||
await storage.importFile(source, OTHER_KEY, expected());
|
||||
const old = join(dir, `${KEY}.apk.part`);
|
||||
const fresh = join(dir, '0f8fad5b-d9cb-469f-a165-70867728950e.apk.part');
|
||||
const stranger = join(dir, 'notes.part');
|
||||
for (const path of [old, fresh, stranger]) writeFileSync(path, 'x');
|
||||
const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
utimesSync(old, longAgo, longAgo);
|
||||
utimesSync(stranger, longAgo, longAgo);
|
||||
|
||||
await expect(
|
||||
storage.removeStaleParts(new Date(Date.now() - 60 * 60 * 1000)),
|
||||
).resolves.toBe(1);
|
||||
expect(readdirSync(dir).sort()).toEqual(
|
||||
[
|
||||
`${OTHER_KEY}.apk`,
|
||||
'0f8fad5b-d9cb-469f-a165-70867728950e.apk.part',
|
||||
'notes.part',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rename,
|
||||
stat,
|
||||
unlink,
|
||||
type FileHandle,
|
||||
} from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { forEachChunk } from './apk-file';
|
||||
import { APK_EXTENSION, APP_RELEASE_DIR } from './app-releases.constants';
|
||||
|
||||
/** 서버가 만든 키(소문자 UUID)만 경로로 만든다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */
|
||||
const FILE_KEY =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/** 쓰는 중인 임시 파일의 꼬리 — `<key>.apk.part` */
|
||||
const PART_SUFFIX = '.part';
|
||||
/** 임시 파일 이름 — 정리할 때 이 꼴만 지운다 */
|
||||
const PART_FILE_NAME =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.apk\.part$/;
|
||||
|
||||
/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) — 서명 그림 · 대용량 첨부 저장과 같다 */
|
||||
const FILE_MODE = 0o640;
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
|
||||
}
|
||||
|
||||
async function unlinkIfExists(path: string): Promise<void> {
|
||||
try {
|
||||
await unlink(path);
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 버퍼를 끝까지 쓴다 (한 번에 다 쓰지 못할 수 있다) */
|
||||
async function writeAll(
|
||||
out: FileHandle,
|
||||
data: Buffer,
|
||||
position: number,
|
||||
): Promise<void> {
|
||||
let done = 0;
|
||||
while (done < data.length) {
|
||||
const { bytesWritten } = await out.write(
|
||||
data,
|
||||
done,
|
||||
data.length - done,
|
||||
position + done,
|
||||
);
|
||||
done += bytesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
/** 저장소에 들이는 동안 원본이 검사한 것과 달라졌다 (SHA-256 · 크기) — 들이지 않았다 */
|
||||
export class AppReleaseFileChangedError extends Error {
|
||||
override readonly name = 'AppReleaseFileChangedError';
|
||||
|
||||
constructor() {
|
||||
super('APK 가 검사한 뒤 바뀌었습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/** 연 판 파일 — 크기는 열린 파일에서(fstat). 부르는 쪽이 닫거나 읽기 흐름에 넘긴다 */
|
||||
export interface OpenedAppReleaseFile {
|
||||
readonly handle: FileHandle;
|
||||
readonly sizeBytes: number;
|
||||
}
|
||||
|
||||
/** 들이는 파일이 맞아야 할 값 — 검사 때 센 것 */
|
||||
export interface ExpectedAppReleaseFile {
|
||||
readonly sizeBytes: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 파일 저장소 — 업로드 볼륨의 하위 폴더(APP_RELEASE_DIR) 안 `<file_key>.apk`.
|
||||
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 서비스가 형식 · 머리글을 정한다 (.claude/rules/security.md "파일 업로드").
|
||||
* 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다
|
||||
*/
|
||||
export class AppReleaseStorage {
|
||||
constructor(private readonly dir: string = APP_RELEASE_DIR) {}
|
||||
|
||||
/** 판 파일을 연다 — 없으면 null */
|
||||
async open(fileKey: string): Promise<OpenedAppReleaseFile | null> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await open(this.pathOf(fileKey), 'r');
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return { handle, sizeBytes: (await handle.stat()).size };
|
||||
} catch (error) {
|
||||
await handle.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 판 파일이 있는가 (운영 도구의 목록) */
|
||||
async exists(fileKey: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(this.pathOf(fileKey));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영자가 넣은 APK(검사하며 연 파일)를 들인다 — `<key>.apk.part` 에 쓰고(wx — 덮어쓰지 않는다) 디스크에 내려쓴 뒤 `<key>.apk` 로 이름을 바꾼다.
|
||||
* 쓰면서 다시 센 SHA-256 · 크기가 검사 때와 다르면(검사한 뒤 원본이 바뀜) AppReleaseFileChangedError.
|
||||
* 어떻게 실패해도 반쯤 쓴 임시 파일을 남기지 않는다 — 이름을 바꾸기 전에는 `<key>.apk` 가 생기지 않는다
|
||||
*/
|
||||
async importFile(
|
||||
source: FileHandle,
|
||||
fileKey: string,
|
||||
expected: ExpectedAppReleaseFile,
|
||||
): Promise<void> {
|
||||
const finalPath = this.pathOf(fileKey);
|
||||
const partPath = `${finalPath}${PART_SUFFIX}`;
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
const out = await open(partPath, 'wx', FILE_MODE);
|
||||
let closed = false;
|
||||
let renamed = false;
|
||||
try {
|
||||
const hash = createHash('sha256');
|
||||
let written = 0;
|
||||
const complete = await forEachChunk(
|
||||
source,
|
||||
expected.sizeBytes,
|
||||
async (chunk) => {
|
||||
hash.update(chunk);
|
||||
await writeAll(out, chunk, written);
|
||||
written += chunk.length;
|
||||
},
|
||||
);
|
||||
if (!complete || hash.digest('hex') !== expected.sha256) {
|
||||
throw new AppReleaseFileChangedError();
|
||||
}
|
||||
// 이름을 바꾸기 전에 디스크에 내려쓴다 — 표에 넣은 판의 파일이 전원이 꺼져도 남게
|
||||
await out.datasync();
|
||||
closed = true;
|
||||
await out.close();
|
||||
await rename(partPath, finalPath);
|
||||
renamed = true;
|
||||
} finally {
|
||||
if (!closed) await out.close().catch(() => undefined);
|
||||
if (!renamed) await unlinkIfExists(partPath);
|
||||
}
|
||||
}
|
||||
|
||||
/** 판 파일을 지운다 — 이미 없으면 그대로 (표에 넣지 못했을 때 되돌리기) */
|
||||
async remove(fileKey: string): Promise<void> {
|
||||
await unlinkIfExists(this.pathOf(fileKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 멈춘 올리기(도구가 쓰는 중에 끊김)가 남긴 임시 파일 가운데 `olderThan` 보다 오래된 것을 지운다 → 지운 수.
|
||||
* 폴더가 없으면 0. 그사이 지워진 것은 건너뛴다
|
||||
*/
|
||||
async removeStaleParts(olderThan: Date): Promise<number> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(this.dir);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return 0;
|
||||
throw error;
|
||||
}
|
||||
let removed = 0;
|
||||
for (const name of names.filter((entry) => PART_FILE_NAME.test(entry))) {
|
||||
const path = join(this.dir, name);
|
||||
try {
|
||||
if ((await stat(path)).mtime >= olderThan) continue;
|
||||
await unlink(path);
|
||||
removed += 1;
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private pathOf(fileKey: string): string {
|
||||
if (!FILE_KEY.test(fileKey)) {
|
||||
// 서버가 만든 키가 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001)
|
||||
throw new Error('휴대폰 앱 파일 키 형식이 올바르지 않습니다.');
|
||||
}
|
||||
return join(this.dir, `${fileKey}${APK_EXTENSION}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import {
|
||||
appReleaseFileName,
|
||||
compareAppVersions,
|
||||
isAppVersion,
|
||||
versionInFileName,
|
||||
} from './app-release-version';
|
||||
|
||||
describe('휴대폰 앱 버전 (사내 배포)', () => {
|
||||
it('버전 글 — 숫자 셋을 점으로(칸마다 1~4자리 · 앞자리 0 없이), 앱 알림 등록의 appVersion 과 같은 규칙', () => {
|
||||
for (const version of ['1.0.0', '0.0.1', '10.20.30', '1234.0.9999']) {
|
||||
expect(isAppVersion(version)).toBe(true);
|
||||
}
|
||||
for (const version of [
|
||||
'',
|
||||
'1.0',
|
||||
'1.0.0.1',
|
||||
'01.0.0',
|
||||
'1.00.0',
|
||||
'1.0.0-beta',
|
||||
'1.0.0+5',
|
||||
'v1.0.0',
|
||||
'12345.0.0',
|
||||
' 1.0.0',
|
||||
'1.0.0\n',
|
||||
]) {
|
||||
expect(isAppVersion(version)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('칸마다 숫자로 견준다 — 글자 차례가 아니다(1.10.0 > 1.9.0)', () => {
|
||||
expect(compareAppVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
|
||||
expect(compareAppVersions('1.9.9', '2.0.0')).toBeLessThan(0);
|
||||
expect(compareAppVersions('1.2.3', '1.2.3')).toBe(0);
|
||||
expect(compareAppVersions('0.9.0', '1.0.0')).toBeLessThan(0);
|
||||
expect(compareAppVersions('1.0.10', '1.0.2')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('파일 이름에서 버전을 찾는다 — 없거나 규칙에 맞지 않으면 null', () => {
|
||||
expect(versionInFileName('ARA-SYSTEM-1.2.3.apk')).toBe('1.2.3');
|
||||
expect(versionInFileName('ARA-SYSTEM-v10.0.1.APK')).toBe('10.0.1');
|
||||
expect(versionInFileName('app-release-1.0.0+12.apk')).toBe('1.0.0');
|
||||
expect(versionInFileName('app-release.apk')).toBeNull();
|
||||
// 네 칸 · 다섯 자리 · 앞자리 0 — 판으로 보지 않는다
|
||||
expect(versionInFileName('ARA-SYSTEM-1.2.3.4.apk')).toBeNull();
|
||||
expect(versionInFileName('ARA-SYSTEM-12345.0.0.apk')).toBeNull();
|
||||
expect(versionInFileName('ARA-SYSTEM-01.0.0.apk')).toBeNull();
|
||||
});
|
||||
|
||||
it('파일 이름에서 찾기는 긴 이름에도 길이에 비례한다 (숫자 · 점이 잔뜩 든 이름)', () => {
|
||||
const name = `${'1.'.repeat(100_000)}apk`;
|
||||
const cpuMs = startCpuTimer();
|
||||
expect(versionInFileName(name)).toBeNull();
|
||||
// 지금 수 ms — 한도는 넉넉히(부하에서 는다 · .claude/rules/testing.md)
|
||||
expect(cpuMs()).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it('내려받는 파일 이름 — ARA-SYSTEM-<버전>.apk', () => {
|
||||
expect(appReleaseFileName('1.0.0')).toBe('ARA-SYSTEM-1.0.0.apk');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
APP_VERSION_MAX_LENGTH,
|
||||
APP_VERSION_PATTERN,
|
||||
} from '@/modules/push/push.constants';
|
||||
import { APK_EXTENSION } from './app-releases.constants';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 판 (순수 함수) — 판 글은 앱 알림 등록의 `appVersion` 과 **같은 규칙**이다(push.constants 의 APP_VERSION_PATTERN —
|
||||
* `숫자.숫자.숫자`, 칸마다 1~4자리 · 앞자리 0 없이). 앱 설정의 최소 판(app-config)도 같은 꼴로 칸마다 숫자로 견준다
|
||||
*/
|
||||
|
||||
/** 파일 이름 안의 판 후보 — 앞뒤가 숫자 · 점으로 이어지지 않는 `x.y.z` (`1.2.3.4` 의 일부는 판으로 보지 않는다). 칸마다 4자리까지라 길이에 비례한다 */
|
||||
const VERSION_IN_NAME =
|
||||
/(?<![0-9.])([0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4})(?![0-9]|\.[0-9])/;
|
||||
|
||||
/** 판 글인가 — 길이를 먼저 본다(정규식 전에) */
|
||||
export function isAppVersion(text: string): boolean {
|
||||
return (
|
||||
text.length <= APP_VERSION_MAX_LENGTH && APP_VERSION_PATTERN.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
/** 두 판을 칸마다 숫자로 견준다 — a 가 낮으면 음수, 같으면 0, 높으면 양수 (둘 다 판 글이어야 한다) */
|
||||
export function compareAppVersions(a: string, b: string): number {
|
||||
const left = a.split('.').map(Number);
|
||||
const right = b.split('.').map(Number);
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const difference = left[index] - right[index];
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 운영자가 넣은 파일 이름에서 판을 찾는다 (`ARA-SYSTEM-1.2.3.apk` → `1.2.3`) — 없거나 판 규칙에 맞지 않으면 null */
|
||||
export function versionInFileName(fileName: string): string | null {
|
||||
const match = VERSION_IN_NAME.exec(fileName);
|
||||
return match && isAppVersion(match[1]) ? match[1] : null;
|
||||
}
|
||||
|
||||
/** 직원이 내려받는 파일 이름 — 서버가 검사한 판으로만 만든다 (`ARA-SYSTEM-1.0.0.apk`) */
|
||||
export function appReleaseFileName(version: string): string {
|
||||
return `ARA-SYSTEM-${version}${APK_EXTENSION}`;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import { BIGINT_NUMBER } from '@/shared/utils/bigint-column';
|
||||
import {
|
||||
APP_RELEASES_TABLE,
|
||||
APP_RELEASE_UPLOADER_MAX_LENGTH,
|
||||
type AppReleasePlatform,
|
||||
} from './app-releases.constants';
|
||||
|
||||
/** 플랫폼 이름 길이 — 'android' (push_registrations.platform 과 같다) */
|
||||
const PLATFORM_COLUMN_LENGTH = 16;
|
||||
/** 판 길이 — 'NNNN.NNNN.NNNN' 까지 (push_registrations.app_version 과 같다) */
|
||||
const VERSION_COLUMN_LENGTH = 16;
|
||||
/** SHA-256 — 소문자 16진수 64자 */
|
||||
const SHA256_HEX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* 나눠 주는 휴대폰 앱 한 판 (계약 1절 "휴대폰 앱 받기" — 사내 배포) — 운영 도구 「휴대폰 앱 올리기」가 한 줄씩 넣는다.
|
||||
*
|
||||
* - "최신" = 그 플랫폼에서 가장 늦게 올린 줄(created_at) — 판 번호가 아니다. 옛 APK 를 다시 올리면 그것이 최신이 된다(되돌리기 길)
|
||||
* - 파일은 업로드 볼륨의 `app-releases/<file_key>.apk` — 디스크 이름은 서버가 만든 UUID 뿐이다(운영자가 넣은 파일 이름 · 경로를 쓰지 않는다)
|
||||
* - 지우는 명령은 없다 — 옛 판은 표 · 볼륨에 남는다(되돌리기 · 문의 대응). 직원 주소로 키링한 표가 아니라 퇴사 마무리와 상관없다
|
||||
*/
|
||||
@Entity({ name: APP_RELEASES_TABLE })
|
||||
@Index('IDX_app_releases_platform_created_at', ['platform', 'createdAt'])
|
||||
export class AppReleaseEntity {
|
||||
/** 판 id — 서버가 만든 UUID (내려받기 응답의 ETag) */
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
/** 플랫폼 — 지금은 android 만 */
|
||||
@Column({ type: 'varchar', length: PLATFORM_COLUMN_LENGTH })
|
||||
platform!: AppReleasePlatform;
|
||||
|
||||
/** 판 (`1.0.0` — 앱의 versionName. 앱 알림 등록의 appVersion 과 같은 규칙) */
|
||||
@Column({ type: 'varchar', length: VERSION_COLUMN_LENGTH })
|
||||
version!: string;
|
||||
|
||||
/** 파일 크기(바이트) — 내려받기의 Content-Length · 범위 계산이 이 값을 쓴다(디스크의 크기와 다르면 내려주지 않는다) */
|
||||
@Column({ name: 'size_bytes', type: 'bigint', transformer: BIGINT_NUMBER })
|
||||
sizeBytes!: number;
|
||||
|
||||
/** 파일의 SHA-256 (소문자 16진수) — 운영자가 빌드한 파일과 견준다 */
|
||||
@Column({ type: 'char', length: SHA256_HEX_LENGTH })
|
||||
sha256!: string;
|
||||
|
||||
/** 디스크의 파일 이름(확장자 빼고) — 서버가 만든 UUID */
|
||||
@Index('UQ_app_releases_file_key', { unique: true })
|
||||
@Column({ name: 'file_key', type: 'uuid' })
|
||||
fileKey!: string;
|
||||
|
||||
/** 올린 이 — 운영 도구를 시작할 때 넣은 운영자 이름 */
|
||||
@Column({
|
||||
name: 'uploaded_by',
|
||||
type: 'varchar',
|
||||
length: APP_RELEASE_UPLOADER_MAX_LENGTH,
|
||||
})
|
||||
uploadedBy!: string;
|
||||
|
||||
/** 올린 때 — 최신을 가르는 차례 · 화면의 "올린 날"(`releasedAt`) */
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기", 2026-11-08) — 한도 · 형식 · 자리를 한 곳에 모은다.
|
||||
* 앱은 스토어 없이 직접 빌드한 APK 를 포털에 로그인한 직원에게만 나눠 준다.
|
||||
* 올리기는 API 가 없다 — 서버 B 의 운영 도구 「휴대폰 앱 올리기」(modules/ops/app-release-commands.ts)
|
||||
*/
|
||||
|
||||
/** 표 이름 — app-release.entity.ts 와 같다 */
|
||||
export const APP_RELEASES_TABLE = 'app_releases';
|
||||
|
||||
/** 나눠 주는 플랫폼 — 지금은 안드로이드만 (아이폰은 나중 거리) */
|
||||
export const APP_RELEASE_PLATFORMS = ['android'] as const;
|
||||
export type AppReleasePlatform = (typeof APP_RELEASE_PLATFORMS)[number];
|
||||
|
||||
/** APK 의 형식 — 내려받기 응답의 Content-Type (휴대폰이 설치 파일로 알아본다) */
|
||||
export const APK_CONTENT_TYPE = 'application/vnd.android.package-archive';
|
||||
|
||||
/** 확장자 — 운영자가 넣은 파일 이름에서 본다(대소문자는 가리지 않는다) · 내려받는 파일 이름에 붙인다 */
|
||||
export const APK_EXTENSION = '.apk';
|
||||
|
||||
/** zip 의 로컬 파일 머리 `PK\x03\x04` — APK 는 zip 이다 */
|
||||
export const APK_ZIP_SIGNATURE: Readonly<Buffer> = Buffer.from([
|
||||
0x50, 0x4b, 0x03, 0x04,
|
||||
]);
|
||||
|
||||
/** 크기 한도 — 1KB 아래는 APK 가 아니다, 200MB 위는 받지 않는다(지금 앱은 30MB 남짓) */
|
||||
export const APK_MIN_BYTES = 1024;
|
||||
export const APK_MAX_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 파일 자리 — 업로드 볼륨(modules/upload 와 같은 UPLOAD_DIR)의 하위 폴더. 이름은 `<file_key>.apk` 뿐이다.
|
||||
* 운영은 named volume `<PROJECT_NAME>_uploads`(매일 백업에 든다), 개발은 바인드 마운트 (.claude/skills/docker/SKILL.md)
|
||||
*/
|
||||
export const APP_RELEASE_DIR = join(UPLOAD_DIR, 'app-releases');
|
||||
|
||||
/** 운영 도구 「휴대폰 앱 목록」이 보이는 판 수 — 최근 것부터 */
|
||||
export const APP_RELEASE_LIST_LIMIT = 10;
|
||||
|
||||
/** 올린 이(운영자 이름) 길이 — 운영 도구의 운영자 이름 한도(ops-audit-log.entity.ts 의 OPS_OPERATOR_MAX_LENGTH)와 같다 */
|
||||
export const APP_RELEASE_UPLOADER_MAX_LENGTH = 100;
|
||||
|
||||
/**
|
||||
* 멈춘 올리기가 남긴 임시 파일(`*.apk.part`)을 지우는 나이 — 올리기를 시작할 때 이보다 오래된 것만 지운다.
|
||||
* 다른 운영자가 막 쓰는 중인 임시 파일은 건드리지 않게 넉넉히 둔다(200MB 를 쓰는 데 몇 초다)
|
||||
*/
|
||||
export const APP_RELEASE_STALE_PART_MS = 60 * 60 * 1000;
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiProduces,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { APK_CONTENT_TYPE } from './app-releases.constants';
|
||||
import { AppReleasesService } from './app-releases.service';
|
||||
import { AppReleaseDto } from './dto/app-release.dto';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기", 2026-11-08) — 포털에 로그인한 직원만 받는다(웹 쿠키 · 앱 Bearer — JwtAuthGuard).
|
||||
* 제한된 세션(새 비밀번호 · 2단계 켜기 전)은 403 AUTH_010 — `@AllowRestrictedSession()` 을 달지 않는다.
|
||||
* 올리기는 API 가 없다 — 서버 B 의 운영 도구 「휴대폰 앱 올리기」(modules/ops/app-release-commands.ts).
|
||||
*
|
||||
* "올린 판이 없음"(404)은 화면이 설정을 열 때마다 묻는 예상된 상태라 debug 로만 남긴다 — 표에는 있는데 파일이 없는 404 는 서비스가 error 로 따로 남긴다
|
||||
*/
|
||||
@ApiTags('App')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors()
|
||||
@UseFilters(new HttpExceptionFilter({ quietStatuses: [HttpStatus.NOT_FOUND] }))
|
||||
@Controller('app-releases')
|
||||
export class AppReleasesController {
|
||||
constructor(private readonly releases: AppReleasesService) {}
|
||||
|
||||
@Get('android/latest')
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '안드로이드 앱의 최신 판 정보',
|
||||
description:
|
||||
'최신 = 가장 늦게 올린 판(올린 시각 — 버전 번호가 아니다). 옛 APK 를 다시 올리면 그것이 최신이 된다. ' +
|
||||
'올린 판이 없으면 404 RES_001 — 화면은 "아직 받을 수 있는 앱이 없습니다" 칸을 보인다(알림 끔). Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(AppReleaseDto, { description: '최신 판 정보' })
|
||||
@ApiErrorResponse('RES_001', '올린 판이 없다')
|
||||
latest(): Promise<AppReleaseDto> {
|
||||
return this.releases.latestInfo('android');
|
||||
}
|
||||
|
||||
@Get('android/latest/file')
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'안드로이드 앱의 최신 판 파일 (GET · HEAD · Range — 표준 래퍼 없음)',
|
||||
description:
|
||||
`늘 ${APK_CONTENT_TYPE} + Content-Disposition: attachment; filename="ARA-SYSTEM-<버전>.apk" + nosniff + sandbox CSP + private, no-store. ` +
|
||||
'강한 ETag("<판 id>") · Last-Modified(올린 때) · Accept-Ranges: bytes — 대용량 첨부 받기와 같은 머리글이고 형식만 APK 다. ' +
|
||||
'Range 하나면 206, 여럿 · 형식 오류면 200 전체, 처음이 크기 이상이면 416(본문 없음). HEAD 는 머리글만. ' +
|
||||
'올린 판이 없거나 그 파일이 없으면 404 RES_001. 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다.',
|
||||
})
|
||||
@ApiProduces(APK_CONTENT_TYPE)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'APK 파일 (206 — 범위)',
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
|
||||
description: '받을 수 없는 범위 — Content-Range: bytes */<크기>, 본문 없음',
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '올린 판이 없다 · 그 파일이 없다')
|
||||
async file(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
await this.releases.serveLatestFile(req, res, 'android');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AppReleaseStorage } from './app-release-storage';
|
||||
import { AppReleaseEntity } from './app-release.entity';
|
||||
import { AppReleasesController } from './app-releases.controller';
|
||||
import { AppReleasesRepository } from './app-releases.repository';
|
||||
import { AppReleasesService } from './app-releases.service';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 받기 모듈 (사내 배포 — 계약 1절 "휴대폰 앱 받기") — 최신 판 정보 · 파일(`/api/app-releases/android/latest` · `/file`).
|
||||
* 판은 표 app_releases, 파일은 업로드 볼륨의 `app-releases/`. 올리기는 운영 도구가 같은 서비스 · 저장소를 다시 엮어 쓴다(modules/ops/ops.module.ts).
|
||||
* 이 모듈은 다른 기능 모듈을 가져오지 않는다
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AppReleaseEntity])],
|
||||
controllers: [AppReleasesController],
|
||||
providers: [
|
||||
AppReleasesRepository,
|
||||
{
|
||||
provide: AppReleaseStorage,
|
||||
useFactory: () => new AppReleaseStorage(),
|
||||
},
|
||||
AppReleasesService,
|
||||
],
|
||||
exports: [AppReleasesService],
|
||||
})
|
||||
export class AppReleasesModule {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { AppReleaseEntity } from './app-release.entity';
|
||||
import type { AppReleasePlatform } from './app-releases.constants';
|
||||
|
||||
/** 늦게 올린 것부터 — 같은 시각이면 id 로 차례를 정한다(늘 같은 답) */
|
||||
const NEWEST_FIRST = { createdAt: 'DESC', id: 'DESC' } as const;
|
||||
|
||||
/** 휴대폰 앱 판 표 (app_releases) */
|
||||
@Injectable()
|
||||
export class AppReleasesRepository {
|
||||
constructor(
|
||||
@InjectRepository(AppReleaseEntity)
|
||||
private readonly rows: Repository<AppReleaseEntity>,
|
||||
) {}
|
||||
|
||||
/** 그 플랫폼에서 가장 늦게 올린 판 — 없으면 null */
|
||||
latest(platform: AppReleasePlatform): Promise<AppReleaseEntity | null> {
|
||||
return this.rows.findOne({ where: { platform }, order: NEWEST_FIRST });
|
||||
}
|
||||
|
||||
/** 늦게 올린 것부터 `limit` 개 */
|
||||
recent(
|
||||
platform: AppReleasePlatform,
|
||||
limit: number,
|
||||
): Promise<AppReleaseEntity[]> {
|
||||
return this.rows.find({
|
||||
where: { platform },
|
||||
order: NEWEST_FIRST,
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async insert(release: AppReleaseEntity): Promise<void> {
|
||||
await this.rows.insert(release);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import type { Request, Response } from 'express';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { inspectApk, type InspectedApk } from './apk-file';
|
||||
import { AppReleaseStorage } from './app-release-storage';
|
||||
import { AppReleaseEntity } from './app-release.entity';
|
||||
import { APK_ZIP_SIGNATURE } from './app-releases.constants';
|
||||
import type { AppReleasesRepository } from './app-releases.repository';
|
||||
import { AppReleasesService } from './app-releases.service';
|
||||
|
||||
/** 표 대역 — 늦게 올린 것부터 (실제 차례는 e2e 가 PostgreSQL 로 본다) */
|
||||
class FakeRepository {
|
||||
readonly rows: AppReleaseEntity[] = [];
|
||||
insertFailure: Error | null = null;
|
||||
|
||||
private newestFirst(platform: string): AppReleaseEntity[] {
|
||||
return this.rows
|
||||
.filter((row) => row.platform === platform)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
latest(platform: string) {
|
||||
return Promise.resolve(this.newestFirst(platform)[0] ?? null);
|
||||
}
|
||||
|
||||
recent(platform: string, limit: number) {
|
||||
return Promise.resolve(this.newestFirst(platform).slice(0, limit));
|
||||
}
|
||||
|
||||
insert(release: AppReleaseEntity) {
|
||||
if (this.insertFailure) return Promise.reject(this.insertFailure);
|
||||
this.rows.push(release);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/** 응답 대역 — 흘려보낸 바이트 · 상태 · 머리글을 모은다 */
|
||||
class FakeResponse extends PassThrough {
|
||||
statusCode = 200;
|
||||
readonly headers = new Map<string, string>();
|
||||
readonly received: Buffer[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.on('data', (part: Buffer) => this.received.push(part));
|
||||
}
|
||||
|
||||
status(code: number): this {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
setHeader(name: string, value: string): this {
|
||||
this.headers.set(name.toLowerCase(), value);
|
||||
return this;
|
||||
}
|
||||
|
||||
get body(): Buffer {
|
||||
return Buffer.concat(this.received);
|
||||
}
|
||||
}
|
||||
|
||||
const request = (method: string, headers: Record<string, string> = {}) =>
|
||||
({ method, headers }) as unknown as Request;
|
||||
|
||||
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
|
||||
try {
|
||||
await run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppReleasesService — 휴대폰 앱 받기 (사내 배포)', () => {
|
||||
let root: string;
|
||||
let releaseDir: string;
|
||||
let repository: FakeRepository;
|
||||
let service: AppReleasesService;
|
||||
let errors: string[];
|
||||
const content = Buffer.concat([APK_ZIP_SIGNATURE, randomBytes(4096)]);
|
||||
|
||||
/** 운영자가 넣은 것처럼 — 임시 폴더의 APK 를 검사해 연 파일째 */
|
||||
async function inspected(data: Buffer = content): Promise<InspectedApk> {
|
||||
const path = join(root, `in-${randomBytes(4).toString('hex')}.apk`);
|
||||
writeFileSync(path, data);
|
||||
const inspection = await inspectApk(path);
|
||||
if (!inspection.ok) throw new Error(inspection.problem);
|
||||
return inspection.apk;
|
||||
}
|
||||
|
||||
async function publish(
|
||||
version: string,
|
||||
at: Date,
|
||||
data: Buffer = content,
|
||||
): Promise<AppReleaseEntity> {
|
||||
const apk = await inspected(data);
|
||||
try {
|
||||
return await service.publish(
|
||||
apk,
|
||||
{ platform: 'android', version, uploadedBy: '운영자 김' },
|
||||
at,
|
||||
);
|
||||
} finally {
|
||||
await apk.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function serve(req: Request): Promise<FakeResponse> {
|
||||
const res = new FakeResponse();
|
||||
await service.serveLatestFile(req, res as unknown as Response, 'android');
|
||||
return res;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'app-releases-'));
|
||||
releaseDir = join(root, 'app-releases');
|
||||
repository = new FakeRepository();
|
||||
service = new AppReleasesService(
|
||||
repository as unknown as AppReleasesRepository,
|
||||
new AppReleaseStorage(releaseDir),
|
||||
);
|
||||
errors = [];
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
errors.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('최신 판 정보 — 올린 판이 없으면 404 RES_001, 있으면 가장 늦게 올린 판(버전 번호가 아니다)', async () => {
|
||||
expect(await codeOf(() => service.latestInfo('android'))).toBe('RES_001');
|
||||
|
||||
await publish('1.2.0', new Date('2026-11-18T01:00:00.000Z'));
|
||||
const older = await publish('1.1.0', new Date('2026-11-19T01:23:45.000Z'));
|
||||
await expect(service.latestInfo('android')).resolves.toEqual({
|
||||
version: '1.1.0',
|
||||
sizeBytes: content.length,
|
||||
releasedAt: older.createdAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('올리기 — 볼륨에 <file_key>.apk 하나 · 표에 한 줄(크기 · SHA-256 · 올린 이)', async () => {
|
||||
const release = await publish(
|
||||
'1.0.0',
|
||||
new Date('2026-11-18T01:23:45.000Z'),
|
||||
);
|
||||
expect(repository.rows).toEqual([release]);
|
||||
expect(release).toMatchObject({
|
||||
platform: 'android',
|
||||
version: '1.0.0',
|
||||
sizeBytes: content.length,
|
||||
uploadedBy: '운영자 김',
|
||||
});
|
||||
expect(release.sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(release.fileKey).not.toBe(release.id);
|
||||
expect(readdirSync(releaseDir)).toEqual([`${release.fileKey}.apk`]);
|
||||
});
|
||||
|
||||
it('올리기 — 표에 넣지 못하면 들인 파일을 지우고 던진다(반쯤 올린 판을 남기지 않는다)', async () => {
|
||||
repository.insertFailure = new Error('db down');
|
||||
const apk = await inspected();
|
||||
try {
|
||||
await expect(
|
||||
service.publish(apk, {
|
||||
platform: 'android',
|
||||
version: '1.0.0',
|
||||
uploadedBy: '운영자 김',
|
||||
}),
|
||||
).rejects.toThrow('db down');
|
||||
} finally {
|
||||
await apk.close();
|
||||
}
|
||||
expect(repository.rows).toEqual([]);
|
||||
expect(readdirSync(releaseDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('올리기 — 버전 글이 아니면 아무것도 하지 않고 던진다(부르는 쪽이 먼저 본다)', async () => {
|
||||
const apk = await inspected();
|
||||
try {
|
||||
await expect(
|
||||
service.publish(apk, {
|
||||
platform: 'android',
|
||||
version: '1.0',
|
||||
uploadedBy: '운영자 김',
|
||||
}),
|
||||
).rejects.toThrow('휴대폰 앱 버전 형식이 올바르지 않습니다.');
|
||||
} finally {
|
||||
await apk.close();
|
||||
}
|
||||
expect(existsSync(releaseDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('올리기 — 멈춘 올리기가 남긴 한 시간 넘은 임시 파일을 먼저 지운다', async () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
await publish('1.0.0', new Date('2026-11-18T01:00:00.000Z'));
|
||||
const stale = join(
|
||||
releaseDir,
|
||||
'0f8fad5b-d9cb-469f-a165-70867728950e.apk.part',
|
||||
);
|
||||
writeFileSync(stale, 'half');
|
||||
|
||||
// 지금 시각(파일의 고친 때)보다 두 시간 뒤에 올린다
|
||||
await publish('1.0.1', new Date(Date.now() + 2 * 60 * 60 * 1000));
|
||||
expect(existsSync(stale)).toBe(false);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('임시 파일 1개를 지웠습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('파일 응답 (Range · HEAD)', () => {
|
||||
let release: AppReleaseEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
release = await publish('1.0.0', new Date('2026-11-18T01:23:45.000Z'));
|
||||
});
|
||||
|
||||
it('전체 200 — APK 형식 · 이름 · 대용량 받기와 같은 머리글(nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => {
|
||||
const res = await serve(request('GET'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.equals(content)).toBe(true);
|
||||
expect(Object.fromEntries(res.headers)).toEqual({
|
||||
'content-type': 'application/vnd.android.package-archive',
|
||||
'content-disposition': 'attachment; filename="ARA-SYSTEM-1.0.0.apk"',
|
||||
'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',
|
||||
'content-length': String(content.length),
|
||||
});
|
||||
});
|
||||
|
||||
it('범위 하나 206 · 끝 N 바이트 · 처음이 크기 이상이면 416(본문 없음) · If-Range 가 다르면 전체 200 · HEAD 는 머리글만', async () => {
|
||||
const partial = await serve(request('GET', { range: 'bytes=0-99' }));
|
||||
expect(partial.statusCode).toBe(206);
|
||||
expect(partial.body.equals(content.subarray(0, 100))).toBe(true);
|
||||
expect(partial.headers.get('content-range')).toBe(
|
||||
`bytes 0-99/${content.length}`,
|
||||
);
|
||||
expect(partial.headers.get('content-length')).toBe('100');
|
||||
|
||||
const suffix = await serve(request('GET', { range: 'bytes=-10' }));
|
||||
expect(suffix.statusCode).toBe(206);
|
||||
expect(suffix.body.equals(content.subarray(content.length - 10))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const unsatisfiable = await serve(
|
||||
request('GET', { range: `bytes=${content.length}-` }),
|
||||
);
|
||||
expect(unsatisfiable.statusCode).toBe(416);
|
||||
expect(unsatisfiable.headers.get('content-range')).toBe(
|
||||
`bytes */${content.length}`,
|
||||
);
|
||||
expect(unsatisfiable.headers.get('content-length')).toBe('0');
|
||||
expect(unsatisfiable.body.length).toBe(0);
|
||||
|
||||
const changed = await serve(
|
||||
request('GET', { range: 'bytes=0-9', 'if-range': '"other"' }),
|
||||
);
|
||||
expect(changed.statusCode).toBe(200);
|
||||
expect(changed.body.length).toBe(content.length);
|
||||
const resumed = await serve(
|
||||
request('GET', { range: 'bytes=10-', 'if-range': `"${release.id}"` }),
|
||||
);
|
||||
expect(resumed.statusCode).toBe(206);
|
||||
expect(resumed.body.equals(content.subarray(10))).toBe(true);
|
||||
|
||||
const head = await serve(request('HEAD', { range: 'bytes=0-1' }));
|
||||
expect(head.statusCode).toBe(206);
|
||||
expect(head.headers.get('content-length')).toBe('2');
|
||||
expect(head.body.length).toBe(0);
|
||||
});
|
||||
|
||||
it('표에는 있는데 파일이 없거나 크기가 다르면 404 RES_001 — 머리글을 쓰지 않고, 운영자가 알게 error 로그', async () => {
|
||||
writeFileSync(join(releaseDir, `${release.fileKey}.apk`), 'cut');
|
||||
const res = new FakeResponse();
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.serveLatestFile(
|
||||
request('GET'),
|
||||
res as unknown as Response,
|
||||
'android',
|
||||
),
|
||||
),
|
||||
).toBe('RES_001');
|
||||
expect(res.headers.size).toBe(0);
|
||||
expect(errors.join('\n')).toContain('크기가 표와 다름');
|
||||
|
||||
rmSync(join(releaseDir, `${release.fileKey}.apk`));
|
||||
expect(await codeOf(() => serve(request('GET')))).toBe('RES_001');
|
||||
expect(errors.join('\n')).toContain('파일 없음');
|
||||
expect(errors.join('\n')).toContain(`release=${release.id}`);
|
||||
});
|
||||
|
||||
it('올린 판이 없으면 404 RES_001 (머리글을 쓰기 전에)', async () => {
|
||||
repository.rows.length = 0;
|
||||
const res = new FakeResponse();
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.serveLatestFile(
|
||||
request('GET'),
|
||||
res as unknown as Response,
|
||||
'android',
|
||||
),
|
||||
),
|
||||
).toBe('RES_001');
|
||||
expect(res.headers.size).toBe(0);
|
||||
});
|
||||
|
||||
it('서버 종료가 시작되면 새 받기는 시작하지 않는다(연결을 끊는다 — 받는 쪽이 이어 받는다)', async () => {
|
||||
service.beforeApplicationShutdown();
|
||||
const res = await serve(request('GET'));
|
||||
expect(res.destroyed).toBe(true);
|
||||
expect(res.body.length).toBe(0);
|
||||
});
|
||||
|
||||
it('목록 — 늦게 올린 것부터, 디스크에 파일이 있는지 함께', async () => {
|
||||
const newer = await publish(
|
||||
'1.0.1',
|
||||
new Date('2026-11-19T01:00:00.000Z'),
|
||||
);
|
||||
rmSync(join(releaseDir, `${release.fileKey}.apk`));
|
||||
await expect(service.listRecent('android', 10)).resolves.toEqual([
|
||||
{ release: newer, hasFile: true },
|
||||
{ release, hasFile: false },
|
||||
]);
|
||||
await expect(service.listRecent('android', 1)).resolves.toEqual([
|
||||
{ release: newer, hasFile: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
type BeforeApplicationShutdown,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { currentRequestId, traceTag } from '@/shared/logger/request-context';
|
||||
import { downloadHeaders } from '@/shared/utils/download-headers';
|
||||
import { decideRange } from '@/shared/utils/http-range';
|
||||
import { isStreamAbortError } from '@/shared/utils/stream-abort';
|
||||
import { inspectApk, type ApkInspection, type InspectedApk } from './apk-file';
|
||||
import {
|
||||
AppReleaseStorage,
|
||||
type OpenedAppReleaseFile,
|
||||
} from './app-release-storage';
|
||||
import { appReleaseFileName, isAppVersion } from './app-release-version';
|
||||
import { AppReleaseEntity } from './app-release.entity';
|
||||
import {
|
||||
APK_CONTENT_TYPE,
|
||||
APP_RELEASE_STALE_PART_MS,
|
||||
type AppReleasePlatform,
|
||||
} from './app-releases.constants';
|
||||
import { AppReleasesRepository } from './app-releases.repository';
|
||||
import type {
|
||||
AppReleaseInfo,
|
||||
AppReleaseListing,
|
||||
AppReleasePublish,
|
||||
} from './app-releases.types';
|
||||
|
||||
/** 버전 글이 아닌 줄(표를 손으로 고침)의 내려받는 이름 — 따옴표 안에 바깥 글이 들어가지 않게 */
|
||||
const FALLBACK_FILE_NAME = 'ARA-SYSTEM.apk';
|
||||
|
||||
/** 머리글 하나 (여럿이면 첫 값) */
|
||||
function firstHeader(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function reasonOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기") — 직원은 최신 판 정보 · 파일을 받고(로그인 필요), 운영 도구가 판을 올린다.
|
||||
* "최신" = 그 플랫폼에서 가장 늦게 올린 판(올린 시각 — 버전 번호가 아니다). 옛 APK 를 다시 올리면 그것이 최신이 된다(되돌리기 길).
|
||||
*
|
||||
* 파일 응답은 대용량 첨부 받기(file-sharing.service.ts 의 serveFile)와 같은 꼴이다 — 머리글은 shared/utils/download-headers.ts, 범위는 http-range.ts.
|
||||
* 로그: 내려받기 성공은 남기지 않는다. 표에는 있는데 파일이 없거나 크기가 다르면 error(운영자가 다시 올린다)
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppReleasesService implements BeforeApplicationShutdown {
|
||||
private readonly logger = new Logger(AppReleasesService.name);
|
||||
/** 지금 흘려보내는 파일 응답 — 서버 종료 때 끊는다 */
|
||||
private readonly streaming = new Set<Response>();
|
||||
private shuttingDown = false;
|
||||
|
||||
constructor(
|
||||
private readonly repository: AppReleasesRepository,
|
||||
private readonly storage: AppReleaseStorage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 서버 종료(HTTP 서버를 닫기 전) — 흘려보내는 파일 응답을 끊는다. 느린 내려받기가 종료 유예(30초)를 다 쓰지 않게 —
|
||||
* 브라우저는 Range 로 이어 받는다(대용량 첨부 받기와 같은 결). 그 뒤에 온 받기는 시작하지 않는다
|
||||
*/
|
||||
beforeApplicationShutdown(): void {
|
||||
this.shuttingDown = true;
|
||||
for (const res of this.streaming) res.destroy();
|
||||
this.streaming.clear();
|
||||
}
|
||||
|
||||
// ── 직원 (로그인 필요 — app-releases.controller.ts) ────────────────────────────
|
||||
|
||||
/** 최신 판 정보 — 올린 판이 없으면 404 RES_001 */
|
||||
async latestInfo(platform: AppReleasePlatform): Promise<AppReleaseInfo> {
|
||||
const release = await this.requireLatest(platform);
|
||||
return {
|
||||
version: release.version,
|
||||
sizeBytes: release.sizeBytes,
|
||||
releasedAt: release.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 최신 판 파일을 내려준다 (GET · HEAD). 실패는 머리글을 쓰기 **전에** 던진다(전역 필터가 표준 실패 형식으로):
|
||||
* 올린 판이 없음 · 파일이 없거나 표의 크기와 다름 404 RES_001.
|
||||
* 범위: 없으면 200 전체, 하나면 206, 처음이 크기 이상이면 416(본문 없음 — 래핑 예외). HEAD 는 머리글만
|
||||
*/
|
||||
async serveLatestFile(
|
||||
req: Request,
|
||||
res: Response,
|
||||
platform: AppReleasePlatform,
|
||||
): Promise<void> {
|
||||
const release = await this.requireLatest(platform);
|
||||
const file = await this.openFileOf(release);
|
||||
let handedOver = false;
|
||||
try {
|
||||
const size = release.sizeBytes;
|
||||
const etag = `"${release.id}"`;
|
||||
const decision = decideRange(
|
||||
firstHeader(req.headers.range),
|
||||
firstHeader(req.headers['if-range']),
|
||||
size,
|
||||
{ etag, lastModified: release.createdAt },
|
||||
);
|
||||
this.writeFileHeaders(res, release, etag);
|
||||
if (decision.kind === 'unsatisfiable') {
|
||||
res.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||
res.setHeader('Content-Range', `bytes */${size}`);
|
||||
res.setHeader('Content-Length', '0');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const range =
|
||||
decision.kind === 'partial'
|
||||
? { start: decision.start, end: decision.end }
|
||||
: undefined;
|
||||
res.status(range ? HttpStatus.PARTIAL_CONTENT : HttpStatus.OK);
|
||||
res.setHeader(
|
||||
'Content-Length',
|
||||
String(range ? range.end - range.start + 1 : size),
|
||||
);
|
||||
if (range) {
|
||||
res.setHeader(
|
||||
'Content-Range',
|
||||
`bytes ${range.start}-${range.end}/${size}`,
|
||||
);
|
||||
}
|
||||
if (req.method === 'HEAD') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (this.shuttingDown) {
|
||||
// 종료 중 — 시작하지 않는다. 연결을 끊으면 받는 쪽이 다시 시도한다
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
// 흐름이 끝나거나 망가지면 파일도 닫는다 (autoClose 기본값)
|
||||
const stream = file.handle.createReadStream(
|
||||
range ? { start: range.start, end: range.end } : {},
|
||||
);
|
||||
handedOver = true;
|
||||
await this.stream(stream, res, release);
|
||||
} finally {
|
||||
if (!handedOver) await file.handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 운영 도구 (modules/ops/app-release-commands.ts) ─────────────────────────────
|
||||
|
||||
/** 운영자가 넣은 경로의 APK 를 검사한다 — 통과하면 연 파일째(부르는 쪽이 닫는다) */
|
||||
inspect(path: string): Promise<ApkInspection> {
|
||||
return inspectApk(path);
|
||||
}
|
||||
|
||||
/** 그 플랫폼의 최신 판 — 없으면 null */
|
||||
latest(platform: AppReleasePlatform): Promise<AppReleaseEntity | null> {
|
||||
return this.repository.latest(platform);
|
||||
}
|
||||
|
||||
/** 늦게 올린 것부터 `limit` 개 — 디스크에 파일이 있는지 함께 */
|
||||
async listRecent(
|
||||
platform: AppReleasePlatform,
|
||||
limit: number,
|
||||
): Promise<AppReleaseListing[]> {
|
||||
const releases = await this.repository.recent(platform, limit);
|
||||
return Promise.all(
|
||||
releases.map(async (release) => ({
|
||||
release,
|
||||
hasFile: await this.storage.exists(release.fileKey),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 판을 올린다 — 검사한 APK 를 볼륨에 들이고(임시 이름 → 디스크에 내려쓰기 → 이름 바꾸기) 표에 한 줄 넣는다. 넣은 순간 최신이 된다.
|
||||
* 들이는 동안 원본이 바뀌었으면 AppReleaseFileChangedError(파일도 표도 그대로). 표에 넣지 못하면 들인 파일을 지우고 던진다 —
|
||||
* 반쯤 올린 판을 남기지 않는다. 시작할 때 멈춘 올리기가 남긴 오래된 임시 파일을 지운다
|
||||
*/
|
||||
async publish(
|
||||
apk: InspectedApk,
|
||||
input: AppReleasePublish,
|
||||
now: Date = new Date(),
|
||||
): Promise<AppReleaseEntity> {
|
||||
if (!isAppVersion(input.version)) {
|
||||
// 부르는 쪽(운영 도구)이 먼저 본다 — 여기까지 오면 버그
|
||||
throw new Error('휴대폰 앱 버전 형식이 올바르지 않습니다.');
|
||||
}
|
||||
await this.removeStaleParts(now);
|
||||
const release = Object.assign(new AppReleaseEntity(), {
|
||||
id: randomUUID(),
|
||||
platform: input.platform,
|
||||
version: input.version,
|
||||
sizeBytes: apk.sizeBytes,
|
||||
sha256: apk.sha256,
|
||||
fileKey: randomUUID(),
|
||||
uploadedBy: input.uploadedBy,
|
||||
createdAt: now,
|
||||
} satisfies AppReleaseEntity);
|
||||
await this.storage.importFile(apk.handle, release.fileKey, {
|
||||
sizeBytes: apk.sizeBytes,
|
||||
sha256: apk.sha256,
|
||||
});
|
||||
try {
|
||||
await this.repository.insert(release);
|
||||
} catch (error) {
|
||||
// 표에 없는 파일은 누구도 받지 않는다 — 지워 둔다
|
||||
await this.storage
|
||||
.remove(release.fileKey)
|
||||
.catch((removeError: unknown) =>
|
||||
this.logger.error(
|
||||
`[SYS_001] 표에 넣지 못한 휴대폰 앱 파일을 지우지도 못했습니다 — 볼륨에 남습니다: file=${release.fileKey}, reason=${reasonOf(removeError)}`,
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
this.logger.log(
|
||||
`휴대폰 앱을 올렸습니다: platform=${release.platform}, version=${release.version}, release=${release.id}, size=${release.sizeBytes}`,
|
||||
);
|
||||
return release;
|
||||
}
|
||||
|
||||
// ── 내부 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private async requireLatest(
|
||||
platform: AppReleasePlatform,
|
||||
): Promise<AppReleaseEntity> {
|
||||
const release = await this.repository.latest(platform);
|
||||
if (release === null) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'app-release-none', platform },
|
||||
});
|
||||
}
|
||||
return release;
|
||||
}
|
||||
|
||||
/** 그 판의 파일 — 없거나 표의 크기와 다르면 error 로그 + 404 RES_001 (직원에게는 올린 판이 없는 것과 같다) */
|
||||
private async openFileOf(
|
||||
release: AppReleaseEntity,
|
||||
): Promise<OpenedAppReleaseFile> {
|
||||
const file = await this.storage.open(release.fileKey);
|
||||
if (file !== null && file.sizeBytes === release.sizeBytes) return file;
|
||||
if (file !== null) await file.handle.close();
|
||||
const missing = file === null;
|
||||
this.logger.error(
|
||||
`[RES_001]${traceTag()} 휴대폰 앱 파일을 내려줄 수 없습니다(${missing ? '파일 없음' : '크기가 표와 다름'}) — 운영 도구로 다시 올려 주세요: release=${release.id}, platform=${release.platform}, version=${release.version}`,
|
||||
);
|
||||
throw new BusinessException('RES_001', {
|
||||
context: {
|
||||
reason: missing
|
||||
? 'app-release-file-missing'
|
||||
: 'app-release-file-size-mismatch',
|
||||
release: release.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 파일 응답의 머리글 — 대용량 첨부 받기와 같은 꼴이고 형식만 APK 다 (shared/utils/download-headers.ts) */
|
||||
private writeFileHeaders(
|
||||
res: Response,
|
||||
release: AppReleaseEntity,
|
||||
etag: string,
|
||||
): void {
|
||||
const fileName = isAppVersion(release.version)
|
||||
? appReleaseFileName(release.version)
|
||||
: FALLBACK_FILE_NAME;
|
||||
for (const [header, value] of downloadHeaders({
|
||||
contentType: APK_CONTENT_TYPE,
|
||||
// 이름은 검사한 버전으로 만든 ASCII 뿐이다 — RFC 5987 이름(filename*)이 필요 없다
|
||||
contentDisposition: `attachment; filename="${fileName}"`,
|
||||
etag,
|
||||
lastModified: release.createdAt,
|
||||
})) {
|
||||
res.setHeader(header, value);
|
||||
}
|
||||
}
|
||||
|
||||
private async stream(
|
||||
stream: Readable,
|
||||
res: Response,
|
||||
release: AppReleaseEntity,
|
||||
): Promise<void> {
|
||||
// 흐름 오류는 처리기가 끝난 뒤에 올 수 있다 — 추적 ID 를 지금 잡아 둔다
|
||||
const traceId = currentRequestId() ?? 'unknown';
|
||||
this.streaming.add(res);
|
||||
try {
|
||||
// 파일을 여는 사이 종료가 시작됐으면 흘려보내지 않는다 (종료 때 끊을 목록에 들기 전이었다)
|
||||
if (this.shuttingDown) res.destroy();
|
||||
await pipeline(stream, res);
|
||||
} catch (error) {
|
||||
// 받는 쪽이 끊었거나 종료로 끊었다 — 장애가 아니다. 디스크 오류 등만 남긴다
|
||||
if (!isStreamAbortError(error)) {
|
||||
this.logger.error(
|
||||
`[SYS_001][${traceId}] 휴대폰 앱 전송이 멈췄습니다: release=${release.id}, reason=${reasonOf(error)}`,
|
||||
);
|
||||
}
|
||||
if (!res.destroyed) res.destroy();
|
||||
} finally {
|
||||
this.streaming.delete(res);
|
||||
}
|
||||
}
|
||||
|
||||
/** 멈춘 올리기가 남긴 오래된 임시 파일 — 정리하지 못해도 올리기는 이어 한다 */
|
||||
private async removeStaleParts(now: Date): Promise<void> {
|
||||
try {
|
||||
const removed = await this.storage.removeStaleParts(
|
||||
new Date(now.getTime() - APP_RELEASE_STALE_PART_MS),
|
||||
);
|
||||
if (removed > 0) {
|
||||
this.logger.warn(
|
||||
`멈춘 휴대폰 앱 올리기가 남긴 임시 파일 ${removed}개를 지웠습니다.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`멈춘 휴대폰 앱 올리기의 임시 파일을 정리하지 못했습니다 — 올리기는 이어 합니다: reason=${reasonOf(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AppReleaseEntity } from './app-release.entity';
|
||||
import type { AppReleasePlatform } from './app-releases.constants';
|
||||
|
||||
/** 최신 판 정보 — `GET /api/app-releases/android/latest` 의 data (계약 1절 "휴대폰 앱 받기") */
|
||||
export interface AppReleaseInfo {
|
||||
readonly version: string;
|
||||
readonly sizeBytes: number;
|
||||
/** 올린 때 — ISO 8601 UTC */
|
||||
readonly releasedAt: string;
|
||||
}
|
||||
|
||||
/** 운영 도구가 올리는 판 */
|
||||
export interface AppReleasePublish {
|
||||
readonly platform: AppReleasePlatform;
|
||||
readonly version: string;
|
||||
/** 운영자 이름 — 운영 도구를 시작할 때 넣은 이름 */
|
||||
readonly uploadedBy: string;
|
||||
}
|
||||
|
||||
/** 운영 도구 「휴대폰 앱 목록」의 한 줄 — 디스크에 파일이 있는지 함께 */
|
||||
export interface AppReleaseListing {
|
||||
readonly release: AppReleaseEntity;
|
||||
readonly hasFile: boolean;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import type { AppReleaseInfo } from '../app-releases.types';
|
||||
|
||||
/** GET /api/app-releases/android/latest 의 응답 (계약 1절 "휴대폰 앱 받기") */
|
||||
export class AppReleaseDto implements AppReleaseInfo {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'버전 — 앱의 versionName(`숫자.숫자.숫자`, 칸마다 1~4자리 · 앞자리 0 없이). 내려받는 파일 이름은 `ARA-SYSTEM-<버전>.apk`',
|
||||
example: '1.0.0',
|
||||
})
|
||||
version!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'APK 파일 크기(바이트)',
|
||||
example: 32_505_856,
|
||||
})
|
||||
sizeBytes!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'올린 때 (ISO 8601 UTC) — 화면의 "올린 날". 최신 = 가장 늦게 올린 판(버전 번호가 아니다)',
|
||||
example: '2026-11-18T01:23:45.000Z',
|
||||
})
|
||||
releasedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { AppReleaseStorage } from '@/modules/app-releases/app-release-storage';
|
||||
import type { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
|
||||
import { APK_ZIP_SIGNATURE } from '@/modules/app-releases/app-releases.constants';
|
||||
import type { AppReleasesRepository } from '@/modules/app-releases/app-releases.repository';
|
||||
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
|
||||
import {
|
||||
APP_VERSION_RULE_TEXT,
|
||||
AppReleaseCommands,
|
||||
apkProblemText,
|
||||
} from './app-release-commands';
|
||||
import type { OpsAuditEntry, OpsAuditService } from './ops-audit.service';
|
||||
import { OpsInputClosed, type OpsIo } from './ops-io';
|
||||
|
||||
const OPERATOR = '운영자 김';
|
||||
|
||||
/** 답 — 글이면 그대로, 함수면 부른 값(묻는 사이에 무언가를 바꿔 본다) */
|
||||
type Answer = string | (() => string);
|
||||
|
||||
/** 줄 입력 흉내 — 답을 차례로 주고, 보인 줄을 모은다. 답이 떨어지면 입력이 끝난 것으로 */
|
||||
class ScriptedIo implements OpsIo {
|
||||
readonly interactive = true;
|
||||
readonly printed: string[] = [];
|
||||
readonly asked: string[] = [];
|
||||
|
||||
constructor(private readonly answers: Answer[]) {}
|
||||
|
||||
print(line = ''): void {
|
||||
this.printed.push(line);
|
||||
}
|
||||
|
||||
ask(question: string): Promise<string> {
|
||||
this.asked.push(question);
|
||||
return this.next().then((answer) => answer.trim());
|
||||
}
|
||||
|
||||
askSecret(question: string): Promise<string> {
|
||||
this.asked.push(question);
|
||||
return this.next();
|
||||
}
|
||||
|
||||
async confirm(question: string): Promise<boolean> {
|
||||
this.asked.push(question);
|
||||
return ['y', 'yes', '예'].includes((await this.next()).toLowerCase());
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
|
||||
get output(): string {
|
||||
return this.printed.join('\n');
|
||||
}
|
||||
|
||||
private next(): Promise<string> {
|
||||
const answer = this.answers.shift();
|
||||
if (answer === undefined) return Promise.reject(new OpsInputClosed());
|
||||
return Promise.resolve(typeof answer === 'function' ? answer() : answer);
|
||||
}
|
||||
}
|
||||
|
||||
/** 표 대역 — 늦게 올린 것부터 */
|
||||
class FakeRepository {
|
||||
readonly rows: AppReleaseEntity[] = [];
|
||||
insertFailure: Error | null = null;
|
||||
|
||||
private newestFirst(): AppReleaseEntity[] {
|
||||
return [...this.rows].sort(
|
||||
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
latest() {
|
||||
return Promise.resolve(this.newestFirst()[0] ?? null);
|
||||
}
|
||||
|
||||
recent(_platform: string, limit: number) {
|
||||
return Promise.resolve(this.newestFirst().slice(0, limit));
|
||||
}
|
||||
|
||||
insert(release: AppReleaseEntity) {
|
||||
if (this.insertFailure) return Promise.reject(this.insertFailure);
|
||||
this.rows.push(release);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const sha256 = (data: Buffer) =>
|
||||
createHash('sha256').update(data).digest('hex');
|
||||
|
||||
describe('운영 도구 — 휴대폰 앱 올리기 · 목록 (사내 배포)', () => {
|
||||
let root: string;
|
||||
/** 운영자가 docker cp 로 넣은 자리 (컨테이너의 /tmp 흉내) */
|
||||
let incoming: string;
|
||||
/** 업로드 볼륨의 app-releases/ 흉내 */
|
||||
let releaseDir: string;
|
||||
let repository: FakeRepository;
|
||||
let service: AppReleasesService;
|
||||
let audit: { entries: OpsAuditEntry[]; record: jest.Mock };
|
||||
let commands: AppReleaseCommands;
|
||||
let errorLogs: string[];
|
||||
|
||||
const apkBytes = () =>
|
||||
Buffer.concat([APK_ZIP_SIGNATURE, randomBytes(8 * 1024)]);
|
||||
|
||||
/** 운영자가 넣은 APK — 경로를 준다 */
|
||||
const dropApk = (name: string, data: Buffer = apkBytes()) => {
|
||||
const path = join(incoming, name);
|
||||
writeFileSync(path, data);
|
||||
return path;
|
||||
};
|
||||
|
||||
/** 볼륨에 남은 것 (없으면 빈 목록) */
|
||||
const stored = () => (existsSync(releaseDir) ? readdirSync(releaseDir) : []);
|
||||
|
||||
/** 이미 올린 판 하나 (표 · 볼륨) */
|
||||
const existing = async (version: string, at: Date) => {
|
||||
const inspection = await service.inspect(dropApk(`old-${version}.apk`));
|
||||
if (!inspection.ok) throw new Error(inspection.problem);
|
||||
try {
|
||||
return await service.publish(
|
||||
inspection.apk,
|
||||
{ platform: 'android', version, uploadedBy: '먼저 올린 사람' },
|
||||
at,
|
||||
);
|
||||
} finally {
|
||||
await inspection.apk.close();
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'ops-app-release-'));
|
||||
incoming = join(root, 'tmp');
|
||||
mkdirSync(incoming);
|
||||
releaseDir = join(root, 'uploads', 'app-releases');
|
||||
repository = new FakeRepository();
|
||||
service = new AppReleasesService(
|
||||
repository as unknown as AppReleasesRepository,
|
||||
new AppReleaseStorage(releaseDir),
|
||||
);
|
||||
const entries: OpsAuditEntry[] = [];
|
||||
audit = {
|
||||
entries,
|
||||
record: jest.fn((entry: OpsAuditEntry) => {
|
||||
entries.push(entry);
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
};
|
||||
commands = new AppReleaseCommands(
|
||||
service,
|
||||
audit as unknown as OpsAuditService,
|
||||
);
|
||||
errorLogs = [];
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(
|
||||
(...args: unknown[]) => void errorLogs.push(args.map(String).join(' ')),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('올리기', () => {
|
||||
it('경로 → 버전(파일 이름의 버전이 기본값) → 바뀔 것(버전 · 크기 · SHA-256) y → 볼륨 · 표 · 실행 기록, 끝에 SHA-256 을 한 번 더', async () => {
|
||||
const data = apkBytes();
|
||||
const path = dropApk('ARA-SYSTEM-1.0.0.apk', data);
|
||||
const io = new ScriptedIo([path, '', 'y']);
|
||||
await commands.upload(io, OPERATOR);
|
||||
|
||||
expect(io.asked).toContain('버전 (빈 값이면 파일 이름의 1.0.0): ');
|
||||
expect(io.output).toContain(' 휴대폰 앱 올리기 — 바뀔 것');
|
||||
expect(io.output).toContain(` 파일 ${path}`);
|
||||
expect(io.output).toContain(' 버전 1.0.0 (처음 올리는 앱)');
|
||||
expect(io.output).toContain(
|
||||
`(${data.length.toLocaleString('en-US')}바이트)`,
|
||||
);
|
||||
expect(io.output).toContain(` SHA-256 ${sha256(data)}`);
|
||||
expect(io.output).toContain(' 받는 이름 ARA-SYSTEM-1.0.0.apk');
|
||||
expect(io.output).toContain(
|
||||
' [완료] 올렸습니다 — 버전 1.0.0 · 8 KB. 직원은 지금부터 이 버전을 받습니다.',
|
||||
);
|
||||
// 끝에 한 번 더 — 빌드한 PC 의 값과 견준다
|
||||
expect(io.printed).toContain(` SHA-256 ${sha256(data)}`);
|
||||
expect(io.output).toContain(
|
||||
'certutil -hashfile ARA-SYSTEM-1.0.0.apk SHA256',
|
||||
);
|
||||
|
||||
const [release] = repository.rows;
|
||||
expect(release).toMatchObject({
|
||||
platform: 'android',
|
||||
version: '1.0.0',
|
||||
sizeBytes: data.length,
|
||||
sha256: sha256(data),
|
||||
uploadedBy: OPERATOR,
|
||||
});
|
||||
expect(stored()).toEqual([`${release.fileKey}.apk`]);
|
||||
expect(audit.entries).toEqual([
|
||||
{
|
||||
operator: OPERATOR,
|
||||
command: 'app-release-upload',
|
||||
target: 'android 1.0.0',
|
||||
result: 'success',
|
||||
detail: `id=${release.id} · ${data.length}바이트 · sha256=${sha256(data)} · 파일 ${path}`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('검사한 연 파일은 성공 · 실패 모두 닫는다', async () => {
|
||||
const closes: string[] = [];
|
||||
const inspect = service.inspect.bind(service);
|
||||
jest.spyOn(service, 'inspect').mockImplementation(async (path) => {
|
||||
const inspection = await inspect(path);
|
||||
if (!inspection.ok) return inspection;
|
||||
const { apk } = inspection;
|
||||
return {
|
||||
ok: true,
|
||||
apk: {
|
||||
...apk,
|
||||
close: () => {
|
||||
closes.push(apk.fileName);
|
||||
return apk.close();
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
await commands.upload(
|
||||
new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'n']),
|
||||
OPERATOR,
|
||||
);
|
||||
await commands.upload(
|
||||
new ScriptedIo([dropApk('ARA-SYSTEM-1.0.1.apk'), 'x']),
|
||||
OPERATOR,
|
||||
);
|
||||
expect(closes).toEqual(['ARA-SYSTEM-1.0.0.apk', 'ARA-SYSTEM-1.0.1.apk']);
|
||||
});
|
||||
|
||||
it('바뀔 것에서 N 이면 아무것도 바꾸지 않는다 (실행 기록도 없다)', async () => {
|
||||
const io = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'n']);
|
||||
await commands.upload(io, OPERATOR);
|
||||
expect(io.output).toContain('하지 않았습니다.');
|
||||
expect(repository.rows).toEqual([]);
|
||||
expect(stored()).toEqual([]);
|
||||
expect(audit.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('버전 — 파일 이름에 없으면 예시로 묻고, 규칙에 맞지 않으면 알리고 멈춘다', async () => {
|
||||
const noVersion = new ScriptedIo([dropApk('app-release.apk'), '']);
|
||||
await commands.upload(noVersion, OPERATOR);
|
||||
expect(noVersion.asked).toContain('버전 (예: 1.0.0): ');
|
||||
expect(noVersion.output).toContain(APP_VERSION_RULE_TEXT);
|
||||
|
||||
for (const version of ['1.0', 'v1.0.0', '01.0.0', '1.0.0-beta']) {
|
||||
const io = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), version]);
|
||||
await commands.upload(io, OPERATOR);
|
||||
expect(io.output).toContain(APP_VERSION_RULE_TEXT);
|
||||
}
|
||||
expect(repository.rows).toEqual([]);
|
||||
expect(audit.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('넣은 버전이 기본값과 다르면 넣은 것 — 파일 이름보다 운영자의 답이 먼저다', async () => {
|
||||
const io = new ScriptedIo([
|
||||
dropApk('ARA-SYSTEM-1.0.0.apk'),
|
||||
'1.0.2',
|
||||
'y',
|
||||
]);
|
||||
await commands.upload(io, OPERATOR);
|
||||
expect(repository.rows[0]?.version).toBe('1.0.2');
|
||||
expect(io.output).toContain(' 받는 이름 ARA-SYSTEM-1.0.2.apk');
|
||||
});
|
||||
|
||||
it('서버가 받는 가장 낮은 버전(app-config)보다 낮으면 올리지 않는다', async () => {
|
||||
const io = new ScriptedIo([dropApk('ARA-SYSTEM-0.9.0.apk'), '']);
|
||||
await commands.upload(io, OPERATOR);
|
||||
expect(io.output).toContain(
|
||||
'서버가 받는 가장 낮은 버전은 1.0.0 입니다 — 그보다 낮은 버전은 올리지 않습니다',
|
||||
);
|
||||
expect(repository.rows).toEqual([]);
|
||||
expect(io.asked).not.toContain('그래도 올릴까요?');
|
||||
});
|
||||
|
||||
it('지금 최신보다 낮으면 알리고 묻는다 — N 이면 그대로, y 면 바뀔 것을 거쳐 올린다(되돌리기 길)', async () => {
|
||||
// 이미 올린 판은 지난 날 — 지금 올리는 판(지금 시각)이 더 늦게 올린 것이 된다
|
||||
await existing('1.2.0', new Date('2026-11-01T01:00:00.000Z'));
|
||||
|
||||
const declined = new ScriptedIo([
|
||||
dropApk('ARA-SYSTEM-1.1.0.apk'),
|
||||
'',
|
||||
'n',
|
||||
]);
|
||||
await commands.upload(declined, OPERATOR);
|
||||
expect(declined.output).toContain(
|
||||
'주의: 넣은 버전(1.1.0)이 지금 직원이 받는 버전(1.2.0 · 2026년 11월 1일 (일) 오전 10:00 올림)보다 낮습니다.',
|
||||
);
|
||||
expect(declined.output).toContain('앱을 지우고 다시 깔아야 합니다');
|
||||
expect(declined.output).toContain('하지 않았습니다.');
|
||||
expect(repository.rows).toHaveLength(1);
|
||||
|
||||
const accepted = new ScriptedIo([
|
||||
dropApk('ARA-SYSTEM-1.1.0.apk'),
|
||||
'',
|
||||
'y',
|
||||
'y',
|
||||
]);
|
||||
await commands.upload(accepted, OPERATOR);
|
||||
expect(accepted.output).toContain(
|
||||
' 버전 1.1.0 (지금 직원이 받는 버전 1.2.0)',
|
||||
);
|
||||
expect(repository.rows).toHaveLength(2);
|
||||
await expect(service.latestInfo('android')).resolves.toMatchObject({
|
||||
version: '1.1.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('지금 최신과 같으면 같다고 알리고 묻는다 · 높으면 묻지 않는다', async () => {
|
||||
await existing('1.0.0', new Date('2026-11-01T01:00:00.000Z'));
|
||||
|
||||
const same = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'n']);
|
||||
await commands.upload(same, OPERATOR);
|
||||
expect(same.output).toContain('과 같습니다.');
|
||||
|
||||
const higher = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.1.apk'), '', 'y']);
|
||||
await commands.upload(higher, OPERATOR);
|
||||
expect(higher.asked).not.toContain('그래도 올릴까요?');
|
||||
expect(repository.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('파일 검사에 걸리면 까닭을 알리고 버전을 묻지 않는다', async () => {
|
||||
const cases: [string, string][] = [
|
||||
[join(incoming, 'none.apk'), apkProblemText('missing')],
|
||||
[dropApk('page.apk', Buffer.alloc(4096)), apkProblemText('not-zip')],
|
||||
[dropApk('tiny.apk', APK_ZIP_SIGNATURE), apkProblemText('too-small')],
|
||||
[dropApk('ARA-SYSTEM-1.0.0.zip'), apkProblemText('not-apk-name')],
|
||||
];
|
||||
for (const [path, text] of cases) {
|
||||
const io = new ScriptedIo([path]);
|
||||
await commands.upload(io, OPERATOR);
|
||||
expect(io.output).toContain(text);
|
||||
expect(io.asked).toHaveLength(1);
|
||||
}
|
||||
const empty = new ScriptedIo(['']);
|
||||
await commands.upload(empty, OPERATOR);
|
||||
expect(empty.output).toContain('경로를 넣어 주세요.');
|
||||
expect(audit.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('검사한 뒤 원본이 바뀌면(묻는 사이 다시 복사) 올리지 않고 반쯤 쓴 파일도 남기지 않는다 — 실패를 실행 기록에', async () => {
|
||||
const path = dropApk('ARA-SYSTEM-1.0.0.apk');
|
||||
const io = new ScriptedIo([
|
||||
path,
|
||||
'',
|
||||
() => {
|
||||
// y/N 을 묻는 사이 같은 자리에 다른 내용을 쓴다
|
||||
writeFileSync(path, apkBytes());
|
||||
return 'y';
|
||||
},
|
||||
]);
|
||||
await commands.upload(io, OPERATOR);
|
||||
expect(io.output).toContain(
|
||||
' [못 함] 파일이 검사한 뒤 바뀌었습니다(SHA-256 · 크기가 다릅니다) — 올리지 않았습니다.',
|
||||
);
|
||||
expect(repository.rows).toEqual([]);
|
||||
expect(stored()).toEqual([]);
|
||||
expect(audit.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
command: 'app-release-upload',
|
||||
target: 'android 1.0.0',
|
||||
result: 'failure',
|
||||
detail: expect.stringMatching(
|
||||
/^AppReleaseFileChangedError · \d+바이트 · sha256=[0-9a-f]{64} · 파일 /,
|
||||
) as unknown,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('표에 넣지 못하면 볼륨에 아무것도 남기지 않고 실패를 알린다(모르는 오류는 오류 로그에)', async () => {
|
||||
repository.insertFailure = new TypeError('db down');
|
||||
const io = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'y']);
|
||||
await commands.upload(io, OPERATOR);
|
||||
expect(io.output).toContain(
|
||||
' [못 함] 처리하지 못했습니다 — 바로 위의 오류 로그(ERROR 줄)를 확인하세요.',
|
||||
);
|
||||
expect(stored()).toEqual([]);
|
||||
expect(errorLogs.join('\n')).toContain('[SYS_001]');
|
||||
expect(audit.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
result: 'failure',
|
||||
detail: expect.stringContaining('TypeError · ') as unknown,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('목록', () => {
|
||||
it('올린 것이 없으면 알린다', async () => {
|
||||
const io = new ScriptedIo([]);
|
||||
await commands.list(io);
|
||||
expect(io.output).toContain('올린 휴대폰 앱이 없습니다.');
|
||||
});
|
||||
|
||||
it('최근 판 — 버전 · 크기 · 올린 때 · 올린 이 · SHA-256 앞부분, 맨 위(▶)가 직원이 받는 판 · 파일이 없으면 알린다 (기록하지 않는다)', async () => {
|
||||
const first = await existing(
|
||||
'1.0.0',
|
||||
new Date('2026-10-20T06:20:00.000Z'),
|
||||
);
|
||||
const second = await existing(
|
||||
'1.0.1',
|
||||
new Date('2026-11-02T01:23:00.000Z'),
|
||||
);
|
||||
rmSync(join(releaseDir, `${first.fileKey}.apk`));
|
||||
|
||||
const io = new ScriptedIo([]);
|
||||
await commands.list(io);
|
||||
expect(io.output).toContain(
|
||||
' 휴대폰 앱 (안드로이드) — 최근 2개, ▶ 가 직원이 받는 버전(가장 늦게 올린 것)',
|
||||
);
|
||||
const lines = io.printed.filter((line) => line.includes('SHA-256'));
|
||||
// 시각은 한국 시각으로 보인다 (시험을 도는 기계의 시간대와 상관없다)
|
||||
expect(lines).toEqual([
|
||||
`▶ 1.0.1 · 8 KB · 2026년 11월 2일 (월) 오전 10:23 · 먼저 올린 사람 · SHA-256 ${second.sha256.slice(0, 12)}…`,
|
||||
` 1.0.0 · 8 KB · 2026년 10월 20일 (화) 오후 3:20 · 먼저 올린 사람 · SHA-256 ${first.sha256.slice(0, 12)}… · 파일 없음 — 다시 올리세요`,
|
||||
]);
|
||||
expect(audit.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('읽지 못하면 알린다(모르는 오류는 오류 로그에)', async () => {
|
||||
jest
|
||||
.spyOn(service, 'listRecent')
|
||||
.mockRejectedValueOnce(new TypeError('db down'));
|
||||
const io = new ScriptedIo([]);
|
||||
await commands.list(io);
|
||||
expect(io.output).toContain('오류 로그');
|
||||
expect(errorLogs.join('\n')).toContain('[SYS_001]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { MIN_SUPPORTED_APP_VERSION } from '@/modules/app-config/app-config.constants';
|
||||
import type {
|
||||
ApkInspection,
|
||||
ApkProblem,
|
||||
InspectedApk,
|
||||
} from '@/modules/app-releases/apk-file';
|
||||
import { AppReleaseFileChangedError } from '@/modules/app-releases/app-release-storage';
|
||||
import {
|
||||
appReleaseFileName,
|
||||
compareAppVersions,
|
||||
isAppVersion,
|
||||
versionInFileName,
|
||||
} from '@/modules/app-releases/app-release-version';
|
||||
import type { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
|
||||
import {
|
||||
APK_MAX_BYTES,
|
||||
APK_MIN_BYTES,
|
||||
APP_RELEASE_LIST_LIMIT,
|
||||
type AppReleasePlatform,
|
||||
} from '@/modules/app-releases/app-releases.constants';
|
||||
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
|
||||
import { OpsAuditService } from './ops-audit.service';
|
||||
import type { OpsIo } from './ops-io';
|
||||
import { RULE, confirmPlan, failureLine, step } from './ops-prompts';
|
||||
import { bytesText, failureDetail, timeText } from './ops-text';
|
||||
|
||||
/** 올리는 플랫폼 — 지금은 안드로이드만 */
|
||||
const PLATFORM: AppReleasePlatform = 'android';
|
||||
|
||||
/** 목록 줄의 SHA-256 앞부분 길이 */
|
||||
const SHA256_PREFIX_LENGTH = 12;
|
||||
|
||||
/** 천 단위 쉼표 — 기계 설정과 상관없이 같은 글 */
|
||||
const COUNT_FORMAT = new Intl.NumberFormat('en-US');
|
||||
|
||||
/** 버전 규칙 안내 */
|
||||
export const APP_VERSION_RULE_TEXT =
|
||||
'버전은 1.0.0 처럼 숫자 셋을 점으로 이어 넣어 주세요(칸마다 1~4자리 · 앞자리 0 없이).';
|
||||
|
||||
/** 검사에 걸린 까닭 → 운영자에게 보일 글 (무엇이 왜 안 되는지와 다음에 할 일) */
|
||||
export function apkProblemText(problem: ApkProblem): string {
|
||||
switch (problem) {
|
||||
case 'not-apk-name':
|
||||
return '파일 이름이 .apk 로 끝나지 않습니다.';
|
||||
case 'missing':
|
||||
return '그 경로에 파일이 없습니다 — sudo docker cp 로 이 컨테이너 안에 넣었는지, 경로가 맞는지 확인하세요.';
|
||||
case 'symlink':
|
||||
return '바로가기(심볼릭 링크)는 따라가지 않습니다 — 파일 자체를 docker cp 로 넣어 주세요.';
|
||||
case 'not-file':
|
||||
return '일반 파일이 아닙니다(폴더 · 장치 등).';
|
||||
case 'unreadable':
|
||||
return '파일을 읽을 권한이 없습니다 — 서버에서 chmod 644 로 바꾼 뒤 다시 docker cp 하세요.';
|
||||
case 'too-small':
|
||||
return `파일이 너무 작습니다(${bytesText(APK_MIN_BYTES)} 미만) — APK 가 아닙니다.`;
|
||||
case 'too-large':
|
||||
return `파일이 너무 큽니다(${bytesText(APK_MAX_BYTES)} 초과) — 올리지 않습니다.`;
|
||||
case 'not-zip':
|
||||
return 'APK 가 아닙니다 — 파일 머리가 zip(PK) 이 아닙니다.';
|
||||
case 'changed':
|
||||
return '읽는 동안 파일 크기가 바뀌었습니다 — 복사가 끝난 뒤 다시 하세요.';
|
||||
}
|
||||
}
|
||||
|
||||
/** 바이트 수 — "32,505,856바이트" */
|
||||
function byteCount(bytes: number): string {
|
||||
return `${COUNT_FORMAT.format(bytes)}바이트`;
|
||||
}
|
||||
|
||||
/** 실행 기록의 덧붙이는 말 — 판 id · 크기 · SHA-256 을 앞에(길면 넣은 경로 쪽이 잘린다). 비밀은 없다 */
|
||||
function uploadDetail(
|
||||
apk: InspectedApk,
|
||||
release: AppReleaseEntity | null,
|
||||
failure: string | null,
|
||||
): string {
|
||||
return [
|
||||
...(failure === null ? [] : [failure]),
|
||||
...(release === null ? [] : [`id=${release.id}`]),
|
||||
`${apk.sizeBytes}바이트`,
|
||||
`sha256=${apk.sha256}`,
|
||||
`파일 ${apk.path}`,
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
/** 목록의 한 줄 */
|
||||
export function releaseLine(
|
||||
release: AppReleaseEntity,
|
||||
hasFile: boolean,
|
||||
isCurrent: boolean,
|
||||
): string {
|
||||
return [
|
||||
`${isCurrent ? '▶' : ' '} ${release.version}`,
|
||||
bytesText(release.sizeBytes),
|
||||
timeText(release.createdAt),
|
||||
release.uploadedBy,
|
||||
`SHA-256 ${release.sha256.slice(0, SHA256_PREFIX_LENGTH)}…`,
|
||||
...(hasFile ? [] : ['파일 없음 — 다시 올리세요']),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 도구 — 휴대폰 앱 (사내 배포 — 계약 1절 "휴대폰 앱 받기" · 6절 "운영 도구"): 올리기 · 목록.
|
||||
* 올리기는 운영자가 `sudo docker cp` 로 컨테이너 안에 넣은 APK 를 검사해 업로드 볼륨과 표에 들이고 실행 기록을 남긴다(넣은 경로는 감사용으로 남긴다).
|
||||
* 목록은 읽기만 한다(기록하지 않는다). 비밀은 다루지 않는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppReleaseCommands {
|
||||
constructor(
|
||||
private readonly releases: AppReleasesService,
|
||||
private readonly audit: OpsAuditService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 올리기 — 경로 → 검사(일반 파일 · .apk · zip 머리 · 1KB~200MB · SHA-256) → 버전(파일 이름의 x.y.z 를 기본값으로) →
|
||||
* 서버가 받는 가장 낮은 버전보다 낮으면 하지 않음 · 지금 최신보다 낮거나 같으면 y/N → 바뀔 것(버전 · 크기 · SHA-256) y/N → 들이기 → 실행 기록.
|
||||
* 끝에 SHA-256 을 한 번 더 보여 빌드한 파일과 견주게 한다
|
||||
*/
|
||||
async upload(io: OpsIo, operator: string): Promise<void> {
|
||||
io.print(
|
||||
'서버에서 APK 를 먼저 이 컨테이너 안에 넣어 두세요 — sudo docker cp <파일> <PROJECT_NAME>-backend:/tmp/',
|
||||
);
|
||||
const path = await io.ask(
|
||||
'컨테이너 안 APK 경로 (예: /tmp/ARA-SYSTEM-1.0.0.apk): ',
|
||||
);
|
||||
if (path === '') {
|
||||
io.print('경로를 넣어 주세요.');
|
||||
return;
|
||||
}
|
||||
let inspection: ApkInspection;
|
||||
try {
|
||||
inspection = await this.releases.inspect(path);
|
||||
} catch (error) {
|
||||
io.print(failureLine(error));
|
||||
return;
|
||||
}
|
||||
if (!inspection.ok) {
|
||||
io.print(apkProblemText(inspection.problem));
|
||||
return;
|
||||
}
|
||||
const { apk } = inspection;
|
||||
try {
|
||||
await this.uploadInspected(io, operator, apk);
|
||||
} finally {
|
||||
await apk.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 휴대폰 앱 목록 — 최근 10개, 맨 위가 직원이 받는 버전 (읽기만 — 기록하지 않는다) */
|
||||
async list(io: OpsIo): Promise<void> {
|
||||
try {
|
||||
const listings = await this.releases.listRecent(
|
||||
PLATFORM,
|
||||
APP_RELEASE_LIST_LIMIT,
|
||||
);
|
||||
if (listings.length === 0) {
|
||||
io.print('올린 휴대폰 앱이 없습니다.');
|
||||
return;
|
||||
}
|
||||
io.print('');
|
||||
io.print(RULE);
|
||||
io.print(
|
||||
` 휴대폰 앱 (안드로이드) — 최근 ${listings.length}개, ▶ 가 직원이 받는 버전(가장 늦게 올린 것)`,
|
||||
);
|
||||
io.print(RULE);
|
||||
listings.forEach(({ release, hasFile }, index) =>
|
||||
io.print(releaseLine(release, hasFile, index === 0)),
|
||||
);
|
||||
io.print(RULE);
|
||||
} catch (error) {
|
||||
io.print(failureLine(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadInspected(
|
||||
io: OpsIo,
|
||||
operator: string,
|
||||
apk: InspectedApk,
|
||||
): Promise<void> {
|
||||
const version = await this.askVersion(io, apk.fileName);
|
||||
if (version === null) return;
|
||||
const minimum = MIN_SUPPORTED_APP_VERSION[PLATFORM];
|
||||
if (compareAppVersions(version, minimum) < 0) {
|
||||
io.print(
|
||||
`서버가 받는 가장 낮은 버전은 ${minimum} 입니다 — 그보다 낮은 버전은 올리지 않습니다(깔자마자 업데이트 화면이 뜹니다).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const latest = await this.releases.latest(PLATFORM);
|
||||
if (latest !== null && !(await this.confirmNotNewer(io, version, latest))) {
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
`파일 ${apk.path}`,
|
||||
`버전 ${version}${latest === null ? ' (처음 올리는 앱)' : ` (지금 직원이 받는 버전 ${latest.version})`}`,
|
||||
`크기 ${bytesText(apk.sizeBytes)} (${byteCount(apk.sizeBytes)})`,
|
||||
`SHA-256 ${apk.sha256}`,
|
||||
`받는 이름 ${appReleaseFileName(version)}`,
|
||||
'올리면 바로 직원이 받는 버전이 된다 — 웹메일 설정 › 휴대폰 앱 · 휴대폰의 <포털>/app',
|
||||
];
|
||||
if (!(await confirmPlan(io, '휴대폰 앱 올리기', lines))) return;
|
||||
|
||||
try {
|
||||
const release = await this.releases.publish(apk, {
|
||||
platform: PLATFORM,
|
||||
version,
|
||||
uploadedBy: operator,
|
||||
});
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`올렸습니다 — 버전 ${version} · ${bytesText(apk.sizeBytes)}. 직원은 지금부터 이 버전을 받습니다.`,
|
||||
);
|
||||
this.showChecksum(io, version, apk.sha256);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'app-release-upload',
|
||||
target: `${PLATFORM} ${version}`,
|
||||
result: 'success',
|
||||
detail: uploadDetail(apk, release, null),
|
||||
});
|
||||
} catch (error) {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
error instanceof AppReleaseFileChangedError
|
||||
? '파일이 검사한 뒤 바뀌었습니다(SHA-256 · 크기가 다릅니다) — 올리지 않았습니다. 복사가 끝난 뒤 다시 하세요.'
|
||||
: failureLine(error),
|
||||
);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'app-release-upload',
|
||||
target: `${PLATFORM} ${version}`,
|
||||
result: 'failure',
|
||||
detail: uploadDetail(apk, null, failureDetail(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 버전을 묻는다 — 빈 값이면 파일 이름의 버전. 규칙에 맞지 않으면 알리고 null */
|
||||
private async askVersion(
|
||||
io: OpsIo,
|
||||
fileName: string,
|
||||
): Promise<string | null> {
|
||||
const suggested = versionInFileName(fileName);
|
||||
const answer = await io.ask(
|
||||
suggested === null
|
||||
? '버전 (예: 1.0.0): '
|
||||
: `버전 (빈 값이면 파일 이름의 ${suggested}): `,
|
||||
);
|
||||
const version = answer === '' ? suggested : answer;
|
||||
if (version === null || !isAppVersion(version)) {
|
||||
io.print(APP_VERSION_RULE_TEXT);
|
||||
return null;
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
/** 넣은 버전이 지금 최신보다 높지 않으면 알리고 묻는다 — 높으면 묻지 않고 true */
|
||||
private async confirmNotNewer(
|
||||
io: OpsIo,
|
||||
version: string,
|
||||
latest: AppReleaseEntity,
|
||||
): Promise<boolean> {
|
||||
const order = compareAppVersions(version, latest.version);
|
||||
if (order > 0) return true;
|
||||
io.print(
|
||||
`주의: 넣은 버전(${version})이 지금 직원이 받는 버전(${latest.version} · ${timeText(latest.createdAt)} 올림)${order === 0 ? '과 같습니다' : '보다 낮습니다'}.`,
|
||||
);
|
||||
io.print(
|
||||
order === 0
|
||||
? ' 올리면 이 파일이 직원이 받는 파일이 됩니다(같은 버전을 고쳐 다시 빌드했을 때).'
|
||||
: ' 올리면 이 파일이 직원이 받는 버전이 됩니다(되돌릴 때 쓰는 길). 이미 더 높은 버전을 깐 휴대폰은 안드로이드가 낮은 버전으로 덮어 깔지 않아, 앱을 지우고 다시 깔아야 합니다(로그인도 다시).',
|
||||
);
|
||||
const yes = await io.confirm('그래도 올릴까요?');
|
||||
if (!yes) io.print('하지 않았습니다.');
|
||||
return yes;
|
||||
}
|
||||
|
||||
/** 끝에 SHA-256 을 한 번 더 — 빌드한 PC 의 값과 견주게 한다 */
|
||||
private showChecksum(io: OpsIo, version: string, sha256: string): void {
|
||||
const fileName = appReleaseFileName(version);
|
||||
io.print('');
|
||||
io.print(RULE);
|
||||
io.print(` SHA-256 ${sha256}`);
|
||||
io.print(' 빌드한 PC 에서 같은 값인지 견주세요.');
|
||||
io.print(` Windows certutil -hashfile ${fileName} SHA256`);
|
||||
io.print(` macOS · 리눅스 shasum -a 256 ${fileName}`);
|
||||
io.print(
|
||||
' 컨테이너에 넣은 파일은 이제 지워도 됩니다 — 볼륨에 사본이 있습니다(mail/README "휴대폰 앱 올리기").',
|
||||
);
|
||||
io.print(RULE);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,9 @@ export type OpsCommand =
|
||||
/** 메일 이전 › 이전용 비밀번호 다시 만들기 */
|
||||
| 'migration-password'
|
||||
/** 메일 이전 › 이전 마치기 — 직원용 임시 비밀번호 */
|
||||
| 'migration-finish';
|
||||
| 'migration-finish'
|
||||
/** 휴대폰 앱 올리기 (사내 배포 — 2026-11-08) — 대상은 `android <버전>`, detail 에 판 id · 크기 · SHA-256 · 넣은 경로 */
|
||||
| 'app-release-upload';
|
||||
|
||||
/**
|
||||
* 비밀번호를 거는 명령 — 메일 이전 중 판정이 그 주소의 마지막 기록(성공 · 이전용 명령의 실패 — ops-data.service.ts 의 countsForMigration)을 본다.
|
||||
@@ -75,7 +77,7 @@ export class OpsAuditLogEntity {
|
||||
@Column({ type: 'varchar', length: OPS_OPERATOR_MAX_LENGTH })
|
||||
operator!: string;
|
||||
|
||||
/** 명령 — OpsCommand (hire · hire-migration · password-reset · … · migration-password · migration-finish) */
|
||||
/** 명령 — OpsCommand (hire · hire-migration · password-reset · … · migration-password · migration-finish · app-release-upload) */
|
||||
@Column({ type: 'varchar', length: COMMAND_COLUMN_LENGTH })
|
||||
command!: string;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { AccountCommands } from './account-commands';
|
||||
import { AdminCommands } from './admin-commands';
|
||||
import { AppReleaseCommands } from './app-release-commands';
|
||||
import { LockCommands } from './lock-commands';
|
||||
import { MigrationCommands } from './migration-commands';
|
||||
import { MigrationPasswordSession } from './migration-password';
|
||||
@@ -21,7 +22,7 @@ const OPERATOR_CONTROL = /[\u0000-\u001f]/;
|
||||
/** 관리자 비밀번호를 다시 묻는 횟수 */
|
||||
const SIGN_IN_ATTEMPTS = 3;
|
||||
|
||||
/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열한 개와 차례 그대로) */
|
||||
/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열세 개와 차례 그대로) */
|
||||
export const OPS_MENU = [
|
||||
['1', '입사'],
|
||||
['2', '비밀번호 재설정'],
|
||||
@@ -34,6 +35,8 @@ export const OPS_MENU = [
|
||||
['9', '현황'],
|
||||
['10', '정책 · 설정'],
|
||||
['11', '메일 이전'],
|
||||
['12', '휴대폰 앱 올리기'],
|
||||
['13', '휴대폰 앱 목록'],
|
||||
['0', '끝내기'],
|
||||
] as const;
|
||||
|
||||
@@ -51,6 +54,7 @@ export class OpsCli {
|
||||
private readonly admin: AdminCommands,
|
||||
private readonly migration: MigrationCommands,
|
||||
private readonly migrationPassword: MigrationPasswordSession,
|
||||
private readonly appReleases: AppReleaseCommands,
|
||||
) {}
|
||||
|
||||
/** 끝나면 종료 코드 — 0 정상, 1 들어가지 못함 */
|
||||
@@ -207,6 +211,10 @@ export class OpsCli {
|
||||
return this.admin.policy(io, operator);
|
||||
case '11':
|
||||
return this.migration.menu(io, operator);
|
||||
case '12':
|
||||
return this.appReleases.upload(io, operator);
|
||||
case '13':
|
||||
return this.appReleases.list(io);
|
||||
default:
|
||||
io.print('메뉴의 번호를 넣어 주세요.');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import type { AppReleasesService } from '@/modules/app-releases/app-releases.service';
|
||||
import type { FileSharingService } from '@/modules/file-sharing/file-sharing.service';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { SystemMailService } from '@/modules/system-mail/system-mail.service';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { kstDateString } from '@/shared/utils/kst-date';
|
||||
import { AccountCommands } from './account-commands';
|
||||
import { AdminCommands } from './admin-commands';
|
||||
import { AppReleaseCommands } from './app-release-commands';
|
||||
import { LockCommands } from './lock-commands';
|
||||
import { MigrationCommands } from './migration-commands';
|
||||
import { MigrationPasswordSession } from './migration-password';
|
||||
@@ -337,13 +339,24 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
let locks: LockCommands;
|
||||
let admin: AdminCommands;
|
||||
let migration: MigrationCommands;
|
||||
/** 휴대폰 앱 — 올리기 · 목록의 자세한 시험은 app-release-commands.spec.ts (여기서는 메뉴만) */
|
||||
let appReleaseService: { listRecent: jest.Mock };
|
||||
let appReleases: AppReleaseCommands;
|
||||
|
||||
/** 엔진 · 서비스가 불린 차례 (한 줄로) */
|
||||
const order = (): string[] => [...engine.calls];
|
||||
|
||||
/** 도구 한 번 실행 */
|
||||
const cli = () =>
|
||||
new OpsCli(engine, accounts, locks, admin, migration, migrationPassword);
|
||||
new OpsCli(
|
||||
engine,
|
||||
accounts,
|
||||
locks,
|
||||
admin,
|
||||
migration,
|
||||
migrationPassword,
|
||||
appReleases,
|
||||
);
|
||||
|
||||
let errorLogs: string[];
|
||||
let warnLogs: string[];
|
||||
@@ -509,6 +522,11 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
audit as unknown as OpsAuditService,
|
||||
migrationPassword,
|
||||
);
|
||||
appReleaseService = { listRecent: jest.fn().mockResolvedValue([]) };
|
||||
appReleases = new AppReleaseCommands(
|
||||
appReleaseService as unknown as AppReleasesService,
|
||||
audit as unknown as OpsAuditService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
@@ -2612,6 +2630,15 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
expect(io.output).toContain('2단계 인증이 켜져 있어');
|
||||
});
|
||||
|
||||
it('메뉴 12 · 13 — 휴대폰 앱 올리기 · 목록 (메뉴는 명령 열셋 + 끝내기)', async () => {
|
||||
const io = new ScriptedIo([OPERATOR, 'pw', '13', '0']);
|
||||
await expect(cli().run(io)).resolves.toBe(0);
|
||||
expect(io.output).toContain('12. 휴대폰 앱 올리기');
|
||||
expect(io.output).toContain('13. 휴대폰 앱 목록');
|
||||
expect(io.output).toContain('올린 휴대폰 앱이 없습니다.');
|
||||
expect(appReleaseService.listRecent).toHaveBeenCalledWith('android', 10);
|
||||
});
|
||||
|
||||
it('입력이 끝나면(Ctrl+D) 조용히 끝낸다', async () => {
|
||||
await expect(cli().run(new ScriptedIo([OPERATOR, 'pw']))).resolves.toBe(
|
||||
0,
|
||||
|
||||
@@ -3,6 +3,10 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AppReleaseStorage } from '@/modules/app-releases/app-release-storage';
|
||||
import { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
|
||||
import { AppReleasesRepository } from '@/modules/app-releases/app-releases.repository';
|
||||
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
|
||||
import { AuthSessionModule } from '@/modules/auth/auth-session.module';
|
||||
import { FileSharingRepository } from '@/modules/file-sharing/file-sharing.repository';
|
||||
import { FileSharingService } from '@/modules/file-sharing/file-sharing.service';
|
||||
@@ -39,6 +43,7 @@ import { buildDatabaseOptions } from '@/shared/config/database.config';
|
||||
import { validateEnv } from '@/shared/config/env.validation';
|
||||
import { AccountCommands } from './account-commands';
|
||||
import { AdminCommands } from './admin-commands';
|
||||
import { AppReleaseCommands } from './app-release-commands';
|
||||
import { LockCommands } from './lock-commands';
|
||||
import { MigrationCommands } from './migration-commands';
|
||||
import { MigrationPasswordSession } from './migration-password';
|
||||
@@ -85,6 +90,7 @@ const OPS_DB_POOL = {
|
||||
StoredFileChunkEntity,
|
||||
ShareLinkEntity,
|
||||
ShareLinkFileEntity,
|
||||
AppReleaseEntity,
|
||||
]),
|
||||
// JmapClient 가 쓰는 캐시 (세션 기억)
|
||||
CacheModule.register({ isGlobal: true }),
|
||||
@@ -132,6 +138,13 @@ const OPS_DB_POOL = {
|
||||
provide: SignatureImageStorage,
|
||||
useFactory: () => new SignatureImageStorage(),
|
||||
},
|
||||
// 휴대폰 앱 — 올리기 · 목록 (서버와 같은 표 · 같은 업로드 볼륨 폴더)
|
||||
AppReleasesRepository,
|
||||
{
|
||||
provide: AppReleaseStorage,
|
||||
useFactory: () => new AppReleaseStorage(),
|
||||
},
|
||||
AppReleasesService,
|
||||
OpsDataService,
|
||||
OpsAuditService,
|
||||
// 이번 실행의 이전용 비밀번호 — 이 프로세스의 메모리에만(한 실행 = 한 프로세스), 끝낼 때 OpsCli 가 지운다
|
||||
@@ -140,6 +153,7 @@ const OPS_DB_POOL = {
|
||||
LockCommands,
|
||||
AdminCommands,
|
||||
MigrationCommands,
|
||||
AppReleaseCommands,
|
||||
OpsCli,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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