50 lines
4.6 KiB
TypeScript

import {test} from 'node:test';import assert from 'node:assert/strict';
import express from 'express';import {once} from 'node:events';import {newDb} from 'pg-mem';
import {generateKeyPair,exportJWK,SignJWT,createLocalJWKSet} from 'jose';
import {Storage,Conflict} from '../server/storage.mjs';import {newCharacter} from '../src/model.ts';
import {characterMcp,installMcp} from '../server/mcp.mjs';import {createKeycloakVerifier,installKeycloakMcpAuth} from '../server/mcp-auth.mjs';
import {Client} from '@modelcontextprotocol/sdk/client/index.js';import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
const origin='https://dnd.example.test',issuer='https://auth.example.test/realms/dev',clientId='dnd-character-mcp';
async function storage(){const pg=newDb().adapters.createPg();const s=new Storage(new pg.Pool());await s.init();return s}
async function tokens(){
const {privateKey,publicKey}=await generateKeyPair('RS256'),pub=await exportJWK(publicKey);pub.kid='test-key';pub.alg='RS256';
const sign=async({azp=clientId,sub='machine-1',iss=issuer,exp=3600}={})=>new SignJWT({azp,sub}).setProtectedHeader({alg:'RS256',kid:'test-key'}).setIssuer(iss).setIssuedAt().setExpirationTime(Math.floor(Date.now()/1000)+exp).sign(privateKey);
return {verify:createKeycloakVerifier({issuer,allowedClient:clientId,jwks:createLocalJWKSet({keys:[pub]})}),sign};
}
test('account sheets remain isolated and stale revisions fail',async()=>{const s=await storage(),c=newCharacter();await s.put('alice',c.id,c,0);assert.equal((await s.list('bob')).length,0);await assert.rejects(s.put('bob',c.id,c,1),Conflict);await s.put('alice',c.id,{...c,name:'Updated'},1);await assert.rejects(s.put('alice',c.id,null,1,true),Conflict)});
test('Keycloak JWKS verifier rejects incorrect client, issuer, expired and unsigned tokens',async()=>{
const {verify,sign}=await tokens(),good=await sign();const identity=await verify(good);
assert.match(identity.sub,/^mcp-machine:dnd-character-mcp:/);assert.deepEqual(identity.scopes,['characters:read','characters:write']);
await assert.rejects(verify(await sign({azp:'untrusted-client'})));
await assert.rejects(verify(await sign({iss:'https://evil.example'})));
await assert.rejects(verify(await sign({exp:-30})));
await assert.rejects(verify('not-a-jwt'));
});
test('MCP middleware advertises Keycloak, denies anonymous tokens and isolates machine storage',async()=>{
const s=await storage(),{verify,sign}=await tokens(),app=express();app.use(express.json());const auth=installKeycloakMcpAuth(app,{origin,issuer,allowedClient:clientId,verify});
installMcp(app,{storage:s,auth,origin});const server=app.listen(0,'127.0.0.1');await once(server,'listening');
const base='http://127.0.0.1:'+server.address().port;
try{
const metadata=await(await fetch(base+'/.well-known/oauth-protected-resource/mcp')).json();assert.deepEqual(metadata.authorization_servers,[issuer]);
for(const path of ['/authorize','/token','/register'])assert.equal((await fetch(base+path)).status,404,path);
assert.equal((await fetch(base+'/mcp/consent')).status,401,'Old consent path must not issue tokens');
const payload={jsonrpc:'2.0',id:1,method:'tools/list',params:{}};
const call=authorization=>fetch(base+'/mcp',{method:'POST',headers:{'content-type':'application/json',accept:'application/json, text/event-stream',...(authorization?{authorization}:{})},body:JSON.stringify(payload)});
assert.equal((await call()).status,401);
assert.equal((await call('Bearer '+await sign({azp:'other'}))).status,401);
assert.equal((await call('Bearer '+await sign())).status,200);
const pc=newCharacter();await s.put('browser-human',pc.id,pc,0);
assert.equal((await s.list('mcp-machine:dnd-character-mcp:machine-1')).length,0);
assert.equal((await s.list('browser-human')).length,1);
}finally{await new Promise(resolve=>server.close(resolve))}
});
test('SDK tools preserve machine ownership and revision enforcement',async()=>{
const s=await storage(),subject='mcp-machine:dnd-character-mcp:machine-1',c=newCharacter();await s.put(subject,c.id,c,0);
const server=characterMcp({storage:s,sub:subject,scopes:['characters:read','characters:write']});
const client=new Client({name:'test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);
try{assert.equal((await client.listTools()).tools.length,17);
const hp=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(hp.isError,undefined);
const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true);
}finally{await client.close()}
});