36 lines
12 KiB
TypeScript
36 lines
12 KiB
TypeScript
import {test} from 'node:test';import assert from 'node:assert/strict';import {newDb} from 'pg-mem';import express from 'express';import {once} from 'node:events';
|
|
import {Storage} from '../server/storage.mjs';import {Campaigns,installCampaigns} from '../server/campaigns.mjs';import {newCharacter} from '../src/model.ts';import {characterMcp} from '../server/mcp.mjs';import {Client} from '@modelcontextprotocol/sdk/client/index.js';import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
|
|
async function fixture(){const pg=newDb().adapters.createPg(),storage=new Storage(new pg.Pool());await storage.init();const campaigns=new Campaigns(storage);await campaigns.init();const c=await campaigns.create('dm','Dungeon Master',{name:'Winter road'});const act=async(sub:string,action:string,input:any)=>campaigns.mutate(sub,c.id,(await campaigns.row(c.id)).revision,action,input);const invite=await act('dm','invite',{role:'player'});await campaigns.join('player','Player',{code:invite.invite_code});return {storage,campaigns,c,act}}
|
|
test('campaign assignment exposes only shared stats and requires ownership; roles are campaign scoped',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();pc.notes='Private history';pc.inventory=[{id:'item',name:'Secret',quantity:1,notes:'Hidden'}];await storage.put('player',pc.id,pc,0);assert.deepEqual(await campaigns.list('stranger'),[]);await assert.rejects(campaigns.view('stranger',c.id));assert.equal((await campaigns.view('dm',c.id)).party.length,0);await assert.rejects(act('dm','assign',{character_id:pc.id}));await act('player','assign',{character_id:pc.id});const party=(await campaigns.view('dm',c.id)).party[0];assert.equal(party.character.name,pc.name);assert.equal(party.character.notes,undefined);assert.equal(party.character.inventory,undefined);assert.equal(party.allowDmEdit,false);const other=await campaigns.create('player','Player',{name:'Other game'});assert.equal((await campaigns.view('player',other.id)).role,'dm');assert.equal((await campaigns.view('player',c.id)).role,'player');await act('player','unassign',{character_id:pc.id});assert.equal((await campaigns.view('dm',c.id)).party.length,0)});
|
|
test('invitations are single-use, expire and stop working after inviter loses DM role',async()=>{const {campaigns,c,act}=await fixture();const invite=await act('dm','invite',{role:'dm'});await campaigns.join('co','Co-DM',{code:invite.invite_code});await assert.rejects(campaigns.join('stranger','Stranger',{code:invite.invite_code}));const pending=await act('dm','invite',{});await act('co','member',{sub:'dm',role:'player'});await assert.rejects(campaigns.join('stranger','Stranger',{code:pending.invite_code}));const expired=await act('co','invite',{});const row=await campaigns.row(c.id);row.data.invites.forEach((i:any)=>i.expires=0);await campaigns.pool.query('UPDATE campaigns SET data=$2 WHERE id=$1',[c.id,row.data]);await assert.rejects(campaigns.join('stranger','Stranger',{code:expired.invite_code}));await assert.rejects(act('co','member',{sub:'co',role:'remove'}));});
|
|
test('private notes, DM secrets and hidden NPCs remain filtered after role changes',async()=>{const {campaigns,c,act}=await fixture();await act('player','note',{title:'Private',body:'secret',visibility:'private'});await act('player','note',{title:'Party',body:'shared',visibility:'party'});await assert.rejects(act('player','note',{title:'DM',body:'secret',visibility:'dm'}));await act('dm','note',{title:'DM',body:'secret',visibility:'dm'});const secret=(await campaigns.view('dm',c.id)).notes.find((n:any)=>n.title==='DM');await act('dm','npc',{name:'Hidden',hp:10,maxHp:10,ac:10,notes:'plot'});await act('dm','npc',{name:'Visible',hp:10,maxHp:10,ac:10,notes:'plot',visible:true});const player=await campaigns.view('player',c.id);assert.deepEqual(player.notes.map((n:any)=>n.title),['Private','Party']);assert.equal(player.npcs.length,1);assert.equal(player.npcs[0].notes,undefined);assert.deepEqual((await campaigns.view('dm',c.id)).notes.map((n:any)=>n.title),['Party','DM']);await act('dm','member',{sub:'player',role:'dm'});await act('player','member',{sub:'dm',role:'player'});assert.equal((await campaigns.view('dm',c.id)).notes.some((n:any)=>n.id===secret.id),false);await assert.rejects(act('dm','note',{id:secret.id,title:'Leak',body:'secret',visibility:'party'}));await assert.rejects(act('dm','removeNote',{id:secret.id}));});
|
|
test('delegated saved HP requires current DM role and owner permission, and preserves private data',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();pc.notes='Private';pc.hp.temp=3;await storage.put('player',pc.id,pc,0);await act('player','assign',{character_id:pc.id});const input={owner:'player',character_id:pc.id,expected_revision:1,amount:-5};await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:true});await assert.rejects(campaigns.partyHp('player',c.id,input));const changed=await campaigns.partyHp('dm',c.id,input);assert.equal(changed.revision,2);assert.deepEqual(changed.character.hp,{current:8,max:10,temp:0});assert.equal(changed.character.notes,undefined);assert.equal((await storage.get('player',pc.id)).character.notes,'Private');await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:false});await assert.rejects(campaigns.partyHp('dm',c.id,{...input,expected_revision:2}));await act('dm','member',{sub:'player',role:'remove'});await assert.rejects(campaigns.view('player',c.id));assert.equal((await campaigns.view('dm',c.id)).party.length,0)});
|
|
test('DM encounter snapshots sort initiative, wrap rounds and clamp HP without touching saved PCs',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0);await assert.rejects(act('player','session',{title:'No'}));await assert.rejects(act('player','npc',{name:'No',hp:1,maxHp:1,ac:1}));await assert.rejects(act('player','invite',{}));await act('dm','session',{title:'Session 1',summary:'Meet at the inn'});await act('dm','encounter',{name:'Ambush',participants:[{name:'PC',initiative:8,hp:10,maxHp:10,ac:10},{name:'Goblin',initiative:15,hp:5,maxHp:5,ac:12}]});let encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.participants[0].name,'Goblin');await assert.rejects(act('player','encounterStep',{id:encounter.id,action:'next'}));await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'damage',participant_id:encounter.participants[0].id,amount:50});encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.round,2);assert.equal(encounter.turn,0);assert.equal(encounter.participants[0].hp,0);assert.equal((await storage.get('player',pc.id)).character.hp.current,10);const old=(await campaigns.row(c.id)).revision;await act('dm','encounterStep',{id:encounter.id,action:'finish'});await assert.rejects(campaigns.mutate('dm',c.id,old,'note',{title:'Stale',body:'',visibility:'party'}));assert.equal((await campaigns.view('dm',c.id)).notes.length,0)});
|
|
test('HTTP campaign routes deny unsigned users and cross-origin writes',async()=>{const {campaigns}=await fixture(),app=express();app.use(express.json());app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:req.get('x-test-user'),name:'Test'};next()});installCampaigns(app,{campaigns,origin:'https://dnd.example.test'});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/campaigns')).status,401);assert.equal((await fetch(base+'/api/campaigns',{method:'POST',headers:{'x-test-user':'dm','content-type':'application/json',origin:'https://evil.test'},body:JSON.stringify({name:'No'})})).status,403);assert.equal((await fetch(base+'/api/campaigns',{headers:{'x-test-user':'dm'}})).status,200)}finally{await new Promise<void>(r=>server.close(()=>r()))}});
|
|
test('MCP exposes campaign tools and enforces read scopes and campaign membership',async()=>{const {storage,campaigns,c}=await fixture();async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'campaign-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const read=await connect('dm',['characters:read']),player=await connect('player',['characters:read','characters:write']),stranger=await connect('stranger',['characters:read']);try{assert.equal((await read.listTools()).tools.length,25);assert.equal((await read.callTool({name:'create_campaign',arguments:{name:'Denied'}})).isError,true);assert.equal((await stranger.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,true);assert.equal((await player.callTool({name:'invite_campaign_member',arguments:{campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{role:'dm'}}})).isError,true);assert.equal((await read.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,undefined)}finally{await Promise.all([read.close(),player.close(),stranger.close()])}});
|
|
|
|
test('DM conditions are manual snapshots, bounded, revision guarded and removed explicitly',async()=>{
|
|
const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0);
|
|
await act('dm','encounter',{name:'Mountain pass',participants:[{name:pc.name,initiative:12,hp:10,maxHp:10,ac:12}]});
|
|
const e=(await campaigns.view('dm',c.id)).encounters[0],p=e.participants[0];
|
|
assert.deepEqual(p.conditions,[]);
|
|
const change={id:e.id,action:'conditions',participant_id:p.id,conditions:['Prone','Concentrating','Prone']};
|
|
await assert.rejects(act('player','encounterStep',change));await assert.rejects(campaigns.mutate('outsider',c.id,(await campaigns.row(c.id)).revision,'encounterStep',change));
|
|
await act('dm','encounterStep',change);let updated=(await campaigns.view('player',c.id)).encounters[0].participants[0];
|
|
assert.deepEqual(updated.conditions,['Prone','Concentrating']);assert.equal(updated.hp,10);assert.equal((await storage.get('player',pc.id)).character.hp.current,10);
|
|
const revision=(await campaigns.row(c.id)).revision;await act('dm','encounterStep',{...change,conditions:[]});
|
|
await assert.rejects(campaigns.mutate('dm',c.id,revision,'encounterStep',change));
|
|
await assert.rejects(act('dm','encounterStep',{...change,conditions:Array(13).fill('Prone')}));await assert.rejects(act('dm','encounterStep',{...change,conditions:[' ']}));await assert.rejects(act('dm','encounterStep',{...change,conditions:undefined}));
|
|
assert.deepEqual((await campaigns.view('dm',c.id)).encounters[0].participants[0].conditions,[]);
|
|
await act('dm','encounterStep',{id:e.id,action:'finish'});await assert.rejects(act('dm','encounterStep',change));
|
|
});
|
|
|
|
test('MCP changes encounter conditions for DMs and denies player and read-only callers',async()=>{
|
|
const {storage,campaigns,c,act}=await fixture();await act('dm','encounter',{name:'Ridge',participants:[{name:'Guard',initiative:1,hp:10,maxHp:10,ac:12}]});
|
|
const e=(await campaigns.view('dm',c.id)).encounters[0];
|
|
async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'conditions-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}
|
|
const dm=await connect('dm',['characters:read','characters:write']),player=await connect('player',['characters:read','characters:write']),read=await connect('dm',['characters:read']);
|
|
const args={campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{id:e.id,action:'conditions',participant_id:e.participants[0].id,conditions:['Prone']}};
|
|
try{assert.equal((await player.callTool({name:'advance_campaign_encounter',arguments:args})).isError,true);assert.equal((await read.callTool({name:'advance_campaign_encounter',arguments:args})).isError,true);assert.equal((await dm.callTool({name:'advance_campaign_encounter',arguments:args})).isError,undefined);assert.deepEqual((await campaigns.view('player',c.id)).encounters[0].participants[0].conditions,['Prone']);assert.equal((await dm.callTool({name:'advance_campaign_encounter',arguments:args})).isError,true)}finally{await Promise.all([dm.close(),player.close(),read.close()])}
|
|
});
|