- 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
402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
import { test, beforeEach, afterEach } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import Database from 'better-sqlite3';
|
||
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,
|
||
getActiveUserId,
|
||
getPendingOperations,
|
||
getResourcePermission,
|
||
listQueuedOperations,
|
||
markPendingOperation,
|
||
scheduleRetries,
|
||
setActiveUserId,
|
||
MAX_PENDING_ATTEMPTS,
|
||
} from '../services/db';
|
||
import {
|
||
pushPendingOps,
|
||
refreshPermissions,
|
||
resetPermissionCachedAtForTests,
|
||
} from '../features/syncOutbox';
|
||
|
||
import type { PendingOperationType } from '../services/db/types';
|
||
|
||
type Harness = MigrationDb & DbSession;
|
||
|
||
let h: Harness;
|
||
|
||
function createHarness(): Harness {
|
||
const sqlite = new Database(':memory:');
|
||
const harness: Harness = {
|
||
execAsync: async (sql: string) => {
|
||
sqlite.exec(sql);
|
||
},
|
||
runAsync: async (sql: string, ...params: unknown[]) => {
|
||
sqlite.prepare(sql).run(...params);
|
||
},
|
||
getFirstAsync: async (sql: string, ...params: unknown[]) =>
|
||
(sqlite.prepare(sql).get(...params) ?? null) as never,
|
||
getAllAsync: async (sql: string, ...params: unknown[]) =>
|
||
sqlite.prepare(sql).all(...params) as never,
|
||
withExclusiveTransactionAsync: async (task: (txn: Harness) => Promise<void>) => {
|
||
sqlite.exec('BEGIN');
|
||
try {
|
||
await task(harness);
|
||
sqlite.exec('COMMIT');
|
||
} catch (error) {
|
||
sqlite.exec('ROLLBACK');
|
||
throw error;
|
||
}
|
||
},
|
||
};
|
||
return harness;
|
||
}
|
||
|
||
function jsonResponse(status: number, body: unknown): Response {
|
||
return new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
function ok(data: unknown): Response {
|
||
return jsonResponse(200, { data });
|
||
}
|
||
|
||
let calls: { url: string; init: RequestInit }[] = [];
|
||
const originalFetch = globalThis.fetch;
|
||
|
||
function stubFetch(handler: (url: string, init: RequestInit) => Promise<Response> | Response): void {
|
||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
const initSafe = init ?? {};
|
||
calls.push({ url: String(input), init: initSafe });
|
||
return handler(String(input), initSafe);
|
||
};
|
||
}
|
||
|
||
function pathOf(url: string): string {
|
||
const u = new URL(url);
|
||
return u.pathname + u.search;
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
h = createHarness();
|
||
for (const migration of MIGRATIONS) {
|
||
await migration.up(h);
|
||
}
|
||
__setDbForTests(h);
|
||
calls = [];
|
||
setAuthToken('v4.local.test-token');
|
||
resetPermissionCachedAtForTests();
|
||
stubFetch(() => ok({ applied: 0, failed: null }));
|
||
});
|
||
|
||
afterEach(() => {
|
||
__setDbForTests(null);
|
||
globalThis.fetch = originalFetch;
|
||
});
|
||
|
||
async function enqueue(op: {
|
||
operation: PendingOperationType;
|
||
resourceId?: string;
|
||
payload?: Record<string, unknown>;
|
||
}) {
|
||
return enqueuePendingOperation({
|
||
resourceId: op.resourceId ?? 'a'.repeat(32),
|
||
resourceType: 'folder',
|
||
refType: 'resource',
|
||
refId: 7,
|
||
operation: op.operation,
|
||
payload: op.payload ?? { name: 'Dossier' },
|
||
});
|
||
}
|
||
|
||
test('sans token : aucun push, aucune requête réseau', async () => {
|
||
setAuthToken(null);
|
||
await enqueue({ operation: 'create_resource' });
|
||
const result = await pushPendingOps();
|
||
assert.deepEqual(result, { pushed: 0, retried: 0 });
|
||
assert.equal(calls.length, 0);
|
||
});
|
||
|
||
test('queue vide : aucune requête réseau', async () => {
|
||
const result = await pushPendingOps();
|
||
assert.deepEqual(result, { pushed: 0, retried: 0 });
|
||
assert.equal(calls.length, 0);
|
||
});
|
||
|
||
test('succès complet : toutes les ops passent à completed, body conforme', async () => {
|
||
const id1 = await enqueue({ operation: 'create_resource', payload: { name: 'A' } });
|
||
const id2 = await enqueue({ operation: 'create_resource', payload: { name: 'B' } });
|
||
stubFetch(() => ok({ applied: 2, failed: null }));
|
||
|
||
const result = await pushPendingOps();
|
||
|
||
assert.deepEqual(result, { pushed: 2, retried: 0 });
|
||
assert.equal(pathOf(calls[0].url), '/api/v1/sync/ops');
|
||
assert.equal(calls[0].init.method, 'POST');
|
||
const body = JSON.parse(String(calls[0].init.body));
|
||
assert.equal(body.operations.length, 2);
|
||
assert.equal(body.operations[0].operation_id, id1);
|
||
assert.equal(body.operations[0].resource_type, 'folder');
|
||
assert.deepEqual(body.operations[0].payload, { name: 'A' });
|
||
assert.equal(body.operations[1].operation_id, id2);
|
||
|
||
const ops = await getPendingOperations();
|
||
const byId = new Map(ops.map((op) => [op.id, op]));
|
||
assert.equal(byId.get(id1)?.status, 'completed');
|
||
assert.equal(byId.get(id2)?.status, 'completed');
|
||
assert.equal(byId.get(id1)?.attempts, 0);
|
||
});
|
||
|
||
test('succès partiel : reprise exacte à l’index applied (les suivantes intactes)', async () => {
|
||
const id1 = await enqueue({ operation: 'create_resource' });
|
||
const id2 = await enqueue({ operation: 'create_resource' });
|
||
const id3 = await enqueue({ operation: 'create_resource' });
|
||
stubFetch(() =>
|
||
ok({
|
||
applied: 1,
|
||
failed: { operation_id: id2, code: 'NAME_CONFLICT', message: 'exists déjà' },
|
||
}),
|
||
);
|
||
|
||
const result = await pushPendingOps();
|
||
|
||
assert.deepEqual(result, { pushed: 1, retried: 0 });
|
||
const ops = await getPendingOperations();
|
||
const byId = new Map(ops.map((op) => [op.id, op]));
|
||
assert.equal(byId.get(id1)?.status, 'completed');
|
||
assert.equal(byId.get(id2)?.status, 'pending');
|
||
assert.equal(byId.get(id2)?.attempts, 1); // backoff programmé, par dead-letter
|
||
assert.ok((byId.get(id2)?.nextRetryAt ?? 0) > 0);
|
||
assert.match(byId.get(id2)?.error ?? '', /NAME_CONFLICT/);
|
||
assert.equal(byId.get(id3)?.status, 'pending');
|
||
assert.equal(byId.get(id3)?.attempts, 0); // jamais soumise → intacte
|
||
});
|
||
|
||
test('applied = 0 + échec : rien commité, pas de dead-letter prématurée', async () => {
|
||
const id1 = await enqueue({ operation: 'create_resource' });
|
||
stubFetch(() =>
|
||
ok({ applied: 0, failed: { operation_id: id1, code: 'INVALID_REQUEST', message: 'payload invalide' } }),
|
||
);
|
||
|
||
const result = await pushPendingOps();
|
||
|
||
assert.deepEqual(result, { pushed: 0, retried: 0 });
|
||
const [op] = await getPendingOperations();
|
||
assert.equal(op.status, 'pending');
|
||
assert.equal(op.attempts, 1); // < MAX_PENDING_ATTEMPTS, le cycle recommence
|
||
assert.ok((op.nextRetryAt ?? 0) > 0);
|
||
});
|
||
|
||
test('échec transitoire (fetch rejette) : retrié sans incrémenter attempts', async () => {
|
||
const id1 = await enqueue({ operation: 'create_resource' });
|
||
const id2 = await enqueue({ operation: 'create_resource' });
|
||
stubFetch(() => {
|
||
throw new Error('network unreachable');
|
||
});
|
||
|
||
const result = await pushPendingOps();
|
||
|
||
assert.deepEqual(result, { pushed: 0, retried: 2 });
|
||
const ops = await getPendingOperations();
|
||
for (const op of ops) {
|
||
assert.equal(op.status, 'pending');
|
||
assert.equal(op.attempts, 0);
|
||
assert.ok((op.nextRetryAt ?? 0) > Date.now());
|
||
}
|
||
});
|
||
|
||
test('échec transitoire 5xx : idem (HTTP_500 → retry, pas de dead-letter)', async () => {
|
||
const id1 = await enqueue({ operation: 'create_resource' });
|
||
stubFetch(() => jsonResponse(500, { error: { code: 'INTERNAL', message: 'boom' } }));
|
||
|
||
const result = await pushPendingOps();
|
||
|
||
assert.deepEqual(result, { pushed: 0, retried: 1 });
|
||
const [op] = await getPendingOperations();
|
||
assert.equal(op.status, 'pending');
|
||
assert.equal(op.attempts, 0);
|
||
});
|
||
|
||
test('refus terminal répété : dead-letter après MAX_PENDING_ATTEMPTS', async () => {
|
||
const id1 = await enqueue({ operation: 'create_resource' });
|
||
stubFetch(() =>
|
||
ok({ applied: 0, failed: { operation_id: id1, code: 'NAME_CONFLICT', message: 'dup' } }),
|
||
);
|
||
|
||
for (let i = 0; i < MAX_PENDING_ATTEMPTS; i++) {
|
||
await pushPendingOps();
|
||
// le backoff repousse next_retry_at : on force le reset pour re-soumettre
|
||
await h.runAsync('UPDATE pending_operations SET next_retry_at = NULL');
|
||
}
|
||
|
||
const [op] = await getPendingOperations();
|
||
assert.equal(op.status, 'failed');
|
||
assert.equal(op.attempts, MAX_PENDING_ATTEMPTS);
|
||
assert.equal(op.nextRetryAt, null); // plus jamais rejouée
|
||
});
|
||
|
||
test('listQueuedOperations honore le tri FIFO et le LIMIT', async () => {
|
||
const id1 = await enqueue({ operation: 'create_resource' });
|
||
const id2 = await enqueue({ operation: 'create_resource' });
|
||
const id3 = await enqueue({ operation: 'create_resource' });
|
||
|
||
const first = await listQueuedOperations(2);
|
||
assert.deepEqual(first.map((op) => op.id), [id1, id2]);
|
||
|
||
const later = Date.now() + 60_000;
|
||
await scheduleRetries([id2], later);
|
||
const due = await listQueuedOperations(10);
|
||
assert.deepEqual(due.map((op) => op.id), [id1, id3]); // id2 pas encore due
|
||
});
|
||
|
||
test('scheduleRetries ne change pas attempts (différent de markPendingOperation failed)', async () => {
|
||
const id1 = await enqueue({ operation: 'create_resource' });
|
||
await scheduleRetries([id1], Date.now() + 1);
|
||
const [op] = await getPendingOperations();
|
||
assert.equal(op.attempts, 0);
|
||
assert.ok((op.nextRetryAt ?? 0) > 0);
|
||
});
|
||
|
||
test('refreshPermissions : delta après = max cachedAt du snapshot', async () => {
|
||
const perms = [
|
||
{
|
||
resource_id: 'a'.repeat(32),
|
||
resourceType: 'folder',
|
||
effectiveAccess: 'owner',
|
||
inherit: true,
|
||
ownerId: 'u1',
|
||
sharedById: null,
|
||
expiresAt: null,
|
||
cachedAt: 1000,
|
||
updatedAt: 1000,
|
||
},
|
||
{
|
||
resource_id: 'b'.repeat(32),
|
||
resourceType: 'file',
|
||
effectiveAccess: 'viewer',
|
||
inherit: false,
|
||
ownerId: 'u2',
|
||
sharedById: 'u1',
|
||
expiresAt: 5,
|
||
cachedAt: 2000,
|
||
updatedAt: 2000,
|
||
},
|
||
];
|
||
stubFetch((url) => (new URL(url).searchParams.get('after') === null ? ok(perms) : ok([])));
|
||
|
||
const n1 = await refreshPermissions();
|
||
assert.equal(n1, 2);
|
||
assert.equal(pathOf(calls[0].url), '/api/v1/sync/permissions');
|
||
assert.equal((await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess, 'owner');
|
||
assert.equal((await getResourcePermission('b'.repeat(32), 'file'))?.expiresAt, 5);
|
||
|
||
// 2e tick (snapshot vide) → after = max cachedAt du 1er
|
||
const n2 = await refreshPermissions();
|
||
assert.equal(n2, 0);
|
||
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions?after=2000');
|
||
});
|
||
|
||
test('refreshPermissions garde la référence dans le temps pour after', async () => {
|
||
const first = {
|
||
resource_id: 'c'.repeat(32),
|
||
resourceType: 'folder',
|
||
effectiveAccess: 'editor',
|
||
inherit: true,
|
||
ownerId: 'u1',
|
||
sharedById: null,
|
||
expiresAt: null,
|
||
cachedAt: 42,
|
||
updatedAt: 42,
|
||
};
|
||
const second = {
|
||
resource_id: 'd'.repeat(32),
|
||
resourceType: 'folder',
|
||
effectiveAccess: 'viewer',
|
||
inherit: false,
|
||
ownerId: 'u3',
|
||
sharedById: 'u1',
|
||
expiresAt: null,
|
||
cachedAt: 84,
|
||
updatedAt: 84,
|
||
};
|
||
stubFetch((url) => {
|
||
const after = new URL(url).searchParams.get('after');
|
||
return ok(after === null ? [first] : [second]);
|
||
});
|
||
|
||
await refreshPermissions();
|
||
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',
|
||
);
|
||
}); |