import assert from "node:assert/strict" import test from "node:test" import { Readable } from "node:stream" import Fastify from "fastify" import { getTableColumns } from "drizzle-orm" import { drizzle } from "drizzle-orm/pg-proxy" import { files } from "../db/schema" import { mcpToolMap } from "../src/mcp/registry" import { createDocumentTools, documentTools } from "../src/mcp/tools/documents" import { assertToolPermission } from "../src/mcp/authz" import { asToolResult } from "../src/mcp/result" import { decodeDocumentUpload, MCP_BODY_LIMIT, MCP_FILE_LIMIT } from "../src/mcp/documentFiles" import { streamToBuffer } from "../src/utils/fileBuffer" import { secrets } from "../src/utils/secrets" import { initS3, s3 } from "../src/utils/s3" import mcpRoutes from "../src/routes/mcp" import authPlugin from "../src/plugins/auth" import { createHash } from "node:crypto" const id = "00000000-0000-4000-8000-000000000001" const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF") const contextFor = (db: any) => ({ server: { db }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read", "documents.write"] }) as any const tool = (name: string) => mcpToolMap.get(`documents.${name}`)! const profileDb = (portal: number | null = null) => drizzle(async () => ({ rows: portal ? [[portal]] : [] })) test("document tools are registered with read/write permissions and reject insufficient permissions", () => { assert.equal(documentTools.length, 15) for (const entry of documentTools) { assert.equal(mcpToolMap.get(entry.name), entry) assert.equal(entry.inputSchema.additionalProperties, false) const context = contextFor(profileDb()) assert.doesNotThrow(() => assertToolPermission(context, entry)) assert.throws(() => assertToolPermission({ ...context, permissions: [] }, entry), { statusCode: 403 }) assert.doesNotThrow(() => assertToolPermission({ ...context, permissions: [], isAdmin: true }, entry)) } for (const name of ["files.upload", "inbox.file", "inbox.archive", "inbox.suggestion.refresh"]) { assert.deepEqual(tool(name).requiredPermissions, ["documents.write"]) } assert.deepEqual(tool("files.download").requiredPermissions, ["documents.read"]) }) test("validates actual upload bytes, strict Base64, filename and size before storage", () => { assert.deepEqual(decodeDocumentUpload("scan.pdf", pdf.toString("base64")), { body: pdf, mimeType: "application/pdf" }) for (const value of ["", null, 42, "!!!!", "Zg==junk", "Zh==", "data:application/pdf;base64," + pdf.toString("base64")]) { assert.throws(() => decodeDocumentUpload("scan.pdf", value), /Base64/) } for (const filename of ["../scan.pdf", "sub/scan.pdf", ".hidden.pdf", "x".repeat(201) + ".pdf", "scan.exe"]) { assert.throws(() => decodeDocumentUpload(filename, pdf.toString("base64"))) } assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.from("not a PDF").toString("base64")), /vollständige PDF/) assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.from("%PDF-1.7 truncated").toString("base64")), /vollständige PDF/) assert.throws(() => decodeDocumentUpload("scan.pdf", "A".repeat(Math.ceil(MCP_FILE_LIMIT / 3) * 4 + 4)), /20 MiB/) const boundary = Buffer.alloc(MCP_FILE_LIMIT, 32) boundary.write("%PDF-1.7\n") boundary.write("%%EOF", boundary.length - 5) assert.equal(decodeDocumentUpload("scan.pdf", boundary.toString("base64")).body.length, MCP_FILE_LIMIT) assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.concat([boundary, Buffer.from("\n")]).toString("base64")), /20 MiB/) }) test("rejects unknown system fields, invalid filters, missing IDs and portal access", async () => { const context = contextFor(profileDb()) for (const [name, args] of [ ["files.upload", { filename: "scan.pdf", contentBase64: pdf.toString("base64"), tenant: 9 }], ["files.get", {}], ["files.get", { id: "bad" }], ["files.list", { folder: "bad" }], ["files.list", { project: -1 }], ["files.list", { limit: 101 }], ["inbox.list", { search: "x".repeat(201) }], ["inbox.file", { id, folder: null, type: id, assignments: { tenant: 9 } }], ["inbox.file", { id, folder: null, type: id, assignments: { project: "12" } }], ["assignments.search", { kind: "__proto__" }], ["files.download", { id, format: "pdf" }], ] as const) await assert.rejects(tool(name).handler(context, args)) await assert.rejects(tool("inbox.count").handler(contextFor(profileDb(123)), {}), { statusCode: 403 }) await assert.rejects(tool("files.download").handler(contextFor(profileDb(123)), { id }), { statusCode: 403 }) await assert.rejects(tool("files.upload").handler(contextFor(profileDb(123)), { filename: "scan.pdf", contentBase64: pdf.toString("base64") }), { statusCode: 403 }) }) for (const name of ["inbox.list", "files.list", "folders.list", "types.list", "assignments.search"]) { test(`${name}: retrieves 205 records with tenant filters and stable pagination`, async () => { const db = drizzle(async (sql, params) => { if (sql.includes('from "auth_profiles"')) return { rows: [] } assert.match(sql, /"tenant(?:_id)?" = \$\d+/) assert.ok(params.includes(7)) assert.match(sql, /"archived" = \$\d+/) assert.match(sql, /order by .*"id"/) if (name === "inbox.list") { assert.match(sql, /"filed_at" is null/) assert.match(sql, /"files"\."tenant" =/) } if (name === "files.list") { assert.match(sql, /"folder" is null/) assert.match(sql, /"createddocument" =/) assert.ok(params.includes(42)) } const limit = Number(params[Number(sql.match(/limit \$(\d+)/)![1]) - 1]) const offset = Number(params[Number(sql.match(/offset \$(\d+)/)?.[1]) - 1] || 0) assert.equal(limit, 101) const selected = sql.slice(7, sql.indexOf(" from ")).split(", ") const idIndex = selected.findIndex(column => /"id"$/.test(column)) return { rows: Array.from({ length: Math.min(limit, 205 - offset) }, (_, i) => { const values = selected.map(() => null as any) values[idIndex] = String(i + offset + 1) return values }) } }) const ids: string[] = [] for (const offset of [0, 100, 200]) { const args = { limit: 100, offset, ...(name === "assignments.search" ? { kind: "project" } : {}), ...(name === "files.list" ? { folder: null, createddocument: 42 } : {}) } const result: any = await tool(name).handler(contextFor(db), args) ids.push(...result.rows.map((row: any) => String(row.id))) assert.equal(result.pagination.hasMore, offset < 200) assert.equal(result.pagination.nextOffset, offset < 200 ? offset + 100 : null) } assert.deepEqual(ids, Array.from({ length: 205 }, (_, i) => String(i + 1))) }) } test("archive confirmation survives MCP's archived-record filter and upload forwards the active user", async () => { const calls: any[] = [] const tools = createDocumentTools({ inbox: () => ({ archiveDocument: async (...args: any[]) => { calls.push(args); return { id, archived: true } }, upload: async (...args: any[]) => { calls.push(args); return { id, created: true, checksum: "hash" } }, }) as any }) const context = contextFor(profileDb()) const archived = await tools.find(t => t.name.endsWith("inbox.archive"))!.handler(context, { id }) assert.deepEqual(asToolResult(archived).structuredContent, { id, success: true, action: "archived" }) assert.deepEqual(calls[0], [7, id, id]) await tools.find(t => t.name.endsWith("files.upload"))!.handler(context, { filename: "scan.pdf", contentBase64: pdf.toString("base64") }) assert.deepEqual(calls[1], [7, id, "scan.pdf", pdf]) }) test("bounded downloads stop a stream even when persisted size is missing or incorrect", async () => { let closed = false async function* chunks() { try { yield Buffer.alloc(4); yield Buffer.alloc(4); yield Buffer.alloc(4) } finally { closed = true } } await assert.rejects(streamToBuffer(chunks(), 5), /zu groß/) assert.equal(closed, true) assert.deepEqual(await streamToBuffer(Readable.from([pdf]), pdf.length), pdf) }) test("HTTP MCP accepts uploads above 1 MiB and streams authorized PDFs without exposing storage paths", async t => { const apiBase = secrets.API_BASE_URL secrets.API_BASE_URL = "https://app.fedeo.test/backend/" t.after(() => { secrets.API_BASE_URL = apiBase }) const row = { id, tenant: 7, name: "scan.pdf", mimeType: "application/pdf", size: pdf.length, archived: false, path: `7/filesbyid/${id}/scan.pdf` } let visible = true let portal = false let admin = true const bearer = "fedeo_mcp_test_documents_token" const headers = { authorization: `Bearer ${bearer}` } const db = drizzle(async (sql, params) => { if (sql.includes('from "m2m_api_keys"')) { assert.ok(params.includes(createHash("sha256").update(bearer).digest("hex"))) return { rows: [[id, 7, id, true, null, "test@example.invalid", admin]] } } if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] } if (sql.includes('from "auth_users"')) return { rows: [[admin]] } if (sql.includes('from "tenants"')) return { rows: [[null, null]] } if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] } if (sql.includes('from "auth_role_permissions"')) return { rows: [] } if (sql.includes('from "auth_profiles"')) return { rows: portal ? [[123]] : [] } assert.match(sql, /from "files"/) assert.match(sql, /"tenant" =/) assert.match(sql, /"archived" =/) assert.ok(params.includes(7)) const selected = sql.slice(7, sql.indexOf(" from ")).split(", ") const columns = getTableColumns(files) return { rows: visible ? [selected.map(column => { const name = column.match(/"([^"]+)"$/)![1] const key = Object.keys(columns).find(key => columns[key].name === name)! return row[key] ?? null })] : [] } }) const app = Fastify() t.after(() => app.close()) app.decorate("db", db as any) await app.register(authPlugin) await app.register(mcpRoutes, { prefix: "/api" }) await initS3() t.mock.method(s3, "send", async command => { assert.equal(command.input.Key, row.path) return { Body: Readable.from([pdf]) } }) const call = (name: string, args: any) => app.inject({ method: "POST", url: "/api/mcp", headers, payload: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } } }) const download = await call("documents.files.download", { id }) assert.equal(download.statusCode, 200) assert.equal(download.json().result.structuredContent.authentication, "Bearer") assert.equal(download.json().result.structuredContent.url, `https://app.fedeo.test/backend/api/mcp/files/${id}/content`) assert.ok(!download.body.includes("filesbyid")) const url = `/api/mcp/files/${id}/content` assert.equal((await app.inject(url)).statusCode, 401) const binary = await app.inject({ url, headers }) assert.equal(binary.statusCode, 200, binary.body) assert.deepEqual(binary.rawPayload, pdf) assert.equal(binary.headers["content-type"], "application/pdf") assert.match(binary.headers["content-disposition"] as string, /attachment; filename="scan.pdf"/) assert.equal(binary.headers["cache-control"], "no-store") const base64 = await call("documents.files.download", { id, format: "base64" }) assert.deepEqual(Buffer.from(base64.json().result.structuredContent.contentBase64, "base64"), pdf) admin = false assert.equal((await app.inject({ url, headers })).statusCode, 403) assert.equal((await call("documents.files.upload", { filename: "scan.pdf", contentBase64: pdf.toString("base64") })).json().error.code, -32003) admin = true portal = true assert.equal((await app.inject({ url, headers })).statusCode, 403) portal = false visible = false assert.equal((await app.inject({ url, headers })).statusCode, 404) assert.equal((await call("documents.files.download", { id })).json().result.isError, true) const largeInvalidUpload = await call("documents.files.upload", { filename: "scan.pdf", contentBase64: "A".repeat(2 * 1024 * 1024) }) assert.equal(largeInvalidUpload.statusCode, 200, "the request must reach file validation, not Fastify's default 1 MiB limit") assert.equal(largeInvalidUpload.json().result.isError, true) const oversized = await app.inject({ method: "POST", url: "/api/mcp", headers: { "content-type": "application/json", "content-length": String(MCP_BODY_LIMIT + 1) }, payload: "{}" }) assert.equal(oversized.statusCode, 413) })