281 lines
9.2 KiB
TypeScript
281 lines
9.2 KiB
TypeScript
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',
|
|
});
|
|
});
|
|
});
|
|
});
|