Files
TREK/server/tests/unit/nest/doc-sync/paperless.provider.test.ts
T
Maurice 2d7cb124b3 feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology
A trip can be bound to a folder, tag or space in a self-hosted document store,
and documents then move in both directions: what someone uploads in TREK appears
there, and what someone files there appears in TREK.

Three decisions shape the rest.

The connection belongs to the TRIP, not to a person. Every other integration
here is per user, and photos are shared only through an opt-in flag; neither can
deliver "everyone on the trip sees the same documents", because visibility would
depend on whose credentials fetched a file, and a member without their own
Paperless account would see nothing. So the trip owner binds the trip once, the
server talks to the provider under that one identity, and TREK's own membership
decides who sees what. The provider never learns that TREK has members. When the
credential holder leaves the trip the binding goes to `orphaned` rather than
quietly carrying on with an ex-member's token.

TREK keeps the bytes. A synced document is an ordinary trip_files row, so
download, trash, offline cache, PDF export and backup keep working untouched,
and a provider outage does not blank out a trip that starts tomorrow.

Deletion never crosses the boundary on its own. A document that disappears
upstream is recorded as missing and waits for a human, and a run that sees more
than 40 percent of its known documents vanish at once refuses the whole listing
rather than acting on it: an unmounted share answers exactly like a mass delete.

The core reconciles by enumerating both sides, because the weakest provider sets
the model. A changed-since query cannot see a deletion, Papra does not move
updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and
Synology FileStation has no change feed at all. A webhook is therefore only ever
"look now", never a source of truth. Echo is caught by comparing content, not
timestamps: pushed_sha256 records what TREK itself wrote, so a provider
reporting TREK's own upload is not mistaken for a stranger's edit.

Provider differences are answered by capability data rather than by branching on
an id: stable ids where they exist (Paperless document id, oc:fileid, driveId)
and a normalised path where they do not, replace-in-place where the API offers
it, and a content hash from the listing only where the provider really supplies
one.

Admins switch providers on under Admin, Addons, Documents, mirroring how photo
providers hang off Journey, including the cascade and the 409 when the parent
addon is off. The per-trip setup lives in the file manager, where the person has
the context that makes the folder choice obvious.
2026-09-18 19:23:07 +02:00

