d3ro-voice/apps/mobile-rn/__tests__/data-portability.local.integration.mjs
2026-08-29 18:33:45 +09:00

574 lines
16 KiB
JavaScript

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