feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
574
apps/mobile-rn/__tests__/data-portability.local.integration.mjs
Normal file
574
apps/mobile-rn/__tests__/data-portability.local.integration.mjs
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
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)),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue