test(mcp): cover auth core — accessGuard, token claims, oauth, GA-gating

Close the regression-coverage gaps flagged in the MCP review (H1, tests-1..7).
The auth core was correct but entirely untested; a regression that inverted a
fail-closed decision or moved next() into the error branch would silently open
the server with no failing test. All new tests run without a live backend or
token (stubbed fetch, hand-built JWTs, ephemeral express server).

- access-guard.test.ts: 401/403/429 decisions, fail-closed paths (fetch throws /
  empty body / non-boolean allowed -> denied), allow-path caller context, cache,
  F1 forged-token distinct-key. (H1, tests-1, tests-2)
- auth.test.ts: extractTokenClaims + isTokenExpired incl. 60s-skew boundary. (tests-3)
- auth.ts: comment documenting aud is parsed but enforced by the backend
  (AuthenticationMiddleware.cs:200 ValidateAudience=true); no behavior change. (tests-4)
- oauth-register.test.ts: /oauth/register caps + cap-503, PKCE-S256 enforcement on
  /oauth/authorize, code_verifier requirement on /oauth/token. (tests-5)
- client-routing.test.ts: pickGlobalOrTenantPath GA-gating + runWithCaller isolation. (tests-6)
- tools-admin/raw/sessions: describe.skipIf(no token) so integration suites skip
  cleanly unattended instead of throwing in beforeAll. (tests-7)

