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:
@@ -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 d’un 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');
|
||||
|
||||
@@ -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, 'l’admin 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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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], 'l’op 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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user