import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { createClient } from '@supabase/supabase-js'; const supabaseUrl = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? ''; const anonKey = process.env.D3RO_LOCAL_SUPABASE_ANON_KEY?.trim() ?? ''; const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? ''; if ( !supabaseUrl.startsWith('http://127.0.0.1:55321') || anonKey.length < 20 || serviceRoleKey.length < 20 ) { throw new Error( 'D3RO local Supabase credentials for port 55321 are required', ); } const options = { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false, }, }; const admin = createClient(supabaseUrl, serviceRoleKey, options); const anonymousClient = createClient(supabaseUrl, anonKey, options); const ownerClient = createClient(supabaseUrl, anonKey, options); const ownerRestoreClient = createClient(supabaseUrl, anonKey, options); const outsiderClient = createClient(supabaseUrl, anonKey, options); const suffix = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; const password = `D3ro-${suffix}-Portability!`; const createdUserIds = []; let assertions = 0; const IDS = { dictionary: '7e050e11-c90c-4e95-8581-d37ad687d836', history: '6a96a77d-f8d8-4c80-ae72-62a27f80c620', meeting: '292a4f9d-288e-4277-aab6-b07132a80c69', transcript: '2d47b3b6-a0dc-4ce4-8cd4-37b32ef3a29c', memo: '8ebac53d-6bb6-4da6-973a-9d4e12e93d3c', document: '0bd5fe39-b785-44c2-81a4-bf156c8cd439', instruction: 'c65eac85-2b0c-45e9-a887-e2b4cb494ec4', newRow: '62ee88d9-c924-4fea-896c-0ddd92ed6997', invalidRow: '67bdf239-eaa2-40d5-8d7a-b45c358c86f1', outsiderRow: 'f37a2b77-92cf-4645-9a51-7458b4e55991', }; function checked(condition, message) { assert.ok(condition, message); assertions += 1; } function checksum(value) { return createHash('sha256').update(value, 'utf8').digest('hex'); } function clone(value) { return JSON.parse(JSON.stringify(value)); } function collectObjectKeys(value, result = new Set()) { if (Array.isArray(value)) { value.forEach(item => collectObjectKeys(item, result)); } else if (value !== null && typeof value === 'object') { Object.entries(value).forEach(([key, child]) => { result.add(key); collectObjectKeys(child, result); }); } return result; } function emptyDatasets(payload) { const next = clone(payload); for (const key of Object.keys(next.datasets)) next.datasets[key] = []; next.exported_at = new Date().toISOString(); next.source = 'mobile'; return next; } async function createUser(client, marker) { const email = `d3ro.portability.${suffix}.${marker}@gmail.com`; const response = await client.auth.signUp({ email, password, options: { data: { name: marker } }, }); assert.equal(response.error, null, `Could not create local ${marker}`); assert.ok( response.data.user && response.data.session, `Local ${marker} has no session`, ); createdUserIds.push(response.data.user.id); return { id: response.data.user.id, email }; } async function requireSuccess(result, label) { assert.equal( result.error, null, `${label}: ${result.error?.message ?? 'unknown error'}`, ); assert.ok(result.data !== null, `${label}: response data missing`); return result.data; } async function requireFailure(result, pattern, label) { assert.ok(result.error, `${label}: unexpectedly succeeded`); const details = `${result.error.code ?? ''} ${result.error.message ?? ''} ${ result.error.details ?? '' }`; assert.match(details, pattern, `${label}: unexpected error ${details}`); assertions += 1; } async function restore(client, payload) { const canonical = JSON.stringify(payload); return client.rpc('restore_account_portability', { canonical_payload: canonical, supplied_checksum: checksum(canonical), }); } try { const owner = await createUser(ownerClient, 'owner'); const outsider = await createUser(outsiderClient, 'outsider'); const timestamp = '2026-08-21T00:00:00.000Z'; await requireFailure( await anonymousClient.rpc('export_account_portability'), /authentication_required|42501|permission/i, 'anonymous account export', ); await requireSuccess( await ownerClient .from('dictionary') .insert({ id: IDS.dictionary, user_id: owner.id, word: 'D3RO Voice', pronunciation: '디쓰리로 보이스', category: 'technical', usage_count: 7, last_used_at: timestamp, created_at: timestamp, updated_at: timestamp, }) .select('id') .single(), 'seed dictionary', ); await requireSuccess( await ownerClient .from('history') .insert({ id: IDS.history, user_id: owner.id, title: 'Portability E2E', original_text: '원자적 복원 테스트', polished_text: '원자적 복원을 테스트합니다.', mode: 'dictation', status: 'completed', duration: 1200, word_count: 3, app_version: '1.0.0', is_favorite: true, revision: 2, created_at: timestamp, updated_at: timestamp, }) .select('id') .single(), 'seed history', ); await requireSuccess( await ownerClient .from('meetings') .insert({ id: IDS.meeting, user_id: owner.id, team_id: null, title: 'Mobile restore meeting', status: 'completed', started_at: timestamp, ended_at: '2026-08-21T01:00:00.000Z', duration_ms: 3_600_000, raw_transcript: '회의 원문', minutes_markdown: '## Summary\n\nAtomic restore.', created_at: timestamp, updated_at: timestamp, }) .select('id') .single(), 'seed meeting', ); await requireSuccess( await ownerClient .from('transcripts') .insert({ id: IDS.transcript, meeting_id: IDS.meeting, segment_index: 0, timestamp_ms: 0, duration_ms: 1000, text: '회의 원문', speaker: 'Speaker 1', edited: false, created_at: timestamp, updated_at: timestamp, }) .select('id') .single(), 'seed transcript', ); await requireSuccess( await ownerClient .from('meeting_memos') .insert({ id: IDS.memo, meeting_id: IDS.meeting, user_id: owner.id, content: '원자성 확인', timestamp_ms: 500, created_at: timestamp, }) .select('id') .single(), 'seed memo', ); await requireSuccess( await ownerClient .from('meeting_documents') .insert({ id: IDS.document, meeting_id: IDS.meeting, user_id: owner.id, template_type: 'minutes', title: '회의록', content: '완전한 회의록', created_at: timestamp, updated_at: timestamp, }) .select('id') .single(), 'seed document', ); await requireSuccess( await ownerClient .from('custom_instructions') .insert({ id: IDS.instruction, user_id: owner.id, builtin_key: null, name: 'Portable instruction', description: 'restorable', prompt: 'Keep every important detail.', icon: 'sparkles', sort_order: 100, revision: 1, created_at: timestamp, updated_at: timestamp, }) .select('id') .single(), 'seed custom instruction', ); const exportRows = await requireSuccess( await ownerClient.rpc('export_account_portability'), 'account export', ); const exported = exportRows[0]; const payload = JSON.parse(exported.canonical_payload); checked( exported.row_count === 7, 'Export did not include all seven restorable rows', ); checked( checksum(exported.canonical_payload) === exported.checksum, 'Database export checksum was not reproducible', ); checked( payload.owner_id === owner.id, 'Export owner was not scoped to auth.uid', ); checked( payload.datasets.history[0].audio_storage_key === undefined, 'History export leaked an audio storage key', ); checked( payload.datasets.meetings[0].audio_storage_key === undefined, 'Meeting export leaked an audio storage key', ); const exportedKeys = collectObjectKeys(payload); checked( !exportedKeys.has('push_token') && !exportedKeys.has('push_tokens'), 'Account export leaked a push token field', ); const outsiderRead = await outsiderClient .from('dictionary') .select('id') .eq('id', IDS.dictionary); assert.equal(outsiderRead.error, null); checked( outsiderRead.data.length === 0, 'RLS exposed owner dictionary data to another account', ); const outsiderRestore = await outsiderClient.rpc( 'restore_account_portability', { canonical_payload: exported.canonical_payload, supplied_checksum: exported.checksum, }, ); await requireFailure( outsiderRestore, /owner_mismatch|42501/i, 'foreign owner restore', ); const ownerDeleteResults = await Promise.all([ ownerClient.from('custom_instructions').delete().eq('id', IDS.instruction), ownerClient.from('history').delete().eq('id', IDS.history), ownerClient.from('dictionary').delete().eq('id', IDS.dictionary), ownerClient.from('meetings').delete().eq('id', IDS.meeting), ]); ownerDeleteResults.forEach((result, index) => assert.equal(result.error, null, `Owner delete ${index} failed`), ); const restored = await requireSuccess( await ownerClient.rpc('restore_account_portability', { canonical_payload: exported.canonical_payload, supplied_checksum: exported.checksum, }), 'atomic restore', ); checked( restored.status === 'imported' && restored.imported_rows === 7 && restored.skipped_rows === 0, 'Atomic restore returned wrong counts', ); const repeated = await requireSuccess( await ownerClient.rpc('restore_account_portability', { canonical_payload: exported.canonical_payload, supplied_checksum: exported.checksum, }), 'duplicate archive restore', ); checked( repeated.status === 'duplicate' && repeated.imported_rows === 0, 'Repeated archive was not idempotent', ); const restoredMeetingChildren = await Promise.all([ ownerClient.from('transcripts').select('id').eq('meeting_id', IDS.meeting), ownerClient .from('meeting_memos') .select('id') .eq('meeting_id', IDS.meeting), ownerClient .from('meeting_documents') .select('id') .eq('meeting_id', IDS.meeting), ]); checked( restoredMeetingChildren.every( result => result.error === null && result.data.length === 1, ), 'Meeting relationships were not restored', ); const secondLogin = await ownerRestoreClient.auth.signInWithPassword({ email: owner.email, password, }); assert.equal(secondLogin.error, null, 'Second-device login failed'); const secondDeviceRows = await ownerRestoreClient .from('history') .select('id,revision') .eq('id', IDS.history) .single(); checked( secondDeviceRows.data?.revision === 2, 'Another device could not restore cloud history revision', ); const corrupted = await ownerClient.rpc('restore_account_portability', { canonical_payload: exported.canonical_payload, supplied_checksum: '0'.repeat(64), }); await requireFailure( corrupted, /checksum_mismatch|22023/i, 'corrupted checksum', ); const invalidTypePayload = emptyDatasets(payload); invalidTypePayload.datasets.dictionary = [ { ...payload.datasets.dictionary[0], id: IDS.invalidRow, word: 'invalid-type', pronunciation: { nested: 'not-text' }, usage_count: 0, last_used_at: null, }, ]; await requireFailure( await restore(ownerClient, invalidTypePayload), /invalid_portability_type|22023/i, 'server field type validation', ); const duplicateIdPayload = emptyDatasets(payload); const duplicateRow = { ...payload.datasets.dictionary[0], id: IDS.invalidRow, word: 'duplicate-inside-file', pronunciation: null, usage_count: 0, last_used_at: null, }; duplicateIdPayload.datasets.dictionary = [duplicateRow, duplicateRow]; await requireFailure( await restore(ownerClient, duplicateIdPayload), /duplicate_portability_row_id|22023/i, 'duplicate row inside one payload', ); const partialPayload = emptyDatasets(payload); partialPayload.datasets.dictionary = [ { ...payload.datasets.dictionary[0], id: IDS.newRow, word: 'would-have-been-inserted', usage_count: 0, pronunciation: null, last_used_at: null, }, { ...payload.datasets.dictionary[0], id: IDS.invalidRow, word: '', usage_count: 0, pronunciation: null, last_used_at: null, }, ]; await requireFailure( await restore(ownerClient, partialPayload), /invalid_dictionary|22023/i, 'partial invalid import', ); const partialProbe = await ownerClient .from('dictionary') .select('id') .eq('id', IDS.newRow); checked( partialProbe.data.length === 0, 'A row survived an invalid partial import', ); const conflictPayload = emptyDatasets(payload); conflictPayload.datasets.dictionary = [ { ...payload.datasets.dictionary[0], word: 'conflicting overwrite' }, { ...payload.datasets.dictionary[0], id: IDS.newRow, word: 'must-roll-back-with-conflict', usage_count: 0, pronunciation: null, last_used_at: null, }, ]; await requireFailure( await restore(ownerClient, conflictPayload), /restore_conflict|P0001/i, 'revision conflict import', ); const conflictProbe = await ownerClient .from('dictionary') .select('id') .eq('id', IDS.newRow); checked( conflictProbe.data.length === 0, 'A new row survived a conflicting all-or-nothing import', ); const foreignRowPayload = emptyDatasets(payload); foreignRowPayload.datasets.dictionary = [ { ...payload.datasets.dictionary[0], id: IDS.outsiderRow, user_id: outsider.id, word: 'foreign-row', }, ]; await requireFailure( await restore(ownerClient, foreignRowPayload), /cross_user|42501/i, 'cross-user row import', ); const foreignProbe = await ownerClient .from('dictionary') .select('id') .eq('id', IDS.outsiderRow); checked( foreignProbe.data.length === 0, 'Cross-user row was partially imported', ); const naturalDuplicatePayload = emptyDatasets(payload); naturalDuplicatePayload.datasets.dictionary = [ { ...payload.datasets.dictionary[0], id: '74de6848-cc2b-44f3-aad1-42e0865a36f9', }, ]; const naturalDuplicate = await requireSuccess( await restore(ownerClient, naturalDuplicatePayload), 'natural-key duplicate import', ); checked( naturalDuplicate.imported_rows === 0 && naturalDuplicate.skipped_rows === 1, 'Natural-key duplicate was not safely skipped', ); const ledger = await ownerClient .from('data_portability_imports') .select('checksum,imported_rows,skipped_rows'); assert.equal(ledger.error, null); checked( ledger.data.length === 2, 'Import ledger did not retain user-scoped successful checksums', ); const directLedgerWrite = await ownerClient .from('data_portability_imports') .insert({ user_id: owner.id, checksum: 'a'.repeat(64), schema_version: 1, source: 'mobile', imported_rows: 0, skipped_rows: 0, }); checked( directLedgerWrite.error !== null, 'Client bypassed the RPC and wrote the import ledger', ); const outsiderLedger = await outsiderClient .from('data_portability_imports') .select('checksum'); assert.equal(outsiderLedger.error, null); checked( outsiderLedger.data.length === 0, 'Import ledger leaked across accounts', ); console.log( `data portability local integration passed: ${assertions} assertions`, ); } finally { await Promise.allSettled( createdUserIds.map(userId => admin.auth.admin.deleteUser(userId)), ); }