@furystack/websocket-api
Version:
WebSocket API implementation for FuryStack
118 lines • 6.37 kB
JavaScript
import { InMemoryStore, User as UserModel, useSystemIdentityContext } from '@furystack/core';
import { getPort } from '@furystack/core/port-generator';
import { createInjector } from '@furystack/inject';
import { DefaultSession, HttpUserContext, SessionStore, UserDataSet, UserResolutionCache, UserStore, useHttpAuthentication, useRestService, } from '@furystack/rest-service';
import { PasswordCredential, PasswordCredentialStore, PasswordResetToken, PasswordResetTokenStore, usePasswordPolicy, } from '@furystack/security';
import { usingAsync } from '@furystack/utils';
import { describe, expect, it, vi } from 'vitest';
import { WebSocket } from 'ws';
import { WhoAmI } from './actions/whoami.js';
import { useWebSocketApi } from './websocket-api.js';
const bindAuthStores = (i) => {
i.bind(UserStore, () => new InMemoryStore({ model: UserModel, primaryKey: 'username' }));
i.bind(SessionStore, () => new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }));
i.bind(PasswordCredentialStore, () => new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }));
i.bind(PasswordResetTokenStore, () => new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' }));
usePasswordPolicy(i);
};
describe('WebSocket Integration tests', () => {
const host = 'localhost';
const path = '/ws';
const setupWebSocket = async () => {
const injector = createInjector();
const port = getPort();
const createdClients = [];
bindAuthStores(injector);
useHttpAuthentication(injector);
await useRestService({ injector, api: {}, root: '', port, hostName: host });
await useWebSocketApi({ injector, actions: [WhoAmI], path, port, hostName: host });
const client = await new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://${host}:${port}${path}`);
createdClients.push(ws);
ws.on('open', () => resolve(ws)).on('error', reject);
});
return {
injector,
client,
createdClients,
port,
[Symbol.asyncDispose]: async () => {
createdClients.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
});
createdClients.length = 0;
await injector[Symbol.asyncDispose]();
},
};
};
const getWhoAmIResult = async (subjectClient) => {
return new Promise((resolve, reject) => {
subjectClient.once('message', (data) => {
resolve(JSON.parse(data.toString()));
});
subjectClient.once('error', reject);
subjectClient.send('whoami');
});
};
describe('Authentication', () => {
it('Should be unauthenticated by default', async () => {
await usingAsync(await setupWebSocket(), async ({ client }) => {
expect((await getWhoAmIResult(client)).currentUser).toBe(null);
});
});
});
it('Should be authenticated, roles should be updated and should be logged out', async () => {
await usingAsync(await setupWebSocket(), async ({ injector, createdClients, port }) => {
const testUser = { username: 'test', password: 'test', roles: [] };
const userStore = injector.get(UserStore);
await userStore.add(testUser);
// Performing login/logout through a disposable setup scope keeps
// `HttpUserContext` (scoped) from being cached on the root injector.
// Per-connection message scopes then resolve their own fresh instance
// each time, so server-side state changes (role updates, logout)
// surface on the next websocket message.
let cookie = '';
await usingAsync(injector.createScope({ owner: 'ws-login' }), async (setupScope) => {
await setupScope.get(HttpUserContext).cookieLogin(testUser, {
setHeader: (_name, value) => {
cookie = value;
},
});
});
const authenticatedClient = await new Promise((done, reject) => {
const cl = new WebSocket(`ws://${host}:${port}${path}`, {
headers: { cookie },
});
createdClients.push(cl);
cl.once('open', () => {
done(cl);
}).once('error', reject);
});
const whoAmIResult = await getWhoAmIResult(authenticatedClient);
expect(whoAmIResult.currentUser).toEqual(testUser);
await usingAsync(useSystemIdentityContext({ injector, username: 'test' }), async (systemScope) => {
const userDataSet = systemScope.get(UserDataSet);
await userDataSet.update(systemScope, testUser.username, { ...testUser, roles: ['newFancyRole'] });
});
// Out-of-band mutations to the user record do not propagate through the
// user-resolution cache automatically; apps that mutate roles in
// storage must explicitly invalidate the cache so the next request
// re-walks the auth providers.
injector.get(UserResolutionCache).invalidateAll();
const updatedWhoAmIResult = await getWhoAmIResult(authenticatedClient);
expect(updatedWhoAmIResult.currentUser.roles).toEqual(['newFancyRole']);
// `cookieLogout` publishes `userLoggedOut` on the IdentityEventBus, which
// closes every websocket whose connect-time cookie carries the
// invalidated session id — locally and on every sibling node.
const closedPromise = new Promise((resolve) => authenticatedClient.once('close', (code) => resolve({ code })));
await usingAsync(injector.createScope({ owner: 'ws-logout' }), async (logoutScope) => {
await logoutScope.get(HttpUserContext).cookieLogout({ headers: { cookie } }, { setHeader: vi.fn() });
});
const closed = await closedPromise;
expect(closed.code).toBe(1008);
});
});
});
//# sourceMappingURL=websocket-integration.spec.js.map