677 lines
47 KiB
TypeScript
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")
|
|
})
|
|
})
|