- api: DeviceRegistration device-only, login/changePassword/resolveUser, interception 401 (purge) exonérée sur le login ; type User aligné sur le fil (is_admin) - SecureStore (expo-secure-store v57) : token + profil, jamais en SQLite ; miroir active_user_id - AuthContext : statut loading/signedOut/signedIn, bootstrap register→restore, signIn/signOut, garde-fou switch de compte - app/login.tsx + gate de routes dans _layout (Redirect signedOut) ; i18n fr/en - DB v5 : user_id sur pending_operations + resource_permissions (UNIQUE par user), repos scopés (user_id IS ? OR IS NULL) - syncOutbox : delta permissions par compte (Map), outbox poussée du compte actif uniquement - tests: register/login/401/resolve/changePassword, scoping outbox+permissions, migrations v5 ; e2e live revert register→login admin
293 lines
13 KiB
TypeScript
293 lines
13 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import Database from 'better-sqlite3';
|
|
import { MIGRATIONS, migrateDatabase } from '../services/db/migrations';
|
|
import { DATABASE_VERSION, DEVICE_USER_ID_KEY } from '../services/db/schema';
|
|
|
|
type Harness = {
|
|
execAsync(sql: string): Promise<void>;
|
|
runAsync(sql: string, ...params: unknown[]): Promise<void>;
|
|
getFirstAsync<T>(sql: string, ...params: unknown[]): Promise<T | null>;
|
|
getAllAsync<T>(sql: string, ...params: unknown[]): Promise<T[]>;
|
|
withExclusiveTransactionAsync<T>(task: (txn: Harness) => Promise<T>): Promise<T>;
|
|
raw: Database.Database;
|
|
};
|
|
|
|
function createHarness(): Harness {
|
|
const db = new Database(':memory:');
|
|
const self: Harness = {
|
|
execAsync: async (sql) => {
|
|
db.exec(sql);
|
|
},
|
|
runAsync: async (sql, ...params) => {
|
|
db.prepare(sql).run(...params);
|
|
},
|
|
getFirstAsync: async (sql, ...params) =>
|
|
(db.prepare(sql).get(...params) ?? null) as never,
|
|
getAllAsync: async (sql, ...params) => db.prepare(sql).all(...params) as never,
|
|
withExclusiveTransactionAsync: async (task) => {
|
|
db.exec('BEGIN');
|
|
try {
|
|
const result = await task(self);
|
|
db.exec('COMMIT');
|
|
return result;
|
|
} catch (err) {
|
|
db.exec('ROLLBACK');
|
|
throw err;
|
|
}
|
|
},
|
|
raw: db,
|
|
};
|
|
return self;
|
|
}
|
|
|
|
async function userVersion(h: Harness): Promise<number> {
|
|
const row = await h.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
|
|
return row?.user_version ?? 0;
|
|
}
|
|
|
|
async function columnNames(h: Harness, table: string): Promise<string[]> {
|
|
const rows = await h.getAllAsync<{ name: string }>(`PRAGMA table_info(${table})`);
|
|
return rows.map((r) => r.name);
|
|
}
|
|
|
|
async function foreignKeyViolations(h: Harness): Promise<unknown[]> {
|
|
return h.getAllAsync('PRAGMA foreign_key_check');
|
|
}
|
|
|
|
const ROOT_URI = 'content://com.android.providers.documents/tree/primary%3ADocuments';
|
|
const SUB_URI = 'content://com.android.providers.documents/tree/primary%3ADocuments%2Fphotos%20encod%2520ed';
|
|
const EMPTY_URI = 'content://com.android.providers.documents/document/empty';
|
|
const MUSIC_URI = 'content://com.android.providers.documents/tree/primary%3AMusic';
|
|
const NOTE_URI = 'content://com.android.providers.documents/document/primary%20note.txt';
|
|
const PIC_URI = 'content://com.android.providers.documents/document/primary%3Aphotos%2Fpic%20.jpg';
|
|
const SONG_URI = 'content://com.android.providers.documents/document/primary%3ASong%20encod%2520.mp3';
|
|
|
|
async function seedLegacyTree(h: Harness): Promise<void> {
|
|
await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri)
|
|
VALUES ('${ROOT_URI}', 'Docs', 1, 'local', 1000, NULL);`);
|
|
await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri)
|
|
VALUES ('${SUB_URI}', 'Photos 2024', 1, 'local', 2000, '${ROOT_URI}');`);
|
|
await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri)
|
|
VALUES ('${EMPTY_URI}', 'Empty', 1, 'cloud', 3000, NULL);`);
|
|
await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri)
|
|
VALUES ('${MUSIC_URI}', 'Music', 0, 'local-cloud', 4000, NULL);`);
|
|
await h.execAsync(`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, sync_status, added_at)
|
|
VALUES ('${NOTE_URI}', 'note.txt', '${ROOT_URI}', 'txt', 10, 'text/plain', 1, 1000, 'local', 1000);`);
|
|
await h.execAsync(`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, sync_status, added_at)
|
|
VALUES ('${PIC_URI}', 'pic.jpg', '${SUB_URI}', 'jpg', 20, 'image/jpeg', 1, 2000, 'local-cloud', 2000);`);
|
|
await h.execAsync(`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, sync_status, added_at)
|
|
VALUES ('${SONG_URI}', 'song.mp3', '${MUSIC_URI}', 'mp3', 30, 'audio/mpeg', 0, 3000, 'cloud', 3000);`);
|
|
}
|
|
|
|
test('fresh migrate v0 → v5 creates full schema and seeds device id', async () => {
|
|
const h = createHarness();
|
|
await migrateDatabase(h);
|
|
|
|
assert.equal(await userVersion(h), DATABASE_VERSION, `user_version should be ${DATABASE_VERSION}`);
|
|
|
|
const folderCols = await columnNames(h, 'folders');
|
|
for (const col of ['resource_id', 'uri', 'parent_resource_id', 'owner_id', 'updated_at']) {
|
|
assert.ok(folderCols.includes(col), `folders should have column ${col}`);
|
|
}
|
|
assert.ok(!folderCols.includes('parent_uri'), 'parent_uri must be replaced by parent_resource_id');
|
|
|
|
const fileCols = await columnNames(h, 'files');
|
|
for (const col of ['resource_id', 'folder_resource_id', 'owner_id', 'updated_at']) {
|
|
assert.ok(fileCols.includes(col), `files should have column ${col}`);
|
|
}
|
|
|
|
for (const table of [
|
|
'resource_permissions',
|
|
'shares',
|
|
'recipients',
|
|
'share_links',
|
|
'pending_operations',
|
|
]) {
|
|
const names = await columnNames(h, table);
|
|
assert.ok(names.length > 0, `table ${table} must exist`);
|
|
}
|
|
|
|
const permCols = await columnNames(h, 'resource_permissions');
|
|
assert.ok(permCols.includes('user_id'), 'resource_permissions must have user_id (v5)');
|
|
const opCols = await columnNames(h, 'pending_operations');
|
|
assert.ok(opCols.includes('user_id'), 'pending_operations must have user_id (v5)');
|
|
|
|
const device = await h.getFirstAsync<{ value: string }>(
|
|
'SELECT "value" FROM user_preferences WHERE "key" = ?',
|
|
DEVICE_USER_ID_KEY,
|
|
);
|
|
assert.ok(device && /^[0-9a-f]{32}$/.test(device.value), 'device_user_id seeded as 32-hex');
|
|
|
|
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphaned FKs after fresh migrate');
|
|
});
|
|
|
|
test('v5 : UNIQUE de resource_permissions scopé par (user_id, resource_id, resource_type)', async () => {
|
|
const h = createHarness();
|
|
await migrateDatabase(h);
|
|
|
|
const row = await h.getFirstAsync<{ sql: string }>(
|
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND tbl_name = 'resource_permissions'`,
|
|
);
|
|
assert.ok(row, 'table sql present');
|
|
assert.match(row.sql, /UNIQUE\s*\(user_id,\s*resource_id,\s*resource_type\)/);
|
|
|
|
// Deux comptes peuvent partager la même ressource sans collision.
|
|
await h.runAsync(
|
|
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
|
|
VALUES ('u1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'owner', 1, NULL, 1, 1)`,
|
|
);
|
|
await h.runAsync(
|
|
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
|
|
VALUES ('u2', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'viewer', 0, NULL, 1, 1)`,
|
|
);
|
|
await assert.rejects(
|
|
() =>
|
|
h.runAsync(
|
|
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
|
|
VALUES ('u1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'editor', 1, NULL, 1, 1)`,
|
|
),
|
|
/UNIQUE constraint failed/,
|
|
);
|
|
});
|
|
|
|
test('user_version is transactional (rollback restores previous version)', async () => {
|
|
const h = createHarness();
|
|
await migrateDatabase(h);
|
|
const before = await userVersion(h);
|
|
|
|
h.raw.exec('BEGIN');
|
|
h.raw.exec('PRAGMA user_version = 999');
|
|
h.raw.exec('ROLLBACK');
|
|
|
|
assert.equal(await userVersion(h), before, 'user_version must be rolled back');
|
|
});
|
|
|
|
test('realistic v2 → v4 tree is preserved with correct reparenting', async () => {
|
|
const h = createHarness();
|
|
await migrateDatabase(h, 2);
|
|
await seedLegacyTree(h);
|
|
|
|
await migrateDatabase(h);
|
|
|
|
assert.equal(await userVersion(h), DATABASE_VERSION);
|
|
|
|
const folders = await h.getAllAsync<{
|
|
resource_id: string;
|
|
uri: string | null;
|
|
name: string;
|
|
parent_resource_id: string | null;
|
|
owner_id: string;
|
|
added_at: number;
|
|
updated_at: number;
|
|
}>('SELECT resource_id, uri, name, parent_resource_id, owner_id, added_at, updated_at FROM folders');
|
|
const files = await h.getAllAsync<{
|
|
resource_id: string;
|
|
uri: string | null;
|
|
name: string;
|
|
folder_resource_id: string;
|
|
owner_id: string;
|
|
added_at: number;
|
|
updated_at: number;
|
|
}>('SELECT resource_id, uri, name, folder_resource_id, owner_id, added_at, updated_at FROM files');
|
|
|
|
assert.equal(folders.length, 4, 'folders count preserved');
|
|
assert.equal(files.length, 3, 'files count preserved');
|
|
|
|
for (const f of folders) {
|
|
assert.match(f.resource_id, /^[0-9a-f]{32}$/, 'resource_id must be 32-hex once');
|
|
assert.ok(f.owner_id, 'owner_id must be set (never NULL implicit)');
|
|
assert.equal(f.updated_at, f.added_at, 'backfilled updated_at = added_at');
|
|
}
|
|
for (const f of files) {
|
|
assert.match(f.resource_id, /^[0-9a-f]{32}$/, 'resource_id must be 32-hex');
|
|
assert.ok(f.owner_id, 'owner_id must be set');
|
|
}
|
|
const resourceIds = [...folders.map((f) => f.resource_id), ...files.map((f) => f.resource_id)];
|
|
assert.equal(new Set(resourceIds).size, resourceIds.length, 'all resource ids unique in the same table');
|
|
|
|
const device = await h.getFirstAsync<{ value: string }>(
|
|
'SELECT "value" FROM user_preferences WHERE "key" = ?',
|
|
DEVICE_USER_ID_KEY,
|
|
);
|
|
for (const f of [...folders, ...files]) {
|
|
assert.equal(f.owner_id, device!.value, `owner_id must be device_user_id, got ${f.owner_id}`);
|
|
}
|
|
|
|
const byUri = new Map(folders.map((f) => [f.uri, f]));
|
|
const root = byUri.get(ROOT_URI);
|
|
const sub = byUri.get(SUB_URI);
|
|
assert.ok(root && sub, 'local uris preserved on local rows');
|
|
assert.equal(sub.parent_resource_id, root.resource_id, 'child folder reparented to root resource_id');
|
|
|
|
const byName = new Map(folders.map((f) => [f.name, f]));
|
|
const empty = byName.get('Empty');
|
|
assert.equal(empty!.parent_resource_id, null, 'root folder stays root');
|
|
|
|
const note = files.find((f) => f.name === 'note.txt');
|
|
const pic = files.find((f) => f.name === 'pic.jpg');
|
|
const song = files.find((f) => f.name === 'song.mp3');
|
|
assert.equal(note!.folder_resource_id, root.resource_id, 'file linked to root folder');
|
|
assert.equal(pic!.folder_resource_id, sub.resource_id, 'file linked to nested folder');
|
|
assert.equal(song!.folder_resource_id, byName.get('Music')!.resource_id, 'cloud file linked to cloud folder');
|
|
|
|
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphaned FKs after v2→v4');
|
|
});
|
|
|
|
test('cascade delete works through the rebuilt self-FK', async () => {
|
|
const h = createHarness();
|
|
await migrateDatabase(h, 2);
|
|
await seedLegacyTree(h);
|
|
await migrateDatabase(h);
|
|
|
|
const folders = await h.getAllAsync<{ resource_id: string; name: string; parent_resource_id: string | null }>(
|
|
'SELECT resource_id, name, parent_resource_id FROM folders',
|
|
);
|
|
const root = folders.find((f) => f.name === 'Docs')!;
|
|
|
|
await h.runAsync('DELETE FROM folders WHERE resource_id = ?', root.resource_id);
|
|
|
|
const names = await h.getAllAsync<{ name: string }>('SELECT name FROM folders');
|
|
assert.equal(names.length, 2, 'root and its subtree removed (Docs, Photos 2024)');
|
|
const files = await h.getAllAsync<{ name: string }>('SELECT name FROM files');
|
|
assert.equal(files.length, 1, 'only Music file remains (note + pic cascaded)');
|
|
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphans after cascade');
|
|
});
|
|
|
|
test('v4 crash is atomic: rollback keeps v3 schema and data, rerun succeeds', async () => {
|
|
const h = createHarness();
|
|
await migrateDatabase(h, 3);
|
|
await seedLegacyTree(h);
|
|
|
|
const originalExec = h.execAsync.bind(h);
|
|
h.execAsync = async (sql) => {
|
|
if (sql.startsWith('DROP TABLE files;')) {
|
|
throw new Error('simulated crash during rebuild');
|
|
}
|
|
return originalExec(sql);
|
|
};
|
|
|
|
const v4 = MIGRATIONS.find((m) => m.version === 4);
|
|
assert.ok(v4, 'v4 migration exists');
|
|
await assert.rejects(() => v4.up(h), /simulated crash/);
|
|
|
|
assert.equal(await userVersion(h), 3, 'user_version unchanged after failed rebuild');
|
|
assert.ok((await columnNames(h, 'folders')).includes('parent_uri'), 'old folders schema restored');
|
|
const folders = await h.getAllAsync<{ name: string }>('SELECT name FROM folders');
|
|
assert.equal(folders.length, 4, 'data intact after rollback');
|
|
|
|
h.execAsync = originalExec;
|
|
await migrateDatabase(h);
|
|
|
|
assert.equal(await userVersion(h), DATABASE_VERSION, 'rerun completes to newest version');
|
|
assert.ok((await columnNames(h, 'folders')).includes('resource_id'));
|
|
const files = await h.getAllAsync<{ name: string }>('SELECT name FROM files');
|
|
assert.equal(files.length, 3, 'files preserved after rerun');
|
|
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphans after rerun');
|
|
});
|
|
|
|
test('concurrent migrateDatabase calls are single-flight', async () => {
|
|
const h = createHarness();
|
|
await Promise.all([migrateDatabase(h), migrateDatabase(h)]);
|
|
assert.equal(await userVersion(h), DATABASE_VERSION);
|
|
}); |