diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index fcc0aa9..2e8f798 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, ], diff --git a/backend/src/migrations/1794140434961-AddAppReleases.ts b/backend/src/migrations/1794140434961-AddAppReleases.ts new file mode 100644 index 0000000..7fa948e --- /dev/null +++ b/backend/src/migrations/1794140434961-AddAppReleases.ts @@ -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 { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/app-releases/apk-file.spec.ts b/backend/src/modules/app-releases/apk-file.spec.ts new file mode 100644 index 0000000..b511712 --- /dev/null +++ b/backend/src/modules/app-releases/apk-file.spec.ts @@ -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 { + 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(''), + 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(); + } + }); +}); diff --git a/backend/src/modules/app-releases/apk-file.ts b/backend/src/modules/app-releases/apk-file.ts new file mode 100644 index 0000000..5b4f811 --- /dev/null +++ b/backend/src/modules/app-releases/apk-file.ts @@ -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; +} + +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, +): Promise { + 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 { + 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 { + 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(); + } +} diff --git a/backend/src/modules/app-releases/app-release-storage.spec.ts b/backend/src/modules/app-releases/app-release-storage.spec.ts new file mode 100644 index 0000000..1f20cd8 --- /dev/null +++ b/backend/src/modules/app-releases/app-release-storage.spec.ts @@ -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('들이면 .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(), + ); + }); +}); diff --git a/backend/src/modules/app-releases/app-release-storage.ts b/backend/src/modules/app-releases/app-release-storage.ts new file mode 100644 index 0000000..f488ce1 --- /dev/null +++ b/backend/src/modules/app-releases/app-release-storage.ts @@ -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}$/; + +/** 쓰는 중인 임시 파일의 꼬리 — `.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 { + try { + await unlink(path); + } catch (error) { + if (!isMissing(error)) throw error; + } +} + +/** 버퍼를 끝까지 쓴다 (한 번에 다 쓰지 못할 수 있다) */ +async function writeAll( + out: FileHandle, + data: Buffer, + position: number, +): Promise { + 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) 안 `.apk`. + * 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 서비스가 형식 · 머리글을 정한다 (.claude/rules/security.md "파일 업로드"). + * 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다 + */ +export class AppReleaseStorage { + constructor(private readonly dir: string = APP_RELEASE_DIR) {} + + /** 판 파일을 연다 — 없으면 null */ + async open(fileKey: string): Promise { + 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 { + try { + await stat(this.pathOf(fileKey)); + return true; + } catch (error) { + if (isMissing(error)) return false; + throw error; + } + } + + /** + * 운영자가 넣은 APK(검사하며 연 파일)를 들인다 — `.apk.part` 에 쓰고(wx — 덮어쓰지 않는다) 디스크에 내려쓴 뒤 `.apk` 로 이름을 바꾼다. + * 쓰면서 다시 센 SHA-256 · 크기가 검사 때와 다르면(검사한 뒤 원본이 바뀜) AppReleaseFileChangedError. + * 어떻게 실패해도 반쯤 쓴 임시 파일을 남기지 않는다 — 이름을 바꾸기 전에는 `.apk` 가 생기지 않는다 + */ + async importFile( + source: FileHandle, + fileKey: string, + expected: ExpectedAppReleaseFile, + ): Promise { + 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 { + await unlinkIfExists(this.pathOf(fileKey)); + } + + /** + * 멈춘 올리기(도구가 쓰는 중에 끊김)가 남긴 임시 파일 가운데 `olderThan` 보다 오래된 것을 지운다 → 지운 수. + * 폴더가 없으면 0. 그사이 지워진 것은 건너뛴다 + */ + async removeStaleParts(olderThan: Date): Promise { + 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}`); + } +} diff --git a/backend/src/modules/app-releases/app-release-version.spec.ts b/backend/src/modules/app-releases/app-release-version.spec.ts new file mode 100644 index 0000000..55e5ff9 --- /dev/null +++ b/backend/src/modules/app-releases/app-release-version.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/modules/app-releases/app-release-version.ts b/backend/src/modules/app-releases/app-release-version.ts new file mode 100644 index 0000000..e84d58e --- /dev/null +++ b/backend/src/modules/app-releases/app-release-version.ts @@ -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 = + /(?.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; +} diff --git a/backend/src/modules/app-releases/app-releases.constants.ts b/backend/src/modules/app-releases/app-releases.constants.ts new file mode 100644 index 0000000..aeebbb8 --- /dev/null +++ b/backend/src/modules/app-releases/app-releases.constants.ts @@ -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.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)의 하위 폴더. 이름은 `.apk` 뿐이다. + * 운영은 named volume `_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; diff --git a/backend/src/modules/app-releases/app-releases.controller.ts b/backend/src/modules/app-releases/app-releases.controller.ts new file mode 100644 index 0000000..982c62a --- /dev/null +++ b/backend/src/modules/app-releases/app-releases.controller.ts @@ -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 { + 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 { + await this.releases.serveLatestFile(req, res, 'android'); + } +} diff --git a/backend/src/modules/app-releases/app-releases.module.ts b/backend/src/modules/app-releases/app-releases.module.ts new file mode 100644 index 0000000..83e974f --- /dev/null +++ b/backend/src/modules/app-releases/app-releases.module.ts @@ -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 {} diff --git a/backend/src/modules/app-releases/app-releases.repository.ts b/backend/src/modules/app-releases/app-releases.repository.ts new file mode 100644 index 0000000..5b99e71 --- /dev/null +++ b/backend/src/modules/app-releases/app-releases.repository.ts @@ -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, + ) {} + + /** 그 플랫폼에서 가장 늦게 올린 판 — 없으면 null */ + latest(platform: AppReleasePlatform): Promise { + return this.rows.findOne({ where: { platform }, order: NEWEST_FIRST }); + } + + /** 늦게 올린 것부터 `limit` 개 */ + recent( + platform: AppReleasePlatform, + limit: number, + ): Promise { + return this.rows.find({ + where: { platform }, + order: NEWEST_FIRST, + take: limit, + }); + } + + async insert(release: AppReleaseEntity): Promise { + await this.rows.insert(release); + } +} diff --git a/backend/src/modules/app-releases/app-releases.service.spec.ts b/backend/src/modules/app-releases/app-releases.service.spec.ts new file mode 100644 index 0000000..c9659a5 --- /dev/null +++ b/backend/src/modules/app-releases/app-releases.service.spec.ts @@ -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(); + 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 = {}) => + ({ method, headers }) as unknown as Request; + +async function codeOf(run: () => Promise): Promise { + 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 { + 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 { + 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 { + 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('올리기 — 볼륨에 .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 }, + ]); + }); + }); +}); diff --git a/backend/src/modules/app-releases/app-releases.service.ts b/backend/src/modules/app-releases/app-releases.service.ts new file mode 100644 index 0000000..3dee860 --- /dev/null +++ b/backend/src/modules/app-releases/app-releases.service.ts @@ -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(); + 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 { + 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 { + 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 { + return inspectApk(path); + } + + /** 그 플랫폼의 최신 판 — 없으면 null */ + latest(platform: AppReleasePlatform): Promise { + return this.repository.latest(platform); + } + + /** 늦게 올린 것부터 `limit` 개 — 디스크에 파일이 있는지 함께 */ + async listRecent( + platform: AppReleasePlatform, + limit: number, + ): Promise { + 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 { + 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 { + 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 { + 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 { + // 흐름 오류는 처리기가 끝난 뒤에 올 수 있다 — 추적 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 { + 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)}`, + ); + } + } +} diff --git a/backend/src/modules/app-releases/app-releases.types.ts b/backend/src/modules/app-releases/app-releases.types.ts new file mode 100644 index 0000000..631c0c5 --- /dev/null +++ b/backend/src/modules/app-releases/app-releases.types.ts @@ -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; +} diff --git a/backend/src/modules/app-releases/dto/app-release.dto.ts b/backend/src/modules/app-releases/dto/app-release.dto.ts new file mode 100644 index 0000000..69c6394 --- /dev/null +++ b/backend/src/modules/app-releases/dto/app-release.dto.ts @@ -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; +} diff --git a/backend/src/modules/ops/app-release-commands.spec.ts b/backend/src/modules/ops/app-release-commands.spec.ts new file mode 100644 index 0000000..1f25d24 --- /dev/null +++ b/backend/src/modules/ops/app-release-commands.spec.ts @@ -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 { + this.asked.push(question); + return this.next().then((answer) => answer.trim()); + } + + askSecret(question: string): Promise { + this.asked.push(question); + return this.next(); + } + + async confirm(question: string): Promise { + 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 { + 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]'); + }); + }); +}); diff --git a/backend/src/modules/ops/app-release-commands.ts b/backend/src/modules/ops/app-release-commands.ts new file mode 100644 index 0000000..285c55e --- /dev/null +++ b/backend/src/modules/ops/app-release-commands.ts @@ -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 { + io.print( + '서버에서 APK 를 먼저 이 컨테이너 안에 넣어 두세요 — sudo docker cp <파일> -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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/backend/src/modules/ops/ops-audit-log.entity.ts b/backend/src/modules/ops/ops-audit-log.entity.ts index c7d0498..97e43bd 100644 --- a/backend/src/modules/ops/ops-audit-log.entity.ts +++ b/backend/src/modules/ops/ops-audit-log.entity.ts @@ -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; diff --git a/backend/src/modules/ops/ops-cli.ts b/backend/src/modules/ops/ops-cli.ts index fcd0ad5..7003e74 100644 --- a/backend/src/modules/ops/ops-cli.ts +++ b/backend/src/modules/ops/ops-cli.ts @@ -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('메뉴의 번호를 넣어 주세요.'); } diff --git a/backend/src/modules/ops/ops-commands.spec.ts b/backend/src/modules/ops/ops-commands.spec.ts index 3e14bc5..3659ba4 100644 --- a/backend/src/modules/ops/ops-commands.spec.ts +++ b/backend/src/modules/ops/ops-commands.spec.ts @@ -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, diff --git a/backend/src/modules/ops/ops.module.ts b/backend/src/modules/ops/ops.module.ts index 4f0e07a..b46872c 100644 --- a/backend/src/modules/ops/ops.module.ts +++ b/backend/src/modules/ops/ops.module.ts @@ -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, ], }) diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 7aacc4b..159b1c5 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -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';