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:
495
scripts/api-onboarding-test.mjs
Normal file
495
scripts/api-onboarding-test.mjs
Normal file
@@ -0,0 +1,495 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const baseUrl = process.argv[2] ?? process.env.API_BASE_URL ?? "http://127.0.0.1:8080";
|
||||
const email = `admin+${Date.now()}@example.com`;
|
||||
|
||||
async function request(method, path, body, token, options = {}) {
|
||||
const response = await fetch(`${baseUrl}${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 ? parseResponseBody(text) : {};
|
||||
const expectedStatus = options.expectedStatus;
|
||||
|
||||
if (expectedStatus !== undefined) {
|
||||
if (response.status !== expectedStatus) {
|
||||
throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(data)}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function parseResponseBody(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`testing onboarding api via ${baseUrl}`);
|
||||
|
||||
const registration = await request("POST", "/api/v1/registration/organization", {
|
||||
organization_name: "Muster GmbH",
|
||||
email,
|
||||
accept_terms: true,
|
||||
});
|
||||
assert(registration.id, "registration id missing");
|
||||
console.log(`registered ${registration.id}`);
|
||||
|
||||
const registrations = await request("GET", "/api/v1/admin/organization-registrations");
|
||||
assert(
|
||||
registrations.some((item) => item.id === registration.id),
|
||||
"registration not found in list",
|
||||
);
|
||||
|
||||
const detail = await request(
|
||||
"GET",
|
||||
`/api/v1/admin/organization-registrations/${registration.id}`,
|
||||
);
|
||||
assert(detail.organization_name === "Muster GmbH", "organization name did not decrypt");
|
||||
assert(detail.email === email, "registration email mismatch");
|
||||
|
||||
const approval = await request(
|
||||
"POST",
|
||||
`/api/v1/admin/organization-registrations/${registration.id}/approve`,
|
||||
);
|
||||
assert(approval.organization_id, "organization id missing");
|
||||
assert(approval.schema_name?.startsWith("company_"), "schema name missing");
|
||||
assert(approval.dev_initial_password, "dev initial password missing");
|
||||
console.log(`approved ${approval.organization_id}`);
|
||||
|
||||
const login = await request("POST", "/api/v1/auth/login", {
|
||||
email,
|
||||
password: approval.dev_initial_password,
|
||||
});
|
||||
assert(login.must_change_password === true, "must_change_password should be true");
|
||||
assert(login.access_token, "access token missing");
|
||||
assert(login.organization_id, "selected organization missing");
|
||||
assert(login.organizations.length >= 1, "login organizations missing");
|
||||
const token = login.access_token;
|
||||
|
||||
const selected = await request(
|
||||
"POST",
|
||||
"/api/v1/auth/select-organization",
|
||||
{ organization_id: login.organization_id },
|
||||
token,
|
||||
);
|
||||
assert(selected.selected === true, "organization was not selected");
|
||||
|
||||
const users = await request("GET", "/api/v1/organizations/current/users", undefined, token);
|
||||
assert(users.length >= 1, "users list is empty");
|
||||
assert(users.some((user) => user.email === email), "owner user missing");
|
||||
|
||||
const setup = await request("PUT", "/api/v1/organizations/current/setup", {
|
||||
display_name: "Muster GmbH",
|
||||
legal_form: "GmbH",
|
||||
street: "Musterstrasse 1",
|
||||
postal_code: "12345",
|
||||
city: "Musterstadt",
|
||||
country: "Deutschland",
|
||||
vat_id: "",
|
||||
email: "info@example.com",
|
||||
phone: "",
|
||||
default_tax_rate: "19",
|
||||
default_payment_days: "14",
|
||||
}, token);
|
||||
assert(setup.saved === true, "organization setup was not saved");
|
||||
|
||||
const loadedSetup = await request("GET", "/api/v1/organizations/current/setup", undefined, token);
|
||||
assert(loadedSetup.setup?.display_name === "Muster GmbH", "organization setup was not loaded");
|
||||
assert(loadedSetup.setup?.street === "Musterstrasse 1", "organization setup street mismatch");
|
||||
|
||||
const numberRanges = await request("GET", "/api/v1/number-ranges", undefined, token);
|
||||
assert(numberRanges.some((range) => range.code === "customers" && range.pattern === "KU{counter}"), "customer number range missing");
|
||||
assert(numberRanges.some((range) => range.code === "items" && range.pattern === "AR{counter}"), "item number range missing");
|
||||
assert(numberRanges.some((range) => range.code === "activities" && range.pattern === "AK{counter}"), "activity number range missing");
|
||||
assert(numberRanges.some((range) => range.code === "outgoing_invoices" && range.pattern === "AR{counter}"), "invoice number range missing");
|
||||
|
||||
const cashDiscountTerm = await request("POST", "/api/v1/cash-discount-terms", {
|
||||
code: "2-10-30",
|
||||
name: "2 % Skonto bei Zahlung innerhalb von 10 Tagen",
|
||||
discount_percent: "2.00",
|
||||
discount_days: 10,
|
||||
net_days: 30,
|
||||
valid_from: null,
|
||||
valid_until: null,
|
||||
is_default_customer_term: true,
|
||||
is_default_supplier_term: true,
|
||||
is_active: true,
|
||||
}, token);
|
||||
assert(cashDiscountTerm.id, "cash discount term id missing");
|
||||
const cashDiscountTerms = await request("GET", "/api/v1/cash-discount-terms", undefined, token);
|
||||
assert(cashDiscountTerms.some((term) => term.id === cashDiscountTerm.id), "cash discount term missing");
|
||||
|
||||
const createdCustomer = await request("POST", "/api/v1/customers", {
|
||||
customer_number: "",
|
||||
name: "Beispielkunde GmbH",
|
||||
status: "active",
|
||||
details: {
|
||||
street: "Kundenstraße 4",
|
||||
postal_code: "54321",
|
||||
city: "Kundenstadt",
|
||||
country: "Deutschland",
|
||||
email: "kunde@example.com",
|
||||
phone: "01234 56789",
|
||||
},
|
||||
standard_discount_percent: "5.50",
|
||||
cash_discount_term_id: cashDiscountTerm.id,
|
||||
}, token);
|
||||
assert(createdCustomer.id, "customer id missing");
|
||||
assert(/^KU\d{3}\.\d{3}\.\d{3}$/.test(createdCustomer.customer_number), "customer number was not generated");
|
||||
|
||||
const customers = await request("GET", "/api/v1/customers", undefined, token);
|
||||
const listedCustomer = customers.find((customer) => customer.id === createdCustomer.id);
|
||||
assert(listedCustomer?.name === "Beispielkunde GmbH", "customer name was not loaded");
|
||||
assert(listedCustomer?.details.city === "Kundenstadt", "customer details were not loaded");
|
||||
assert(listedCustomer?.standard_discount_percent.startsWith("5.5"), "customer discount missing");
|
||||
assert(listedCustomer?.cash_discount_term_id === cashDiscountTerm.id, "customer cash discount missing");
|
||||
|
||||
const updatedCustomer = await request("PUT", `/api/v1/customers/${createdCustomer.id}`, {
|
||||
...createdCustomer,
|
||||
name: "Beispielkunde AG",
|
||||
standard_discount_percent: "7.00",
|
||||
}, token);
|
||||
assert(updatedCustomer.name === "Beispielkunde AG", "customer update failed");
|
||||
|
||||
const supplier = await request("POST", "/api/v1/suppliers", {
|
||||
supplier_number: "",
|
||||
name: "Beispiellieferant GmbH",
|
||||
status: "active",
|
||||
details: { street: "Lieferweg 1", postal_code: "10115", city: "Berlin", country: "Deutschland", email: "lieferant@example.com", phone: "" },
|
||||
standard_discount_percent: "2.00",
|
||||
cash_discount_term_id: cashDiscountTerm.id,
|
||||
payment_days: 30,
|
||||
}, token);
|
||||
assert(/^LI\d{3}\.\d{3}\.\d{3}$/.test(supplier.supplier_number), "supplier number was not generated");
|
||||
const suppliers = await request("GET", "/api/v1/suppliers", undefined, token);
|
||||
assert(suppliers.some((record) => record.id === supplier.id && record.details.city === "Berlin"), "supplier CRUD failed");
|
||||
assert(suppliers.some((record) => record.id === supplier.id && record.cash_discount_term_id === cashDiscountTerm.id), "supplier cash discount missing");
|
||||
|
||||
const item = await request("POST", "/api/v1/items", {
|
||||
item_number: "",
|
||||
name: "Montagestunde",
|
||||
unit: "Std",
|
||||
tax_rate: "19",
|
||||
default_purchase_price: "40.00",
|
||||
default_sales_price: "85.00",
|
||||
status: "active",
|
||||
}, token);
|
||||
assert(/^AR\d{3}\.\d{3}\.\d{3}$/.test(item.item_number), "item number was not generated");
|
||||
const updatedItem = await request("PUT", `/api/v1/items/${item.id}`, { ...item, default_sales_price: "95.00" }, token);
|
||||
assert(updatedItem.default_sales_price === "95.00", "item update failed");
|
||||
const priceHistory = await request("GET", `/api/v1/items/${item.id}/prices`, undefined, token);
|
||||
assert(priceHistory.length >= 2, "item price history missing");
|
||||
assert(priceHistory.some((entry) => entry.sales_price?.startsWith("95")), "updated item price history missing");
|
||||
|
||||
const priceListContent = [
|
||||
"item_number;name;unit;tax_rate;purchase_price;sales_price",
|
||||
"IMP-100;Importartikel;Stk;19;10.00;25.00",
|
||||
`${item.item_number};Montagestunde Import;Std;19;42.00;99.00`,
|
||||
].join("\n");
|
||||
const importPreview = await request("POST", "/api/v1/imports/price-list/preview", {
|
||||
source_name: "api-test-price-list.csv",
|
||||
delimiter: ";",
|
||||
content: priceListContent,
|
||||
}, token);
|
||||
assert(importPreview.total_rows === 2, "price import preview row count mismatch");
|
||||
assert(importPreview.valid_rows === 2, "price import preview valid rows mismatch");
|
||||
assert(importPreview.rows.some((row) => row.item_number === "IMP-100" && row.action === "create"), "price import create action missing");
|
||||
assert(importPreview.rows.some((row) => row.item_number === item.item_number && row.action === "update"), "price import update action missing");
|
||||
|
||||
const importApply = await request("POST", "/api/v1/imports/price-list/apply", {
|
||||
source_name: "api-test-price-list.csv",
|
||||
delimiter: ";",
|
||||
content: priceListContent,
|
||||
}, token);
|
||||
assert(importApply.import_id, "price import id missing");
|
||||
assert(importApply.applied_rows === 2, "price import applied rows mismatch");
|
||||
assert(importApply.error_rows === 0, "price import errors mismatch");
|
||||
|
||||
const importedItems = await request("GET", "/api/v1/items", undefined, token);
|
||||
assert(importedItems.some((record) => record.item_number === "IMP-100" && record.name === "Importartikel"), "imported item missing");
|
||||
const reloadedItem = importedItems.find((record) => record.id === item.id);
|
||||
assert(reloadedItem?.default_sales_price?.startsWith("99"), "imported item price update missing");
|
||||
const importedPriceHistory = await request("GET", `/api/v1/items/${item.id}/prices`, undefined, token);
|
||||
assert(importedPriceHistory.some((entry) => entry.source.startsWith("import:") && entry.sales_price?.startsWith("99")), "import price history missing");
|
||||
|
||||
const connector = await request("POST", "/api/v1/api-connectors", {
|
||||
code: "demo_price_api",
|
||||
name: "Demo Preis API",
|
||||
connector_type: "demo",
|
||||
config: {
|
||||
base_url: "https://example.test/api",
|
||||
token: "secret",
|
||||
delimiter: ";",
|
||||
price_list_csv: [
|
||||
"item_number;name;unit;tax_rate;purchase_price;sales_price",
|
||||
"IMP-100;Importartikel API;Stk;19;11.00;27.00",
|
||||
].join("\n"),
|
||||
},
|
||||
is_active: true,
|
||||
sync_interval_minutes: 60,
|
||||
}, token);
|
||||
assert(connector.id, "api connector id missing");
|
||||
assert(connector.config?.token === "secret", "api connector config roundtrip failed");
|
||||
const connectors = await request("GET", "/api/v1/api-connectors", undefined, token);
|
||||
assert(connectors.some((record) => record.id === connector.id && record.config?.base_url === "https://example.test/api"), "api connector list missing");
|
||||
const connectorSync = await request("POST", `/api/v1/api-connectors/${connector.id}/sync`, {}, token);
|
||||
assert(connectorSync.synced === true, "api connector sync failed");
|
||||
assert(connectorSync.applied_rows === 1, "api connector price sync did not apply rows");
|
||||
const syncedItems = await request("GET", "/api/v1/items", undefined, token);
|
||||
assert(syncedItems.some((record) => record.item_number === "IMP-100" && record.default_sales_price?.startsWith("27")), "api connector price update missing");
|
||||
const deletedConnector = await request("DELETE", `/api/v1/api-connectors/${connector.id}`, undefined, token);
|
||||
assert(deletedConnector.deleted === true, "api connector deactivate failed");
|
||||
|
||||
const priceRule = await request("POST", "/api/v1/price-rules", {
|
||||
code: "standard_import_markup",
|
||||
name: "Standardaufschlag Import",
|
||||
source_type: "import",
|
||||
source_id: null,
|
||||
markup_percent: "25.00",
|
||||
rounding_mode: "cent",
|
||||
is_active: true,
|
||||
}, token);
|
||||
assert(priceRule.id, "price rule id missing");
|
||||
const priceRules = await request("GET", "/api/v1/price-rules", undefined, token);
|
||||
assert(priceRules.some((record) => record.id === priceRule.id && record.markup_percent.startsWith("25")), "price rule list missing");
|
||||
const updatedPriceRule = await request("PUT", `/api/v1/price-rules/${priceRule.id}`, {
|
||||
...priceRule,
|
||||
markup_percent: "30.00",
|
||||
rounding_mode: "five_cent",
|
||||
}, token);
|
||||
assert(updatedPriceRule.rounding_mode === "five_cent", "price rule update failed");
|
||||
const deletedPriceRule = await request("DELETE", `/api/v1/price-rules/${priceRule.id}`, undefined, token);
|
||||
assert(deletedPriceRule.deleted === true, "price rule deactivate failed");
|
||||
|
||||
const quote = await request("POST", "/api/v1/quotes", {
|
||||
quote_number: "",
|
||||
customer_id: createdCustomer.id,
|
||||
status: "draft",
|
||||
valid_until: null,
|
||||
cash_discount_term_id: cashDiscountTerm.id,
|
||||
customer_discount_percent: "7.00",
|
||||
notes: "Erstes Testangebot.",
|
||||
items: [{
|
||||
item_id: item.id,
|
||||
description: "Montagestunde mit Sonderpreis",
|
||||
quantity: "2.00",
|
||||
unit_price: "90.00",
|
||||
original_unit_price: "95.00",
|
||||
discount_percent: "0.00",
|
||||
tax_rate: "19.00",
|
||||
}],
|
||||
}, token);
|
||||
assert(/^AN\d{3}\.\d{3}\.\d{3}$/.test(quote.quote_number), "quote number was not generated");
|
||||
assert(quote.items.length === 1, "quote item missing");
|
||||
assert(quote.items[0].price_overridden === true, "quote price override missing");
|
||||
|
||||
const quotes = await request("GET", "/api/v1/quotes", undefined, token);
|
||||
assert(quotes.some((record) => record.id === quote.id && record.notes.includes("Testangebot")), "quote was not loaded");
|
||||
const updatedQuote = await request("PUT", `/api/v1/quotes/${quote.id}`, {
|
||||
...quote,
|
||||
status: "sent",
|
||||
items: quote.items.map((line) => ({ ...line, quantity: "3.00" })),
|
||||
}, token);
|
||||
assert(updatedQuote.status === "sent", "quote update failed");
|
||||
const convertedInvoice = await request("POST", `/api/v1/quotes/${quote.id}/convert-to-invoice`, undefined, token);
|
||||
assert(/^AR\d{3}\.\d{3}\.\d{3}$/.test(convertedInvoice.invoice_number), "converted invoice number missing");
|
||||
assert(convertedInvoice.source_quote_id === quote.id, "converted invoice quote link missing");
|
||||
assert(convertedInvoice.items.length === 1, "converted invoice item missing");
|
||||
const invoices = await request("GET", "/api/v1/outgoing-invoices", undefined, token);
|
||||
assert(invoices.some((record) => record.id === convertedInvoice.id), "outgoing invoice was not loaded");
|
||||
const finalized = await request("POST", `/api/v1/outgoing-invoices/${convertedInvoice.id}/finalize`, undefined, token);
|
||||
assert(finalized.finalized === true, "outgoing invoice finalize failed");
|
||||
const deletedQuote = await request("DELETE", `/api/v1/quotes/${quote.id}`, undefined, token);
|
||||
assert(deletedQuote.deleted === true, "quote cancel failed");
|
||||
|
||||
const incomingInvoice = await request("POST", "/api/v1/incoming-invoices", {
|
||||
invoice_number: "EXT-10001",
|
||||
supplier_id: supplier.id,
|
||||
status: "received",
|
||||
cash_discount_term_id: cashDiscountTerm.id,
|
||||
invoice_date: null,
|
||||
due_at: null,
|
||||
items: [{
|
||||
item_id: item.id,
|
||||
description: "Einkauf Montagestunde",
|
||||
quantity: "1.00",
|
||||
unit_price: "40.00",
|
||||
tax_rate: "19.00",
|
||||
}],
|
||||
}, token);
|
||||
assert(incomingInvoice.id, "incoming invoice id missing");
|
||||
assert(incomingInvoice.cash_discount_term_id === cashDiscountTerm.id, "incoming invoice cash discount missing");
|
||||
const incomingInvoices = await request("GET", "/api/v1/incoming-invoices", undefined, token);
|
||||
assert(incomingInvoices.some((record) => record.id === incomingInvoice.id), "incoming invoice was not loaded");
|
||||
const deletedIncomingInvoice = await request("DELETE", `/api/v1/incoming-invoices/${incomingInvoice.id}`, undefined, token);
|
||||
assert(deletedIncomingInvoice.deleted === true, "incoming invoice cancel failed");
|
||||
|
||||
const deletedItem = await request("DELETE", `/api/v1/items/${item.id}`, undefined, token);
|
||||
assert(deletedItem.deleted === true, "item deactivate failed");
|
||||
|
||||
const deletedSupplier = await request("DELETE", `/api/v1/suppliers/${supplier.id}`, undefined, token);
|
||||
assert(deletedSupplier.deleted === true, "supplier deactivate failed");
|
||||
|
||||
const deletedCustomer = await request("DELETE", `/api/v1/customers/${createdCustomer.id}`, undefined, token);
|
||||
assert(deletedCustomer.deleted === true, "customer deactivate failed");
|
||||
|
||||
const activity = await request("POST", "/api/v1/activities", {
|
||||
activity_number: null,
|
||||
activity_type: "task",
|
||||
title: "Angebot prüfen",
|
||||
body: "Preise mit Kunde abstimmen.",
|
||||
status: "open",
|
||||
priority: "high",
|
||||
due_at: null,
|
||||
}, token);
|
||||
assert(/^AK\d{3}\.\d{3}\.\d{3}$/.test(activity.activity_number), "activity number was not generated");
|
||||
const activities = await request("GET", "/api/v1/activities", undefined, token);
|
||||
assert(activities.some((record) => record.id === activity.id && record.body.includes("Preise")), "activity CRUD failed");
|
||||
const deletedActivity = await request("DELETE", `/api/v1/activities/${activity.id}`, undefined, token);
|
||||
assert(deletedActivity.deleted === true, "activity cancel failed");
|
||||
|
||||
const communication = await request("POST", "/api/v1/communications", {
|
||||
communication_type: "email",
|
||||
direction: "outbound",
|
||||
subject: "Rückfrage zum Angebot",
|
||||
body: "Kunde bittet um aktualisierte Dokumente.",
|
||||
status: "open",
|
||||
occurred_at: null,
|
||||
links: [{ entity_type: "customer", entity_id: createdCustomer.id }],
|
||||
}, token);
|
||||
assert(communication.id, "communication id missing");
|
||||
assert(communication.subject === "Rückfrage zum Angebot", "communication subject mismatch");
|
||||
assert(communication.links.some((link) => link.entity_id === createdCustomer.id), "communication link missing");
|
||||
const communications = await request("GET", "/api/v1/communications", undefined, token);
|
||||
assert(communications.some((record) => record.id === communication.id), "communication list missing");
|
||||
|
||||
const documentContent = Buffer.from("Dokumentinhalt für Phase 6", "utf8").toString("base64");
|
||||
const documentRecord = await request("POST", "/api/v1/documents", {
|
||||
title: "Testdokument",
|
||||
description: "Dokument für Phase 6",
|
||||
file_name: "phase-6.txt",
|
||||
content_type: "text/plain",
|
||||
content_base64: documentContent,
|
||||
links: [{ entity_type: "communication", entity_id: communication.id }],
|
||||
}, token);
|
||||
assert(documentRecord.id, "document id missing");
|
||||
assert(documentRecord.latest_version?.file_name === "phase-6.txt", "document metadata missing");
|
||||
const documents = await request("GET", "/api/v1/documents", undefined, token);
|
||||
assert(documents.some((record) => record.id === documentRecord.id), "document list missing");
|
||||
const downloadedDocument = await request("GET", `/api/v1/documents/${documentRecord.id}/download`, undefined, token);
|
||||
assert(downloadedDocument.content_base64 === documentContent, "document download content mismatch");
|
||||
const auditLog = await request("GET", `/api/v1/documents/${documentRecord.id}/audit-log`, undefined, token);
|
||||
assert(auditLog.some((entry) => entry.action === "upload"), "document upload audit missing");
|
||||
assert(auditLog.some((entry) => entry.action === "download"), "document download audit missing");
|
||||
const deletedDocument = await request("DELETE", `/api/v1/documents/${documentRecord.id}`, undefined, token);
|
||||
assert(deletedDocument.deleted === true, "document archive failed");
|
||||
|
||||
const invitedEmail = `user+${Date.now()}@example.com`;
|
||||
const invitation = await request("POST", "/api/v1/organizations/current/invitations", {
|
||||
email: invitedEmail,
|
||||
roles: ["viewer"],
|
||||
}, token);
|
||||
assert(invitation.id, "invitation id missing");
|
||||
assert(invitation.dev_invitation_token, "dev invitation token missing");
|
||||
|
||||
const invitedUsers = await request("GET", "/api/v1/organizations/current/users", undefined, token);
|
||||
const invitedUser = invitedUsers.find((user) => user.user_id === invitation.user_id);
|
||||
assert(invitedUser, "invited user missing");
|
||||
const acceptedInvitation = await request("POST", "/api/v1/auth/accept-invitation", {
|
||||
token: invitation.dev_invitation_token,
|
||||
new_password: "InvitePass123",
|
||||
new_password_confirm: "InvitePass123",
|
||||
});
|
||||
assert(acceptedInvitation.accepted === true, "invitation accept failed");
|
||||
const invitedLogin = await request("POST", "/api/v1/auth/login", {
|
||||
email: invitedEmail,
|
||||
password: "InvitePass123",
|
||||
});
|
||||
assert(invitedLogin.access_token, "invited user login failed");
|
||||
assert(invitedLogin.organization_id, "invited user selected organization missing");
|
||||
const invitedToken = invitedLogin.access_token;
|
||||
|
||||
await request(
|
||||
"POST",
|
||||
"/api/v1/auth/select-organization",
|
||||
{ organization_id: invitedLogin.organization_id },
|
||||
invitedToken,
|
||||
);
|
||||
|
||||
const deniedCustomerWrite = await request("POST", "/api/v1/customers", {
|
||||
customer_number: "",
|
||||
name: "Nicht erlaubt GmbH",
|
||||
status: "active",
|
||||
details: {
|
||||
street: "Sperrweg 1",
|
||||
postal_code: "12345",
|
||||
city: "Teststadt",
|
||||
country: "Deutschland",
|
||||
email: "nicht-erlaubt@example.test",
|
||||
phone: "",
|
||||
},
|
||||
standard_discount_percent: "0",
|
||||
cash_discount_term_id: null,
|
||||
}, invitedToken, { expectedStatus: 403 });
|
||||
assert(deniedCustomerWrite.message === "Berechtigung fehlt", "viewer customer write was not forbidden");
|
||||
|
||||
const deniedRoleWrite = await request(
|
||||
"PATCH",
|
||||
`/api/v1/organizations/current/users/${invitation.user_id}/roles`,
|
||||
{ roles: ["admin"] },
|
||||
invitedToken,
|
||||
{ expectedStatus: 403 },
|
||||
);
|
||||
assert(deniedRoleWrite.message === "Berechtigung fehlt", "viewer role write was not forbidden");
|
||||
|
||||
await request(
|
||||
"PATCH",
|
||||
`/api/v1/organizations/current/users/${invitation.user_id}/roles`,
|
||||
{ roles: ["sales", "viewer"] },
|
||||
token,
|
||||
);
|
||||
const updatedUsers = await request("GET", "/api/v1/organizations/current/users", undefined, token);
|
||||
const updatedUser = updatedUsers.find((user) => user.user_id === invitation.user_id);
|
||||
assert(updatedUser.roles.includes("sales"), "role change was not saved");
|
||||
|
||||
const resetRequest = await request("POST", "/api/v1/auth/request-password-reset", { email });
|
||||
assert(resetRequest.queued === true, "password reset request failed");
|
||||
assert(resetRequest.dev_reset_token, "dev reset token missing");
|
||||
const resetPassword = await request("POST", "/api/v1/auth/reset-password", {
|
||||
token: resetRequest.dev_reset_token,
|
||||
new_password: "ResetPass123",
|
||||
new_password_confirm: "ResetPass123",
|
||||
});
|
||||
assert(resetPassword.changed === true, "password reset failed");
|
||||
const resetLogin = await request("POST", "/api/v1/auth/login", {
|
||||
email,
|
||||
password: "ResetPass123",
|
||||
});
|
||||
assert(resetLogin.access_token, "login after password reset failed");
|
||||
|
||||
console.log("onboarding api test ok");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
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);
|
||||
});
|
||||
130
scripts/dev-seed.mjs
Normal file
130
scripts/dev-seed.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const baseUrl = process.argv[2] ?? process.env.API_BASE_URL ?? "http://127.0.0.1:8080";
|
||||
const stamp = Date.now();
|
||||
const email = process.env.DEV_SEED_EMAIL ?? `seed-admin-${stamp}@example.test`;
|
||||
|
||||
async function request(method, path, body, token) {
|
||||
const response = await fetch(`${baseUrl}${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;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`creating development seed data via ${baseUrl}`);
|
||||
|
||||
const bootstrap = await request("POST", "/api/v1/dev/bootstrap-local", {
|
||||
organization_name: `Seed Firma ${stamp}`,
|
||||
email,
|
||||
});
|
||||
assert(bootstrap.password, "dev bootstrap password missing");
|
||||
|
||||
const login = await request("POST", "/api/v1/auth/login", {
|
||||
email,
|
||||
password: bootstrap.password,
|
||||
});
|
||||
const token = login.access_token;
|
||||
assert(token, "login token missing");
|
||||
|
||||
await request("POST", "/api/v1/auth/select-organization", {
|
||||
organization_id: login.organization_id,
|
||||
}, token);
|
||||
|
||||
const cashDiscountTerm = await request("POST", "/api/v1/cash-discount-terms", {
|
||||
code: `SEED-${stamp}`,
|
||||
name: "2 % Skonto, 30 Tage netto",
|
||||
discount_percent: "2.00",
|
||||
discount_days: 10,
|
||||
net_days: 30,
|
||||
valid_from: null,
|
||||
valid_until: null,
|
||||
is_default_customer_term: true,
|
||||
is_default_supplier_term: true,
|
||||
is_active: true,
|
||||
}, token);
|
||||
|
||||
const customer = await request("POST", "/api/v1/customers", {
|
||||
customer_number: "",
|
||||
name: "Seed Kunde GmbH",
|
||||
status: "active",
|
||||
details: {
|
||||
street: "Kundenweg 10",
|
||||
postal_code: "60311",
|
||||
city: "Frankfurt",
|
||||
country: "Deutschland",
|
||||
email: "kunde@example.test",
|
||||
phone: "",
|
||||
},
|
||||
standard_discount_percent: "5.00",
|
||||
cash_discount_term_id: cashDiscountTerm.id,
|
||||
}, token);
|
||||
|
||||
const supplier = await request("POST", "/api/v1/suppliers", {
|
||||
supplier_number: "",
|
||||
name: "Seed Lieferant GmbH",
|
||||
status: "active",
|
||||
details: {
|
||||
street: "Lieferstraße 8",
|
||||
postal_code: "10115",
|
||||
city: "Berlin",
|
||||
country: "Deutschland",
|
||||
email: "lieferant@example.test",
|
||||
phone: "",
|
||||
},
|
||||
standard_discount_percent: "0.00",
|
||||
cash_discount_term_id: cashDiscountTerm.id,
|
||||
payment_days: 30,
|
||||
}, token);
|
||||
|
||||
const item = await request("POST", "/api/v1/items", {
|
||||
item_number: "",
|
||||
name: "Seed Montagestunde",
|
||||
unit: "Std",
|
||||
tax_rate: "19.00",
|
||||
default_purchase_price: "40.00",
|
||||
default_sales_price: "85.00",
|
||||
status: "active",
|
||||
}, token);
|
||||
|
||||
const activity = await request("POST", "/api/v1/activities", {
|
||||
activity_number: null,
|
||||
activity_type: "task",
|
||||
title: "Seed Aktivität",
|
||||
body: "Testdaten für lokale Entwicklung.",
|
||||
status: "open",
|
||||
priority: "normal",
|
||||
due_at: null,
|
||||
}, token);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
email,
|
||||
password: bootstrap.password,
|
||||
organization_id: login.organization_id,
|
||||
customer_number: customer.customer_number,
|
||||
supplier_number: supplier.supplier_number,
|
||||
item_number: item.item_number,
|
||||
activity_number: activity.activity_number,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
92
scripts/schema-migration-test.mjs
Normal file
92
scripts/schema-migration-test.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const baseUrl = process.argv[2] ?? process.env.API_BASE_URL ?? "http://127.0.0.1:8080";
|
||||
const email = `schema-migration-${Date.now()}@example.test`;
|
||||
|
||||
async function request(method, path, body, token) {
|
||||
const response = await fetch(`${baseUrl}${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;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`testing schema migration idempotency via ${baseUrl}`);
|
||||
|
||||
const registration = await request("POST", "/api/v1/registration/organization", {
|
||||
organization_name: "Migrationstest GmbH",
|
||||
email,
|
||||
accept_terms: true,
|
||||
});
|
||||
|
||||
const approval = await request(
|
||||
"POST",
|
||||
`/api/v1/admin/organization-registrations/${registration.id}/approve`,
|
||||
);
|
||||
assert(approval.schema_name, "schema name missing after approval");
|
||||
|
||||
const retry = await request(
|
||||
"POST",
|
||||
`/api/v1/admin/organization-registrations/${registration.id}/retry-provisioning`,
|
||||
);
|
||||
assert(retry.provisioned === true, "retry provisioning did not report success");
|
||||
assert(retry.schema_name === approval.schema_name, "retry provisioning schema mismatch");
|
||||
|
||||
const login = await request("POST", "/api/v1/auth/login", {
|
||||
email,
|
||||
password: approval.dev_initial_password,
|
||||
});
|
||||
const token = login.access_token;
|
||||
await request("POST", "/api/v1/auth/select-organization", {
|
||||
organization_id: login.organization_id,
|
||||
}, token);
|
||||
|
||||
const ranges = await request("GET", "/api/v1/number-ranges", undefined, token);
|
||||
for (const code of ["customers", "suppliers", "items", "activities", "outgoing_invoices", "incoming_invoices", "quotes"]) {
|
||||
assert(ranges.some((range) => range.code === code), `number range missing after retry: ${code}`);
|
||||
}
|
||||
|
||||
const users = await request("GET", "/api/v1/organizations/current/users", undefined, token);
|
||||
const owner = users.find((user) => user.email === email);
|
||||
assert(owner?.roles.includes("owner"), "owner role missing after retry");
|
||||
assert(owner?.roles.includes("admin"), "admin role missing after retry");
|
||||
|
||||
const communication = await request("POST", "/api/v1/communications", {
|
||||
communication_type: "internal_note",
|
||||
direction: "internal",
|
||||
subject: "Migrationstest",
|
||||
body: "Kommunikationstabellen sind vorhanden.",
|
||||
status: "open",
|
||||
occurred_at: null,
|
||||
links: [],
|
||||
}, token);
|
||||
assert(communication.id, "communication insert failed after retry");
|
||||
|
||||
const navigationSettings = await request("PUT", "/api/v1/users/me/settings/navigation", {
|
||||
mode: "groups",
|
||||
}, token);
|
||||
assert(navigationSettings.mode === "groups", "navigation user setting was not saved");
|
||||
|
||||
console.log("schema migration idempotency test ok");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
26
scripts/standard-check.sh
Normal file
26
scripts/standard-check.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "== Rust format =="
|
||||
cargo fmt --all -- --check
|
||||
|
||||
echo "== Rust workspace check =="
|
||||
cargo check --workspace
|
||||
|
||||
echo "== Desktopclient headless unit tests =="
|
||||
cargo test -p companytool-desktop-client
|
||||
|
||||
echo "== Node script syntax =="
|
||||
node --check scripts/api-onboarding-test.mjs
|
||||
node --check scripts/communication-test.mjs
|
||||
node --check scripts/dev-seed.mjs
|
||||
node --check scripts/schema-migration-test.mjs
|
||||
|
||||
echo "== Webfrontend build and type check =="
|
||||
npm --prefix web-frontend run build
|
||||
|
||||
echo "standard check ok"
|
||||
139
scripts/ws-smoke-test.mjs
Normal file
139
scripts/ws-smoke-test.mjs
Normal file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
const wsUrl = process.argv[2] ?? process.env.WS_URL ?? "ws://localhost:8080/ws";
|
||||
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 waitForMessage(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(JSON.parse(event.data));
|
||||
}
|
||||
|
||||
socket.addEventListener("message", onMessage, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const session = await createSession();
|
||||
const socket = new WebSocket(wsUrl);
|
||||
|
||||
console.log(`connecting ${wsUrl}`);
|
||||
await waitForOpen(socket);
|
||||
console.log("socket open");
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: "hello",
|
||||
payload: {
|
||||
protocol_version: protocolVersion,
|
||||
key_id: session.keyId,
|
||||
session_key: session.exportedKey,
|
||||
},
|
||||
}));
|
||||
|
||||
const ack = await waitForMessage(socket);
|
||||
if (ack.type !== "hello_ack") {
|
||||
throw new Error(`expected hello_ack, got ${JSON.stringify(ack)}`);
|
||||
}
|
||||
console.log(`hello_ack protocol=${ack.payload.protocol_version} key=${ack.payload.key_id}`);
|
||||
|
||||
const firstEncrypted = await waitForMessage(socket);
|
||||
if (firstEncrypted.type !== "encrypted") {
|
||||
throw new Error(`expected encrypted snapshot, got ${JSON.stringify(firstEncrypted)}`);
|
||||
}
|
||||
const snapshot = await decryptMessage(session, firstEncrypted.payload);
|
||||
console.log(`decrypted first server message: ${snapshot.type}`);
|
||||
|
||||
const subscribe = await encryptMessage(session, {
|
||||
type: "subscribe",
|
||||
payload: { topic: "records" },
|
||||
});
|
||||
socket.send(JSON.stringify({ type: "encrypted", payload: subscribe }));
|
||||
console.log("sent encrypted subscribe");
|
||||
|
||||
const ping = await encryptMessage(session, { type: "ping" });
|
||||
socket.send(JSON.stringify({ type: "encrypted", payload: ping }));
|
||||
|
||||
const pongEnvelope = await waitForMessage(socket);
|
||||
if (pongEnvelope.type !== "encrypted") {
|
||||
throw new Error(`expected encrypted pong, got ${JSON.stringify(pongEnvelope)}`);
|
||||
}
|
||||
const pong = await decryptMessage(session, pongEnvelope.payload);
|
||||
console.log(`decrypted ping response: ${pong.type}`);
|
||||
|
||||
socket.close();
|
||||
console.log("communication smoke test ok");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user