149 lines
4.1 KiB
TypeScript
149 lines
4.1 KiB
TypeScript
import {
|
|
exportAccountArchive,
|
|
importAccountArchive,
|
|
stableJsonStringify,
|
|
type PortablePayload,
|
|
} from '../src/features/data-portability';
|
|
import { sha256Hex } from '../src/features/data-portability/canonical-json';
|
|
|
|
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: 'cloud',
|
|
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',
|
|
],
|
|
};
|
|
}
|
|
|
|
function client(
|
|
rpcImpl: (
|
|
name: string,
|
|
args?: Record<string, unknown>,
|
|
) => Promise<{
|
|
data: unknown;
|
|
error: unknown;
|
|
}>,
|
|
): any {
|
|
return {
|
|
auth: {
|
|
getUser: jest.fn(async () => ({
|
|
data: { user: { id: OWNER } },
|
|
error: null,
|
|
})),
|
|
},
|
|
rpc: jest.fn(rpcImpl),
|
|
};
|
|
}
|
|
|
|
describe('account portability service contracts', () => {
|
|
it('verifies the server export checksum and row count', async () => {
|
|
const canonical = stableJsonStringify(payload());
|
|
const fake = client(async name => ({
|
|
data:
|
|
name === 'export_account_portability'
|
|
? [
|
|
{
|
|
canonical_payload: canonical,
|
|
checksum: sha256Hex(canonical),
|
|
exported_at: payload().exported_at,
|
|
row_count: 0,
|
|
},
|
|
]
|
|
: null,
|
|
error: null,
|
|
}));
|
|
const result = await exportAccountArchive(OWNER, fake);
|
|
expect(result.rowCount).toBe(0);
|
|
expect(result.archive.checksum).toBe(sha256Hex(canonical));
|
|
expect(fake.rpc).toHaveBeenCalledWith('export_account_portability');
|
|
});
|
|
|
|
it('does not call restore RPC when a portable file is corrupted', async () => {
|
|
const fake = client(async () => ({ data: null, error: null }));
|
|
const invalid = JSON.stringify({
|
|
format: 'd3ro-account-portability',
|
|
schema_version: 1,
|
|
checksum_algorithm: 'sha256',
|
|
checksum: '0'.repeat(64),
|
|
canonical_payload: stableJsonStringify(payload()),
|
|
});
|
|
|
|
await expect(
|
|
importAccountArchive(
|
|
OWNER,
|
|
invalid,
|
|
'backup.json',
|
|
'application/json',
|
|
fake,
|
|
),
|
|
).rejects.toMatchObject({ code: 'checksum' });
|
|
expect(fake.rpc).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sends only canonical payload and checksum to the atomic restore RPC', async () => {
|
|
const canonical = stableJsonStringify(payload());
|
|
const archive = JSON.stringify({
|
|
format: 'd3ro-account-portability',
|
|
schema_version: 1,
|
|
checksum_algorithm: 'sha256',
|
|
checksum: sha256Hex(canonical),
|
|
canonical_payload: canonical,
|
|
});
|
|
const fake = client(async (name, args) => ({
|
|
data:
|
|
name === 'restore_account_portability'
|
|
? {
|
|
status: 'imported',
|
|
checksum: args?.supplied_checksum,
|
|
imported_rows: 0,
|
|
skipped_rows: 0,
|
|
imported_at: '2026-08-21T00:01:00.000Z',
|
|
}
|
|
: null,
|
|
error: null,
|
|
}));
|
|
const result = await importAccountArchive(
|
|
OWNER,
|
|
archive,
|
|
'backup.json',
|
|
'application/json',
|
|
fake,
|
|
);
|
|
expect(result.result.status).toBe('imported');
|
|
expect(fake.rpc).toHaveBeenCalledWith('restore_account_portability', {
|
|
canonical_payload: canonical,
|
|
supplied_checksum: sha256Hex(canonical),
|
|
});
|
|
});
|
|
|
|
it('refuses a mismatched active session before export', async () => {
|
|
const fake = client(async () => ({ data: null, error: null }));
|
|
fake.auth.getUser.mockResolvedValue({
|
|
data: { user: { id: '1d30574e-5da7-4b22-bf4a-028fd0084962' } },
|
|
error: null,
|
|
});
|
|
await expect(exportAccountArchive(OWNER, fake)).rejects.toMatchObject({
|
|
code: 'auth',
|
|
});
|
|
expect(fake.rpc).not.toHaveBeenCalled();
|
|
});
|
|
});
|