- POST /api/mcp/files nimmt multipart/form-data oder Rohdaten mit dem MCP-Token an, Ziel Dokumentenablage oder Eingangsrechnung - documents.files.upload_url liefert Adresse, Parameter und curl-Beispiele - Upload einer Eingangsrechnung bereitet nur noch die hochgeladene Datei vor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
6.2 KiB
TypeScript
107 lines
6.2 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import test from "node:test"
|
|
import { createHash } from "node:crypto"
|
|
import Fastify from "fastify"
|
|
import { drizzle } from "drizzle-orm/pg-proxy"
|
|
import mcpRoutes from "../src/routes/mcp"
|
|
import authPlugin from "../src/plugins/auth"
|
|
import { mcpToolMap } from "../src/mcp/registry"
|
|
import { parseUploadQuery, storeMcpFileUpload } from "../src/mcp/fileUploads"
|
|
import { MCP_FILE_LIMIT } from "../src/mcp/documentFiles"
|
|
import { secrets } from "../src/utils/secrets"
|
|
|
|
const id = "00000000-0000-4000-8000-000000000001"
|
|
const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF")
|
|
const bearer = "fedeo_mcp_test_upload_token"
|
|
const headers = { authorization: `Bearer ${bearer}` }
|
|
|
|
async function app(t: any) {
|
|
const uploads: any[] = []
|
|
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", true]] }
|
|
}
|
|
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
|
|
if (sql.includes('from "auth_users"')) return { rows: [[true]] }
|
|
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
|
|
return { rows: [] }
|
|
})
|
|
const server = Fastify()
|
|
t.after(() => server.close())
|
|
server.decorate("db", db as any)
|
|
await server.register(authPlugin)
|
|
await server.register(mcpRoutes, {
|
|
prefix: "/api",
|
|
storeUpload: async (context: any, upload: any) => { uploads.push({ tenantId: context.tenantId, ...upload }); return { stored: true } },
|
|
})
|
|
return { server, uploads }
|
|
}
|
|
|
|
const multipartBody = (filename: string, content: Buffer, field = "file") => {
|
|
const boundary = "----fedeo-test"
|
|
const payload = Buffer.concat([
|
|
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\nContent-Type: application/pdf\r\n\r\n`),
|
|
content,
|
|
Buffer.from(`\r\n--${boundary}--\r\n`),
|
|
])
|
|
return { payload, headers: { ...headers, "content-type": `multipart/form-data; boundary=${boundary}` } }
|
|
}
|
|
|
|
test("binary endpoint accepts multipart and raw uploads without Base64", async (t) => {
|
|
const { server, uploads } = await app(t)
|
|
const multipart = multipartBody("rechnung.pdf", pdf)
|
|
const first = await server.inject({ method: "POST", url: "/api/mcp/files?target=incoming_invoice&invoiceId=5&prepare=false", ...multipart })
|
|
assert.equal(first.statusCode, 200, first.body)
|
|
assert.deepEqual(first.json(), { stored: true })
|
|
assert.equal(uploads[0].tenantId, 7)
|
|
assert.equal(uploads[0].target, "incoming_invoice")
|
|
assert.equal(uploads[0].filename, "rechnung.pdf")
|
|
assert.equal(uploads[0].invoiceId, 5)
|
|
assert.equal(uploads[0].prepare, false)
|
|
assert.deepEqual(uploads[0].body, pdf)
|
|
|
|
const raw = await server.inject({ method: "POST", url: "/api/mcp/files?filename=scan.pdf", headers: { ...headers, "content-type": "application/pdf" }, payload: pdf })
|
|
assert.equal(raw.statusCode, 200, raw.body)
|
|
assert.equal(uploads[1].target, "inbox")
|
|
assert.equal(uploads[1].mimeType, "application/pdf")
|
|
assert.deepEqual(uploads[1].body, pdf)
|
|
})
|
|
|
|
test("binary endpoint rejects missing auth, filenames, wrong fields, types and oversized files", async (t) => {
|
|
const { server, uploads } = await app(t)
|
|
const raw = { "content-type": "application/pdf" }
|
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: raw, payload: pdf })).statusCode, 401)
|
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
|
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", ...multipartBody("a.pdf", pdf, "other") })).statusCode, 400)
|
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, "content-type": "text/html" }, payload: "<p>" })).statusCode, 415)
|
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf&target=wiki", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
|
|
const tooLarge = Buffer.alloc(MCP_FILE_LIMIT + 1)
|
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, ...raw }, payload: tooLarge })).statusCode, 413)
|
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", ...multipartBody("a.pdf", tooLarge) })).statusCode, 413)
|
|
assert.equal(uploads.length, 0)
|
|
})
|
|
|
|
test("query parsing keeps invoice options to the incoming invoice target", () => {
|
|
assert.deepEqual(parseUploadQuery({}), { target: "inbox", invoiceId: null, prepare: true, filename: null })
|
|
assert.throws(() => parseUploadQuery({ invoiceId: "3" }), /nur für target=incoming_invoice/)
|
|
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", invoiceId: "x" }), /positive ganze Zahl/)
|
|
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", prepare: "ja" }), /true oder false/)
|
|
})
|
|
|
|
test("stored uploads require the permission of the chosen target", async () => {
|
|
const context: any = { server: { db: null }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read"] }
|
|
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: pdf }), /documents\.write/)
|
|
await assert.rejects(storeMcpFileUpload(context, { target: "incoming_invoice", filename: "a.pdf", body: pdf }), /accounting\.incoming_invoices\.write/)
|
|
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: Buffer.alloc(0) }), /leer/)
|
|
})
|
|
|
|
test("upload_url tool returns the absolute endpoint with examples", 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 result: any = await mcpToolMap.get("documents.files.upload_url")!.handler({} as any, {})
|
|
assert.equal(result.url, "https://app.fedeo.test/backend/api/mcp/files")
|
|
assert.match(result.examples[0], /-F "file=@rechnung\.pdf"/)
|
|
})
|