npm test (no token): 100 passed / 0 failed / 39 skipped (was 3 suites failing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-29 16:39:22 +02:00
parent f562469229
commit 40fa171184
8 changed files with 685 additions and 25 deletions
@@ -0,0 +1,278 @@
/**
* Unit tests for the MCP access guard — the security-critical request gate
* (review findings H1 / tests-1 / tests-2).
*
* accessGuard decides 401 / 403 / 429 and the next()-allow path; checkAccess
* (exercised through it) is the fail-closed backend check. The code is correct
* today — these tests exist purely to catch a regression that would open the
* server: e.g. inverting `!result.allowed`, moving next() into the fail branch,
* or dropping the fail-closed default on a backend error. With scale-to-zero,
* "backend unreachable" is a routine condition, not an edge case.
*
* No live backend or real token is needed: fetch is stubbed and JWTs are
* hand-built (the proxy does not verify signatures — the backend does).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Request, Response, NextFunction } from 'express';
// RATE_LIMIT is read from the environment at module import. Set a low cap so
// the 429 path is cheap to drive, BEFORE the dynamic import resolves.
process.env.MCP_RATE_LIMIT_PER_MINUTE = '2';
const { accessGuard } = await import('../access-guard.js');
// Same ESM singleton the guard uses internally — so the context set by
// runWithCaller inside next() is observable here.
const { isGlobalAdmin, getCurrentToken } = await import('../client.js');
/** Build an unsigned JWT-shaped token carrying the given claims. */
function makeToken(claims: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
return `${header}.${payload}.sig`;
}
const futureExp = () => Math.floor(Date.now() / 1000) + 3600;
/** A valid (well-formed, unexpired) token for the given upn. */
function validToken(upn: string): string {
return makeToken({ upn, exp: futureExp() });
}
function mockReq(authHeader?: string): Request {
return {
headers: { host: 'mcp.example.com', ...(authHeader ? { authorization: authHeader } : {}) },
protocol: 'https',
} as unknown as Request;
}
interface CapturedRes {
res: Response;
statusCode: number | null;
body: unknown;
headers: Record<string, string>;
}
function mockRes(): CapturedRes {
const captured: CapturedRes = { res: null as unknown as Response, statusCode: null, body: undefined, headers: {} };
const res: Partial<Response> = {
setHeader(name: string, value: string | number | readonly string[]) {
captured.headers[name] = String(value);
return this as Response;
},
status(code: number) {
captured.statusCode = code;
return this as Response;
},
json(payload: unknown) {
captured.body = payload;
return this as Response;
},
};
captured.res = res as Response;
return captured;
}
interface GuardOutcome {
nextCalled: boolean;
/** caller context captured INSIDE next() (only meaningful when nextCalled) */
ctx?: { token: string | undefined; ga: boolean };
status: number | null;
body: unknown;
headers: Record<string, string>;
}
/**
* Drives accessGuard and resolves once it either sends a response (res.json)
* or calls next(). The guard's work is async (checkAccess), so we await.
*/
function runGuard(req: Request): Promise<GuardOutcome> {
return new Promise<GuardOutcome>((resolve, reject) => {
const cap = mockRes();
let settled = false;
const done = (o: GuardOutcome) => {
if (settled) return;
settled = true;
resolve(o);
};
// Capture the response when json() is called.
const origJson = cap.res.json.bind(cap.res);
cap.res.json = ((payload: unknown) => {
const r = origJson(payload);
done({ nextCalled: false, status: cap.statusCode, body: cap.body, headers: cap.headers });
return r;
}) as Response['json'];
const next: NextFunction = () => {
// Read the per-request caller context from inside the async scope.
done({
nextCalled: true,
ctx: { token: getCurrentToken(), ga: isGlobalAdmin() },
status: cap.statusCode,
body: cap.body,
headers: cap.headers,
});
};
try {
accessGuard(req, cap.res, next);
} catch (err) {
reject(err);
}
// Safety net so a hung guard fails the test rather than the runner timing out.
setTimeout(() => done({ nextCalled: false, status: cap.statusCode, body: cap.body, headers: cap.headers }), 2_000);
});
}
/** Stub global fetch to return a backend access-check response. */
function stubBackend(opts: { status?: number; body?: unknown; rawText?: string; reject?: boolean }) {
const fn = vi.fn(async () => {
if (opts.reject) throw new Error('network down');
const text = opts.rawText !== undefined ? opts.rawText : JSON.stringify(opts.body ?? {});
return { status: opts.status ?? 200, text: async () => text } as unknown as Response;
});
vi.stubGlobal('fetch', fn);
return fn;
}
let upnCounterSeed = 0;
/** Unique upn per test → isolates the module-level access cache + rate buckets. */
function uniqueUpn(): string {
upnCounterSeed += 1;
return `user${upnCounterSeed}@contoso.com`;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('accessGuard — 401 (authentication failures)', () => {
beforeEach(() => stubBackend({ body: { allowed: true } }));
it('rejects a request with no Authorization header', async () => {
const out = await runGuard(mockReq());
expect(out.nextCalled).toBe(false);
expect(out.status).toBe(401);
expect(out.headers['WWW-Authenticate']).toContain('oauth-protected-resource');
});
it('rejects a non-Bearer Authorization header', async () => {
const out = await runGuard(mockReq('Basic dXNlcjpwYXNz'));
expect(out.status).toBe(401);
});
it('rejects a token missing the required upn claim', async () => {
const out = await runGuard(mockReq(`Bearer ${makeToken({ exp: futureExp() })}`));
expect(out.status).toBe(401);
expect(out.headers['WWW-Authenticate']).toContain('error="invalid_token"');
});
it('rejects a malformed (non-decodable) token', async () => {
const out = await runGuard(mockReq('Bearer not-a-jwt'));
expect(out.status).toBe(401);
});
it('rejects an expired token before ever calling the backend', async () => {
const fetchFn = stubBackend({ body: { allowed: true } });
const expired = makeToken({ upn: 'alice@contoso.com', exp: Math.floor(Date.now() / 1000) - 100 });
const out = await runGuard(mockReq(`Bearer ${expired}`));
expect(out.status).toBe(401);
expect(out.headers['WWW-Authenticate']).toContain('error="invalid_token"');
expect(fetchFn).not.toHaveBeenCalled();
});
});
describe('accessGuard — 403 (authorization + fail-closed)', () => {
it('denies when the backend reports allowed:false and surfaces the reason', async () => {
stubBackend({ body: { allowed: false, reason: 'not on the MCP whitelist' } });
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.nextCalled).toBe(false);
expect(out.status).toBe(403);
expect((out.body as { reason: string }).reason).toBe('not on the MCP whitelist');
});
it('fail-closed: denies when the backend fetch throws (tests-2)', async () => {
// scale-to-zero ⇒ a cold/unreachable backend is routine. checkAccess must
// resolve allowed:false, never allowed:true.
stubBackend({ reject: true });
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.nextCalled).toBe(false);
expect(out.status).toBe(403);
expect((out.body as { reason: string }).reason).toMatch(/unavailable/i);
});
it('fail-closed: denies when the backend returns an empty body (tests-2)', async () => {
stubBackend({ status: 502, rawText: '' });
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.nextCalled).toBe(false);
expect(out.status).toBe(403);
expect((out.body as { reason: string }).reason).toMatch(/empty body/i);
});
it('fail-closed: treats allowed values other than boolean true as denied', async () => {
// `allowed: "true"` (string) or `allowed: 1` must NOT be coerced to allow.
stubBackend({ body: { allowed: 'true' } });
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.status).toBe(403);
});
});
describe('accessGuard — allow path', () => {
it('calls next() exactly once and scopes the caller context (token + GA flag)', async () => {
const upn = uniqueUpn();
stubBackend({ body: { allowed: true, accessGrant: 'whitelisted', isGlobalAdmin: true } });
const token = validToken(upn);
const out = await runGuard(mockReq(`Bearer ${token}`));
expect(out.nextCalled).toBe(true);
expect(out.status).toBeNull(); // no response sent on the allow path
expect(out.ctx).toEqual({ token, ga: true });
});
it('propagates isGlobalAdmin:false through the caller context', async () => {
stubBackend({ body: { allowed: true, isGlobalAdmin: false } });
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.nextCalled).toBe(true);
expect(out.ctx?.ga).toBe(false);
});
});
describe('accessGuard — 429 (rate limit) and caching', () => {
it('429s once the per-minute budget is exhausted (cap=2)', async () => {
const upn = uniqueUpn();
const token = validToken(upn);
stubBackend({ body: { allowed: true } });
const first = await runGuard(mockReq(`Bearer ${token}`));
const second = await runGuard(mockReq(`Bearer ${token}`));
const third = await runGuard(mockReq(`Bearer ${token}`));
expect(first.nextCalled).toBe(true);
expect(second.nextCalled).toBe(true);
expect(third.nextCalled).toBe(false);
expect(third.status).toBe(429);
expect((third.body as { retryAfterSeconds: number }).retryAfterSeconds).toBe(60);
});
it('serves the cached access decision (one backend call across repeat requests)', async () => {
const token = validToken(uniqueUpn());
const fetchFn = stubBackend({ body: { allowed: true } });
await runGuard(mockReq(`Bearer ${token}`));
await runGuard(mockReq(`Bearer ${token}`));
// Second request hits the 5-min UPN+tokenHash cache — no second backend call.
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it('does not let a forged token reuse a legitimate token cache entry', async () => {
// Same upn, two different token strings ⇒ distinct cache keys ⇒ two backend
// calls. This is the F1 guarantee, observed end-to-end through the guard.
const upn = uniqueUpn();
const fetchFn = stubBackend({ body: { allowed: true } });
await runGuard(mockReq(`Bearer ${makeToken({ upn, exp: futureExp(), jti: 'legit' })}`));
await runGuard(mockReq(`Bearer ${makeToken({ upn, exp: futureExp(), jti: 'forged' })}`));
expect(fetchFn).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,92 @@
/**
* Unit tests for the token-claim helpers (review finding tests-3 / tests-4).
*
* extractTokenClaims + isTokenExpired are the first gate every authenticated
* request passes through in accessGuard. The code is correct; these tests pin
* the contract so a regression (e.g. inverting the expiry comparison or losing
* the skew buffer) fails loudly rather than silently weakening auth.
*
* No signature validation happens here by design — the backend re-validates the
* same token. These helpers only decode + check the unix `exp`.
*/
import { describe, it, expect } from 'vitest';
import { extractTokenClaims, isTokenExpired } from '../auth.js';
/** Build an unsigned JWT-shaped string with the given payload claims. */
function makeToken(claims: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
return `${header}.${payload}.sig`;
}
const nowSec = () => Math.floor(Date.now() / 1000);
describe('extractTokenClaims', () => {
it('decodes upn / oid / tid / exp / aud from a well-formed token', () => {
const claims = extractTokenClaims(
makeToken({
upn: 'alice@contoso.com',
oid: 'object-id-123',
tid: 'tenant-id-456',
exp: nowSec() + 3600,
aud: 'api://00000000-0000-0000-0000-000000000000',
}),
);
expect(claims).not.toBeNull();
expect(claims!.upn).toBe('alice@contoso.com');
expect(claims!.oid).toBe('object-id-123');
expect(claims!.tid).toBe('tenant-id-456');
expect(typeof claims!.exp).toBe('number');
});
it('surfaces the aud claim so it is observable (tests-4: parsed, not locally validated)', () => {
// The proxy does not validate audience — the backend does. But it must
// still decode it, e.g. for diagnostics. This pins that aud is parsed.
const claims = extractTokenClaims(makeToken({ upn: 'a@b.c', aud: 'api://some-resource' }));
expect(claims!.aud).toBe('api://some-resource');
});
it('returns null for a token that is not three dot-separated parts', () => {
expect(extractTokenClaims('only.two')).toBeNull();
expect(extractTokenClaims('no-dots-at-all')).toBeNull();
expect(extractTokenClaims('a.b.c.d')).toBeNull();
expect(extractTokenClaims('')).toBeNull();
});
it('returns null when the payload segment is not valid JSON', () => {
const header = Buffer.from('{}').toString('base64url');
const garbagePayload = Buffer.from('this-is-not-json').toString('base64url');
expect(extractTokenClaims(`${header}.${garbagePayload}.sig`)).toBeNull();
});
it('returns null for completely malformed input', () => {
expect(extractTokenClaims('!@#$%^&*()')).toBeNull();
});
});
describe('isTokenExpired', () => {
it('treats a token with no exp claim as expired (fail-closed)', () => {
expect(isTokenExpired({})).toBe(true);
expect(isTokenExpired({ upn: 'a@b.c' })).toBe(true);
});
it('reports a token whose exp is in the past as expired', () => {
expect(isTokenExpired({ exp: nowSec() - 100 })).toBe(true);
});
it('reports a token expiring within the 60s skew buffer as expired', () => {
// exp is technically still in the future, but inside the 60s buffer the
// helper conservatively reports expired so a borderline token is not used.
expect(isTokenExpired({ exp: nowSec() + 30 })).toBe(true);
});
it('reports a token comfortably in the future as not expired', () => {
expect(isTokenExpired({ exp: nowSec() + 3600 })).toBe(false);
});
it('treats a token exactly at the skew boundary as still valid', () => {
// now > exp - 60 ⇒ expired. At exp = now + 61, (exp - 60) = now + 1 > now,
// so it is NOT expired. Pins the boundary direction.
expect(isTokenExpired({ exp: nowSec() + 61 })).toBe(false);
});
});
@@ -0,0 +1,88 @@
/**
* Unit tests for the GA-gating / caller-context routing (review finding tests-6).
*
* pickGlobalOrTenantPath is the hinge of the MCP server's tenant isolation:
* a Global Admin is routed to /api/global/* (cross-tenant), everyone else to
* the tenant-scoped /api/* variant where the backend resolves the tenant from
* the JWT. A regression that flips this default would either expose
* cross-tenant endpoints to a tenant-admin or break GA cross-tenant queries.
*
* runWithCaller scopes that decision per async context so concurrent sessions
* cannot observe each other's token / GA flag — this is asserted explicitly.
*/
import { describe, it, expect } from 'vitest';
import {
pickGlobalOrTenantPath,
isGlobalAdmin,
getCurrentToken,
runWithCaller,
} from '../client.js';
const GLOBAL = '/api/global/devices/blocked';
const TENANT = '/api/devices/blocked';
describe('pickGlobalOrTenantPath — GA gating', () => {
it('defaults to the tenant path when no caller context is active', () => {
// No runWithCaller wrapper ⇒ isGlobalAdmin() is false by design.
expect(isGlobalAdmin()).toBe(false);
expect(getCurrentToken()).toBeUndefined();
expect(pickGlobalOrTenantPath(GLOBAL, TENANT)).toBe(TENANT);
});
it('routes a Global Admin to the /api/global/* path', () => {
runWithCaller({ token: 'ga-token', isGlobalAdmin: true }, () => {
expect(isGlobalAdmin()).toBe(true);
expect(getCurrentToken()).toBe('ga-token');
expect(pickGlobalOrTenantPath(GLOBAL, TENANT)).toBe(GLOBAL);
});
});
it('routes a non-GA caller to the tenant-scoped path', () => {
runWithCaller({ token: 'tenant-token', isGlobalAdmin: false }, () => {
expect(isGlobalAdmin()).toBe(false);
expect(pickGlobalOrTenantPath(GLOBAL, TENANT)).toBe(TENANT);
});
});
it('reverts to the no-context default after the callback returns', () => {
runWithCaller({ token: 't', isGlobalAdmin: true }, () => {
expect(isGlobalAdmin()).toBe(true);
});
// Context must not leak past the run() boundary.
expect(isGlobalAdmin()).toBe(false);
expect(getCurrentToken()).toBeUndefined();
});
});
describe('runWithCaller — context isolation', () => {
it('keeps concurrent contexts from bleeding into each other', async () => {
// Interleave two async callers on the event loop; each must continue to see
// its own token + GA flag despite the other running between awaits.
const ga = runWithCaller({ token: 'ga', isGlobalAdmin: true }, async () => {
await Promise.resolve();
return { token: getCurrentToken(), ga: isGlobalAdmin(), path: pickGlobalOrTenantPath(GLOBAL, TENANT) };
});
const tenant = runWithCaller({ token: 'tn', isGlobalAdmin: false }, async () => {
await Promise.resolve();
return { token: getCurrentToken(), ga: isGlobalAdmin(), path: pickGlobalOrTenantPath(GLOBAL, TENANT) };
});
const [gaResult, tenantResult] = await Promise.all([ga, tenant]);
expect(gaResult).toEqual({ token: 'ga', ga: true, path: GLOBAL });
expect(tenantResult).toEqual({ token: 'tn', ga: false, path: TENANT });
});
it('nested contexts restore the parent context on exit', () => {
runWithCaller({ token: 'outer', isGlobalAdmin: true }, () => {
expect(getCurrentToken()).toBe('outer');
runWithCaller({ token: 'inner', isGlobalAdmin: false }, () => {
expect(getCurrentToken()).toBe('inner');
expect(isGlobalAdmin()).toBe(false);
});
// Inner run() must not clobber the outer context.
expect(getCurrentToken()).toBe('outer');
expect(isGlobalAdmin()).toBe(true);
});
});
});
@@ -0,0 +1,172 @@
/**
* Endpoint tests for the OAuth proxy router (review finding tests-5).
*
* The file header of security-guards.test.ts claims /oauth/register + PKCE-S256
* enforcement are covered, but those tests only assert constant magnitudes (F2)
* and signState/verifyState (F4) — never the route handlers themselves. This
* suite closes that gap by mounting createOAuthRouter() on an ephemeral Express
* server and exercising the actual request paths.
*
* No external network is reached: every asserted path returns before the proxy
* would call Entra ID (the /oauth/authorize success case yields a 302 we read
* with redirect:'manual'; the token error case returns before fetch).
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import express from 'express';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
// createOAuthRouter() throws at import unless the Entra client id is present.
process.env.AUTOPILOT_ENTRA_CLIENT_ID ??= '00000000-0000-0000-0000-000000000000';
const { createOAuthRouter, _registryForTest, MAX_REGISTERED_CLIENTS, MAX_REDIRECT_URIS_PER_CLIENT, MAX_REDIRECT_URI_LENGTH, MAX_CLIENT_NAME_LENGTH } =
await import('../oauth.js');
let server: Server;
let baseUrl: string;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(createOAuthRouter());
await new Promise<void>((resolve) => {
server = app.listen(0, '127.0.0.1', () => resolve());
});
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
afterEach(() => {
// Reset the registry between tests (the cap test fills it to capacity).
_registryForTest.clear();
});
async function register(body: unknown) {
const res = await fetch(`${baseUrl}/oauth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
return { status: res.status, json: json as Record<string, unknown> };
}
describe('/oauth/register — dynamic client registration (RFC 7591)', () => {
it('registers a client with a loopback redirect_uri and returns a client_id', async () => {
const { status, json } = await register({
client_name: 'Claude Code',
redirect_uris: ['http://localhost:54321/callback'],
});
expect(status).toBe(201);
expect(typeof json.client_id).toBe('string');
expect(json.redirect_uris).toEqual(['http://localhost:54321/callback']);
// RFC 7591 default for a public client.
expect(json.token_endpoint_auth_method).toBe('none');
});
it('rejects a hostile redirect_uri host (allowlist defense-in-depth)', async () => {
const { status, json } = await register({
client_name: 'evil',
redirect_uris: ['https://attacker.tld/cb'],
});
expect(status).toBe(400);
expect(json.error).toBe('invalid_redirect_uri');
});
it('rejects more than MAX_REDIRECT_URIS_PER_CLIENT redirect_uris', async () => {
const uris = Array.from({ length: MAX_REDIRECT_URIS_PER_CLIENT + 1 }, (_, i) => `http://localhost:${1000 + i}/cb`);
const { status, json } = await register({ client_name: 'greedy', redirect_uris: uris });
expect(status).toBe(400);
expect(json.error).toBe('invalid_redirect_uri');
});
it('rejects an oversized redirect_uri', async () => {
const huge = `http://localhost:1/${'a'.repeat(MAX_REDIRECT_URI_LENGTH + 1)}`;
const { status, json } = await register({ client_name: 'x', redirect_uris: [huge] });
expect(status).toBe(400);
expect(json.error).toBe('invalid_redirect_uri');
});
it('rejects an over-long client_name', async () => {
const { status, json } = await register({
client_name: 'n'.repeat(MAX_CLIENT_NAME_LENGTH + 1),
redirect_uris: ['http://localhost:1/cb'],
});
expect(status).toBe(400);
expect(json.error).toBe('invalid_client_metadata');
});
it('returns 503 once the registry is at its hard cap', async () => {
_registryForTest.fill(MAX_REGISTERED_CLIENTS);
expect(_registryForTest.size()).toBeGreaterThanOrEqual(MAX_REGISTERED_CLIENTS);
const { status, json } = await register({
client_name: 'overflow',
redirect_uris: ['http://localhost:1/cb'],
});
expect(status).toBe(503);
expect(json.error).toBe('temporarily_unavailable');
});
});
describe('/oauth/authorize — PKCE S256 enforcement', () => {
async function authorize(query: Record<string, string>) {
const qs = new URLSearchParams(query).toString();
return fetch(`${baseUrl}/oauth/authorize?${qs}`, { redirect: 'manual' });
}
it('rejects a request with no code_challenge (PKCE mandatory)', async () => {
const res = await authorize({ redirect_uri: 'http://localhost:1/cb' });
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect(json.error).toBe('invalid_request');
expect(String(json.error_description)).toMatch(/code_challenge is required/);
});
it('rejects a downgrade to code_challenge_method=plain', async () => {
const res = await authorize({
redirect_uri: 'http://localhost:1/cb',
code_challenge: 'abc123',
code_challenge_method: 'plain',
});
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect(String(json.error_description)).toMatch(/must be S256/);
});
it('rejects a missing redirect_uri', async () => {
const res = await authorize({ code_challenge: 'abc123' });
expect(res.status).toBe(400);
});
it('redirects a valid S256 request to the Entra authorize endpoint', async () => {
const res = await authorize({
redirect_uri: 'http://localhost:54321/cb',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
state: 'client-state',
});
expect(res.status).toBe(302);
const location = res.headers.get('location') ?? '';
expect(location).toContain('login.microsoftonline.com');
expect(location).toContain('code_challenge_method=S256');
// The client's challenge is forwarded unchanged.
expect(location).toContain('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
});
});
describe('/oauth/token — PKCE finishing move', () => {
it('rejects an authorization_code grant with no code_verifier', async () => {
const res = await fetch(`${baseUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code', code: 'abc' }).toString(),
});
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect(String(json.error_description)).toMatch(/code_verifier is required/);
});
});
@@ -5,11 +5,17 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { apiFetch, getToken } from './helpers.js';
// Integration suite: only runs when a backend token is supplied. Without one it
// SKIPS (not errors), so an unattended CI run still goes green on the unit
// suites instead of failing the whole file. (review finding tests-7)
const RUN_INTEGRATION = !!process.env.AUTOPILOT_API_TOKEN;
const suite = describe.skipIf(!RUN_INTEGRATION);
beforeAll(() => {
getToken();
if (RUN_INTEGRATION) getToken();
});
describe('get_metrics', () => {
suite('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);
@@ -30,7 +36,7 @@ describe('get_metrics', () => {
});
});
describe('get_usage_metrics', () => {
suite('get_usage_metrics', () => {
it('should return platform usage overview', async () => {
const data = await apiFetch<any>(`/api/global/metrics/usage`);
expect(data).toHaveProperty('sessions');
@@ -41,7 +47,7 @@ describe('get_usage_metrics', () => {
});
});
describe('get_geographic_metrics', () => {
suite('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);
@@ -55,7 +61,7 @@ describe('get_geographic_metrics', () => {
});
});
describe('get_geographic_sessions', () => {
suite('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);
@@ -63,7 +69,7 @@ describe('get_geographic_sessions', () => {
});
});
describe('get_platform_metrics', () => {
suite('get_platform_metrics', () => {
it('should return agent performance metrics', async () => {
const data = await apiFetch<any>(`/api/global/metrics/platform`);
expect(data).toHaveProperty('sessionsAnalyzed');
@@ -75,7 +81,7 @@ describe('get_platform_metrics', () => {
});
});
describe('get_api_usage', () => {
suite('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);
@@ -92,7 +98,7 @@ describe('get_api_usage', () => {
});
});
describe('get_audit_logs', () => {
suite('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);
@@ -107,7 +113,7 @@ describe('get_audit_logs', () => {
});
});
describe('get_ops_events', () => {
suite('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);
@@ -123,7 +129,7 @@ describe('get_ops_events', () => {
});
});
describe('list_session_reports', () => {
suite('list_session_reports', () => {
it('should return session reports', async () => {
const data = await apiFetch<any>(`/api/global/session-reports`);
expect(data.success).toBe(true);
@@ -7,14 +7,21 @@ import { apiFetch, getToken } from './helpers.js';
let knownTenantId: string;
// Integration suite: only runs when a backend token is supplied. Without one it
// SKIPS (not errors), so an unattended CI run still goes green on the unit
// suites instead of failing the whole file. (review finding tests-7)
const RUN_INTEGRATION = !!process.env.AUTOPILOT_API_TOKEN;
const suite = describe.skipIf(!RUN_INTEGRATION);
beforeAll(async () => {
if (!RUN_INTEGRATION) return;
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', () => {
suite('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');
@@ -26,7 +33,7 @@ describe('list_tables', () => {
});
});
describe('query_table', () => {
suite('query_table', () => {
it('should query TenantConfiguration table', async () => {
const data = await apiFetch<any>(`/api/global/raw/tables/TenantConfiguration?limit=2`);
expect(data).toHaveProperty('entities');
@@ -58,7 +65,7 @@ describe('query_table', () => {
});
});
describe('query_raw_sessions', () => {
suite('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');
@@ -84,7 +91,7 @@ describe('query_raw_sessions', () => {
});
});
describe('query_raw_events', () => {
suite('query_raw_events', () => {
it('should return events for a tenant', async () => {
if (!knownTenantId) return;
const data = await apiFetch<any>(
@@ -95,7 +102,7 @@ describe('query_raw_events', () => {
});
});
describe('query_backend_logs', () => {
suite('query_backend_logs', () => {
it('should execute KQL query (POST)', async () => {
const data = await apiFetch<any>(`/api/global/raw/logs`, {
method: 'POST',
@@ -111,7 +118,7 @@ describe('query_backend_logs', () => {
});
});
describe('read-only verification', () => {
suite('read-only verification', () => {
it('should reject PUT on session endpoint', async () => {
// Verify the API doesn't accept mutation methods on read endpoints
try {
@@ -9,11 +9,17 @@ import { apiFetch, buildQuery, getToken } from './helpers.js';
let knownSessionId: string;
let knownTenantId: string;
// Integration suite: only runs when a backend token is supplied. Without one it
// SKIPS (not errors), so an unattended CI run still goes green on the unit
// suites instead of failing the whole file. (review finding tests-7)
const RUN_INTEGRATION = !!process.env.AUTOPILOT_API_TOKEN;
const suite = describe.skipIf(!RUN_INTEGRATION);
beforeAll(() => {
getToken(); // Fail fast if no token
if (RUN_INTEGRATION) getToken(); // Fail fast if no token
});
describe('search_sessions', () => {
suite('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);
@@ -50,7 +56,7 @@ describe('search_sessions', () => {
});
});
describe('search_sessions_by_event', () => {
suite('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`,
@@ -68,7 +74,7 @@ describe('search_sessions_by_event', () => {
});
});
describe('get_session', () => {
suite('get_session', () => {
it('should return session details', async () => {
const data = await apiFetch<any>(`/api/sessions/${knownSessionId}`);
expect(data.success).toBe(true);
@@ -93,7 +99,7 @@ describe('get_session', () => {
});
});
describe('get_session_events', () => {
suite('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);
@@ -128,7 +134,7 @@ describe('get_session_events', () => {
});
});
describe('get_session_analysis', () => {
suite('get_session_analysis', () => {
it('should return analysis results', async () => {
const data = await apiFetch<any>(`/api/sessions/${knownSessionId}/analysis`);
expect(data.success).toBe(true);
@@ -137,7 +143,7 @@ describe('get_session_analysis', () => {
});
});
describe('search_sessions_by_cve', () => {
suite('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`,
@@ -155,7 +161,7 @@ describe('search_sessions_by_cve', () => {
});
});
describe('list_blocked_devices', () => {
suite('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);
@@ -20,7 +20,18 @@ export interface TokenClaims {
tid?: string;
/** Token expiry (unix timestamp) */
exp?: number;
/** Audience */
/**
* Audience. Parsed for observability/diagnostics but intentionally NOT
* validated here — the same Bearer token is passed through to the backend
* API, which enforces the audience cryptographically alongside the
* signature/issuer/lifetime checks. See
* `src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs:200`
* (`ValidateAudience = true`, `ValidAudiences = { clientId, api://clientId }`).
* Duplicating that gate here would only add a second, drift-prone copy of the
* accepted-audience list (RFC 8707 resource indicators are honored by the
* backend, not the proxy). This is spec-conformant token pass-through: the
* user's token is forwarded to the resource it was issued for.
*/
aud?: string;
}