90 lines
7.8 KiB
TypeScript
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()])}
|
|
});
|