574 lines
16 KiB
JavaScript
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)),
|
|
);
|
|
}
|