d3ro-voice/apps/mobile-rn/__tests__/data-portability-service.test.ts
2026-08-29 18:33:45 +09:00

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();
});
});