906 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Paperless-ngx adapter — PAPERLESS-001..095.
*
* Two things are pinned here. What the client puts on the wire: the pinned API
* version (an unpinned Accept header is how the `checksum` field silently moved
* out of the document serializer between two `docker pull`s), the token header,
* the `original=true` on every download, and the one JSON object the custom
* fields have to arrive in. And what the adapter makes of the answers: the
* error code for each way a self-hosted instance disappoints, the newest
* revision's hash rather than the root one, and the document id surviving a
* replace although the consume task names a different one.
*
* `safeFetch` and the capped readers are mocked: the SSRF guard does a real DNS
* lookup and the reader wants a real stream. The readCappedJson stub still
* parses the bytes it is handed, so "Paperless answered an HTML login page"
* stays a genuine parse failure instead of a mock returning undefined on
* command. The behaviour asserted here was measured against a live
* Paperless-ngx 3.1.3 first; see BEFUND-paperless.md in the test lab.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Readable } from 'node:stream';
const {
safeFetchMock,
readCappedJsonMock,
readCappedTextMock,
discardBodyMock,
FakeSsrfBlockedError,
} = vi.hoisted(() => {
// Declared inside the hoisted block: a class at module level is not hoisted
// with the vi.mock factory and would be read before it exists.
class FakeSsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
return {
safeFetchMock: vi.fn(),
readCappedJsonMock: vi.fn(),
readCappedTextMock: vi.fn(),
discardBodyMock: vi.fn(),
FakeSsrfBlockedError,
};
});
vi.mock('../../../../src/utils/ssrfGuard', () => ({
safeFetch: safeFetchMock,
SsrfBlockedError: FakeSsrfBlockedError,
}));
vi.mock('../../../../src/utils/cappedFetch', () => ({
readCappedJson: readCappedJsonMock,
readCappedText: readCappedTextMock,
discardBody: discardBodyMock,
}));
import {
PaperlessClient,
PaperlessError,
apiBase,
buildMultipart,
currentVersion,
sha256OrNull,
DOCUMENT_PAGE_SIZE,
} from '../../../../src/nest/doc-sync/providers/paperless.client';
import {
PaperlessDocumentProvider,
PAPERLESS_WEBHOOK_SECRET_HEADER,
TRIP_UID_FIELD_NAME,
documentFileName,
normalizeMime,
parseScopeKey,
titleFromFileName,
} from '../../../../src/nest/doc-sync/providers/paperless.provider';
import { PROVIDER_MAX_PAGES } from '../../../../src/nest/doc-sync/doc-sync.constants';
import type {
DocResult,
DocumentConnectionRef,
DocumentScopeRef,
PushRequest,
} from '../../../../src/nest/doc-sync/document-provider';
import { docFailed } from '../../../../src/nest/doc-sync/document-provider';
const TOKEN = 'ba17f0c4d2e94f118a6c0d3b7e5a9c21f4d6b8e0';
const BASE = 'https://papers.example.org';
const CONN: DocumentConnectionRef = {
connectionId: 4,
ownerId: 9,
baseUrl: BASE,
secrets: { api_token: TOKEN },
settings: {},
allowInsecureTls: false,
};
const SCOPE: DocumentScopeRef = {
linkId: 7,
tripId: 42,
scopeKey: 'tag:3',
remoteRootId: '3',
remoteRootPath: '/documents?tags__id__all=3',
cursor: null,
};
const HASH_A = 'a'.repeat(64);
const HASH_B = 'b'.repeat(64);
interface Reply {
ok: boolean;
status: number;
headers: Headers;
body: ReadableStream<Uint8Array> | null;
text(): Promise<string>;
}
interface Recorded {
url: URL;
init: RequestInit;
options?: { rejectUnauthorized?: boolean };
body: string;
}
let calls: Recorded[] = [];
let routes: Map<string, Reply[]>;
function reply(body: unknown, status = 200, headers: Record<string, string> = {}): Reply {
const raw = typeof body === 'string' ? body : JSON.stringify(body);
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(headers),
body: null,
text: async () => raw,
};
}
/** A streaming answer, for the download path. */
function streamReply(bytes: string, headers: Record<string, string> = {}): Reply {
return {
ok: true,
status: 200,
headers: new Headers(headers),
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(bytes));
controller.close();
},
}),
text: async () => bytes,
};
}
/**
* Stub one route. Several replies are consumed in order and the last one
* repeats, which is what the pagination cases want.
*/
function on(route: string, ...replies: Reply[]): void {
routes.set(route, replies);
}
function requests(route: string): Recorded[] {
return calls.filter((call) => `${(call.init.method ?? 'GET').toUpperCase()} ${call.url.pathname}` === route);
}
function lastRequest(route: string): Recorded {
const matching = requests(route);
const last = matching[matching.length - 1];
if (last === undefined) throw new Error(`no request was made to ${route}`);
return last;
}
function headerOf(call: Recorded, name: string): string | undefined {
const headers = call.init.headers as Record<string, string> | undefined;
return headers?.[name];
}
function tagRow(id: number, name: string, documentCount = 0): unknown {
return { id, name, slug: name, color: '#a6cee3', document_count: documentCount, owner: 2 };
}
function docRow(over: Record<string, unknown> = {}): unknown {
return {
id: 11,
title: 'Hotelrechnung Kyoto',
tags: [3],
mime_type: 'application/pdf',
original_file_name: 'rechnung.pdf',
modified: '2026-09-18T17:30:00.000000+02:00',
added: '2026-09-18T17:29:00.000000+02:00',
deleted_at: null,
custom_fields: [{ field: 1, value: 'trip-uid-42' }],
versions: [{ id: 11, checksum: HASH_A, added: '2026-09-18T15:29:00.000000Z', is_root: true }],
...over,
};
}
function page(results: unknown[], next: string | null = null, count?: number): unknown {
return { count: count ?? results.length, next, previous: null, results };
}
function taskRow(over: Record<string, unknown> = {}): unknown {
return {
id: 3,
task_id: 'task-uuid-1',
status: 'success',
result_data: { document_id: 11 },
related_document_ids: [11],
...over,
};
}
function pushRequest(over: Partial<PushRequest> = {}): PushRequest {
const bytes = Buffer.from('%PDF-1.4 hotel\n');
return {
body: Readable.from(bytes),
fileName: 'rechnung.pdf',
mimeType: 'application/pdf',
size: bytes.length,
sha256: HASH_A,
mtimeSeconds: 1_789_000_000,
trekDocUid: 'doc-uid-1',
trekTripUid: 'trip-uid-42',
...over,
};
}
// `!result.success` does not narrow: the server workspace compiles with
// `strict: false`, so a boolean discriminant stops discriminating. docFailed is
// the predicate the production code uses for the same reason.
function expectFail(result: DocResult<unknown>): { code: string; detail?: string; status?: number } {
if (!docFailed(result)) throw new Error('expected a failure, got success');
return result.error;
}
function expectOk<T>(result: DocResult<T>): T {
if (docFailed(result)) {
throw new Error(`expected success, got ${result.error.code}: ${result.error.detail ?? ''}`);
}
return result.data;
}
let client: PaperlessClient;
let provider: PaperlessDocumentProvider;
beforeEach(() => {
calls = [];
routes = new Map();
client = new PaperlessClient();
provider = new PaperlessDocumentProvider(client);
safeFetchMock.mockReset();
readCappedJsonMock.mockReset();
readCappedTextMock.mockReset();
discardBodyMock.mockReset();
readCappedJsonMock.mockImplementation(async (res: { text(): Promise<string> }) => {
const text = await res.text();
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
});
readCappedTextMock.mockImplementation(async (res: { text(): Promise<string> }) => ({
text: await res.text(),
truncated: false,
}));
safeFetchMock.mockImplementation(
async (url: string, init: RequestInit, options?: { rejectUnauthorized?: boolean }) => {
const parsed = new URL(url);
const method = (init.method ?? 'GET').toUpperCase();
const body = typeof init.body === 'string'
? init.body
: Buffer.isBuffer(init.body)
? init.body.toString('latin1')
: '';
calls.push({ url: parsed, init, options, body });
const queue = routes.get(`${method} ${parsed.pathname}`);
if (queue === undefined || queue.length === 0) {
throw new Error(`no stubbed reply for ${method} ${parsed.pathname}`);
}
return (queue.length > 1 ? queue.shift() : queue[0]) as unknown as Response;
},
);
});
describe('PaperlessClient — the request it builds', () => {
it('PAPERLESS-001: appends /api to whatever the operator pasted', () => {
expect(apiBase(BASE)).toBe(`${BASE}/api`);
expect(apiBase(`${BASE}/`)).toBe(`${BASE}/api`);
expect(apiBase(`${BASE}/api`)).toBe(`${BASE}/api`);
expect(apiBase(`${BASE}/api/`)).toBe(`${BASE}/api`);
expect(apiBase(' https://home.example.org/paperless/ ')).toBe('https://home.example.org/paperless/api');
expect(apiBase('http://192.168.178.9:8000')).toBe('http://192.168.178.9:8000/api');
});
it('PAPERLESS-002: sends the token as a Token header and pins the API version', async () => {
on('GET /api/ui_settings/', reply({ user: { username: 'admin' }, settings: {}, permissions: [] }));
await provider.probe(CONN);
const call = lastRequest('GET /api/ui_settings/');
expect(headerOf(call, 'Authorization')).toBe(`Token ${TOKEN}`);
expect(headerOf(call, 'Accept')).toBe('application/json; version=10');
});
it('PAPERLESS-003: never puts the token in the query string', async () => {
on('GET /api/ui_settings/', reply({ user: { username: 'admin' }, settings: {}, permissions: [] }));
on('GET /api/tags/', reply(page([tagRow(3, 'japan')])));
on('GET /api/documents/', reply(page([docRow()])));
await provider.probe(CONN);
await provider.listScopes(CONN);
await provider.list(CONN, SCOPE);
expect(calls.length).toBeGreaterThanOrEqual(3);
for (const call of calls) {
expect(call.url.search).not.toContain(TOKEN);
expect(call.url.search).not.toMatch(/token|api_key/i);
}
});
it('PAPERLESS-004: maps allowInsecureTls onto rejectUnauthorized and bounds the call', async () => {
on('GET /api/ui_settings/', reply({ user: { username: 'admin' }, settings: {}, permissions: [] }));
await provider.probe(CONN);
await provider.probe({ ...CONN, allowInsecureTls: true });
expect(calls[0]?.options).toEqual({ rejectUnauthorized: true });
expect(calls[1]?.options).toEqual({ rejectUnauthorized: false });
expect(calls[0]?.init.signal).toBeInstanceOf(AbortSignal);
});
it('PAPERLESS-005: reads the profile that does not echo the token back', async () => {
on('GET /api/ui_settings/', reply({
user: { username: 'admin' },
settings: { version: '3.1.3' },
permissions: ['add_workflow', 'add_document'],
}));
const probed = expectOk(await provider.probe(CONN));
expect(probed.account).toBe('admin');
expect(requests('GET /api/profile/')).toHaveLength(0);
});
});
describe('PaperlessProvider — probing a connection', () => {
it('PAPERLESS-010: reports the self-registering webhook when the token may create workflows', async () => {
on('GET /api/ui_settings/', reply({
user: { username: 'admin' },
settings: { version: '3.1.3' },
permissions: ['add_workflow'],
}));
const probed = expectOk(await provider.probe(CONN));
expect(probed.capabilities.push).toBe('webhook-self-registered');
expect(probed.capabilities.stableId).toBe(true);
expect(probed.capabilities.contentHashInListing).toBe(true);
expect(probed.capabilities.replaceInPlace).toBe(true);
expect(probed.capabilities.remoteTrash).toBe(true);
});
it('PAPERLESS-011: downgrades to a manual webhook when the token may not create one', async () => {
on('GET /api/ui_settings/', reply({
user: { username: 'reisender' },
settings: { version: '3.1.3' },
permissions: ['view_document', 'add_document'],
}));
const probed = expectOk(await provider.probe(CONN));
expect(probed.capabilities.push).toBe('webhook-manual');
// The capability the CORE reads stays the provider's own answer; only the
// probe reflects this token's narrower rights.
expect(provider.capabilities(CONN).push).toBe('webhook-self-registered');
});
it('PAPERLESS-012: a wrong token is unauthorized, a forbidden one is forbidden', async () => {
on('GET /api/ui_settings/', reply({ detail: 'Invalid token.' }, 401));
expect(expectFail(await provider.probe(CONN)).code).toBe('unauthorized');
on('GET /api/ui_settings/', reply({ detail: 'no' }, 403));
expect(expectFail(await provider.probe(CONN)).code).toBe('forbidden');
});
it('PAPERLESS-013: an HTML login page is a provider error, not a crash', async () => {
on('GET /api/ui_settings/', reply('<!doctype html><title>Sign in</title>'));
const error = expectFail(await provider.probe(CONN));
expect(error.code).toBe('provider_error');
expect(error.detail).toContain('Paperless itself');
});
it('PAPERLESS-014: a connection without a token never reaches the network', async () => {
const error = expectFail(await provider.probe({ ...CONN, secrets: {} }));
expect(error.code).toBe('unauthorized');
expect(calls).toHaveLength(0);
});
it('PAPERLESS-015: the SSRF guard, a timeout and a self-signed certificate each get their own code', async () => {
safeFetchMock.mockImplementationOnce(async () => {
throw new FakeSsrfBlockedError('Requests to loopback and link-local addresses are not allowed');
});
expect(expectFail(await provider.probe(CONN)).code).toBe('ssrf_blocked');
safeFetchMock.mockImplementationOnce(async () => {
const err = new Error('The operation was aborted due to timeout');
err.name = 'TimeoutError';
throw err;
});
expect(expectFail(await provider.probe(CONN)).code).toBe('timeout');
safeFetchMock.mockImplementationOnce(async () => {
const cause = Object.assign(new Error('self-signed certificate'), {
code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
});
throw Object.assign(new TypeError('fetch failed'), { cause });
});
expect(expectFail(await provider.probe(CONN)).code).toBe('tls_untrusted');
safeFetchMock.mockImplementationOnce(async () => {
throw Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }),
});
});
expect(expectFail(await provider.probe(CONN)).code).toBe('unreachable');
});
});
describe('PaperlessProvider — scopes are tags', () => {
it('PAPERLESS-020: a tag becomes a scope key, a label and the instance filter URL', async () => {
on('GET /api/tags/', reply(page([tagRow(3, 'japan-2026', 4), tagRow(9, 'privat')])));
const options = expectOk(await provider.listScopes(CONN));
expect(options).toEqual([
{ scopeKey: 'tag:3', label: 'japan-2026', remoteRootId: '3', remoteRootPath: '/documents?tags__id__all=3' },
{ scopeKey: 'tag:9', label: 'privat', remoteRootId: '9', remoteRootPath: '/documents?tags__id__all=9' },
]);
});
it('PAPERLESS-021: follows the pages of a long tag list', async () => {
on(
'GET /api/tags/',
reply(page([tagRow(1, 'a')], 'https://papers.example.org/api/tags/?page=2', 2)),
reply(page([tagRow(2, 'b')], null, 2)),
);
const options = expectOk(await provider.listScopes(CONN));
expect(options.map((option) => option.scopeKey)).toEqual(['tag:1', 'tag:2']);
expect(requests('GET /api/tags/')).toHaveLength(2);
expect(requests('GET /api/tags/')[1]?.url.searchParams.get('page')).toBe('2');
});
it('PAPERLESS-022: a search narrows server-side, an empty one is not sent at all', async () => {
on('GET /api/tags/', reply(page([tagRow(3, 'japan-2026')])));
await provider.listScopes(CONN, ' japan ');
await provider.listScopes(CONN, ' ');
expect(requests('GET /api/tags/')[0]?.url.searchParams.get('name__icontains')).toBe('japan');
expect(requests('GET /api/tags/')[1]?.url.searchParams.has('name__icontains')).toBe(false);
});
it('PAPERLESS-023: creating a tag that exists is a conflict, never a silent adoption', async () => {
on('POST /api/tags/', reply({ error: 'Object violates owner / name unique constraint' }, 400));
expect(expectFail(await provider.createScope(CONN, 'japan-2026')).code).toBe('conflict');
});
it('PAPERLESS-024: a tag that is gone is scope_missing, not a bare 404', async () => {
on('GET /api/tags/3/', reply({ detail: 'No Tag matches the given query.' }, 404));
const error = expectFail(await provider.resolveScope(CONN, SCOPE));
expect(error.code).toBe('scope_missing');
expect(error.status).toBe(404);
});
it('PAPERLESS-025: another provider’s scope key is refused before any request', async () => {
for (const scopeKey of ['path:/trek', 'tag:', 'tag:abc', 'tag:-1', 'fileid:437']) {
const error = expectFail(await provider.resolveScope(CONN, { ...SCOPE, scopeKey }));
expect(error.code).toBe('scope_missing');
}
expect(calls).toHaveLength(0);
expect(parseScopeKey('tag:12')).toBe(12);
});
});
describe('PaperlessProvider — listing a scope', () => {
it('PAPERLESS-030: maps a document onto the core’s shape', async () => {
on('GET /api/documents/', reply(page([docRow()])));
const listed = expectOk(await provider.list(CONN, SCOPE));
expect(listed.documents).toEqual([
{
remoteId: '11',
name: 'Hotelrechnung Kyoto.pdf',
size: null,
mimeType: 'application/pdf',
remoteVersion: '2026-09-18T17:30:00.000000+02:00',
contentHash: HASH_A,
remoteModifiedAt: '2026-09-18T17:30:00.000000+02:00',
isDeleted: false,
},
]);
const call = lastRequest('GET /api/documents/');
expect(call.url.searchParams.get('tags__id__all')).toBe('3');
expect(call.url.searchParams.get('ordering')).toBe('id');
expect(call.url.searchParams.get('page_size')).toBe(String(DOCUMENT_PAGE_SIZE));
});
it('PAPERLESS-031: a checksum that is not sha256 is reported as no hash at all', async () => {
on('GET /api/documents/', reply(page([
docRow({ versions: [{ id: 1, checksum: 'a91fe3f8e2cf4b2b5560b2222d65d60c', added: null, is_root: true }] }),
])));
const listed = expectOk(await provider.list(CONN, SCOPE));
expect(listed.documents[0]?.contentHash).toBeNull();
expect(sha256OrNull('A'.repeat(64))).toBe('a'.repeat(64));
expect(sha256OrNull('zz')).toBeNull();
});
it('PAPERLESS-032: after a replace the hash is the newest revision, not the root one', async () => {
on('GET /api/documents/', reply(page([
docRow({
versions: [
{ id: 12, checksum: HASH_B, added: '2026-09-18T15:40:00.000000Z', is_root: false },
{ id: 11, checksum: HASH_A, added: '2026-09-18T15:29:00.000000Z', is_root: true },
],
}),
])));
const listed = expectOk(await provider.list(CONN, SCOPE));
expect(listed.documents[0]?.contentHash).toBe(HASH_B);
// Independent of the order the instance happens to send them in.
expect(currentVersion({
id: 1,
title: '',
originalFileName: null,
mimeType: null,
modified: null,
added: null,
deletedAt: null,
tagIds: [],
versions: [
{ id: 11, checksum: HASH_A, added: '2026-09-18T15:29:00.000000Z', isRoot: true },
{ id: 12, checksum: HASH_B, added: '2026-09-18T15:40:00.000000Z', isRoot: false },
],
})?.checksum).toBe(HASH_B);
});
it('PAPERLESS-033: a listing that hits the page cap says so and claims nothing about the cursor', async () => {
const endless = reply(page([docRow()], 'https://papers.example.org/api/documents/?page=2', 9999));
on('GET /api/documents/', endless);
const listed = expectOk(await provider.list(CONN, { ...SCOPE, cursor: 'v1:9999:x' }));
expect(listed.truncated).toBe(true);
expect(listed.cursorUnchanged).toBe(false);
expect(requests('GET /api/documents/')).toHaveLength(PROVIDER_MAX_PAGES);
});
it('PAPERLESS-034: an unchanged scope is recognised, a changed one is not', async () => {
on('GET /api/documents/', reply(page([docRow()])));
const first = expectOk(await provider.list(CONN, SCOPE));
expect(first.cursorUnchanged).toBe(false);
const second = expectOk(await provider.list(CONN, { ...SCOPE, cursor: first.cursor }));
expect(second.cursorUnchanged).toBe(true);
// A document leaving the tag moves no timestamp — only the count notices.
on('GET /api/documents/', reply(page([], null, 0)));
const third = expectOk(await provider.list(CONN, { ...SCOPE, cursor: first.cursor }));
expect(third.cursorUnchanged).toBe(false);
expect(third.documents).toHaveLength(0);
});
it('PAPERLESS-035: a document without a title still gets a usable name', async () => {
on('GET /api/documents/', reply(page([
docRow({ id: 5, title: '', original_file_name: 'scan_2026.PDF' }),
docRow({ id: 6, title: '', original_file_name: null, mime_type: 'image/png' }),
docRow({ id: 7, title: 'Bordkarte.pdf', original_file_name: 'bordkarte.pdf' }),
])));
const listed = expectOk(await provider.list(CONN, SCOPE));
expect(listed.documents.map((doc) => doc.name)).toEqual([
'scan_2026.PDF',
'document-6.png',
// An extension the title already carries is not doubled.
'Bordkarte.pdf',
]);
expect(documentFileName({
id: 8,
title: 'Notiz',
originalFileName: null,
mimeType: 'text/plain',
modified: null,
added: null,
deletedAt: null,
tagIds: [],
versions: [],
})).toBe('Notiz.txt');
});
it('PAPERLESS-036: an answer that is not a listing is a provider error', async () => {
on('GET /api/documents/', reply({ unexpected: true }));
expect(expectFail(await provider.list(CONN, SCOPE)).code).toBe('provider_error');
});
});
describe('PaperlessProvider — fetching bytes', () => {
it('PAPERLESS-040: asks for the original file and hands back the stream with its version', async () => {
on('GET /api/documents/11/', reply(docRow()));
on('GET /api/documents/11/download/', streamReply('%PDF-1.4 hotel', {
'content-length': '14',
'content-type': 'application/pdf',
}));
const fetched = expectOk(await provider.fetch(CONN, SCOPE, '11'));
expect(lastRequest('GET /api/documents/11/download/').url.searchParams.get('original')).toBe('true');
expect(fetched.size).toBe(14);
expect(fetched.mimeType).toBe('application/pdf');
expect(fetched.remoteVersion).toBe('2026-09-18T17:30:00.000000+02:00');
const chunks: Buffer[] = [];
for await (const chunk of fetched.body) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks).toString()).toBe('%PDF-1.4 hotel');
});
it('PAPERLESS-041: a missing content-length is an unknown size, never zero', async () => {
on('GET /api/documents/11/', reply(docRow()));
on('GET /api/documents/11/download/', streamReply('%PDF-1.4 hotel'));
const fetched = expectOk(await provider.fetch(CONN, SCOPE, '11'));
expect(fetched.size).toBeNull();
});
it('PAPERLESS-042: a deleted document is not_found, and a nonsense id never leaves the process', async () => {
on('GET /api/documents/11/', reply({ detail: 'No Document matches the given query.' }, 404));
expect(expectFail(await provider.fetch(CONN, SCOPE, '11')).code).toBe('not_found');
const before = calls.length;
expect(expectFail(await provider.fetch(CONN, SCOPE, '../../etc/passwd')).code).toBe('not_found');
expect(calls).toHaveLength(before);
});
});
describe('PaperlessProvider — pushing bytes', () => {
function stubSuccessfulCreate(): void {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow()])));
on('GET /api/documents/11/', reply(docRow()));
}
it('PAPERLESS-050: an unaccepted type is refused before a single byte is uploaded', async () => {
const error = expectFail(await provider.push(CONN, SCOPE, pushRequest({
fileName: 'tour.gpx',
mimeType: 'application/gpx+xml',
})));
expect(error.code).toBe('unsupported_type');
expect(calls).toHaveLength(0);
});
it('PAPERLESS-050b: a stored Content-Type with parameters is still the type it names', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow()])));
on('GET /api/documents/11/', reply(docRow({ mime_type: 'text/plain' })));
expectOk(await provider.push(CONN, SCOPE, pushRequest({
fileName: 'notiz.txt',
mimeType: 'Text/Plain; charset=utf-8',
})));
expect(lastRequest('POST /api/documents/post_document/').body).toContain('Content-Type: text/plain\r\n');
expect(normalizeMime('application/pdf')).toBe('application/pdf');
});
it('PAPERLESS-051: an upload carries the tag, the title and the custom field in one request', async () => {
stubSuccessfulCreate();
const pushed = expectOk(await provider.push(CONN, SCOPE, pushRequest()));
expect(pushed).toEqual({
remoteId: '11',
remoteVersion: '2026-09-18T17:30:00.000000+02:00',
remoteModifiedAt: '2026-09-18T17:30:00.000000+02:00',
// Paperless stores duplicate bytes twice, so nothing here may claim a
// deduplication happened.
deduplicated: false,
});
const upload = lastRequest('POST /api/documents/post_document/');
expect(headerOf(upload, 'Content-Type')).toMatch(/^multipart\/form-data; boundary=/);
// Content-Length is undici's business; setting it makes it refuse the call.
expect(headerOf(upload, 'Content-Length')).toBeUndefined();
expect(upload.body).toContain('name="tags"\r\n\r\n3');
expect(upload.body).toContain('name="title"\r\n\r\nrechnung');
expect(upload.body).toContain('name="custom_fields"\r\n\r\n{"1":"trip-uid-42"}');
expect(upload.body).toContain('filename="rechnung.pdf"');
expect(upload.body).toContain('%PDF-1.4 hotel');
});
it('PAPERLESS-052: the trip anchor field is created when the instance has none', async () => {
on('GET /api/custom_fields/', reply(page([])));
on('POST /api/custom_fields/', reply({ id: 4, name: TRIP_UID_FIELD_NAME, data_type: 'string' }));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow()])));
on('GET /api/documents/11/', reply(docRow()));
expectOk(await provider.push(CONN, SCOPE, pushRequest()));
expect(lastRequest('POST /api/custom_fields/').body).toContain(TRIP_UID_FIELD_NAME);
expect(lastRequest('POST /api/documents/post_document/').body).toContain('{"4":"trip-uid-42"}');
});
it('PAPERLESS-053: a custom field list from a build that ignores the filter is not trusted', async () => {
on('GET /api/custom_fields/', reply(page([
{ id: 8, name: 'rechnungsnummer', data_type: 'string' },
{ id: 9, name: 'TREK_TRIP_ID', data_type: 'string' },
])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow()])));
on('GET /api/documents/11/', reply(docRow()));
expectOk(await provider.push(CONN, SCOPE, pushRequest()));
expect(lastRequest('POST /api/documents/post_document/').body).toContain('{"9":"trip-uid-42"}');
expect(requests('POST /api/custom_fields/')).toHaveLength(0);
});
it('PAPERLESS-054: a consume that fails is a provider error, a duplicate one a conflict', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow({
status: 'failure',
related_document_ids: [],
result_data: { error_type: 'ConsumerError', error_message: 'kaputt.pdf: InputFileError' },
})])));
const failed = expectFail(await provider.push(CONN, SCOPE, pushRequest()));
expect(failed.code).toBe('provider_error');
expect(failed.detail).toContain('InputFileError');
on('GET /api/tasks/', reply(page([taskRow({
status: 'FAILURE',
related_document_ids: [],
result_data: { error_message: 'It is a duplicate of Rechnung (#4)' },
})])));
expect(expectFail(await provider.push(CONN, SCOPE, pushRequest())).code).toBe('conflict');
});
it('PAPERLESS-055: the task filter is not trusted either — a foreign task is ignored', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow({ task_id: 'someone-elses-task', status: 'success' })])));
vi.spyOn(client, 'awaitConsume');
// The only way out is the poll budget, so the client is asked directly with
// a short one rather than holding the test for two minutes.
const creds = { baseUrl: BASE, token: TOKEN, allowInsecureTls: false };
await expect(
client.awaitConsume(creds, 'task-uuid-1', { intervalMs: 1, timeoutMs: 5 }),
).rejects.toMatchObject({ code: 'timeout' });
});
it('PAPERLESS-056: an upload whose task outlives the budget is found again by its checksum', async () => {
vi.spyOn(client, 'awaitConsume').mockRejectedValue(
new PaperlessError('timeout', 'Paperless is still processing the uploaded file'),
);
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
// One document with the same bytes in another tag, one in ours: the tagged
// one is the upload we just made.
on('GET /api/documents/', reply(page([
docRow({ id: 21, tags: [99] }),
docRow({ id: 11, tags: [3] }),
])));
on('GET /api/documents/11/', reply(docRow()));
const pushed = expectOk(await provider.push(CONN, SCOPE, pushRequest()));
expect(pushed.remoteId).toBe('11');
expect(lastRequest('GET /api/documents/').url.searchParams.get('checksum__iexact')).toBe(HASH_A);
});
it('PAPERLESS-057: when the checksum finds nothing either, the timeout is reported', async () => {
vi.spyOn(client, 'awaitConsume').mockRejectedValue(
new PaperlessError('timeout', 'Paperless is still processing the uploaded file'),
);
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/documents/', reply(page([])));
expect(expectFail(await provider.push(CONN, SCOPE, pushRequest())).code).toBe('timeout');
});
it('PAPERLESS-058: a replace keeps the id it was given, not the one the task names', async () => {
on('POST /api/documents/11/update_version/', reply('"task-uuid-2"'));
// update_version files the new revision under a fresh internal id that
// answers 404 on its own. Adopting it would break the mapping.
on('GET /api/tasks/', reply(page([taskRow({ task_id: 'task-uuid-2', related_document_ids: [77] })])));
on('GET /api/documents/11/', reply(docRow({
modified: '2026-09-18T18:00:00.000000+02:00',
versions: [
{ id: 12, checksum: HASH_A, added: '2026-09-18T16:00:00.000000Z', is_root: false },
{ id: 11, checksum: HASH_B, added: '2026-09-18T15:29:00.000000Z', is_root: true },
],
})));
const pushed = expectOk(await provider.push(CONN, SCOPE, pushRequest({ remoteId: '11' })));
expect(pushed.remoteId).toBe('11');
expect(pushed.remoteVersion).toBe('2026-09-18T18:00:00.000000+02:00');
expect(requests('POST /api/documents/post_document/')).toHaveLength(0);
expect(requests('GET /api/documents/77/')).toHaveLength(0);
});
it('PAPERLESS-059: a file larger than it claimed is refused without being uploaded', async () => {
const error = expectFail(await provider.push(CONN, SCOPE, pushRequest({ size: 4 })));
expect(error.code).toBe('too_large');
expect(requests('POST /api/documents/post_document/')).toHaveLength(0);
});
it('PAPERLESS-060: a type the instance refuses comes back as the same code as the local check', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply({ document: ['File type text/xml not supported'] }, 400));
expect(expectFail(await provider.push(CONN, SCOPE, pushRequest())).code).toBe('unsupported_type');
});
it('PAPERLESS-061: bytes that arrived differently are reported and the new document is trashed', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow()])));
on('GET /api/documents/11/', reply(docRow({
versions: [{ id: 11, checksum: HASH_B, added: '2026-09-18T15:29:00.000000Z', is_root: true }],
})));
on('DELETE /api/documents/11/', reply('', 204));
const error = expectFail(await provider.push(CONN, SCOPE, pushRequest({ sha256: HASH_A })));
expect(error.code).toBe('checksum_mismatch');
expect(requests('DELETE /api/documents/11/')).toHaveLength(1);
});
it('PAPERLESS-062: a mismatch after a replace leaves the existing document alone', async () => {
on('POST /api/documents/11/update_version/', reply('"task-uuid-2"'));
on('GET /api/tasks/', reply(page([taskRow({ task_id: 'task-uuid-2' })])));
on('GET /api/documents/11/', reply(docRow({
versions: [{ id: 12, checksum: HASH_B, added: '2026-09-18T16:00:00.000000Z', is_root: false }],
})));
const error = expectFail(await provider.push(CONN, SCOPE, pushRequest({ remoteId: '11', sha256: HASH_A })));
expect(error.code).toBe('checksum_mismatch');
expect(requests('DELETE /api/documents/11/')).toHaveLength(0);
});
});
describe('PaperlessProvider — renaming and trashing', () => {
it('PAPERLESS-070: renaming sets the title without the extension it would add back', async () => {
on('PATCH /api/documents/11/', reply(docRow({ modified: '2026-09-18T19:00:00.000000+02:00' })));
const renamed = expectOk(await provider.rename(CONN, SCOPE, '11', ' Hotelrechnung Kyoto.pdf '));
expect(lastRequest('PATCH /api/documents/11/').body).toBe('{"title":"Hotelrechnung Kyoto"}');
expect(renamed.remoteVersion).toBe('2026-09-18T19:00:00.000000+02:00');
// An extension this adapter would never have added stays part of the name.
expect(titleFromFileName('Vertrag 2026.2')).toBe('Vertrag 2026.2');
expect(titleFromFileName('bordkarte.PDF')).toBe('bordkarte');
});
it('PAPERLESS-071: renaming a document that is gone is not_found', async () => {
on('PATCH /api/documents/11/', reply({ detail: 'No Document matches the given query.' }, 404));
expect(expectFail(await provider.rename(CONN, SCOPE, '11', 'neu.pdf')).code).toBe('not_found');
});
it('PAPERLESS-080: trashing is a soft delete, and doing it twice is still success', async () => {
on('DELETE /api/documents/11/', reply('', 204), reply({ detail: 'No Document matches the given query.' }, 404));
expectOk(await provider.trash(CONN, SCOPE, '11'));
expectOk(await provider.trash(CONN, SCOPE, '11'));
expect(requests('DELETE /api/documents/11/')).toHaveLength(2);
});
});
describe('PaperlessProvider — the self-registered webhook', () => {
it('PAPERLESS-090: subscribes both triggers, filtered on the tag, with the secret in a header', async () => {
on('POST /api/workflows/', reply({ id: 6 }, 201));
const registered = expectOk(
await provider.registerWebhook(CONN, SCOPE, 'https://trek.example.org/api/docsync/hook/7', 'sh4red'),
);
expect(registered.subscriptionId).toBe('6');
const sent = JSON.parse(lastRequest('POST /api/workflows/').body) as {
triggers: Array<{ type: number; filter_has_tags: number[]; sources: number[] }>;
actions: Array<{ type: number; webhook: Record<string, unknown> }>;
};
expect(sent.triggers.map((trigger) => trigger.type)).toEqual([2, 3]);
for (const trigger of sent.triggers) {
expect(trigger.filter_has_tags).toEqual([3]);
// Including the web UI, or a document dropped in by hand fires nothing.
expect(trigger.sources).toEqual([1, 2, 3, 4]);
}
expect(sent.actions[0]?.type).toBe(4);
expect(sent.actions[0]?.webhook).toMatchObject({
url: 'https://trek.example.org/api/docsync/hook/7',
use_params: true,
as_json: true,
include_document: false,
params: { linkId: '7', tripId: '42' },
headers: { [PAPERLESS_WEBHOOK_SECRET_HEADER]: 'sh4red' },
});
// The secret travels in a header, never in the URL or the body.
expect(lastRequest('POST /api/workflows/').url.search).not.toContain('sh4red');
expect(JSON.stringify(sent.actions[0]?.webhook.params)).not.toContain('sh4red');
});
it('PAPERLESS-091: a callback URL Paperless cannot store is refused here, not upstream', async () => {
const long = `https://trek.example.org/api/docsync/hook/${'x'.repeat(240)}`;
const error = expectFail(await provider.registerWebhook(CONN, SCOPE, long, 's'));
expect(error.code).toBe('provider_error');
expect(error.detail).toContain('limit 256');
expect(calls).toHaveLength(0);
});
it('PAPERLESS-092: unsubscribing twice is success, and a nonsense id never leaves the process', async () => {
on('DELETE /api/workflows/6/', reply('', 204), reply({ detail: 'Not found.' }, 404));
expectOk(await provider.unregisterWebhook(CONN, '6'));
expectOk(await provider.unregisterWebhook(CONN, '6'));
const before = calls.length;
expect(expectFail(await provider.unregisterWebhook(CONN, 'workflow-6')).code).toBe('not_found');
expect(calls).toHaveLength(before);
});
});
describe('the multipart body', () => {
it('PAPERLESS-095: a file name cannot break out of the Content-Disposition header', () => {
const { body, contentType } = buildMultipart(
[{ name: 'tags', value: '3' }],
{
fileName: 'a";\r\nX-Injected: 1\r\n\r\nevil.pdf',
mimeType: 'application/pdf',
bytes: Buffer.from('bytes'),
},
);
const text = body.toString('latin1');
expect(text).not.toContain('X-Injected: 1\r\n');
// Quote, CR and LF each become an underscore; everything else survives, so
// the name stays recognisable in the archive.
expect(text).toContain('filename="a_;__X-Injected: 1____evil.pdf"');
const boundary = /boundary=(.+)$/.exec(contentType)?.[1] ?? '';
expect(boundary.length).toBeGreaterThan(16);
expect(text.endsWith(`--${boundary}--\r\n`)).toBe(true);
});
});