diff --git a/apps/mobile-rn/__tests__/linked-audio-cleanup-redteam-r2-28.test.ts b/apps/mobile-rn/__tests__/linked-audio-cleanup-redteam-r2-28.test.ts new file mode 100644 index 0000000..b520f98 --- /dev/null +++ b/apps/mobile-rn/__tests__/linked-audio-cleanup-redteam-r2-28.test.ts @@ -0,0 +1,254 @@ +jest.mock('react-native-keychain', () => ({ + ACCESSIBLE: { AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'device-only' }, + SECURITY_LEVEL: { SECURE_SOFTWARE: 1 }, + STORAGE_TYPE: { AES_GCM_NO_AUTH: 'aes-gcm' }, + getGenericPassword: jest.fn().mockResolvedValue(false), + setGenericPassword: jest.fn(), + resetGenericPassword: jest.fn(), + getAllGenericPasswordServices: jest.fn(), +})) + +interface RecordedQuery { + table: string + calls: Array<[string, unknown[]]> +} + +type QueryResult = { data: unknown; error: unknown } + +const mockQueries: RecordedQuery[] = [] +let mockResults: QueryResult[] = [] +const mockStorageRemove = jest.fn() + +function mockBuilder(table: string): Record { + const recorded: RecordedQuery = { table, calls: [] } + mockQueries.push(recorded) + const resolve = (): QueryResult => mockResults.shift() ?? { data: null, error: null } + const builder: Record = {} + for (const method of ['select', 'update', 'delete', 'eq', 'neq', 'in', 'is', 'order', 'limit']) { + builder[method] = (...args: unknown[]) => { + recorded.calls.push([method, args]) + return builder + } + } + builder.maybeSingle = async () => { + recorded.calls.push(['maybeSingle', []]) + return resolve() + } + builder.then = ( + onFulfilled: (value: QueryResult) => unknown, + onRejected?: (reason: unknown) => unknown, + ) => Promise.resolve(resolve()).then(onFulfilled, onRejected) + return builder +} + +jest.mock('../src/lib/supabase', () => ({ + supabase: { + from: jest.fn((table: string) => mockBuilder(table)), + storage: { from: jest.fn(() => ({ remove: mockStorageRemove })) }, + channel: jest.fn(), + removeChannel: jest.fn(), + }, +})) + +import type { Meeting } from '@d3ro/api-client' +import { + createSupabaseLinkedAudioStore, + purgeUnlinkedAudio, + selectRemovableStorageKeys, + type LinkedAudioRef, + type LinkedAudioStore, +} from '../src/features/audio/linked-audio-cleanup' +import { + deleteHistoryRevisionSafe, + HistoryServiceError, +} from '../src/features/history/history-service' +import { deleteMeetingRevisionSafe } from '../src/features/meetings/meetings-service' + +const USER_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_USER = '33333333-3333-4333-8333-333333333333' +const HISTORY_ID = '22222222-2222-4222-8222-222222222222' +const MEETING_ID = '44444444-4444-4444-8444-444444444444' +const AUDIO_ID = '55555555-5555-4555-8555-555555555555' +const SHA = 'a'.repeat(64) +const KEY = `${USER_ID}/imports/${SHA}/voice.m4a` + +function audio(overrides: Partial = {}): LinkedAudioRef { + return { id: AUDIO_ID, storage_key: KEY, upload_status: 'uploaded', ...overrides } +} + +function fakeStore(linked: LinkedAudioRef[]): jest.Mocked { + return { + findLinked: jest.fn(async () => linked), + claimUnlinked: jest.fn(async (_userId: string, ids: string[]) => + linked + .filter((row) => ids.includes(row.id)) + .map((row) => ({ ...row, upload_status: 'deleted' as const }))), + findReferencedKeys: jest.fn(async () => []), + removeObjects: jest.fn(async () => undefined), + deleteRows: jest.fn(async () => undefined), + restoreStatus: jest.fn(async () => undefined), + } +} + +function meeting(overrides: Partial = {}): Meeting { + return { + id: MEETING_ID, + user_id: USER_ID, + team_id: null, + title: 'Weekly sync', + status: 'completed', + started_at: '2026-08-21T00:00:00.000Z', + ended_at: '2026-08-21T01:00:00.000Z', + duration_ms: 3_600_000, + raw_transcript: null, + edited_transcript: null, + minutes_markdown: null, + minutes_json: null, + stt_model: 'whisper', + llm_model: null, + stt_latency_ms: null, + llm_latency_ms: null, + error_message: null, + audio_storage_key: KEY, + created_at: '2026-08-21T00:00:00.000Z', + updated_at: '2026-08-21T01:00:00.000Z', + ...overrides, + } +} + +beforeEach(() => { + mockQueries.length = 0 + mockResults = [] + mockStorageRemove.mockReset() +}) + +describe('history delete removes the uploaded original audio', () => { + test('deleting a history entry removes its storage object and audio_files row', async () => { + const store = fakeStore([audio()]) + mockResults = [{ data: { id: HISTORY_ID }, error: null }] + + await deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store) + + expect(store.findLinked).toHaveBeenCalledWith(USER_ID, 'history_id', HISTORY_ID) + expect(store.claimUnlinked).toHaveBeenCalledWith(USER_ID, [AUDIO_ID]) + expect(store.removeObjects).toHaveBeenCalledWith([KEY]) + expect(store.deleteRows).toHaveBeenCalledWith(USER_ID, [AUDIO_ID]) + // 스냅샷은 부모 삭제 전에 찍어야 한다(삭제 후엔 history_id 가 NULL 이 된다). + expect(store.findLinked.mock.invocationCallOrder[0]) + .toBeLessThan(store.claimUnlinked.mock.invocationCallOrder[0]) + }) + + test('a revision conflict leaves the audio untouched', async () => { + const store = fakeStore([audio()]) + mockResults = [{ data: null, error: null }] + + await expect(deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store)) + .rejects.toMatchObject({ code: 'conflict' }) + expect(store.claimUnlinked).not.toHaveBeenCalled() + expect(store.removeObjects).not.toHaveBeenCalled() + expect(store.deleteRows).not.toHaveBeenCalled() + }) + + test('a failed audio lookup aborts the delete instead of orphaning audio', async () => { + const store = fakeStore([audio()]) + store.findLinked.mockRejectedValueOnce(new TypeError('Network request failed')) + + await expect(deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store)) + .rejects.toBeInstanceOf(HistoryServiceError) + expect(mockQueries.filter((query) => query.table === 'history')).toHaveLength(0) + }) + + test('audio cleanup failure does not turn a completed delete into an error', async () => { + const store = fakeStore([audio()]) + store.removeObjects.mockRejectedValueOnce(new Error('storage unavailable')) + mockResults = [{ data: { id: HISTORY_ID }, error: null }] + + await expect(deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store)).resolves.toBeUndefined() + // 저장소 삭제 실패 시 선점을 되돌려 같은 파일 재가져오기가 막히지 않게 한다. + expect(store.restoreStatus).toHaveBeenCalledWith(USER_ID, AUDIO_ID, 'uploaded') + expect(store.deleteRows).not.toHaveBeenCalled() + }) +}) + +describe('meeting delete removes the uploaded original audio', () => { + test('deleting a meeting removes its storage object and audio_files row', async () => { + const store = fakeStore([audio()]) + mockResults = [{ data: { id: MEETING_ID }, error: null }] + + await deleteMeetingRevisionSafe(USER_ID, meeting(), store) + + expect(store.findLinked).toHaveBeenCalledWith(USER_ID, 'meeting_id', MEETING_ID) + expect(store.removeObjects).toHaveBeenCalledWith([KEY]) + expect(store.deleteRows).toHaveBeenCalledWith(USER_ID, [AUDIO_ID]) + }) + + test('a meeting conflict leaves the audio untouched', async () => { + const store = fakeStore([audio()]) + mockResults = [{ data: null, error: null }] + + await expect(deleteMeetingRevisionSafe(USER_ID, meeting(), store)) + .rejects.toMatchObject({ code: 'conflict' }) + expect(store.removeObjects).not.toHaveBeenCalled() + }) +}) + +describe('purgeUnlinkedAudio policy', () => { + test('rows re-linked to another owner before cleanup are not removed', async () => { + const store = fakeStore([audio()]) + store.claimUnlinked.mockResolvedValueOnce([]) + + const result = await purgeUnlinkedAudio(store, USER_ID, [audio()]) + + expect(result).toEqual({ removedKeys: [], deletedRowIds: [], failed: false }) + expect(store.removeObjects).not.toHaveBeenCalled() + }) + + test('a storage key still referenced by another row is kept', async () => { + const store = fakeStore([audio()]) + store.findReferencedKeys.mockResolvedValueOnce([KEY]) + + const result = await purgeUnlinkedAudio(store, USER_ID, [audio()]) + + expect(store.removeObjects).not.toHaveBeenCalled() + expect(result.deletedRowIds).toEqual([AUDIO_ID]) + }) + + test('selectRemovableStorageKeys keeps only own, unreferenced, unique keys', () => { + const rows = [ + audio(), + audio({ id: 'b' }), + audio({ id: 'c', storage_key: `${OTHER_USER}/imports/${SHA}/x.m4a` }), + audio({ id: 'd', storage_key: `${USER_ID}/../${OTHER_USER}/x.m4a` }), + audio({ id: 'e', storage_key: `${USER_ID}/imports/${'b'.repeat(64)}/y.m4a` }), + ] + expect(selectRemovableStorageKeys(USER_ID, rows, [`${USER_ID}/imports/${'b'.repeat(64)}/y.m4a`])) + .toEqual([KEY]) + }) +}) + +describe('Supabase linked audio adapter', () => { + test('claims only rows that are still unlinked, scoped to the user', async () => { + mockResults = [{ data: [audio({ upload_status: 'deleted' })], error: null }] + const claimed = await createSupabaseLinkedAudioStore().claimUnlinked(USER_ID, [AUDIO_ID]) + + expect(claimed).toEqual([audio({ upload_status: 'deleted' })]) + const query = mockQueries[0] + expect(query.table).toBe('audio_files') + expect(query.calls).toEqual(expect.arrayContaining([ + ['update', [{ upload_status: 'deleted' }]], + ['eq', ['user_id', USER_ID]], + ['in', ['id', [AUDIO_ID]]], + ['is', ['history_id', null]], + ['is', ['meeting_id', null]], + ])) + }) + + test('removes objects from the audio bucket and surfaces storage errors', async () => { + mockStorageRemove.mockResolvedValueOnce({ data: [], error: null }) + await createSupabaseLinkedAudioStore().removeObjects([KEY]) + expect(mockStorageRemove).toHaveBeenCalledWith([KEY]) + + mockStorageRemove.mockResolvedValueOnce({ data: null, error: new Error('denied') }) + await expect(createSupabaseLinkedAudioStore().removeObjects([KEY])).rejects.toThrow('denied') + }) +}) diff --git a/apps/mobile-rn/src/features/audio/linked-audio-cleanup.ts b/apps/mobile-rn/src/features/audio/linked-audio-cleanup.ts new file mode 100644 index 0000000..65d0c11 --- /dev/null +++ b/apps/mobile-rn/src/features/audio/linked-audio-cleanup.ts @@ -0,0 +1,210 @@ +import type { AudioFileUploadStatus, D3roSupabaseClient } from '@d3ro/api-client' +import { supabase } from '../../lib/supabase' + +/** + * 기록·회의를 지울 때 거기에 연결된 원본 음성(audio 버킷 객체 + audio_files 행)을 함께 정리한다. + * + * audio_files.history_id / meeting_id 는 ON DELETE SET NULL 이라 부모 행만 지우면 음성이 + * 고아가 되어 계정 삭제 전까지 클라우드에 남는다. 그래서 부모를 지우기 "전에" 연결된 음성을 + * 스냅샷해 두고, 부모 삭제가 성공한 "뒤에만" 정리한다(리비전 충돌로 삭제가 거부되면 음성은 + * 건드리지 않는다). + * + * 정리 순서: (1) 여전히 부모가 없는 행만 'deleted' 로 선점 → (2) 저장소 객체 삭제 → + * (3) 행 삭제. 선점 덕분에 같은 파일 재가져오기(sha256 재사용)가 지워질 행을 집어 가지 않고, + * 그 사이 다른 기록/회의에 다시 연결된 행은 선점 조건에서 빠진다. 저장소 삭제가 실패하면 + * 선점을 원래 상태로 되돌려 재가져오기 경로가 막히지 않게 한다. 행 저장 키는 결정적 + * (`/imports//`)이라 'deleted' 행이 남으면 같은 파일을 다시 올릴 수 없기 때문이다. + */ + +export type AudioOwnerColumn = 'history_id' | 'meeting_id' + +export interface LinkedAudioRef { + id: string + storage_key: string + upload_status: AudioFileUploadStatus +} + +/** audio_files / audio 버킷 IO 포트. 서비스는 이 인터페이스에만 의존한다. */ +export interface LinkedAudioStore { + /** 부모에 연결되어 있고 아직 삭제 표시되지 않은 음성 행. */ + findLinked(userId: string, column: AudioOwnerColumn, ownerId: string): Promise + /** 여전히 어떤 부모에도 연결되지 않은 행만 'deleted' 로 표시하고, 표시된 행을 돌려준다. */ + claimUnlinked(userId: string, ids: string[]): Promise + /** 주어진 키 중 다른(삭제 표시되지 않은) 행이 아직 참조하는 저장 키. */ + findReferencedKeys(userId: string, keys: string[], excludeIds: string[]): Promise + removeObjects(keys: string[]): Promise + deleteRows(userId: string, ids: string[]): Promise + restoreStatus(userId: string, id: string, status: AudioFileUploadStatus): Promise +} + +export interface LinkedAudioCleanupResult { + removedKeys: string[] + deletedRowIds: string[] + failed: boolean +} + +/** 정리 대상 저장 키 선택(순수 정책): 본인 경로이고, 다른 행이 참조하지 않는 키만, 중복 없이. */ +export function selectRemovableStorageKeys( + userId: string, + claimed: readonly LinkedAudioRef[], + stillReferenced: readonly string[], +): string[] { + const referenced = new Set(stillReferenced) + const prefix = `${userId}/` + const keys = new Set() + for (const row of claimed) { + const key = row.storage_key + if (key.length <= prefix.length || !key.startsWith(prefix)) continue + if (key.includes('..')) continue + if (referenced.has(key)) continue + keys.add(key) + } + return [...keys] +} + +/** 부모 삭제 전에 호출한다. 읽기 실패는 그대로 던져 부모 삭제도 하지 않게 한다. */ +export async function snapshotLinkedAudio( + store: LinkedAudioStore, + userId: string, + column: AudioOwnerColumn, + ownerId: string, +): Promise { + return store.findLinked(userId, column, ownerId) +} + +/** + * 부모 삭제가 성공한 뒤 호출한다. 부모는 이미 지워졌으므로 이 단계의 실패는 삭제 자체를 + * 실패로 돌리지 않는다(best effort) — 결과의 failed 로만 알린다. + */ +export async function purgeUnlinkedAudio( + store: LinkedAudioStore, + userId: string, + snapshot: readonly LinkedAudioRef[], +): Promise { + const result: LinkedAudioCleanupResult = { removedKeys: [], deletedRowIds: [], failed: false } + const ids = [...new Set(snapshot.map((row) => row.id))] + if (ids.length === 0) return result + + let claimed: LinkedAudioRef[] + try { + claimed = await store.claimUnlinked(userId, ids) + } catch { + result.failed = true + return result + } + if (claimed.length === 0) return result + + const previousStatus = new Map(snapshot.map((row) => [row.id, row.upload_status])) + const claimedIds = claimed.map((row) => row.id) + + try { + const referenced = await store.findReferencedKeys( + userId, + [...new Set(claimed.map((row) => row.storage_key))], + claimedIds, + ) + const keys = selectRemovableStorageKeys(userId, claimed, referenced) + if (keys.length > 0) await store.removeObjects(keys) + result.removedKeys = keys + } catch { + result.failed = true + for (const row of claimed) { + const status = previousStatus.get(row.id) + if (status === undefined || status === 'deleted') continue + try { + await store.restoreStatus(userId, row.id, status) + } catch { + // 되돌리기 실패 시 행은 'deleted' 로 남아 이후 정리 대상임을 표시한다. + } + } + return result + } + + try { + await store.deleteRows(userId, claimedIds) + result.deletedRowIds = claimedIds + } catch { + // 객체는 이미 지워졌고 행은 'deleted' 로 남아 UI·재사용 조회에서 제외된다. + result.failed = true + } + return result +} + +function database(): D3roSupabaseClient { + return supabase as unknown as D3roSupabaseClient +} + +const AUDIO_BUCKET = 'audio' + +function toRefs(rows: ReadonlyArray<{ id: string; storage_key: string; upload_status: AudioFileUploadStatus }> | null): LinkedAudioRef[] { + return (rows ?? []).map((row) => ({ + id: row.id, + storage_key: row.storage_key, + upload_status: row.upload_status, + })) +} + +/** Supabase 어댑터. RLS(audio_files_*_own, audio_*_own)가 본인 행·경로로 한정한다. */ +export function createSupabaseLinkedAudioStore(): LinkedAudioStore { + return { + async findLinked(userId, column, ownerId) { + const { data, error } = await database() + .from('audio_files') + .select('id, storage_key, upload_status') + .eq('user_id', userId) + .eq(column, ownerId) + .neq('upload_status', 'deleted') + if (error !== null) throw error + return toRefs(data) + }, + async claimUnlinked(userId, ids) { + const { data, error } = await database() + .from('audio_files') + .update({ upload_status: 'deleted' }) + .eq('user_id', userId) + .in('id', ids) + .is('history_id', null) + .is('meeting_id', null) + .neq('upload_status', 'deleted') + .select('id, storage_key, upload_status') + if (error !== null) throw error + return toRefs(data) + }, + async findReferencedKeys(userId, keys, excludeIds) { + if (keys.length === 0) return [] + const { data, error } = await database() + .from('audio_files') + .select('id, storage_key') + .eq('user_id', userId) + .in('storage_key', keys) + .neq('upload_status', 'deleted') + if (error !== null) throw error + const excluded = new Set(excludeIds) + return (data ?? []) + .filter((row) => !excluded.has(row.id)) + .map((row) => row.storage_key) + }, + async removeObjects(keys) { + const { error } = await supabase.storage.from(AUDIO_BUCKET).remove(keys) + if (error !== null) throw error + }, + async deleteRows(userId, ids) { + const { error } = await database() + .from('audio_files') + .delete() + .eq('user_id', userId) + .in('id', ids) + .eq('upload_status', 'deleted') + if (error !== null) throw error + }, + async restoreStatus(userId, id, status) { + const { error } = await database() + .from('audio_files') + .update({ upload_status: status }) + .eq('user_id', userId) + .eq('id', id) + .eq('upload_status', 'deleted') + if (error !== null) throw error + }, + } +} diff --git a/apps/mobile-rn/src/features/history/history-service.ts b/apps/mobile-rn/src/features/history/history-service.ts index 8c34b64..96f107e 100644 --- a/apps/mobile-rn/src/features/history/history-service.ts +++ b/apps/mobile-rn/src/features/history/history-service.ts @@ -8,6 +8,13 @@ import type { ProcessingJob, } from '@d3ro/api-client' import { supabase } from '../../lib/supabase' +import { + createSupabaseLinkedAudioStore, + purgeUnlinkedAudio, + snapshotLinkedAudio, + type LinkedAudioRef, + type LinkedAudioStore, +} from '../audio/linked-audio-cleanup' export type HistoryFilter = 'all' | 'favorites' | 'processing' @@ -306,16 +313,29 @@ export async function updateHistoryRevisionSafe( } } +/** + * 리비전이 일치할 때만 기록을 지우고, 연결된 원본 음성(audio_files + audio 버킷)도 정리한다. + * audio_files.history_id 는 ON DELETE SET NULL 이라 기록만 지우면 음성이 고아로 남기 때문에 + * 삭제 전에 연결을 스냅샷하고, 삭제가 성공한 뒤에만 정리한다. + */ export async function deleteHistoryRevisionSafe( userId: string, historyId: string, expectedRevision: number, + audioStore: LinkedAudioStore = createSupabaseLinkedAudioStore(), ): Promise { requireUserId(userId) if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) { throw new HistoryServiceError('validation', 'Expected revision is invalid') } + let linkedAudio: LinkedAudioRef[] + try { + linkedAudio = await snapshotLinkedAudio(audioStore, userId, 'history_id', historyId) + } catch (error) { + throw historyError(error) + } + try { const { data, error } = await client() .from('history') @@ -336,6 +356,8 @@ export async function deleteHistoryRevisionSafe( } catch (error) { throw historyError(error) } + + await purgeUnlinkedAudio(audioStore, userId, linkedAudio) } export async function createAudioPlaybackUrl( diff --git a/apps/mobile-rn/src/features/meetings/meetings-service.ts b/apps/mobile-rn/src/features/meetings/meetings-service.ts index ae8c432..4cc19a5 100644 --- a/apps/mobile-rn/src/features/meetings/meetings-service.ts +++ b/apps/mobile-rn/src/features/meetings/meetings-service.ts @@ -10,6 +10,13 @@ import type { } from '@d3ro/api-client' import { supabase } from '../../lib/supabase' import { createUuidV4 } from '../../lib/random-id' +import { + createSupabaseLinkedAudioStore, + purgeUnlinkedAudio, + snapshotLinkedAudio, + type LinkedAudioRef, + type LinkedAudioStore, +} from '../audio/linked-audio-cleanup' export interface MeetingListOptions { userId: string @@ -515,9 +522,14 @@ export async function updateMeetingTitleRevisionSafe( } } +/** + * 변경 시각이 일치할 때만 회의를 지우고, 연결된 원본 음성(audio_files + audio 버킷)도 정리한다. + * audio_files.meeting_id 는 ON DELETE SET NULL 이라 회의만 지우면 음성이 고아로 남는다. + */ export async function deleteMeetingRevisionSafe( userId: string, current: Meeting, + audioStore: LinkedAudioStore = createSupabaseLinkedAudioStore(), ): Promise { requireUuid(userId, 'authenticated user') assertMeetingRow(current) @@ -525,6 +537,13 @@ export async function deleteMeetingRevisionSafe( throw new MeetingServiceError('forbidden', 'Only the meeting owner can delete it') } + let linkedAudio: LinkedAudioRef[] + try { + linkedAudio = await snapshotLinkedAudio(audioStore, userId, 'meeting_id', current.id) + } catch (error) { + throw toMeetingServiceError(error) + } + try { const { data, error } = await client() .from('meetings') @@ -544,6 +563,8 @@ export async function deleteMeetingRevisionSafe( } catch (error) { throw toMeetingServiceError(error) } + + await purgeUnlinkedAudio(audioStore, userId, linkedAudio) } export async function createMeetingMemo(