test(documents): pin the picker's origin allowlist and both source adapters

This commit is contained in:
Marc-André Bombeck committed 2026-09-26 13:03:45 +02:00
1 parent fdc60f69c2
commit cab67b4c06
2 files changed
+545

No files matched your search

@@ -0,0 +1,435 @@
/**
* The Paperless-ngx and Papra adapters against recorded response shapes, with
* the network mocked at `safeFetch`. What these pin:
*
* - every request carries the listed origin as the operator-approved pin and
* forbids redirects, and a 3xx is reported, not followed;
* - the source's own query parameters (name, tag, date, page), and the local
* re-check that keeps a server ignoring a filter from widening a result;
* - the error mapping (401 → authRefused, 406 / an old X-Api-Version →
* versionTooOld, 404 → notFound, non-JSON → badResponse, a refused or dead
* host → unreachable);
* - the byte caps on JSON and on a download.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const safeFetch = vi.hoisted(() => vi.fn());
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
return { ...actual, safeFetch };
});
import { SafeFetchError } from "@/lib/safe-fetch";
import { sourceClient } from "../clients";
import {
DocumentSourceError,
DocumentSourceTooLargeError,
SOURCE_JSON_MAX_BYTES,
} from "../http";
const PAPERLESS = {
system: "PAPERLESS" as const,
origin: "http://paperless.lan:8000",
baseUrl: "http://paperless.lan:8000/paperless",
organizationId: null,
token: "pl-secret-token",
};
const PAPRA = {
system: "PAPRA" as const,
origin: "https://papra.example.com",
baseUrl: "https://papra.example.com",
organizationId: "org_123",
token: "ppk_secret",
};
function json(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
/** Route mocked requests by path; anything unmatched is a 404. */
function serve(routes: Record<string, (url: URL) => Response>) {
safeFetch.mockImplementation(async (target: string) => {
const url = new URL(target);
for (const [prefix, handler] of Object.entries(routes)) {
if (url.pathname.startsWith(prefix)) return handler(url);
}
return new Response("not found", { status: 404 });
});
}
function calledUrls(): URL[] {
return safeFetch.mock.calls.map((c) => new URL(String(c[0])));
}
async function failure(promise: Promise<unknown>) {
try {
await promise;
} catch (err) {
return err;
}
throw new Error("expected a failure");
}
beforeEach(() => {
safeFetch.mockReset();
});
describe("every request is pinned to the listed origin", () => {
it("passes the origin as the operator-approved pin and forbids redirects", async () => {
serve({ "/paperless/api/documents/": () => json({ results: [] }) });
await sourceClient(PAPERLESS).test();
expect(safeFetch).toHaveBeenCalledTimes(1);
const [target, init, opts] = safeFetch.mock.calls[0];
expect(String(target)).toBe(
"http://paperless.lan:8000/paperless/api/documents/?page_size=1&fields=id",
);
expect(init.redirect).toBe("manual");
expect(init.headers.Authorization).toBe("Token pl-secret-token");
expect(init.headers.Accept).toBe("application/json; version=9");
expect(opts.operatorApprovedPrivateOrigin).toBe(
"http://paperless.lan:8000",
);
expect(opts.timeoutMs).toBeGreaterThan(0);
expect(opts.requirePublicHost).toBeUndefined();
});
it("reports a redirect instead of following it", async () => {
serve({
"/paperless/api/documents/": () =>
new Response(null, {
status: 302,
headers: { location: "http://169.254.169.254/latest/meta-data" },
}),
});
const err = await failure(sourceClient(PAPERLESS).test());
expect(err).toBeInstanceOf(DocumentSourceError);
expect((err as DocumentSourceError).code).toBe("redirected");
expect(safeFetch).toHaveBeenCalledTimes(1);
});
it("reports a refused or dead destination as unreachable", async () => {
safeFetch.mockRejectedValue(new SafeFetchError("refused", "private_host"));
const err = await failure(sourceClient(PAPRA).test());
expect((err as DocumentSourceError).code).toBe("unreachable");
});
});
describe("Paperless-ngx", () => {
const tags = {
results: [
{ id: 7, name: "Health" },
{ id: 9, name: "Insurance" },
],
next: null,
};
it("searches by title with tag, date and page, and names the tags", async () => {
serve({
"/paperless/api/tags/": () => json(tags),
"/paperless/api/documents/": () =>
json({
count: 30,
next: "http://paperless.lan:8000/paperless/api/documents/?page=3",
results: [
{
id: 41,
title: "Blood test",
created: "2025-03-04",
tags: [7],
original_file_name: "labor.pdf",
},
// A server that ignored the tag filter must not widen the result.
{ id: 42, title: "Car", created: "2025-03-05", tags: [9] },
// Nor one that ignored the date filter.
{ id: 43, title: "Old", created: "2019-01-01", tags: [7] },
// No title: the file name without its extension.
{
id: 44,
title: " ",
created: "2025-06-01T10:00:00+02:00",
tags: [7],
original_file_name: "discharge-letter.pdf",
},
],
}),
});
const result = await sourceClient(PAPERLESS).search({
q: "blood",
tagId: "7",
from: "2025-01-01",
to: "2025-12-31",
page: 2,
});
const list = calledUrls().find((u) => u.pathname.endsWith("/documents/"));
expect(list?.searchParams.get("title__icontains")).toBe("blood");
expect(list?.searchParams.get("tags__id__all")).toBe("7");
expect(list?.searchParams.get("created__date__gte")).toBe("2025-01-01");
expect(list?.searchParams.get("created__date__lte")).toBe("2025-12-31");
expect(list?.searchParams.get("page")).toBe("2");
expect(list?.searchParams.get("page_size")).toBe("25");
expect(result.hasMore).toBe(true);
expect(result.items).toEqual([
{
sourceId: "41",
title: "Blood test",
date: "2025-03-04",
tags: ["Health"],
sizeBytes: null,
},
{
sourceId: "44",
title: "discharge-letter",
date: "2025-06-01",
tags: ["Health"],
sizeBytes: null,
},
]);
});
it("answers a page past the end with nothing", async () => {
serve({
"/paperless/api/tags/": () => json(tags),
"/paperless/api/documents/": () =>
json({ detail: "Invalid page." }, { status: 404 }),
});
await expect(
sourceClient(PAPERLESS).search({
q: "",
tagId: null,
from: null,
to: null,
page: 99,
}),
).resolves.toEqual({ items: [], hasMore: false });
});
it("maps a refused token, an unsupported version and an old server", async () => {
serve({ "/paperless/api/": () => json({}, { status: 401 }) });
expect(
((await failure(sourceClient(PAPERLESS).test())) as DocumentSourceError)
.code,
).toBe("authRefused");
serve({ "/paperless/api/": () => json({}, { status: 406 }) });
expect(
((await failure(sourceClient(PAPERLESS).test())) as DocumentSourceError)
.code,
).toBe("versionTooOld");
serve({
"/paperless/api/": () =>
json({ results: [] }, { headers: { "x-api-version": "5" } }),
});
expect(
((await failure(sourceClient(PAPERLESS).test())) as DocumentSourceError)
.code,
).toBe("versionTooOld");
});
it("reports a web page where the API should be as a bad response", async () => {
serve({
"/paperless/api/": () =>
new Response("<html>login</html>", {
headers: { "content-type": "text/html" },
}),
});
const err = await failure(sourceClient(PAPERLESS).test());
expect((err as DocumentSourceError).code).toBe("badResponse");
});
it("refuses a JSON answer larger than the cap", async () => {
serve({
"/paperless/api/": () =>
new Response("x".repeat(SOURCE_JSON_MAX_BYTES + 10), { status: 200 }),
});
const err = await failure(sourceClient(PAPERLESS).test());
expect((err as DocumentSourceError).code).toBe("badResponse");
});
it("reads a document's details and downloads the original, within the cap", async () => {
serve({
"/paperless/api/documents/41/download/": () =>
new Response(new Uint8Array([37, 80, 68, 70]), {
headers: { "content-disposition": 'attachment; filename="x.pdf"' },
}),
"/paperless/api/documents/41/": () =>
json({
id: 41,
title: "Blood test",
created: "2025-03-04",
original_file_name: "labor.pdf",
}),
});
const client = sourceClient(PAPERLESS);
await expect(client.document("41")).resolves.toEqual({
title: "Blood test",
date: "2025-03-04",
filename: "labor.pdf",
});
const file = await client.download("41", 1024);
expect([...file.bytes]).toEqual([37, 80, 68, 70]);
expect(file.filename).toBe("x.pdf");
const download = calledUrls().at(-1);
expect(download?.searchParams.get("original")).toBe("true");
});
it("refuses a download larger than the vault accepts, declared or not", async () => {
serve({
"/paperless/api/documents/41/download/": () =>
new Response(new Uint8Array(2048), {
headers: { "content-length": "2048" },
}),
});
expect(
await failure(sourceClient(PAPERLESS).download("41", 1024)),
).toBeInstanceOf(DocumentSourceTooLargeError);
serve({
"/paperless/api/documents/41/download/": () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(800));
controller.enqueue(new Uint8Array(800));
controller.close();
},
}),
),
});
expect(
await failure(sourceClient(PAPERLESS).download("41", 1024)),
).toBeInstanceOf(DocumentSourceTooLargeError);
});
});
describe("Papra", () => {
const tags = {
tags: [
{ id: "tag_b", name: "Lab results" },
{ id: "tag_a", name: "Health" },
],
};
it("sends the name and the tag through searchQuery and re-checks both", async () => {
serve({
"/api/organizations/org_123/tags": () => json(tags),
"/api/organizations/org_123/documents": () =>
json({
documentsCount: 60,
documents: [
{
id: "doc_1",
name: "Blood test.pdf",
originalName: "Blood test.pdf",
originalSize: 1234,
createdAt: "2025-03-04T09:00:00.000Z",
tags: [{ id: "tag_b", name: "Lab results" }],
},
{
id: "doc_2",
name: "Other.pdf",
createdAt: "2025-03-04T09:00:00.000Z",
tags: [{ id: "tag_a", name: "Health" }],
},
{
id: "doc_3",
name: "Too old.pdf",
documentDate: "2018-01-01",
createdAt: "2025-03-04T09:00:00.000Z",
tags: [{ id: "tag_b", name: "Lab results" }],
},
],
}),
});
const result = await sourceClient(PAPRA).search({
q: "blood",
tagId: "tag_b",
from: "2025-01-01",
to: null,
page: 1,
});
const list = calledUrls().find((u) => u.pathname.endsWith("/documents"));
expect(list?.searchParams.get("searchQuery")).toBe(
'blood tag:"Lab results"',
);
expect(list?.searchParams.get("pageIndex")).toBe("0");
expect(list?.searchParams.get("pageSize")).toBe("25");
expect(safeFetch.mock.calls[0][1].headers.Authorization).toBe(
"Bearer ppk_secret",
);
expect(result).toEqual({
items: [
{
sourceId: "doc_1",
title: "Blood test",
date: "2025-03-04",
tags: ["Lab results"],
sizeBytes: 1234,
},
],
hasMore: true,
});
});
it("matches nothing for a tag that no longer exists", async () => {
serve({ "/api/organizations/org_123/tags": () => json(tags) });
await expect(
sourceClient(PAPRA).search({
q: "",
tagId: "tag_gone",
from: null,
to: null,
page: 1,
}),
).resolves.toEqual({ items: [], hasMore: false });
expect(calledUrls().some((u) => u.pathname.endsWith("/documents"))).toBe(
false,
);
});
it("lists tags by name and maps an unknown organization to notFound", async () => {
serve({ "/api/organizations/org_123/tags": () => json(tags) });
await expect(sourceClient(PAPRA).tags()).resolves.toEqual([
{ id: "tag_a", name: "Health" },
{ id: "tag_b", name: "Lab results" },
]);
serve({});
const err = await failure(sourceClient(PAPRA).test());
expect((err as DocumentSourceError).code).toBe("notFound");
});
it("reads a document and downloads its file", async () => {
serve({
"/api/organizations/org_123/documents/doc_1/file": () =>
new Response(new Uint8Array([1, 2, 3])),
"/api/organizations/org_123/documents/doc_1": () =>
json({
document: {
id: "doc_1",
name: "Blood test.pdf",
originalName: "blood.pdf",
documentDate: "2025-02-01",
createdAt: "2025-03-04T09:00:00.000Z",
},
}),
});
const client = sourceClient(PAPRA);
await expect(client.document("doc_1")).resolves.toEqual({
title: "Blood test",
date: "2025-02-01",
filename: "blood.pdf",
});
const file = await client.download("doc_1", 1024);
expect([...file.bytes]).toEqual([1, 2, 3]);
});
});
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
configuredDocumentSourceOrigins,
documentSourcesAvailable,
evaluateSourceBaseUrl,
listedDocumentSourceOrigins,
} from "../origins";
const ORIGINAL = process.env.DOCUMENT_SOURCE_ORIGINS;
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.DOCUMENT_SOURCE_ORIGINS;
else process.env.DOCUMENT_SOURCE_ORIGINS = ORIGINAL;
vi.restoreAllMocks();
});
describe("DOCUMENT_SOURCE_ORIGINS", () => {
it("is off when unset or empty", () => {
delete process.env.DOCUMENT_SOURCE_ORIGINS;
expect(documentSourcesAvailable()).toBe(false);
process.env.DOCUMENT_SOURCE_ORIGINS = " ";
expect(documentSourcesAvailable()).toBe(false);
});
it("keeps valid exact origins and drops malformed ones with a redacted warning", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.DOCUMENT_SOURCE_ORIGINS =
"https://paperless.example.com, http://papra.lan:1221, https://user:secret@bad.example/x?token=abc, http://169.254.169.254";
const origins = configuredDocumentSourceOrigins();
expect([...origins].sort()).toEqual([
"http://papra.lan:1221",
"https://paperless.example.com",
]);
expect(documentSourcesAvailable()).toBe(true);
const logged = warn.mock.calls.map((c) => String(c[0])).join("\n");
expect(logged).toContain("DOCUMENT_SOURCE_ORIGINS");
expect(logged).not.toContain("secret");
expect(logged).not.toContain("token=abc");
});
it("lists the origins for the settings card", () => {
process.env.DOCUMENT_SOURCE_ORIGINS =
"https://paperless.example.com,http://papra.lan:1221";
expect(listedDocumentSourceOrigins()).toEqual([
"http://papra.lan:1221",
"https://paperless.example.com",
]);
});
});
describe("evaluateSourceBaseUrl", () => {
const listed = new Set(["https://paperless.example.com", "http://10.0.0.5"]);
it("accepts a listed origin and keeps a base path without the trailing slash", () => {
expect(
evaluateSourceBaseUrl("https://paperless.example.com/paperless/", listed),
).toEqual({
ok: true,
origin: "https://paperless.example.com",
baseUrl: "https://paperless.example.com/paperless",
});
expect(evaluateSourceBaseUrl("http://10.0.0.5", listed)).toEqual({
ok: true,
origin: "http://10.0.0.5",
baseUrl: "http://10.0.0.5",
});
});
it("refuses an origin that is not listed, public or private", () => {
for (const url of [
"https://example.org",
"http://10.0.0.6",
"http://192.168.1.10:8000",
"http://169.254.169.254",
"http://localhost:8000",
]) {
expect(evaluateSourceBaseUrl(url, listed), url).toEqual({
ok: false,
reason: "notAllowed",
});
}
});
it("does not turn a listed host into a suffix, sibling port or other scheme grant", () => {
for (const url of [
"https://evil.paperless.example.com",
"https://paperless.example.com:8443",
"http://paperless.example.com",
"https://paperless.example.com.evil.test",
]) {
expect(evaluateSourceBaseUrl(url, listed).ok, url).toBe(false);
}
});
it("refuses userinfo, a query, a fragment and non-http schemes", () => {
for (const url of [
"https://user:pass@paperless.example.com",
"https://paperless.example.com/?token=x",
"https://paperless.example.com/#x",
"ftp://paperless.example.com",
"not a url",
]) {
expect(evaluateSourceBaseUrl(url, listed), url).toEqual({
ok: false,
reason: "invalid",
});
}
});
});