(y-provider) add endpoint returning document connection state

We need a new endpoint in the y-provider server allowing the backend to
retrieve the number of active connections on a document and if a session
key exists.
This commit is contained in:
Manuel Raynaud
2025-06-25 17:25:46 +02:00
parent 65b6701708
commit b96de36382
6 changed files with 340 additions and 0 deletions

View File

@@ -0,0 +1,275 @@
import request from 'supertest';
import { v4 as uuid } from 'uuid';
import { describe, expect, test, vi } from 'vitest';
vi.mock('../src/env', async (importOriginal) => {
return {
...(await importOriginal()),
PORT: 5556,
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
};
});
console.error = vi.fn();
import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env';
import { hocuspocusServer, initApp } from '@/servers';
const apiEndpoint = '/collaboration/api/get-connections/';
describe('Server Tests', () => {
test('POST /collaboration/api/get-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room`)
.set('Origin', origin)
.set('Authorization', 'wrong-api-key');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Unauthorized: Invalid API Key');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if room not indicated', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key')
.send({ document_id: 'test-document' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Room name not provided');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if session key not indicated', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key')
.send({ document_id: 'test-document' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Session key not provided');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] return a 404 if room not found', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Room not found');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key existing', async () => {
const document = await hocuspocusServer.createDocument(
'test-room',
{},
uuid(),
{ isAuthenticated: true, readOnly: false, requiresAuthentication: true },
{},
);
document.addConnection({
webSocket: 1,
context: { sessionKey: 'test-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 2,
context: { sessionKey: 'other-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 3,
context: { sessionKey: 'last-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 4,
context: { sessionKey: 'session-read-only' },
document: document,
pongReceived: false,
readOnly: true,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toEqual({
count: 3,
exists: true,
});
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing', async () => {
const document = await hocuspocusServer.createDocument(
'test-room',
{},
uuid(),
{ isAuthenticated: true, readOnly: false, requiresAuthentication: true },
{},
);
document.addConnection({
webSocket: 1,
context: { sessionKey: 'test-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 2,
context: { sessionKey: 'other-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 3,
context: { sessionKey: 'last-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 4,
context: { sessionKey: 'session-read-only' },
document: document,
pongReceived: false,
readOnly: true,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=non-existing-session-key`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toEqual({
count: 3,
exists: false,
});
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing, read only connection', async () => {
const document = await hocuspocusServer.createDocument(
'test-room',
{},
uuid(),
{ isAuthenticated: true, readOnly: false, requiresAuthentication: true },
{},
);
document.addConnection({
webSocket: 1,
context: { sessionKey: 'test-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 2,
context: { sessionKey: 'other-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 3,
context: { sessionKey: 'last-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 4,
context: { sessionKey: 'session-read-only' },
document: document,
pongReceived: false,
readOnly: true,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=session-read-only`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toEqual({
count: 3,
exists: false,
});
});
});

View File

@@ -0,0 +1,48 @@
import { Request, Response } from 'express';
import { hocuspocusServer } from '@/servers';
import { logger } from '@/utils';
type getDocumentConnectionInfoRequestQuery = {
room?: string;
sessionKey?: string;
};
export const getDocumentConnectionInfoHandler = (
req: Request<object, object, object, getDocumentConnectionInfoRequestQuery>,
res: Response,
) => {
const room = req.query.room;
const sessionKey = req.query.sessionKey;
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
if (!req.query.sessionKey) {
res.status(400).json({ error: 'Session key not provided' });
return;
}
logger('Getting document connection info for room:', room);
const roomInfo = hocuspocusServer.documents.get(room);
if (!roomInfo) {
logger('Room not found:', room);
res.status(404).json({ error: 'Room not found' });
return;
}
const connections = roomInfo
.getConnections()
.filter((connection) => connection.readOnly === false);
res.status(200).json({
count: connections.length,
exists: connections.some(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(connection) => connection.context.sessionKey === sessionKey,
),
});
};

View File

@@ -1,3 +1,4 @@
export * from './collaborationResetConnectionsHandler';
export * from './collaborationWSHandler';
export * from './convertHandler';
export * from './getDocumentConnectionInfoHandler';

View File

@@ -2,4 +2,5 @@ export const routes = {
COLLABORATION_WS: '/collaboration/ws/',
COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/',
CONVERT: '/api/convert/',
COLLABORATION_GET_CONNECTIONS: '/collaboration/api/get-connections/',
};

View File

@@ -9,6 +9,7 @@ import {
collaborationResetConnectionsHandler,
collaborationWSHandler,
convertHandler,
getDocumentConnectionInfoHandler,
} from '@/handlers';
import { corsMiddleware, httpSecurity, wsSecurity } from '@/middlewares';
import { routes } from '@/routes';
@@ -41,6 +42,12 @@ export const initApp = () => {
collaborationResetConnectionsHandler,
);
app.get(
routes.COLLABORATION_GET_CONNECTIONS,
httpSecurity,
getDocumentConnectionInfoHandler,
);
/**
* Route to convert Markdown or BlockNote blocks
*/

View File

@@ -60,6 +60,14 @@ export const hocuspocusServer = Server.configure({
connection.readOnly = !canEdit;
const session = requestHeaders['cookie']
?.split('; ')
.find((cookie) => cookie.startsWith('docs_sessionid='));
if (session) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
context.sessionKey = session.split('=')[1];
}
/*
* Unauthenticated users can be allowed to connect
* so we flag only authenticated users