mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
913 lines
40 KiB
TypeScript
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);
|
|
});
|
|
});
|