@@ -2,7 +2,9 @@ import http from 'http';
22import { WebSocket } from 'ws' ;
33import { EventEmitter } from 'events' ;
44import { attachWebSocket , type WebSocketHandle } from '@/api/rest/websocket' ;
5+ import { signAccessToken } from '@/lib/jwt' ;
56import type { ProjectManager } from '@/lib/project-manager' ;
7+ import type { ServerConfig } from '@/lib/multi-config' ;
68
79function createMockProjectManager ( ) : ProjectManager & EventEmitter {
810 const em = new EventEmitter ( ) ;
@@ -141,4 +143,151 @@ describe('WebSocket server', () => {
141143 expect ( pm2 . listenerCount ( 'note:created' ) ) . toBe ( 0 ) ;
142144 server2 . close ( ) ;
143145 } ) ;
146+
147+ it ( 'debounces graph:updated events' , async ( ) => {
148+ const ws = new WebSocket ( `ws://127.0.0.1:${ port } /api/ws` ) ;
149+ await waitForOpen ( ws ) ;
150+
151+ const messages : any [ ] = [ ] ;
152+ ws . on ( 'message' , ( data ) => messages . push ( JSON . parse ( data . toString ( ) ) ) ) ;
153+
154+ // Emit multiple graph:updated rapidly
155+ pm . emit ( 'graph:updated' , { projectId : 'test' , file : 'a.ts' , graph : 'code' } ) ;
156+ pm . emit ( 'graph:updated' , { projectId : 'test' , file : 'b.ts' , graph : 'code' } ) ;
157+
158+ // Wait for debounce (WS_DEBOUNCE_MS = 1000)
159+ await new Promise ( r => setTimeout ( r , 1500 ) ) ;
160+
161+ // Should receive a single debounced message with both files
162+ const graphUpdates = messages . filter ( m => m . type === 'graph:updated' ) ;
163+ expect ( graphUpdates ) . toHaveLength ( 1 ) ;
164+ expect ( graphUpdates [ 0 ] . data . files ) . toContain ( 'a.ts' ) ;
165+ expect ( graphUpdates [ 0 ] . data . files ) . toContain ( 'b.ts' ) ;
166+ ws . close ( ) ;
167+ } ) ;
168+ } ) ;
169+
170+ // ---------------------------------------------------------------------------
171+ // WebSocket with authentication
172+ // ---------------------------------------------------------------------------
173+
174+ describe ( 'WebSocket auth' , ( ) => {
175+ const JWT_SECRET = 'a' . repeat ( 32 ) ;
176+ const users = {
177+ alice : { name : 'Alice' , email : 'alice@test.com' , apiKey : 'key-alice' } ,
178+ bob : { name : 'Bob' , email : 'bob@test.com' , apiKey : 'key-bob' } ,
179+ } ;
180+
181+ let server : http . Server ;
182+ let wsHandle : WebSocketHandle ;
183+ let pm : ProjectManager & EventEmitter ;
184+ let port : number ;
185+
186+ beforeAll ( async ( ) => {
187+ pm = createMockProjectManager ( ) ;
188+ // Add second project that bob can't access
189+ ( pm as any ) . getProject = ( id : string ) => {
190+ if ( id === 'test' ) return {
191+ config : {
192+ graphConfigs : {
193+ docs : { enabled : true } , code : { enabled : true } ,
194+ knowledge : { enabled : true , access : { alice : 'rw' } } ,
195+ files : { enabled : true } , tasks : { enabled : true } , skills : { enabled : true } ,
196+ } ,
197+ access : { alice : 'rw' } ,
198+ } ,
199+ workspaceId : undefined ,
200+ } ;
201+ if ( id === 'secret' ) return {
202+ config : {
203+ graphConfigs : {
204+ docs : { enabled : true , access : { alice : 'rw' } } , code : { enabled : true , access : { alice : 'rw' } } ,
205+ knowledge : { enabled : true , access : { alice : 'rw' } } ,
206+ files : { enabled : true , access : { alice : 'rw' } } ,
207+ tasks : { enabled : true , access : { alice : 'rw' } } ,
208+ skills : { enabled : true , access : { alice : 'rw' } } ,
209+ } ,
210+ access : { alice : 'rw' } ,
211+ } ,
212+ workspaceId : undefined ,
213+ } ;
214+ return undefined ;
215+ } ;
216+
217+ server = http . createServer ( ) ;
218+ wsHandle = attachWebSocket ( server , pm , {
219+ jwtSecret : JWT_SECRET ,
220+ users,
221+ serverConfig : { defaultAccess : 'deny' , jwtSecret : JWT_SECRET } as ServerConfig ,
222+ } ) ;
223+
224+ await new Promise < void > ( ( resolve ) => {
225+ server . listen ( 0 , '127.0.0.1' , ( ) => {
226+ port = ( server . address ( ) as any ) . port ;
227+ resolve ( ) ;
228+ } ) ;
229+ } ) ;
230+ } ) ;
231+
232+ afterAll ( async ( ) => {
233+ for ( const client of wsHandle . wss . clients ) client . terminate ( ) ;
234+ wsHandle . cleanup ( ) ;
235+ await new Promise < void > ( ( resolve ) => wsHandle . wss . close ( ( ) => resolve ( ) ) ) ;
236+ server . closeAllConnections ( ) ;
237+ await new Promise < void > ( ( resolve ) => server . close ( ( ) => resolve ( ) ) ) ;
238+ } ) ;
239+
240+ it ( 'rejects connection without cookie' , async ( ) => {
241+ const ws = new WebSocket ( `ws://127.0.0.1:${ port } /api/ws` ) ;
242+ await expect ( waitForOpen ( ws ) ) . rejects . toThrow ( ) ;
243+ } ) ;
244+
245+ it ( 'rejects connection with invalid cookie' , async ( ) => {
246+ const ws = new WebSocket ( `ws://127.0.0.1:${ port } /api/ws` , {
247+ headers : { cookie : 'mgm_access=invalid-token' } ,
248+ } ) ;
249+ await expect ( waitForOpen ( ws ) ) . rejects . toThrow ( ) ;
250+ } ) ;
251+
252+ it ( 'accepts connection with valid JWT cookie' , async ( ) => {
253+ const token = signAccessToken ( 'alice' , JWT_SECRET , '15m' ) ;
254+ const ws = new WebSocket ( `ws://127.0.0.1:${ port } /api/ws` , {
255+ headers : { cookie : `mgm_access=${ token } ` } ,
256+ } ) ;
257+ await waitForOpen ( ws ) ;
258+ expect ( ws . readyState ) . toBe ( WebSocket . OPEN ) ;
259+ ws . close ( ) ;
260+ } ) ;
261+
262+ it ( 'filters events by user access — alice sees test project' , async ( ) => {
263+ const token = signAccessToken ( 'alice' , JWT_SECRET , '15m' ) ;
264+ const ws = new WebSocket ( `ws://127.0.0.1:${ port } /api/ws` , {
265+ headers : { cookie : `mgm_access=${ token } ` } ,
266+ } ) ;
267+ await waitForOpen ( ws ) ;
268+
269+ const msgPromise = waitForMessage ( ws ) ;
270+ pm . emit ( 'note:created' , { projectId : 'test' , noteId : 'n1' } ) ;
271+ const msg = await msgPromise ;
272+ expect ( msg . type ) . toBe ( 'note:created' ) ;
273+ ws . close ( ) ;
274+ } ) ;
275+
276+ it ( 'filters events by user access — bob does not see secret project' , async ( ) => {
277+ const token = signAccessToken ( 'bob' , JWT_SECRET , '15m' ) ;
278+ const ws = new WebSocket ( `ws://127.0.0.1:${ port } /api/ws` , {
279+ headers : { cookie : `mgm_access=${ token } ` } ,
280+ } ) ;
281+ await waitForOpen ( ws ) ;
282+
283+ const messages : any [ ] = [ ] ;
284+ ws . on ( 'message' , ( data ) => messages . push ( JSON . parse ( data . toString ( ) ) ) ) ;
285+
286+ pm . emit ( 'note:created' , { projectId : 'secret' , noteId : 'hidden' } ) ;
287+
288+ // Wait briefly — bob should NOT receive the event
289+ await new Promise ( r => setTimeout ( r , 200 ) ) ;
290+ expect ( messages . filter ( m => m . projectId === 'secret' ) ) . toHaveLength ( 0 ) ;
291+ ws . close ( ) ;
292+ } ) ;
144293} ) ;
0 commit comments