- archive.entities.list / archive.entities.restore für alle per MCP archivierbaren Entitäten mit Berechtigungsprüfung je Entität und Historieneintrag - accounting.bank_accounts.balances liefert Stände von Bankkonten und Kassenbüchern, optional zu einem Stichtag Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
5.5 KiB
TypeScript
100 lines
5.5 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import test from "node:test"
|
|
import { getTableColumns, getTableName } from "drizzle-orm"
|
|
import { PgDialect } from "drizzle-orm/pg-core"
|
|
import * as schema from "../db/schema"
|
|
import { mcpToolMap } from "../src/mcp/registry"
|
|
import { archiveEntities, prepareArchiveRestoreInput } from "../src/mcp/tools/archive"
|
|
import { asToolResult } from "../src/mcp/result"
|
|
|
|
const tenantId = 7
|
|
const userId = "00000000-0000-4000-8000-000000000001"
|
|
const uuid = "00000000-0000-4000-8000-000000000002"
|
|
const dialect = new PgDialect()
|
|
|
|
/** Minimal Drizzle stand-in which evaluates equality predicates, including tenant filters. */
|
|
function fixture(initial: Record<string, any[]> = {}, listRows: any[] = []) {
|
|
const state = structuredClone(initial)
|
|
const rowsFor = (table: any) => state[getTableName(table)] ||= []
|
|
const matches = (table: any, where: any, row: any) => {
|
|
const query = dialect.sqlToQuery(where)
|
|
const columns = getTableColumns(table) as Record<string, any>
|
|
const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)!
|
|
return [...query.sql.matchAll(/"\w+"\."(\w+)" = \$(\d+)/g)]
|
|
.every((match) => row[keyFor(match[1])] === query.params[Number(match[2]) - 1])
|
|
}
|
|
const listed: string[] = []
|
|
const db: any = {
|
|
transaction: async (callback: any) => callback(db),
|
|
select: () => ({ from: (table: any) => {
|
|
let condition: any
|
|
const query: any = {
|
|
where: (where: any) => { condition = where; return query },
|
|
orderBy: () => query,
|
|
offset: () => query,
|
|
limit: () => query,
|
|
for: () => query,
|
|
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
|
|
if (listRows.length) { listed.push(dialect.sqlToQuery(condition).sql); return listRows }
|
|
return rowsFor(table).filter((row) => matches(table, condition, row)).map((row) => ({ ...row }))
|
|
}).then(resolve, reject),
|
|
}
|
|
return query
|
|
} }),
|
|
insert: (table: any) => ({ values: async (data: any) => { rowsFor(table).push(data) } }),
|
|
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({
|
|
returning: async () => rowsFor(table).filter((row) => matches(table, where, row)).map((row) => ({ ...Object.assign(row, data) })),
|
|
}) }) }),
|
|
}
|
|
const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] }
|
|
return { context, rows: rowsFor, listed }
|
|
}
|
|
const call = async (context: any, name: string, args: Record<string, any>) => await mcpToolMap.get(`archive.entities.${name}`)!.handler(context, args) as any
|
|
|
|
for (const [key, entity] of Object.entries(archiveEntities)) {
|
|
test(`${key}: restore unarchives within the tenant and records history`, async () => {
|
|
const id = entity.uuid ? uuid : 1
|
|
const f = fixture({
|
|
[getTableName(entity.table)]: [{ id, tenant: tenantId, archived: true }],
|
|
// Restoring documents requires an internal (non-portal) profile.
|
|
auth_profiles: [{ tenant_id: tenantId, user_id: userId, customer_for_portal: null }],
|
|
})
|
|
const result = await call(f.context, "restore", { entity: key, id })
|
|
assert.deepEqual(asToolResult(result).structuredContent?.success, true)
|
|
assert.equal(result.record.archived, undefined)
|
|
const [row] = f.rows(entity.table)
|
|
assert.equal(row.archived, false)
|
|
assert.equal(row.updatedBy ?? row.updated_by, userId)
|
|
await assert.rejects(call(f.context, "restore", { entity: key, id }), /nicht archiviert/)
|
|
})
|
|
}
|
|
|
|
test("restore rejects foreign tenants, unknown entities, invalid IDs and missing write permission", async () => {
|
|
const f = fixture({ customers: [{ id: 1, tenant: 8, archived: true }], tasks: [{ id: 2, tenant: tenantId, archived: true }] })
|
|
await assert.rejects(call(f.context, "restore", { entity: "customers", id: 1 }), /nicht gefunden/)
|
|
assert.equal(f.rows(schema.customers)[0].archived, true)
|
|
await assert.rejects(call(f.context, "restore", { entity: "accounts", id: 1 }), /unbekannte Entität/)
|
|
assert.throws(() => prepareArchiveRestoreInput({ entity: "cost_centres", id: 1 }), /UUID/)
|
|
assert.throws(() => prepareArchiveRestoreInput({ entity: "tasks", id: "abc" }), /ganze Zahl/)
|
|
f.context.isAdmin = false
|
|
f.context.permissions = ["organisation.tasks.read"]
|
|
await assert.rejects(call(f.context, "restore", { entity: "tasks", id: 2 }), /Fehlende Berechtigung.*organisation\.tasks\.write/)
|
|
f.context.permissions.push("organisation.tasks.write")
|
|
await call(f.context, "restore", { entity: "tasks", id: 2 })
|
|
assert.equal(f.rows(schema.tasks)[0].archived, false)
|
|
})
|
|
|
|
test("list returns archived rows visibly and requires the read permission", async () => {
|
|
const f = fixture({}, [{ id: 1, tenant: tenantId, archived: true, name: "Alt" }])
|
|
const result = asToolResult(await call(f.context, "list", { entity: "customers", search: "Al" }))
|
|
assert.deepEqual(result.structuredContent?.rows, [{ id: 1, tenant: tenantId, name: "Alt", isArchived: true }])
|
|
assert.match(f.listed[0], /"customers"\."archived" = \$2/)
|
|
f.context.isAdmin = false
|
|
f.context.permissions = []
|
|
await assert.rejects(call(f.context, "list", { entity: "customers" }), /masterdata\.customers\.read/)
|
|
})
|
|
|
|
test("restore is advertised as a write tool", () => {
|
|
assert.equal(mcpToolMap.get("archive.entities.restore")!.readOnly, false)
|
|
})
|