feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
281
apps/mobile-rn/__tests__/import-audio-validation.test.ts
Normal file
281
apps/mobile-rn/__tests__/import-audio-validation.test.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue