90 lines
7.8 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.equal(identity.sub,'machine-1');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 retains user storage isolation',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('machine-1')).length,0);
assert.equal((await s.list('browser-human')).length,1);
}finally{await new Promise(resolve=>server.close(resolve))}
});
test('SDK tools use delegated Keycloak user 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,19);
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()}
});
test('portrait MCP uploads compact WebP bytes without echoing data, and removes with revision guard',async()=>{
const s=await storage(),c=newCharacter();c.name='Artful hero';c.notes='Keep all notes';
await s.put('artist',c.id,c,0);
const pc=characterMcp({storage:s,sub:'artist',scopes:['characters:read','characters:write']}),client=new Client({name:'portrait-test',version:'1'});
const [a,b]=InMemoryTransport.createLinkedPair();await pc.connect(a);await client.connect(b);
// Minimal syntactically structured RIFF/WebP stream for validating the upload envelope.
const bytes=Buffer.alloc(30);bytes.write('RIFF',0);bytes.writeUInt32LE(22,4);bytes.write('WEBPVP8X',8);bytes.writeUInt32LE(10,16);
const image='data:image/webp;base64,'+bytes.toString('base64');
try{
const added=await client.callTool({name:'upload_character_portrait',arguments:{id:c.id,expected_revision:1,image_data_url:image}});
assert.equal(added.isError,undefined);
assert.deepEqual(added.structuredContent.portrait,{present:true,mime_type:'image/webp',bytes:30});
assert.equal(added.structuredContent.revision,2);
assert.ok(!JSON.stringify(added).includes(image));
const stored=await s.get('artist',c.id);
assert.equal(stored.character.portrait,image);assert.equal(stored.character.notes,'Keep all notes');
const stale=await client.callTool({name:'upload_character_portrait',arguments:{id:c.id,expected_revision:1,image_data_url:image}});
assert.equal(stale.isError,true);
const cleared=await client.callTool({name:'remove_character_portrait',arguments:{id:c.id,expected_revision:2}});
assert.equal(cleared.isError,undefined);assert.equal(cleared.structuredContent.portrait.present,false);
assert.equal((await s.get('artist',c.id)).character.portrait,null);
}finally{await client.close()}
});
test('portrait MCP rejects invalid signatures, oversized data, another account and read-only scopes',async()=>{
const s=await storage(),c=newCharacter();await s.put('alice',c.id,c,0);
async function connect(sub,scopes){const server=characterMcp({storage:s,sub,scopes}),client=new Client({name:'portrait-auth-test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}
const user=await connect('alice',['characters:read','characters:write']),readonly=await connect('alice',['characters:read']),other=await connect('bob',['characters:read','characters:write']);
const image='data:image/jpeg;base64,'+Buffer.alloc(35,65).toString('base64');
const call=(client,image_data_url=image)=>client.callTool({name:'upload_character_portrait',arguments:{id:c.id,expected_revision:1,image_data_url}});
try{
assert.equal((await call(user)).isError,true);
assert.equal((await call(user,'data:image/png;base64,AAAA')).isError,true);
assert.equal((await call(user,'data:image/webp;base64,'+'A'.repeat(320004))).isError,true);
assert.equal((await call(readonly)).isError,true);
assert.equal((await call(other)).isError,true);
assert.equal((await readonly.callTool({name:'remove_character_portrait',arguments:{id:c.id,expected_revision:1}})).isError,true);
assert.equal((await s.get('alice',c.id)).revision,1);
}finally{await Promise.all([user.close(),readonly.close(),other.close()])}
});