From 95b4963d75acc9c5f56ca9742f93120f2ec90233 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 6 Jul 2026 17:45:29 +0200 Subject: [PATCH 1/2] feat(mcp): add observability read tools Add read-only MCP tools for audit logs, instance and device history, instance resources and the application and team BOM, with unit and equivalence tests. Query-string building uses the shared appendQuery helper. Allow-list their scopes for the expert-mcp platform token. --- forge/ee/lib/mcp/tools/observability.js | 245 ++++++++++++ forge/routes/auth/permissions.js | 9 +- .../ee/lib/mcp/tools/observability_spec.js | 367 ++++++++++++++++++ 3 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 forge/ee/lib/mcp/tools/observability.js create mode 100644 test/unit/forge/ee/lib/mcp/tools/observability_spec.js diff --git a/forge/ee/lib/mcp/tools/observability.js b/forge/ee/lib/mcp/tools/observability.js new file mode 100644 index 0000000000..96932028ad --- /dev/null +++ b/forge/ee/lib/mcp/tools/observability.js @@ -0,0 +1,245 @@ +const { z } = require('zod') + +const { teamId, applicationId, hostedInstanceId, remoteInstanceId, basePagination, basePaginationKeys, searchQuery, searchQueryKeys, auditLogFilters, auditLogFilterKeys, appendQuery } = require('../schemas') + +// Audit-log routes accept cursor+limit pagination, free-text query, event +// (single name or array) and username. scope narrows which entity levels are +// returned and its allowed values differ per route (the device route has none); +// includeChildren pulls in descendant entries within the chosen scope. +const includeChildren = z.boolean().optional().describe('Also include audit entries from child entities within the chosen scope') +const auditLogInput = { ...basePagination, ...searchQuery, ...auditLogFilters } +const auditLogKeys = [...basePaginationKeys, ...searchQueryKeys, ...auditLogFilterKeys] + +module.exports = [ + { + name: 'platform_get_team_audit_log', + title: 'Get Team Audit Log', + description: `FlowFuse platform automation tool: + Reads the audit log for a team, showing events like membership changes, billing changes, + and administrative actions taken across the team's applications, instances, and devices. + A team-scoped PAT only sees audit log entries for teams it is scoped to. + Use this when the user asks what happened on a team, or wants to investigate recent changes.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + ...auditLogInput, + scope: z.enum(['team', 'application', 'project', 'device']).optional().describe('Entity level to include (default team)'), + includeChildren + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/teams/${args.teamId}/audit-log`, args, [...auditLogKeys, 'scope', 'includeChildren']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_export_team_audit_log', + title: 'Export Team Audit Log', + description: `FlowFuse platform automation tool: + Exports the team audit log as a CSV file. + Use this when the user wants a downloadable or shareable copy of the team's audit history, + rather than reading entries directly with platform_get_team_audit_log.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + ...auditLogInput, + scope: z.enum(['team', 'application', 'project', 'device']).optional().describe('Entity level to include (default team)'), + includeChildren + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/teams/${args.teamId}/audit-log/export`, args, [...auditLogKeys, 'scope', 'includeChildren']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_hosted_instance_audit_log', + title: 'Get Hosted Instance Audit Log', + description: `FlowFuse platform automation tool: + Reads the audit log for a hosted instance, showing events like deployments, restarts, + settings changes, and other actions taken against that instance. + Use this when the user wants to know what has happened to a specific hosted instance.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + hostedInstanceId, + ...auditLogInput, + scope: z.enum(['project', 'device']).optional().describe('Entity level to include (default project)'), + includeChildren + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/projects/${args.hostedInstanceId}/audit-log`, args, [...auditLogKeys, 'scope', 'includeChildren']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_export_hosted_instance_audit_log', + title: 'Export Hosted Instance Audit Log', + description: `FlowFuse platform automation tool: + Exports a hosted instance audit log as a CSV file. + Use this when the user wants a downloadable or shareable copy of the instance's audit history, + rather than reading entries directly with platform_get_hosted_instance_audit_log.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + hostedInstanceId, + ...auditLogInput, + scope: z.enum(['project', 'device']).optional().describe('Entity level to include (default project)'), + includeChildren + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/projects/${args.hostedInstanceId}/audit-log/export`, args, [...auditLogKeys, 'scope', 'includeChildren']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_remote_instance_audit_log', + title: 'Get Remote Instance Audit Log', + description: `FlowFuse platform automation tool: + Reads the audit log for a remote instance/device, showing events like connection changes, + deployments, and configuration changes for that device. + Use this when the user wants to know what has happened to a specific remote instance.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + remoteInstanceId, + ...auditLogInput + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/devices/${args.remoteInstanceId}/audit-log`, args, auditLogKeys) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_export_remote_instance_audit_log', + title: 'Export Remote Instance Audit Log', + description: `FlowFuse platform automation tool: + Exports a remote instance/device audit log as a CSV file. + Use this when the user wants a downloadable or shareable copy of the device's audit history, + rather than reading entries directly with platform_get_remote_instance_audit_log.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + remoteInstanceId, + ...auditLogInput + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/devices/${args.remoteInstanceId}/audit-log/export`, args, auditLogKeys) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_export_application_audit_log', + title: 'Export Application Audit Log', + description: `FlowFuse platform automation tool: + Exports an application audit log as a CSV file. + Use this when the user wants a downloadable or shareable copy of the application's audit history. + To read audit log entries directly instead, use the application audit log read tool.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + applicationId, + ...auditLogInput, + scope: z.enum(['application', 'project', 'device']).optional().describe('Entity level to include (default application)'), + includeChildren + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/applications/${args.applicationId}/audit-log/export`, args, [...auditLogKeys, 'scope', 'includeChildren']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_hosted_instance_history', + title: 'Get Hosted Instance History', + description: `FlowFuse platform automation tool: + Reads a timeline of changes made to a hosted instance over time. + This is plan-gated on the projectHistory feature, which defaults to enabled; + if the team's plan has this feature disabled, the tool reports that instance history + is not enabled for this team rather than a bare not-found error. + Use this when the user wants a chronological view of what changed on an instance.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + hostedInstanceId, + ...basePagination + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/projects/${args.hostedInstanceId}/history`, args, basePaginationKeys) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_remote_instance_history', + title: 'Get Remote Instance History', + description: `FlowFuse platform automation tool: + Reads a timeline of changes made to a remote instance/device over time. + This is plan-gated on the projectHistory feature, which defaults to enabled; + if the team's plan has this feature disabled, the tool reports that device history + is not enabled for this team rather than a bare not-found error. + Use this when the user wants a chronological view of what changed on a device.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + remoteInstanceId, + ...basePagination + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/devices/${args.remoteInstanceId}/history`, args, basePaginationKeys) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_hosted_instance_resources', + title: 'Get Hosted Instance Resources', + description: `FlowFuse platform automation tool: + Reads a point-in-time snapshot of resource usage (CPU, memory) for a hosted instance. + This is plan-gated on the instanceResources feature, which defaults to disabled; + if the team's plan has this feature disabled, the tool reports that resource usage + is not enabled for this team rather than a bare not-found error. + This only returns a snapshot, not a live streaming feed.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + hostedInstanceId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.hostedInstanceId}/resources` }) + return response + } + }, + { + name: 'platform_get_team_bom', + title: 'Get Team Bill of Materials', + description: `FlowFuse platform automation tool: + Reads the bill of materials for a team: the applications, instances, and their + dependencies across the team. This is plan-gated on the bom feature, which defaults + to disabled; if disabled for the team, the tool reports that the bill of materials + is not enabled for this team rather than the raw platform error. + Results are filtered to the applications the calling token can access, + so a scoped token sees only its in-scope subset instead of an error.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/bom` }) + return response + } + }, + { + name: 'platform_get_application_bom', + title: 'Get Application Bill of Materials', + description: `FlowFuse platform automation tool: + Reads the bill of materials for a single application: its instances and their dependencies. + This is plan-gated on the bom feature, which defaults to disabled; if the team's plan + has this feature disabled, the tool reports that the bill of materials is not enabled + for this team rather than the raw platform error.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + applicationId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}/bom` }) + return response + } + } +] diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index 15a713be0c..9db5c24e6b 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -52,7 +52,14 @@ const IMPLICIT_TOKEN_SCOPES = { 'stack:list', 'flow-blueprint:list', 'project:status', - 'template:list' + 'template:list', + // observability + 'team:audit-log', // get team audit log + 'project:audit-log', // get hosted instance audit log + 'device:audit-log', // get remote instance audit log + 'project:history', // get hosted instance or remote instance history + 'team:bom', // get team bill of materials + 'application:bom' // get application bill of materials ] } diff --git a/test/unit/forge/ee/lib/mcp/tools/observability_spec.js b/test/unit/forge/ee/lib/mcp/tools/observability_spec.js new file mode 100644 index 0000000000..9aee8d13bb --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/observability_spec.js @@ -0,0 +1,367 @@ +const should = require('should') // eslint-disable-line no-unused-vars +const sinon = require('sinon') + +const { expectToolMatchesRoute, createExpertMcpToken, toolFinder } = require('../../../../../../lib/mcpToolEquivalence') + +const FF_UTIL = require('flowforge-test-utils') + +const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/observability') +const { basePaginationKeys, searchQueryKeys, auditLogFilterKeys } = FF_UTIL.require('forge/ee/lib/mcp/schemas') +const findTool = toolFinder(tools) + +// audit-log routes honor cursor+limit, free-text query, and event/username. +// scope + includeChildren are honored on team/instance/application routes but +// not the device route, so they are listed per tool below. +const auditLogKeys = [...basePaginationKeys, ...searchQueryKeys, ...auditLogFilterKeys] +const auditLogScopedKeys = [...auditLogKeys, 'scope', 'includeChildren'] + +describe('MCP Observability Tools', function () { + function stubInject (response = { statusCode: 200, json: () => ({}) }) { + return sinon.stub().resolves(response) + } + + const toolDefinitions = [ + { + name: 'platform_get_team_audit_log', + param: 'teamId', + base: id => `/api/v1/teams/${id}/audit-log`, + queryKeys: auditLogScopedKeys + }, + { + name: 'platform_export_team_audit_log', + param: 'teamId', + base: id => `/api/v1/teams/${id}/audit-log/export`, + queryKeys: auditLogScopedKeys + }, + { + name: 'platform_get_hosted_instance_audit_log', + param: 'hostedInstanceId', + base: id => `/api/v1/projects/${id}/audit-log`, + queryKeys: auditLogScopedKeys + }, + { + name: 'platform_export_hosted_instance_audit_log', + param: 'hostedInstanceId', + base: id => `/api/v1/projects/${id}/audit-log/export`, + queryKeys: auditLogScopedKeys + }, + { + name: 'platform_get_remote_instance_audit_log', + param: 'remoteInstanceId', + base: id => `/api/v1/devices/${id}/audit-log`, + queryKeys: auditLogKeys + }, + { + name: 'platform_export_remote_instance_audit_log', + param: 'remoteInstanceId', + base: id => `/api/v1/devices/${id}/audit-log/export`, + queryKeys: auditLogKeys + }, + { + name: 'platform_export_application_audit_log', + param: 'applicationId', + base: id => `/api/v1/applications/${id}/audit-log/export`, + queryKeys: auditLogScopedKeys + }, + { + name: 'platform_get_hosted_instance_history', + param: 'hostedInstanceId', + base: id => `/api/v1/projects/${id}/history`, + queryKeys: basePaginationKeys + }, + { + name: 'platform_get_remote_instance_history', + param: 'remoteInstanceId', + base: id => `/api/v1/devices/${id}/history`, + queryKeys: basePaginationKeys + }, + { + name: 'platform_get_hosted_instance_resources', + param: 'hostedInstanceId', + base: id => `/api/v1/projects/${id}/resources`, + queryKeys: null + }, + { + name: 'platform_get_team_bom', + param: 'teamId', + base: id => `/api/v1/teams/${id}/bom`, + queryKeys: null + }, + { + name: 'platform_get_application_bom', + param: 'applicationId', + base: id => `/api/v1/applications/${id}/bom`, + queryKeys: null + } + ] + + toolDefinitions.forEach(def => { + describe(def.name, function () { + it('is registered as a read-only, non-destructive tool', function () { + const tool = findTool(def.name) + tool.annotations.should.have.property('readOnlyHint', true) + tool.annotations.should.have.property('destructiveHint', false) + }) + + it('exposes the expected inputSchema keys', function () { + const tool = findTool(def.name) + const expectedKeys = [def.param, ...(def.queryKeys || [])] + Object.keys(tool.inputSchema).sort().should.deepEqual(expectedKeys.sort()) + }) + + it('calls inject with the correct method and bare URL when no query params are given', async function () { + const tool = findTool(def.name) + const inject = stubInject() + const args = { [def.param]: 'abc123' } + + await tool.handler(args, { inject }) + + inject.calledOnce.should.be.true() + const call = inject.firstCall.args[0] + call.method.should.equal('GET') + call.url.should.equal(def.base('abc123')) + }) + + if (def.queryKeys) { + it('serialises pagination params onto the URL', async function () { + const tool = findTool(def.name) + const inject = stubInject() + // limit is honored by every paginated tool here; query is not + // (the history routes take only cursor/limit), so assert on limit. + const args = { [def.param]: 'abc123', limit: 10 } + + await tool.handler(args, { inject }) + + const call = inject.firstCall.args[0] + call.url.should.equal(`${def.base('abc123')}?limit=10`) + }) + } + }) + }) + + describe('audit-log tools', function () { + it('serialises an array `event` filter as one repeated query param per element', async function () { + const tool = findTool('platform_get_team_audit_log') + const inject = stubInject() + const args = { teamId: 'team1', event: ['user.login', 'user.logout'], limit: 5 } + + await tool.handler(args, { inject }) + + const call = inject.firstCall.args[0] + call.url.should.equal('/api/v1/teams/team1/audit-log?limit=5&event=user.login&event=user.logout') + }) + + it('serialises the `username` filter alongside other query params', async function () { + const tool = findTool('platform_get_hosted_instance_audit_log') + const inject = stubInject() + const args = { hostedInstanceId: 'instance1', username: 'alice', limit: 20 } + + await tool.handler(args, { inject }) + + const call = inject.firstCall.args[0] + call.url.should.equal('/api/v1/projects/instance1/audit-log?limit=20&username=alice') + }) + + it('serialises the scope and includeChildren filters where the route supports them', async function () { + const tool = findTool('platform_get_team_audit_log') + const inject = stubInject() + const args = { teamId: 'team1', scope: 'application', includeChildren: true } + + await tool.handler(args, { inject }) + + const call = inject.firstCall.args[0] + call.url.should.equal('/api/v1/teams/team1/audit-log?scope=application&includeChildren=true') + }) + }) + + describe('pass-through response handling', function () { + it('returns the raw inject response unchanged, without intercepting 404s', async function () { + const tool = findTool('platform_get_hosted_instance_resources') + const notFoundResponse = { statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) } + const inject = stubInject(notFoundResponse) + + const result = await tool.handler({ hostedInstanceId: 'instance1' }, { inject }) + + result.should.equal(notFoundResponse) + }) + }) +}) + +describe('MCP Observability Tools - integration smoke', function () { + const setup = require('../../../setup') + + let app + let token + + before(async function () { + app = await setup({ + ai: { enabled: true }, + expert: { enabled: true } + }) + token = await createExpertMcpToken(app) + }) + + after(async function () { + await app.close() + }) + + function inject (options) { + return app.inject({ + ...options, + headers: { + ...(options.headers || {}), + authorization: `Bearer ${token}` + } + }) + } + + it('reads the team audit log via the underlying route', async function () { + const tool = tools.find(t => t.name === 'platform_get_team_audit_log') + + const response = await tool.handler({ teamId: app.team.hashid }, { inject }) + + response.statusCode.should.equal(200) + const body = response.json() + body.should.have.property('meta') + body.should.have.property('count') + body.should.have.property('log') + body.log.should.be.an.Array() + }) + + describe('tool-vs-route equivalence', function () { + let team + let instance + let application + let device + + before(async function () { + team = app.team + instance = app.instance + application = app.application + + // Enable plan-gated features so equivalence tests hit the real 200, not the "disabled" guard. + const defaultTeamTypeProperties = app.defaultTeamType.properties + defaultTeamTypeProperties.features.bom = true + app.defaultTeamType.properties = defaultTeamTypeProperties + await app.defaultTeamType.save() + + device = await app.factory.createDevice({ name: 'observability-device' }, team, instance, application) + + // Seed one real entry per scope so assertions compare actual data, not an empty-list shape. + await app.db.controllers.AuditLog.teamLog(team.id, app.user.id, 'team.settings.updated', { settings: { name: team.name } }) + await app.db.controllers.AuditLog.projectLog(instance.id, app.user.id, 'flows.set', { type: 'flows' }) + await app.db.controllers.AuditLog.deviceLog(device.id, app.user.id, 'flows.set', { type: 'flows' }) + + await instance.update({ versions: { 'node-red': { wanted: '4.0.3', current: '4.0.2' } } }) + }) + + it('platform_get_team_audit_log matches GET /api/v1/teams/:teamId/audit-log, including query params', async function () { + const tool = findTool('platform_get_team_audit_log') + await expectToolMatchesRoute(inject, tool, { teamId: team.hashid, event: 'team.settings.updated', limit: 5 }, { + method: 'GET', + url: `/api/v1/teams/${team.hashid}/audit-log?limit=5&event=team.settings.updated` + }) + }) + + it('platform_export_team_audit_log matches GET /api/v1/teams/:teamId/audit-log/export', async function () { + const tool = findTool('platform_export_team_audit_log') + await expectToolMatchesRoute(inject, tool, { teamId: team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${team.hashid}/audit-log/export`, + raw: true + }) + }) + + it('platform_get_hosted_instance_audit_log matches GET /api/v1/projects/:instanceId/audit-log, including query params', async function () { + const tool = findTool('platform_get_hosted_instance_audit_log') + await expectToolMatchesRoute(inject, tool, { hostedInstanceId: instance.id, username: app.user.username, limit: 5 }, { + method: 'GET', + url: `/api/v1/projects/${instance.id}/audit-log?limit=5&username=${app.user.username}` + }) + }) + + it('platform_export_hosted_instance_audit_log matches GET /api/v1/projects/:instanceId/audit-log/export', async function () { + const tool = findTool('platform_export_hosted_instance_audit_log') + await expectToolMatchesRoute(inject, tool, { hostedInstanceId: instance.id }, { + method: 'GET', + url: `/api/v1/projects/${instance.id}/audit-log/export`, + raw: true + }) + }) + + it('platform_get_remote_instance_audit_log matches GET /api/v1/devices/:deviceId/audit-log', async function () { + const tool = findTool('platform_get_remote_instance_audit_log') + await expectToolMatchesRoute(inject, tool, { remoteInstanceId: device.hashid }, { + method: 'GET', + url: `/api/v1/devices/${device.hashid}/audit-log` + }) + }) + + it('platform_export_remote_instance_audit_log matches GET /api/v1/devices/:deviceId/audit-log/export', async function () { + const tool = findTool('platform_export_remote_instance_audit_log') + await expectToolMatchesRoute(inject, tool, { remoteInstanceId: device.hashid }, { + method: 'GET', + url: `/api/v1/devices/${device.hashid}/audit-log/export`, + raw: true + }) + }) + + it('platform_export_application_audit_log matches GET /api/v1/applications/:applicationId/audit-log/export', async function () { + const tool = findTool('platform_export_application_audit_log') + await expectToolMatchesRoute(inject, tool, { applicationId: application.hashid }, { + method: 'GET', + url: `/api/v1/applications/${application.hashid}/audit-log/export`, + raw: true + }) + }) + + it('platform_get_hosted_instance_history matches GET /api/v1/projects/:instanceId/history', async function () { + const tool = findTool('platform_get_hosted_instance_history') + await expectToolMatchesRoute(inject, tool, { hostedInstanceId: instance.id }, { + method: 'GET', + url: `/api/v1/projects/${instance.id}/history` + }) + }) + + it('platform_get_remote_instance_history matches GET /api/v1/devices/:deviceId/history', async function () { + const tool = findTool('platform_get_remote_instance_history') + await expectToolMatchesRoute(inject, tool, { remoteInstanceId: device.hashid }, { + method: 'GET', + url: `/api/v1/devices/${device.hashid}/history` + }) + }) + + it('platform_get_hosted_instance_resources matches GET /api/v1/projects/:instanceId/resources', async function () { + // The stub container driver returns fresh random point-in-time values on + // every call, so bodies are never byte-identical; normalize to compare shape only. + const shapeOnly = (body) => ({ + meta: body.meta, + count: body.count, + resourcesLength: body.resources.length, + resourceKeys: body.resources[0] && Object.keys(body.resources[0]).sort() + }) + const tool = findTool('platform_get_hosted_instance_resources') + await expectToolMatchesRoute(inject, tool, { hostedInstanceId: instance.id }, { + method: 'GET', + url: `/api/v1/projects/${instance.id}/resources`, + normalize: shapeOnly + }) + }) + + it('platform_get_team_bom matches GET /api/v1/teams/:teamId/bom', async function () { + const tool = findTool('platform_get_team_bom') + await expectToolMatchesRoute(inject, tool, { teamId: team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${team.hashid}/bom` + }) + }) + + it('platform_get_application_bom matches GET /api/v1/applications/:applicationId/bom', async function () { + const tool = findTool('platform_get_application_bom') + await expectToolMatchesRoute(inject, tool, { applicationId: application.hashid }, { + method: 'GET', + url: `/api/v1/applications/${application.hashid}/bom` + }) + }) + }) +}) From a5b4257159b7fc02bf7c23aef43af418295ed042 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 7 Jul 2026 14:53:27 +0200 Subject: [PATCH 2/2] refactor(mcp): scope this branch to bill-of-materials read tools --- forge/ee/lib/mcp/tools/bom.js | 40 ++ forge/ee/lib/mcp/tools/observability.js | 245 ------------ forge/routes/auth/permissions.js | 6 +- test/unit/forge/ee/lib/mcp/tools/bom_spec.js | 132 +++++++ .../ee/lib/mcp/tools/observability_spec.js | 367 ------------------ 5 files changed, 173 insertions(+), 617 deletions(-) create mode 100644 forge/ee/lib/mcp/tools/bom.js delete mode 100644 forge/ee/lib/mcp/tools/observability.js create mode 100644 test/unit/forge/ee/lib/mcp/tools/bom_spec.js delete mode 100644 test/unit/forge/ee/lib/mcp/tools/observability_spec.js diff --git a/forge/ee/lib/mcp/tools/bom.js b/forge/ee/lib/mcp/tools/bom.js new file mode 100644 index 0000000000..1999157b13 --- /dev/null +++ b/forge/ee/lib/mcp/tools/bom.js @@ -0,0 +1,40 @@ +const { teamId, applicationId } = require('../schemas') + +module.exports = [ + { + name: 'platform_get_team_bom', + title: 'Get Team Bill of Materials', + description: `FlowFuse platform automation tool: + Reads the bill of materials for a team: the applications, instances, and their + dependencies across the team. This is plan-gated on the bom feature, which defaults + to disabled; if disabled for the team, the tool reports that the bill of materials + is not enabled for this team rather than the raw platform error. + Results are filtered to the applications the calling token can access, + so a scoped token sees only its in-scope subset instead of an error.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/bom` }) + return response + } + }, + { + name: 'platform_get_application_bom', + title: 'Get Application Bill of Materials', + description: `FlowFuse platform automation tool: + Reads the bill of materials for a single application: its instances and their dependencies. + This is plan-gated on the bom feature, which defaults to disabled; if the team's plan + has this feature disabled, the tool reports that the bill of materials is not enabled + for this team rather than the raw platform error.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + applicationId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}/bom` }) + return response + } + } +] diff --git a/forge/ee/lib/mcp/tools/observability.js b/forge/ee/lib/mcp/tools/observability.js deleted file mode 100644 index 96932028ad..0000000000 --- a/forge/ee/lib/mcp/tools/observability.js +++ /dev/null @@ -1,245 +0,0 @@ -const { z } = require('zod') - -const { teamId, applicationId, hostedInstanceId, remoteInstanceId, basePagination, basePaginationKeys, searchQuery, searchQueryKeys, auditLogFilters, auditLogFilterKeys, appendQuery } = require('../schemas') - -// Audit-log routes accept cursor+limit pagination, free-text query, event -// (single name or array) and username. scope narrows which entity levels are -// returned and its allowed values differ per route (the device route has none); -// includeChildren pulls in descendant entries within the chosen scope. -const includeChildren = z.boolean().optional().describe('Also include audit entries from child entities within the chosen scope') -const auditLogInput = { ...basePagination, ...searchQuery, ...auditLogFilters } -const auditLogKeys = [...basePaginationKeys, ...searchQueryKeys, ...auditLogFilterKeys] - -module.exports = [ - { - name: 'platform_get_team_audit_log', - title: 'Get Team Audit Log', - description: `FlowFuse platform automation tool: - Reads the audit log for a team, showing events like membership changes, billing changes, - and administrative actions taken across the team's applications, instances, and devices. - A team-scoped PAT only sees audit log entries for teams it is scoped to. - Use this when the user asks what happened on a team, or wants to investigate recent changes.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId, - ...auditLogInput, - scope: z.enum(['team', 'application', 'project', 'device']).optional().describe('Entity level to include (default team)'), - includeChildren - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/teams/${args.teamId}/audit-log`, args, [...auditLogKeys, 'scope', 'includeChildren']) - const response = await inject({ method: 'GET', url }) - return response - } - }, - { - name: 'platform_export_team_audit_log', - title: 'Export Team Audit Log', - description: `FlowFuse platform automation tool: - Exports the team audit log as a CSV file. - Use this when the user wants a downloadable or shareable copy of the team's audit history, - rather than reading entries directly with platform_get_team_audit_log.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId, - ...auditLogInput, - scope: z.enum(['team', 'application', 'project', 'device']).optional().describe('Entity level to include (default team)'), - includeChildren - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/teams/${args.teamId}/audit-log/export`, args, [...auditLogKeys, 'scope', 'includeChildren']) - const response = await inject({ method: 'GET', url }) - return response - } - }, - { - name: 'platform_get_hosted_instance_audit_log', - title: 'Get Hosted Instance Audit Log', - description: `FlowFuse platform automation tool: - Reads the audit log for a hosted instance, showing events like deployments, restarts, - settings changes, and other actions taken against that instance. - Use this when the user wants to know what has happened to a specific hosted instance.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - hostedInstanceId, - ...auditLogInput, - scope: z.enum(['project', 'device']).optional().describe('Entity level to include (default project)'), - includeChildren - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/projects/${args.hostedInstanceId}/audit-log`, args, [...auditLogKeys, 'scope', 'includeChildren']) - const response = await inject({ method: 'GET', url }) - return response - } - }, - { - name: 'platform_export_hosted_instance_audit_log', - title: 'Export Hosted Instance Audit Log', - description: `FlowFuse platform automation tool: - Exports a hosted instance audit log as a CSV file. - Use this when the user wants a downloadable or shareable copy of the instance's audit history, - rather than reading entries directly with platform_get_hosted_instance_audit_log.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - hostedInstanceId, - ...auditLogInput, - scope: z.enum(['project', 'device']).optional().describe('Entity level to include (default project)'), - includeChildren - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/projects/${args.hostedInstanceId}/audit-log/export`, args, [...auditLogKeys, 'scope', 'includeChildren']) - const response = await inject({ method: 'GET', url }) - return response - } - }, - { - name: 'platform_get_remote_instance_audit_log', - title: 'Get Remote Instance Audit Log', - description: `FlowFuse platform automation tool: - Reads the audit log for a remote instance/device, showing events like connection changes, - deployments, and configuration changes for that device. - Use this when the user wants to know what has happened to a specific remote instance.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - remoteInstanceId, - ...auditLogInput - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/devices/${args.remoteInstanceId}/audit-log`, args, auditLogKeys) - const response = await inject({ method: 'GET', url }) - return response - } - }, - { - name: 'platform_export_remote_instance_audit_log', - title: 'Export Remote Instance Audit Log', - description: `FlowFuse platform automation tool: - Exports a remote instance/device audit log as a CSV file. - Use this when the user wants a downloadable or shareable copy of the device's audit history, - rather than reading entries directly with platform_get_remote_instance_audit_log.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - remoteInstanceId, - ...auditLogInput - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/devices/${args.remoteInstanceId}/audit-log/export`, args, auditLogKeys) - const response = await inject({ method: 'GET', url }) - return response - } - }, - { - name: 'platform_export_application_audit_log', - title: 'Export Application Audit Log', - description: `FlowFuse platform automation tool: - Exports an application audit log as a CSV file. - Use this when the user wants a downloadable or shareable copy of the application's audit history. - To read audit log entries directly instead, use the application audit log read tool.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - applicationId, - ...auditLogInput, - scope: z.enum(['application', 'project', 'device']).optional().describe('Entity level to include (default application)'), - includeChildren - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/applications/${args.applicationId}/audit-log/export`, args, [...auditLogKeys, 'scope', 'includeChildren']) - const response = await inject({ method: 'GET', url }) - return response - } - }, - { - name: 'platform_get_hosted_instance_history', - title: 'Get Hosted Instance History', - description: `FlowFuse platform automation tool: - Reads a timeline of changes made to a hosted instance over time. - This is plan-gated on the projectHistory feature, which defaults to enabled; - if the team's plan has this feature disabled, the tool reports that instance history - is not enabled for this team rather than a bare not-found error. - Use this when the user wants a chronological view of what changed on an instance.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - hostedInstanceId, - ...basePagination - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/projects/${args.hostedInstanceId}/history`, args, basePaginationKeys) - const response = await inject({ method: 'GET', url }) - return response - } - }, - { - name: 'platform_get_remote_instance_history', - title: 'Get Remote Instance History', - description: `FlowFuse platform automation tool: - Reads a timeline of changes made to a remote instance/device over time. - This is plan-gated on the projectHistory feature, which defaults to enabled; - if the team's plan has this feature disabled, the tool reports that device history - is not enabled for this team rather than a bare not-found error. - Use this when the user wants a chronological view of what changed on a device.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - remoteInstanceId, - ...basePagination - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/devices/${args.remoteInstanceId}/history`, args, basePaginationKeys) - const response = await inject({ method: 'GET', url }) - return response - } - }, - { - name: 'platform_get_hosted_instance_resources', - title: 'Get Hosted Instance Resources', - description: `FlowFuse platform automation tool: - Reads a point-in-time snapshot of resource usage (CPU, memory) for a hosted instance. - This is plan-gated on the instanceResources feature, which defaults to disabled; - if the team's plan has this feature disabled, the tool reports that resource usage - is not enabled for this team rather than a bare not-found error. - This only returns a snapshot, not a live streaming feed.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - hostedInstanceId - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.hostedInstanceId}/resources` }) - return response - } - }, - { - name: 'platform_get_team_bom', - title: 'Get Team Bill of Materials', - description: `FlowFuse platform automation tool: - Reads the bill of materials for a team: the applications, instances, and their - dependencies across the team. This is plan-gated on the bom feature, which defaults - to disabled; if disabled for the team, the tool reports that the bill of materials - is not enabled for this team rather than the raw platform error. - Results are filtered to the applications the calling token can access, - so a scoped token sees only its in-scope subset instead of an error.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/bom` }) - return response - } - }, - { - name: 'platform_get_application_bom', - title: 'Get Application Bill of Materials', - description: `FlowFuse platform automation tool: - Reads the bill of materials for a single application: its instances and their dependencies. - This is plan-gated on the bom feature, which defaults to disabled; if the team's plan - has this feature disabled, the tool reports that the bill of materials is not enabled - for this team rather than the raw platform error.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - applicationId - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}/bom` }) - return response - } - } -] diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index 9db5c24e6b..f00fc58a5b 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -53,11 +53,7 @@ const IMPLICIT_TOKEN_SCOPES = { 'flow-blueprint:list', 'project:status', 'template:list', - // observability - 'team:audit-log', // get team audit log - 'project:audit-log', // get hosted instance audit log - 'device:audit-log', // get remote instance audit log - 'project:history', // get hosted instance or remote instance history + // bill of materials 'team:bom', // get team bill of materials 'application:bom' // get application bill of materials ] diff --git a/test/unit/forge/ee/lib/mcp/tools/bom_spec.js b/test/unit/forge/ee/lib/mcp/tools/bom_spec.js new file mode 100644 index 0000000000..8db4d5f8ba --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/bom_spec.js @@ -0,0 +1,132 @@ +const should = require('should') // eslint-disable-line no-unused-vars +const sinon = require('sinon') + +const { expectToolMatchesRoute, createExpertMcpToken, toolFinder } = require('../../../../../../lib/mcpToolEquivalence') + +const FF_UTIL = require('flowforge-test-utils') + +const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/bom') +const findTool = toolFinder(tools) + +describe('MCP BOM Tools', function () { + function stubInject (response = { statusCode: 200, json: () => ({}) }) { + return sinon.stub().resolves(response) + } + + const toolDefinitions = [ + { + name: 'platform_get_team_bom', + param: 'teamId', + base: id => `/api/v1/teams/${id}/bom`, + queryKeys: null + }, + { + name: 'platform_get_application_bom', + param: 'applicationId', + base: id => `/api/v1/applications/${id}/bom`, + queryKeys: null + } + ] + + toolDefinitions.forEach(def => { + describe(def.name, function () { + it('is registered as a read-only, non-destructive tool', function () { + const tool = findTool(def.name) + tool.annotations.should.have.property('readOnlyHint', true) + tool.annotations.should.have.property('destructiveHint', false) + }) + + it('exposes the expected inputSchema keys', function () { + const tool = findTool(def.name) + const expectedKeys = [def.param, ...(def.queryKeys || [])] + Object.keys(tool.inputSchema).sort().should.deepEqual(expectedKeys.sort()) + }) + + it('calls inject with the correct method and bare URL when no query params are given', async function () { + const tool = findTool(def.name) + const inject = stubInject() + const args = { [def.param]: 'abc123' } + + await tool.handler(args, { inject }) + + inject.calledOnce.should.be.true() + const call = inject.firstCall.args[0] + call.method.should.equal('GET') + call.url.should.equal(def.base('abc123')) + }) + }) + }) + + describe('pass-through response handling', function () { + it('returns the raw inject response unchanged, without intercepting 404s', async function () { + const tool = findTool('platform_get_team_bom') + const notFoundResponse = { statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) } + const inject = stubInject(notFoundResponse) + + const result = await tool.handler({ teamId: 'team1' }, { inject }) + + result.should.equal(notFoundResponse) + }) + }) +}) + +describe('MCP BOM Tools - integration smoke', function () { + const setup = require('../../../setup') + + let app + let token + + before(async function () { + app = await setup({ + ai: { enabled: true }, + expert: { enabled: true } + }) + token = await createExpertMcpToken(app) + }) + + after(async function () { + await app.close() + }) + + function inject (options) { + return app.inject({ + ...options, + headers: { + ...(options.headers || {}), + authorization: `Bearer ${token}` + } + }) + } + + describe('tool-vs-route equivalence', function () { + let team + let application + + before(async function () { + team = app.team + application = app.application + + // Enable the plan-gated bom feature so equivalence tests hit the real 200, not the "disabled" guard. + const defaultTeamTypeProperties = app.defaultTeamType.properties + defaultTeamTypeProperties.features.bom = true + app.defaultTeamType.properties = defaultTeamTypeProperties + await app.defaultTeamType.save() + }) + + it('platform_get_team_bom matches GET /api/v1/teams/:teamId/bom', async function () { + const tool = findTool('platform_get_team_bom') + await expectToolMatchesRoute(inject, tool, { teamId: team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${team.hashid}/bom` + }) + }) + + it('platform_get_application_bom matches GET /api/v1/applications/:applicationId/bom', async function () { + const tool = findTool('platform_get_application_bom') + await expectToolMatchesRoute(inject, tool, { applicationId: application.hashid }, { + method: 'GET', + url: `/api/v1/applications/${application.hashid}/bom` + }) + }) + }) +}) diff --git a/test/unit/forge/ee/lib/mcp/tools/observability_spec.js b/test/unit/forge/ee/lib/mcp/tools/observability_spec.js deleted file mode 100644 index 9aee8d13bb..0000000000 --- a/test/unit/forge/ee/lib/mcp/tools/observability_spec.js +++ /dev/null @@ -1,367 +0,0 @@ -const should = require('should') // eslint-disable-line no-unused-vars -const sinon = require('sinon') - -const { expectToolMatchesRoute, createExpertMcpToken, toolFinder } = require('../../../../../../lib/mcpToolEquivalence') - -const FF_UTIL = require('flowforge-test-utils') - -const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/observability') -const { basePaginationKeys, searchQueryKeys, auditLogFilterKeys } = FF_UTIL.require('forge/ee/lib/mcp/schemas') -const findTool = toolFinder(tools) - -// audit-log routes honor cursor+limit, free-text query, and event/username. -// scope + includeChildren are honored on team/instance/application routes but -// not the device route, so they are listed per tool below. -const auditLogKeys = [...basePaginationKeys, ...searchQueryKeys, ...auditLogFilterKeys] -const auditLogScopedKeys = [...auditLogKeys, 'scope', 'includeChildren'] - -describe('MCP Observability Tools', function () { - function stubInject (response = { statusCode: 200, json: () => ({}) }) { - return sinon.stub().resolves(response) - } - - const toolDefinitions = [ - { - name: 'platform_get_team_audit_log', - param: 'teamId', - base: id => `/api/v1/teams/${id}/audit-log`, - queryKeys: auditLogScopedKeys - }, - { - name: 'platform_export_team_audit_log', - param: 'teamId', - base: id => `/api/v1/teams/${id}/audit-log/export`, - queryKeys: auditLogScopedKeys - }, - { - name: 'platform_get_hosted_instance_audit_log', - param: 'hostedInstanceId', - base: id => `/api/v1/projects/${id}/audit-log`, - queryKeys: auditLogScopedKeys - }, - { - name: 'platform_export_hosted_instance_audit_log', - param: 'hostedInstanceId', - base: id => `/api/v1/projects/${id}/audit-log/export`, - queryKeys: auditLogScopedKeys - }, - { - name: 'platform_get_remote_instance_audit_log', - param: 'remoteInstanceId', - base: id => `/api/v1/devices/${id}/audit-log`, - queryKeys: auditLogKeys - }, - { - name: 'platform_export_remote_instance_audit_log', - param: 'remoteInstanceId', - base: id => `/api/v1/devices/${id}/audit-log/export`, - queryKeys: auditLogKeys - }, - { - name: 'platform_export_application_audit_log', - param: 'applicationId', - base: id => `/api/v1/applications/${id}/audit-log/export`, - queryKeys: auditLogScopedKeys - }, - { - name: 'platform_get_hosted_instance_history', - param: 'hostedInstanceId', - base: id => `/api/v1/projects/${id}/history`, - queryKeys: basePaginationKeys - }, - { - name: 'platform_get_remote_instance_history', - param: 'remoteInstanceId', - base: id => `/api/v1/devices/${id}/history`, - queryKeys: basePaginationKeys - }, - { - name: 'platform_get_hosted_instance_resources', - param: 'hostedInstanceId', - base: id => `/api/v1/projects/${id}/resources`, - queryKeys: null - }, - { - name: 'platform_get_team_bom', - param: 'teamId', - base: id => `/api/v1/teams/${id}/bom`, - queryKeys: null - }, - { - name: 'platform_get_application_bom', - param: 'applicationId', - base: id => `/api/v1/applications/${id}/bom`, - queryKeys: null - } - ] - - toolDefinitions.forEach(def => { - describe(def.name, function () { - it('is registered as a read-only, non-destructive tool', function () { - const tool = findTool(def.name) - tool.annotations.should.have.property('readOnlyHint', true) - tool.annotations.should.have.property('destructiveHint', false) - }) - - it('exposes the expected inputSchema keys', function () { - const tool = findTool(def.name) - const expectedKeys = [def.param, ...(def.queryKeys || [])] - Object.keys(tool.inputSchema).sort().should.deepEqual(expectedKeys.sort()) - }) - - it('calls inject with the correct method and bare URL when no query params are given', async function () { - const tool = findTool(def.name) - const inject = stubInject() - const args = { [def.param]: 'abc123' } - - await tool.handler(args, { inject }) - - inject.calledOnce.should.be.true() - const call = inject.firstCall.args[0] - call.method.should.equal('GET') - call.url.should.equal(def.base('abc123')) - }) - - if (def.queryKeys) { - it('serialises pagination params onto the URL', async function () { - const tool = findTool(def.name) - const inject = stubInject() - // limit is honored by every paginated tool here; query is not - // (the history routes take only cursor/limit), so assert on limit. - const args = { [def.param]: 'abc123', limit: 10 } - - await tool.handler(args, { inject }) - - const call = inject.firstCall.args[0] - call.url.should.equal(`${def.base('abc123')}?limit=10`) - }) - } - }) - }) - - describe('audit-log tools', function () { - it('serialises an array `event` filter as one repeated query param per element', async function () { - const tool = findTool('platform_get_team_audit_log') - const inject = stubInject() - const args = { teamId: 'team1', event: ['user.login', 'user.logout'], limit: 5 } - - await tool.handler(args, { inject }) - - const call = inject.firstCall.args[0] - call.url.should.equal('/api/v1/teams/team1/audit-log?limit=5&event=user.login&event=user.logout') - }) - - it('serialises the `username` filter alongside other query params', async function () { - const tool = findTool('platform_get_hosted_instance_audit_log') - const inject = stubInject() - const args = { hostedInstanceId: 'instance1', username: 'alice', limit: 20 } - - await tool.handler(args, { inject }) - - const call = inject.firstCall.args[0] - call.url.should.equal('/api/v1/projects/instance1/audit-log?limit=20&username=alice') - }) - - it('serialises the scope and includeChildren filters where the route supports them', async function () { - const tool = findTool('platform_get_team_audit_log') - const inject = stubInject() - const args = { teamId: 'team1', scope: 'application', includeChildren: true } - - await tool.handler(args, { inject }) - - const call = inject.firstCall.args[0] - call.url.should.equal('/api/v1/teams/team1/audit-log?scope=application&includeChildren=true') - }) - }) - - describe('pass-through response handling', function () { - it('returns the raw inject response unchanged, without intercepting 404s', async function () { - const tool = findTool('platform_get_hosted_instance_resources') - const notFoundResponse = { statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) } - const inject = stubInject(notFoundResponse) - - const result = await tool.handler({ hostedInstanceId: 'instance1' }, { inject }) - - result.should.equal(notFoundResponse) - }) - }) -}) - -describe('MCP Observability Tools - integration smoke', function () { - const setup = require('../../../setup') - - let app - let token - - before(async function () { - app = await setup({ - ai: { enabled: true }, - expert: { enabled: true } - }) - token = await createExpertMcpToken(app) - }) - - after(async function () { - await app.close() - }) - - function inject (options) { - return app.inject({ - ...options, - headers: { - ...(options.headers || {}), - authorization: `Bearer ${token}` - } - }) - } - - it('reads the team audit log via the underlying route', async function () { - const tool = tools.find(t => t.name === 'platform_get_team_audit_log') - - const response = await tool.handler({ teamId: app.team.hashid }, { inject }) - - response.statusCode.should.equal(200) - const body = response.json() - body.should.have.property('meta') - body.should.have.property('count') - body.should.have.property('log') - body.log.should.be.an.Array() - }) - - describe('tool-vs-route equivalence', function () { - let team - let instance - let application - let device - - before(async function () { - team = app.team - instance = app.instance - application = app.application - - // Enable plan-gated features so equivalence tests hit the real 200, not the "disabled" guard. - const defaultTeamTypeProperties = app.defaultTeamType.properties - defaultTeamTypeProperties.features.bom = true - app.defaultTeamType.properties = defaultTeamTypeProperties - await app.defaultTeamType.save() - - device = await app.factory.createDevice({ name: 'observability-device' }, team, instance, application) - - // Seed one real entry per scope so assertions compare actual data, not an empty-list shape. - await app.db.controllers.AuditLog.teamLog(team.id, app.user.id, 'team.settings.updated', { settings: { name: team.name } }) - await app.db.controllers.AuditLog.projectLog(instance.id, app.user.id, 'flows.set', { type: 'flows' }) - await app.db.controllers.AuditLog.deviceLog(device.id, app.user.id, 'flows.set', { type: 'flows' }) - - await instance.update({ versions: { 'node-red': { wanted: '4.0.3', current: '4.0.2' } } }) - }) - - it('platform_get_team_audit_log matches GET /api/v1/teams/:teamId/audit-log, including query params', async function () { - const tool = findTool('platform_get_team_audit_log') - await expectToolMatchesRoute(inject, tool, { teamId: team.hashid, event: 'team.settings.updated', limit: 5 }, { - method: 'GET', - url: `/api/v1/teams/${team.hashid}/audit-log?limit=5&event=team.settings.updated` - }) - }) - - it('platform_export_team_audit_log matches GET /api/v1/teams/:teamId/audit-log/export', async function () { - const tool = findTool('platform_export_team_audit_log') - await expectToolMatchesRoute(inject, tool, { teamId: team.hashid }, { - method: 'GET', - url: `/api/v1/teams/${team.hashid}/audit-log/export`, - raw: true - }) - }) - - it('platform_get_hosted_instance_audit_log matches GET /api/v1/projects/:instanceId/audit-log, including query params', async function () { - const tool = findTool('platform_get_hosted_instance_audit_log') - await expectToolMatchesRoute(inject, tool, { hostedInstanceId: instance.id, username: app.user.username, limit: 5 }, { - method: 'GET', - url: `/api/v1/projects/${instance.id}/audit-log?limit=5&username=${app.user.username}` - }) - }) - - it('platform_export_hosted_instance_audit_log matches GET /api/v1/projects/:instanceId/audit-log/export', async function () { - const tool = findTool('platform_export_hosted_instance_audit_log') - await expectToolMatchesRoute(inject, tool, { hostedInstanceId: instance.id }, { - method: 'GET', - url: `/api/v1/projects/${instance.id}/audit-log/export`, - raw: true - }) - }) - - it('platform_get_remote_instance_audit_log matches GET /api/v1/devices/:deviceId/audit-log', async function () { - const tool = findTool('platform_get_remote_instance_audit_log') - await expectToolMatchesRoute(inject, tool, { remoteInstanceId: device.hashid }, { - method: 'GET', - url: `/api/v1/devices/${device.hashid}/audit-log` - }) - }) - - it('platform_export_remote_instance_audit_log matches GET /api/v1/devices/:deviceId/audit-log/export', async function () { - const tool = findTool('platform_export_remote_instance_audit_log') - await expectToolMatchesRoute(inject, tool, { remoteInstanceId: device.hashid }, { - method: 'GET', - url: `/api/v1/devices/${device.hashid}/audit-log/export`, - raw: true - }) - }) - - it('platform_export_application_audit_log matches GET /api/v1/applications/:applicationId/audit-log/export', async function () { - const tool = findTool('platform_export_application_audit_log') - await expectToolMatchesRoute(inject, tool, { applicationId: application.hashid }, { - method: 'GET', - url: `/api/v1/applications/${application.hashid}/audit-log/export`, - raw: true - }) - }) - - it('platform_get_hosted_instance_history matches GET /api/v1/projects/:instanceId/history', async function () { - const tool = findTool('platform_get_hosted_instance_history') - await expectToolMatchesRoute(inject, tool, { hostedInstanceId: instance.id }, { - method: 'GET', - url: `/api/v1/projects/${instance.id}/history` - }) - }) - - it('platform_get_remote_instance_history matches GET /api/v1/devices/:deviceId/history', async function () { - const tool = findTool('platform_get_remote_instance_history') - await expectToolMatchesRoute(inject, tool, { remoteInstanceId: device.hashid }, { - method: 'GET', - url: `/api/v1/devices/${device.hashid}/history` - }) - }) - - it('platform_get_hosted_instance_resources matches GET /api/v1/projects/:instanceId/resources', async function () { - // The stub container driver returns fresh random point-in-time values on - // every call, so bodies are never byte-identical; normalize to compare shape only. - const shapeOnly = (body) => ({ - meta: body.meta, - count: body.count, - resourcesLength: body.resources.length, - resourceKeys: body.resources[0] && Object.keys(body.resources[0]).sort() - }) - const tool = findTool('platform_get_hosted_instance_resources') - await expectToolMatchesRoute(inject, tool, { hostedInstanceId: instance.id }, { - method: 'GET', - url: `/api/v1/projects/${instance.id}/resources`, - normalize: shapeOnly - }) - }) - - it('platform_get_team_bom matches GET /api/v1/teams/:teamId/bom', async function () { - const tool = findTool('platform_get_team_bom') - await expectToolMatchesRoute(inject, tool, { teamId: team.hashid }, { - method: 'GET', - url: `/api/v1/teams/${team.hashid}/bom` - }) - }) - - it('platform_get_application_bom matches GET /api/v1/applications/:applicationId/bom', async function () { - const tool = findTool('platform_get_application_bom') - await expectToolMatchesRoute(inject, tool, { applicationId: application.hashid }, { - method: 'GET', - url: `/api/v1/applications/${application.hashid}/bom` - }) - }) - }) -})