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
+68 -5
View File
@@ -1,6 +1,6 @@
import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { api, setAuthToken, ApiError } from '../api/client';
import { api, setAuthToken, setUnauthorizedHandler, ApiError } from '../api/client';
import type { ApiData } from '../api/types';
function jsonResponse(status: number, body: unknown, headers?: HeadersInit): Response {
@@ -33,10 +33,12 @@ function pathOf(url: string): string {
beforeEach(() => {
calls = [];
setAuthToken(null);
setUnauthorizedHandler(null);
});
afterEach(() => {
globalThis.fetch = originalFetch;
setUnauthorizedHandler(null);
});
test('nominal : GET /health', async () => {
@@ -47,13 +49,74 @@ test('nominal : GET /health', async () => {
assert.deepEqual(res.data, { status: 'healthy' });
});
test('nominal : POST /devices (register) JSON', async () => {
stubFetch(() => ok({ deviceId: 'aaaa', token: 'v4.local.x' }));
test('nominal : POST /devices (register) — plus aucun token, device-only', async () => {
stubFetch(() => ok({ deviceId: 'aaaa' }));
const res = await api.registerDevice('aaaa');
assert.equal(pathOf(calls[0].url), '/api/v1/devices');
assert.equal(calls[0].init.method, 'POST');
assert.deepEqual(JSON.parse(String(calls[0].init.body)), { deviceId: 'aaaa' });
assert.equal(res.data.token, 'v4.local.x');
assert.deepEqual(res.data, { deviceId: 'aaaa' });
assert.ok(!('token' in res.data), 'POST /devices ne doit plus émettre de token');
});
test('nominal : POST /auth/login (seule porte de token)', async () => {
stubFetch(() =>
ok({
token: 'v4.local.xyz',
expires_at: 1700000000000,
user: { id: 'u-1', username: 'alice', is_admin: false },
}),
);
const res = await api.login('alice', 'secret-pass', 'dev-1');
assert.equal(pathOf(calls[0].url), '/api/v1/auth/login');
assert.equal(calls[0].init.method, 'POST');
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
username: 'alice',
password: 'secret-pass',
device_id: 'dev-1',
});
assert.equal(res.data.user.id, 'u-1');
assert.equal(res.data.user.is_admin, false);
});
test('auth : 401 de login ne déclenche PAS le handler de purge de session', async () => {
let purged = 0;
setUnauthorizedHandler(() => {
purged++;
});
stubFetch(() => jsonResponse(401, { error: { code: 'UNAUTHORIZED', message: 'invalid credentials' } }));
await assert.rejects(() => api.login('bob', 'wrong', 'dev-1'), (err: unknown) => (err as ApiError).code === 'UNAUTHORIZED');
assert.equal(purged, 0, 'mauvais identifiants ≠ session à purger');
});
test('auth : 401 dun endpoint protégé déclenche le handler de purge', async () => {
let purged = 0;
setUnauthorizedHandler(() => {
purged++;
});
setAuthToken('tok-expired');
stubFetch(() => jsonResponse(401, { error: { code: 'UNAUTHORIZED', message: 'token invalid' } }));
await assert.rejects(() => api.listFiles(), (err: unknown) => (err as ApiError).code === 'UNAUTHORIZED');
assert.equal(purged, 1, 'token révoqué → purge de session');
setUnauthorizedHandler(null);
});
test('nominal : PATCH /users/me/password', async () => {
stubFetch(() => ok({ id: 'u-1' }));
await api.changePassword('old-pass', 'new-pass-8chars');
assert.equal(pathOf(calls[0].url), '/api/v1/users/me/password');
assert.equal(calls[0].init.method, 'PATCH');
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
current_password: 'old-pass',
new_password: 'new-pass-8chars',
});
});
test('nominal : GET /users/resolve?username= (résolution exacte)', async () => {
stubFetch(() => ok({ id: 'u-9', username: 'bob' }));
const res = await api.resolveUser('BOB');
assert.equal(pathOf(calls[0].url), '/api/v1/users/resolve?username=BOB');
assert.equal(res.data.username, 'bob');
});
test('nominal : GET /files — query filtrée (undefined/null ignorés)', async () => {
@@ -184,7 +247,7 @@ test('auth : token posé → Authorization: Bearer', async () => {
test('auth : fusion avec Content-Type existant (POST /devices + token)', async () => {
setAuthToken('tok-123');
stubFetch(() => ok({ deviceId: 'a', token: 't' }));
stubFetch(() => ok({ deviceId: 'a' }));
await api.registerDevice('a');
const headers = new Headers(calls[0].init.headers);
assert.equal(headers.get('Authorization'), 'Bearer tok-123');
+17 -5
View File
@@ -13,10 +13,17 @@ import { MIGRATIONS, type MigrationDb } from '../services/db/migrations';
import type { DbSession } from '../services/db/session';
import { __setDbForTests } from '../services/db/session';
import { api, setAuthToken } from '../api/client';
import { enqueuePendingOperation, getPendingOperations } from '../services/db';
import {
clearActiveUserId,
enqueuePendingOperation,
getPendingOperations,
setActiveUserId,
} from '../services/db';
import { pushPendingOps, refreshPermissions, resetPermissionCachedAtForTests } from '../features/syncOutbox';
const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
type Harness = MigrationDb & DbSession;
@@ -75,9 +82,10 @@ beforeEach(async () => {
afterEach(() => {
__setDbForTests(null);
setAuthToken(null);
clearActiveUserId();
});
test('bout en bout : register → push outbox → snapshot → relecture serveur', async (t) => {
test('bout en bout : register + login admin → push outbox → snapshot → relecture serveur', async (t) => {
try {
await api.health();
} catch (error) {
@@ -88,11 +96,15 @@ test('bout en bout : register → push outbox → snapshot → relecture serveur
throw error;
}
// Enregistrement du device (idempotent) puis login = SEULE porte de token.
deviceId = newDeviceId();
const reg = await api.registerDevice(deviceId);
setAuthToken(reg.data.token);
await api.registerDevice(deviceId);
const login = await api.login(ADMIN_USERNAME, ADMIN_PASSWORD, deviceId);
setAuthToken(login.data.token);
await setActiveUserId(login.data.user.id);
assert.equal(login.data.user.is_admin, true, 'ladmin E2E doit exister (ADMIN_*)');
// 1. Enqueue un CREATE_RESOURCE puis push
// 1. Enqueue un CREATE_RESOURCE puis push (scopé au compte connecté)
const name = `e2e-${deviceId.slice(0, 8)}`;
const resourceId = newDeviceId();
const opId = await enqueuePendingOperation({
+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);
+68
View File
@@ -7,11 +7,13 @@ import { __setDbForTests } from '../services/db/session';
import { api, setAuthToken } from '../api/client';
import {
enqueuePendingOperation,
getActiveUserId,
getPendingOperations,
getResourcePermission,
listQueuedOperations,
markPendingOperation,
scheduleRetries,
setActiveUserId,
MAX_PENDING_ATTEMPTS,
} from '../services/db';
import {
@@ -331,4 +333,70 @@ test('refreshPermissions garde la référence dans le temps pour after', async (
await refreshPermissions();
assert.equal((await getResourcePermission('d'.repeat(32), 'folder'))?.effectiveAccess, 'viewer');
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions?after=42');
});
test('outbox scopée par compte : seul le compte actif voit/pousse ses ops', async () => {
await setActiveUserId('u1');
const idA = await enqueue({ operation: 'create_resource', payload: { name: 'A' } });
await setActiveUserId('u2');
const idB = await enqueue({ operation: 'create_resource', payload: { name: 'B' } });
assert.equal(await getActiveUserId(), 'u2');
const visibleAsU2 = (await getPendingOperations()).map((op) => op.id);
assert.deepEqual(visibleAsU2, [idB], 'u2 ne voit que ses opérations');
await setActiveUserId('u1');
assert.deepEqual(
(await getPendingOperations()).map((op) => op.id),
[idA],
'u1 ne voit que ses opérations',
);
stubFetch(() => ok({ applied: 1, failed: null }));
await setActiveUserId('u2');
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 1, retried: 0 });
const body = JSON.parse(String(calls[0].init.body));
assert.equal(body.operations.length, 1, 'u2 ne pousse pas les opérations de u1');
assert.equal(body.operations[0].operation_id, idB);
await setActiveUserId('u1');
const leftover = await getPendingOperations();
assert.deepEqual(leftover.map((op) => op.id), [idA], 'lop u1 reste intacte et non-poussée');
});
test('permissions scopées par compte : snapshot et miroir par compte', async () => {
const permA = {
resource_id: 'a'.repeat(32),
resourceType: 'folder',
effectiveAccess: 'owner',
inherit: true,
ownerId: 'u1',
sharedById: null,
expiresAt: null,
cachedAt: 1000,
updatedAt: 1000,
};
await setActiveUserId('u1');
stubFetch((url) => (new URL(url).searchParams.get('after') === null ? ok([permA]) : ok([])));
await refreshPermissions();
assert.equal(
(await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess,
'owner',
'u1 voit la permission de son snapshot',
);
// u2 n'a pas encore de snapshot : pas d'`after` (repart du début) et la
// permission de u1 n'est pas visible tant que son propre snapshot n'est pas
// arrivé.
await setActiveUserId('u2');
assert.equal(await getResourcePermission('a'.repeat(32), 'folder'), null);
await refreshPermissions();
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions', 'u2 repart sans after');
assert.equal(
(await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess,
'owner',
'u2 voit sa propre copie après son snapshot',
);
});