Files
FEDEO/backend/tests/scannerInbox.test.ts

677 lines
47 KiB
TypeScript

import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { readFile, mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"
import path from "node:path"
import os from "node:os"
import Fastify from "fastify"
import { Pool } from "pg"
import { drizzle } from "drizzle-orm/node-postgres"
import { migrate } from "drizzle-orm/node-postgres/migrator"
import { getTableColumns } from "drizzle-orm"
import { PDFDocument } from "pdf-lib"
import { files } from "../db/schema"
import gateway from "../src/routes/scannerInboxGateway"
import inboxRoutes from "../src/routes/documentInbox"
import corsPlugin from "../src/plugins/cors"
import { createScannerInboxService, SCANNER_UPLOAD_LIMIT, validateScannerFile, type ScannerInboxService } from "../src/modules/scanner-inbox.service"
import type { FilingSuggestionResult } from "../src/modules/scanner-inbox-suggestions"
import { createDocumentTools } from "../src/mcp/tools/documents"
import { assertToolPermission } from "../src/mcp/authz"
const execute = promisify(execFile)
const basic = (username = "scanner", password = "secret") => ({ authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` })
const samplePdf = Buffer.from("%PDF-1.7\nscanner fixture\n%%EOF")
test("scanner formats are verified independently of the supplied content type", () => {
assert.equal(validateScannerFile("scan.PDF", samplePdf), "application/pdf")
assert.equal(validateScannerFile("scan.jpg", Buffer.from([255, 216, 255, 0])), "image/jpeg")
assert.equal(validateScannerFile("scan.png", Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), "image/png")
assert.equal(validateScannerFile("scan.tiff", Buffer.from("49492a00", "hex")), "image/tiff")
for (const [filename, body, status] of [
["scan.pdf", Buffer.alloc(0), 400], ["scan.pdf", Buffer.from("<html>error</html>"), 415],
["scan.pdf", Buffer.from("%PDF-truncated"), 415], ["scan.zip", samplePdf, 415],
["../scan.pdf", samplePdf, 400], ["a/b.pdf", samplePdf, 400], ["a\\b.pdf", samplePdf, 400],
[".hidden.pdf", samplePdf, 400], ["a\u0000.pdf", samplePdf, 400],
] as const) assert.throws(() => validateScannerFile(filename, body), { statusCode: status })
assert.throws(() => validateScannerFile("large.pdf", Buffer.alloc(SCANNER_UPLOAD_LIMIT + 1)), { statusCode: 413 })
})
const protocolFixture = async () => {
const calls: any[] = []
const probes: any[] = []
const service = {
authenticate: async (username: string, password: string) => username === "scanner" && password === "secret" ? { tenantId: 7 } : null,
accept: async (...args: any[]) => { calls.push(args); validateScannerFile(args[1], args[2]); return { created: true, checksum: "abc" } },
probe: async (...args: any[]) => { probes.push(args) },
find: async () => null,
} as unknown as ScannerInboxService
const app = Fastify()
await app.register(gateway, { prefix: "/scanner-inbox", service })
// Match production: the global CORS plugin is registered after the scanner gateway.
await app.register(corsPlugin)
return { app, calls, probes }
}
test("WebDAV authentication happens before body parsing, including discovery requests", async t => {
const { app } = await protocolFixture()
t.after(() => app.close())
for (const method of ["OPTIONS", "PROPFIND", "PUT", "HEAD", "GET"] as const) {
const res = await app.inject({ method, url: "/scanner-inbox/scan.pdf" })
assert.equal(res.statusCode, 401)
assert.match(res.headers["www-authenticate"] as string, /^Basic /)
}
const res = await app.inject({ method: "PUT", url: "/scanner-inbox/scan.pdf", headers: { ...basic("scanner", "wrong"), "content-type": "application/json" }, payload: "not json" })
assert.equal(res.statusCode, 401)
})
test("Epson can authenticate with a GET probe of the inbox without listing documents", async t => {
const { app, calls } = await protocolFixture()
t.after(() => app.close())
for (const url of ["/scanner-inbox", "/scanner-inbox/"]) {
const challenge = await app.inject({ method: "GET", url })
assert.equal(challenge.statusCode, 401)
assert.match(challenge.headers["www-authenticate"] as string, /^Basic /)
assert.equal((await app.inject({ method: "GET", url, headers: basic("scanner", "wrong") })).statusCode, 401)
const probe = await app.inject({ method: "GET", url, headers: basic() })
assert.equal(probe.statusCode, 200)
assert.equal(probe.body, "")
assert.equal(probe.headers.dav, "1")
assert.equal(probe.headers["cache-control"], "no-store")
assert.equal(probe.headers.location, undefined)
const options = await app.inject({ method: "OPTIONS", url, headers: basic() })
assert.ok((options.headers.allow as string).split(/,\s*/).includes("GET"))
}
assert.equal(calls.length, 0)
assert.equal((await app.inject({ method: "GET", url: "/scanner-inbox/missing.pdf", headers: basic() })).statusCode, 404)
})
test("Epson discovery works with both slash forms, XML bodies and a backend proxy prefix", async t => {
const { app } = await protocolFixture()
t.after(() => app.close())
for (const url of ["/scanner-inbox", "/scanner-inbox/"]) {
const options = await app.inject({ method: "OPTIONS", url, headers: basic() })
assert.equal(options.statusCode, 200)
assert.match(options.headers.allow as string, /PUT/)
const head = await app.inject({ method: "HEAD", url, headers: basic() })
assert.equal(head.statusCode, 200)
const res = await app.inject({ method: "PROPFIND", url, headers: { ...basic(), depth: "1", "content-type": "text/xml", "x-forwarded-prefix": "/backend" }, payload: '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>' })
assert.equal(res.statusCode, 207, res.body)
assert.match(res.body, /<d:href>\/backend\/scanner-inbox\/<\/d:href>/)
assert.match(res.body, /<d:collection\/>/)
assert.equal((res.body.match(/<d:response>/g) || []).length, 1)
}
assert.equal((await app.inject({ method: "PROPFIND", url: "/scanner-inbox/", headers: { ...basic(), depth: "infinity" } })).statusCode, 403)
})
test("Epson's extra separator does not turn filename checks into false collisions", async t => {
const { app, calls } = await protocolFixture()
t.after(() => app.close())
for (const separator of ["//", "///"]) {
const url = `/scanner-inbox${separator}scan.pdf`
assert.equal((await app.inject({ method: "HEAD", url, headers: basic() })).statusCode, 404)
const upload = await app.inject({ method: "PUT", url, headers: basic(), payload: samplePdf })
assert.equal(upload.statusCode, 201)
assert.equal(calls.at(-1)[1], "scan.pdf")
assert.deepEqual(calls.at(-1)[2], samplePdf)
for (const name of ["sub/scan.pdf", "sub%2Fscan.pdf", "..%5Cscan.pdf", ".hidden.pdf", "%2e%2e%2Fscan.pdf"]) {
assert.equal((await app.inject({ method: "PUT", url: `/scanner-inbox${separator}${name}`, headers: basic(), payload: samplePdf })).statusCode, 400)
}
}
assert.equal(calls.length, 2)
})
test("Epson's empty PUT is a write probe, followed by the actual document upload", async t => {
const { app, calls, probes } = await protocolFixture()
t.after(() => app.close())
const url = "/scanner-inbox//scan.pdf"
const probe = await app.inject({ method: "PUT", url, headers: { ...basic(), "content-length": "0" } })
assert.equal(probe.statusCode, 201, probe.body)
assert.equal(calls.length, 0)
assert.equal(probes.length, 1)
assert.equal(probes[0][0].tenantId, 7)
assert.equal(probes[0][1], "scan.pdf")
const upload = await app.inject({ method: "PUT", url, headers: basic(), payload: samplePdf })
assert.equal(upload.statusCode, 201)
assert.equal(calls.length, 1)
assert.deepEqual(calls[0][2], samplePdf)
assert.equal(probes.length, 1)
})
test("raw PDFs reach storage unchanged and scanner credentials cannot download or delete", async t => {
const { app, calls } = await protocolFixture()
t.after(() => app.close())
for (const contentType of ["application/pdf", "application/octet-stream", undefined]) {
const res = await app.inject({ method: "PUT", url: "/scanner-inbox/Rechnung%20%26%20Beleg.pdf", headers: { ...basic(), ...(contentType ? { "content-type": contentType } : {}) }, payload: samplePdf })
assert.equal(res.statusCode, 201, res.body)
assert.equal(calls.at(-1)[0].tenantId, 7)
assert.equal(calls.at(-1)[1], "Rechnung & Beleg.pdf")
assert.deepEqual(calls.at(-1)[2], samplePdf)
}
for (const method of ["DELETE", "POST", "PATCH"] as const) {
assert.equal((await app.inject({ method, url: "/scanner-inbox/", headers: basic() })).statusCode, 405)
}
for (const name of ["sub/scan.pdf", "sub%2Fscan.pdf", "..%5Cscan.pdf"]) {
assert.equal((await app.inject({ method: "PUT", url: "/scanner-inbox/" + name, headers: basic(), payload: samplePdf })).statusCode, 400)
}
assert.equal((await app.inject({ method: "PUT", url: "/scanner-inbox/scan.pdf", headers: { ...basic(), "content-range": "bytes 0-10/20" }, payload: samplePdf })).statusCode, 400)
assert.equal((await app.inject({ method: "PUT", url: "/scanner-inbox/scan.pdf", headers: { ...basic(), "if-match": '"old"' }, payload: samplePdf })).statusCode, 412)
assert.equal((await app.inject({ method: "PUT", url: "/scanner-inbox/scan.pdf", headers: { ...basic(), "content-type": "application/pdf", "content-length": String(SCANNER_UPLOAD_LIMIT + 1) }, payload: samplePdf })).statusCode, 413)
})
// Uses only a disposable database and fake object storage, never DATABASE_URL or production secrets.
test("scanner inbox integration: migration, persistence, isolation, retries and management", {
skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE,
}, async t => {
const container = "fedeo-scanner-inbox-test-" + randomUUID()
const dbPassword = randomUUID()
await execute("docker", ["run", "--rm", "-d", "--name", container, "-e", `POSTGRES_PASSWORD=${dbPassword}`, "-p", "127.0.0.1::5432", process.env.FEDEO_TEST_POSTGRES_IMAGE!])
let pool: Pool | undefined
const app = Fastify()
const tempMigrations = await mkdtemp(path.join(os.tmpdir(), "fedeo-scanner-inbox-"))
t.after(async () => {
await app.close()
await pool?.end()
await execute("docker", ["rm", "-f", container])
await rm(tempMigrations, { recursive: true, force: true })
})
const { stdout } = await execute("docker", ["port", container, "5432/tcp"])
pool = new Pool({ host: "127.0.0.1", port: Number(stdout.trim().split(":").at(-1)), user: "postgres", password: dbPassword, database: "postgres", connectionTimeoutMillis: 1000 })
for (let attempt = 0; ; attempt++) {
try { await pool.query("SELECT 1"); break } catch (error) {
if (attempt >= 80) throw error
await new Promise(resolve => setTimeout(resolve, 100))
}
}
const columns = Object.values(getTableColumns(files)).map(column => {
const suffix = column.name === "id" ? " PRIMARY KEY" : column.name === "created_at" ? " DEFAULT now()" : column.name === "archived" ? " DEFAULT false" : ""
return `"${column.name}" ${column.getSQLType()}${suffix}`
}).join(", ")
await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, locked text);
INSERT INTO tenants VALUES (1, null), (2, null);
CREATE TABLE files (${columns}, CHECK (name <> 'reject.pdf'));
CREATE TABLE filetags (id uuid PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
CREATE TABLE folders (id uuid PRIMARY KEY, tenant bigint, name text, parent uuid, "standardFiletype" uuid,
"standardFiletypeIsOptional" boolean DEFAULT true, archived boolean DEFAULT false);
CREATE TABLE projects (id bigint PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
CREATE TABLE customers (id bigint PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
CREATE TABLE checks (id uuid PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
CREATE TABLE auth_profiles (id uuid DEFAULT gen_random_uuid(), tenant_id bigint, user_id uuid, customer_for_portal bigint,
full_name text, active boolean DEFAULT true);`)
const migrationRoot = path.resolve(__dirname, "../db/migrations")
const journal = JSON.parse(await readFile(path.join(migrationRoot, "meta/_journal.json"), "utf8"))
const entries = ["0073_scanner_document_inbox", "0074_scanner_inbox_filing", "0077_scanner_inbox_preparation"].map(tag => journal.entries.find((item: any) => item.tag === tag))
await mkdir(path.join(tempMigrations, "meta"))
for (const entry of entries) {
assert.ok(entry)
assert.ok(journal.entries.filter((item: any) => item.idx < entry.idx).every((item: any) => item.when < entry.when))
await writeFile(path.join(tempMigrations, entry.tag + ".sql"), await readFile(path.join(migrationRoot, entry.tag + ".sql")))
}
await writeFile(path.join(tempMigrations, "meta/_journal.json"), JSON.stringify({ ...journal, entries }))
const db = drizzle(pool) as any
await migrate(db, { migrationsFolder: tempMigrations })
await migrate(db, { migrationsFolder: tempMigrations })
const stored = new Map<string, Buffer>()
let storageFailure = false
let aiResult: FilingSuggestionResult = { suggestion: null }
const analyses: any[] = []
let analysisGate: Promise<void> | undefined
let analysisStarted: (() => void) | undefined
let analysisFailure = false
const service = createScannerInboxService(db, {
async put(key, body) { stored.set(key, Buffer.from(body)); if (storageFailure) throw new Error("Simulated object store failure") },
async remove(key) { stored.delete(key) },
}, async (document, candidates) => {
analyses.push({ document, candidates })
const result = aiResult
analysisStarted?.()
await analysisGate
if (analysisFailure) throw new Error("Simulated AI failure")
return result
})
const first = await service.credentials(1)
const second = await service.credentials(2)
assert.equal(first.password.length, 20, "Epson destination password limit")
assert.ok(first.username.length <= 30)
assert.equal((await service.connection(1) as any).passwordHash, undefined)
assert.equal((await pool.query("SELECT password_hash FROM scanner_inbox_connections WHERE tenant_id=1")).rows[0].password_hash.length, 64)
const userId = randomUUID()
app.decorate("db", db)
await app.register(gateway, { prefix: "/scanner-inbox", service })
await app.register(corsPlugin)
await app.register(async api => {
api.addHook("onRequest", async req => {
req.user = { tenant_id: Number(req.headers["x-test-tenant"] || 1) || null, user_id: userId, email: "test@example.invalid", is_admin: req.headers["x-test-admin"] !== "false" }
})
await api.register(inboxRoutes, { service })
}, { prefix: "/api" })
const doc = await PDFDocument.create()
doc.addPage()
doc.addPage()
const pdf = Buffer.from(await doc.save())
const send = (name: string, payload = pdf, headers: Record<string, string> = basic(first.username, first.password)) => app.inject({ method: "PUT", url: `/scanner-inbox/${encodeURIComponent(name)}`, headers: { "content-type": "application/pdf", ...headers }, payload })
const probe = (name: string, headers: Record<string, string> = basic(first.username, first.password)) =>
app.inject({ method: "PUT", url: `/scanner-inbox/${encodeURIComponent(name)}`, headers: { "content-length": "0", ...headers } })
await t.test("empty write probes do not create documents, receipts or storage objects", async () => {
for (let attempt = 0; attempt < 2; attempt++) assert.equal((await probe("scan.pdf")).statusCode, 201)
assert.equal((await probe("scan.pdf", basic(second.username, second.password))).statusCode, 201)
assert.equal((await probe("invalid.zip")).statusCode, 415)
assert.equal((await probe("sub/scan.pdf")).statusCode, 400)
assert.equal((await probe("unauthenticated.pdf", {})).statusCode, 401)
assert.equal(stored.size, 0)
assert.equal((await pool!.query("SELECT count(*) FROM files")).rows[0].count, "0")
assert.equal((await pool!.query("SELECT count(*) FROM scanner_inbox_documents")).rows[0].count, "0")
assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })).statusCode, 404)
})
await t.test("stores one unchanged two-page PDF as one document without extraction", async () => {
const res = await send("scan.pdf")
assert.equal(res.statusCode, 201, res.body)
const row = (await pool.query("SELECT * FROM files")).rows[0]
assert.equal(row.tenant, "1")
assert.equal(row.mimeType, "application/pdf")
assert.equal(row.extracted_text, null)
assert.equal((await pool.query("SELECT suggestion_status FROM scanner_inbox_documents WHERE file_id=$1", [row.id])).rows[0].suggestion_status, "pending")
assert.equal(analyses.length, 0, "the upload only queues preparation; it does not wait for OCR/AI")
assert.deepEqual(stored.get(row.path), pdf)
assert.equal((await PDFDocument.load(stored.get(row.path)!)).getPageCount(), 2)
const listing = await app.inject({ method: "GET", url: "/api/document-inbox" })
assert.equal(listing.json().total, 1)
assert.equal(listing.json().documents[0].name, "scan.pdf")
assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })).statusCode, 200)
assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox//scan.pdf", headers: basic(first.username, first.password) })).statusCode, 200)
assert.equal((await app.inject({ method: "PUT", url: "/scanner-inbox//scan.pdf", headers: basic(first.username, first.password), payload: pdf })).statusCode, 204)
assert.equal((await service.list(1, 50, 0)).total, 1)
assert.equal((await app.inject({ method: "GET", url: "/scanner-inbox//scan.pdf", headers: basic(first.username, first.password) })).statusCode, 405)
const download = await app.inject({ method: "GET", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })
assert.equal(download.statusCode, 405)
assert.ok(!(download.headers.allow as string).split(/,\s*/).includes("GET"))
assert.notDeepEqual(download.rawPayload, pdf)
assert.equal((await probe("scan.pdf")).statusCode, 409)
assert.equal((await probe("scan.pdf", { ...basic(first.username, first.password), "if-none-match": "*" })).statusCode, 412)
assert.deepEqual(stored.get(row.path), pdf)
assert.equal(stored.size, 1)
})
await t.test("serialises simultaneous retries and preserves existing content on filename conflicts", async () => {
const retry = await Promise.all([send("scan.pdf"), send("scan.pdf")])
assert.deepEqual(retry.map(res => res.statusCode), [204, 204])
const concurrent = await Promise.all([send("parallel.pdf"), send("parallel.pdf")])
assert.deepEqual(concurrent.map(res => res.statusCode).sort(), [201, 204])
assert.equal((await send("scan.pdf", samplePdf)).statusCode, 409)
assert.equal((await send("scan.pdf", pdf, { ...basic(first.username, first.password), "if-none-match": "*" })).statusCode, 412)
assert.equal(stored.size, 2)
})
await t.test("credentials bind writes, metadata and lists to a tenant", async () => {
const otherHeaders = basic(second.username, second.password)
assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: otherHeaders })).statusCode, 404)
assert.equal((await send("scan.pdf", pdf, otherHeaders)).statusCode, 201)
const otherList = await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-tenant": "2" } })
assert.equal(otherList.json().total, 1)
const own = await app.inject({ method: "GET", url: "/api/document-inbox?limit=1&offset=1" })
assert.equal(own.json().total, 2)
assert.equal(own.json().documents.length, 1)
const filtered = await app.inject({ method: "GET", url: "/api/document-inbox?search=parallel" })
assert.equal(filtered.json().total, 1)
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox?limit=-1" })).statusCode, 400)
})
await t.test("storage and database failures leave no incomplete file or inbox entry", async () => {
const before = stored.size
storageFailure = true
assert.equal((await send("failed.pdf")).statusCode, 500)
storageFailure = false
assert.equal(stored.size, before)
assert.equal((await send("reject.pdf")).statusCode, 500)
assert.equal(stored.size, before)
assert.equal((await pool.query("SELECT count(*) FROM files WHERE name IN ('failed.pdf', 'reject.pdf')")).rows[0].count, "0")
assert.equal((await send("failed.pdf")).statusCode, 201)
})
const targetFolder = randomUUID()
const targetType = randomUUID()
const alternativeType = randomUUID()
const foreignFolder = randomUUID()
const foreignType = randomUUID()
const archivedFolder = randomUUID()
const archivedType = randomUUID()
await pool.query(`INSERT INTO filetags (id, tenant, name, archived) VALUES
($1, 1, 'Rechnung', false), ($2, 1, 'Sonstiges', false), ($3, 2, 'Fremder Typ', false), ($4, 1, 'Archiviert', true)`,
[targetType, alternativeType, foreignType, archivedType])
await pool.query(`INSERT INTO folders (id, tenant, name, "standardFiletype", "standardFiletypeIsOptional", archived) VALUES
($1, 1, 'Rechnungen', $2, false, false), ($3, 2, 'Fremder Ordner', null, true, false), ($4, 1, 'Archiviert', null, true, true)`,
[targetFolder, targetType, foreignFolder, archivedFolder])
const fileInto = (id: string, folder: string | null = targetFolder, type: string = targetType, headers = {}) =>
app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload: { folder, type }, headers })
const suggest = (id: string, headers = {}) => app.inject({ method: "GET", url: `/api/document-inbox/${id}/suggestion`, headers })
const queue = (id: string, headers = {}) => app.inject({ method: "POST", url: `/api/document-inbox/${id}/suggestion`, headers })
await t.test("AI analysis is tenant scoped, validates destinations and never files a document", async () => {
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
const before = (await pool.query("SELECT folder, type, extracted_text FROM files WHERE id=$1", [id])).rows[0]
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Rechnung erkannt." } }
assert.equal((await suggest(id, { "x-test-tenant": "2" })).statusCode, 404)
assert.equal((await suggest("invalid")).statusCode, 400)
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
assert.equal((await suggest(id)).statusCode, 403)
await pool.query("DELETE FROM auth_profiles")
assert.equal(analyses.length, 0)
assert.equal((await suggest(id)).json().status, "pending")
assert.equal(analyses.length, 0, "reading the preview never starts analysis")
assert.equal(await service.processNextSuggestion(), true)
const result = await suggest(id, { "x-test-admin": "false" })
assert.equal(result.statusCode, 200, result.body)
assert.deepEqual(result.json().suggestion, aiResult.suggestion)
assert.equal(analyses[0].document.name, "scan.pdf")
assert.deepEqual(analyses[0].candidates.folders.map((folder: any) => folder.id), [targetFolder])
assert.ok(!analyses[0].candidates.types.some((type: any) => [foreignType, archivedType].includes(type.id)))
assert.deepEqual((await pool.query("SELECT folder, type, extracted_text FROM files WHERE id=$1", [id])).rows[0], before)
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 1)
aiResult = { suggestion: { ...aiResult.suggestion!, folderId: foreignFolder } }
assert.equal((await queue(id)).statusCode, 202)
await service.processNextSuggestion()
assert.equal((await suggest(id)).json().suggestion, null)
})
await t.test("AI results are checked again if destination availability changes during analysis", async () => {
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Rechnung erkannt." } }
let finish!: () => void
analysisGate = new Promise(resolve => { finish = resolve })
const started = new Promise<void>(resolve => { analysisStarted = resolve })
await queue(id)
const pending = service.processNextSuggestion()
await started
await pool.query("UPDATE folders SET archived=true WHERE id=$1", [targetFolder])
finish()
await pending
assert.equal((await suggest(id)).json().suggestion, null)
await pool.query("UPDATE folders SET archived=false WHERE id=$1", [targetFolder])
analysisGate = undefined
analysisStarted = undefined
})
await t.test("prepared results persist, failed jobs wait for explicit retry and expired claims resume", async () => {
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
const otherService = createScannerInboxService(db, undefined, async () => aiResult)
const before = analyses.length
assert.equal((await otherService.getSuggestion(1, id)).status, "ready")
assert.equal(analyses.length, before)
await queue(id)
analysisFailure = true
await service.processNextSuggestion()
analysisFailure = false
assert.equal((await suggest(id)).json().status, "failed")
assert.equal((await suggest(id)).json().suggestion, null)
const failedCount = analyses.length
await suggest(id)
assert.equal(analyses.length, failedCount)
await queue(id)
await pool.query("UPDATE scanner_inbox_documents SET suggestion_status='processing', suggestion_claim=$2, suggestion_updated_at=now()-interval '6 minutes' WHERE file_id=$1", [id, randomUUID()])
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Vorbereitet nach Neustart." } }
await otherService.processNextSuggestion()
assert.equal((await suggest(id)).json().suggestion.reason, "Vorbereitet nach Neustart.")
})
await t.test("an expired worker cannot overwrite a newer completed suggestion", async () => {
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
await queue(id)
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Alter Vorschlag." } }
let finish!: () => void
analysisGate = new Promise(resolve => { finish = resolve })
const started = new Promise<void>(resolve => { analysisStarted = resolve })
const pending = service.processNextSuggestion()
await started
await pool.query("UPDATE scanner_inbox_documents SET suggestion_updated_at=now()-interval '6 minutes' WHERE file_id=$1", [id])
const otherService = createScannerInboxService(db, undefined, async () => ({ suggestion: {
folderId: null, typeId: alternativeType, confidence: "medium", reason: "Neuer Vorschlag.",
} }))
await otherService.processNextSuggestion()
finish()
await pending
assert.equal((await suggest(id)).json().suggestion.reason, "Neuer Vorschlag.")
analysisGate = undefined
analysisStarted = undefined
})
await t.test("entity choices and filing reject foreign, archived and wrong-type IDs atomically", async () => {
await pool.query("INSERT INTO projects VALUES (11, 1, 'Umbau', false), (12, 2, 'Fremdes Projekt', false), (13, 1, 'Archiv', true)")
await pool.query("INSERT INTO customers VALUES (21, 1, 'Musterkunde', false), (22, 2, 'Fremder Kunde', false)")
const checkId = randomUUID()
const profileId = randomUUID()
await pool.query("INSERT INTO checks VALUES ($1, 1, 'Prüfung', false)", [checkId])
await pool.query("INSERT INTO auth_profiles (id, tenant_id, full_name) VALUES ($1, 1, 'Test Mitarbeiter')", [profileId])
const choices = await app.inject({ method: "GET", url: "/api/document-inbox/assignment-options?kind=project&search=Umbau" })
assert.deepEqual(choices.json(), [{ id: "11", name: "Umbau" }])
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox/assignment-options?kind=unknown" })).statusCode, 400)
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
for (const assignments of [{ project: "12" }, { project: "13" }, { project: randomUUID() }, { project: "11", customer: "22" }, { check: 11 }, { authProfile: randomUUID() }]) {
const res = await app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload: { folder: targetFolder, type: targetType, assignments } })
assert.equal(res.statusCode, 400, res.body)
}
assert.deepEqual((await pool.query("SELECT folder, type, project, customer FROM files WHERE id=$1", [id])).rows[0], { folder: null, type: null, project: null, customer: null })
await pool.query("UPDATE files SET project=11 WHERE id=$1", [id])
assert.deepEqual((await app.inject({ method: "GET", url: `/api/document-inbox/${id}/assignments` })).json(), [{ kind: "project", id: "11", name: "Umbau", available: true }])
assert.equal((await app.inject({ method: "GET", url: `/api/document-inbox/${id}/assignments`, headers: { "x-test-tenant": "2" } })).statusCode, 404)
await service.fileDocument(1, userId, id, targetFolder, targetType, { project: null, customer: "21", check: checkId, authProfile: profileId })
assert.deepEqual((await pool.query('SELECT project, customer, "check", auth_profile FROM files WHERE id=$1', [id])).rows[0], { project: null, customer: '21', check: checkId, auth_profile: profileId })
// Restore the fixture so existing retry/filing tests can exercise this same original document.
await pool.query('UPDATE files SET folder=null, type=null, customer=null, "check"=null, auth_profile=null WHERE id=$1', [id])
await pool.query("UPDATE scanner_inbox_documents SET filed_at=null WHERE file_id=$1", [id])
})
await t.test("menu count includes only this tenant's open, unarchived documents", async () => {
const res = await app.inject({ method: "GET", url: "/api/document-inbox/count" })
assert.equal(res.json().total, (await service.list(1, 50, 0, "")).total)
const other = await app.inject({ method: "GET", url: "/api/document-inbox/count", headers: { "x-test-tenant": "2" } })
assert.equal(other.json().total, 1)
const id = (await service.list(1, 50, 0, "failed.pdf")).documents[0].id
await pool.query("UPDATE files SET archived=true WHERE id=$1", [id])
assert.equal((await service.openCount(1)).total, res.json().total - 1)
await pool.query("UPDATE files SET archived=false WHERE id=$1", [id])
})
await t.test("filing options and destinations exclude other tenants and archived entries", async () => {
const options = await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })
assert.equal(options.statusCode, 200)
assert.deepEqual(options.json().folders.map((item: any) => item.id), [targetFolder])
assert.equal(options.json().folders[0].standardFiletypeIsOptional, false)
assert.deepEqual(options.json().types.map((item: any) => item.id).sort(), [targetType, alternativeType].sort())
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
for (const [folder, type] of [[foreignFolder, targetType], [archivedFolder, targetType], [targetFolder, foreignType],
[null, archivedType], [targetFolder, alternativeType], [randomUUID(), targetType], [null, randomUUID()]]) {
assert.equal((await fileInto(id, folder, type!)).statusCode, 400)
}
assert.equal((await fileInto(id, null, targetType, { "x-test-tenant": "2" })).statusCode, 404)
assert.equal((await fileInto(randomUUID())).statusCode, 404)
for (const payload of [{ folder: null }, { type: targetType }, { folder: "invalid", type: targetType }, { folder: null, type: null }]) {
assert.equal((await app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload })).statusCode, 400)
}
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
assert.equal((await fileInto(id)).statusCode, 423)
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
assert.equal((await fileInto(id)).statusCode, 403)
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })).statusCode, 403)
await pool.query("DELETE FROM auth_profiles")
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 1)
const row = (await pool.query("SELECT folder, type FROM files WHERE id=$1", [id])).rows[0]
assert.deepEqual(row, { folder: null, type: null })
})
await t.test("filing updates the original file once, keeps its content and cannot be undone by scanner retries", async () => {
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
const before = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
const countBefore = stored.size
const responses = await Promise.all([fileInto(id, targetFolder, targetType, { "x-test-admin": "false" }), fileInto(id)])
assert.deepEqual(responses.map(response => response.statusCode).sort(), [200, 409])
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
assert.equal(row.folder, targetFolder)
assert.equal(row.type, targetType)
assert.equal(row.updated_by, userId)
assert.ok(row.updated_at)
assert.equal(row.path, before.path)
assert.equal(row.name, before.name)
assert.deepEqual(stored.get(row.path), pdf)
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 0)
const analysisCount = analyses.length
assert.equal((await suggest(id)).statusCode, 404)
assert.equal(analyses.length, analysisCount)
assert.equal((await send("scan.pdf")).statusCode, 204)
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 0)
assert.equal((await service.list(2, 50, 0, "scan.pdf")).total, 1)
assert.equal(stored.size, countBefore)
assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })).statusCode, 200)
const rootId = (await service.list(1, 50, 0, "parallel.pdf")).documents[0].id
assert.equal((await fileInto(rootId, null, alternativeType)).statusCode, 200)
assert.deepEqual((await pool.query("SELECT folder, type FROM files WHERE id=$1", [rootId])).rows[0], { folder: null, type: alternativeType })
})
await t.test("a failure marking the receipt rolls back file classification", async () => {
const id = (await service.list(1, 50, 0, "failed.pdf")).documents[0].id
await pool.query("ALTER TABLE scanner_inbox_documents ADD CONSTRAINT simulate_filing_failure CHECK (remote_filename <> 'failed.pdf' OR filed_at IS NULL)")
assert.equal((await fileInto(id)).statusCode, 500)
assert.deepEqual((await pool.query("SELECT folder, type FROM files WHERE id=$1", [id])).rows[0], { folder: null, type: null })
assert.equal((await service.list(1, 50, 0, "failed.pdf")).total, 1)
await pool.query("ALTER TABLE scanner_inbox_documents DROP CONSTRAINT simulate_filing_failure")
await pool.query("UPDATE files SET archived=true WHERE id=$1", [id])
assert.equal((await fileInto(id)).statusCode, 404)
await pool.query("UPDATE files SET archived=false WHERE id=$1", [id])
})
await t.test("archiving is tenant scoped, preserves the original and invalidates an ongoing analysis", async () => {
assert.equal((await send("archive.pdf")).statusCode, 201)
const id = (await service.list(1, 50, 0, "archive.pdf")).documents[0].id
const archive = (fileId = id, headers = {}) => app.inject({ method: "POST", url: `/api/document-inbox/${fileId}/archive`, headers })
assert.equal((await archive(id, { "x-test-tenant": "2" })).statusCode, 404)
assert.equal((await archive(randomUUID())).statusCode, 404)
assert.equal((await archive("invalid")).statusCode, 400)
const filedId = (await pool.query("SELECT file_id FROM scanner_inbox_documents WHERE tenant_id=1 AND remote_filename='scan.pdf'")).rows[0].file_id
assert.equal((await archive(filedId)).statusCode, 409)
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
assert.equal((await archive()).statusCode, 423)
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
assert.equal((await archive()).statusCode, 403)
await pool.query("DELETE FROM auth_profiles")
const before = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
const countBefore = (await service.openCount(1)).total
const storedBefore = stored.size
// Run this document first, then archive it while its provider response is still pending.
await pool.query("UPDATE scanner_inbox_documents SET received_at=now()-interval '1 day' WHERE file_id=$1", [id])
let finish!: () => void
analysisGate = new Promise(resolve => { finish = resolve })
const started = new Promise<void>(resolve => { analysisStarted = resolve })
const pending = service.processNextSuggestion()
await started
assert.equal(analyses.at(-1).document.name, "archive.pdf")
const result = await archive(id, { "x-test-admin": "false" })
finish()
await pending
analysisGate = undefined
analysisStarted = undefined
assert.equal(result.statusCode, 200, result.body)
assert.deepEqual(result.json(), { id, archived: true })
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
assert.equal(row.archived, true)
assert.equal(row.updated_by, userId)
assert.ok(row.updated_at)
assert.equal(row.path, before.path)
assert.equal(row.folder, before.folder)
assert.equal(row.type, before.type)
assert.deepEqual(stored.get(row.path), pdf)
const receipt = (await pool.query("SELECT filed_at, suggestion_claim, suggestion_status FROM scanner_inbox_documents WHERE file_id=$1", [id])).rows[0]
assert.deepEqual(receipt, { filed_at: null, suggestion_claim: null, suggestion_status: "pending" })
assert.equal((await service.list(1, 50, 0, "archive.pdf")).total, 0)
assert.equal((await service.openCount(1)).total, countBefore - 1)
assert.equal((await suggest(id)).statusCode, 404)
assert.equal((await fileInto(id)).statusCode, 404)
assert.equal((await archive()).statusCode, 200, "repeated requests remain successful")
assert.equal((await pool.query("SELECT updated_at FROM files WHERE id=$1", [id])).rows[0].updated_at.getTime(), row.updated_at.getTime())
assert.equal((await send("archive.pdf")).statusCode, 204)
assert.equal((await service.list(1, 50, 0, "archive.pdf")).total, 0)
assert.equal(stored.size, storedBefore)
while (await service.processNextSuggestion()) { /* drain the remaining fixtures */ }
assert.equal(analyses.filter(item => item.document.name === "archive.pdf").length, 1)
})
await t.test("maintenance, portal access and administrator-only settings are enforced", async () => {
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
assert.equal((await send("maintenance.pdf")).statusCode, 423)
assert.equal((await probe("maintenance.pdf")).statusCode, 423)
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-tenant": "0" } })).statusCode, 400)
for (const [method, url] of [["GET", "/api/document-inbox/scanner"], ["POST", "/api/document-inbox/scanner/credentials"], ["DELETE", "/api/document-inbox/scanner"]] as const) {
assert.equal((await app.inject({ method, url, headers: { "x-test-admin": "false" } })).statusCode, 403)
}
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-admin": "false" } })).statusCode, 200)
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox" })).statusCode, 403)
await pool.query("DELETE FROM auth_profiles")
})
await t.test("revocation and rotation reject old credentials without removing documents", async () => {
const oldIdentity = await service.authenticate(first.username, first.password)
assert.equal((await app.inject({ method: "DELETE", url: "/api/document-inbox/scanner" })).statusCode, 204)
assert.equal((await send("disabled.pdf")).statusCode, 401)
assert.equal((await probe("disabled.pdf")).statusCode, 401)
const rotated = await app.inject({ method: "POST", url: "/api/document-inbox/scanner/credentials" })
assert.equal(rotated.statusCode, 200)
assert.equal(rotated.headers["cache-control"], "no-store")
assert.equal(rotated.json().connection.username, first.username)
assert.equal((await send("old.pdf")).statusCode, 401)
await assert.rejects(service.accept(oldIdentity!, "late.pdf", pdf), { statusCode: 401 })
await assert.rejects(service.probe(oldIdentity!, "late.pdf"), { statusCode: 401 })
assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201)
assert.equal((await service.list(1, 50, 0, "")).total, 2)
})
await t.test("MCP uploads, downloads and filing use the same tenant-scoped document lifecycle", async () => {
const tools = createDocumentTools({ inbox: () => service, readFile: async key => {
assert.ok(stored.has(key))
return stored.get(key)!
} })
const call = async (name: string, args: any = {}, tenantId = 1) => {
const tool = tools.find(tool => tool.name === `documents.${name}`)!
const context = { server: { db }, userId, tenantId, isAdmin: false, permissions: ["documents.read", "documents.write"] } as any
assertToolPermission(context, tool)
return await tool.handler(context, args) as any
}
const upload = (filename: string, body = pdf, tenant = 1) => call("files.upload", { filename, contentBase64: body.toString("base64") }, tenant)
await service.disable(1)
const created = await upload("mcp.pdf")
assert.equal(created.created, true, "MCP uploads do not require scanner credentials")
assert.equal((await upload("mcp.pdf")).id, created.id)
assert.equal((await upload("mcp.pdf")).created, false)
await assert.rejects(upload("mcp.pdf", samplePdf), { statusCode: 409 })
assert.notEqual((await upload("mcp.pdf", pdf, 2)).id, created.id)
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [created.id])).rows[0]
assert.equal(row.created_by, userId)
assert.deepEqual(stored.get(row.path), pdf)
assert.equal(row.extracted_text, null)
const list = await call("inbox.list", { search: "mcp.pdf", limit: 1 })
assert.equal(list.rows[0].id, created.id)
assert.equal(list.rows[0].suggestionStatus, "pending")
assert.equal((await call("inbox.suggestion.get", { id: created.id })).status, "pending")
const downloaded = await call("files.download", { id: created.id, format: "base64" })
assert.deepEqual(Buffer.from(downloaded.contentBase64, "base64"), pdf)
await assert.rejects(call("files.download", { id: created.id }, 2), { statusCode: 404 })
await assert.rejects(call("inbox.archive", { id: created.id }, 2), { statusCode: 404 })
await assert.rejects(call("inbox.file", { id: created.id, folder: foreignFolder, type: targetType }), { statusCode: 400 })
await assert.rejects(call("inbox.file", { id: created.id, folder: targetFolder, type: alternativeType }), { statusCode: 400 })
await pool.query("INSERT INTO projects (id, tenant, name) VALUES (901, 1, 'MCP project'), (902, 2, 'Foreign MCP project')")
await assert.rejects(call("inbox.file", { id: created.id, folder: targetFolder, type: targetType, assignments: { project: 902 } }), { statusCode: 400 })
const filed = await call("inbox.file", { id: created.id, folder: targetFolder, type: targetType, assignments: { project: 901 } })
assert.equal(filed.id, created.id)
assert.equal((await call("inbox.list", { search: "mcp.pdf" })).rows.length, 0)
assert.equal((await call("files.list", { project: 901 })).rows[0].id, created.id)
assert.equal((await call("files.get", { id: created.id })).file.path, undefined)
await assert.rejects(call("inbox.archive", { id: created.id }), { statusCode: 409 })
const archived = await upload("mcp-archive.pdf")
await call("inbox.archive", { id: archived.id })
await assert.rejects(call("files.download", { id: archived.id }), { statusCode: 404 })
assert.equal((await call("files.list", { search: "mcp-archive.pdf" })).rows.length, 0)
const before = stored.size
storageFailure = true
await assert.rejects(upload("mcp-storage-error.pdf"), /Simulated object store failure/)
storageFailure = false
await assert.rejects(upload("reject.pdf"))
assert.equal(stored.size, before)
assert.equal((await pool.query("SELECT count(*) FROM files WHERE name IN ('mcp-storage-error.pdf', 'reject.pdf')")).rows[0].count, "0")
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
await assert.rejects(upload("mcp-maintenance.pdf"), { statusCode: 423 })
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
await assert.rejects(upload("mcp-portal.pdf"), { statusCode: 403 })
await assert.rejects(call("files.download", { id: created.id }), { statusCode: 403 })
await pool.query("DELETE FROM auth_profiles")
})
})