mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
ed18c37903
Every file the release touches was read once more, and what survived an independent check against the code is fixed here, with a test for each. Data and credentials - A document sync connection edited to a new address no longer carries the stored token along: a blank form under another origin is refused, the same rule the probe route already had. Dawarich and AirTrail testConnection send the stored key only to the host it was stored for. - The key rotation script now covers document_connections.secrets and trip_document_links.webhook_secret, which it had skipped. - Turning a booked night into a pause in the road trip popup asks first and says that the booking and its expense go with it. Deleting a place says the same when a night is booked there, on the desktop and on the phone. - A moved version marker over unchanged bytes no longer replaces the TREK file and its attachments; a real revision carries the attachments over. Planner - A drop in the day plan names the row it lands ahead of in the full day, so a hidden hotel stop no longer shifts it one slot early; the store sorts before it splices. Via re-anchoring, the booked-night guard and the sidebar's day membership read the stored day, the index space the server uses. - A hotel stop placed mid-day re-pins the road trip vias of that day, and the booking session receives the new stop order too. The Public API and the iCal feed list a booked hotel once. A plugin can no longer write a stay against another trip's day. Road trip and map - The route overview paces its requests to the public routers and retries a rate limit, and it says which legs it could not route instead of quietly reporting a shorter trip. A click on a via handle no longer drops a second via; handles hide when zooming out. Read-only members cannot edit the driving limits. The phone shows a way back when no day is picked and prints the night drive departure in the chosen clock format. Integrations and search - Google transit keeps conventional trains when the train chip is on. TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and accepts the whole boolean family. A caller-supplied description no longer lands in the instance-wide enrichment cache. Nominatim searches carry a timeout. An OSM place id is checked for shape before it reaches Overpass. - Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off knows what the Atlas already shows, and a host change with a new key clears the old instance's state. - Document sync: a webhook address pasted by hand works on the token alone, deleting a connection unregisters its subscriptions, Nextcloud under a sub-path connects, Synology answers a name collision before uploading and verifies a move into the bin, Paperless titles are capped at 128 characters, a DNS failure reads as unreachable rather than as a policy verdict, videos from the store are accepted like uploads, and remote_missing has a way out. Conflict buttons only for the owner, a refused choice is reported, a refused credential gets a Reconnect button, disconnecting asks first on the desktop as it does on the phone, and the folder picker no longer spins forever. Release - The server holds the release notice back from a bundle that cannot draw it, so the previous shell served from the service worker cache after an update no longer shows bare keys and no longer lets a click use the notice up. - Import into an existing list gets the same body limit as a new list. Chat images say when the limit is reached and the new controls are translated. The transit backend switch reports a failed save. The Helm chart carries the Amap credentials in its Secret; the shipped templates document the Places API and Amap variables and the Overpass default. The wiki follows.
799 lines
34 KiB
TypeScript
799 lines
34 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);
|
|
});
|
|
|
|
/**
|
|
* 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 = svc.getConnection(created.id) as ConnectionRow;
|
|
expect(row.base_url).toBe('https://paperless.example.com');
|
|
expect(row.owner_user_id).toBe(OWNER);
|
|
expect(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(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' } });
|
|
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(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(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((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((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 = 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 = 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);
|
|
});
|
|
});
|