feat(scan): support iX1600 duplex scanning on Linux

This commit is contained in:
2026-09-17 18:22:43 +02:00
parent cfef07b96d
commit dd37622bb6
24 changed files with 1411 additions and 215 deletions

View File

@@ -1,7 +1,8 @@
import { FastifyInstance, FastifyRequest } from "fastify"
import multipart from "@fastify/multipart"
import { createHash } from "node:crypto"
import { and, asc, eq, sql } from "drizzle-orm"
import { basename } from "node:path"
import { and, asc, eq, inArray, sql } from "drizzle-orm"
import { instanceAgentScanJobs, instanceAgents } from "../../db/schema"
import { saveFile } from "../utils/files"
@@ -178,11 +179,12 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance
})
.where(and(
eq(instanceAgentScanJobs.id, req.params.id),
eq(instanceAgentScanJobs.agentId, agent.id)
eq(instanceAgentScanJobs.agentId, agent.id),
inArray(instanceAgentScanJobs.status, ["pending", "running"])
))
.returning()
if (!job) return reply.code(404).send({ error: "Scan job not found" })
if (!job) return reply.code(409).send({ error: "Scan job is no longer active or does not exist" })
return { job }
})
@@ -209,7 +211,8 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
const fileBuffer = await data.toBuffer()
const filename = job.requestedFilename || data.filename || `${job.id}.pdf`
// The agent normalizes the extension to the actual output format.
const filename = basename(data.filename || job.requestedFilename || `${job.id}.pdf`)
const createdFile = await saveFile(
server,

View File

@@ -0,0 +1,89 @@
import assert from "node:assert/strict"
import test from "node:test"
import { createHash } from "node:crypto"
import { readFileSync } from "node:fs"
import { createRequire } from "node:module"
import path from "node:path"
import vm from "node:vm"
import ts from "typescript"
import Fastify from "fastify"
import { getTableColumns, getTableName } from "drizzle-orm"
import { PgDialect } from "drizzle-orm/pg-core"
// Load the real route with only file storage replaced; tests never contact S3 or a live database.
const routePath = path.resolve(__dirname, "../src/routes/instanceAgentGateway.ts")
const routeRequire = createRequire(routePath)
const compiled = ts.transpileModule(readFileSync(routePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
}).outputText
const fixture = async (status = "running") => {
const token = "test-agent-token"
const agent = { id: "agent", tokenHash: createHash("sha256").update(token).digest("hex"), active: true }
const job: any = { id: "job", agentId: "agent", tenantId: 7, requestedBy: "user", requestedFilename: "scan.png", target: { folder: "folder" }, status }
const saved: any[] = []
const rows: Record<string, any[]> = { instance_agents: [agent], instance_agent_scan_jobs: [job] }
const dialect = new PgDialect()
const matching = (table: any, condition: any) => {
const query = dialect.sqlToQuery(condition)
const columns = getTableColumns(table) as Record<string, any>
const clauses = [...query.sql.matchAll(/"\w+"\."(\w+)" (?:= \$(\d+)|in \(([^)]+)\))/g)]
assert.ok(clauses.length, query.sql)
return rows[getTableName(table)].filter(row => clauses.every(match => {
const key = Object.keys(columns).find(key => columns[key].name === match[1])!
if (match[2]) return row[key] === query.params[Number(match[2]) - 1]
return [...match[3].matchAll(/\$(\d+)/g)].some(parameter => row[key] === query.params[Number(parameter[1]) - 1])
}))
}
const db = {
select: () => ({ from: (table: any) => ({ where: (where: any) => ({ limit: async () => matching(table, where) }) }) }),
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({ returning: async () => matching(table, where).map(row => Object.assign(row, data)) }) }) }),
}
const exports: any = {}
vm.runInNewContext(compiled, {
exports,
require: (name: string) => name === "../utils/files"
? { saveFile: async (...args: any[]) => { saved.push(args); return { id: "saved-file" } } }
: routeRequire(name),
})
const app = Fastify()
app.decorate("db", db)
await app.register(exports.default)
return { app, job, saved, headers: { "x-agent-token": token } }
}
test("late failure status cannot overwrite an already completed scan", async (t) => {
const f = await fixture("completed")
t.after(() => f.app.close())
const response = await f.app.inject({ method: "POST", url: "/scan-jobs/job/status", headers: f.headers, payload: { status: "failed", message: "upload timeout" } })
assert.equal(response.statusCode, 409)
assert.equal(f.job.status, "completed")
})
test("running jobs accept progress and scanner failures", async (t) => {
const f = await fixture()
t.after(() => f.app.close())
for (const status of ["running", "failed"]) {
const response = await f.app.inject({ method: "POST", url: "/scan-jobs/job/status", headers: f.headers, payload: { status, message: "4 Seiten" } })
assert.equal(response.statusCode, 200)
assert.equal(f.job.status, status)
}
})
test("upload preserves the actual PDF filename, tenant and target folder", async (t) => {
const f = await fixture()
t.after(() => f.app.close())
const boundary = "fedeo-test-boundary"
const response = await f.app.inject({
method: "POST", url: "/scan-jobs/job/upload",
headers: { ...f.headers, "content-type": `multipart/form-data; boundary=${boundary}` },
payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="scan.pdf"\r\nContent-Type: application/pdf\r\n\r\n%PDF-test\r\n--${boundary}--\r\n`,
})
assert.equal(response.statusCode, 200, response.body)
assert.equal(f.saved[0][1], 7)
assert.equal(f.saved[0][3].filename, "scan.pdf")
assert.equal(f.saved[0][3].contentType, "application/pdf")
assert.equal(f.saved[0][4], "folder")
assert.equal(f.job.status, "completed")
assert.equal(f.job.fileId, "saved-file")
})