diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eb9c97d..464bf15 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -131,7 +131,7 @@ jobs: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} - envs: GHCR_TOKEN,GHCR_USER,DOMAIN,ACME_EMAIL,CORS_ORIGIN,RTC_MIN_PORT,RTC_MAX_PORT,MEDIASOUP_WORKER_NUM,STT_MODEL_SIZE,MONGO_DB_NAME,IMAGE_TAG,GEMINI_API_KEY,GEMINI_MODEL,MONGO_URI + envs: GHCR_TOKEN,GHCR_USER,DOMAIN,ACME_EMAIL,CORS_ORIGIN,RTC_MIN_PORT,RTC_MAX_PORT,MEDIASOUP_WORKER_NUM,STT_MODEL_SIZE,MONGO_DB_NAME,IMAGE_TAG,GEMINI_API_KEY,GEMINI_MODEL,MONGO_URI,ADMIN_API_TOKEN script: | set -e echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin @@ -158,6 +158,7 @@ jobs: echo "GEMINI_MODEL=$GEMINI_MODEL" echo "MONGO_URI=$MONGO_URI" echo "MONGO_DB_NAME=${MONGO_DB_NAME:-convene-prod}" + echo "ADMIN_API_TOKEN=$ADMIN_API_TOKEN" } > .env docker compose -f docker-compose.prod.yml pull @@ -179,6 +180,7 @@ jobs: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} MONGO_URI: ${{ secrets.MONGO_URI }} + ADMIN_API_TOKEN: ${{ secrets.ADMIN_API_TOKEN }} # 5) 프론트 배포 (정적 export → S3 → CloudFront). FRONTEND_DEPLOY_ENABLED 게이트. deploy-frontend: diff --git a/apps/backend/.env.template b/apps/backend/.env.template index 47e5ed0..e13f028 100644 --- a/apps/backend/.env.template +++ b/apps/backend/.env.template @@ -14,6 +14,11 @@ MONGO_URI=mongodb://localhost:27017 # 회의록 저장 database 이름. 환경별로 분리(dev/prod). 기본: convene-dev MONGO_DB_NAME=convene-dev +# 관리자 재요약 엔드포인트(POST /reports/:id/resummarize) 인증 토큰. +# Authorization: Bearer <이 값> 으로 호출을 가른다. 비우면 엔드포인트가 비활성(403)된다. +# 임의의 랜덤 문자열을 직접 생성해 넣는다(예: openssl rand -hex 32). +ADMIN_API_TOKEN= + # ===== 선택(미설정 시 기본값) ===== # HTTP/WS 포트. 기본 5000 (frontend dev 가 3000 을 쓰므로 backend 는 5000) diff --git a/apps/backend/src/config/admin.config.spec.ts b/apps/backend/src/config/admin.config.spec.ts new file mode 100644 index 0000000..10a8a49 --- /dev/null +++ b/apps/backend/src/config/admin.config.spec.ts @@ -0,0 +1,15 @@ +import { resolveAdminConfig } from './admin.config'; + +describe('resolveAdminConfig', () => { + it('ADMIN_API_TOKEN 이 없거나 비어 있으면 null(엔드포인트 비활성 신호)', () => { + expect(resolveAdminConfig({})).toBeNull(); + expect(resolveAdminConfig({ ADMIN_API_TOKEN: '' })).toBeNull(); + expect(resolveAdminConfig({ ADMIN_API_TOKEN: ' ' })).toBeNull(); + }); + + it('ADMIN_API_TOKEN 이 있으면 trim 한 token 을 돌려준다', () => { + expect(resolveAdminConfig({ ADMIN_API_TOKEN: ' secret-123 ' })).toEqual({ + token: 'secret-123', + }); + }); +}); diff --git a/apps/backend/src/config/admin.config.ts b/apps/backend/src/config/admin.config.ts new file mode 100644 index 0000000..7d3f96a --- /dev/null +++ b/apps/backend/src/config/admin.config.ts @@ -0,0 +1,22 @@ +/** + * 관리자 전용 엔드포인트(예: 회의록 재요약)의 인증 토큰을 해석하는 모듈. + * + * `gemini.config.ts` 등과 동일하게 부트스트랩 단계의 env 해석만 담당하는 순수 함수. + * + * - 회원/계정 개념이 없는 제품이라 단일 운영자 시크릿(`ADMIN_API_TOKEN`) 으로 + * 관리자 호출을 가른다. `Authorization: Bearer ` 헤더로 검증한다. + * - 토큰이 비어 있으면 `null` 을 반환한다. 이때 `AdminGuard` 는 관리자 엔드포인트를 + * 비활성(403)으로 막아 실수로 무방비 노출되는 일이 없게 한다. + */ + +export interface AdminConfig { + readonly token: string; +} + +export function resolveAdminConfig( + env: NodeJS.ProcessEnv = process.env, +): AdminConfig | null { + const token = env.ADMIN_API_TOKEN?.trim(); + if (token === undefined || token.length === 0) return null; + return { token }; +} diff --git a/apps/backend/src/reports/application/report-finalization.service.spec.ts b/apps/backend/src/reports/application/report-finalization.service.spec.ts index 68e3a13..09315d4 100644 --- a/apps/backend/src/reports/application/report-finalization.service.spec.ts +++ b/apps/backend/src/reports/application/report-finalization.service.spec.ts @@ -9,7 +9,7 @@ import { } from '@/shared-kernel/domain/value-objects'; import { MeetingReport } from '../domain/meeting-report'; -import { ReportNotFoundError } from './report.errors'; +import { ReportNotFoundError, ReportNotResummarizableError } from './report.errors'; import { ReportFinalizationService } from './report-finalization.service'; interface CapturedEvent { @@ -281,6 +281,184 @@ describe('ReportFinalizationService.completeTranscription', () => { }); }); +describe('ReportFinalizationService.resummarize', () => { + const startedAt = new Date('2026-01-01T00:00:00Z'); + const endedAt = new Date('2026-01-01T00:30:00Z'); + const now = new Date('2026-01-01T01:00:00Z'); + const reportId = 'rep_resum'; + const chat = [chatEntry({ nickname: '준', text: '회의 시작', sentAt: startedAt })]; + const transcript = [transcriptSegment({ text: '안녕하세요', startMs: 0, endMs: 1000 })]; + const firstSummary: ReportSummary = reportSummary({ + title: '1차 요약', + overview: '처음 요약', + decisions: [], + actionItems: [], + keyTopics: [], + }); + const newSummary: ReportSummary = reportSummary({ + title: '재요약', + overview: '새 프롬프트 요약', + decisions: ['결정 1'], + actionItems: [], + keyTopics: [], + }); + + /** STT done + summary done/failed 상태의 회의록을 만든다. */ + const makeReport = (summaryState: 'done' | 'failed' | 'pending'): MeetingReport => { + const report = MeetingReport.fromEndedMeeting({ + id: reportId, + meetingId: 'mtg_x', + code: 'code-x', + source: 'web', + externalReference: NO_EXTERNAL_REFERENCE, + startedAt, + endedAt, + participants: [], + chat, + }); + report.applyTranscript(transcript); + if (summaryState === 'done') report.applySummary(firstSummary); + else if (summaryState === 'failed') report.markSummaryFailed('llm boom', endedAt); + return report; + }; + + const makeService = ( + report: MeetingReport | null, + opts: { summarizerResult?: ReportSummary; summarizerError?: Error } = {}, + ) => { + const store = new Map(); + if (report) store.set(report.id, report); + const saves: string[] = []; + const { events, publisher } = makeEventPublisher(); + const summarizer = { + summarize: jest.fn(async () => { + if (opts.summarizerError) throw opts.summarizerError; + return opts.summarizerResult ?? newSummary; + }), + }; + const service = new ReportFinalizationService({ + repository: { + save: async (r) => { + store.set(r.id, r); + saves.push(r.id); + }, + findById: async (id) => store.get(id) ?? null, + findByMeetingId: async () => null, + listRecent: async () => [], + }, + summarizer, + notion: noopNotion(), + idGenerator: { next: () => 'unused' }, + clock: { now: () => now }, + eventPublisher: publisher, + }); + return { service, store, saves, events, summarizer }; + }; + + it('존재하지 않는 reportId면 ReportNotFoundError를 던진다', async () => { + const { service } = makeService(null); + await expect(service.resummarize('unknown')).rejects.toThrow(ReportNotFoundError); + }); + + it('summary 가 pending(파이프라인 진행 중)이면 ReportNotResummarizableError', async () => { + const { service, summarizer } = makeService(makeReport('pending')); + await expect(service.resummarize(reportId)).rejects.toThrow(ReportNotResummarizableError); + expect(summarizer.summarize).not.toHaveBeenCalled(); + }); + + it('STT 가 실패해 transcript 가 없으면 재요약을 거부하고 Summarizer 를 호출하지 않는다', async () => { + // STT 실패 → transcript 없음. 빈 입력으로 LLM 을 호출하지 않도록 차단해야 한다. + const sttFailed = MeetingReport.fromEndedMeeting({ + id: reportId, + meetingId: 'mtg_x', + code: 'code-x', + source: 'web', + externalReference: NO_EXTERNAL_REFERENCE, + startedAt, + endedAt, + participants: [], + chat, + }); + sttFailed.markTranscriptionFailed('ai-worker 5xx', endedAt); + sttFailed.markSummaryFailed('cascade', endedAt); + const { service, summarizer } = makeService(sttFailed); + await expect(service.resummarize(reportId)).rejects.toThrow(ReportNotResummarizableError); + expect(summarizer.summarize).not.toHaveBeenCalled(); + }); + + it('저장된 transcript+chat+meta 를 Summarizer 에 그대로 전달한다', async () => { + const { service, summarizer } = makeService(makeReport('done')); + await service.resummarize(reportId); + expect(summarizer.summarize).toHaveBeenCalledWith({ + transcript, + chat, + meta: { meetingId: 'mtg_x', code: 'code-x', startedAt, endedAt }, + }); + }); + + it('성공 시 기존 summary 를 새 결과로 교체하고 summaryStatus=done 유지', async () => { + const { service, store } = makeService(makeReport('done')); + await service.resummarize(reportId); + const after = store.get(reportId)!; + expect(after.summary).toEqual(newSummary); + expect(after.pipeline.summaryStatus).toBe('done'); + }); + + it('실패했던 회의록을 재요약하면 done 으로 복구된다', async () => { + const { service, store } = makeService(makeReport('failed')); + await service.resummarize(reportId); + const after = store.get(reportId)!; + expect(after.summary).toEqual(newSummary); + expect(after.pipeline.summaryStatus).toBe('done'); + }); + + it('성공 시 summary.completed → finalized 순으로 발행한다', async () => { + const { service, events } = makeService(makeReport('done')); + await service.resummarize(reportId); + expect(events.map((e) => e.name)).toEqual([ + REPORT_EVENTS.SUMMARY_COMPLETED, + REPORT_EVENTS.FINALIZED, + ]); + expect(events[0].payload).toEqual({ reportId }); + }); + + it('재요약 Summarizer 가 throw 하면 에러를 그대로 전파한다(동기 HTTP → 5xx)', async () => { + const { service } = makeService(makeReport('done'), { + summarizerError: new Error('LLM 503'), + }); + await expect(service.resummarize(reportId)).rejects.toThrow('LLM 503'); + }); + + it('done 회의록 재요약 실패 시 done 상태를 보존하고 저장/이벤트를 하지 않는다(격하 방지)', async () => { + const { service, store, saves, events } = makeService(makeReport('done'), { + summarizerError: new Error('LLM 503'), + }); + await expect(service.resummarize(reportId)).rejects.toThrow(); + const after = store.get(reportId)!; + expect(after.pipeline.summaryStatus).toBe('done'); + expect(after.summary).toEqual(firstSummary); + expect(saves).toEqual([]); + expect(events).toEqual([]); + }); + + it('failed 회의록 재요약 실패 시 기존 failed 상태/failures 를 그대로 둔다', async () => { + const { service, store, saves } = makeService(makeReport('failed'), { + summarizerError: new Error('LLM 503'), + }); + await expect(service.resummarize(reportId)).rejects.toThrow(); + const after = store.get(reportId)!; + expect(after.pipeline.summaryStatus).toBe('failed'); + expect(after.pipeline.failures).toEqual([{ stage: 'summary', error: 'llm boom', at: endedAt }]); + expect(saves).toEqual([]); + }); + + it('갱신된 MeetingReport 를 반환한다', async () => { + const { service } = makeService(makeReport('done')); + const result = await service.resummarize(reportId); + expect(result.summary).toEqual(newSummary); + }); +}); + describe('ReportFinalizationService.listRecent', () => { const startedAt = new Date('2026-01-01T00:00:00Z'); diff --git a/apps/backend/src/reports/application/report-finalization.service.ts b/apps/backend/src/reports/application/report-finalization.service.ts index f7388b6..adb752d 100644 --- a/apps/backend/src/reports/application/report-finalization.service.ts +++ b/apps/backend/src/reports/application/report-finalization.service.ts @@ -11,7 +11,7 @@ import { import { Clock, DomainEventPublisher } from '@/shared-kernel/domain/ports'; import { ChatEntry, ExternalReference, Source } from '@/shared-kernel/domain/value-objects'; -import { ReportNotFoundError } from './report.errors'; +import { ReportNotFoundError, ReportNotResummarizableError } from './report.errors'; /** * Reports Bounded Context의 Application Service. @@ -120,6 +120,57 @@ export class ReportFinalizationService { } } + /** + * 관리자 재요약. 저장된 transcript+chat 을 SummarizerPort 로 다시 요약해 기존 + * summary 를 교체한다(요약 실패 회의록 재시도 / 새 프롬프트 A/B 비교 용도). + * + * 재요약 조건: STT 완료(sttStatus=done)로 transcript 가 있고, 1차 요약이 끝난 + * (summaryStatus=done/failed) 회의록. STT 가 실패/미완료면 transcript 가 비어 빈 + * 입력으로 LLM 을 호출하게 되므로 거부하고, 요약이 진행 중(pending)이어도 거부한다 + * (둘 다 `ReportNotResummarizableError` → 409). + * + * 동기 HTTP 요청이므로 요약기 실패는 삼키지 않고 그대로 전파한다(상위에서 5xx). + * 실패 시 **상태를 바꾸거나 저장하지 않는다** — 이미 done 이던 회의록이 일시적 + * LLM 장애로 failed 로 격하되어 "요약 실패" 로 보이는 UX 퇴행을 막기 위함이다. + * 성공했을 때만 중간 pending 없이 done 으로 교체하므로 영구 pending 으로도 갇히지 않는다. + */ + async resummarize(reportId: string): Promise { + const report = await this.requireReport(reportId); + if (report.pipeline.sttStatus !== 'done') { + throw new ReportNotResummarizableError( + reportId, + `STT 가 완료되지 않아(sttStatus=${report.pipeline.sttStatus}) 재요약할 transcript 가 없습니다`, + ); + } + if (report.pipeline.summaryStatus === 'pending') { + throw new ReportNotResummarizableError(reportId, '요약이 진행 중입니다(summaryStatus=pending)'); + } + + // summarize 실패는 catch 하지 않고 전파한다. 이 시점 이전에는 어떤 상태 변경/저장도 + // 하지 않았으므로 기존 회의록 상태(done/failed)는 그대로 보존된다. + const summary = await this.deps.summarizer.summarize({ + transcript: report.transcript, + chat: report.chat, + meta: { + meetingId: report.meetingId, + code: report.code, + startedAt: report.startedAt, + endedAt: report.endedAt, + }, + }); + report.replaceSummary(summary); + await this.deps.repository.save(report); + await this.deps.eventPublisher.publish(REPORT_EVENTS.SUMMARY_COMPLETED, { + reportId: report.id, + }); + // 재요약 대상은 이미 STT 가 끝난 회의록이므로 pipeline 은 항상 final 상태다. + // 1차 파이프라인과 동일하게 finalized 이벤트를 발행한다. + await this.deps.eventPublisher.publish(REPORT_EVENTS.FINALIZED, { + reportId: report.id, + }); + return report; + } + async listRecent(limit: number): Promise { return this.deps.repository.listRecent(limit); } diff --git a/apps/backend/src/reports/application/report.errors.ts b/apps/backend/src/reports/application/report.errors.ts index 0a6818a..4221e85 100644 --- a/apps/backend/src/reports/application/report.errors.ts +++ b/apps/backend/src/reports/application/report.errors.ts @@ -11,3 +11,20 @@ export class ReportNotFoundError extends Error { this.name = 'ReportNotFoundError'; } } + +/** + * 재요약 대상이 아닌 회의록에 재요약을 요청했을 때 발생한다. + * + * 재요약 가능 조건: STT 가 완료되어(sttStatus=done) transcript 가 있고, 1차 요약이 + * 이미 끝난(summaryStatus=done/failed, 진행 중 아님) 상태. 어긋나면 `reason` 에 + * 사유를 담아 던지며, Interface layer 가 409 Conflict 로 매핑한다. + */ +export class ReportNotResummarizableError extends Error { + constructor( + public readonly reportId: string, + public readonly reason: string, + ) { + super(`Report "${reportId}" cannot be resummarized: ${reason}`); + this.name = 'ReportNotResummarizableError'; + } +} diff --git a/apps/backend/src/reports/domain/meeting-report.spec.ts b/apps/backend/src/reports/domain/meeting-report.spec.ts index 9d238b2..b5ceda9 100644 --- a/apps/backend/src/reports/domain/meeting-report.spec.ts +++ b/apps/backend/src/reports/domain/meeting-report.spec.ts @@ -131,6 +131,39 @@ describe('MeetingReport (Aggregate Root)', () => { }); }); + describe('replaceSummary / markResummaryFailed (관리자 재요약)', () => { + const newSummary = () => + reportSummary({ + title: '재요약된 주간 회의', + overview: '새 프롬프트 요약', + decisions: ['결정 A'], + actionItems: [], + keyTopics: [], + }); + + it('성공했던 회의록을 새 summary 로 교체하고 summary=done 을 유지한다', () => { + const r = MeetingReport.fromEndedMeeting(baseInput()); + r.applyTranscript([transcriptSegment({ text: '안녕', startMs: 0, endMs: 500 })]); + r.applySummary(validSummary()); + const replacement = newSummary(); + r.replaceSummary(replacement); + expect(r.summary).toBe(replacement); + expect(r.pipeline.summaryStatus).toBe('done'); + }); + + it('실패했던 회의록을 재요약으로 done 으로 복구한다', () => { + const r = MeetingReport.fromEndedMeeting(baseInput()); + r.markSummaryFailed('llm boom', failAt); + r.replaceSummary(newSummary()); + expect(r.pipeline.summaryStatus).toBe('done'); + }); + + it('아직 요약 전(pending)이면 재요약을 거부한다', () => { + const r = MeetingReport.fromEndedMeeting(baseInput()); + expect(() => r.replaceSummary(newSummary())).toThrow(/pending/); + }); + }); + describe('attachNotionPushResult', () => { it('pipeline이 final 상태가 아니면 거부한다', () => { const r = MeetingReport.fromEndedMeeting(baseInput()); diff --git a/apps/backend/src/reports/domain/meeting-report.ts b/apps/backend/src/reports/domain/meeting-report.ts index 5135e77..f02d41b 100644 --- a/apps/backend/src/reports/domain/meeting-report.ts +++ b/apps/backend/src/reports/domain/meeting-report.ts @@ -160,6 +160,19 @@ export class MeetingReport { this._pipeline = this._pipeline.markSummaryFailed(error, at); } + /** + * 관리자 재요약 성공 결과를 반영한다. 기존 summary 를 새 summary 로 교체하고 + * summary stage 를 done 으로 전이한다(이미 끝난 stage 대상, ARCHITECTURE.md §5). + * + * 재요약 실패 시에는 호출하지 않는다 — Application 이 에러를 전파하고 기존 상태를 + * 보존하므로 done 회의록이 failed 로 격하되지 않는다. + */ + replaceSummary(summary: ReportSummary): void { + const next = this._pipeline.resummarizeDone(); + this._summary = summary; + this._pipeline = next; + } + attachNotionPushResult(result: NotionPushResult): void { if (!this._pipeline.isFinal) { throw new Error('Cannot attach Notion push result before pipeline is final'); diff --git a/apps/backend/src/reports/domain/value-objects/pipeline-state.spec.ts b/apps/backend/src/reports/domain/value-objects/pipeline-state.spec.ts index 075f9ee..d2938d1 100644 --- a/apps/backend/src/reports/domain/value-objects/pipeline-state.spec.ts +++ b/apps/backend/src/reports/domain/value-objects/pipeline-state.spec.ts @@ -77,6 +77,40 @@ describe('PipelineState', () => { }); }); + describe('resummarizeDone (관리자 재요약)', () => { + it('failed 였던 summary 를 done 으로 덮어쓰고 기존 failures 는 보존한다', () => { + const s = PipelineState.initial() + .markTranscriptionDone() + .markSummaryFailed('llm boom', at1) + .resummarizeDone(); + expect(s.sttStatus).toBe('done'); + expect(s.summaryStatus).toBe('done'); + expect(s.failures).toEqual([{ stage: 'summary', error: 'llm boom', at: at1 }]); + expect(s.isDone).toBe(true); + }); + + it('done 이던 summary 도 다시 done 으로 덮어쓸 수 있다(새 프롬프트 재요약)', () => { + const s = PipelineState.initial() + .markTranscriptionDone() + .markSummaryDone() + .resummarizeDone(); + expect(s.summaryStatus).toBe('done'); + }); + + it('summary 가 아직 pending 이면 재요약 전이를 거부한다', () => { + const s = PipelineState.initial().markTranscriptionDone(); + expect(() => s.resummarizeDone()).toThrow(/pending/); + }); + + it('새 인스턴스를 반환하고 원본은 유지된다(immutable)', () => { + const a = PipelineState.initial().markTranscriptionDone().markSummaryFailed('boom', at1); + const b = a.resummarizeDone(); + expect(a.summaryStatus).toBe('failed'); + expect(b.summaryStatus).toBe('done'); + expect(a).not.toBe(b); + }); + }); + describe('immutability', () => { it('전이 메서드는 새 인스턴스를 반환하고 원본은 유지된다', () => { const a = PipelineState.initial(); diff --git a/apps/backend/src/reports/domain/value-objects/pipeline-state.ts b/apps/backend/src/reports/domain/value-objects/pipeline-state.ts index 26f5999..0e510de 100644 --- a/apps/backend/src/reports/domain/value-objects/pipeline-state.ts +++ b/apps/backend/src/reports/domain/value-objects/pipeline-state.ts @@ -77,6 +77,22 @@ export class PipelineState { ); } + /** + * 이미 끝난(done/failed) summary stage 를 새 요약 성공 결과로 덮어쓴다. + * + * 관리자 재요약(`POST /reports/:id/resummarize`) 전용 전이. 중간 pending 을 + * 거치지 않고 한 번에 done 으로 교체하므로, 재요약 도중 크래시가 나도 회의록이 + * 영구 pending 으로 갇히지 않는다. 아직 1차 요약이 끝나지 않은(pending) stage 는 + * 재요약 대상이 아니므로 거부한다. 누적 failures 는 보존. + * + * 재요약 실패 시에는 이 전이를 호출하지 않는다 — 기존 상태(done/failed)를 그대로 + * 두고 에러만 전파해 done 회의록이 failed 로 격하되는 것을 막는다(Application 참고). + */ + resummarizeDone(): PipelineState { + this.assertNotPending('summary'); + return new PipelineState(this.sttStatus, 'done', this.failures); + } + /** 두 stage 모두 pending이 아니면 finalize 가능. */ get isFinal(): boolean { return this.sttStatus !== 'pending' && this.summaryStatus !== 'pending'; @@ -94,6 +110,13 @@ export class PipelineState { } } + private assertNotPending(stage: PipelineStage): void { + const status = stage === 'stt' ? this.sttStatus : this.summaryStatus; + if (status === 'pending') { + throw new Error(`Pipeline stage "${stage}" is still pending; cannot resummarize`); + } + } + private appendFailure( stage: PipelineStage, error: string, diff --git a/apps/backend/src/reports/interface/controllers/reports.controller.ts b/apps/backend/src/reports/interface/controllers/reports.controller.ts index b56e7fe..cc5bab8 100644 --- a/apps/backend/src/reports/interface/controllers/reports.controller.ts +++ b/apps/backend/src/reports/interface/controllers/reports.controller.ts @@ -4,18 +4,25 @@ import { type ReportListResponse, } from '@convene/shared-interfaces'; import { + ConflictException, Controller, Get, HttpCode, HttpStatus, NotFoundException, Param, + Post, Query, + UseGuards, } from '@nestjs/common'; -import { ReportNotFoundError } from '@/reports/application/report.errors'; +import { + ReportNotFoundError, + ReportNotResummarizableError, +} from '@/reports/application/report.errors'; import { ReportFinalizationService } from '@/reports/application/report-finalization.service'; import { ListReportsQueryDto } from '@/reports/interface/dto/list-reports-query.dto'; +import { AdminGuard } from '@/reports/interface/guards/admin.guard'; import { toReportDetailResponse, @@ -53,4 +60,26 @@ export class ReportsController { throw e; } } + + /** + * 관리자 재요약. 저장된 transcript+chat 으로 다시 요약해 기존 summary 를 교체한다. + * `AdminGuard` 가 `Authorization: Bearer ` 로 호출자를 가른다. + */ + @Post(':id/resummarize') + @UseGuards(AdminGuard) + @HttpCode(HttpStatus.OK) + async resummarize(@Param('id') id: string): Promise { + try { + const report = await this.service.resummarize(id); + return toReportDetailResponse(report); + } catch (e) { + if (e instanceof ReportNotFoundError) { + throw new NotFoundException(e.message); + } + if (e instanceof ReportNotResummarizableError) { + throw new ConflictException(e.message); + } + throw e; + } + } } diff --git a/apps/backend/src/reports/interface/guards/admin.guard.spec.ts b/apps/backend/src/reports/interface/guards/admin.guard.spec.ts new file mode 100644 index 0000000..98044ee --- /dev/null +++ b/apps/backend/src/reports/interface/guards/admin.guard.spec.ts @@ -0,0 +1,55 @@ +import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; + +import { AdminGuard, extractBearerToken, safeTokenEqual } from './admin.guard'; + +/** Authorization 헤더만 가진 가짜 HTTP ExecutionContext. */ +const contextWithAuth = (authorization?: string): ExecutionContext => + ({ + switchToHttp: () => ({ + getRequest: () => ({ headers: authorization === undefined ? {} : { authorization } }), + }), + }) as unknown as ExecutionContext; + +describe('extractBearerToken', () => { + it('Bearer 스킴에서 토큰만 추출한다(대소문자/여백 허용)', () => { + expect(extractBearerToken('Bearer abc')).toBe('abc'); + expect(extractBearerToken('bearer abc ')).toBe('abc'); + }); + + it('Bearer 형식이 아니거나 문자열이 아니면 null', () => { + expect(extractBearerToken('abc')).toBeNull(); + expect(extractBearerToken('Basic abc')).toBeNull(); + expect(extractBearerToken(undefined)).toBeNull(); + expect(extractBearerToken(['Bearer abc'])).toBeNull(); + }); +}); + +describe('safeTokenEqual', () => { + it('같으면 true, 다르거나 길이가 다르면 false', () => { + expect(safeTokenEqual('secret', 'secret')).toBe(true); + expect(safeTokenEqual('secret', 'secreT')).toBe(false); + expect(safeTokenEqual('secret', 'longer-secret')).toBe(false); + }); +}); + +describe('AdminGuard', () => { + it('서버에 토큰이 설정돼 있지 않으면 403(엔드포인트 비활성)', () => { + const guard = new AdminGuard(null); + expect(() => guard.canActivate(contextWithAuth('Bearer anything'))).toThrow(ForbiddenException); + }); + + it('Authorization 헤더가 없으면 401', () => { + const guard = new AdminGuard('secret'); + expect(() => guard.canActivate(contextWithAuth())).toThrow(UnauthorizedException); + }); + + it('토큰이 일치하지 않으면 401', () => { + const guard = new AdminGuard('secret'); + expect(() => guard.canActivate(contextWithAuth('Bearer wrong'))).toThrow(UnauthorizedException); + }); + + it('Bearer 토큰이 일치하면 통과한다', () => { + const guard = new AdminGuard('secret'); + expect(guard.canActivate(contextWithAuth('Bearer secret'))).toBe(true); + }); +}); diff --git a/apps/backend/src/reports/interface/guards/admin.guard.ts b/apps/backend/src/reports/interface/guards/admin.guard.ts new file mode 100644 index 0000000..4e6dc0f --- /dev/null +++ b/apps/backend/src/reports/interface/guards/admin.guard.ts @@ -0,0 +1,65 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Inject, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; + +/** + * AdminGuard 가 주입받는 관리자 토큰 값의 DI 토큰. + * ReportsModule 이 `resolveAdminConfig()` 결과(token | null)를 이 토큰으로 제공한다. + */ +export const ADMIN_API_TOKEN = Symbol('ADMIN_API_TOKEN'); + +/** + * 관리자 전용 엔드포인트(회의록 재요약 등)를 단일 운영자 시크릿으로 보호하는 Guard. + * + * `Authorization: Bearer ` 헤더를 검증한다. + * - 서버에 토큰이 설정돼 있지 않으면(`token === null`) 엔드포인트 자체를 비활성으로 + * 보고 403 으로 막는다(실수로 무방비 노출 방지). + * - 토큰은 있으나 요청 헤더가 없거나 일치하지 않으면 401. + * + * 토큰 값은 `resolveAdminConfig` 가 env 에서 읽어 주입한다(ReportsModule useFactory). + */ +@Injectable() +export class AdminGuard implements CanActivate { + constructor(@Inject(ADMIN_API_TOKEN) private readonly token: string | null) {} + + canActivate(context: ExecutionContext): boolean { + if (this.token === null) { + throw new ForbiddenException( + '관리자 엔드포인트가 비활성화되어 있습니다(ADMIN_API_TOKEN 미설정).', + ); + } + const request = context.switchToHttp().getRequest<{ headers: Record }>(); + const provided = extractBearerToken(request.headers.authorization); + if (provided === null || !safeTokenEqual(provided, this.token)) { + throw new UnauthorizedException('유효한 관리자 토큰이 필요합니다.'); + } + return true; + } +} + +/** `Authorization: Bearer ` 헤더에서 토큰만 추출한다. 형식이 아니면 null. */ +export function extractBearerToken(header: unknown): string | null { + if (typeof header !== 'string') return null; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + return match ? match[1].trim() : null; +} + +/** + * 타이밍 공격을 막기 위한 상수 시간 토큰 비교. + * + * 두 토큰을 먼저 SHA-256(고정 32바이트)으로 해시한 뒤 비교한다. 길이가 다른 + * 입력에서도 항상 같은 길이 버퍼를 비교하므로 timingSafeEqual 예외를 피하고, + * 길이 차이로 토큰 길이가 노출되는 타이밍 누출도 없앤다. + */ +export function safeTokenEqual(a: string, b: string): boolean { + const hashA = createHash('sha256').update(a).digest(); + const hashB = createHash('sha256').update(b).digest(); + return timingSafeEqual(hashA, hashB); +} diff --git a/apps/backend/src/reports/reports.module.ts b/apps/backend/src/reports/reports.module.ts index 0e64ad2..38e52b6 100644 --- a/apps/backend/src/reports/reports.module.ts +++ b/apps/backend/src/reports/reports.module.ts @@ -1,5 +1,6 @@ import { Logger, Module } from '@nestjs/common'; +import { resolveAdminConfig } from '@/config/admin.config'; import { resolveGeminiConfig } from '@/config/gemini.config'; import { ReportFinalizationService } from '@/reports/application/report-finalization.service'; import { ReportMeetingLifecycleListener } from '@/reports/application/report-meeting-lifecycle.listener'; @@ -11,6 +12,7 @@ import { NoopNotion } from '@/reports/infrastructure/noop.notion'; import { NoopSummarizer } from '@/reports/infrastructure/noop.summarizer'; import { UuidReportIdGenerator } from '@/reports/infrastructure/uuid-report-id.generator'; import { ReportsController } from '@/reports/interface/controllers/reports.controller'; +import { ADMIN_API_TOKEN,AdminGuard } from '@/reports/interface/guards/admin.guard'; import { NestEventBusDomainEventPublisher } from '@/shared-kernel/infrastructure/nest-event-bus.publisher'; import { SystemClock } from '@/shared-kernel/infrastructure/system.clock'; @@ -37,6 +39,13 @@ import { SystemClock } from '@/shared-kernel/infrastructure/system.clock'; UuidReportIdGenerator, ReportMeetingLifecycleListener, ReportPipelineListener, + AdminGuard, + { + // 관리자 재요약 엔드포인트 보호. ADMIN_API_TOKEN 미설정 시 token=null → + // AdminGuard 가 엔드포인트를 비활성(403)으로 막는다. + provide: ADMIN_API_TOKEN, + useFactory: (): string | null => resolveAdminConfig()?.token ?? null, + }, { provide: GeminiSummarizer, useFactory: (): SummarizerPort => { diff --git a/apps/backend/test/reports.e2e-spec.ts b/apps/backend/test/reports.e2e-spec.ts index 5ad60da..658b311 100644 --- a/apps/backend/test/reports.e2e-spec.ts +++ b/apps/backend/test/reports.e2e-spec.ts @@ -65,12 +65,48 @@ const waitForFinalizedReport = async ( throw new Error(`Report for code ${code} did not finalize within ${timeoutMs}ms`); }; +/** 회의 생성→채팅→종료→파이프라인 finalize 까지 진행하고 회의록 목록 항목을 돌려준다. */ +const createFinalizedReport = async ( + httpServer: ReturnType, + baseUrl: string, +): Promise => { + const created = await request(httpServer) + .post('/meetings') + .send({ source: 'web' }) + .expect(201); + const createdBody = created.body as CreateMeetingResponse; + const code = createdBody.code; + + const client = await connectClient(baseUrl); + try { + client.emit(MEETING_WS_EVENTS.JOIN, { code, nickname: 'alice' }); + await new Promise((r) => setTimeout(r, 30)); + client.emit(MEETING_WS_EVENTS.CHAT, { code, text: '회의 시작' }); + await new Promise((r) => setTimeout(r, 30)); + } finally { + client.disconnect(); + } + + await request(httpServer) + .delete(`/meetings/${code}`) + .set('x-host-token', createdBody.hostToken) + .expect(200); + + return waitForFinalizedReport(httpServer, code); +}; + describe('Reports e2e', () => { let app: INestApplication; let baseUrl: string; let httpServer: ReturnType; + // 재요약 엔드포인트는 ADMIN_API_TOKEN 으로 보호된다. AdminGuard 가 모듈 초기화 + // 시점에 env 를 읽으므로 테스트 모듈 생성 전에 토큰을 심는다. + const ADMIN_TOKEN = 'e2e-admin-token'; + let prevAdminToken: string | undefined; beforeAll(async () => { + prevAdminToken = process.env.ADMIN_API_TOKEN; + process.env.ADMIN_API_TOKEN = ADMIN_TOKEN; const moduleRef: TestingModule = await Test.createTestingModule({ imports: [AppModule], }) @@ -102,6 +138,8 @@ describe('Reports e2e', () => { afterAll(async () => { await app.close(); + if (prevAdminToken === undefined) delete process.env.ADMIN_API_TOKEN; + else process.env.ADMIN_API_TOKEN = prevAdminToken; }); it('회의 생성→채팅→종료 후 회의록이 목록/상세에서 노출된다', async () => { @@ -171,4 +209,39 @@ describe('Reports e2e', () => { await request(httpServer).get('/reports?limit=999').expect(400); await request(httpServer).get('/reports?limit=abc').expect(400); }); + + describe('POST /reports/:id/resummarize (관리자 재요약)', () => { + it('Authorization 헤더가 없으면 401', async () => { + const report = await createFinalizedReport(httpServer, baseUrl); + await request(httpServer).post(`/reports/${report.id}/resummarize`).expect(401); + }); + + it('잘못된 토큰이면 401', async () => { + const report = await createFinalizedReport(httpServer, baseUrl); + await request(httpServer) + .post(`/reports/${report.id}/resummarize`) + .set('Authorization', 'Bearer wrong-token') + .expect(401); + }); + + it('올바른 Bearer 토큰이면 200 으로 재요약된 상세를 돌려준다', async () => { + const report = await createFinalizedReport(httpServer, baseUrl); + const res = await request(httpServer) + .post(`/reports/${report.id}/resummarize`) + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .expect(200); + const body = res.body as ReportDetailResponse; + expect(body.id).toBe(report.id); + // NoopSummarizer 가 placeholder 요약을 다시 채워 summary stage 는 done 유지. + expect(body.pipeline.summaryStatus).toBe('done'); + expect(body.summary?.title).toBe('(요약 미적용)'); + }); + + it('존재하지 않는 report id 는 토큰이 맞아도 404', async () => { + await request(httpServer) + .post('/reports/unknown-id/resummarize') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .expect(404); + }); + }); });