mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
976 lines
46 KiB
TypeScript
976 lines
46 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
import { HttpException, Logger } from '@nestjs/common';
|
|
import type { Request } from 'express';
|
|
|
|
/**
|
|
* DocSyncController built with `new`, against a real in-memory SQLite.
|
|
*
|
|
* The guards are e2e's job (tests/e2e/doc-sync.e2e.test.ts drives the real
|
|
* AddonGuard, JwtAuthGuard and TripAccessGuard). What is left here is what the
|
|
* handler itself decides once a request is through the door, and all of it is
|
|
* the kind that would be expensive to notice late: who counts as the owner, a
|
|
* form field that must not lose a secret it never saw, and a webhook whose
|
|
* callback URL is derived from the request rather than from configuration.
|
|
*
|
|
* The config service and the provider registry are the real ones: the merge
|
|
* this file pins runs through actual encryption and actual provider-field rows,
|
|
* and a stub of either would only be a restatement of the controller. The
|
|
* reconciler is a spy: a run is a side effect here, not the subject.
|
|
*/
|
|
|
|
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,
|
|
};
|
|
});
|
|
|
|
|
|
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
|
|
|
/**
|
|
* The SSRF guard resolves DNS, and none of these hostnames exist. It is not
|
|
* what this file tests, and a suite that needs a resolver is a suite that fails
|
|
* on a train.
|
|
*/
|
|
const { checkSsrf } = vi.hoisted(() => ({
|
|
checkSsrf: vi.fn(async (url: string) => ({
|
|
allowed: !url.includes('blocked.invalid'),
|
|
isPrivate: false,
|
|
error: url.includes('blocked.invalid') ? 'blocked in test' : undefined,
|
|
})),
|
|
}));
|
|
vi.mock('../../../../src/utils/ssrfGuard', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../../../src/utils/ssrfGuard')>()),
|
|
checkSsrf,
|
|
}));
|
|
|
|
import { db as testDb } from '../../../../src/db/database';
|
|
import { createTrip, createUser } from '../../../helpers/factories';
|
|
import { DocSyncController } from '../../../../src/nest/doc-sync/doc-sync.controller';
|
|
import { DocSyncConfigService } from '../../../../src/nest/doc-sync/doc-sync-config.service';
|
|
import { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
|
|
import type {
|
|
DocumentConnectionRef,
|
|
DocumentProvider,
|
|
DocumentScopeRef,
|
|
} from '../../../../src/nest/doc-sync/document-provider';
|
|
import type { LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
|
|
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
|
|
import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
|
|
import type {
|
|
DocsyncConnectionDto,
|
|
DocsyncConnectionTestDto,
|
|
DocsyncLinkDto,
|
|
} from '../../../../src/nest/doc-sync/doc-sync.dto';
|
|
import type { User } from '../../../../src/types';
|
|
import { createTestUnitOfWork, createTestTripsRepo } from '../../../helpers/test-uow';
|
|
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
|
import {
|
|
createTestDocumentConnectionsRepo,
|
|
createTestDocumentProviderFieldsRepo,
|
|
createTestDocumentProvidersRepo,
|
|
createTestDocumentSyncItemsRepo,
|
|
createTestTripDocumentLinksRepo,
|
|
} from '../../../helpers/doc-sync-repos';
|
|
|
|
const CAPS = {
|
|
push: 'webhook-self-registered' as const,
|
|
stableId: true,
|
|
remoteTrash: true,
|
|
replaceInPlace: true,
|
|
contentHashInListing: true,
|
|
maxUploadBytes: null,
|
|
acceptedMimeTypes: null,
|
|
canCreateScope: true,
|
|
};
|
|
|
|
function fakeProvider(id: string) {
|
|
return {
|
|
id,
|
|
capabilities: () => CAPS,
|
|
probe: vi.fn(async (_conn: DocumentConnectionRef) => ({ success: true as const, data: { account: `${id}-account`, capabilities: CAPS } })),
|
|
listScopes: vi.fn(async (_conn: DocumentConnectionRef, _query?: string) => ({ success: true as const, data: [] })),
|
|
createScope: vi.fn(async () => ({ success: false as const, error: { code: 'provider_error' as const } })),
|
|
resolveScope: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
|
|
list: vi.fn(async () => ({ success: true as const, data: { documents: [], cursor: null, cursorUnchanged: false, truncated: false } })),
|
|
fetch: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
|
|
push: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
|
|
rename: vi.fn(async () => ({ success: true as const, data: { remoteVersion: 'v2' } })),
|
|
trash: vi.fn(async () => ({ success: true as const, data: undefined })),
|
|
registerWebhook: vi.fn(async (_conn: DocumentConnectionRef, _scope: DocumentScopeRef, _callbackUrl: string, _secret: string) => ({
|
|
success: true as const,
|
|
data: { subscriptionId: 'sub-7' },
|
|
})),
|
|
unregisterWebhook: vi.fn(async (_conn: DocumentConnectionRef, _subscriptionId: string) => ({ success: true as const, data: undefined })),
|
|
};
|
|
}
|
|
|
|
const paperless = fakeProvider('paperless');
|
|
const nextcloud = fakeProvider('nextcloud');
|
|
|
|
const sync = {
|
|
syncLink: vi.fn(async (_link: LinkRow, _opts?: { full?: boolean }) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })),
|
|
retryShelvedItems: vi.fn((_linkId: number) => {}),
|
|
isSwitchedOff: vi.fn((_link: LinkRow) => false),
|
|
status: vi.fn(() => ({ links: [], items: {} })),
|
|
resolveConflict: vi.fn(async () => true),
|
|
// R2 (DSCTRL4/DSCTRL5, moved off the controller onto DocSyncService) — a
|
|
// real read against this file's own testDb, not a stub: "the document
|
|
// list" describe block below is testing the join/trip-scoping SHAPE this
|
|
// method now owns, and a canned return would only restate the assertion.
|
|
itemsForTrip: vi.fn((tripId: number, state?: string) =>
|
|
state
|
|
? testDb
|
|
.prepare(
|
|
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
|
|
LEFT JOIN trip_files f ON f.id = i.file_id
|
|
WHERE i.trip_id = ? AND i.state = ? ORDER BY i.id DESC LIMIT 500`,
|
|
)
|
|
.all(tripId, state)
|
|
: testDb
|
|
.prepare(
|
|
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
|
|
LEFT JOIN trip_files f ON f.id = i.file_id
|
|
WHERE i.trip_id = ? ORDER BY i.id DESC LIMIT 500`,
|
|
)
|
|
.all(tripId),
|
|
),
|
|
};
|
|
|
|
const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]);
|
|
const realtime = { broadcast: vi.fn() };
|
|
// Built in beforeAll: DocSyncConfigService now takes a UnitOfWork, and
|
|
// createTestUnitOfWork is async — module-scope construction cannot await it.
|
|
let config: DocSyncConfigService;
|
|
let controller: DocSyncController;
|
|
let t: TestOrm;
|
|
|
|
/** Only what publicOrigin reads: header lookup plus the connection's own scheme. */
|
|
function makeReq(headers: Record<string, string> = {}, protocol = 'http'): Request {
|
|
return {
|
|
protocol,
|
|
headers,
|
|
get: (name: string) => headers[name.toLowerCase()],
|
|
} as unknown as Request;
|
|
}
|
|
|
|
function connBody(over: Partial<DocsyncConnectionDto> = {}): DocsyncConnectionDto {
|
|
return {
|
|
providerId: 'paperless',
|
|
baseUrl: 'https://paperless.example.com',
|
|
credentials: {},
|
|
allowInsecureTls: false,
|
|
...over,
|
|
} as DocsyncConnectionDto;
|
|
}
|
|
|
|
function linkBody(connectionId: number, over: Partial<DocsyncLinkDto> = {}): DocsyncLinkDto {
|
|
return {
|
|
connectionId,
|
|
scopeKey: 'tag:1',
|
|
remoteRootId: '1',
|
|
remoteRootPath: '/TREK/japan',
|
|
remoteLabel: 'Japan 2026',
|
|
direction: 'both',
|
|
deletePolicy: 'unlink',
|
|
conflictPolicy: 'manual',
|
|
syncEnabled: true,
|
|
...over,
|
|
} as DocsyncLinkDto;
|
|
}
|
|
|
|
async function thrown(fn: () => unknown): Promise<HttpException> {
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
return err as HttpException;
|
|
}
|
|
throw new Error('expected the handler to throw');
|
|
}
|
|
|
|
let owner: User;
|
|
let member: User;
|
|
let admin: User;
|
|
let tripId: number;
|
|
let otherTripId: number;
|
|
|
|
beforeAll(async () => {
|
|
// L1 (Plan 3h Task 7 review): createLink's fire-and-forget syncLink call
|
|
// is now wrapped in its own withRequestContext fork, which needs a real
|
|
// MikroORM instance — sync itself stays a spy, this ORM never resolves a
|
|
// repository through it.
|
|
t = await createTestOrm(testDb);
|
|
config = new DocSyncConfigService(
|
|
await createTestTripsRepo(testDb),
|
|
await createTestDocumentProvidersRepo(testDb),
|
|
await createTestDocumentProviderFieldsRepo(testDb),
|
|
await createTestDocumentConnectionsRepo(testDb),
|
|
await createTestTripDocumentLinksRepo(testDb),
|
|
await createTestDocumentSyncItemsRepo(testDb),
|
|
registry,
|
|
await createTestUnitOfWork(testDb),
|
|
);
|
|
controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, realtime as unknown as RealtimeService, t.orm);
|
|
const o = createUser(testDb, { username: 'owner', email: 'owner@test.local' }).user;
|
|
const m = createUser(testDb, { username: 'member', email: 'member@test.local' }).user;
|
|
const a = createUser(testDb, { username: 'admin', email: 'admin@test.local', role: 'admin' }).user;
|
|
owner = { id: o.id, role: 'user' } as User;
|
|
member = { id: m.id, role: 'user' } as User;
|
|
admin = { id: a.id, role: 'admin' } as User;
|
|
tripId = createTrip(testDb, o.id, { title: 'Japan' }).id;
|
|
otherTripId = createTrip(testDb, m.id, { title: 'Norway' }).id;
|
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, m.id);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await t.close();
|
|
testDb.close();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections; DELETE FROM trip_files');
|
|
// Providers ship switched off; these three are what the cases below need.
|
|
testDb.prepare("UPDATE document_providers SET enabled = 0").run();
|
|
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('paperless', 'nextcloud', 'papra')").run();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function storedPaperless(credentials: Record<string, string> = { api_token: 'stored-token' }) {
|
|
const res = await config.upsertConnection(tripId, Number(owner.id), connBody({ credentials }));
|
|
if ('error' in res) throw new Error(`fixture failed: ${res.error.code} ${res.error.detail ?? ''}`);
|
|
return res.data;
|
|
}
|
|
|
|
describe('who may change a trip binding', () => {
|
|
it('lets the trip owner through', async () => {
|
|
await expect(controller.resolve(String(tripId), '1', owner, { keep: 'trek' })).resolves.toEqual({ success: true });
|
|
});
|
|
|
|
it('lets an instance admin through on a trip they neither own nor joined', async () => {
|
|
await expect(controller.resolve(String(otherTripId), '1', admin, { keep: 'trek' })).resolves.toEqual({ success: true });
|
|
});
|
|
|
|
it('refuses a plain member with the message the client renders', async () => {
|
|
const err = await thrown(() => controller.resolve(String(tripId), '1', member, { keep: 'trek' }));
|
|
expect(err.getStatus()).toBe(403);
|
|
expect(err.message).toBe('Only the trip owner can change document sync');
|
|
});
|
|
|
|
it('answers 404 for a trip that does not exist, rather than the 403 that would confirm it', async () => {
|
|
const err = await thrown(() => controller.resolve('999999', '1', member, { keep: 'trek' }));
|
|
expect(err.getStatus()).toBe(404);
|
|
expect(err.message).toBe('Trip not found');
|
|
});
|
|
});
|
|
|
|
describe('providers', () => {
|
|
it('reports an enabled provider with no registered adapter as unavailable instead of hiding it', async () => {
|
|
const rows = (await controller.providers()) as Array<{ id: string; available: boolean }>;
|
|
const byId = Object.fromEntries(rows.map((r) => [r.id, r.available]));
|
|
// papra is switched on in the DB, but no adapter is registered in this suite.
|
|
expect(byId.papra).toBe(false);
|
|
expect(byId.paperless).toBe(true);
|
|
expect(byId.nextcloud).toBe(true);
|
|
});
|
|
|
|
it('hands the form flags to the client as booleans rather than the 0/1 the column stores', async () => {
|
|
const rows = (await controller.providers()) as Array<{ id: string; fields: Array<{ field_key: string; secret: boolean; required: boolean }> }>;
|
|
const fields = rows.find((r) => r.id === 'paperless')!.fields;
|
|
const token = fields.find((f) => f.field_key === 'api_token')!;
|
|
expect(token.secret).toBe(true);
|
|
expect(token.required).toBe(true);
|
|
expect(fields.find((f) => f.field_key === 'allow_insecure_tls')!.secret).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('storing a connection', () => {
|
|
it('hands the stored connection back with the secret masked instead of echoed', async () => {
|
|
const res = (await controller.upsertConnection(
|
|
String(tripId),
|
|
owner,
|
|
connBody({ credentials: { api_token: 'super-secret' } }),
|
|
)) as { id: number; secrets: Record<string, string> };
|
|
|
|
expect(res.secrets.api_token).not.toContain('super-secret');
|
|
|
|
const listed = (await controller.listConnections(String(tripId))) as Array<{ id: number; secrets: Record<string, string> }>;
|
|
expect(listed).toHaveLength(1);
|
|
expect(listed[0].id).toBe(res.id);
|
|
// The list is what the form renders from, so the secret must not survive
|
|
// the round trip either.
|
|
expect(listed[0].secrets.api_token).not.toContain('super-secret');
|
|
});
|
|
|
|
it('refuses a base URL the SSRF guard turns down', async () => {
|
|
const err = await thrown(() => controller.upsertConnection(String(tripId), owner, connBody({ baseUrl: 'https://blocked.invalid' })));
|
|
expect(err.getStatus()).toBe(400);
|
|
expect(await config.listConnections(tripId)).toHaveLength(0);
|
|
});
|
|
|
|
it('refuses to point the stored secret at another server when the form leaves it blank', async () => {
|
|
// The probe route had this rule; the save route let an instance admin, or
|
|
// whoever the trip was handed to, redirect the owner's token to a host of
|
|
// their own with an empty form.
|
|
const conn = await storedPaperless();
|
|
const err = await thrown(() =>
|
|
controller.upsertConnection(String(tripId), admin, connBody({ baseUrl: 'https://elsewhere.example', credentials: {} })),
|
|
);
|
|
expect(err.getStatus()).toBe(400);
|
|
expect((await config.getConnection(conn.id))!.base_url).toBe('https://paperless.example.com');
|
|
expect((await config.getConnection(conn.id))!.owner_user_id).toBe(Number(owner.id));
|
|
});
|
|
|
|
it('refuses a provider the instance admin has not switched on, and names it', async () => {
|
|
testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'papra'").run();
|
|
const err = await thrown(() =>
|
|
controller.upsertConnection(String(tripId), owner, connBody({ providerId: 'papra', credentials: { api_key: 'k', organization_id: 'org_1' } })),
|
|
);
|
|
expect(err.getStatus()).toBe(400);
|
|
expect(err.message).toBe('Provider: "papra" is not enabled, contact server administrator');
|
|
});
|
|
});
|
|
|
|
describe('probing form values', () => {
|
|
it('keeps the stored secret when the form leaves the field blank', async () => {
|
|
await storedPaperless();
|
|
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
|
|
expect(paperless.probe.mock.calls[0][0]).toMatchObject({ secrets: { api_token: 'stored-token' } });
|
|
});
|
|
|
|
it('takes a freshly typed secret over the stored one', async () => {
|
|
await storedPaperless();
|
|
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'typed-now' } }) as DocsyncConnectionTestDto);
|
|
expect(paperless.probe.mock.calls[0][0]).toMatchObject({ secrets: { api_token: 'typed-now' } });
|
|
});
|
|
|
|
it('keeps a stored non-secret setting the form did not resend', async () => {
|
|
const res = await config.upsertConnection(tripId, Number(owner.id), connBody({
|
|
providerId: 'nextcloud',
|
|
baseUrl: 'https://cloud.example.com',
|
|
credentials: { login_name: 'alice', app_password: 'app-pw', base_path: '/TREK' },
|
|
}));
|
|
expect('error' in res).toBe(false);
|
|
|
|
await controller.testConnection(String(tripId), owner, connBody({
|
|
providerId: 'nextcloud',
|
|
baseUrl: 'https://cloud.example.com',
|
|
credentials: { app_password: '' },
|
|
}) as DocsyncConnectionTestDto);
|
|
|
|
expect(nextcloud.probe.mock.calls[0][0]).toMatchObject({
|
|
settings: { login_name: 'alice', base_path: '/TREK' },
|
|
secrets: { app_password: 'app-pw' },
|
|
});
|
|
});
|
|
|
|
it('takes a freshly typed setting over the stored one', async () => {
|
|
const res = await config.upsertConnection(tripId, Number(owner.id), connBody({
|
|
providerId: 'nextcloud',
|
|
baseUrl: 'https://cloud.example.com',
|
|
credentials: { login_name: 'alice', app_password: 'app-pw' },
|
|
}));
|
|
expect('error' in res).toBe(false);
|
|
|
|
await controller.testConnection(String(tripId), owner, connBody({
|
|
providerId: 'nextcloud',
|
|
baseUrl: 'https://cloud.example.com',
|
|
credentials: { login_name: 'bob' },
|
|
}) as DocsyncConnectionTestDto);
|
|
|
|
expect(nextcloud.probe.mock.calls[0][0]).toMatchObject({ settings: { login_name: 'bob' } });
|
|
});
|
|
|
|
it('answers with a body instead of throwing when no adapter is registered', async () => {
|
|
await expect(
|
|
controller.testConnection(String(tripId), owner, connBody({ providerId: 'papra', credentials: { api_key: 'k' } }) as DocsyncConnectionTestDto),
|
|
).resolves.toEqual({ connected: false, error: 'unknown_provider' });
|
|
});
|
|
|
|
it('answers with a body instead of throwing when the URL is refused', async () => {
|
|
await expect(
|
|
controller.testConnection(String(tripId), owner, connBody({ baseUrl: 'https://blocked.invalid' }) as DocsyncConnectionTestDto),
|
|
).resolves.toEqual({ connected: false, error: 'ssrf_blocked' });
|
|
expect(paperless.probe).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('answers with a body instead of throwing when the provider rejects the credentials', async () => {
|
|
paperless.probe.mockResolvedValueOnce({ success: false, error: { code: 'unauthorized', detail: 'token expired' } } as never);
|
|
await expect(
|
|
controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'nope' } }) as DocsyncConnectionTestDto),
|
|
).resolves.toEqual({ connected: false, error: 'unauthorized', detail: 'token expired' });
|
|
});
|
|
|
|
it('writes a successful probe onto the stored connection, so the status panel has something to show', async () => {
|
|
const conn = await storedPaperless();
|
|
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
|
|
const row = (await config.getConnection(conn.id))!;
|
|
expect(row.last_probe_state).toBe('ok');
|
|
expect(JSON.parse(row.capabilities!)).toMatchObject({ push: 'webhook-self-registered' });
|
|
});
|
|
|
|
it('leaves no probe record behind when the form values belong to no stored connection yet', async () => {
|
|
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'first-try' } }) as DocsyncConnectionTestDto);
|
|
expect(await config.listConnections(tripId)).toHaveLength(0);
|
|
expect(paperless.probe.mock.calls[0][0]).toMatchObject({ connectionId: 0, secrets: { api_token: 'first-try' } });
|
|
});
|
|
});
|
|
|
|
describe('a secret the adapter earned itself', () => {
|
|
const EARNED = 'a1b2c3:DEVICE-SECRET';
|
|
|
|
it('is in neither connection payload the form is rendered from', async () => {
|
|
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id = 'synologydrive'").run();
|
|
const nas = connBody({
|
|
providerId: 'synologydrive',
|
|
baseUrl: 'https://nas.example.com:5001',
|
|
credentials: { username: 'anna', password: 'nas-pw' },
|
|
});
|
|
const created = (await controller.upsertConnection(String(tripId), owner, nas)) as { id: number };
|
|
await config.saveEarnedSecret(created.id, 'device_token', EARNED);
|
|
|
|
const listed = JSON.stringify(await controller.listConnections(String(tripId)));
|
|
const edited = JSON.stringify(
|
|
await controller.upsertConnection(String(tripId), owner, { ...nas, credentials: { username: 'anna', password: 'rotated' } }),
|
|
);
|
|
for (const payload of [listed, edited]) {
|
|
expect(payload).not.toContain('DEVICE-SECRET');
|
|
expect(payload).not.toContain('device_token');
|
|
}
|
|
expect(config.toRef((await config.getConnection(created.id))!).secrets.device_token).toBe(EARNED);
|
|
});
|
|
|
|
it('reaches a probe of the saved form, which gets no way to write', async () => {
|
|
const conn = await storedPaperless();
|
|
await config.saveEarnedSecret(conn.id, 'device_token', EARNED);
|
|
const res = await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
|
|
|
|
const ref = paperless.probe.mock.calls[0][0];
|
|
// The row as well as its id: together they are where the saved connection keeps its device token.
|
|
expect(ref).toMatchObject({ connectionId: conn.id, createdAt: conn.created_at, secrets: { device_token: EARNED } });
|
|
expect(ref.createdAt).not.toBe('');
|
|
expect(ref.saveSecret).toBeUndefined();
|
|
expect(JSON.stringify(res)).not.toContain('DEVICE-SECRET');
|
|
});
|
|
|
|
it('gives a probe of an unsaved form no way to write either', async () => {
|
|
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'first-try' } }) as DocsyncConnectionTestDto);
|
|
const ref = paperless.probe.mock.calls[0][0];
|
|
expect(ref.connectionId).toBe(0);
|
|
expect(ref.createdAt).toBe('');
|
|
expect(ref.saveSecret).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('reading the trip state', () => {
|
|
it('asks the reconciler with a number, because the path hands the handler a string', async () => {
|
|
await controller.status(String(tripId));
|
|
expect(sync.status).toHaveBeenCalledWith(tripId);
|
|
});
|
|
});
|
|
|
|
describe('removing a connection', () => {
|
|
it('unbinds without touching the documents that came through it', async () => {
|
|
const conn = await storedPaperless();
|
|
const fileId = Number(
|
|
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
|
|
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
|
|
);
|
|
|
|
await expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).resolves.toEqual({ success: true });
|
|
expect(await config.getConnection(conn.id)).toBeUndefined();
|
|
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
|
|
});
|
|
|
|
it('takes down every subscription TREK registered for its bindings, before the credential goes', async () => {
|
|
// The bindings cascade away with the connection, and the rows were the
|
|
// only record of the Paperless workflows and Nextcloud listeners TREK had
|
|
// set up: left standing, they keep posting to a token that answers
|
|
// nothing, for good. Unbinding one binding already did this.
|
|
const conn = await storedPaperless();
|
|
const first = (await controller.createLink(
|
|
String(tripId), owner, linkBody(conn.id, { scopeKey: 'tag:1' }), makeReq({ host: 'trek.example' }),
|
|
)) as { id: number };
|
|
paperless.registerWebhook.mockResolvedValueOnce({ success: true, data: { subscriptionId: 'sub-8' } } as never);
|
|
await controller.createLink(String(tripId), owner, linkBody(conn.id, { scopeKey: 'tag:2' }), makeReq({ host: 'trek.example' }));
|
|
// One that never got a subscription, which must not be unregistered as ''.
|
|
await controller.createLink(String(tripId), owner, linkBody(conn.id, { scopeKey: 'tag:3' }), makeReq());
|
|
paperless.unregisterWebhook.mockClear();
|
|
|
|
await controller.deleteConnection(String(tripId), String(conn.id), owner);
|
|
|
|
expect(paperless.unregisterWebhook.mock.calls.map((c) => c[1]).sort()).toEqual(['sub-7', 'sub-8']);
|
|
expect(paperless.unregisterWebhook.mock.calls[0][0]).toMatchObject({ connectionId: conn.id, secrets: { api_token: 'stored-token' } });
|
|
expect(await config.getConnection(conn.id)).toBeUndefined();
|
|
expect(await config.getLink(first.id)).toBeUndefined();
|
|
});
|
|
|
|
it('still unbinds when the provider refuses to take a subscription down', async () => {
|
|
const conn = await storedPaperless();
|
|
await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq({ host: 'trek.example' }));
|
|
paperless.unregisterWebhook.mockResolvedValueOnce({ success: false, error: { code: 'unreachable' } } as never);
|
|
|
|
await expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).resolves.toEqual({ success: true });
|
|
expect(await config.getConnection(conn.id)).toBeUndefined();
|
|
});
|
|
|
|
it('refuses a connection that belongs to a different trip', async () => {
|
|
const conn = await storedPaperless();
|
|
const err = await thrown(() => controller.deleteConnection(String(otherTripId), String(conn.id), admin));
|
|
expect(err.getStatus()).toBe(404);
|
|
expect(await config.getConnection(conn.id)).toBeTruthy();
|
|
expect(realtime.broadcast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('pings the trip once for every binding that went with it, and for no other', async () => {
|
|
const conn = await storedPaperless();
|
|
const cloud = await config.upsertConnection(tripId, Number(owner.id), connBody({
|
|
providerId: 'nextcloud',
|
|
baseUrl: 'https://cloud.example.com',
|
|
credentials: { login_name: 'alice', app_password: 'app-pw', base_path: '/TREK' },
|
|
}));
|
|
if ('error' in cloud) throw new Error(`fixture failed: ${cloud.error.code}`);
|
|
const bind = async (connectionId: number, scopeKey: string) => {
|
|
const res = await config.createLink(tripId, Number(owner.id), linkBody(connectionId, { scopeKey }));
|
|
if ('error' in res) throw new Error(`fixture failed: ${res.error.code}`);
|
|
return res.data.id;
|
|
};
|
|
const first = await bind(conn.id, 'tag:1');
|
|
const second = await bind(conn.id, 'tag:2');
|
|
await bind(cloud.data.id, 'folder:1');
|
|
|
|
await controller.deleteConnection(String(tripId), String(conn.id), owner);
|
|
|
|
expect(realtime.broadcast.mock.calls).toEqual([
|
|
[tripId, 'docsync:changed', { linkId: first, pulled: 0, pushed: 0 }],
|
|
[tripId, 'docsync:changed', { linkId: second, pulled: 0, pushed: 0 }],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('the scope picker', () => {
|
|
it('passes the search text to the adapter and hands back what it offers', async () => {
|
|
const conn = await storedPaperless();
|
|
paperless.listScopes.mockResolvedValueOnce({
|
|
success: true,
|
|
data: [{ scopeKey: 'tag:1', label: 'Japan 2026', remoteRootId: '1', remoteRootPath: '/TREK/japan' }],
|
|
} as never);
|
|
|
|
const res = await controller.listScopes(String(tripId), String(conn.id), owner, 'japan');
|
|
|
|
expect(paperless.listScopes.mock.calls[0][1]).toBe('japan');
|
|
expect(res).toMatchObject({ scopes: [{ scopeKey: 'tag:1' }] });
|
|
});
|
|
|
|
it('reports a provider failure in the body, so the picker can say why it is empty', async () => {
|
|
const conn = await storedPaperless();
|
|
paperless.listScopes.mockResolvedValueOnce({ success: false, error: { code: 'unauthorized' } } as never);
|
|
await expect(controller.listScopes(String(tripId), String(conn.id), owner)).resolves.toEqual({ scopes: [], error: 'unauthorized' });
|
|
});
|
|
|
|
it('refuses a connection whose provider has no adapter on this instance', async () => {
|
|
const id = Number(
|
|
testDb.prepare('INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url) VALUES (?, ?, ?, ?)')
|
|
.run(tripId, 'papra', Number(owner.id), 'https://papra.example.com').lastInsertRowid,
|
|
);
|
|
const err = await thrown(() => controller.listScopes(String(tripId), String(id), owner));
|
|
expect(err.getStatus()).toBe(400);
|
|
expect(err.message).toBe('Provider not available');
|
|
});
|
|
|
|
it('refuses a connection that belongs to a different trip', async () => {
|
|
const conn = await storedPaperless();
|
|
const err = await thrown(() => controller.listScopes(String(otherTripId), String(conn.id), admin));
|
|
expect(err.getStatus()).toBe(404);
|
|
expect(paperless.listScopes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('hands back the container the adapter created', async () => {
|
|
const conn = await storedPaperless();
|
|
paperless.createScope.mockResolvedValueOnce({
|
|
success: true,
|
|
data: { scopeKey: 'tag:9', label: 'Norway', remoteRootId: '9', remoteRootPath: '/TREK/norway' },
|
|
} as never);
|
|
|
|
await expect(controller.createScope(String(tripId), String(conn.id), owner, { name: 'Norway' }))
|
|
.resolves.toMatchObject({ scopeKey: 'tag:9', label: 'Norway' });
|
|
expect(paperless.createScope).toHaveBeenCalledWith(expect.objectContaining({ connectionId: conn.id }), 'Norway');
|
|
});
|
|
|
|
it('creates nothing through a connection that belongs to a different trip', async () => {
|
|
// The connection comes from the path and nowhere else, so the path is what
|
|
// has to match the trip the caller was admitted to.
|
|
const conn = await storedPaperless();
|
|
const err = await thrown(() => controller.createScope(String(otherTripId), String(conn.id), admin, { name: 'Norway' }));
|
|
expect(err.getStatus()).toBe(404);
|
|
expect(paperless.createScope).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('turns a refused creation into a 400 carrying the provider code', async () => {
|
|
const conn = await storedPaperless();
|
|
paperless.createScope.mockResolvedValueOnce({ success: false, error: { code: 'forbidden' } } as never);
|
|
const err = await thrown(() => controller.createScope(String(tripId), String(conn.id), owner, { name: 'Norway' }));
|
|
expect(err.getStatus()).toBe(400);
|
|
expect(err.message).toBe('forbidden');
|
|
});
|
|
});
|
|
|
|
describe('creating a binding', () => {
|
|
it('subscribes at the provider and remembers the subscription id', async () => {
|
|
const conn = await storedPaperless();
|
|
const link = (await controller.createLink(
|
|
String(tripId),
|
|
owner,
|
|
linkBody(conn.id),
|
|
makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'trek.example' }),
|
|
)) as { id: number };
|
|
|
|
const row = (await config.getLink(link.id))!;
|
|
expect(row.webhook_subscription_id).toBe('sub-7');
|
|
expect(paperless.registerWebhook.mock.calls[0][2]).toBe(`https://trek.example/api/docsync/webhook/${row.webhook_token}`);
|
|
// The secret handed to the provider is the plaintext one, not the stored blob.
|
|
expect(paperless.registerWebhook.mock.calls[0][3]).toBe(config.webhookSecret(row));
|
|
});
|
|
|
|
it('keeps the binding when the provider refuses the subscription, because polling still carries it', async () => {
|
|
const conn = await storedPaperless();
|
|
paperless.registerWebhook.mockResolvedValueOnce({ success: false, error: { code: 'forbidden' } } as never);
|
|
|
|
const link = (await controller.createLink(
|
|
String(tripId),
|
|
owner,
|
|
linkBody(conn.id),
|
|
makeReq({ 'x-forwarded-host': 'trek.example' }),
|
|
)) as { id: number; syncEnabled: boolean };
|
|
|
|
const row = (await config.getLink(link.id))!;
|
|
expect(row.webhook_subscription_id).toBeNull();
|
|
expect(row.sync_enabled).toBe(1);
|
|
});
|
|
|
|
it('says so in the log when the provider refuses the subscription, since nothing on screen will', async () => {
|
|
// The refusal used to go nowhere at all: not thrown, not logged, and the
|
|
// first run overwrites the link state a moment later. An admin whose
|
|
// Paperless kept declining the workflow had no way to find out why the
|
|
// binding only ever ran on the timer.
|
|
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
|
try {
|
|
const conn = await storedPaperless();
|
|
paperless.registerWebhook.mockResolvedValueOnce({ success: false, error: { code: 'forbidden', detail: 'API key may not manage workflows' } } as never);
|
|
|
|
const link = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq({ host: 'trek.example' }))) as { id: number };
|
|
|
|
const lines = warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(`link ${link.id}`));
|
|
expect(lines).toHaveLength(1);
|
|
expect(lines[0]).toContain('forbidden');
|
|
expect(lines[0]).toContain('API key may not manage workflows');
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('does not offer a webhook when no host reaches TREK', async () => {
|
|
const conn = await storedPaperless();
|
|
const link = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { webhookUrl: string | null };
|
|
expect(paperless.registerWebhook).not.toHaveBeenCalled();
|
|
expect(link.webhookUrl).toBeNull();
|
|
});
|
|
|
|
it('starts a first run at once instead of letting the user wait out a poll interval', async () => {
|
|
const conn = await storedPaperless();
|
|
await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq({ host: 'trek.example' }));
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
expect(sync.syncLink.mock.calls[0][1]).toEqual({ full: true });
|
|
});
|
|
|
|
it('pings the whole trip, so a member already on the Files tab gets the sync button', async () => {
|
|
const conn = await storedPaperless();
|
|
const link = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
|
|
|
// Three arguments: no socket is skipped, the owner's own button reads it too.
|
|
expect(realtime.broadcast).toHaveBeenCalledWith(tripId, 'docsync:changed', { linkId: link.id, pulled: 0, pushed: 0 });
|
|
});
|
|
|
|
it('refuses a binding for a connection that belongs to a different trip', async () => {
|
|
const conn = await storedPaperless();
|
|
const err = await thrown(() =>
|
|
controller.createLink(String(otherTripId), admin, linkBody(conn.id), makeReq({ host: 'trek.example' })),
|
|
);
|
|
expect(err.getStatus()).toBe(400);
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
expect(realtime.broadcast).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('changing a binding', () => {
|
|
it('writes only the fields the request carried', async () => {
|
|
const conn = await storedPaperless();
|
|
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
|
|
|
const res = (await controller.updateLink(String(tripId), String(created.id), owner, { syncEnabled: false }, makeReq())) as {
|
|
syncEnabled: boolean;
|
|
direction: string;
|
|
remoteLabel: string;
|
|
};
|
|
|
|
expect(res.syncEnabled).toBe(false);
|
|
expect(res.direction).toBe('both');
|
|
expect(res.remoteLabel).toBe('Japan 2026');
|
|
});
|
|
|
|
it('refuses a link that belongs to a different trip', async () => {
|
|
const conn = await storedPaperless();
|
|
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
|
const err = await thrown(() => controller.updateLink(String(otherTripId), String(created.id), admin, { syncEnabled: false }, makeReq()));
|
|
expect(err.getStatus()).toBe(404);
|
|
expect((await config.getLink(created.id))!.sync_enabled).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('a manual run', () => {
|
|
it('passes the full flag through to the reconciler', async () => {
|
|
const conn = await storedPaperless();
|
|
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
|
sync.syncLink.mockClear();
|
|
|
|
await controller.syncNow(String(tripId), String(created.id), { full: true });
|
|
|
|
expect(sync.syncLink.mock.calls[0][0]).toMatchObject({ id: created.id });
|
|
expect(sync.syncLink.mock.calls[0][1]).toEqual({ full: true });
|
|
});
|
|
|
|
it('refuses a link that belongs to a different trip', async () => {
|
|
const conn = await storedPaperless();
|
|
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
|
sync.syncLink.mockClear();
|
|
|
|
const err = await thrown(() => controller.syncNow(String(otherTripId), String(created.id), { full: false }));
|
|
expect(err.getStatus()).toBe(404);
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a binding an admin switched off with a code, and touches nothing', async () => {
|
|
// A code rather than a sentence so the client can say it in the reader's
|
|
// language; the MCP tool answers with the same one. Refused before the
|
|
// shelved rows are cleared, so the binding resumes exactly as it was.
|
|
const conn = await storedPaperless();
|
|
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
|
sync.syncLink.mockClear();
|
|
sync.isSwitchedOff.mockReturnValueOnce(true);
|
|
|
|
const err = await thrown(() => controller.syncNow(String(tripId), String(created.id), { full: false }));
|
|
|
|
expect(err.getStatus()).toBe(409);
|
|
expect(err.getResponse()).toEqual({ error: 'provider_disabled' });
|
|
expect(sync.isSwitchedOff.mock.calls[0][0]).toMatchObject({ id: created.id });
|
|
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a paused binding whose owner left, although the sweep never marked it', async () => {
|
|
const res = await config.upsertConnection(tripId, Number(member.id), connBody({ credentials: { api_token: 'member-token' } }));
|
|
if ('error' in res) throw new Error('fixture failed');
|
|
const created = (await controller.createLink(String(tripId), owner, linkBody(res.data.id, { syncEnabled: false }), makeReq())) as { id: number };
|
|
sync.syncLink.mockClear();
|
|
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, member.id);
|
|
try {
|
|
const err = await thrown(() => controller.syncNow(String(tripId), String(created.id), { full: false }));
|
|
|
|
expect(err.getStatus()).toBe(409);
|
|
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
} finally {
|
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member.id);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('the document list', () => {
|
|
async function seedItems() {
|
|
const conn = await storedPaperless();
|
|
const fileId = Number(
|
|
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
|
|
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
|
|
);
|
|
const linkId = Number(
|
|
testDb.prepare(
|
|
`INSERT INTO trip_document_links (trip_id, connection_id, provider_id, remote_scope_key)
|
|
VALUES (?, ?, ?, ?)`,
|
|
).run(tripId, conn.id, 'paperless', 'tag:1').lastInsertRowid,
|
|
);
|
|
const insert = testDb.prepare('INSERT INTO document_sync_items (link_id, trip_id, file_id, trek_doc_uid, state) VALUES (?, ?, ?, ?, ?)');
|
|
insert.run(linkId, tripId, fileId, 'uid-1', 'synced');
|
|
insert.run(linkId, tripId, null, 'uid-2', 'conflict');
|
|
// Another trip's row, which must never appear in this trip's list.
|
|
insert.run(linkId, otherTripId, null, 'uid-3', 'conflict');
|
|
}
|
|
|
|
it('carries the local file name, so the list reads as documents rather than as ids', async () => {
|
|
await seedItems();
|
|
const rows = (await controller.items(String(tripId))) as Array<{ trek_doc_uid: string; file_name: string | null }>;
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.find((r) => r.trek_doc_uid === 'uid-1')!.file_name).toBe('boarding.pdf');
|
|
});
|
|
|
|
it('narrows to one state when asked, and never leaves the trip', async () => {
|
|
await seedItems();
|
|
const rows = (await controller.items(String(tripId), 'conflict')) as Array<{ trek_doc_uid: string }>;
|
|
expect(rows.map((r) => r.trek_doc_uid)).toEqual(['uid-2']);
|
|
});
|
|
});
|
|
|
|
describe('resolving a conflict', () => {
|
|
it('refuses an item that is not in conflict rather than pretending it did something', async () => {
|
|
sync.resolveConflict.mockResolvedValueOnce(false);
|
|
const err = await thrown(() => controller.resolve(String(tripId), '5', owner, { keep: 'provider' }));
|
|
expect(err.getStatus()).toBe(400);
|
|
expect(err.message).toBe('Item is not in conflict');
|
|
});
|
|
});
|
|
|
|
describe('removing a binding', () => {
|
|
it('unsubscribes at the provider and keeps both copies of the documents', async () => {
|
|
const conn = await storedPaperless();
|
|
const created = (await controller.createLink(
|
|
String(tripId),
|
|
owner,
|
|
linkBody(conn.id),
|
|
makeReq({ host: 'trek.example' }),
|
|
)) as { id: number };
|
|
const fileId = Number(
|
|
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
|
|
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
|
|
);
|
|
testDb
|
|
.prepare('INSERT INTO document_sync_items (link_id, trip_id, file_id, trek_doc_uid, state) VALUES (?, ?, ?, ?, ?)')
|
|
.run(created.id, tripId, fileId, 'uid-1', 'synced');
|
|
|
|
const res = await controller.deleteLink(String(tripId), String(created.id), owner);
|
|
|
|
expect(res).toEqual({ success: true, documentsKept: true });
|
|
expect(paperless.unregisterWebhook.mock.calls[0][1]).toBe('sub-7');
|
|
expect(await config.getLink(created.id)).toBeUndefined();
|
|
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
|
|
});
|
|
|
|
it('pings the trip that the binding is gone', async () => {
|
|
const conn = await storedPaperless();
|
|
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
|
realtime.broadcast.mockClear();
|
|
|
|
await controller.deleteLink(String(tripId), String(created.id), owner);
|
|
|
|
expect(realtime.broadcast.mock.calls).toEqual([
|
|
[tripId, 'docsync:changed', { linkId: created.id, pulled: 0, pushed: 0 }],
|
|
]);
|
|
});
|
|
|
|
it('refuses a link that belongs to a different trip', async () => {
|
|
const conn = await storedPaperless();
|
|
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
|
realtime.broadcast.mockClear();
|
|
const err = await thrown(() => controller.deleteLink(String(otherTripId), String(created.id), admin));
|
|
expect(err.getStatus()).toBe(404);
|
|
expect(await config.getLink(created.id)).toBeTruthy();
|
|
expect(realtime.broadcast).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('the callback origin a provider is given', () => {
|
|
async function linkedTrip() {
|
|
const conn = await storedPaperless();
|
|
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
|
return (await config.getLink(created.id))!;
|
|
}
|
|
|
|
it('builds the callback URL from the forwarded proto and host', async () => {
|
|
const row = await linkedTrip();
|
|
const [link] = (await controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'docs.example' }))) as Array<{ webhookUrl: string }>;
|
|
expect(link.webhookUrl).toBe(`https://docs.example/api/docsync/webhook/${row.webhook_token}`);
|
|
});
|
|
|
|
it('takes the first hop when a proxy chain appends its own protocol', async () => {
|
|
await linkedTrip();
|
|
const [link] = (await controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https, http', 'x-forwarded-host': 'docs.example' }))) as Array<{ webhookUrl: string }>;
|
|
expect(link.webhookUrl).toMatch(/^https:\/\/docs\.example\//);
|
|
});
|
|
|
|
it('falls back to the Host header and the connection scheme when nothing is forwarded', async () => {
|
|
await linkedTrip();
|
|
const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.lan:3001' }, 'http'))) as Array<{ webhookUrl: string }>;
|
|
expect(link.webhookUrl).toMatch(/^http:\/\/trek\.lan:3001\//);
|
|
});
|
|
|
|
it('offers no callback URL when a proxy strips the host', async () => {
|
|
await linkedTrip();
|
|
const [link] = (await controller.listLinks(String(tripId), makeReq())) as Array<{ webhookUrl: string | null }>;
|
|
expect(link.webhookUrl).toBeNull();
|
|
});
|
|
|
|
it('offers no callback URL for a store the probe found unable to call back at all', async () => {
|
|
// Synology takes no webhook. Showing the address with "paste this into
|
|
// your provider" next to it promised something there was nowhere to do.
|
|
const row = await linkedTrip();
|
|
await config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'none' });
|
|
const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.example' }))) as Array<{ webhookUrl: string | null }>;
|
|
expect(link.webhookUrl).toBeNull();
|
|
});
|
|
|
|
it('keeps offering it for a store the probe found able to take one', async () => {
|
|
const row = await linkedTrip();
|
|
await config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'webhook-manual' });
|
|
const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.example' }))) as Array<{ webhookUrl: string | null }>;
|
|
expect(link.webhookUrl).toBe(`http://trek.example/api/docsync/webhook/${row.webhook_token}`);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A manual run is also a request to try the given-up rows once more.
|
|
*
|
|
* The scheduler must never do this: the attempt limit exists precisely so a
|
|
* document a provider refuses is not re-uploaded on every tick. A person
|
|
* pressing the button is the signal that something changed.
|
|
*/
|
|
describe('a manual run and the shelved rows', () => {
|
|
it('clears the attempt counters of the link it runs', async () => {
|
|
const conn = await storedPaperless();
|
|
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
|
sync.retryShelvedItems.mockClear();
|
|
|
|
await controller.syncNow(String(tripId), String(created.id), { full: false });
|
|
|
|
expect(sync.retryShelvedItems).toHaveBeenCalledWith(created.id);
|
|
});
|
|
|
|
it('does not clear them for a link the caller does not own', async () => {
|
|
sync.retryShelvedItems.mockClear();
|
|
await expect(controller.syncNow(String(tripId), '999999', { full: false })).rejects.toThrow();
|
|
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The trip the route was authorised against is now passed on to the service.
|
|
*
|
|
* `assertCanManage` proves the caller may manage the trip in the URL and says
|
|
* nothing about whether the item id in the path belongs to that trip: the id is
|
|
* a plain integer, and nothing tied the two together. The check itself lives in
|
|
* the service (where the row is), so what belongs here is that the trip reaches
|
|
* it at all.
|
|
*/
|
|
describe('resolving a conflict carries its trip', () => {
|
|
it('passes the trip from the URL to the service', async () => {
|
|
sync.resolveConflict.mockClear();
|
|
await controller.resolve(String(tripId), '42', owner, { keep: 'trek' } as never);
|
|
expect(sync.resolveConflict).toHaveBeenCalledWith(42, 'trek', tripId);
|
|
});
|
|
});
|