import { FileSystem } from 'react-native-file-access'; import { buildAudioStorageKey, countTranscriptWords, decodeBase64, detectAudioMimeType, localPathFromFileUri, normalizeAudioMimeType, sanitizeAudioFileName, validateLocalAudio, } from '../src/features/import/audio-validation'; import { AudioPipelineError, MAX_IMPORT_BYTES, } from '../src/features/import/audio-import-types'; import { createMultipartAudioBody } from '../src/features/import/multipart-audio'; function bytes(...values: number[]): Uint8Array { return Uint8Array.from(values); } function ascii(value: string): number[] { return [...value].map(character => character.charCodeAt(0)); } function base64(value: Uint8Array): string { return Buffer.from(value).toString('base64'); } describe('audio import validation', () => { test('decodes padded and unpadded-content base64 exactly', () => { expect([...decodeBase64('AQIDBA==')]).toEqual([1, 2, 3, 4]); expect([...decodeBase64('AAEC')]).toEqual([0, 1, 2]); expect([...decodeBase64('')]).toEqual([]); }); test.each(['abc', 'AA=A', 'AA?=', '===='])( 'rejects malformed base64 %s', value => { expect(() => decodeBase64(value)).toThrow(AudioPipelineError); }, ); test.each([ ['audio/vnd.wave', 'audio/wav'], ['Audio/MP3; charset=binary', 'audio/mpeg'], ['audio/m4a', 'audio/mp4'], ['audio/x-flac', 'audio/flac'], ['application/pdf', null], [null, null], ])('normalizes MIME %s to %s', (input, expected) => { expect(normalizeAudioMimeType(input)).toBe(expected); }); test.each([ [bytes(...ascii('RIFF'), 1, 2, 3, 4, ...ascii('WAVE')), 'audio/wav'], [bytes(...ascii('fLaC')), 'audio/flac'], [bytes(...ascii('OggS')), 'audio/ogg'], [bytes(0x1a, 0x45, 0xdf, 0xa3), 'audio/webm'], [bytes(0, 0, 0, 20, ...ascii('ftyp'), 0, 0, 0, 0), 'audio/mp4'], [bytes(...ascii('ID3')), 'audio/mpeg'], [bytes(0xff, 0xf1), 'audio/aac'], [bytes(0xff, 0xfb), 'audio/mpeg'], [bytes(...ascii('PK\u0003\u0004')), null], ])('detects audio header as %s', (header, expected) => { expect(detectAudioMimeType(header)).toBe(expected); }); test('sanitizes file names and supplies a MIME extension', () => { expect(sanitizeAudioFileName('../회의 녹음?.m4a', 'audio/mp4')).toBe('회의-녹음-.m4a'); expect(sanitizeAudioFileName(null, 'audio/wav')).toBe('imported-audio.wav'); expect(sanitizeAudioFileName('voice', 'audio/mpeg')).toBe('voice.mp3'); }); test('builds an owned deterministic storage key', () => { const userId = '123e4567-e89b-42d3-a456-426614174000'; const checksum = 'a'.repeat(64); expect(buildAudioStorageKey(userId, checksum, '회의 녹음.m4a')).toBe( `${userId}/imports/${checksum}/audio.m4a`, ); }); test('rejects invalid ownership and checksum data', () => { expect(() => buildAudioStorageKey('other-user', 'a'.repeat(64), 'a.wav')).toThrow( AudioPipelineError, ); expect(() => buildAudioStorageKey('123e4567-e89b-42d3-a456-426614174000', 'a', 'a.wav')).toThrow( AudioPipelineError, ); }); test('counts whitespace-separated transcript words without inventing content', () => { expect(countTranscriptWords(' 안녕 세상\nhello ')).toBe(3); expect(countTranscriptWords('')).toBe(0); }); test('builds an exact multipart body without changing audio bytes', () => { const boundary = '----D3ROVoiceMobileBoundary12345'; const audio = bytes(0, 1, 2, 13, 10, 255); const multipart = createMultipartAudioBody( { fileName: '회의.wav', mimeType: 'audio/wav' }, audio, 'ko', boundary, ); const body = new Uint8Array(multipart.body); const prefix = Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="__.wav"\r\nContent-Type: audio/wav\r\n\r\n`, 'ascii', ); expect([...body.slice(prefix.length, prefix.length + audio.length)]).toEqual([...audio]); expect(Buffer.from(body).toString('latin1')).toContain( `name="language_code"\r\n\r\nko\r\n--${boundary}--\r\n`, ); }); test('rejects multipart header injection and invalid language codes', () => { expect(() => createMultipartAudioBody( { fileName: 'a.wav', mimeType: 'text/plain' }, bytes(1), 'ko', '----D3ROVoiceMobileBoundary12345', )).toThrow(AudioPipelineError); expect(() => createMultipartAudioBody( { fileName: 'a.wav', mimeType: 'audio/wav' }, bytes(1), 'ko\r\nadmin', '----D3ROVoiceMobileBoundary12345', )).toThrow(AudioPipelineError); }); test('accepts every meeting language supported by the recording queue', () => { for (const languageCode of ['ko', 'en', 'ja', 'zh-cn']) { expect(() => createMultipartAudioBody( { fileName: 'voice.wav', mimeType: 'audio/wav' }, Uint8Array.from([1, 2, 3]), languageCode, '--------------------d3ro-language', )).not.toThrow(); } }); test('accepts only absolute file URIs', () => { expect(localPathFromFileUri('file:///data/user/0/cache/a%20b.wav')).toBe( '/data/user/0/cache/a b.wav', ); expect(() => localPathFromFileUri('content://downloads/1')).toThrow( AudioPipelineError, ); }); describe('local file inspection', () => { const mockedFileSystem = FileSystem as unknown as { stat: jest.Mock; readFileChunk: jest.Mock; }; const dispose = jest.fn(async () => undefined); beforeEach(() => { jest.clearAllMocks(); mockedFileSystem.stat = jest.fn(async () => ({ filename: 'voice.wav', lastModified: 1, path: '/cache/voice.wav', size: 12, type: 'file', })); mockedFileSystem.readFileChunk = jest.fn(async () => base64( bytes(...ascii('RIFF'), 1, 2, 3, 4, ...ascii('WAVE')), )); }); test('returns authoritative stat and normalized MIME data', async () => { await expect(validateLocalAudio({ path: '/cache/voice.wav', uri: 'file:///cache/voice.wav', fileName: 'voice.wav', reportedMimeType: 'audio/vnd.wave', reportedSize: 12, durationMs: null, source: 'file-picker', dispose, })).resolves.toMatchObject({ sizeBytes: 12, mimeType: 'audio/wav', fileName: 'voice.wav', }); }); test('rejects an empty file', async () => { mockedFileSystem.stat.mockResolvedValue({ type: 'file', size: 0 }); await expect(validateLocalAudio({ path: '/cache/empty.wav', uri: 'file:///cache/empty.wav', fileName: 'empty.wav', reportedMimeType: 'audio/wav', reportedSize: 0, durationMs: null, source: 'file-picker', dispose, })).rejects.toMatchObject({ code: 'empty-file' }); }); test('rejects a file above the mobile memory-safe limit', async () => { mockedFileSystem.stat.mockResolvedValue({ type: 'file', size: MAX_IMPORT_BYTES + 1, }); await expect(validateLocalAudio({ path: '/cache/large.wav', uri: 'file:///cache/large.wav', fileName: 'large.wav', reportedMimeType: 'audio/wav', reportedSize: MAX_IMPORT_BYTES + 1, durationMs: null, source: 'file-picker', dispose, })).rejects.toMatchObject({ code: 'file-too-large' }); }); test('rejects metadata changes during copy', async () => { await expect(validateLocalAudio({ path: '/cache/changed.wav', uri: 'file:///cache/changed.wav', fileName: 'changed.wav', reportedMimeType: 'audio/wav', reportedSize: 99, durationMs: null, source: 'file-picker', dispose, })).rejects.toMatchObject({ code: 'file-read' }); }); test('rejects a renamed non-audio payload', async () => { mockedFileSystem.readFileChunk.mockResolvedValue(base64(bytes(...ascii('PK\u0003\u0004')))); await expect(validateLocalAudio({ path: '/cache/fake.wav', uri: 'file:///cache/fake.wav', fileName: 'fake.wav', reportedMimeType: 'audio/wav', reportedSize: 12, durationMs: null, source: 'file-picker', dispose, })).rejects.toMatchObject({ code: 'unsupported-mime' }); }); test('rejects a conflicting provider MIME', async () => { await expect(validateLocalAudio({ path: '/cache/voice.wav', uri: 'file:///cache/voice.wav', fileName: 'voice.wav', reportedMimeType: 'audio/mpeg', reportedSize: 12, durationMs: null, source: 'file-picker', dispose, })).rejects.toMatchObject({ code: 'unsupported-mime' }); }); test('accepts Android audio/mpeg metadata for a magic-verified M4A container', async () => { mockedFileSystem.readFileChunk.mockResolvedValue(base64( bytes(0, 0, 0, 20, ...ascii('ftyp'), 0, 0, 0, 0), )); await expect(validateLocalAudio({ path: '/cache/voice.m4a', uri: 'file:///cache/voice.m4a', fileName: 'voice.m4a', reportedMimeType: 'audio/mpeg', reportedSize: 12, durationMs: null, source: 'file-picker', dispose, })).resolves.toMatchObject({ sizeBytes: 12, mimeType: 'audio/mp4', fileName: 'voice.m4a', }); }); }); });