mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
703 lines
30 KiB
TypeScript
703 lines
30 KiB
TypeScript
/**
|
|
* The configuration half of document sync: the connection a trip admin creates
|
|
* and the binding that points it at a folder or a tag.
|
|
*
|
|
* The database here is real in-memory SQLite with the full schema, and the
|
|
* at-rest crypto is real too. Both are deliberate. Almost everything this
|
|
* service owns is either a credential or a decision about one, and a spy on
|
|
* `db.run` would only prove that some strings were handed along. Reading the
|
|
* row back is the assertion. A stubbed `maybe_encrypt_api_key` would let a
|
|
* regression that stores a Paperless token in plaintext pass every case below.
|
|
*
|
|
* Only the SSRF guard is faked, because it resolves DNS: the guard is not what
|
|
* is under test, and a suite that needs a working resolver is a suite that
|
|
* fails on someone's train.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
return {
|
|
testDb: db,
|
|
dbMock: {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: () => undefined,
|
|
isOwner: () => false,
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../../../../src/db/database', () => dbMock);
|
|
|
|
// The whole guard surface, not only `checkSsrf`: modules on the import graph
|
|
// pull other names off it, and a factory mock that omits one throws on access
|
|
// rather than falling back to the real export.
|
|
const { checkSsrf } = vi.hoisted(() => ({ checkSsrf: vi.fn() }));
|
|
vi.mock('../../../../src/utils/ssrfGuard', () => ({
|
|
checkSsrf,
|
|
safeFetch: vi.fn(),
|
|
safeFetchFollow: vi.fn(),
|
|
safeFetchAdminConfigured: vi.fn(),
|
|
safeFetchLlm: vi.fn(),
|
|
createPinnedDispatcher: vi.fn(() => ({})),
|
|
SsrfBlockedError: class extends Error {},
|
|
}));
|
|
|
|
import { DOCSYNC_SECRET_MASK, type DocsyncConnectionInput, type DocsyncLinkInput } from '@trek/shared';
|
|
import { createTables } from '../../../../src/db/schema';
|
|
import { runMigrations } from '../../../../src/db/migrations';
|
|
import { resetTestDb } from '../../../helpers/test-db';
|
|
import { createTrip, createUser } from '../../../helpers/factories';
|
|
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
|
import {
|
|
DocSyncConfigService,
|
|
type ConnectionRow,
|
|
type LinkRow,
|
|
} from '../../../../src/nest/doc-sync/doc-sync-config.service';
|
|
import type { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
|
|
// `if (!result.success)` does not narrow in this workspace: strictNullChecks is
|
|
// off, so the boolean discriminant stops discriminating. The domain's own
|
|
// predicate is what every call site uses instead.
|
|
import { docFailed } from '../../../../src/nest/doc-sync/document-provider';
|
|
|
|
const svc = new DocSyncConfigService(new DatabaseService(testDb), {} as DocumentProviderRegistry);
|
|
|
|
const TOKEN = 'paperless-token-1234';
|
|
|
|
let OWNER = 0;
|
|
let MEMBER = 0;
|
|
let TRIP = 0;
|
|
|
|
function connectionInput(over: Partial<DocsyncConnectionInput> = {}): DocsyncConnectionInput {
|
|
return {
|
|
providerId: 'paperless',
|
|
baseUrl: 'https://paperless.example.com',
|
|
credentials: { api_token: TOKEN },
|
|
allowInsecureTls: false,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
async function connect(over: Partial<DocsyncConnectionInput> = {}, userId = OWNER): Promise<ConnectionRow> {
|
|
const result = await svc.upsertConnection(TRIP, userId, connectionInput(over));
|
|
if (docFailed(result)) throw new Error(`fixture connection refused: ${JSON.stringify(result.error)}`);
|
|
return result.data;
|
|
}
|
|
|
|
function linkInput(connectionId: number, over: Partial<DocsyncLinkInput> = {}): DocsyncLinkInput {
|
|
return {
|
|
connectionId,
|
|
scopeKey: 'tag:1',
|
|
remoteLabel: 'Japan 2026',
|
|
direction: 'both',
|
|
deletePolicy: 'unlink',
|
|
conflictPolicy: 'manual',
|
|
syncEnabled: true,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function link(connectionId: number, over: Partial<DocsyncLinkInput> = {}): LinkRow {
|
|
const result = svc.createLink(TRIP, OWNER, linkInput(connectionId, over));
|
|
if (docFailed(result)) throw new Error(`fixture link refused: ${JSON.stringify(result.error)}`);
|
|
return result.data;
|
|
}
|
|
|
|
function storedSecret(connectionId: number): string | undefined {
|
|
const row = svc.getConnection(connectionId) as ConnectionRow;
|
|
return svc.toRef(row).secrets.api_token;
|
|
}
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// The document_* tables are not in resetTestDb's list, and they reference
|
|
// trips and users, so they go first, before the rows they hang off vanish.
|
|
testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections;');
|
|
resetTestDb(testDb);
|
|
vi.clearAllMocks();
|
|
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '203.0.113.10' });
|
|
OWNER = createUser(testDb, { username: 'owner', email: 'owner@test.local' }).user.id;
|
|
MEMBER = createUser(testDb, { username: 'member', email: 'member@test.local' }).user.id;
|
|
TRIP = createTrip(testDb, OWNER, { title: 'Japan' }).id;
|
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(TRIP, MEMBER);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
describe('upsertConnection secrets', () => {
|
|
it('keeps the stored credential when the form comes back with the field blank', async () => {
|
|
const created = await connect();
|
|
await connect({ credentials: { api_token: '' } });
|
|
expect(storedSecret(created.id)).toBe(TOKEN);
|
|
});
|
|
|
|
it('keeps it when the form posts back the mask it was shown', async () => {
|
|
// This is what makes a GET-then-PUT round trip safe: the client renders the
|
|
// form from the masked view and never has to hold the real credential.
|
|
const created = await connect();
|
|
await connect({ credentials: { api_token: DOCSYNC_SECRET_MASK } });
|
|
expect(storedSecret(created.id)).toBe(TOKEN);
|
|
});
|
|
|
|
it('keeps it when the field is missing from the payload altogether', async () => {
|
|
const created = await connect();
|
|
await connect({ credentials: {} });
|
|
expect(storedSecret(created.id)).toBe(TOKEN);
|
|
});
|
|
|
|
it('replaces it when a new credential actually arrives', async () => {
|
|
const created = await connect();
|
|
await connect({ credentials: { api_token: 'rotated-token' } });
|
|
expect(storedSecret(created.id)).toBe('rotated-token');
|
|
});
|
|
|
|
it('updates the trip\'s connection in place rather than adding a second one', async () => {
|
|
const first = await connect();
|
|
const second = await connect({ baseUrl: 'https://paperless.example.com/', credentials: {} });
|
|
expect(second.id).toBe(first.id);
|
|
const count = testDb
|
|
.prepare('SELECT COUNT(*) AS n FROM document_connections WHERE trip_id = ?')
|
|
.get(TRIP) as { n: number };
|
|
expect(count.n).toBe(1);
|
|
});
|
|
|
|
it('carries a non-secret setting forward when a later save omits it', async () => {
|
|
const created = await svc.upsertConnection(TRIP, OWNER, {
|
|
providerId: 'nextcloud',
|
|
baseUrl: 'https://cloud.example.com',
|
|
credentials: { login_name: 'anna', app_password: 'app-pw', base_path: '/TREK' },
|
|
allowInsecureTls: false,
|
|
});
|
|
expect(created.success).toBe(true);
|
|
|
|
await svc.upsertConnection(TRIP, OWNER, {
|
|
providerId: 'nextcloud',
|
|
baseUrl: 'https://cloud.example.com',
|
|
credentials: { login_name: 'anna' },
|
|
allowInsecureTls: false,
|
|
});
|
|
const row = svc.listConnections(TRIP).find((c) => c.provider_id === 'nextcloud') as ConnectionRow;
|
|
expect(svc.toRef(row).settings).toMatchObject({ login_name: 'anna', base_path: '/TREK' });
|
|
expect(svc.toRef(row).secrets.app_password).toBe('app-pw');
|
|
});
|
|
});
|
|
|
|
describe('publicConnection', () => {
|
|
it('reports that a secret is set without ever carrying its value', async () => {
|
|
const created = await connect();
|
|
const view = svc.publicConnection(svc.getConnection(created.id) as ConnectionRow);
|
|
expect(view.secrets).toEqual({ api_token: DOCSYNC_SECRET_MASK });
|
|
expect(JSON.stringify(view)).not.toContain(TOKEN);
|
|
});
|
|
|
|
it('leaves a secret it has no value for out of the map entirely', async () => {
|
|
// Masking an unset field would tell the client "already configured" and the
|
|
// connection form would render an optional credential as one that is done.
|
|
const created = await svc.upsertConnection(TRIP, OWNER, {
|
|
providerId: 'synologydrive',
|
|
baseUrl: 'https://nas.example.com:5001',
|
|
credentials: { username: 'anna', password: 'nas-pw' },
|
|
allowInsecureTls: false,
|
|
});
|
|
expect(created.success).toBe(true);
|
|
const row = svc.listConnections(TRIP).find((c) => c.provider_id === 'synologydrive') as ConnectionRow;
|
|
expect(svc.publicConnection(row).secrets).toEqual({ password: DOCSYNC_SECRET_MASK });
|
|
});
|
|
|
|
it('reads a capabilities blob left behind by another build as unknown, not as a crash', async () => {
|
|
const created = await connect();
|
|
testDb.prepare('UPDATE document_connections SET capabilities = ? WHERE id = ?').run('{not json', created.id);
|
|
const view = svc.publicConnection(svc.getConnection(created.id) as ConnectionRow);
|
|
expect(view.capabilities).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('a secret the provider earned itself', () => {
|
|
// The shape the Synology adapter stores: account digest, colon, DSM's token.
|
|
const EARNED = 'a1b2c3:DEVICE-7';
|
|
|
|
function nas(credentials: Record<string, string> = { username: 'anna', password: 'nas-pw', otp_code: '123456' }) {
|
|
return connect({ providerId: 'synologydrive', baseUrl: 'https://nas.example.com:5001', credentials });
|
|
}
|
|
|
|
function secretsOf(connectionId: number): Readonly<Record<string, string>> {
|
|
return svc.toRef(svc.getConnection(connectionId) as ConnectionRow).secrets;
|
|
}
|
|
|
|
it('is stored through the ref the adapter was handed, encrypted with the form\'s secrets', async () => {
|
|
const conn = await nas();
|
|
svc.toRef(conn).saveSecret!('device_token', EARNED);
|
|
|
|
expect(secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456', device_token: EARNED });
|
|
expect((svc.getConnection(conn.id) as ConnectionRow).secrets).not.toContain('DEVICE-7');
|
|
});
|
|
|
|
it('never reaches a client, not even as a mask', async () => {
|
|
const conn = await nas();
|
|
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
|
|
const view = svc.publicConnection(svc.getConnection(conn.id) as ConnectionRow);
|
|
|
|
expect(view.secrets).toEqual({ password: DOCSYNC_SECRET_MASK, otp_code: DOCSYNC_SECRET_MASK });
|
|
expect(JSON.stringify(view)).not.toContain('DEVICE-7');
|
|
expect(JSON.stringify(view)).not.toContain('device_token');
|
|
});
|
|
|
|
it('survives a form edit, whatever the form sends for the secrets it does show', async () => {
|
|
const conn = await nas();
|
|
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
|
|
await nas({ username: 'anna', password: 'rotated', otp_code: '' });
|
|
await nas({ username: 'anna', password: DOCSYNC_SECRET_MASK });
|
|
|
|
expect(secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED });
|
|
});
|
|
|
|
it('cannot be planted or overwritten through the form', async () => {
|
|
const conn = await nas();
|
|
await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' });
|
|
expect(secretsOf(conn.id).device_token).toBeUndefined();
|
|
|
|
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
|
|
await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' });
|
|
expect(secretsOf(conn.id).device_token).toBe(EARNED);
|
|
expect(svc.toRef(svc.getConnection(conn.id) as ConnectionRow).settings).not.toHaveProperty('device_token');
|
|
});
|
|
|
|
it('is written against the row as it is now, so a run holding an old ref cannot undo a password edit', async () => {
|
|
const conn = await nas();
|
|
const ref = svc.toRef(conn);
|
|
await nas({ username: 'anna', password: 'rotated' });
|
|
ref.saveSecret!('device_token', EARNED);
|
|
|
|
expect(secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED });
|
|
});
|
|
|
|
it('is dropped with null, and the form\'s secrets stay', async () => {
|
|
const conn = await nas();
|
|
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
|
|
svc.saveEarnedSecret(conn.id, 'device_token', null);
|
|
|
|
expect(secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456' });
|
|
});
|
|
|
|
it('brings back no connection that was deleted while a run still held its ref', async () => {
|
|
const conn = await nas();
|
|
const ref = svc.toRef(conn);
|
|
svc.deleteConnection(conn.id);
|
|
|
|
ref.saveSecret!('device_token', EARNED);
|
|
expect(svc.getConnection(conn.id)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('validateBaseUrl', () => {
|
|
it('stores a private address, because a self-hosted instance is the point', async () => {
|
|
checkSsrf.mockResolvedValue({ allowed: false, isPrivate: true, error: 'private network' });
|
|
const verdict = await svc.validateBaseUrl('https://paperless.fritz.box');
|
|
expect(verdict).toEqual({ success: true, data: { url: 'https://paperless.fritz.box', isPrivate: true } });
|
|
|
|
const created = await connect({ baseUrl: 'https://paperless.fritz.box' });
|
|
expect(created.base_url).toBe('https://paperless.fritz.box');
|
|
});
|
|
|
|
it('refuses an address the guard rejects for any other reason, and writes nothing', async () => {
|
|
checkSsrf.mockResolvedValue({ allowed: false, isPrivate: false, error: 'Could not resolve hostname (ENOTFOUND)' });
|
|
const result = await svc.upsertConnection(TRIP, OWNER, connectionInput({ baseUrl: 'https://nope.invalid' }));
|
|
expect(result.success).toBe(false);
|
|
expect(result.success === false && result.error.code).toBe('ssrf_blocked');
|
|
expect(svc.listConnections(TRIP)).toHaveLength(0);
|
|
});
|
|
|
|
it('strips trailing slashes so the adapter can append its own path', async () => {
|
|
const created = await connect({ baseUrl: 'https://paperless.example.com///' });
|
|
expect(created.base_url).toBe('https://paperless.example.com');
|
|
});
|
|
|
|
it('refuses an empty address before it asks the guard anything', async () => {
|
|
const result = await svc.validateBaseUrl(' ');
|
|
expect(result.success).toBe(false);
|
|
expect(checkSsrf).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('upsertConnection required fields', () => {
|
|
it('names the missing credential instead of storing a connection that cannot authenticate', async () => {
|
|
const result = await svc.upsertConnection(TRIP, OWNER, connectionInput({ credentials: {} }));
|
|
expect(result.success).toBe(false);
|
|
expect(result.success === false && result.error.detail).toContain('api_token');
|
|
expect(svc.listConnections(TRIP)).toHaveLength(0);
|
|
});
|
|
|
|
it('refuses a missing required field that is not a secret either', async () => {
|
|
const result = await svc.upsertConnection(TRIP, OWNER, {
|
|
providerId: 'papra',
|
|
baseUrl: 'https://papra.example.com',
|
|
credentials: { api_key: 'ppapi_x' },
|
|
allowInsecureTls: false,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(result.success === false && result.error.detail).toContain('organization_id');
|
|
});
|
|
|
|
it('accepts a provider whose optional credential is left blank', async () => {
|
|
// Synology's OTP is a one-time code, not a stored credential; demanding it
|
|
// would make every save after the first one impossible.
|
|
const result = await svc.upsertConnection(TRIP, OWNER, {
|
|
providerId: 'synologydrive',
|
|
baseUrl: 'https://nas.example.com:5001',
|
|
credentials: { username: 'anna', password: 'nas-pw', otp_code: '' },
|
|
allowInsecureTls: false,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('enabledProviderIds', () => {
|
|
it('names only the providers an admin switched on, in the order the shelf shows them', () => {
|
|
// Every document provider ships OFF, so an empty answer on a fresh install
|
|
// is the correct one and a non-empty one would be a policy change.
|
|
expect(svc.enabledProviderIds()).toEqual([]);
|
|
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('nextcloud', 'paperless')").run();
|
|
expect(svc.enabledProviderIds()).toEqual(['paperless', 'nextcloud']);
|
|
});
|
|
});
|
|
|
|
describe('recordProbe', () => {
|
|
it('keeps what the last good probe learned when a later one fails', async () => {
|
|
// A token that expired overnight must not make TREK forget that this
|
|
// instance can accept uploads; the capabilities describe the provider, not
|
|
// the state of the credential.
|
|
const conn = await connect();
|
|
svc.recordProbe(conn.id, 'ok', null, { push: 'multipart' });
|
|
svc.recordProbe(conn.id, 'failed', '401 Unauthorized', null);
|
|
|
|
const view = svc.publicConnection(svc.getConnection(conn.id) as ConnectionRow);
|
|
expect(view).toMatchObject({
|
|
lastProbeState: 'failed',
|
|
lastProbeError: '401 Unauthorized',
|
|
capabilities: { push: 'multipart' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('deleteConnection', () => {
|
|
it('drops the bindings that hung off it and leaves the other connection alone', async () => {
|
|
const doomed = await connect();
|
|
const kept = await connect({
|
|
providerId: 'nextcloud',
|
|
baseUrl: 'https://cloud.example.com',
|
|
credentials: { login_name: 'anna', app_password: 'app-pw' },
|
|
});
|
|
const doomedLink = link(doomed.id, { scopeKey: 'tag:1' });
|
|
const keptLink = link(kept.id, { scopeKey: 'folder:1' });
|
|
testDb
|
|
.prepare('INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)')
|
|
.run(doomedLink.id, TRIP, 'uid-a');
|
|
|
|
svc.deleteConnection(doomed.id);
|
|
|
|
expect(svc.getConnection(doomed.id)).toBeUndefined();
|
|
expect(svc.listLinks(TRIP).map((l) => l.id)).toEqual([keptLink.id]);
|
|
expect(testDb.prepare('SELECT COUNT(*) AS n FROM document_sync_items').get()).toEqual({ n: 0 });
|
|
});
|
|
});
|
|
|
|
describe('toRef', () => {
|
|
it('names the row and not only its id, which a backup restored in place hands out again', async () => {
|
|
const first = await connect();
|
|
testDb.prepare("UPDATE document_connections SET created_at = '2026-09-01 08:00:00' WHERE id = ?").run(first.id);
|
|
const before = svc.toRef(svc.getConnection(first.id) as ConnectionRow);
|
|
|
|
// What the restore of an older backup does to this table: the row is gone
|
|
// and the id sequence is back where it stood before the row was made.
|
|
testDb.prepare('DELETE FROM document_connections WHERE id = ?').run(first.id);
|
|
testDb.prepare("UPDATE sqlite_sequence SET seq = ? WHERE name = 'document_connections'").run(first.id - 1);
|
|
const after = svc.toRef(await connect());
|
|
|
|
expect(after.connectionId).toBe(before.connectionId);
|
|
expect(before.createdAt).toBe('2026-09-01 08:00:00');
|
|
expect(after.createdAt).not.toBe(before.createdAt);
|
|
});
|
|
});
|
|
|
|
describe('updateLink', () => {
|
|
it('changes only the fields the patch names', async () => {
|
|
const conn = await connect();
|
|
const created = link(conn.id, { direction: 'both', conflictPolicy: 'manual' });
|
|
const patched = svc.updateLink(created.id, { direction: 'pull', syncEnabled: false });
|
|
expect(patched).toMatchObject({
|
|
direction: 'pull',
|
|
sync_enabled: 0,
|
|
conflict_policy: 'manual',
|
|
remote_label: created.remote_label,
|
|
});
|
|
});
|
|
|
|
it('hands back the binding untouched when the patch is empty', async () => {
|
|
const conn = await connect();
|
|
const created = link(conn.id);
|
|
expect(svc.updateLink(created.id, {})).toEqual(created);
|
|
});
|
|
});
|
|
|
|
describe('toScopeRef', () => {
|
|
it('hands the adapter the anchor and the cursor of that binding, each in its own field', async () => {
|
|
// The adapters take a ref object rather than positional arguments because
|
|
// an id and a path swapped compile cleanly and then read the wrong folder.
|
|
const conn = await connect();
|
|
const created = svc.createLink(
|
|
TRIP,
|
|
OWNER,
|
|
linkInput(conn.id, { scopeKey: 'fileid:437', remoteRootId: '437', remoteRootPath: '/TREK/japan' }),
|
|
);
|
|
expect(created.success).toBe(true);
|
|
const row = created.success ? created.data : ({} as LinkRow);
|
|
testDb.prepare('UPDATE trip_document_links SET remote_cursor = ? WHERE id = ?').run('etag-9', row.id);
|
|
|
|
expect(svc.toScopeRef(svc.getLink(row.id) as LinkRow)).toEqual({
|
|
linkId: row.id,
|
|
tripId: TRIP,
|
|
scopeKey: 'fileid:437',
|
|
remoteRootId: '437',
|
|
remoteRootPath: '/TREK/japan',
|
|
cursor: 'etag-9',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('createLink', () => {
|
|
it('returns the binding that already exists rather than making a second one', async () => {
|
|
const conn = await connect();
|
|
const first = link(conn.id);
|
|
const again = svc.createLink(TRIP, MEMBER, linkInput(conn.id, { remoteLabel: 'renamed' }));
|
|
expect(again.success && again.data.id).toBe(first.id);
|
|
expect(svc.listLinks(TRIP)).toHaveLength(1);
|
|
});
|
|
|
|
it('binds a second scope of the same connection separately', async () => {
|
|
const conn = await connect();
|
|
const first = link(conn.id, { scopeKey: 'tag:1' });
|
|
const second = link(conn.id, { scopeKey: 'tag:2' });
|
|
expect(second.id).not.toBe(first.id);
|
|
expect(svc.listLinks(TRIP)).toHaveLength(2);
|
|
});
|
|
|
|
it('refuses a connection that belongs to somebody else\'s trip', async () => {
|
|
const otherTrip = createTrip(testDb, MEMBER, { title: 'Not yours' }).id;
|
|
const conn = await connect();
|
|
const result = svc.createLink(otherTrip, MEMBER, linkInput(conn.id));
|
|
expect(result.success).toBe(false);
|
|
expect(result.success === false && result.error.code).toBe('not_found');
|
|
expect(svc.listLinks(otherTrip)).toHaveLength(0);
|
|
});
|
|
|
|
it('refuses a connection id that does not exist at all', async () => {
|
|
const result = svc.createLink(TRIP, OWNER, linkInput(9999));
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('stores the anchor the picker sent back with the scope key', async () => {
|
|
const conn = await connect();
|
|
const result = svc.createLink(
|
|
TRIP,
|
|
OWNER,
|
|
linkInput(conn.id, { remoteRootId: '17', remoteRootPath: '/TREK/japan', remoteLabel: 'Japan 2026' }),
|
|
);
|
|
expect(result.success && result.data).toMatchObject({
|
|
remote_scope_key: 'tag:1',
|
|
remote_root_id: '17',
|
|
remote_root_path: '/TREK/japan',
|
|
remote_label: 'Japan 2026',
|
|
});
|
|
});
|
|
|
|
it('stores NULL for an anchor field the client left out', async () => {
|
|
// Synology binds by path alone, so its options carry no root id at all.
|
|
const conn = await connect();
|
|
const result = svc.createLink(TRIP, OWNER, linkInput(conn.id));
|
|
expect(result.success && result.data).toMatchObject({ remote_root_id: null, remote_root_path: null });
|
|
});
|
|
|
|
it('gives every binding its own webhook token and keeps the secret out of the public view', async () => {
|
|
// A leaked webhook URL has to identify exactly one binding, so revoking one
|
|
// cannot be used to poke another.
|
|
const conn = await connect();
|
|
const first = link(conn.id, { scopeKey: 'tag:1' });
|
|
const second = link(conn.id, { scopeKey: 'tag:2' });
|
|
expect(first.webhook_token).toBeTruthy();
|
|
expect(second.webhook_token).not.toBe(first.webhook_token);
|
|
expect(svc.getLinkByToken(first.webhook_token as string)?.id).toBe(first.id);
|
|
|
|
const secret = svc.webhookSecret(first);
|
|
expect(secret).toBeTruthy();
|
|
const view = svc.publicLink(first, 'https://trek.example.com');
|
|
expect(view.webhookSecret).toBe(DOCSYNC_SECRET_MASK);
|
|
expect(JSON.stringify(view)).not.toContain(secret);
|
|
});
|
|
});
|
|
|
|
describe('publicLink', () => {
|
|
it('names the store of a binding whose provider the admin has since switched off', async () => {
|
|
// The client only learns names for the providers that are on, so this is
|
|
// the one place a binding left behind can still get its name from.
|
|
const conn = await connect();
|
|
const bound = link(conn.id);
|
|
testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'paperless'").run();
|
|
|
|
expect(svc.publicLink(bound, null)).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' });
|
|
});
|
|
|
|
it('falls back to the provider id for a provider this build does not know', async () => {
|
|
const conn = await connect();
|
|
const bound = link(conn.id);
|
|
expect(svc.publicLink({ ...bound, provider_id: 'dropbox' }, null).providerName).toBe('dropbox');
|
|
});
|
|
});
|
|
|
|
describe('deleteLink', () => {
|
|
it('takes the pairing rows with it and leaves the other binding\'s alone', async () => {
|
|
const conn = await connect();
|
|
const doomed = link(conn.id, { scopeKey: 'tag:1' });
|
|
const kept = link(conn.id, { scopeKey: 'tag:2' });
|
|
const insert = testDb.prepare(
|
|
'INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)',
|
|
);
|
|
insert.run(doomed.id, TRIP, 'uid-a');
|
|
insert.run(doomed.id, TRIP, 'uid-b');
|
|
insert.run(kept.id, TRIP, 'uid-c');
|
|
|
|
svc.deleteLink(doomed.id);
|
|
|
|
expect(svc.getLink(doomed.id)).toBeUndefined();
|
|
expect(svc.getLink(kept.id)).toBeDefined();
|
|
const rows = testDb.prepare('SELECT link_id FROM document_sync_items').all() as { link_id: number }[];
|
|
expect(rows.map((r) => r.link_id)).toEqual([kept.id]);
|
|
});
|
|
|
|
it('leaves the connection behind, so unbinding one scope does not log the trip out', async () => {
|
|
const conn = await connect();
|
|
svc.deleteLink(link(conn.id).id);
|
|
expect(svc.getConnection(conn.id)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('markOrphanedLinks', () => {
|
|
async function bind(ownerUserId: number, providerId: 'paperless' | 'nextcloud'): Promise<LinkRow> {
|
|
const credentials =
|
|
providerId === 'paperless' ? { api_token: TOKEN } : { login_name: 'anna', app_password: 'app-pw' };
|
|
const conn = await connect({ providerId, credentials, baseUrl: `https://${providerId}.example.com` }, ownerUserId);
|
|
return link(conn.id, { scopeKey: `scope:${providerId}` });
|
|
}
|
|
|
|
it('disables the binding whose credential owner left the trip', async () => {
|
|
const orphan = await bind(MEMBER, 'paperless');
|
|
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
|
|
|
|
expect(svc.markOrphanedLinks()).toBe(1);
|
|
expect(svc.getLink(orphan.id)).toMatchObject({ last_sync_state: 'orphaned', sync_enabled: 0 });
|
|
});
|
|
|
|
it('counts the trip owner as being on the trip, although no membership row says so', async () => {
|
|
// TREK's owner has no trip_members row; reading membership without the
|
|
// owner would orphan every binding the trip admin created, which is most
|
|
// of them.
|
|
const mine = await bind(OWNER, 'paperless');
|
|
expect(svc.markOrphanedLinks()).toBe(0);
|
|
expect(svc.getLink(mine.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 });
|
|
});
|
|
|
|
it('leaves a still-valid binding alone while orphaning the one beside it', async () => {
|
|
const orphan = await bind(MEMBER, 'paperless');
|
|
const kept = await bind(OWNER, 'nextcloud');
|
|
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
|
|
|
|
expect(svc.markOrphanedLinks()).toBe(1);
|
|
expect(svc.getLink(orphan.id)?.last_sync_state).toBe('orphaned');
|
|
expect(svc.getLink(kept.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 });
|
|
});
|
|
|
|
it('counts nothing on a second sweep, so the job stays quiet after the first one', async () => {
|
|
await bind(MEMBER, 'paperless');
|
|
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
|
|
expect(svc.markOrphanedLinks()).toBe(1);
|
|
expect(svc.markOrphanedLinks()).toBe(0);
|
|
});
|
|
|
|
it('does not touch a binding the user had already switched off', async () => {
|
|
const paused = await bind(MEMBER, 'paperless');
|
|
svc.updateLink(paused.id, { syncEnabled: false });
|
|
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
|
|
|
|
expect(svc.markOrphanedLinks()).toBe(0);
|
|
expect(svc.getLink(paused.id)?.last_sync_state).toBe('never');
|
|
});
|
|
});
|
|
|
|
describe('isOrphaned', () => {
|
|
async function bind(ownerUserId: number): Promise<LinkRow> {
|
|
return link((await connect({}, ownerUserId)).id);
|
|
}
|
|
|
|
it('answers yes for a paused binding whose owner left, which the sweep never marks', async () => {
|
|
const paused = await bind(MEMBER);
|
|
svc.updateLink(paused.id, { syncEnabled: false });
|
|
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
|
|
|
|
expect(svc.markOrphanedLinks()).toBe(0);
|
|
expect(svc.isOrphaned(svc.getLink(paused.id)!)).toBe(true);
|
|
});
|
|
|
|
it('answers yes for a marked binding, and no while the owner is on the trip', async () => {
|
|
const kept = await bind(OWNER);
|
|
expect(svc.isOrphaned(kept)).toBe(false);
|
|
|
|
testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(kept.id);
|
|
expect(svc.isOrphaned(svc.getLink(kept.id)!)).toBe(true);
|
|
});
|
|
|
|
it('leaves a connection that is gone to the run, which reports it as not found', async () => {
|
|
const bound = await bind(OWNER);
|
|
expect(svc.isOrphaned({ ...bound, connection_id: 999999 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('switching an orphaned binding back on', () => {
|
|
async function orphan(): Promise<LinkRow> {
|
|
const bound = link((await connect({}, MEMBER)).id);
|
|
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
|
|
svc.markOrphanedLinks();
|
|
return bound;
|
|
}
|
|
|
|
it('keeps it off while its owner is still gone, and changes the rest of the patch', async () => {
|
|
const bound = await orphan();
|
|
|
|
const res = svc.updateLink(bound.id, { syncEnabled: true, direction: 'pull' });
|
|
|
|
expect(res).toMatchObject({ sync_enabled: 0, last_sync_state: 'orphaned', direction: 'pull' });
|
|
});
|
|
|
|
it('lets it run again once its owner is back on the trip', async () => {
|
|
const bound = await orphan();
|
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(TRIP, MEMBER);
|
|
|
|
const res = svc.updateLink(bound.id, { syncEnabled: true });
|
|
|
|
expect(res).toMatchObject({ sync_enabled: 1, last_sync_state: 'never' });
|
|
expect(svc.isOrphaned(res!)).toBe(false);
|
|
});
|
|
});
|