Skip to content

Commit 863cbf0

Browse files
committed
test: add team, WS auth/debounce/filtering, promise-queue drain tests
- team.test.ts: scanTeamDir + ensureAuthorInTeam (12 tests) - websocket.test.ts: JWT auth reject/accept, access filtering, graph:updated debounce (6 new tests, total 13) - promise-queue.test.ts: waitForPending (2 new tests) 1898 tests across 52 suites.
1 parent be32a57 commit 863cbf0

3 files changed

Lines changed: 274 additions & 0 deletions

File tree

src/tests/promise-queue.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,27 @@ describe('PromiseQueue', () => {
4242
expect(await p3).toBe('recovered');
4343
});
4444

45+
it('waitForPending resolves immediately when empty', async () => {
46+
const queue = new PromiseQueue();
47+
await queue.waitForPending(); // should not hang
48+
});
49+
50+
it('waitForPending waits for in-flight tasks', async () => {
51+
const queue = new PromiseQueue();
52+
const order: number[] = [];
53+
54+
queue.enqueue(async () => {
55+
await new Promise(r => setTimeout(r, 50));
56+
order.push(1);
57+
});
58+
queue.enqueue(async () => {
59+
order.push(2);
60+
});
61+
62+
await queue.waitForPending();
63+
expect(order).toEqual([1, 2]);
64+
});
65+
4566
it('prevents concurrent execution (simulates race condition)', async () => {
4667
const queue = new PromiseQueue();
4768
let counter = 0;

src/tests/team.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as os from 'os';
4+
import { scanTeamDir, ensureAuthorInTeam } from '@/lib/team';
5+
6+
function tmpDir(): string {
7+
return fs.mkdtempSync(path.join(os.tmpdir(), 'gm-team-'));
8+
}
9+
10+
describe('scanTeamDir', () => {
11+
it('returns empty for non-existent dir', () => {
12+
expect(scanTeamDir('/nonexistent/.team')).toEqual([]);
13+
});
14+
15+
it('returns empty for empty dir', () => {
16+
const dir = tmpDir();
17+
expect(scanTeamDir(dir)).toEqual([]);
18+
});
19+
20+
it('scans team member files', () => {
21+
const dir = tmpDir();
22+
fs.writeFileSync(path.join(dir, 'alice.md'), '---\nname: Alice\nemail: alice@test.com\n---\n# Alice\n');
23+
fs.writeFileSync(path.join(dir, 'bob.md'), '---\nname: Bob\nemail: bob@test.com\n---\n# Bob\n');
24+
25+
const members = scanTeamDir(dir);
26+
expect(members).toHaveLength(2);
27+
expect(members.find(m => m.id === 'alice')).toMatchObject({ name: 'Alice', email: 'alice@test.com' });
28+
expect(members.find(m => m.id === 'bob')).toMatchObject({ name: 'Bob', email: 'bob@test.com' });
29+
});
30+
31+
it('skips non-md files', () => {
32+
const dir = tmpDir();
33+
fs.writeFileSync(path.join(dir, 'alice.md'), '---\nname: Alice\nemail: a@t.com\n---\n');
34+
fs.writeFileSync(path.join(dir, 'notes.txt'), 'not a team file');
35+
36+
const members = scanTeamDir(dir);
37+
expect(members).toHaveLength(1);
38+
});
39+
40+
it('skips directories', () => {
41+
const dir = tmpDir();
42+
fs.mkdirSync(path.join(dir, 'subdir.md'), { recursive: true });
43+
fs.writeFileSync(path.join(dir, 'alice.md'), '---\nname: Alice\nemail: a@t.com\n---\n');
44+
45+
const members = scanTeamDir(dir);
46+
expect(members).toHaveLength(1);
47+
});
48+
49+
it('uses filename as fallback name', () => {
50+
const dir = tmpDir();
51+
fs.writeFileSync(path.join(dir, 'charlie.md'), '---\nemail: c@t.com\n---\n');
52+
53+
const members = scanTeamDir(dir);
54+
expect(members[0].name).toBe('charlie');
55+
});
56+
57+
it('handles malformed frontmatter gracefully', () => {
58+
const dir = tmpDir();
59+
fs.writeFileSync(path.join(dir, 'bad.md'), 'no frontmatter at all');
60+
fs.writeFileSync(path.join(dir, 'good.md'), '---\nname: Good\nemail: g@t.com\n---\n');
61+
62+
const members = scanTeamDir(dir);
63+
// Should still have at least the good one; bad one may parse with empty fm
64+
expect(members.length).toBeGreaterThanOrEqual(1);
65+
});
66+
});
67+
68+
describe('ensureAuthorInTeam', () => {
69+
it('creates team member file', () => {
70+
const dir = tmpDir();
71+
const teamDir = path.join(dir, '.team');
72+
ensureAuthorInTeam(teamDir, { name: 'Alice Dev', email: 'alice@dev.com' });
73+
74+
expect(fs.existsSync(path.join(teamDir, 'alice-dev.md'))).toBe(true);
75+
const content = fs.readFileSync(path.join(teamDir, 'alice-dev.md'), 'utf-8');
76+
expect(content).toContain('Alice Dev');
77+
expect(content).toContain('alice@dev.com');
78+
});
79+
80+
it('does not overwrite existing file', () => {
81+
const dir = tmpDir();
82+
const teamDir = path.join(dir, '.team');
83+
fs.mkdirSync(teamDir, { recursive: true });
84+
fs.writeFileSync(path.join(teamDir, 'alice.md'), 'existing content');
85+
86+
ensureAuthorInTeam(teamDir, { name: 'Alice', email: 'new@email.com' });
87+
const content = fs.readFileSync(path.join(teamDir, 'alice.md'), 'utf-8');
88+
expect(content).toBe('existing content');
89+
});
90+
91+
it('skips if no name', () => {
92+
const dir = tmpDir();
93+
const teamDir = path.join(dir, '.team');
94+
ensureAuthorInTeam(teamDir, { name: '', email: 'a@b.com' });
95+
expect(fs.existsSync(teamDir)).toBe(false);
96+
});
97+
98+
it('slugifies name correctly', () => {
99+
const dir = tmpDir();
100+
const teamDir = path.join(dir, '.team');
101+
ensureAuthorInTeam(teamDir, { name: 'John O\'Brien III', email: 'j@b.com' });
102+
expect(fs.existsSync(path.join(teamDir, 'john-o-brien-iii.md'))).toBe(true);
103+
});
104+
});

src/tests/websocket.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import http from 'http';
22
import { WebSocket } from 'ws';
33
import { EventEmitter } from 'events';
44
import { attachWebSocket, type WebSocketHandle } from '@/api/rest/websocket';
5+
import { signAccessToken } from '@/lib/jwt';
56
import type { ProjectManager } from '@/lib/project-manager';
7+
import type { ServerConfig } from '@/lib/multi-config';
68

79
function 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

Comments
 (0)