feat: role-aware presence bar, WebSocket logging fixes
- findAdminByKey returns role from admins.json (defaults to 'admin') - JWT includes config-defined role instead of hardcoded 'admin' - PresenceBar split into "who's here?" (page admins) and "connected" (bot/utility services with icon+color badges) - Bot/utility roles appear in presence on all pages when connected - usePresence hook uses refs to avoid WS reconnect on navigation - WS auth log prints admin name instead of generic 'admin' - WS connection log reads X-Forwarded-For for real client IP - AuthContext stores adminRole from login response - Uncomment admins.json Docker volume mount, add SELinux :z flags Made-with: Cursor
This commit is contained in:
@@ -26,23 +26,33 @@ describe('load-admins', () => {
|
||||
|
||||
test('loads admins from ADMIN_CONFIG_PATH', () => {
|
||||
const configPath = writeConfig([
|
||||
{ name: 'Alice', key: 'key-a' },
|
||||
{ name: 'Bob', key: 'key-b' }
|
||||
{ name: 'Alice', role: 'admin', key: 'key-a' },
|
||||
{ name: 'Bob', role: 'bot', key: 'key-b' }
|
||||
]);
|
||||
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||
|
||||
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice' });
|
||||
expect(findAdminByKey('key-b')).toEqual({ name: 'Bob' });
|
||||
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice', role: 'admin' });
|
||||
expect(findAdminByKey('key-b')).toEqual({ name: 'Bob', role: 'bot' });
|
||||
expect(findAdminByKey('wrong')).toBeNull();
|
||||
});
|
||||
|
||||
test('defaults role to admin when not specified', () => {
|
||||
const configPath = writeConfig([
|
||||
{ name: 'Alice', key: 'key-a' }
|
||||
]);
|
||||
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||
|
||||
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice', role: 'admin' });
|
||||
});
|
||||
|
||||
test('falls back to ADMIN_KEY when no config file', () => {
|
||||
process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json');
|
||||
process.env.ADMIN_KEY = 'legacy-key';
|
||||
|
||||
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||
expect(findAdminByKey('legacy-key')).toEqual({ name: 'Admin' });
|
||||
expect(findAdminByKey('legacy-key')).toEqual({ name: 'Admin', role: 'admin' });
|
||||
expect(findAdminByKey('wrong')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -91,7 +101,7 @@ describe('POST /api/auth/login — named admins', () => {
|
||||
({ app } = require('../../backend/server'));
|
||||
});
|
||||
|
||||
test('login returns admin name in response', async () => {
|
||||
test('login returns admin name and role in response', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.set('Content-Type', 'application/json')
|
||||
@@ -99,10 +109,11 @@ describe('POST /api/auth/login — named admins', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBeDefined();
|
||||
expect(res.body.role).toBe('admin');
|
||||
expect(res.body.token).toBeDefined();
|
||||
});
|
||||
|
||||
test('verify returns admin name in user object', async () => {
|
||||
test('verify returns admin name and role in user object', async () => {
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.set('Content-Type', 'application/json')
|
||||
@@ -114,6 +125,7 @@ describe('POST /api/auth/login — named admins', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.name).toBeDefined();
|
||||
expect(res.body.user.role).toBe('admin');
|
||||
});
|
||||
|
||||
test('invalid key still returns 401', async () => {
|
||||
@@ -155,15 +167,15 @@ describe('WebSocket presence', () => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
function makeToken(name) {
|
||||
return jwt.sign({ role: 'admin', name }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||
function makeToken(name, role = 'admin') {
|
||||
return jwt.sign({ role, name }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||
}
|
||||
|
||||
function connectAndAuth(name) {
|
||||
function connectAndAuth(name, role = 'admin') {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name) }));
|
||||
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name, role) }));
|
||||
});
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
@@ -187,7 +199,7 @@ describe('WebSocket presence', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('page_focus triggers presence_update with admin name and page', async () => {
|
||||
test('page_focus triggers presence_update with admin name, role, and page', async () => {
|
||||
const ws1 = await connectAndAuth('Alice');
|
||||
const ws2 = await connectAndAuth('Bob');
|
||||
|
||||
@@ -198,7 +210,7 @@ describe('WebSocket presence', () => {
|
||||
const msg = await presencePromise;
|
||||
expect(msg.admins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Alice', page: '/history' })
|
||||
expect.objectContaining({ name: 'Alice', role: 'admin', page: '/history' })
|
||||
])
|
||||
);
|
||||
|
||||
@@ -224,4 +236,61 @@ describe('WebSocket presence', () => {
|
||||
|
||||
ws2.close();
|
||||
});
|
||||
|
||||
test('bot role appears in presence without page_focus', async () => {
|
||||
const wsAdmin = await connectAndAuth('Alice');
|
||||
const wsBot = await connectAndAuth('ChatBot', 'bot');
|
||||
|
||||
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const presencePromise = waitForMessage(wsAdmin, 'presence_update');
|
||||
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||
|
||||
const msg = await presencePromise;
|
||||
const botEntry = msg.admins.find(a => a.name === 'ChatBot');
|
||||
expect(botEntry).toBeDefined();
|
||||
expect(botEntry.role).toBe('bot');
|
||||
expect(botEntry.page).toBeNull();
|
||||
|
||||
wsAdmin.close();
|
||||
wsBot.close();
|
||||
});
|
||||
|
||||
test('utility role appears in presence without page_focus', async () => {
|
||||
const wsAdmin = await connectAndAuth('Alice');
|
||||
const wsUtil = await connectAndAuth('OBS', 'utility');
|
||||
|
||||
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const presencePromise = waitForMessage(wsAdmin, 'presence_update');
|
||||
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||
|
||||
const msg = await presencePromise;
|
||||
const utilEntry = msg.admins.find(a => a.name === 'OBS');
|
||||
expect(utilEntry).toBeDefined();
|
||||
expect(utilEntry.role).toBe('utility');
|
||||
expect(utilEntry.page).toBeNull();
|
||||
|
||||
wsAdmin.close();
|
||||
wsUtil.close();
|
||||
});
|
||||
|
||||
test('admin without page_focus is NOT in presence', async () => {
|
||||
const ws1 = await connectAndAuth('Alice');
|
||||
const ws2 = await connectAndAuth('Bob');
|
||||
|
||||
const presencePromise = waitForMessage(ws1, 'presence_update');
|
||||
ws1.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||
|
||||
const msg = await presencePromise;
|
||||
const bobEntry = msg.admins.find(a => a.name === 'Bob');
|
||||
expect(bobEntry).toBeUndefined();
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user