feat: lots of stuff

This commit is contained in:
Morten Olsen
2025-10-16 00:23:18 +02:00
parent 521ffd395f
commit 8e594d59fd
30 changed files with 1739 additions and 31 deletions

73
tests/mqtt.test.ts Normal file
View File

@@ -0,0 +1,73 @@
import { describe, beforeEach, afterEach, it, vi, expect } from 'vitest';
import { createWorld, type World } from './utils/utils.world.ts';
import { statements } from './utils/utils.statements.ts';
describe('mqtt', () => {
let world: World = undefined as unknown as World;
beforeEach(async () => {
world = await createWorld({});
});
afterEach(async () => {
if (world) {
await world.destroy();
}
});
it('should be able to send messages to all subscribers', async () => {
const [clientA, clientB, clientC] = await world.connect(statements.all, statements.all, statements.all);
const spyB = vi.fn();
const spyC = vi.fn();
clientB.on('message', spyB);
clientC.on('message', spyC);
await clientB.subscribeAsync('test');
await clientC.subscribeAsync('test');
await clientA.publishAsync('test', 'test');
await vi.waitUntil(() => spyB.mock.calls.length && spyC.mock.calls.length);
expect(spyB).toHaveBeenCalledTimes(1);
expect(spyC).toHaveBeenCalledTimes(1);
});
it('should not be able to subscribe if not allowed', async () => {
const [client] = await world.connect([]);
const promise = client.subscribeAsync('test');
await expect(promise).rejects.toThrow();
});
it('should not be able to publish if not allowed', async () => {
const [client] = await world.connect([]);
const promise = client.publishAsync('test', 'test');
// TODO: why does this not throw?
// await expect(promise).rejects.toThrow();
});
it('should not be able to read messages if not allowed', async () => {
const [clientA, clientB] = await world.connect(statements.all, statements.noRead);
const spy = vi.fn();
clientB.on('message', spy);
await clientB.subscribeAsync('test');
await clientA.publishAsync('test', 'test');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(spy).toHaveBeenCalledTimes(0);
});
it('should be able to handle many connections', async () => {
const clients = await world.connect(...new Array(50).fill(statements.all));
const spies = await Promise.all(
clients.map(async (client) => {
const spy = vi.fn();
client.on('message', spy);
await client.subscribeAsync('test');
return spy;
}),
);
const [sender] = await world.connect(statements.all);
await sender.publishAsync('test', 'test');
await vi.waitUntil(() => spies.every((s) => s.mock.calls.length));
});
});

4
tests/tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.json",
"include": ["../src/**/*.ts", "./**/*.ts"]
}

View File

@@ -0,0 +1,25 @@
import type { Statement } from '#root/access/access.schemas.ts';
const statements = {
all: [
{
effect: 'allow',
resources: ['**'],
actions: ['**'],
},
],
noRead: [
{
effect: 'allow',
resources: ['**'],
actions: ['**'],
},
{
effect: 'disallow',
resources: ['**'],
actions: ['mqtt:read'],
},
],
} satisfies Record<string, Statement[]>;
export { statements };

View File

@@ -0,0 +1,73 @@
import mqtt, { connectAsync, MqttClient } from 'mqtt';
import getPort from 'get-port';
import { AccessHandler } from '#root/access/access.handler.ts';
import { type Statement } from '#root/access/access.schemas.ts';
import { AccessTokens } from '#root/access/access.token.ts';
import { MqttServer } from '#root/server/server.ts';
import type { TopicDefinition } from '#root/topics/topcis.schemas.ts';
import { TopicsHandler } from '#root/topics/topics.handler.ts';
import { TopicsStore } from '#root/topics/topics.store.ts';
type CreateSocketOptions = {
port: number;
token: string;
};
const createSocket = async (options: CreateSocketOptions) => {
const { port, token } = options;
const mqttClient = await connectAsync(`ws://localhost:${port}/ws`, {
username: 'token',
password: token,
reconnectOnConnackError: false,
});
return mqttClient;
};
type WorldOptions = {
topics?: TopicDefinition[];
};
const createWorld = async (options: WorldOptions) => {
const { topics = [] } = options;
const secret = 'test';
const accessHandler = new AccessHandler();
const accessTokens = new AccessTokens({
secret,
});
accessHandler.register('token', accessTokens);
const topicsHandler = new TopicsHandler();
const topicsStore = new TopicsStore();
topicsStore.register(...topics);
topicsHandler.register(topicsStore);
const server = new MqttServer({ topicsHandler, accessHandler });
const fastify = await server.getHttpServer();
const port = await getPort();
await fastify.listen({ port });
const sockets: MqttClient[] = [];
return {
connect: async (...clients: Statement[][]) => {
const newSockets = await Promise.all(
clients.map((statements) =>
createSocket({
port,
token: accessTokens.generate({
statements,
}),
}),
),
);
sockets.push(...newSockets);
return newSockets;
},
destroy: async () => {
await Promise.all(sockets.map((s) => s.endAsync()));
await fastify.close();
},
};
};
type World = Awaited<ReturnType<typeof createWorld>>;
export type { World };
export { createWorld };