Files
TREK/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts
T
2026-10-06 21:50:59 +02:00

913 lines
40 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 { db as testDb } from '../../../../src/db/database';
import {
DocSyncConfigService,
type ConnectionRow,
type LinkRow,
} from '../../../../src/nest/doc-sync/doc-sync-config.service';
// `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';
import type { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
import {
createTestDocumentConnectionsRepo,
createTestDocumentProviderFieldsRepo,
createTestDocumentProvidersRepo,
createTestDocumentSyncItemsRepo,
createTestTripDocumentLinksRepo,
} from '../../../helpers/doc-sync-repos';
import { createTrip, createUser } from '../../../helpers/factories';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestUnitOfWork, createTestTripsRepo } from '../../../helpers/test-uow';
import { DOCSYNC_SECRET_MASK, type DocsyncConnectionInput, type DocsyncLinkInput } from '@trek/shared';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
vi.mock('../../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../../helpers/db-mock');
const db = createSnapshotTestDb();
return {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => undefined,
isOwner: () => false,
};
});
// 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 {},
}));
let svc: DocSyncConfigService;
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,
};
}
async function link(connectionId: number, over: Partial<DocsyncLinkInput> = {}): Promise<LinkRow> {
const result = await svc.createLink(TRIP, OWNER, linkInput(connectionId, over));
if (docFailed(result)) throw new Error(`fixture link refused: ${JSON.stringify(result.error)}`);
return result.data;
}
async function storedSecret(connectionId: number): Promise<string | undefined> {
const row = (await svc.getConnection(connectionId)) as ConnectionRow;
return svc.toRef(row).secrets.api_token;
}
beforeAll(async () => {
svc = new DocSyncConfigService(
await createTestTripsRepo(testDb),
await createTestDocumentProvidersRepo(testDb),
await createTestDocumentProviderFieldsRepo(testDb),
await createTestDocumentConnectionsRepo(testDb),
await createTestTripDocumentLinksRepo(testDb),
await createTestDocumentSyncItemsRepo(testDb),
{} as DocumentProviderRegistry,
await createTestUnitOfWork(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(await 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(await 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(await 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(await 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);
});
/**
* A stored secret only ever goes to the address it was stored against.
*
* Merged under a new host, a blank form carried the token there: a trip
* changes hands with its connection, and the new owner or an instance admin
* could point the previous owner's Paperless token at a server of their own
* and read it off the first request. The probe route had this rule; the
* save route did not.
*/
describe('under a new address', () => {
it('refuses to carry the stored secret over, and writes nothing', async () => {
const created = await connect();
const result = await svc.upsertConnection(
TRIP,
MEMBER,
connectionInput({
baseUrl: 'https://attacker.example.net',
credentials: {},
}),
);
expect(docFailed(result)).toBe(true);
expect(docFailed(result) && result.error.code).toBe('unauthorized');
const row = (await svc.getConnection(created.id)) as ConnectionRow;
expect(row.base_url).toBe('https://paperless.example.com');
expect(row.owner_user_id).toBe(OWNER);
expect(await storedSecret(created.id)).toBe(TOKEN);
});
it('refuses the mask as well, since the mask is not the credential', async () => {
await connect();
const result = await svc.upsertConnection(
TRIP,
OWNER,
connectionInput({
baseUrl: 'https://paperless.example.net',
credentials: { api_token: DOCSYNC_SECRET_MASK },
}),
);
expect(docFailed(result) && result.error.code).toBe('unauthorized');
});
it('takes a credential typed in for it', async () => {
const created = await connect();
const moved = await connect({
baseUrl: 'https://paperless.example.net',
credentials: { api_token: 'new-token' },
});
expect(moved.id).toBe(created.id);
expect(moved.base_url).toBe('https://paperless.example.net');
expect(await storedSecret(created.id)).toBe('new-token');
});
it('drops a secret the provider earned for the old address, even when the form is complete', async () => {
const conn = await connect({
providerId: 'synologydrive',
baseUrl: 'https://nas.example.com:5001',
credentials: { username: 'anna', password: 'nas-pw' },
});
await svc.saveEarnedSecret(conn.id, 'device_token', 'a1b2c3:DEVICE-7');
await connect({
providerId: 'synologydrive',
baseUrl: 'https://nas.example.net:5001',
credentials: { username: 'anna', password: 'nas-pw' },
});
expect(svc.toRef((await svc.getConnection(conn.id)) as ConnectionRow).secrets).toEqual({ password: 'nas-pw' });
});
it('reads a path or a trailing slash on the same server as the same address', async () => {
const created = await connect();
await connect({ baseUrl: 'https://paperless.example.com/paperless/', credentials: {} });
expect(await storedSecret(created.id)).toBe(TOKEN);
});
it('reads another port or scheme as another server', async () => {
await connect();
const port = await svc.upsertConnection(
TRIP,
OWNER,
connectionInput({ baseUrl: 'https://paperless.example.com:8443', credentials: {} }),
);
const scheme = await svc.upsertConnection(
TRIP,
OWNER,
connectionInput({ baseUrl: 'http://paperless.example.com', credentials: {} }),
);
expect(docFailed(port)).toBe(true);
expect(docFailed(scheme)).toBe(true);
});
});
/**
* Who the credential belongs to is written down with it, and the orphan
* check reads it: a binding stands down once that person leaves the trip.
* The caller took the row over on every save, so an edit that kept the
* stored token read as the caller's own token from then on.
*/
describe('the credential owner', () => {
it('stays with the person whose secret is stored when somebody else saves the form blank', async () => {
const created = await connect();
await connect({ credentials: {} }, MEMBER);
expect(((await svc.getConnection(created.id)) as ConnectionRow).owner_user_id).toBe(OWNER);
});
it('moves to the person who typed a new secret in', async () => {
const created = await connect();
await connect({ credentials: { api_token: 'members-own-token' } }, MEMBER);
expect(((await svc.getConnection(created.id)) as ConnectionRow).owner_user_id).toBe(MEMBER);
});
it('stays put when only an optional secret is typed, since that runs against the stored password', async () => {
const nas = { providerId: 'synologydrive' as const, baseUrl: 'https://nas.example.com:5001' };
const created = await connect({ ...nas, credentials: { username: 'anna', password: 'nas-pw' } });
await connect({ ...nas, credentials: { username: 'anna', otp_code: '123456' } }, MEMBER);
const row = (await svc.getConnection(created.id)) as ConnectionRow;
expect(row.owner_user_id).toBe(OWNER);
expect(svc.toRef(row).secrets).toEqual({ password: 'nas-pw', otp_code: '123456' });
});
});
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 = (await 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 = await svc.publicConnection((await 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 = (await svc.listConnections(TRIP)).find((c) => c.provider_id === 'synologydrive') as ConnectionRow;
expect((await 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 = await svc.publicConnection((await 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 });
}
async function secretsOf(connectionId: number): Promise<Readonly<Record<string, string>>> {
return svc.toRef((await 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();
await svc.toRef(conn).saveSecret!('device_token', EARNED);
expect(await secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456', device_token: EARNED });
expect(((await svc.getConnection(conn.id)) as ConnectionRow).secrets).not.toContain('DEVICE-7');
});
// 3h L3 carry (task-7-review.md's L3: "document_connections.secrets ... has
// no `enc:v1:` check"). The whole-secret-map column is one `maybe_encrypt_
// api_key` call (`encryptSecrets`, doc-sync-secrets.ts) over the JSON blob,
// so the at-rest pin is the same shape as `AirtrailService`'s and
// `DawarichService`'s: read the raw column, assert the ciphertext prefix,
// then assert `decryptSecrets` round-trips it.
it('DOCSYNC-ENC-001: the raw document_connections.secrets column is enc:v1:-prefixed ciphertext, never plaintext, and round-trips', async () => {
const conn = await nas();
const raw = (
testDb.prepare('SELECT secrets FROM document_connections WHERE id = ?').get(conn.id) as { secrets: string | null }
).secrets;
expect(raw).toMatch(/^enc:v1:/);
expect(raw).not.toContain('nas-pw');
expect(raw).not.toContain('123456');
expect(await secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456' });
});
it('never reaches a client, not even as a mask', async () => {
const conn = await nas();
await svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
const view = await svc.publicConnection((await 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();
await svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await nas({ username: 'anna', password: 'rotated', otp_code: '' });
await nas({ username: 'anna', password: DOCSYNC_SECRET_MASK });
expect(await 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((await secretsOf(conn.id)).device_token).toBeUndefined();
await svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' });
expect((await secretsOf(conn.id)).device_token).toBe(EARNED);
expect(svc.toRef((await 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' });
await ref.saveSecret!('device_token', EARNED);
expect(await 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();
await svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await svc.saveEarnedSecret(conn.id, 'device_token', null);
expect(await 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);
await svc.deleteConnection(conn.id);
await ref.saveSecret!('device_token', EARNED);
expect(await 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(await 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(await 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', async () => {
// 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(await svc.enabledProviderIds()).toEqual([]);
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('nextcloud', 'paperless')").run();
expect(await 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();
await svc.recordProbe(conn.id, 'ok', null, { push: 'multipart' });
await svc.recordProbe(conn.id, 'failed', '401 Unauthorized', null);
const view = await svc.publicConnection((await 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 = await link(doomed.id, { scopeKey: 'tag:1' });
const keptLink = await 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');
await svc.deleteConnection(doomed.id);
expect(await svc.getConnection(doomed.id)).toBeUndefined();
expect((await 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((await 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 = await link(conn.id, { direction: 'both', conflictPolicy: 'manual' });
const patched = await 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 = await link(conn.id);
expect(await 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 = await 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((await 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 = await link(conn.id);
const again = await svc.createLink(TRIP, MEMBER, linkInput(conn.id, { remoteLabel: 'renamed' }));
expect(again.success && again.data.id).toBe(first.id);
expect(await svc.listLinks(TRIP)).toHaveLength(1);
});
it('binds a second scope of the same connection separately', async () => {
const conn = await connect();
const first = await link(conn.id, { scopeKey: 'tag:1' });
const second = await link(conn.id, { scopeKey: 'tag:2' });
expect(second.id).not.toBe(first.id);
expect(await 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 = await svc.createLink(otherTrip, MEMBER, linkInput(conn.id));
expect(result.success).toBe(false);
expect(result.success === false && result.error.code).toBe('not_found');
expect(await svc.listLinks(otherTrip)).toHaveLength(0);
});
it('refuses a connection id that does not exist at all', async () => {
const result = await 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 = await 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 = await 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 = await link(conn.id, { scopeKey: 'tag:1' });
const second = await link(conn.id, { scopeKey: 'tag:2' });
expect(first.webhook_token).toBeTruthy();
expect(second.webhook_token).not.toBe(first.webhook_token);
expect((await svc.getLinkByToken(first.webhook_token as string))?.id).toBe(first.id);
const secret = svc.webhookSecret(first);
expect(secret).toBeTruthy();
const view = await svc.publicLink(first, 'https://trek.example.com');
expect(view.webhookSecret).toBe(DOCSYNC_SECRET_MASK);
expect(JSON.stringify(view)).not.toContain(secret);
});
// 3h L3 carry (task-7-review.md's L3: "trip_document_links.webhook_secret —
// No at-rest pin."). `createLink` mints the secret with the same
// `encryptSecrets` call the connection's own secret map uses (`doc-sync-
// config.service.ts:417`), so the pin is the same shape: raw column is
// `enc:v1:`-prefixed ciphertext, never the plaintext, and `webhookSecret`
// round-trips it.
it('DOCSYNC-ENC-002: the raw trip_document_links.webhook_secret column is enc:v1:-prefixed ciphertext, never plaintext, and round-trips', async () => {
const conn = await connect();
const bound = await link(conn.id);
const raw = (
testDb.prepare('SELECT webhook_secret FROM trip_document_links WHERE id = ?').get(bound.id) as {
webhook_secret: string | null;
}
).webhook_secret;
expect(raw).toMatch(/^enc:v1:/);
const secret = svc.webhookSecret(bound);
expect(secret).toBeTruthy();
expect(raw).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 = await link(conn.id);
testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'paperless'").run();
expect(await 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 = await link(conn.id);
expect((await 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 = await link(conn.id, { scopeKey: 'tag:1' });
const kept = await 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');
await svc.deleteLink(doomed.id);
expect(await svc.getLink(doomed.id)).toBeUndefined();
expect(await 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();
await svc.deleteLink((await link(conn.id)).id);
expect(await 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 await 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(await svc.markOrphanedLinks()).toBe(1);
expect(await 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(await svc.markOrphanedLinks()).toBe(0);
expect(await 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(await svc.markOrphanedLinks()).toBe(1);
expect((await svc.getLink(orphan.id))?.last_sync_state).toBe('orphaned');
expect(await svc.getLink(kept.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 });
});
/**
* DSC20/DSC21's shared predicate (`DocumentConnectionsRepository
* .listOrphanedIds`) — the single-link check (`isOrphaned`, which
* delegates to the private `ownerLeft`) and the bulk sweep
* (`markOrphanedLinks`) must agree on every case, since both now draw
* from the SAME repository method rather than two independent
* re-implementations of the `NOT IN (... UNION ...)` subquery. Three
* cases in one test: an owner still on the trip via `trip_members`, an
* owner on it only via `trips.user_id` (no membership row), and an owner
* genuinely gone.
*/
it('the single-link check and the bulk sweep agree on every case: owner via trip_members, owner via trips.user_id, owner genuinely gone', async () => {
// A connection is keyed per (trip_id, provider_id) — `upsertConnection`
// treats a second `connect()` call for a provider this trip already
// has as an EDIT of that same row, not a new connection. Three
// independent connections therefore need three distinct providers.
const viaMembership = await bind(MEMBER, 'paperless');
const viaTripOwner = await bind(OWNER, 'nextcloud');
// A stranger who was never on the trip at all — indistinguishable from
// "genuinely gone" to the shared predicate, which only asks "is the
// owner currently IN the membership set", never why they are not.
const strangerId = createUser(testDb, { username: 'stranger-doc-sync', email: 'stranger-doc-sync@test.local' }).user
.id;
const goneConn = await connect(
{
providerId: 'synologydrive',
credentials: { username: 'anna', password: 'nas-pw' },
baseUrl: 'https://nas.example.com:5001',
},
strangerId,
);
const goneLink = await link(goneConn.id, { scopeKey: 'scope:gone' });
expect(await svc.isOrphaned((await svc.getLink(viaMembership.id)) as LinkRow)).toBe(false);
expect(await svc.isOrphaned((await svc.getLink(viaTripOwner.id)) as LinkRow)).toBe(false);
expect(await svc.isOrphaned((await svc.getLink(goneLink.id)) as LinkRow)).toBe(true);
expect(await svc.markOrphanedLinks()).toBe(1);
expect((await svc.getLink(viaMembership.id))?.last_sync_state).toBe('never');
expect((await svc.getLink(viaTripOwner.id))?.last_sync_state).toBe('never');
expect((await svc.getLink(goneLink.id))?.last_sync_state).toBe('orphaned');
});
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(await svc.markOrphanedLinks()).toBe(1);
expect(await svc.markOrphanedLinks()).toBe(0);
});
it('does not touch a binding the user had already switched off', async () => {
const paused = await bind(MEMBER, 'paperless');
await svc.updateLink(paused.id, { syncEnabled: false });
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(await svc.markOrphanedLinks()).toBe(0);
expect((await 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);
await svc.updateLink(paused.id, { syncEnabled: false });
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(await svc.markOrphanedLinks()).toBe(0);
expect(await svc.isOrphaned((await 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(await svc.isOrphaned(kept)).toBe(false);
testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(kept.id);
expect(await svc.isOrphaned((await 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(await svc.isOrphaned({ ...bound, connection_id: 999999 })).toBe(false);
});
});
describe('switching an orphaned binding back on', () => {
async function orphan(): Promise<LinkRow> {
const bound = await link((await connect({}, MEMBER)).id);
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
await 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 = await 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 = await svc.updateLink(bound.id, { syncEnabled: true });
expect(res).toMatchObject({ sync_enabled: 1, last_sync_state: 'never' });
expect(await svc.isOrphaned(res!)).toBe(false);
});
});