122 lines
3.4 KiB
TypeScript
122 lines
3.4 KiB
TypeScript
import {
|
|
createPortableArchive,
|
|
parsePortableArchive,
|
|
parsePortablePayload,
|
|
serializePortableArchive,
|
|
stableJsonStringify,
|
|
type PortablePayload,
|
|
} from '../src/features/data-portability';
|
|
|
|
const OWNER = '4f642f87-2d12-4dcf-9aa1-f4427d4078a8';
|
|
|
|
function payload(): PortablePayload {
|
|
return {
|
|
format: 'd3ro-account-portability',
|
|
schema_version: 1,
|
|
exported_at: '2026-08-21T00:00:00.000Z',
|
|
owner_id: OWNER,
|
|
source: 'mobile',
|
|
account: { profile: null, settings: null, subscription: null },
|
|
datasets: {
|
|
dictionary: [],
|
|
history: [],
|
|
meetings: [],
|
|
transcripts: [],
|
|
meeting_memos: [],
|
|
meeting_documents: [],
|
|
custom_instructions: [],
|
|
},
|
|
exclusions: [
|
|
'raw_audio',
|
|
'storage_objects',
|
|
'payment_credentials',
|
|
'push_tokens',
|
|
],
|
|
};
|
|
}
|
|
|
|
describe('data portability archive schema', () => {
|
|
it('round-trips the strict versioned archive and checksum', () => {
|
|
const canonical = stableJsonStringify(payload());
|
|
const archive = createPortableArchive(canonical);
|
|
const parsed = parsePortableArchive(
|
|
serializePortableArchive(archive),
|
|
OWNER,
|
|
);
|
|
|
|
expect(parsed.archive.checksum).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(parsed.payload).toEqual(payload());
|
|
});
|
|
|
|
it('rejects a checksum-tampered payload', () => {
|
|
const archive = createPortableArchive(stableJsonStringify(payload()));
|
|
const tampered = JSON.stringify({
|
|
...archive,
|
|
canonical_payload: archive.canonical_payload.replace(
|
|
'"source":"mobile"',
|
|
'"source":"cloud"',
|
|
),
|
|
});
|
|
|
|
expect(() => parsePortableArchive(tampered, OWNER)).toThrow('checksum');
|
|
});
|
|
|
|
it('rejects unsupported fields instead of ignoring them', () => {
|
|
const invalid = {
|
|
...payload(),
|
|
storage_objects: [{ path: 'private/audio.wav' }],
|
|
};
|
|
expect(() => parsePortablePayload(stableJsonStringify(invalid))).toThrow(
|
|
'unsupported fields',
|
|
);
|
|
});
|
|
|
|
it('rejects a foreign owner before an RPC can run', () => {
|
|
const foreign = {
|
|
...payload(),
|
|
owner_id: '1d30574e-5da7-4b22-bf4a-028fd0084962',
|
|
};
|
|
const archive = createPortableArchive(stableJsonStringify(foreign));
|
|
expect(() =>
|
|
parsePortableArchive(serializePortableArchive(archive), OWNER),
|
|
).toThrow('different account');
|
|
});
|
|
|
|
it('rejects orphan meeting children', () => {
|
|
const invalid = payload();
|
|
invalid.datasets.transcripts.push({
|
|
id: '2d47b3b6-a0dc-4ce4-8cd4-37b32ef3a29c',
|
|
meeting_id: '292a4f9d-288e-4277-aab6-b07132a80c69',
|
|
segment_index: 0,
|
|
timestamp_ms: 0,
|
|
duration_ms: null,
|
|
text: 'orphan',
|
|
speaker: null,
|
|
edited: false,
|
|
created_at: '2026-08-21T00:00:00.000Z',
|
|
updated_at: '2026-08-21T00:00:00.000Z',
|
|
});
|
|
expect(() => parsePortablePayload(stableJsonStringify(invalid))).toThrow(
|
|
'without its parent',
|
|
);
|
|
});
|
|
|
|
it('rejects the same row id twice', () => {
|
|
const dictionary = {
|
|
id: '7e050e11-c90c-4e95-8581-d37ad687d836',
|
|
user_id: OWNER,
|
|
word: 'D3RO',
|
|
pronunciation: null,
|
|
category: 'technical' as const,
|
|
usage_count: 0,
|
|
last_used_at: null,
|
|
created_at: '2026-08-21T00:00:00.000Z',
|
|
updated_at: '2026-08-21T00:00:00.000Z',
|
|
};
|
|
const invalid = payload();
|
|
invalid.datasets.dictionary = [dictionary, dictionary];
|
|
expect(() => parsePortablePayload(stableJsonStringify(invalid))).toThrow(
|
|
'repeats a row id',
|
|
);
|
|
});
|
|
});
|