mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
test(documents): pin the picker's origin allowlist and both source adapters
This commit is contained in:
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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user