mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Merge pull request #501 from aakhter/pr/webview-sse-owner
fix(webview): route webview:changed only to its owner in multi-user mode
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @fileoverview Owner routing of the `webview:changed` SSE event.
|
||||
*
|
||||
* Saved web tabs are owner-scoped in multi-user mode (`canAccessOwned` on every
|
||||
* CRUD route), but their invalidation event used to carry no owner and fell
|
||||
* through `deriveSseHint`'s global branch, so every connected user learned the
|
||||
* ids of every other user's web-tab creates, edits and deletes. The event now
|
||||
* carries the resource owner and routes to that owner plus admins; single-user
|
||||
* mode (no SSE identity) still delivers it to every client.
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { CleanupManager } from '../src/utils/index.js';
|
||||
import type { AuthUser } from '../src/types.js';
|
||||
import { installRouteErrorHandler } from '../src/web/route-error-handler.js';
|
||||
import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
import { SseStreamManager, type SseRoutingHint } from '../src/web/sse-stream-manager.js';
|
||||
import { deriveWebviewSseHint } from '../src/web/webview-sse.js';
|
||||
|
||||
function client() {
|
||||
const writes: string[] = [];
|
||||
return {
|
||||
writes,
|
||||
reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply,
|
||||
};
|
||||
}
|
||||
|
||||
/** The server's real event → routing-hint derivation, without starting the server. */
|
||||
function serverHint(event: string, data: unknown): SseRoutingHint | undefined {
|
||||
const server = new WebServer(3999, false, true) as unknown as {
|
||||
deriveSseHint(event: string, data: unknown): SseRoutingHint | undefined;
|
||||
};
|
||||
return server.deriveSseHint(event, data);
|
||||
}
|
||||
|
||||
describe('deriveWebviewSseHint', () => {
|
||||
it('routes to the exact owner and fails closed when the owner is missing', () => {
|
||||
expect(deriveWebviewSseHint({ action: 'updated', id: 'w1', owner: 'alice' })).toEqual({
|
||||
username: 'alice',
|
||||
sessionScoped: true,
|
||||
});
|
||||
expect(deriveWebviewSseHint({ action: 'updated', id: 'w1' })).toEqual({
|
||||
username: undefined,
|
||||
sessionScoped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('is what the server derives for the webview: family (never the global branch)', () => {
|
||||
const payload = { action: 'created', id: 'w1', owner: 'alice' };
|
||||
expect(serverHint(SseEvent.WebviewChanged, payload)).toEqual({ username: 'alice', sessionScoped: true });
|
||||
expect(serverHint(SseEvent.WebviewChanged, { action: 'deleted', id: 'w1' })).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('webview:changed delivery', () => {
|
||||
it('reaches the owner and admins, never another ordinary user', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alice = client();
|
||||
const bob = client();
|
||||
const admin = client();
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
const payload = { action: 'deleted', id: 'w1', owner: 'alice' };
|
||||
|
||||
manager.broadcast(SseEvent.WebviewChanged, payload, serverHint(SseEvent.WebviewChanged, payload));
|
||||
|
||||
expect(alice.writes).toEqual(['event: webview:changed\ndata: {"action":"deleted","id":"w1","owner":"alice"}\n\n']);
|
||||
expect(admin.writes).toEqual(alice.writes);
|
||||
expect(bob.writes).toEqual([]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('single-user mode: clients without an identity all still receive it', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const tabA = client();
|
||||
const tabB = client();
|
||||
manager.addClient(tabA.reply, null, false, undefined, undefined);
|
||||
manager.addClient(tabB.reply, null, false, undefined, undefined);
|
||||
const payload = { action: 'created', id: 'w1', owner: '@single' };
|
||||
|
||||
manager.broadcast(SseEvent.WebviewChanged, payload, serverHint(SseEvent.WebviewChanged, payload));
|
||||
|
||||
expect(tabA.writes).toHaveLength(1);
|
||||
expect(tabB.writes).toEqual(tabA.writes);
|
||||
cleanup.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('webview routes → SSE, end to end', () => {
|
||||
let tmpDir: string;
|
||||
let savedDataDir: string | undefined;
|
||||
let savedMode: string | undefined;
|
||||
let cleanup: CleanupManager;
|
||||
let manager: SseStreamManager;
|
||||
const apps: FastifyInstance[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webview-sse-'));
|
||||
savedDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
savedMode = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_DATA_DIR = tmpDir;
|
||||
cleanup = new CleanupManager();
|
||||
manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const app of apps.splice(0)) await app.close();
|
||||
cleanup.dispose();
|
||||
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
||||
else process.env.CODEMAN_DATA_DIR = savedDataDir;
|
||||
if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = savedMode;
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
/** A route app acting as `authUser`, whose broadcasts go through the server's routing. */
|
||||
async function appAs(authUser: AuthUser | undefined): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
await app.register(fastifyWebsocket);
|
||||
app.decorateRequest('authUser', undefined);
|
||||
app.addHook('onRequest', async (req) => {
|
||||
req.authUser = authUser;
|
||||
});
|
||||
registerWebviewRoutes(app, {
|
||||
broadcast: (event: string, data: unknown) => manager.broadcast(event, data, serverHint(event, data)),
|
||||
tabLayouts: { webviewCreated: async () => {}, webviewDeleted: async () => {} },
|
||||
} as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
apps.push(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("multi-user: another user's SSE stream never sees a web-tab create, edit or delete", async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const aliceApp = await appAs({ username: 'alice', role: 'user' });
|
||||
const alice = client();
|
||||
const bob = client();
|
||||
const admin = client();
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
const created = await aliceApp.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webviews',
|
||||
payload: { name: 'Grafana', url: 'http://127.0.0.1:4000/' },
|
||||
});
|
||||
expect(created.statusCode).toBe(200);
|
||||
const id = created.json().data.id as string;
|
||||
const patched = await aliceApp.inject({ method: 'PATCH', url: `/api/webviews/${id}`, payload: { name: 'G2' } });
|
||||
expect(patched.statusCode).toBe(200);
|
||||
expect((await aliceApp.inject({ method: 'DELETE', url: `/api/webviews/${id}` })).statusCode).toBe(200);
|
||||
|
||||
const expected = ['created', 'updated', 'deleted'].map(
|
||||
(action) => `event: webview:changed\ndata: ${JSON.stringify({ action, id, owner: 'alice' })}\n\n`
|
||||
);
|
||||
expect(alice.writes).toEqual(expected);
|
||||
expect(admin.writes).toEqual(expected);
|
||||
expect(bob.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("multi-user: an admin editing a user's web tab notifies that user, not a bystander", async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const aliceApp = await appAs({ username: 'alice', role: 'user' });
|
||||
const adminApp = await appAs({ username: 'root', role: 'admin' });
|
||||
const id = (
|
||||
await aliceApp.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webviews',
|
||||
payload: { name: 'G', url: 'http://127.0.0.1:4000/' },
|
||||
})
|
||||
).json().data.id as string;
|
||||
const alice = client();
|
||||
const bob = client();
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
|
||||
expect((await adminApp.inject({ method: 'DELETE', url: `/api/webviews/${id}` })).statusCode).toBe(200);
|
||||
|
||||
expect(alice.writes).toEqual([
|
||||
`event: webview:changed\ndata: ${JSON.stringify({ action: 'deleted', id, owner: 'alice' })}\n\n`,
|
||||
]);
|
||||
expect(bob.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('single-user: every client still receives the event', async () => {
|
||||
const soloApp = await appAs(undefined);
|
||||
const tabA = client();
|
||||
const tabB = client();
|
||||
manager.addClient(tabA.reply, null, false, undefined, undefined);
|
||||
manager.addClient(tabB.reply, null, false, undefined, undefined);
|
||||
|
||||
const created = await soloApp.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webviews',
|
||||
payload: { name: 'G', url: 'http://127.0.0.1:4000/' },
|
||||
});
|
||||
expect(created.statusCode).toBe(200);
|
||||
|
||||
expect(tabA.writes).toEqual([
|
||||
`event: webview:changed\ndata: ${JSON.stringify({ action: 'created', id: created.json().data.id, owner: '@single' })}\n\n`,
|
||||
]);
|
||||
expect(tabB.writes).toEqual(tabA.writes);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user