442 lines
17 KiB
TypeScript
442 lines
17 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 {
|
|
saveFolder,
|
|
getFolders,
|
|
getFolder,
|
|
removeFolder,
|
|
saveFile,
|
|
getFiles,
|
|
canAccess,
|
|
saveResourcePermission,
|
|
saveShare,
|
|
getShares,
|
|
createShareLink,
|
|
getShareLinks,
|
|
incrementLinkDownloads,
|
|
enqueuePendingOperation,
|
|
getPendingOperations,
|
|
getNextQueuedOperation,
|
|
markPendingOperation,
|
|
getDeviceUserId,
|
|
DEVICE_USER_ID_KEY,
|
|
PERMISSION_TTL_MS,
|
|
} from '../services/db';
|
|
import type { FileEntry } from '../services/safDirectory.types';
|
|
import { MAX_PENDING_ATTEMPTS } from '../services/db/repositories/pendingOps';
|
|
|
|
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;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
h = createHarness();
|
|
for (const migration of MIGRATIONS) {
|
|
await migration.up(h);
|
|
}
|
|
__setDbForTests(h);
|
|
});
|
|
|
|
afterEach(() => {
|
|
__setDbForTests(null);
|
|
});
|
|
|
|
function fileEntry(uri: string, overrides: Partial<FileEntry> = {}): FileEntry {
|
|
return {
|
|
uri,
|
|
name: uri.split('/').pop() ?? uri,
|
|
isDirectory: false,
|
|
extension: uri.split('.').pop() ?? '',
|
|
exists: true,
|
|
size: 10,
|
|
type: 'application/octet-stream',
|
|
lastModified: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function setOwner(
|
|
table: 'folders' | 'files',
|
|
resourceId: string,
|
|
ownerId: string,
|
|
): Promise<void> {
|
|
await h.runAsync(`UPDATE ${table} SET owner_id = ? WHERE resource_id = ?`, ownerId, resourceId);
|
|
}
|
|
|
|
test('folder upsert is idempotent by uri (same resource_id, single row)', async () => {
|
|
const a = await saveFolder({ uri: 'content://a', name: 'A' });
|
|
const b = await saveFolder({ uri: 'content://a', name: 'A renamed' });
|
|
|
|
assert.equal(a.resource_id, b.resource_id);
|
|
assert.equal(b.name, 'A renamed');
|
|
assert.equal(b.uri, 'content://a');
|
|
assert.equal((await getFolders()).length, 1);
|
|
});
|
|
|
|
test('distinct uris produce distinct rows', async () => {
|
|
await saveFolder({ uri: 'content://a', name: 'A' });
|
|
await saveFolder({ uri: 'content://b', name: 'B' });
|
|
assert.equal((await getFolders()).length, 2);
|
|
});
|
|
|
|
test('cloud-only folder gains a uri without duplicating (reconciled by resource_id)', async () => {
|
|
const cloud = await saveFolder({ uri: null, name: 'Cloud', resource_id: 'abc123' });
|
|
assert.equal(cloud.uri, null);
|
|
const wired = await saveFolder({ uri: 'content://cloud', name: 'Cloud', resource_id: 'abc123' });
|
|
assert.equal(wired.resource_id, 'abc123');
|
|
assert.equal(wired.uri, 'content://cloud');
|
|
assert.equal((await getFolders()).length, 1);
|
|
});
|
|
|
|
test('inserting a folder with a nonexistent parent throws a FK error (no silent orphan)', async () => {
|
|
await assert.rejects(
|
|
() => saveFolder({ uri: 'content://child', name: 'C' }, { parentResourceId: 'does-not-exist' }),
|
|
/FOREIGN KEY/,
|
|
);
|
|
});
|
|
|
|
test('inserting a file before its parent folder throws a FK error', async () => {
|
|
await assert.rejects(
|
|
() => saveFile(fileEntry('content://orphan/f.pdf'), 'missing-folder'),
|
|
/FOREIGN KEY/,
|
|
);
|
|
});
|
|
|
|
test('re-saving a folder without a parent keeps the existing parent', async () => {
|
|
const root = await saveFolder({ uri: 'content://r', name: 'R' });
|
|
const child = await saveFolder({ uri: 'content://r/c', name: 'C' }, { parentResourceId: root.resource_id });
|
|
const reSaved = await saveFolder({ uri: 'content://r/c', name: 'C2' });
|
|
assert.equal(reSaved.parent_resource_id, root.resource_id);
|
|
});
|
|
|
|
test('a full SAF walk repeated twice is a no-op (BFS order, uri reconciliation)', async () => {
|
|
const root = await saveFolder({ uri: 'content://root', name: 'Root' });
|
|
const sub = await saveFolder({ uri: 'content://root/sub', name: 'Sub' }, { parentResourceId: root.resource_id });
|
|
await saveFile(fileEntry('content://root/sub/f1.pdf'), sub.resource_id);
|
|
|
|
const root2 = await saveFolder({ uri: 'content://root', name: 'Root' });
|
|
const sub2 = await saveFolder({ uri: 'content://root/sub', name: 'Sub' }, { parentResourceId: root2.resource_id });
|
|
await saveFile(fileEntry('content://root/sub/f1.pdf'), sub2.resource_id);
|
|
|
|
assert.equal(root.resource_id, root2.resource_id);
|
|
assert.equal(sub.resource_id, sub2.resource_id);
|
|
assert.equal(sub2.parent_resource_id, root2.resource_id);
|
|
assert.equal((await getFolders()).length, 2);
|
|
assert.equal((await getFiles()).length, 1);
|
|
const stored = await getFiles(sub2.resource_id);
|
|
assert.equal(stored.length, 1);
|
|
});
|
|
|
|
test('removing a root folder cascades to its subtree and files', async () => {
|
|
const root = await saveFolder({ uri: 'content://root', name: 'Root' });
|
|
const sub = await saveFolder({ uri: 'content://root/sub', name: 'Sub' }, { parentResourceId: root.resource_id });
|
|
await saveFile(fileEntry('content://root/sub/f1.pdf'), sub.resource_id);
|
|
|
|
await removeFolder(root.resource_id);
|
|
|
|
assert.equal((await getFolders()).length, 0);
|
|
assert.equal((await getFiles()).length, 0);
|
|
});
|
|
|
|
test('canAccess: nothing granted => denied, owner fallback grants owner', async () => {
|
|
const root = await saveFolder({ uri: 'content://r', name: 'R' });
|
|
const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id });
|
|
const f = await saveFile(fileEntry('content://r/s/f.pdf'), sub.resource_id);
|
|
|
|
const device = await getDeviceUserId();
|
|
await setOwner('folders', root.resource_id, 'other');
|
|
await setOwner('folders', sub.resource_id, 'other');
|
|
await setOwner('files', f.resource_id, 'other');
|
|
|
|
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, false);
|
|
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, false);
|
|
|
|
await setOwner('folders', sub.resource_id, device);
|
|
assert.equal((await canAccess(f.resource_id, 'file', 'owner')).allowed, true);
|
|
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).source, 'owner');
|
|
});
|
|
|
|
test('canAccess: granted permission on an ancestor inherits through deep folders', async () => {
|
|
const ids: string[] = [];
|
|
let parentResourceId: string | null = null;
|
|
for (let depth = 0; depth < 5; depth++) {
|
|
const folder = await saveFolder(
|
|
{ uri: `content://chain/${depth}`, name: `n${depth}` },
|
|
{ parentResourceId },
|
|
);
|
|
ids.push(folder.resource_id);
|
|
parentResourceId = folder.resource_id;
|
|
}
|
|
const f = await saveFile(fileEntry('content://chain/f.pdf'), parentResourceId!);
|
|
const device = await getDeviceUserId();
|
|
for (const id of ids) await setOwner('folders', id, 'other');
|
|
await setOwner('files', f.resource_id, 'other');
|
|
|
|
await saveResourcePermission({
|
|
resource_id: ids[0],
|
|
resourceType: 'folder',
|
|
effectiveAccess: 'viewer',
|
|
inherit: true,
|
|
});
|
|
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, true, 'viewer inherited from root');
|
|
assert.equal((await canAccess(f.resource_id, 'file', 'editor')).allowed, false, 'viewer is not editor');
|
|
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).source, 'inherited');
|
|
});
|
|
|
|
test('canAccess: inherit=false blocks propagation but allows the node itself', async () => {
|
|
const root = await saveFolder({ uri: 'content://r', name: 'R' });
|
|
const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id });
|
|
const f = await saveFile(fileEntry('content://r/s/f.pdf'), sub.resource_id);
|
|
const device = await getDeviceUserId();
|
|
await setOwner('folders', root.resource_id, 'other');
|
|
await setOwner('folders', sub.resource_id, 'other');
|
|
await setOwner('files', f.resource_id, 'other');
|
|
|
|
await saveResourcePermission({
|
|
resource_id: root.resource_id,
|
|
resourceType: 'folder',
|
|
effectiveAccess: 'viewer',
|
|
inherit: false,
|
|
});
|
|
assert.equal((await canAccess(root.resource_id, 'folder', 'viewer')).allowed, true, 'root itself keeps viewer');
|
|
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, false, 'inheritance stopped');
|
|
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, false);
|
|
|
|
await saveResourcePermission({
|
|
resource_id: sub.resource_id,
|
|
resourceType: 'folder',
|
|
effectiveAccess: 'viewer',
|
|
inherit: true,
|
|
});
|
|
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, true, 'closer ancestor grants descend');
|
|
});
|
|
|
|
test('canAccess: an expired permission is denied and does not propagate', async () => {
|
|
const root = await saveFolder({ uri: 'content://r', name: 'R' });
|
|
const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id });
|
|
const device = await getDeviceUserId();
|
|
await setOwner('folders', root.resource_id, 'other');
|
|
await setOwner('folders', sub.resource_id, 'other');
|
|
|
|
await saveResourcePermission({
|
|
resource_id: root.resource_id,
|
|
resourceType: 'folder',
|
|
effectiveAccess: 'viewer',
|
|
inherit: true,
|
|
expiresAt: Date.now() - 1000,
|
|
});
|
|
assert.equal((await canAccess(root.resource_id, 'folder', 'viewer')).allowed, false);
|
|
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, false);
|
|
});
|
|
|
|
test('canAccess: stale cache only allows read (viewer), fresh cache allows the granted level', async () => {
|
|
const root = await saveFolder({ uri: 'content://r', name: 'R' });
|
|
const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id });
|
|
const device = await getDeviceUserId();
|
|
await setOwner('folders', root.resource_id, 'other');
|
|
await setOwner('folders', sub.resource_id, 'other');
|
|
|
|
await saveResourcePermission({
|
|
resource_id: root.resource_id,
|
|
resourceType: 'folder',
|
|
effectiveAccess: 'editor',
|
|
inherit: true,
|
|
});
|
|
assert.equal((await canAccess(sub.resource_id, 'folder', 'editor')).allowed, true, 'fresh editor grant');
|
|
|
|
await h.runAsync(
|
|
'UPDATE resource_permissions SET cached_at = ? WHERE resource_id = ?',
|
|
Date.now() - PERMISSION_TTL_MS - 60_000,
|
|
root.resource_id,
|
|
);
|
|
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, true, 'stale still allows read');
|
|
assert.equal((await canAccess(sub.resource_id, 'folder', 'editor')).allowed, false, 'stale blocks writes');
|
|
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).stale, true);
|
|
});
|
|
|
|
test('canAccess: a cached decision on the exact node is authoritative even if weaker', async () => {
|
|
const root = await saveFolder({ uri: 'content://r', name: 'R' });
|
|
const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id });
|
|
const device = await getDeviceUserId();
|
|
await setOwner('folders', root.resource_id, 'other');
|
|
await setOwner('folders', sub.resource_id, 'other');
|
|
|
|
await saveResourcePermission({
|
|
resource_id: root.resource_id,
|
|
resourceType: 'folder',
|
|
effectiveAccess: 'editor',
|
|
inherit: true,
|
|
});
|
|
await saveResourcePermission({
|
|
resource_id: sub.resource_id,
|
|
resourceType: 'folder',
|
|
effectiveAccess: 'viewer',
|
|
inherit: true,
|
|
});
|
|
|
|
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, true);
|
|
assert.equal((await canAccess(sub.resource_id, 'folder', 'editor')).allowed, false, 'exact viewer cache overrides inherited editor');
|
|
});
|
|
|
|
test('outbox: ops on the same resource are drained in FIFO order (id tie-break)', async () => {
|
|
const id1 = await enqueuePendingOperation({ operation: 'create_resource', resourceId: 'a' });
|
|
const id2 = await enqueuePendingOperation({ operation: 'update_metadata', resourceId: 'a' });
|
|
const id3 = await enqueuePendingOperation({ operation: 'delete_resource', resourceId: 'a' });
|
|
await h.runAsync('UPDATE pending_operations SET created_at = ?', 1000);
|
|
|
|
const ops = await getPendingOperations();
|
|
assert.deepEqual(
|
|
ops.map((o) => o.operation),
|
|
['create_resource', 'update_metadata', 'delete_resource'],
|
|
);
|
|
|
|
const first = await getNextQueuedOperation();
|
|
assert.equal(first!.id, id1);
|
|
await markPendingOperation(first!.id, 'completed');
|
|
const second = await getNextQueuedOperation();
|
|
assert.equal(second!.id, id2);
|
|
await markPendingOperation(second!.id, 'completed');
|
|
assert.equal((await getNextQueuedOperation())!.id, id3);
|
|
});
|
|
|
|
test('outbox: a failure stays pending with a backoff, then dead-letters after max attempts', async () => {
|
|
const id = await enqueuePendingOperation({ operation: 'create_resource', resourceId: 'a' });
|
|
|
|
await markPendingOperation(id, 'failed', 'boom');
|
|
let row = await h.getFirstAsync<{
|
|
status: string;
|
|
attempts: number;
|
|
next_retry_at: number | null;
|
|
}>('SELECT status, attempts, next_retry_at FROM pending_operations WHERE id = ?', id);
|
|
assert.equal(row!.status, 'pending');
|
|
assert.equal(row!.attempts, 1);
|
|
assert.ok((row!.next_retry_at ?? 0) > Date.now(), 'retry scheduled in the future');
|
|
|
|
assert.equal(await getNextQueuedOperation(), null, 'backoff respects next_retry_at');
|
|
|
|
await h.runAsync('UPDATE pending_operations SET next_retry_at = ? WHERE id = ?', 0, id);
|
|
const due = await getNextQueuedOperation();
|
|
assert.ok(due);
|
|
assert.equal(due.attempts, 1);
|
|
|
|
for (let i = 0; i < MAX_PENDING_ATTEMPTS - 1; i++) {
|
|
await markPendingOperation(id, 'failed', 'still failing');
|
|
}
|
|
row = await h.getFirstAsync<{
|
|
status: string;
|
|
attempts: number;
|
|
next_retry_at: number | null;
|
|
}>('SELECT status, attempts, next_retry_at FROM pending_operations WHERE id = ?', id);
|
|
assert.equal(row!.status, 'failed', 'dead-lettered');
|
|
assert.equal(row!.attempts, MAX_PENDING_ATTEMPTS);
|
|
assert.equal(row!.next_retry_at, null);
|
|
assert.equal(await getNextQueuedOperation(), null, 'dead-lettered ops are never re-picked');
|
|
|
|
const share = await saveShare({
|
|
resourceId: 'a',
|
|
resourceType: 'folder',
|
|
recipientType: 'user',
|
|
recipientId: 'u1',
|
|
relation: 'viewer',
|
|
});
|
|
assert.equal(share.pushStatus, 'synced');
|
|
});
|
|
|
|
test('shares and share links derive pushStatus from the outbox', async () => {
|
|
const share = await saveShare({
|
|
resourceId: 'res-1',
|
|
resourceType: 'folder',
|
|
recipientType: 'user',
|
|
recipientId: 'u1',
|
|
relation: 'editor',
|
|
});
|
|
assert.equal(share.pushStatus, 'synced');
|
|
|
|
const opId = await enqueuePendingOperation({
|
|
refType: 'share',
|
|
refId: share.id,
|
|
operation: 'share',
|
|
resourceId: 'res-1',
|
|
resourceType: 'folder',
|
|
});
|
|
assert.equal((await getShares('res-1', 'folder'))[0].pushStatus, 'pending');
|
|
|
|
await markPendingOperation(opId, 'failed', 'nope');
|
|
assert.equal(
|
|
(await getShares('res-1', 'folder'))[0].pushStatus,
|
|
'pending',
|
|
'a single failure schedules a retry, share stays pending',
|
|
);
|
|
|
|
for (let attempt = 1; attempt < MAX_PENDING_ATTEMPTS; attempt++) {
|
|
await markPendingOperation(opId, 'failed', 'still failing');
|
|
}
|
|
assert.equal((await getShares('res-1', 'folder'))[0].pushStatus, 'failed', 'dead-lettered');
|
|
|
|
await markPendingOperation(opId, 'completed');
|
|
const shares = await getShares('res-1', 'folder');
|
|
assert.equal(shares.length, 1);
|
|
assert.equal(shares[0].pushStatus, 'synced', 'no pending/failed left');
|
|
|
|
const link = await createShareLink({ resourceId: 'res-1', resourceType: 'folder' });
|
|
assert.match(link.token, /^[0-9a-f]{32}$/);
|
|
assert.equal(link.pushStatus, 'synced');
|
|
|
|
const linkOpId = await enqueuePendingOperation({
|
|
refType: 'share_link',
|
|
refId: link.id,
|
|
operation: 'create_link',
|
|
});
|
|
assert.equal((await getShareLinks('res-1', 'folder'))[0].pushStatus, 'pending');
|
|
await markPendingOperation(linkOpId, 'completed');
|
|
assert.equal((await getShareLinks('res-1', 'folder'))[0].pushStatus, 'synced');
|
|
|
|
await incrementLinkDownloads(link.id);
|
|
assert.equal((await getShareLinks('res-1', 'folder'))[0].downloadsCount, 1);
|
|
});
|
|
|
|
test('device user id is a stable lowercase 32-hex string shared by all rows', async () => {
|
|
const device = await getDeviceUserId();
|
|
assert.match(device, /^[0-9a-f]{32}$/);
|
|
|
|
const root = await saveFolder({ uri: 'content://r', name: 'R' });
|
|
const rootRow = await getFolder(root.resource_id);
|
|
assert.equal(rootRow!.owner_id, device);
|
|
|
|
const seeded = await h.getFirstAsync<{ value: string }>(
|
|
'SELECT "value" FROM user_preferences WHERE "key" = ?',
|
|
DEVICE_USER_ID_KEY,
|
|
);
|
|
assert.equal(seeded!.value, device);
|
|
}); |