Mirror isolated MCP test count fixes

This commit is contained in:
Admin 2026-10-08 03:50:28 +00:00
parent f78591da0b
commit 4fa9c4901a
2 changed files with 3 additions and 3 deletions

View File

@ -5,4 +5,4 @@ test('official seeds preserve both editions, provenance and all SRD spells',asyn
test('seeding persists counts, runs idempotently and preserves personal sheets',async()=>{const pc=newCharacter();pc.notes='Private';await storage.put('owner',pc.id,pc,0);const before=(await catalogue.pool.query('SELECT id FROM rules_catalogue')).rows.length;assert.equal(before,seed.entries.length);await catalogue.init();assert.equal((await catalogue.pool.query('SELECT id FROM rules_catalogue')).rows.length,before);assert.equal((await storage.get('owner',pc.id)).character.notes,'Private');assert.equal((await storage.get('owner',pc.id)).revision,1)}); test('seeding persists counts, runs idempotently and preserves personal sheets',async()=>{const pc=newCharacter();pc.notes='Private';await storage.put('owner',pc.id,pc,0);const before=(await catalogue.pool.query('SELECT id FROM rules_catalogue')).rows.length;assert.equal(before,seed.entries.length);await catalogue.init();assert.equal((await catalogue.pool.query('SELECT id FROM rules_catalogue')).rows.length,before);assert.equal((await storage.get('owner',pc.id)).character.notes,'Private');assert.equal((await storage.get('owner',pc.id)).revision,1)});
test('catalogue filters and paging never mix editions or fabricate mechanics',async()=>{const old=await catalogue.list({edition:'2014',kind:'spell',q:'Fire Bolt'}),revised=await catalogue.list({edition:'2024',kind:'spell',q:'Fire Bolt'});assert.equal(old.total,1);assert.equal(revised.total,1);assert.notEqual(old.entries[0].id,revised.entries[0].id);assert.equal(old.entries[0].description,undefined);const entry=await catalogue.get(revised.entries[0].id);assert.equal(entry.level,0);assert.match(entry.description,/Casting Time: Action/);assert.match(entry.description,/1d10 Fire damage/);assert.match((await catalogue.get(old.entries[0].id)).description,/Casting Time: 1 action/);const cantrips=await catalogue.list({edition:'2024',kind:'spell',level:0});assert.ok(cantrips.entries.every((e:any)=>e.level===0));const first=await catalogue.list({edition:'2014',kind:'spell'}),second=await catalogue.list({edition:'2014',kind:'spell',offset:40});assert.equal(first.entries.length,40);assert.ok(!second.entries.some((e:any)=>first.entries.some((f:any)=>f.id===e.id)));assert.equal((await catalogue.list({q:"' OR 1=1 --"})).total,0);await assert.rejects(catalogue.list({edition:'unknown'}));assert.equal(await catalogue.get('missing'),undefined);const staff=await catalogue.list({edition:'2024',kind:'item',category:'Weapon',q:'Quarterstaff'});assert.equal(staff.total,1);assert.equal((await catalogue.get(staff.entries[0].id)).mastery,'Topple')}); test('catalogue filters and paging never mix editions or fabricate mechanics',async()=>{const old=await catalogue.list({edition:'2014',kind:'spell',q:'Fire Bolt'}),revised=await catalogue.list({edition:'2024',kind:'spell',q:'Fire Bolt'});assert.equal(old.total,1);assert.equal(revised.total,1);assert.notEqual(old.entries[0].id,revised.entries[0].id);assert.equal(old.entries[0].description,undefined);const entry=await catalogue.get(revised.entries[0].id);assert.equal(entry.level,0);assert.match(entry.description,/Casting Time: Action/);assert.match(entry.description,/1d10 Fire damage/);assert.match((await catalogue.get(old.entries[0].id)).description,/Casting Time: 1 action/);const cantrips=await catalogue.list({edition:'2024',kind:'spell',level:0});assert.ok(cantrips.entries.every((e:any)=>e.level===0));const first=await catalogue.list({edition:'2014',kind:'spell'}),second=await catalogue.list({edition:'2014',kind:'spell',offset:40});assert.equal(first.entries.length,40);assert.ok(!second.entries.some((e:any)=>first.entries.some((f:any)=>f.id===e.id)));assert.equal((await catalogue.list({q:"' OR 1=1 --"})).total,0);await assert.rejects(catalogue.list({edition:'unknown'}));assert.equal(await catalogue.get('missing'),undefined);const staff=await catalogue.list({edition:'2024',kind:'item',category:'Weapon',q:'Quarterstaff'});assert.equal(staff.total,1);assert.equal((await catalogue.get(staff.entries[0].id)).mastery,'Topple')});
test('catalogue HTTP requires a session and validates edition and missing entry',async()=>{const app=express();app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:'reader'};next()});installCatalogue(app,{catalogue});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${(server.address() as any).port}`;try{assert.equal((await fetch(base+'/api/catalogue')).status,401);assert.equal((await fetch(base+'/api/catalogue?edition=bad',{headers:{'x-test-user':'1'}})).status,400);const r=await fetch(base+'/api/catalogue?edition=2024&kind=spell&q=Fire%20Bolt',{headers:{'x-test-user':'1'}});assert.equal(r.status,200);assert.equal((await r.json()).total,1);assert.equal((await fetch(base+'/api/catalogue/missing',{headers:{'x-test-user':'1'}})).status,404)}finally{await new Promise<void>(r=>server.close(()=>r()))}}); test('catalogue HTTP requires a session and validates edition and missing entry',async()=>{const app=express();app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:'reader'};next()});installCatalogue(app,{catalogue});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${(server.address() as any).port}`;try{assert.equal((await fetch(base+'/api/catalogue')).status,401);assert.equal((await fetch(base+'/api/catalogue?edition=bad',{headers:{'x-test-user':'1'}})).status,400);const r=await fetch(base+'/api/catalogue?edition=2024&kind=spell&q=Fire%20Bolt',{headers:{'x-test-user':'1'}});assert.equal(r.status,200);assert.equal((await r.json()).total,1);assert.equal((await fetch(base+'/api/catalogue/missing',{headers:{'x-test-user':'1'}})).status,404)}finally{await new Promise<void>(r=>server.close(()=>r()))}});
test('read-only MCP searches and reads official catalogue without modifying characters',async()=>{const server=characterMcp({storage,catalogue,sub:'reader',scopes:['characters:read']}),client=new Client({name:'catalogue-test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);try{assert.equal((await client.listTools()).tools.length,24);const result:any=await client.callTool({name:'search_rules_catalogue',arguments:{edition:'2024',kind:'spell',q:'Fire Bolt'}});assert.equal(result.isError,undefined);const id=result.structuredContent.entries[0].id;const entry:any=await client.callTool({name:'get_rules_entry',arguments:{id}});assert.equal(entry.structuredContent.entry.name,'Fire Bolt');assert.equal((await storage.list('reader')).length,0)}finally{await client.close()}}); test('read-only MCP searches and reads official catalogue without modifying characters',async()=>{const server=characterMcp({storage,catalogue,sub:'reader',scopes:['characters:read']}),client=new Client({name:'catalogue-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 result:any=await client.callTool({name:'search_rules_catalogue',arguments:{edition:'2024',kind:'spell',q:'Fire Bolt'}});assert.equal(result.isError,undefined);const id=result.structuredContent.entries[0].id;const entry:any=await client.callTool({name:'get_rules_entry',arguments:{id}});assert.equal(entry.structuredContent.entry.name,'Fire Bolt');assert.equal((await storage.list('reader')).length,0)}finally{await client.close()}});

View File

@ -8,7 +8,7 @@ async function storage(){const pg=newDb().adapters.createPg();const s=new Storag
const hash=async(s:string)=>(await import('node:crypto')).createHash('sha256').update(s).digest('hex'); const hash=async(s:string)=>(await import('node:crypto')).createHash('sha256').update(s).digest('hex');
test('persistent sheets isolate accounts and reject stale updates/deletions',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);const updated=await s.put('alice',c.id,{...c,name:'Updated'},1);assert.equal(updated.revision,2);await assert.rejects(s.put('alice',c.id,null,1,true),Conflict);assert.equal((await s.get('alice',c.id)).character.name,'Updated');const deleted=await s.put('alice',c.id,null,2,true);assert.equal(deleted.deleted,true);assert.equal((await s.list('alice')).length,1)}); test('persistent sheets isolate accounts and reject stale updates/deletions',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);const updated=await s.put('alice',c.id,{...c,name:'Updated'},1);assert.equal(updated.revision,2);await assert.rejects(s.put('alice',c.id,null,1,true),Conflict);assert.equal((await s.get('alice',c.id)).character.name,'Updated');const deleted=await s.put('alice',c.id,null,2,true);assert.equal(deleted.deleted,true);assert.equal((await s.list('alice')).length,1)});
test('OAuth codes bind client, redirect, resource, scope and one-time use; refresh rotates; revocation rejects access',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin});const client={client_id:'client',redirect_uris:['https://chatgpt.com/callback']};const payload={purpose:'mcp-code',clientId:'client',sub:'alice',challenge:'abc',redirectUri:client.redirect_uris[0],scopes:['characters:read'],sessionExpiry:Math.floor(Date.now()/1000)+3600,nonce:'unique'};const code=await codec.seal(payload,300);await s.code(await hash(code),{});assert.equal(await p.challengeForAuthorizationCode(client,code),'abc');await assert.rejects(p.challengeForAuthorizationCode({client_id:'other'},code));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,'https://evil.test',p.resource));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,new URL('https://other.test/mcp')));const tokens=await p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource);assert.equal((await p.verifyAccessToken(tokens.access_token)).extra.sub,'alice');await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource));await assert.rejects(p.verifyAccessToken(await codec.seal({sub:'alice'},300)));await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,['characters:write'],p.resource));const renewed=await p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource);await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource));await p.revokeToken(client,{token:renewed.access_token});await assert.rejects(p.verifyAccessToken(tokens.access_token));}); test('OAuth codes bind client, redirect, resource, scope and one-time use; refresh rotates; revocation rejects access',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin});const client={client_id:'client',redirect_uris:['https://chatgpt.com/callback']};const payload={purpose:'mcp-code',clientId:'client',sub:'alice',challenge:'abc',redirectUri:client.redirect_uris[0],scopes:['characters:read'],sessionExpiry:Math.floor(Date.now()/1000)+3600,nonce:'unique'};const code=await codec.seal(payload,300);await s.code(await hash(code),{});assert.equal(await p.challengeForAuthorizationCode(client,code),'abc');await assert.rejects(p.challengeForAuthorizationCode({client_id:'other'},code));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,'https://evil.test',p.resource));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,new URL('https://other.test/mcp')));const tokens=await p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource);assert.equal((await p.verifyAccessToken(tokens.access_token)).extra.sub,'alice');await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource));await assert.rejects(p.verifyAccessToken(await codec.seal({sub:'alice'},300)));await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,['characters:write'],p.resource));const renewed=await p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource);await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource));await p.revokeToken(client,{token:renewed.access_token});await assert.rejects(p.verifyAccessToken(tokens.access_token));});
test('MCP SDK lists tools, performs account-scoped HP changes, rejects stale revisions and denies read-only writes',async()=>{const s=await storage(),c=newCharacter();c.hp.temp=5;await s.put('alice',c.id,c,0);async function connect(sub:string,scopes:string[]){const server=characterMcp({storage:s,sub,scopes}),client=new Client({name:'test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const client=await connect('alice',['characters:read','characters:write']);assert.equal((await client.listTools()).tools.length,22);const hp=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-7}});assert.equal(hp.isError,undefined);assert.deepEqual((await s.get('alice',c.id)).character.hp,{current:8,max:10,temp:0});const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true);const bob=await connect('bob',['characters:read']);const missing=await bob.callTool({name:'get_character',arguments:{id:c.id}});assert.equal(missing.isError,true);const readonly=await connect('alice',['characters:read']);assert.equal((await readonly.callTool({name:'create_character',arguments:{name:'Denied'}})).isError,true);await Promise.all([client.close(),bob.close(),readonly.close()])}); test('MCP SDK lists tools, performs account-scoped HP changes, rejects stale revisions and denies read-only writes',async()=>{const s=await storage(),c=newCharacter();c.hp.temp=5;await s.put('alice',c.id,c,0);async function connect(sub:string,scopes:string[]){const server=characterMcp({storage:s,sub,scopes}),client=new Client({name:'test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const client=await connect('alice',['characters:read','characters:write']);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:-7}});assert.equal(hp.isError,undefined);assert.deepEqual((await s.get('alice',c.id)).character.hp,{current:8,max:10,temp:0});const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true);const bob=await connect('bob',['characters:read']);const missing=await bob.callTool({name:'get_character',arguments:{id:c.id}});assert.equal(missing.isError,true);const readonly=await connect('alice',['characters:read']);assert.equal((await readonly.callTool({name:'create_character',arguments:{name:'Denied'}})).isError,true);await Promise.all([client.close(),bob.close(),readonly.close()])});
test('HTTP discovery, bearer denial, PKCE enforcement and API origin protection',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice'};next()});const auth=p.install(app,{cookie:()=>{},readCookie:()=>''});installMcp(app,{storage:s,auth,origin});installCharacters(app,{storage:s,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 denied=await fetch(base+'/mcp',{method:'POST',headers:{'content-type':'application/json'},body:'{}'});assert.equal(denied.status,401);assert.match(denied.headers.get('www-authenticate')||'',/oauth-protected-resource\/mcp/);const metadata=await(await fetch(base+'/.well-known/oauth-protected-resource/mcp')).json();assert.equal(metadata.resource,origin+'/mcp');const registered=await fetch(base+'/register',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({client_name:'Tests',redirect_uris:['https://chatgpt.com/callback'],token_endpoint_auth_method:'none'})});assert.equal(registered.status,201);const client=await registered.json();const authorize=await fetch(base+'/authorize?'+new URLSearchParams({client_id:client.client_id,redirect_uri:client.redirect_uris[0],response_type:'code',resource:origin+'/mcp',code_challenge:'a'.repeat(43),code_challenge_method:'S256'}),{redirect:'manual'});assert.equal(authorize.status,302);const badPkce=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({client_id:client.client_id,grant_type:'authorization_code',code:'invalid',code_verifier:'wrong',redirect_uri:client.redirect_uris[0],resource:origin+'/mcp'})});assert.equal(badPkce.status,400);const c=newCharacter();const blocked=await fetch(base+'/api/characters/'+c.id,{method:'PUT',headers:{'content-type':'application/json','origin':'https://evil.test'},body:JSON.stringify({character:c,revision:0})});assert.equal(blocked.status,403)}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}}); test('HTTP discovery, bearer denial, PKCE enforcement and API origin protection',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice'};next()});const auth=p.install(app,{cookie:()=>{},readCookie:()=>''});installMcp(app,{storage:s,auth,origin});installCharacters(app,{storage:s,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 denied=await fetch(base+'/mcp',{method:'POST',headers:{'content-type':'application/json'},body:'{}'});assert.equal(denied.status,401);assert.match(denied.headers.get('www-authenticate')||'',/oauth-protected-resource\/mcp/);const metadata=await(await fetch(base+'/.well-known/oauth-protected-resource/mcp')).json();assert.equal(metadata.resource,origin+'/mcp');const registered=await fetch(base+'/register',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({client_name:'Tests',redirect_uris:['https://chatgpt.com/callback'],token_endpoint_auth_method:'none'})});assert.equal(registered.status,201);const client=await registered.json();const authorize=await fetch(base+'/authorize?'+new URLSearchParams({client_id:client.client_id,redirect_uri:client.redirect_uris[0],response_type:'code',resource:origin+'/mcp',code_challenge:'a'.repeat(43),code_challenge_method:'S256'}),{redirect:'manual'});assert.equal(authorize.status,302);const badPkce=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({client_id:client.client_id,grant_type:'authorization_code',code:'invalid',code_verifier:'wrong',redirect_uri:client.redirect_uris[0],resource:origin+'/mcp'})});assert.equal(badPkce.status,400);const c=newCharacter();const blocked=await fetch(base+'/api/characters/'+c.id,{method:'PUT',headers:{'content-type':'application/json','origin':'https://evil.test'},body:JSON.stringify({character:c,revision:0})});assert.equal(blocked.status,403)}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}});
test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong verifier',async()=>{ test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong verifier',async()=>{
const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice',name:'Alice',exp:Math.floor(Date.now()/1000)+3600};next()}); const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice',name:'Alice',exp:Math.floor(Date.now()/1000)+3600};next()});
@ -24,6 +24,6 @@ test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong v
const tokenBody=(v:string)=>new URLSearchParams({client_id:c.client_id,grant_type:'authorization_code',code,code_verifier:v,redirect_uri:c.redirect_uris[0],resource:origin+'/mcp'}); const tokenBody=(v:string)=>new URLSearchParams({client_id:c.client_id,grant_type:'authorization_code',code,code_verifier:v,redirect_uri:c.redirect_uris[0],resource:origin+'/mcp'});
const wrong=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody('x'.repeat(64))});assert.equal(wrong.status,400); const wrong=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody('x'.repeat(64))});assert.equal(wrong.status,400);
const exchanged=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody(verifier)});assert.equal(exchanged.status,200);const tokens=await exchanged.json(); const exchanged=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody(verifier)});assert.equal(exchanged.status,200);const tokens=await exchanged.json();
const {StreamableHTTPClientTransport}=await import('@modelcontextprotocol/sdk/client/streamableHttp.js');const client=new Client({name:'http-test',version:'1'});await client.connect(new StreamableHTTPClientTransport(new URL(base+'/mcp'),{requestInit:{headers:{Authorization:'Bearer '+tokens.access_token}}}));assert.equal((await client.listTools()).tools.length,22);const created=await client.callTool({name:'create_character',arguments:{name:'HTTP adventurer'}});assert.equal(created.isError,undefined);assert.equal((await s.list('alice'))[0].character.name,'HTTP adventurer');await client.close(); const {StreamableHTTPClientTransport}=await import('@modelcontextprotocol/sdk/client/streamableHttp.js');const client=new Client({name:'http-test',version:'1'});await client.connect(new StreamableHTTPClientTransport(new URL(base+'/mcp'),{requestInit:{headers:{Authorization:'Bearer '+tokens.access_token}}}));assert.equal((await client.listTools()).tools.length,17);const created=await client.callTool({name:'create_character',arguments:{name:'HTTP adventurer'}});assert.equal(created.isError,undefined);assert.equal((await s.list('alice'))[0].character.name,'HTTP adventurer');await client.close();
}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))} }finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}
}); });