fix composite PartitionKey leaking into event TenantId + add MCP integration test suite

MapToEnrollmentEvent was returning the composite PartitionKey ({TenantId}_{SessionId})
as TenantId instead of extracting the actual tenant ID. Now prefers the stored TenantId
property with fallback extraction from the composite key.

Also adds a Vitest-based integration test suite (39 tests) covering all MCP tool endpoints:
- Session tools (search, get, events, analysis, CVE, blocked devices)
- Admin tools (metrics, geographic, platform, usage, audit, ops, reports)
- Raw data tools (tables, raw sessions/events, backend logs)
- Read-only verification (PUT/DELETE rejection)
- Error handling (404, 400, empty results)

Run with: AUTOPILOT_API_TOKEN="eyJ..." npm test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-03 13:12:44 +02:00
parent e940273a89
commit 60b8372de0
8 changed files with 1740 additions and 4 deletions
@@ -1511,11 +1511,21 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
private EnrollmentEvent MapToEnrollmentEvent(TableEntity entity)
{
// Events table uses composite PartitionKey: "{TenantId}_{SessionId}".
// Prefer the stored TenantId property; fall back to extracting from the composite key.
var partitionKey = entity.PartitionKey ?? string.Empty;
var tenantId = entity.GetString("TenantId");
if (string.IsNullOrEmpty(tenantId) && partitionKey.Contains('_'))
{
var separatorIndex = partitionKey.IndexOf('_');
tenantId = partitionKey.Substring(0, separatorIndex);
}
return new EnrollmentEvent
{
EventId = entity.GetString("EventId") ?? string.Empty,
SessionId = entity.GetString("SessionId") ?? string.Empty,
TenantId = entity.PartitionKey,
TenantId = tenantId ?? partitionKey,
Timestamp = DateTime.SpecifyKind(
entity.GetDateTimeOffset("Timestamp")?.UtcDateTime
?? entity.GetDateTime("Timestamp")
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,9 @@
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"docker:build": "docker build -f Dockerfile -t autopilotmonitor-mcp:local ../../../",
"docker:run": "docker run --rm -p 8080:8080 -e AUTOPILOT_API_URL=https://autopilotmonitor-api.azurewebsites.net autopilotmonitor-mcp:local"
"docker:run": "docker run --rm -p 8080:8080 -e AUTOPILOT_API_URL=https://autopilotmonitor-api.azurewebsites.net autopilotmonitor-mcp:local",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
@@ -25,6 +27,7 @@
"@types/express": "^5.0.6",
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
"typescript": "^5.0.0",
"vitest": "^4.1.2"
}
}
@@ -0,0 +1,79 @@
/**
* Shared test helpers for MCP integration tests.
*
* These tests call the real backend API — they require a valid Bearer token
* provided via the AUTOPILOT_API_TOKEN environment variable.
*
* Usage:
* AUTOPILOT_API_TOKEN="eyJ..." npm test
*/
const BASE_URL = process.env.AUTOPILOT_API_URL ?? 'https://autopilotmonitor-api.azurewebsites.net';
const TOKEN = process.env.AUTOPILOT_API_TOKEN ?? '';
export function getToken(): string {
if (!TOKEN) {
throw new Error(
'AUTOPILOT_API_TOKEN environment variable is required.\n' +
'Get a token from the browser (F12 → Network → copy Authorization header value) and run:\n' +
' AUTOPILOT_API_TOKEN="eyJ..." npm test',
);
}
return TOKEN;
}
export function getBaseUrl(): string {
return BASE_URL;
}
interface ApiFetchOptions extends RequestInit {
expectStatus?: number;
}
/**
* Lightweight fetch wrapper for integration tests.
* Returns parsed JSON on success, throws on unexpected status.
*/
export async function apiFetch<T = unknown>(path: string, options: ApiFetchOptions = {}): Promise<T> {
const { expectStatus, ...fetchOpts } = options;
const url = `${BASE_URL}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
'X-Client-Source': 'mcp-test',
...((fetchOpts.headers as Record<string, string>) ?? {}),
};
const res = await fetch(url, { ...fetchOpts, headers, signal: AbortSignal.timeout(25_000) });
if (expectStatus !== undefined) {
if (res.status !== expectStatus) {
const body = await res.text().catch(() => '(no body)');
throw new Error(`Expected status ${expectStatus}, got ${res.status}: ${body.slice(0, 300)}`);
}
// For non-2xx expected statuses, try to parse the JSON body anyway
const text = await res.text();
try {
return JSON.parse(text) as T;
} catch {
return text as unknown as T;
}
}
if (!res.ok) {
const body = await res.text().catch(() => '(no body)');
throw new Error(`API ${res.status}: ${body.slice(0, 500)}`);
}
return res.json() as Promise<T>;
}
/** Build query string from params, skipping null/undefined values. */
export function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') p.set(k, String(v));
}
const s = p.toString();
return s ? `?${s}` : '';
}
@@ -0,0 +1,137 @@
/**
* Integration tests for MCP admin and metrics tools.
* Tests against the live backend — requires AUTOPILOT_API_TOKEN env var.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { apiFetch, getToken } from './helpers.js';
beforeAll(() => {
getToken();
});
describe('get_metrics', () => {
it('should return global enrollment metrics', async () => {
const data = await apiFetch<any>(`/api/global/metrics/summary?days=30`);
expect(data.success).toBe(true);
expect(Array.isArray(data.summary)).toBe(true);
if (data.summary.length > 0) {
expect(data.summary[0]).toHaveProperty('tenantId');
expect(data.summary[0]).toHaveProperty('totalSessions');
expect(data.summary[0]).toHaveProperty('failureRate');
}
});
it('should return app metrics', async () => {
const data = await apiFetch<any>(`/api/global/metrics/app?days=30`);
expect(data.success).toBe(true);
expect(data).toHaveProperty('totalApps');
expect(data).toHaveProperty('slowestApps');
expect(data).toHaveProperty('topFailingApps');
});
});
describe('get_usage_metrics', () => {
it('should return platform usage overview', async () => {
const data = await apiFetch<any>(`/api/global/metrics/usage`);
expect(data).toHaveProperty('sessions');
expect(data).toHaveProperty('tenants');
expect(data).toHaveProperty('users');
expect(data.sessions).toHaveProperty('total');
expect(data.tenants).toHaveProperty('total');
});
});
describe('get_geographic_metrics', () => {
it('should return geographic distribution (by country)', async () => {
const data = await apiFetch<any>(`/api/global/metrics/geographic?groupBy=country&days=30`);
expect(data.success).toBe(true);
expect(Array.isArray(data.locations)).toBe(true);
if (data.locations.length > 0) {
const loc = data.locations[0];
expect(loc).toHaveProperty('locationKey');
expect(loc).toHaveProperty('sessionCount');
expect(loc).toHaveProperty('successRate');
}
});
});
describe('get_geographic_sessions', () => {
it('should return sessions for a country', async () => {
const data = await apiFetch<any>(`/api/global/metrics/geographic/sessions?country=DE&days=7`);
expect(data.success).toBe(true);
expect(Array.isArray(data.sessions)).toBe(true);
});
});
describe('get_platform_metrics', () => {
it('should return agent performance metrics', async () => {
const data = await apiFetch<any>(`/api/global/metrics/platform`);
expect(data).toHaveProperty('sessionsAnalyzed');
expect(data).toHaveProperty('cpu');
expect(data).toHaveProperty('memory');
expect(data).toHaveProperty('network');
expect(data.cpu).toHaveProperty('avgPercent');
expect(data.memory).toHaveProperty('avgWorkingSetMB');
});
});
describe('get_api_usage', () => {
it('should return daily usage summary', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/daily`);
expect(Array.isArray(data.summaries)).toBe(true);
if (data.summaries.length > 0) {
expect(data.summaries[0]).toHaveProperty('date');
expect(data.summaries[0]).toHaveProperty('totalRequests');
}
});
it('should return global per-record usage', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage`);
// Response structure depends on implementation, just verify no error
expect(data).toBeDefined();
});
});
describe('get_audit_logs', () => {
it('should return audit log entries (cross-tenant)', async () => {
const data = await apiFetch<any>(`/api/global/audit/logs`);
expect(data.success).toBe(true);
expect(Array.isArray(data.logs)).toBe(true);
if (data.logs.length > 0) {
const log = data.logs[0];
expect(log).toHaveProperty('action');
expect(log).toHaveProperty('entityType');
expect(log).toHaveProperty('performedBy');
expect(log).toHaveProperty('timestamp');
}
});
});
describe('get_ops_events', () => {
it('should return ops events', async () => {
const data = await apiFetch<any>(`/api/global/ops-events?maxResults=5`);
expect(data.success).toBe(true);
expect(Array.isArray(data.events)).toBe(true);
});
it('should filter by category', async () => {
const data = await apiFetch<any>(`/api/global/ops-events?category=Security&maxResults=5`);
expect(data.success).toBe(true);
for (const evt of data.events) {
expect(evt.category).toBe('Security');
}
});
});
describe('list_session_reports', () => {
it('should return session reports', async () => {
const data = await apiFetch<any>(`/api/global/session-reports`);
expect(data.success).toBe(true);
expect(Array.isArray(data.reports)).toBe(true);
if (data.reports.length > 0) {
expect(data.reports[0]).toHaveProperty('reportId');
expect(data.reports[0]).toHaveProperty('sessionId');
expect(data.reports[0]).toHaveProperty('tenantId');
}
});
});
@@ -0,0 +1,140 @@
/**
* Integration tests for MCP raw data tools (tables, raw sessions/events, backend logs).
* Tests against the live backend — requires AUTOPILOT_API_TOKEN env var.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { apiFetch, getToken } from './helpers.js';
let knownTenantId: string;
beforeAll(async () => {
getToken();
// Discover a tenant with data
const data = await apiFetch<any>(`/api/global/search/sessions?limit=1`);
knownTenantId = data.sessions[0]?.tenantId ?? '';
});
describe('list_tables', () => {
it('should return list of Azure Table Storage tables', async () => {
const data = await apiFetch<any>(`/api/global/raw/tables`);
expect(data).toHaveProperty('tables');
expect(Array.isArray(data.tables)).toBe(true);
expect(data.tables.length).toBeGreaterThan(10);
expect(data.tables).toContain('Sessions');
expect(data.tables).toContain('Events');
expect(data.tables).toContain('TenantConfiguration');
});
});
describe('query_table', () => {
it('should query TenantConfiguration table', async () => {
const data = await apiFetch<any>(`/api/global/raw/tables/TenantConfiguration?limit=2`);
expect(data).toHaveProperty('entities');
expect(Array.isArray(data.entities)).toBe(true);
if (data.entities.length > 0) {
expect(data.entities[0]).toHaveProperty('PartitionKey');
expect(data.entities[0]).toHaveProperty('RowKey');
}
});
it('should return error for non-existent table', async () => {
const data = await apiFetch<any>(
`/api/global/raw/tables/ZZZ_NonExistent_Table`,
{ expectStatus: 404 },
);
// Should be a structured error
expect(data).toBeDefined();
});
it('should filter by partitionKey', async () => {
if (!knownTenantId) return;
const data = await apiFetch<any>(
`/api/global/raw/tables/Sessions?partitionKey=${knownTenantId}&limit=2`,
);
expect(data).toHaveProperty('entities');
for (const entity of data.entities) {
expect(entity.PartitionKey).toBe(knownTenantId);
}
});
});
describe('query_raw_sessions', () => {
it('should return raw sessions (cross-tenant)', async () => {
const data = await apiFetch<any>(`/api/global/raw/sessions?limit=3`);
expect(data).toHaveProperty('sessions');
expect(Array.isArray(data.sessions)).toBe(true);
expect(data.sessions.length).toBeLessThanOrEqual(3);
});
it('should filter by status', async () => {
const data = await apiFetch<any>(`/api/global/raw/sessions?status=Succeeded&limit=2`);
for (const s of data.sessions) {
expect(s.status).toBe('Succeeded');
}
});
it('should support field projection', async () => {
const data = await apiFetch<any>(
`/api/global/raw/sessions?fields=sessionId,status&limit=2`,
);
expect(data.sessions.length).toBeGreaterThan(0);
const s = data.sessions[0];
expect(s).toHaveProperty('sessionId');
expect(s).toHaveProperty('status');
});
});
describe('query_raw_events', () => {
it('should return events for a tenant', async () => {
if (!knownTenantId) return;
const data = await apiFetch<any>(
`/api/raw/events?tenantId=${knownTenantId}&eventType=enrollment_complete&limit=2`,
);
expect(data).toHaveProperty('events');
expect(Array.isArray(data.events)).toBe(true);
});
});
describe('query_backend_logs', () => {
it('should execute KQL query (POST)', async () => {
const data = await apiFetch<any>(`/api/global/raw/logs`, {
method: 'POST',
body: JSON.stringify({
query: 'traces | take 1',
timespan: 'PT1H',
}),
});
expect(data).toHaveProperty('tables');
expect(Array.isArray(data.tables)).toBe(true);
expect(data.tables[0]).toHaveProperty('columns');
expect(data.tables[0]).toHaveProperty('rows');
});
});
describe('read-only verification', () => {
it('should reject PUT on session endpoint', async () => {
// Verify the API doesn't accept mutation methods on read endpoints
try {
await apiFetch(`/api/sessions/00000000-0000-0000-0000-000000000001`, {
method: 'PUT',
body: JSON.stringify({ status: 'Failed' }),
});
// If it doesn't throw, the endpoint exists for PUT — that would be unexpected
expect.fail('PUT should not succeed on a read endpoint');
} catch (e: any) {
// Expected: 404 (no route) or 405 (method not allowed) or similar
expect(e.message).toMatch(/API (404|405|400)/);
}
});
it('should reject DELETE on session events endpoint', async () => {
try {
await apiFetch(`/api/sessions/00000000-0000-0000-0000-000000000001/events`, {
method: 'DELETE',
});
expect.fail('DELETE should not succeed on a read endpoint');
} catch (e: any) {
expect(e.message).toMatch(/API (404|405|400)/);
}
});
});
@@ -0,0 +1,164 @@
/**
* Integration tests for MCP session tools.
* Tests against the live backend — requires AUTOPILOT_API_TOKEN env var.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { apiFetch, buildQuery, getToken } from './helpers.js';
// Shared state discovered during tests
let knownSessionId: string;
let knownTenantId: string;
beforeAll(() => {
getToken(); // Fail fast if no token
});
describe('search_sessions', () => {
it('should return sessions (cross-tenant, no filters)', async () => {
const data = await apiFetch<any>(`/api/global/search/sessions?limit=3`);
expect(data.success).toBe(true);
expect(Array.isArray(data.sessions)).toBe(true);
expect(data.sessions.length).toBeGreaterThan(0);
expect(data.sessions.length).toBeLessThanOrEqual(3);
// Store for subsequent tests
knownSessionId = data.sessions[0].sessionId;
knownTenantId = data.sessions[0].tenantId;
// Validate session shape
const s = data.sessions[0];
expect(s).toHaveProperty('sessionId');
expect(s).toHaveProperty('tenantId');
expect(s).toHaveProperty('status');
expect(s).toHaveProperty('serialNumber');
});
it('should filter by status', async () => {
const data = await apiFetch<any>(`/api/global/search/sessions?status=Failed&limit=2`);
expect(data.success).toBe(true);
for (const s of data.sessions) {
expect(s.status).toBe('Failed');
}
});
it('should return empty for impossible filter', async () => {
const data = await apiFetch<any>(
`/api/global/search/sessions?serialNumber=ZZZZZ_NONEXISTENT_99999&limit=1`,
);
expect(data.success).toBe(true);
expect(data.sessions).toHaveLength(0);
});
});
describe('search_sessions_by_event', () => {
it('should find sessions with enrollment_complete events', async () => {
const data = await apiFetch<any>(
`/api/global/search/sessions-by-event?eventType=enrollment_complete&limit=3`,
);
expect(data.success).toBe(true);
expect(Array.isArray(data.sessions)).toBe(true);
});
it('should return empty for unknown event type', async () => {
const data = await apiFetch<any>(
`/api/global/search/sessions-by-event?eventType=totally_fake_event&limit=1`,
);
expect(data.success).toBe(true);
expect(data.count).toBe(0);
});
});
describe('get_session', () => {
it('should return session details', async () => {
const data = await apiFetch<any>(`/api/sessions/${knownSessionId}`);
expect(data.success).toBe(true);
expect(data.session).toBeDefined();
expect(data.session.sessionId).toBe(knownSessionId);
});
it('should return 404 for non-existent session', async () => {
const data = await apiFetch<any>(
`/api/sessions/00000000-0000-0000-0000-000000000000`,
{ expectStatus: 404 },
);
expect(data.success).toBe(false);
});
it('should reject invalid UUID format', async () => {
const data = await apiFetch<any>(
`/api/sessions/not-a-guid`,
{ expectStatus: 400 },
);
expect(data.success).toBe(false);
});
});
describe('get_session_events', () => {
it('should return events for a session', async () => {
const data = await apiFetch<any>(`/api/sessions/${knownSessionId}/events`);
expect(data.success).toBe(true);
expect(data.sessionId).toBe(knownSessionId);
expect(Array.isArray(data.events)).toBe(true);
expect(data.count).toBeGreaterThan(0);
});
it('should have correct tenantId (not composite key)', async () => {
const data = await apiFetch<any>(`/api/sessions/${knownSessionId}/events`);
expect(data.events.length).toBeGreaterThan(0);
const event = data.events[0];
// TenantId should NOT contain underscore + sessionId (composite key bug)
expect(event.tenantId).not.toContain('_');
expect(event.tenantId).toHaveLength(36); // UUID length
});
it('should return events for cross-tenant session without tenantId (Global Admin fallback)', async () => {
// This tests the cross-tenant fix we deployed
const data = await apiFetch<any>(`/api/sessions/${knownSessionId}/events`);
expect(data.success).toBe(true);
expect(data.count).toBeGreaterThan(0);
});
it('should return 0 events for non-existent session (not an error)', async () => {
// A valid GUID that doesn't exist should return success with 0 events
const data = await apiFetch<any>(
`/api/sessions/00000000-0000-0000-0000-000000000001/events`,
);
expect(data.success).toBe(true);
expect(data.count).toBe(0);
});
});
describe('get_session_analysis', () => {
it('should return analysis results', async () => {
const data = await apiFetch<any>(`/api/sessions/${knownSessionId}/analysis`);
expect(data.success).toBe(true);
expect(data.sessionId).toBe(knownSessionId);
expect(Array.isArray(data.results)).toBe(true);
});
});
describe('search_sessions_by_cve', () => {
it('should return results (possibly empty) for a CVE search', async () => {
const data = await apiFetch<any>(
`/api/global/search/sessions-by-cve?cveId=CVE-2024-21447&limit=3`,
);
expect(data.success).toBe(true);
expect(Array.isArray(data.sessions)).toBe(true);
});
it('should return empty for nonexistent CVE', async () => {
const data = await apiFetch<any>(
`/api/global/search/sessions-by-cve?cveId=CVE-9999-99999&limit=1`,
);
expect(data.success).toBe(true);
expect(data.count).toBe(0);
});
});
describe('list_blocked_devices', () => {
it('should return blocked devices list (cross-tenant)', async () => {
const data = await apiFetch<any>(`/api/global/devices/blocked`);
expect(data.success).toBe(true);
expect(Array.isArray(data.blocked)).toBe(true);
});
});
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
testTimeout: 30_000,
hookTimeout: 10_000,
},
});