From 0777315d1dbe33ae0db8cd6720be841ba104d278 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 25 Oct 2026 14:28:05 +0100 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20Archivierte=20Eintr=C3=A4ge=20wied?= =?UTF-8?q?erherstellen=20und=20Kontost=C3=A4nde=20abfragen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/mcp/registry.ts | 2 + backend/src/mcp/tools/accounting.ts | 22 +++ backend/src/mcp/tools/archive.ts | 150 +++++++++++++++++++ backend/src/mcp/types.ts | 2 + backend/src/modules/bank-balances.service.ts | 71 +++++++++ backend/src/routes/banking.ts | 3 +- backend/src/routes/mcp.ts | 2 +- backend/src/utils/history.ts | 3 +- backend/tests/bankBalances.test.ts | 24 +++ backend/tests/mcpArchiveRestore.test.ts | 99 ++++++++++++ backend/tests/mcpPagination.test.ts | 5 +- 11 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 backend/src/mcp/tools/archive.ts create mode 100644 backend/src/modules/bank-balances.service.ts create mode 100644 backend/tests/bankBalances.test.ts create mode 100644 backend/tests/mcpArchiveRestore.test.ts diff --git a/backend/src/mcp/registry.ts b/backend/src/mcp/registry.ts index f4fdfbb5..fabd6f95 100644 --- a/backend/src/mcp/registry.ts +++ b/backend/src/mcp/registry.ts @@ -3,6 +3,7 @@ import { masterdataTools } from "./tools/masterdata" import { organisationTools } from "./tools/organisation" import { wikiTools } from "./tools/wiki" import { documentTools } from "./tools/documents" +import { archiveTools } from "./tools/archive" export const mcpTools = [ ...accountingTools, @@ -10,6 +11,7 @@ export const mcpTools = [ ...organisationTools, ...wikiTools, ...documentTools, + ...archiveTools, ] export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool])) diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 40f51baa..308520d8 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -22,6 +22,8 @@ import { saveFile } from "../../utils/files" import { insertHistoryItem } from "../../utils/history" import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service" import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service" +import { loadBankBalances } from "../../modules/bank-balances.service" +import { dateField } from "../masterdataFields" import { prepareStatementAllocationInput, statementAllocationUuidArg, @@ -1310,6 +1312,26 @@ export const accountingTools: McpTool[] = [ return { invoice: updated } }, }, + { + name: "accounting.bank_accounts.balances", + title: "Kontostände abfragen", + description: "Liefert die Kontostände der Bankkonten und Kassenbücher des aktiven Mandanten samt Summe. Bankkonten zeigen den zuletzt synchronisierten Buchungssaldo; mit asOf wird der Stand zu einem vergangenen Datum aus den Bankumsätzen zurückgerechnet. Kassenbücher ergeben sich aus Anfangsbestand und Buchungen.", + requiredPermissions: ["accounting.bank.read"], + inputSchema: { + type: "object", + properties: { + account: { type: "number", description: "Optionale Bankkonto-ID; ohne Angabe alle Konten." }, + asOf: { ...dateField.schema, description: "Optionaler Stichtag YYYY-MM-DD (Stand zum Tagesende)." }, + }, + }, + async handler(context, args) { + const account = numberArg(args, "account") + const asOf = args.asOf === undefined || args.asOf === null ? null : dateField.parse(args.asOf, "asOf") + const result = await loadBankBalances(context.server, context.tenantId, { account, asOf }) + if (account && !result.accounts.length) throw new Error("Bankkonto nicht gefunden") + return result + }, + }, { name: "accounting.bank_statements.list", title: "Bankumsätze auflisten", diff --git a/backend/src/mcp/tools/archive.ts b/backend/src/mcp/tools/archive.ts new file mode 100644 index 00000000..e2c0d9ac --- /dev/null +++ b/backend/src/mcp/tools/archive.ts @@ -0,0 +1,150 @@ +import { and, desc, eq, getTableColumns, ilike, or } from "drizzle-orm" +import * as tables from "../../../db/schema" +import { assertToolPermission } from "../authz" +import { assertInternalDocumentAccess } from "../documentFiles" +import { idField, InputField, numericField, objectField, textField, uuidField } from "../masterdataFields" +import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" +import { McpContext, McpTool } from "../types" +import { insertHistoryItem } from "../../utils/history" +import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service" + +type ArchiveEntity = { + label: string + table: any + /** Key used by the history log and the generic resource routes. */ + resource: string + permission: string + searchColumns: string[] + uuid?: boolean + internalDocuments?: boolean +} + +const masterdata = (label: string, table: any, resource: string, namespace: string, searchColumns: string[], uuid = false): ArchiveEntity => + ({ label, table, resource, permission: `masterdata.${namespace}`, searchColumns, uuid }) + +/** Every entity the MCP can archive; restore checks the matching write permission. */ +export const archiveEntities: Record = { + customers: masterdata("Kunde", tables.customers, "customers", "customers", ["name", "customerNumber"]), + vendors: masterdata("Lieferant", tables.vendors, "vendors", "vendors", ["name", "vendorNumber"]), + contacts: masterdata("Kontakt", tables.contacts, "contacts", "contacts", ["fullName", "email"]), + products: masterdata("Artikel", tables.products, "products", "products", ["name", "article_number"]), + services: masterdata("Leistung", tables.services, "services", "services", ["name"]), + cost_centres: masterdata("Kostenstelle", tables.costcentres, "costcentres", "cost_centres", ["name", "number"], true), + branches: masterdata("Niederlassung", tables.branches, "branches", "branches", ["name", "number"]), + teams: masterdata("Team", tables.teams, "teams", "teams", ["name"]), + vehicles: masterdata("Fahrzeug", tables.vehicles, "vehicles", "vehicles", ["name", "license_plate"]), + inventory_items: masterdata("Inventar", tables.inventoryitems, "inventoryitems", "inventory_items", ["name", "articleNumber"]), + projects: { label: "Projekt", table: tables.projects, resource: "projects", permission: "organisation.projects", searchColumns: ["name", "projectNumber"] }, + events: { label: "Termin", table: tables.events, resource: "events", permission: "organisation.events", searchColumns: ["name"] }, + tasks: { label: "Aufgabe", table: tables.tasks, resource: "tasks", permission: "organisation.tasks", searchColumns: ["name"] }, + outgoing_documents: { label: "Ausgangsbeleg", table: tables.createddocuments, resource: "createddocuments", permission: "accounting.outgoing_documents", searchColumns: ["documentNumber", "title"] }, + incoming_invoices: { label: "Eingangsbeleg", table: tables.incominginvoices, resource: "incominginvoices", permission: "accounting.incoming_invoices", searchColumns: ["reference", "description"] }, + files: { label: "Datei", table: tables.files, resource: "files", permission: "documents", searchColumns: ["name"], uuid: true, internalDocuments: true }, +} + +const entityField: InputField = { + schema: { type: "string", enum: Object.keys(archiveEntities), description: "Art des archivierten Datensatzes." }, + parse(value, path) { + if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(archiveEntities, value)) throw new Error(`${path}: unbekannte Entität`) + return value + }, +} +const entityIdField: InputField = { + schema: { type: ["integer", "string"], description: "ID des Datensatzes; UUID bei cost_centres und files, sonst ganze Zahl." }, + parse: (value) => value, +} +const searchField: InputField = { + schema: { type: "string", maxLength: 200, description: "Optionaler Suchtext, z. B. Name oder Nummer." }, + parse(value, path) { + const text = textField().parse(value, path) + if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`) + return text + }, +} +const listInput = objectField({ + entity: entityField, + search: searchField, + limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit }, + offset: { ...numericField(true, 0), schema: paginationProperties.offset }, +}) +const restoreInput = objectField({ entity: entityField, id: entityIdField }) + +const columnKey = (table: any, ...names: string[]) => names.find((name) => name in getTableColumns(table)) + +/** asToolResult hides records flagged archived; archived rows are marked explicitly instead. */ +const withoutArchivedFlag = ({ archived: _archived, path: _path, extractedText: _text, ...row }: Record) => row + +async function authorize(context: McpContext, tool: McpTool, entity: ArchiveEntity, access: "read" | "write") { + assertToolPermission(context, { name: tool.name, requiredPermissions: [`${entity.permission}.${access}`] }) + if (entity.internalDocuments) await assertInternalDocumentAccess(context) +} + +export function prepareArchiveRestoreInput(args: Record) { + const input = restoreInput.parse(args, "arguments") + if (!input.entity) throw new Error("entity ist erforderlich") + if (input.id === undefined || input.id === null) throw new Error("id ist erforderlich") + const entity = archiveEntities[input.entity] + return { key: input.entity as string, entity, id: (entity.uuid ? uuidField : idField).parse(input.id, "id") } +} + +const listTool: McpTool = { + name: "archive.entities.list", + title: "Archivierte Einträge auflisten", + description: "Listet archivierte Datensätze einer Entitätsart im aktiven Mandanten, zuletzt geänderte zuerst. IDs anschließend mit archive.entities.restore wiederherstellen.", + // Checked per entity in the handler. + requiredPermissions: [], + inputSchema: { ...listInput.schema, required: ["entity"] }, + async handler(context, args) { + const input = listInput.parse(args, "arguments") + if (!input.entity) throw new Error("entity ist erforderlich") + const entity = archiveEntities[input.entity] + await authorize(context, listTool, entity, "read") + const table = entity.table + const conditions: any[] = [eq(table.tenant, context.tenantId), eq(table.archived, true)] + if (input.search) { + const pattern = `%${input.search.replace(/[\\%_]/g, "\\$&")}%` + conditions.push(or(...entity.searchColumns.map((column) => ilike(table[column], pattern)))) + } + const updatedAt = columnKey(table, "updatedAt", "updated_at")! + const rows = await context.server.db.select().from(table).where(and(...conditions)) + .orderBy(desc(table[updatedAt]), desc(table.id)).limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input)) + const result = paginatedResult(rows, input) + return { ...result, entity: input.entity, rows: result.rows.map((row: any) => ({ ...withoutArchivedFlag(row), isArchived: true })) } + }, +} + +const restoreTool: McpTool = { + name: "archive.entities.restore", + title: "Archivierten Eintrag wiederherstellen", + description: `Stellt einen archivierten Datensatz im aktiven Mandanten wieder her (entity: ${Object.keys(archiveEntities).join(", ")}). Benötigt die Schreibberechtigung der jeweiligen Entität.`, + requiredPermissions: [], + readOnly: false, + inputSchema: { ...restoreInput.schema, required: ["entity", "id"] }, + async handler(context, args) { + const { key, entity, id } = prepareArchiveRestoreInput(args) + await authorize(context, restoreTool, entity, "write") + const table = entity.table + return context.server.db.transaction(async (tx) => { + const server = { ...context.server, db: tx } as unknown as McpContext["server"] + const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId)) + const [existing] = await tx.select().from(table).where(scope).limit(1).for("update") + if (!existing) throw new Error(`${entity.label} nicht gefunden`) + if (!existing.archived) throw new Error(`${entity.label} ist nicht archiviert`) + const [result] = await tx.update(table).set({ + archived: false, + [columnKey(table, "updatedAt", "updated_at")!]: new Date(), + [columnKey(table, "updatedBy", "updated_by")!]: context.userId, + }).where(scope).returning() + if (["products", "services"].includes(entity.resource)) { + await recalculateServicePricesForTenant(server, context.tenantId, context.userId) + } + await insertHistoryItem(server, { + tenant_id: context.tenantId, created_by: context.userId, entity: entity.resource, entityId: result.id, + action: "restored", oldVal: existing, newVal: result, + }) + return { entity: key, id: result.id, success: true, action: "restored", record: withoutArchivedFlag(result) } + }) + }, +} + +export const archiveTools: McpTool[] = [listTool, restoreTool] diff --git a/backend/src/mcp/types.ts b/backend/src/mcp/types.ts index c4430de7..00738ccc 100644 --- a/backend/src/mcp/types.ts +++ b/backend/src/mcp/types.ts @@ -23,6 +23,8 @@ export type McpTool = { title: string description: string requiredPermissions: string[] + /** Overrides the readOnlyHint derived from requiredPermissions, e.g. for per-entity checks. */ + readOnly?: boolean inputSchema: Record handler: (context: McpContext, args: Record) => Promise } diff --git a/backend/src/modules/bank-balances.service.ts b/backend/src/modules/bank-balances.service.ts new file mode 100644 index 00000000..456b2247 --- /dev/null +++ b/backend/src/modules/bank-balances.service.ts @@ -0,0 +1,71 @@ +import { FastifyInstance } from "fastify" +import { and, eq, sql } from "drizzle-orm" +import { bankaccounts, bankstatements } from "../../db/schema" + +export const CASHBOOK_BANK_ID = "fedeo-cashbook" + +type BalanceAccount = Pick + +const roundMoney = (value: number) => Math.round(value * 100) / 100 + +/** + * Bank accounts store the last synced booked balance; cashbooks store their opening balance + * and derive the current balance from their bookings. For a past date, bank balances are + * reconstructed by reverting the statements booked after that date. + */ +export function calculateAccountBalance(account: BalanceAccount, statementSum: number, asOf: string | null) { + const cashbook = account.bankId === CASHBOOK_BANK_ID + const stored = Number(account.balance || 0) + const balance = cashbook ? stored + statementSum : stored - statementSum + return { + id: account.id, + type: cashbook ? "cashbook" : "bank", + name: account.name || account.ownerName || (cashbook ? "Kassenbuch" : "Bankkonto"), + iban: cashbook ? null : account.iban, + datevNumber: account.datevNumber, + expired: account.expired, + balance: roundMoney(balance), + currency: "EUR", + ...(cashbook ? { openingBalance: roundMoney(stored) } : { syncedBalance: roundMoney(stored), syncedAt: account.syncedAt }), + source: cashbook ? "cashbook_bookings" : asOf ? "bank_sync_reconstructed" : "bank_sync", + } +} + +export async function loadBankBalances(server: FastifyInstance, tenantId: number, options: { account?: number | null; asOf?: string | null } = {}) { + const asOf = options.asOf || null + const conditions = [eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false)] + if (options.account) conditions.push(eq(bankaccounts.id, options.account)) + const accounts = await server.db.select().from(bankaccounts).where(and(...conditions)).orderBy(bankaccounts.name, bankaccounts.id) + + const sums = async (dateCondition: any) => { + const rows = await server.db + .select({ account: bankstatements.account, total: sql`coalesce(sum(${bankstatements.amount}), 0)` }) + .from(bankstatements) + .where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), ...(dateCondition ? [dateCondition] : []))) + .groupBy(bankstatements.account) + return new Map(rows.map((row) => [row.account, Number(row.total)])) + } + // Synced statements may carry a full timestamp; compare the calendar day only. + const bookingDay = sql`left(${bankstatements.date}, 10)` + // Cashbooks need bookings up to the date, bank accounts only those after it. + const cashbookSums = await sums(asOf ? sql`${bookingDay} <= ${asOf}` : null) + const bankSums = asOf ? await sums(sql`${bookingDay} > ${asOf}`) : new Map() + + const balances = accounts.map((account) => calculateAccountBalance( + account, + (account.bankId === CASHBOOK_BANK_ID ? cashbookSums : bankSums).get(account.id) || 0, + asOf, + )) + const active = balances.filter((account) => !account.expired) + return { + asOf, + accounts: balances, + totals: { + balance: roundMoney(active.reduce((sum, account) => sum + account.balance, 0)), + bank: roundMoney(active.filter((account) => account.type === "bank").reduce((sum, account) => sum + account.balance, 0)), + cashbook: roundMoney(active.filter((account) => account.type === "cashbook").reduce((sum, account) => sum + account.balance, 0)), + currency: "EUR", + note: "Summen ohne abgelaufene Bankverbindungen.", + }, + } +} diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index 92349ebd..8de6a5bb 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -10,6 +10,7 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" import { centralServicesClient } from "../modules/push-server.client" import { findBankInstitutionByBic } from "../modules/banking-institution" import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details" +import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service" import { bankrequisitions, @@ -33,9 +34,7 @@ import { desc, } from "drizzle-orm" - export default async function bankingRoutes(server: FastifyInstance) { - const CASHBOOK_BANK_ID = "fedeo-cashbook" const ContraAccounts = aliasedTable(accounts, "contra_accounts") const ContraCustomers = aliasedTable(customers, "contra_customers") const ContraVendors = aliasedTable(vendors, "contra_vendors") diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts index ac55fa38..8fcf6a99 100644 --- a/backend/src/routes/mcp.ts +++ b/backend/src/routes/mcp.ts @@ -270,7 +270,7 @@ export default async function mcpRoutes(server: FastifyInstance) { description: tool.description, inputSchema: tool.inputSchema, annotations: { - readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")), + readOnlyHint: tool.readOnly ?? !tool.requiredPermissions.some((permission) => permission.endsWith(".write")), }, })), })) diff --git a/backend/src/utils/history.ts b/backend/src/utils/history.ts index a8c6d6ce..19b7e3d3 100644 --- a/backend/src/utils/history.ts +++ b/backend/src/utils/history.ts @@ -50,7 +50,7 @@ export async function insertHistoryItem( created_by: string | null entity: string entityId: string | number - action: "created" | "updated" | "unchanged" | "deleted" | "archived" + action: "created" | "updated" | "unchanged" | "deleted" | "archived" | "restored" oldVal?: any newVal?: any text?: string @@ -62,6 +62,7 @@ export async function insertHistoryItem( updated: `Eintrag in ${entityLabel} geändert`, unchanged: `Eintrag in ${entityLabel} unverändert`, archived: `Eintrag in ${entityLabel} archiviert`, + restored: `Eintrag in ${entityLabel} wiederhergestellt`, deleted: `Eintrag in ${entityLabel} gelöscht` } diff --git a/backend/tests/bankBalances.test.ts b/backend/tests/bankBalances.test.ts new file mode 100644 index 00000000..7ce5dd54 --- /dev/null +++ b/backend/tests/bankBalances.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { calculateAccountBalance, CASHBOOK_BANK_ID } from "../src/modules/bank-balances.service" + +const syncedAt = new Date("2026-10-20T08:00:00Z") +const bank = { id: 1, name: "Giro", iban: "DE00", ownerName: null, bankId: "bank", datevNumber: "1200", balance: 1000.1, expired: false, syncedAt } +const cashbook = { ...bank, id: 2, name: "Kasse", iban: "CASH-1", bankId: CASHBOOK_BANK_ID, datevNumber: "1000", balance: 50 } + +test("bank accounts report the synced balance and revert later statements for a past date", () => { + assert.equal(calculateAccountBalance(bank, 0, null).balance, 1000.1) + assert.equal(calculateAccountBalance(bank, 0, null).source, "bank_sync") + const past = calculateAccountBalance(bank, -200.05, "2026-10-01") + assert.equal(past.balance, 1200.15) + assert.equal(past.syncedBalance, 1000.1) + assert.equal(past.source, "bank_sync_reconstructed") +}) + +test("cashbooks add their bookings to the opening balance", () => { + const result = calculateAccountBalance(cashbook, 12.345, null) + assert.equal(result.type, "cashbook") + assert.equal(result.balance, 62.35) + assert.equal(result.openingBalance, 50) + assert.equal(result.iban, null) +}) diff --git a/backend/tests/mcpArchiveRestore.test.ts b/backend/tests/mcpArchiveRestore.test.ts new file mode 100644 index 00000000..79f9f859 --- /dev/null +++ b/backend/tests/mcpArchiveRestore.test.ts @@ -0,0 +1,99 @@ +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 = {}, 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 + 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) => 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) +}) diff --git a/backend/tests/mcpPagination.test.ts b/backend/tests/mcpPagination.test.ts index fb43766c..98d94b74 100644 --- a/backend/tests/mcpPagination.test.ts +++ b/backend/tests/mcpPagination.test.ts @@ -40,11 +40,12 @@ for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any return values }) } }, { schema }) - const context = { server: { db }, tenantId: 7, userId: "test" } as any + const context = { server: { db }, tenantId: 7, userId: "test", isAdmin: true } as any + const properties = tool.inputSchema.properties as any const ids: unknown[] = [] let offset = 0 for (let page = 0; page < 3; page++) { - const result: any = await tool.handler(context, { ...((tool.inputSchema.properties as any).id ? { id: 1 } : {}), limit: 100, offset }) + const result: any = await tool.handler(context, { ...(properties.id ? { id: 1 } : {}), ...(properties.entity ? { entity: properties.entity.enum[0] } : {}), limit: 100, offset }) assert.equal(result.rows.length, page < 2 ? 100 : 5) assert.equal(result.pagination.hasMore, page < 2) assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)