feat: Add password reset functionality with request and reset forms
feat: Implement price list import feature with preview and apply options feat: Create price rules management page with CRUD operations feat: Develop quotes management page with itemized quotes and status tracking feat: Introduce organization registration page for new users feat: Build suppliers management page with detailed supplier information feat: Create users management page for inviting and managing roles chore: Add TypeScript configuration for improved type checking chore: Set up Vite configuration for development server and API proxy chore: Add Vite environment type definitions for better TypeScript support
This commit is contained in:
258
scripts/communication-test.mjs
Normal file
258
scripts/communication-test.mjs
Normal file
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { webcrypto } from "node:crypto";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const wsUrl = process.argv[2] ?? process.env.WS_URL ?? "ws://localhost:8080/ws";
|
||||
const apiBaseUrl = process.argv[3] ?? process.env.API_BASE_URL;
|
||||
const protocolVersion = 1;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
|
||||
function base64ToBytes(value) {
|
||||
return Buffer.from(value, "base64");
|
||||
}
|
||||
|
||||
async function createSession() {
|
||||
const key = await webcrypto.subtle.generateKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
const rawKey = await webcrypto.subtle.exportKey("raw", key);
|
||||
|
||||
return {
|
||||
key,
|
||||
keyId: webcrypto.randomUUID(),
|
||||
exportedKey: bytesToBase64(new Uint8Array(rawKey)),
|
||||
};
|
||||
}
|
||||
|
||||
async function encryptMessage(session, message) {
|
||||
const nonce = webcrypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintext = encoder.encode(JSON.stringify(message));
|
||||
const ciphertext = await webcrypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
session.key,
|
||||
plaintext,
|
||||
);
|
||||
|
||||
return {
|
||||
enc: `aes-256-gcm-v${protocolVersion}`,
|
||||
key_id: session.keyId,
|
||||
nonce: bytesToBase64(nonce),
|
||||
ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptMessage(session, envelope) {
|
||||
const plaintext = await webcrypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToBytes(envelope.nonce) },
|
||||
session.key,
|
||||
base64ToBytes(envelope.ciphertext),
|
||||
);
|
||||
|
||||
return JSON.parse(decoder.decode(plaintext));
|
||||
}
|
||||
|
||||
function waitForOpen(socket) {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function waitForRawMessage(socket, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
socket.removeEventListener("message", onMessage);
|
||||
reject(new Error(`timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
function onMessage(event) {
|
||||
clearTimeout(timeout);
|
||||
resolve(String(event.data));
|
||||
}
|
||||
|
||||
socket.addEventListener("message", onMessage, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDecryptedType(client, expectedType, timeoutMs = 5000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const raw = await waitForRawMessage(client.socket, Math.max(100, deadline - Date.now()));
|
||||
const wire = JSON.parse(raw);
|
||||
|
||||
assert.equal(wire.type, "encrypted", `${client.name}: expected encrypted wire message`);
|
||||
assert.equal(wire.payload.key_id, client.session.keyId, `${client.name}: key id mismatch`);
|
||||
assertNoPlaintext(raw, client.name);
|
||||
|
||||
const message = await decryptMessage(client.session, wire.payload);
|
||||
if (message.type === expectedType) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`${client.name}: did not receive ${expectedType}`);
|
||||
}
|
||||
|
||||
async function waitForRecordChanged(client, expectedTitle, timeoutMs = 5000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const message = await waitForDecryptedType(
|
||||
client,
|
||||
"record_changed",
|
||||
Math.max(100, deadline - Date.now()),
|
||||
);
|
||||
if (!expectedTitle || message.payload.record?.title === expectedTitle) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`${client.name}: did not receive record_changed ${expectedTitle}`);
|
||||
}
|
||||
|
||||
function assertNoPlaintext(raw, clientName) {
|
||||
for (const forbidden of ["snapshot", "record_changed", "Erster Datensatz", "records"]) {
|
||||
assert.equal(
|
||||
raw.includes(forbidden),
|
||||
false,
|
||||
`${clientName}: raw frame leaked plaintext marker ${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectClient(name) {
|
||||
const session = await createSession();
|
||||
const socket = new WebSocket(wsUrl);
|
||||
|
||||
await waitForOpen(socket);
|
||||
socket.send(JSON.stringify({
|
||||
type: "hello",
|
||||
payload: {
|
||||
protocol_version: protocolVersion,
|
||||
key_id: session.keyId,
|
||||
session_key: session.exportedKey,
|
||||
},
|
||||
}));
|
||||
|
||||
const ack = JSON.parse(await waitForRawMessage(socket));
|
||||
assert.equal(ack.type, "hello_ack", `${name}: expected hello_ack`);
|
||||
assert.equal(ack.payload.protocol_version, protocolVersion, `${name}: protocol mismatch`);
|
||||
assert.equal(ack.payload.key_id, session.keyId, `${name}: ack key mismatch`);
|
||||
|
||||
const client = { name, socket, session };
|
||||
const snapshot = await waitForDecryptedType(client, "snapshot");
|
||||
assert.ok(Array.isArray(snapshot.payload.records), `${name}: snapshot records missing`);
|
||||
|
||||
await sendEncrypted(client, {
|
||||
type: "subscribe",
|
||||
payload: { topic: "records" },
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async function sendEncrypted(client, message) {
|
||||
const envelope = await encryptMessage(client.session, message);
|
||||
client.socket.send(JSON.stringify({ type: "encrypted", payload: envelope }));
|
||||
}
|
||||
|
||||
function closeClient(client) {
|
||||
if (client.socket.readyState === WebSocket.OPEN) {
|
||||
client.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method, path, body, token) {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function createLiveEventViaApi() {
|
||||
const email = `live-event-${Date.now()}@example.test`;
|
||||
const bootstrap = await request("POST", "/api/v1/dev/bootstrap-local", {
|
||||
organization_name: "Live Event Test GmbH",
|
||||
email,
|
||||
});
|
||||
const login = await request("POST", "/api/v1/auth/login", {
|
||||
email,
|
||||
password: bootstrap.password,
|
||||
});
|
||||
const token = login.access_token;
|
||||
await request("POST", "/api/v1/auth/select-organization", {
|
||||
organization_id: login.organization_id,
|
||||
}, token);
|
||||
|
||||
await request("POST", "/api/v1/activities", {
|
||||
activity_number: null,
|
||||
activity_type: "task",
|
||||
title: "Live-Event testen",
|
||||
body: "Änderung muss an alle Clients gehen.",
|
||||
status: "open",
|
||||
priority: "normal",
|
||||
due_at: null,
|
||||
}, token);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`testing encrypted communication via ${wsUrl}`);
|
||||
|
||||
const clientA = await connectClient("client-a");
|
||||
console.log("client-a handshake, encrypted snapshot and subscribe ok");
|
||||
|
||||
const clientB = await connectClient("client-b");
|
||||
console.log("client-b handshake, encrypted snapshot and subscribe ok");
|
||||
|
||||
await sendEncrypted(clientA, { type: "ping" });
|
||||
const [pongA, pongB] = await Promise.all([
|
||||
waitForDecryptedType(clientA, "pong"),
|
||||
waitForDecryptedType(clientB, "pong"),
|
||||
]);
|
||||
|
||||
assert.equal(pongA.type, "pong");
|
||||
assert.equal(pongB.type, "pong");
|
||||
assert.notEqual(clientA.session.keyId, clientB.session.keyId, "clients must use different keys");
|
||||
|
||||
if (apiBaseUrl) {
|
||||
console.log(`testing api-triggered live event via ${apiBaseUrl}`);
|
||||
const waitA = waitForRecordChanged(clientA, "Aktivität angelegt");
|
||||
const waitB = waitForRecordChanged(clientB, "Aktivität angelegt");
|
||||
await createLiveEventViaApi();
|
||||
await Promise.all([waitA, waitB]);
|
||||
console.log("api-triggered live event reached both clients");
|
||||
}
|
||||
|
||||
closeClient(clientA);
|
||||
closeClient(clientB);
|
||||
|
||||
console.log("encrypted multi-client communication test ok");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user