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"
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("error"), 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 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" } },
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 }
}
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"] 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 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: '' })
assert.equal(res.statusCode, 207, res.body)
assert.match(res.body, /\/backend\/scanner-inbox\/<\/d:href>/)
assert.match(res.body, //)
assert.equal((res.body.match(//g) || []).length, 1)
}
assert.equal((await app.inject({ method: "PROPFIND", url: "/scanner-inbox/", headers: { ...basic(), depth: "infinity" } })).statusCode, 403)
})
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 ["GET", "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 auth_profiles (tenant_id bigint, user_id uuid, customer_for_portal bigint);`)
const migrationRoot = path.resolve(__dirname, "../db/migrations")
const journal = JSON.parse(await readFile(path.join(migrationRoot, "meta/_journal.json"), "utf8"))
const entry = journal.entries.find((item: any) => item.tag === "0073_scanner_document_inbox")
assert.ok(entry)
assert.ok(journal.entries.filter((item: any) => item.idx < entry.idx).every((item: any) => item.when < entry.when))
await mkdir(path.join(tempMigrations, "meta"))
await writeFile(path.join(tempMigrations, "meta/_journal.json"), JSON.stringify({ ...journal, entries: [entry] }))
await writeFile(path.join(tempMigrations, entry.tag + ".sql"), await readFile(path.join(migrationRoot, entry.tag + ".sql")))
const db = drizzle(pool) as any
await migrate(db, { migrationsFolder: tempMigrations })
await migrate(db, { migrationsFolder: tempMigrations })
const stored = new Map()
let storageFailure = 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) },
})
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 = basic(first.username, first.password)) => app.inject({ method: "PUT", url: `/scanner-inbox/${encodeURIComponent(name)}`, headers: { "content-type": "application/pdf", ...headers }, payload })
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.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)
})
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)
})
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)
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 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)
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 })
assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201)
assert.ok((await service.list(1, 50, 0, "")).total >= 4)
})
})