feat(mobile): identité user-first — login/password, SecureStore, scoping par compte

- 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
This commit is contained in:
m
2026-09-10 21:34:34 +02:00
parent c2c7dddba6
commit 0536fca6c3
25 changed files with 663 additions and 49 deletions
+35 -1
View File
@@ -80,7 +80,7 @@ async function seedLegacyTree(h: Harness): Promise<void> {
VALUES ('${SONG_URI}', 'song.mp3', '${MUSIC_URI}', 'mp3', 30, 'audio/mpeg', 0, 3000, 'cloud', 3000);`);
}
test('fresh migrate v0 → v4 creates full schema and seeds device id', async () => {
test('fresh migrate v0 → v5 creates full schema and seeds device id', async () => {
const h = createHarness();
await migrateDatabase(h);
@@ -108,6 +108,11 @@ test('fresh migrate v0 → v4 creates full schema and seeds device id', async ()
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,
@@ -117,6 +122,35 @@ test('fresh migrate v0 → v4 creates full schema and seeds device id', async ()
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);