feat(mcp): add document inbox tools and PDF transfers
This commit is contained in:
212
backend/tests/mcpDocuments.test.ts
Normal file
212
backend/tests/mcpDocuments.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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 "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)
|
||||
})
|
||||
Reference in New Issue
Block a user