From 24199faaacafd8003a87c89464b403c025f58694 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Mon, 8 Jul 2024 11:30:21 -0700 Subject: [PATCH] Addressed various initialization issues and patterns (#143) There are more to be done, but this is a good start. - Fixed confusing patterns of existing async method take callbacks, and yet return immediately without invoking and awaiting on the callback. - Introduced `DwnServer.stop()` for clarity and consistency. - Introduced `HttpApi.close()` for clarity and consistency. - Added missing `removeProcessHandlers` as counterpart to `setProcessHandlers` for proper clean up. - Added `didResolver` to `DwnServerOptions` for overriding DID Resolver cache LevelDB implementation in tests to prevent DB locking issues. - Used Poller to make some tests more stable. --- package-lock.json | 79 +++++++++++++++-- package.json | 4 +- src/dwn-server.ts | 95 +++++++++++++++++---- src/http-api.ts | 36 ++++++-- src/process-handlers.ts | 67 ++++++++++----- src/storage.ts | 6 +- src/ws-api.ts | 3 +- tests/connection/connection-manager.spec.ts | 15 ++-- tests/cors.spec.ts | 5 +- tests/dwn-server.spec.ts | 10 ++- tests/http-api.spec.ts | 22 ++--- tests/process-handler.spec.ts | 55 +++++++++--- tests/scenarios/registration.spec.ts | 88 +++++++++---------- tests/scenarios/web5-connect.spec.ts | 52 ++++++----- tests/ws-api.spec.ts | 10 +-- 15 files changed, 375 insertions(+), 172 deletions(-) diff --git a/package-lock.json b/package-lock.json index a5d0fb5..1c2e835 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@web5/dwn-server", "version": "0.4.1", "dependencies": { - "@tbd54566975/dwn-sdk-js": "0.4.0", + "@tbd54566975/dwn-sdk-js": "0.4.2", "@tbd54566975/dwn-sql-store": "0.6.1", "better-sqlite3": "^8.5.0", "body-parser": "^1.20.2", @@ -27,7 +27,7 @@ "readable-stream": "4.4.2", "response-time": "2.3.2", "uuid": "9.0.0", - "ws": "8.17.1" + "ws": "8.18.0" }, "bin": { "dwn-server": "dist/esm/src/main.js" @@ -605,9 +605,9 @@ "dev": true }, "node_modules/@tbd54566975/dwn-sdk-js": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.4.0.tgz", - "integrity": "sha512-eBDjIZQEsxAagKwDbHKzML00/jXlnRN9FLnV9Qx/4UkxZdKRM7IXghFnTRE7aYkwQS8nveAVcijBw46ARSPKcw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.4.2.tgz", + "integrity": "sha512-ABwu6RMHDtTcRbI+b9AyqC3l7Hf50TJhbBaCMax9o/IV7KtM0RyMMBka5M7eGzQyHgWISykdUGW59yhe7/Z5Ig==", "dependencies": { "@ipld/dag-cbor": "9.0.3", "@js-temporal/polyfill": "0.4.4", @@ -3251,6 +3251,27 @@ "node": ">= 0.6" } }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/ent": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", @@ -7857,6 +7878,27 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/puppeteer-core/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/qjobs": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", @@ -8670,6 +8712,27 @@ "ws": "~8.17.1" } }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -9715,9 +9778,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index a8fc520..d6990b2 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "url": "https://github.com/TBD54566975/dwn-server/issues" }, "dependencies": { - "@tbd54566975/dwn-sdk-js": "0.4.0", + "@tbd54566975/dwn-sdk-js": "0.4.2", "@tbd54566975/dwn-sql-store": "0.6.1", "better-sqlite3": "^8.5.0", "body-parser": "^1.20.2", @@ -45,7 +45,7 @@ "readable-stream": "4.4.2", "response-time": "2.3.2", "uuid": "9.0.0", - "ws": "8.17.1" + "ws": "8.18.0" }, "devDependencies": { "@types/bytes": "3.1.1", diff --git a/src/dwn-server.ts b/src/dwn-server.ts index eb822a4..c4ad608 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -1,26 +1,53 @@ +import type { DidResolver } from '@web5/dids'; import type { EventStream } from '@tbd54566975/dwn-sdk-js'; -import { Dwn, EventEmitterStream } from '@tbd54566975/dwn-sdk-js'; - +import type { ProcessHandlers } from './process-handlers.js'; import type { Server } from 'http'; +import type { WebSocketServer } from 'ws'; +import type { DwnServerConfig } from './config.js'; + import log from 'loglevel'; import prefix from 'loglevel-plugin-prefix'; -import { type WebSocketServer } from 'ws'; - +import { config as defaultConfig } from './config.js'; +import { getDWNConfig } from './storage.js'; import { HttpServerShutdownHandler } from './lib/http-server-shutdown-handler.js'; - -import { type DwnServerConfig, config as defaultConfig } from './config.js'; import { HttpApi } from './http-api.js'; -import { setProcessHandlers } from './process-handlers.js'; -import { getDWNConfig } from './storage.js'; -import { WsApi } from './ws-api.js'; import { RegistrationManager } from './registration/registration-manager.js'; +import { WsApi } from './ws-api.js'; +import { Dwn, EventEmitterStream } from '@tbd54566975/dwn-sdk-js'; +import { removeProcessHandlers, setProcessHandlers } from './process-handlers.js'; +/** + * Options for the DwnServer constructor. + * This is different to DwnServerConfig in that the DwnServerConfig defines configuration that come from environment variables so (more) user facing. + * Where as DwnServerOptions wraps DwnServerConfig with additional overrides that can be used for testing. + */ export type DwnServerOptions = { + /** + * A custom DID resolver to use in the DWN. + * Mainly for testing purposes. Ignored if `dwn` is provided. + */ + didResolver?: DidResolver; dwn?: Dwn; config?: DwnServerConfig; }; +/** + * State of the DwnServer, either Stopped or Started, to help short-circuit start and stop logic. + */ +enum DwnServerState { + Stopped, + Started +} + export class DwnServer { + serverState = DwnServerState.Stopped; + processHandlers: ProcessHandlers; + + /** + * A custom DID resolver to use in the DWN. + * Mainly for testing purposes. Ignored if `dwn` is provided. + */ + didResolver?: DidResolver; dwn?: Dwn; config: DwnServerConfig; #httpServerShutdownHandler: HttpServerShutdownHandler; @@ -32,6 +59,8 @@ export class DwnServer { */ constructor(options: DwnServerOptions = {}) { this.config = options.config ?? defaultConfig; + + this.didResolver = options.didResolver; this.dwn = options.dwn; log.setLevel(this.config.logLevel as log.LogLevelDesc); @@ -40,9 +69,17 @@ export class DwnServer { prefix.apply(log); } + /** + * Starts the DWN server. + */ async start(): Promise { + if (this.serverState === DwnServerState.Started) { + return; + } + await this.#setupServer(); - setProcessHandlers(this); + this.processHandlers = setProcessHandlers(this); + this.serverState = DwnServerState.Started; } /** @@ -68,17 +105,18 @@ export class DwnServer { eventStream = new EventEmitterStream(); } - this.dwn = await Dwn.create(getDWNConfig(this.config, { + const dwnConfig = getDWNConfig(this.config, { + didResolver: this.didResolver, tenantGate: registrationManager, eventStream, - })); + }) + this.dwn = await Dwn.create(dwnConfig); } this.#httpApi = await HttpApi.create(this.config, this.dwn, registrationManager); - await this.#httpApi.start(this.config.port, () => { - log.info(`HttpServer listening on port ${this.config.port}`); - }); + await this.#httpApi.start(this.config.port); + log.info(`HttpServer listening on port ${this.config.port}`); this.#httpServerShutdownHandler = new HttpServerShutdownHandler( this.#httpApi.server, @@ -91,8 +129,31 @@ export class DwnServer { } } - stop(callback: () => void): void { - this.#httpServerShutdownHandler.stop(callback); + /** + * Stops the DWN server. + */ + async stop(): Promise { + if (this.serverState === DwnServerState.Stopped) { + return; + } + + await this.dwn.close(); + await this.#httpApi.close(); + + // close WebSocket server if it was initialized + if (this.#wsApi !== undefined) { + await this.#wsApi.close(); + } + + await new Promise((resolve) => { + this.#httpServerShutdownHandler.stop(() => { + resolve(); + }); + }); + + removeProcessHandlers(this.processHandlers); + + this.serverState = DwnServerState.Stopped; } get httpServer(): Server { diff --git a/src/http-api.ts b/src/http-api.ts index 0c6baec..f5c682d 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -340,10 +340,6 @@ export class HttpApi { this.#setupWeb5ConnectServerRoutes(); } - #listen(port: number, callback?: () => void): void { - this.#server.listen(port, callback); - } - #setupRegistrationRoutes(): void { if (this.#config.registrationProofOfWorkEnabled) { this.#api.get('/registration/proof-of-work', async (_req: Request, res: Response) => { @@ -458,8 +454,34 @@ export class HttpApi { }); } - async start(port: number, callback?: () => void): Promise { - this.#listen(port, callback); - return this.#server; + /** + * Starts the HTTP API endpoint on the given port. + * @returns The HTTP server instance. + */ + async start(port: number): Promise { + // promisify http.Server.listen() and await on it + await new Promise((resolve) => { + this.#server.listen(port, () => { + resolve(); + }); + }); + } + + /** + * Stops the HTTP API endpoint. + */ + async close(): Promise { + // promisify http.Server.close() and await on it + await new Promise((resolve, reject) => { + this.#server.close((err?: Error) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + + this.server.closeAllConnections(); } } diff --git a/src/process-handlers.ts b/src/process-handlers.ts index d458cec..554fcaf 100644 --- a/src/process-handlers.ts +++ b/src/process-handlers.ts @@ -1,36 +1,65 @@ import type { DwnServer } from './dwn-server.js'; -export const gracefulShutdown = (dwnServer: DwnServer): void => { - dwnServer.stop(() => { - console.log('http server stopped.. exiting'); - process.exit(0); - }); +export const gracefulShutdown = async (dwnServer: DwnServer): Promise => { + await dwnServer.stop(); + console.log('http server stopped.. exiting'); + process.exit(0); }; -export const setProcessHandlers = (dwnServer: DwnServer): void => { - process.on('unhandledRejection', (reason, promise) => { +export type ProcessHandlers = { + unhandledRejectionHandler: (reason: any, promise: Promise) => void, + uncaughtExceptionHandler: (err: Error) => void, + sigintHandler: () => Promise, + sigtermHandler: () => Promise +}; + +export const setProcessHandlers = (dwnServer: DwnServer): ProcessHandlers => { + const unhandledRejectionHandler = (reason: any, promise: Promise): void => { console.error( `Unhandled promise rejection. Reason: ${reason}. Promise: ${JSON.stringify( promise, )}`, ); - }); + }; - process.on('uncaughtException', (err) => { + const uncaughtExceptionHandler = (err: Error): void => { console.error('Uncaught exception:', err.stack || err); - }); + }; - // triggered by ctrl+c with no traps in between - process.on('SIGINT', async () => { + const sigintHandler = async (): Promise => { console.log('exit signal received [SIGINT]. starting graceful shutdown'); + await gracefulShutdown(dwnServer); + }; - gracefulShutdown(dwnServer); - }); - - // triggered by docker, tiny etc. - process.on('SIGTERM', async () => { + const sigtermHandler = async (): Promise => { console.log('exit signal received [SIGTERM]. starting graceful shutdown'); + await gracefulShutdown(dwnServer); + }; - gracefulShutdown(dwnServer); - }); + process.on('unhandledRejection', unhandledRejectionHandler); + process.on('uncaughtException', uncaughtExceptionHandler); + process.on('SIGINT', sigintHandler); + process.on('SIGTERM', sigtermHandler); + + // Store handlers to be able to remove them later + return { + unhandledRejectionHandler, + uncaughtExceptionHandler, + sigintHandler, + sigtermHandler + }; }; + +export const removeProcessHandlers = (handlers: ProcessHandlers): void => { + const { + unhandledRejectionHandler, + uncaughtExceptionHandler, + sigintHandler, + sigtermHandler + } = handlers; + + process.removeListener('unhandledRejection', unhandledRejectionHandler); + process.removeListener('uncaughtException', uncaughtExceptionHandler); + process.removeListener('SIGINT', sigintHandler); + process.removeListener('SIGTERM', sigtermHandler); +}; \ No newline at end of file diff --git a/src/storage.ts b/src/storage.ts index 189f00f..e74b3d4 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -6,6 +6,7 @@ import { MessageStoreLevel, ResumableTaskStoreLevel, } from '@tbd54566975/dwn-sdk-js'; +import type { DidResolver } from '@web5/dids'; import type { DataStore, DwnConfig, @@ -52,17 +53,18 @@ export type StoreType = DataStore | EventLog | MessageStore | ResumableTaskStore export function getDWNConfig( config : DwnServerConfig, options : { + didResolver? : DidResolver, tenantGate? : TenantGate, eventStream? : EventStream, } ): DwnConfig { - const { tenantGate, eventStream } = options; + const { tenantGate, eventStream, didResolver } = options; const dataStore: DataStore = getStore(config.dataStore, EStoreType.DataStore); const eventLog: EventLog = getStore(config.eventLog, EStoreType.EventLog); const messageStore: MessageStore = getStore(config.messageStore, EStoreType.MessageStore); const resumableTaskStore: ResumableTaskStore = getStore(config.messageStore, EStoreType.ResumableTaskStore); - return { eventStream, eventLog, dataStore, messageStore, resumableTaskStore, tenantGate }; + return { didResolver, eventStream, eventLog, dataStore, messageStore, resumableTaskStore, tenantGate }; } function getLevelStore( diff --git a/src/ws-api.ts b/src/ws-api.ts index 426aadc..2cfbd88 100644 --- a/src/ws-api.ts +++ b/src/ws-api.ts @@ -34,9 +34,8 @@ export class WsApi { this.#wsServer.on('close', () => this.#connectionManager.closeAll()); } - start(): WebSocketServer { + start(): void { this.#setupWebSocket(); - return this.#wsServer; } async close(): Promise { diff --git a/tests/connection/connection-manager.spec.ts b/tests/connection/connection-manager.spec.ts index 37f246c..616f7ee 100644 --- a/tests/connection/connection-manager.spec.ts +++ b/tests/connection/connection-manager.spec.ts @@ -8,7 +8,6 @@ import { getTestDwn } from '../test-dwn.js'; import { InMemoryConnectionManager } from '../../src/connection/connection-manager.js'; import { config } from '../../src/config.js'; import { WsApi } from '../../src/ws-api.js'; -import type { Server } from 'http'; import { HttpApi } from '../../src/http-api.js'; import { JsonRpcSocket } from '../../src/json-rpc-socket.js'; @@ -17,24 +16,23 @@ chai.use(chaiAsPromised); describe('InMemoryConnectionManager', () => { let dwn: Dwn; let connectionManager: InMemoryConnectionManager; - let server: Server + let httpApi: HttpApi; let wsApi: WsApi; beforeEach(async () => { dwn = await getTestDwn({ withEvents: true }); connectionManager = new InMemoryConnectionManager(dwn); - const httpApi = await HttpApi.create(config, dwn); - server = await httpApi.start(9002); - wsApi = new WsApi(server, dwn, connectionManager); + httpApi = await HttpApi.create(config, dwn); + await httpApi.start(9002); + wsApi = new WsApi(httpApi.server, dwn, connectionManager); wsApi.start(); }); afterEach(async () => { await connectionManager.closeAll(); await dwn.close(); + await httpApi.close(); await wsApi.close(); - server.close(); - server.closeAllConnections(); sinon.restore(); }); @@ -48,6 +46,7 @@ describe('InMemoryConnectionManager', () => { }); it('closes all connections on `closeAll`', async () => { + await JsonRpcSocket.connect('ws://127.0.0.1:9002'); expect((connectionManager as any).connections.size).to.equal(1); @@ -56,5 +55,5 @@ describe('InMemoryConnectionManager', () => { await connectionManager.closeAll(); expect((connectionManager as any).connections.size).to.equal(0); - }); + }) }); \ No newline at end of file diff --git a/tests/cors.spec.ts b/tests/cors.spec.ts index 9b9649c..1ce7363 100644 --- a/tests/cors.spec.ts +++ b/tests/cors.spec.ts @@ -72,10 +72,9 @@ class CorsProxySetup { resolve(null); }); }); + // shutdown dwn - await new Promise((resolve) => { - dwnServer.stop(resolve); - }); + await dwnServer.stop(); } } diff --git a/tests/dwn-server.spec.ts b/tests/dwn-server.spec.ts index 1f33890..fc6a765 100644 --- a/tests/dwn-server.spec.ts +++ b/tests/dwn-server.spec.ts @@ -16,7 +16,7 @@ describe('DwnServer', function () { const dwnServer = new DwnServer({ config: dwnServerConfig, dwn }); await dwnServer.start(); - dwnServer.stop(() => console.log('server Stop')); + await dwnServer.stop(); expect(dwnServer.httpServer.listening).to.be.false; }); @@ -34,7 +34,9 @@ describe('DwnServer', function () { await withoutSocketServer.start(); expect(withoutSocketServer.httpServer.listening).to.be.true; expect(withoutSocketServer.wsServer).to.be.undefined; - withoutSocketServer.stop(() => console.log('server Stop')); + + await withoutSocketServer.stop(); + console.log('server Stop'); expect(withoutSocketServer.httpServer.listening).to.be.false; }); @@ -50,7 +52,9 @@ describe('DwnServer', function () { await withSocketServer.start(); expect(withSocketServer.wsServer).to.not.be.undefined; - withSocketServer.stop(() => console.log('server Stop')); + + await withSocketServer.stop(); + console.log('server Stop'); expect(withSocketServer.httpServer.listening).to.be.false; }); }); diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 6e882ee..2d938f4 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -13,7 +13,6 @@ import { import type { Dwn, DwnError, Persona, ProtocolsConfigureMessage, RecordsQueryReply } from '@tbd54566975/dwn-sdk-js'; import { expect } from 'chai'; -import type { Server } from 'http'; import fetch from 'node-fetch'; import { webcrypto } from 'node:crypto'; import { useFakeTimers } from 'sinon'; @@ -47,7 +46,6 @@ if (!globalThis.crypto) { describe('http api', function () { let httpApi: HttpApi; - let server: Server; let alice: Persona; let registrationManager: RegistrationManager; let dwn: Dwn; @@ -75,7 +73,7 @@ describe('http api', function () { beforeEach(async function () { sinon.restore(); - server = await httpApi.start(3000); + await httpApi.start(3000); // generate a new persona for each test to avoid state pollution alice = await TestDataGenerator.generateDidKeyPersona(); @@ -83,8 +81,7 @@ describe('http api', function () { }); afterEach(async function () { - server.close(); - server.closeAllConnections(); + await httpApi.close(); }); after(function () { @@ -1060,12 +1057,11 @@ describe('http api', function () { // start server without websocket support enabled - server.close(); - server.closeAllConnections(); + await httpApi.close(); config.webSocketSupport = false; httpApi = await HttpApi.create(config, dwn, registrationManager); - server = await httpApi.start(3000); + await httpApi.start(3000); resp = await fetch(`http://localhost:3000/info`); expect(resp.status).to.equal(200); @@ -1079,8 +1075,7 @@ describe('http api', function () { }); it('verify /info still returns when package.json file does not exist', async function () { - server.close(); - server.closeAllConnections(); + await httpApi.close(); // set up spy to check for an error log by the server const logSpy = sinon.spy(log, 'error'); @@ -1089,7 +1084,7 @@ describe('http api', function () { const packageJsonConfig = config.packageJsonPath; config.packageJsonPath = '/some/invalid/file.json'; httpApi = await HttpApi.create(config, dwn, registrationManager); - server = await httpApi.start(3000); + await httpApi.start(3000); const resp = await fetch(`http://localhost:3000/info`); const info = await resp.json(); @@ -1111,14 +1106,13 @@ describe('http api', function () { }); it('verify /info returns server name from config', async function () { - server.close(); - server.closeAllConnections(); + await httpApi.close(); // set a custom name for the `serverName` const serverName = config.serverName; config.serverName = '@web5/dwn-server-2' httpApi = await HttpApi.create(config, dwn, registrationManager); - server = await httpApi.start(3000); + await httpApi.start(3000); const resp = await fetch(`http://localhost:3000/info`); const info = await resp.json(); diff --git a/tests/process-handler.spec.ts b/tests/process-handler.spec.ts index 97dfe4d..082c87c 100644 --- a/tests/process-handler.spec.ts +++ b/tests/process-handler.spec.ts @@ -1,49 +1,76 @@ -import { expect } from 'chai'; -import sinon from 'sinon'; +import type { Dwn } from '@tbd54566975/dwn-sdk-js'; +import sinon from 'sinon'; import { config } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; +import { expect } from 'chai'; import { getTestDwn } from './test-dwn.js'; +import { Poller } from '@tbd54566975/dwn-sdk-js'; describe('Process Handlers', function () { + let dwn: Dwn; let dwnServer: DwnServer; let processExitStub: sinon.SinonStub; beforeEach(async function () { - const testDwn = await getTestDwn(); - dwnServer = new DwnServer({ dwn: testDwn, config: config }); + const dwn = await getTestDwn(); + dwnServer = new DwnServer({ dwn, config: config }); await dwnServer.start(); processExitStub = sinon.stub(process, 'exit'); }); - afterEach(async function () { - dwnServer.stop(() => console.log('server stop in Process Handlers tests')); - process.removeAllListeners('SIGINT'); - process.removeAllListeners('SIGTERM'); - process.removeAllListeners('uncaughtException'); + afterEach(async () => { + await dwnServer.stop(); processExitStub.restore(); }); + it('should stop when SIGINT is emitted', async function () { process.emit('SIGINT'); - expect(dwnServer.httpServer.listening).to.be.false; - expect(processExitStub.called).to.be.false; // Ensure process.exit is not called + + Poller.pollUntilSuccessOrTimeout(async () => { + expect(dwnServer.httpServer.listening).to.be.false; + expect(processExitStub.called).to.be.false; // Ensure process.exit is not called + }); }); it('should stop when SIGTERM is emitted', async function () { process.emit('SIGTERM'); - expect(dwnServer.httpServer.listening).to.be.false; - expect(processExitStub.called).to.be.false; // Ensure process.exit is not called + + Poller.pollUntilSuccessOrTimeout(async () => { + expect(dwnServer.httpServer.listening).to.be.false; + expect(processExitStub.called).to.be.false; // Ensure process.exit is not called + }); }); - it('should log an error for an uncaught exception', function () { + it('should log an error for an uncaught exception', async () => { + + // IMPORTANT: this test is a bit tricky to write because + // existing process `uncaughtException` listener/handler will result will trigger an error when we force an `uncaughtException` event + // causing the test to fail. So we need to remove the existing listener and add them back after the test. + // To be in full control of the test, we also create the DWN server (which adds it's own `uncaughtException` listener) + // AFTER removing the existing listener. + await dwnServer.stop(); + + // storing then removing existing listeners and adding back at the very end of the test + const existingUncaughtExceptionListeners = [...process.listeners('uncaughtException')]; + process.removeAllListeners('uncaughtException'); + + dwnServer = new DwnServer({ dwn, config: config }); + await dwnServer.start(); + const consoleErrorStub = sinon.stub(console, 'error'); // Stub console.error const errorMessage = 'Test uncaught exception'; const error = new Error(errorMessage); process.emit('uncaughtException', error); + // Ensure console.error was called with the expected error message + console.log('console.error call count', consoleErrorStub.callCount); expect(consoleErrorStub.calledOnce).to.be.true; // Restore the original console.error consoleErrorStub.restore(); + + // add back original listeners + existingUncaughtExceptionListeners.forEach(listener => process.on('uncaughtException', listener)); }); }); diff --git a/tests/scenarios/registration.spec.ts b/tests/scenarios/registration.spec.ts index df3e9c7..987a3c9 100644 --- a/tests/scenarios/registration.spec.ts +++ b/tests/scenarios/registration.spec.ts @@ -1,35 +1,28 @@ -// node.js 18 and earlier, needs globalThis.crypto polyfill -import { DataStream, TestDataGenerator } from '@tbd54566975/dwn-sdk-js'; +import type { DwnServerConfig } from '../../src/config.js'; import type { Persona } from '@tbd54566975/dwn-sdk-js'; +import type { ProofOfWorkChallengeModel } from '../../src/registration/proof-of-work-types.js'; +import type { RegistrationManager } from '../../src/registration/registration-manager.js'; +import type { JsonRpcRequest, JsonRpcResponse } from '../../src/lib/json-rpc.js'; +import type { RegistrationData, RegistrationRequest } from '../../src/registration/registration-types.js'; -import { expect } from 'chai'; -import { readFileSync } from 'fs'; import fetch from 'node-fetch'; -import { webcrypto } from 'node:crypto'; -import { useFakeTimers } from 'sinon'; -import { v4 as uuidv4 } from 'uuid'; - -import type { DwnServerConfig } from '../../src/config.js'; import { config } from '../../src/config.js'; -import type { - JsonRpcRequest, - JsonRpcResponse, -} from '../../src/lib/json-rpc.js'; -import { - createJsonRpcRequest, -} from '../../src/lib/json-rpc.js'; -import { ProofOfWork } from '../../src/registration/proof-of-work.js'; -import { - createRecordsWriteMessage, -} from '../utils.js'; -import type { ProofOfWorkChallengeModel } from '../../src/registration/proof-of-work-types.js'; -import type { RegistrationData, RegistrationRequest } from '../../src/registration/registration-types.js'; -import type { RegistrationManager } from '../../src/registration/registration-manager.js'; +import { createJsonRpcRequest} from '../../src/lib/json-rpc.js'; +import { createRecordsWriteMessage } from '../utils.js'; +import { DwnServer } from '../../src/dwn-server.js'; import { DwnServerErrorCode } from '../../src/dwn-error.js'; +import { expect } from 'chai'; +import { ProofOfWork } from '../../src/registration/proof-of-work.js'; import { ProofOfWorkManager } from '../../src/registration/proof-of-work-manager.js'; -import { DwnServer } from '../../src/dwn-server.js'; import { randomBytes } from 'crypto'; +import { readFileSync } from 'fs'; +import { useFakeTimers } from 'sinon'; +import { v4 as uuidv4 } from 'uuid'; +import { webcrypto } from 'node:crypto'; +import { DataStream, TestDataGenerator } from '@tbd54566975/dwn-sdk-js'; +import { DidDht, DidKey, UniversalResolver } from '@web5/dids'; +// node.js 18 and earlier, needs globalThis.crypto polyfill if (!globalThis.crypto) { // @ts-ignore globalThis.crypto = webcrypto; @@ -41,6 +34,7 @@ describe('Registration scenarios', function () { const proofOfWorkEndpoint = 'http://localhost:3000/registration/proof-of-work'; const registrationEndpoint = 'http://localhost:3000/registration'; + // let didResolverCache = new DidResolverCacheLevel({ location: 'RESOLVERCACHE' }); let alice: Persona; let registrationManager: RegistrationManager; let clock; @@ -64,24 +58,24 @@ describe('Registration scenarios', function () { dwnServerConfig.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; dwnServerConfig.registrationProofOfWorkInitialMaxHash = '0FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; // 1 in 16 chance of solving - dwnServer = new DwnServer({ config: dwnServerConfig }); + // CRITICAL: We need to create a custom DID resolver that does not use a LevelDB based cache (which is the default cache used in `DWN`) + // otherwise we will receive a `Database is not open` coming from LevelDB. + // This is likely due to the fact that LevelDB is the default cache used in `DWN`, and we have tests creating default DWN instances, + // so here we have to create a DWN that does not use the same LevelDB cache to avoid hitting LevelDB locked issues. + // Long term we should investigate and unify approach of DWN instantiation taken by tests to avoid this "workaround" entirely. + const didResolver = new UniversalResolver({ + didResolvers : [DidDht, DidKey], + }); + + dwnServer = new DwnServer({ config: dwnServerConfig, didResolver }); await dwnServer.start(); registrationManager = dwnServer.registrationManager; }); - after(function () { - dwnServer.stop(() => { }); + after(async () => { clock.restore(); }); - beforeEach(function () { - dwnServer.start(); - }); - - afterEach(function () { - dwnServer.stop(() => {}); - }); - it('should facilitate tenant registration with terms-of-service and proof-or-work turned on', async () => { // Scenario: // 1. Alice fetches the terms-of-service. @@ -532,13 +526,8 @@ describe('Registration scenarios', function () { }); - /** - * NOTE: The tests below instantiate their own server configs and should should take care to stop the `dwnServer` - * This is done to avoid LevelDB locking for the default `DidResolver` cache. - */ - it('should initialize ProofOfWorkManager with challenge nonce seed if given.', async function () { - dwnServer.stop(() => {}); + await dwnServer.stop(); const registrationProofOfWorkSeed = randomBytes(32).toString('hex'); const configWithProofOfWorkSeed: DwnServerConfig = { @@ -554,7 +543,16 @@ describe('Registration scenarios', function () { }); it('should allow tenant registration to be turned off to allow all DWN messages through.', async () => { - dwnServer.stop(() => {}); + await dwnServer.stop(); + + // CRITICAL: We need to create a custom DID resolver that does not use a LevelDB based cache (which is the default cache used in `DWN`) + // otherwise we will receive a `Database is not open` coming from LevelDB. + // This is likely due to the fact that LevelDB is the default cache used in `DWN`, and we have tests creating default DWN instances, + // so here we have to create a DWN that does not use the same LevelDB cache to avoid hitting LevelDB locked issues. + // Long term we should investigate and unify approach of DWN instantiation taken by tests to avoid this "workaround" entirely. + const didResolver = new UniversalResolver({ + didResolvers : [DidDht, DidKey], + }); // Scenario: // 1. There is a DWN that does not require tenant registration. @@ -566,17 +564,19 @@ describe('Registration scenarios', function () { registrationProofOfWorkEnabled: false, termsOfServiceFilePath: undefined, }; - dwnServer = new DwnServer({ config: configClone }); + dwnServer = new DwnServer({ config: configClone, didResolver }); await dwnServer.start(); const { jsonRpcRequest, dataBytes } = await generateRecordsWriteJsonRpcRequest(alice); - const writeResponse = await fetch(dwnMessageEndpoint, { + + const writeResponse = await fetch('http://localhost:3002', { method: 'POST', headers: { 'dwn-request': JSON.stringify(jsonRpcRequest), }, body: new Blob([dataBytes]), }); + const writeResponseBody = await writeResponse.json() as JsonRpcResponse; expect(writeResponse.status).to.equal(200); expect(writeResponseBody.result.reply.status.code).to.equal(202); diff --git a/tests/scenarios/web5-connect.spec.ts b/tests/scenarios/web5-connect.spec.ts index d1685f2..6199048 100644 --- a/tests/scenarios/web5-connect.spec.ts +++ b/tests/scenarios/web5-connect.spec.ts @@ -3,9 +3,10 @@ import sinon from 'sinon'; import { config } from '../../src/config.js'; import { DwnServer } from '../../src/dwn-server.js'; import { expect } from 'chai'; +import { Poller } from '@tbd54566975/dwn-sdk-js'; import { useFakeTimers } from 'sinon'; import { Web5ConnectServer } from '../../src/web5-connect/web5-connect-server.js'; -import { webcrypto } from 'node:crypto'; +import { randomUUID, webcrypto } from 'node:crypto'; // node.js 18 and earlier needs globalThis.crypto polyfill if (!globalThis.crypto) { @@ -22,31 +23,30 @@ describe('Web5 Connect scenarios', function () { before(async function () { - // NOTE: using SQL to workaround an issue where multiple instances of DwnServer can be started using LevelDB in the same test run, + // NOTE: using SQL to workaround an issue where multiple instances of DwnServer cannot be started using LevelDB in the same test run, // and dwn-server.spec.ts already uses LevelDB. dwnServerConfig.messageStore = 'sqlite://', dwnServerConfig.dataStore = 'sqlite://', dwnServerConfig.eventLog = 'sqlite://', dwnServer = new DwnServer({ config: dwnServerConfig }); - await dwnServer.start(); }); - after(function () { - dwnServer.stop(() => { }); + after(async () => { + await dwnServer.stop(); }); - beforeEach(function () { + beforeEach(async () => { sinon.restore(); // wipe all previous stubs/spies/mocks/fakes/clock // IMPORTANT: MUST be called AFTER `sinon.restore()` because `sinon.restore()` resets fake timers clock = useFakeTimers({ shouldAdvanceTime: true }); - dwnServer.start(); + await dwnServer.start(); }); - afterEach(function () { + afterEach(async () => { clock.restore(); - dwnServer.stop(() => {}); + await dwnServer.stop(); }); it('should be able to set and get Web5 Connect Request & Response objects', async () => { @@ -70,18 +70,22 @@ describe('Web5 Connect scenarios', function () { // 2. Identity Provider (wallet) fetches the Web5 Connect Request object from the Web5 Connect server. const requestUrl = (await postWeb5ConnectRequestResult.json() as any).request_uri; - const getWeb5ConnectRequestResult = await fetch(requestUrl, { - method: 'GET', + + let getWeb5ConnectRequestResult; + await Poller.pollUntilSuccessOrTimeout(async () => { + console.log('Polling for Web5 Connect Request object...') + getWeb5ConnectRequestResult = await fetch(requestUrl, { method: 'GET' }); + expect(getWeb5ConnectRequestResult.status).to.equal(200); }); + const fetchedRequest = await getWeb5ConnectRequestResult.json(); - expect(getWeb5ConnectRequestResult.status).to.equal(200); expect(fetchedRequest).to.deep.equal(requestBody.request); // 3. Should receive 404 if fetching the same Web5 Connect Request again - const getWeb5ConnectRequestResult2 = await fetch(requestUrl, { - method: 'GET', + await Poller.pollUntilSuccessOrTimeout(async () => { + const getWeb5ConnectRequestResult2 = await fetch(requestUrl, { method: 'GET' }); + expect(getWeb5ConnectRequestResult2.status).to.equal(404); }); - expect(getWeb5ConnectRequestResult2.status).to.equal(404); // 4. Identity Provider (wallet) should receive 400 if sending an incomplete response. const incompleteResponseBody = { @@ -95,10 +99,11 @@ describe('Web5 Connect scenarios', function () { }); expect(postIncompleteWeb5ConnectResponseResult.status).to.equal(400); + const state = `dummyState-${randomUUID()}`; // 5. Identity Provider (wallet) sends the Web5 Connect Response object to the Web5 Connect server. const web5ConnectResponseBody = { id_token : { dummyToken: 'dummyToken' }, - state : 'dummyState', + state }; const postWeb5ConnectResponseResult = await fetch(`${web5ConnectBaseUrl}/connect/sessions`, { method: 'POST', @@ -109,18 +114,21 @@ describe('Web5 Connect scenarios', function () { // 6. App fetches the Web5 Connect Response object from the Web5 Connect server. const web5ConnectResponseUrl = `${web5ConnectBaseUrl}/connect/sessions/${web5ConnectResponseBody.state}.jwt`; - const getWeb5ConnectResponseResult = await fetch(web5ConnectResponseUrl, { - method: 'GET', + + let getWeb5ConnectResponseResult; + await Poller.pollUntilSuccessOrTimeout(async () => { + getWeb5ConnectResponseResult = await fetch(web5ConnectResponseUrl, { method: 'GET' }); + expect(getWeb5ConnectResponseResult.status).to.equal(200); }); + const fetchedResponse = await getWeb5ConnectResponseResult.json(); - expect(getWeb5ConnectResponseResult.status).to.equal(200); expect(fetchedResponse).to.deep.equal(web5ConnectResponseBody.id_token); // 7. Should receive 404 if fetching the same Web5 Connect Response object again. - const getWeb5ConnectResponseResult2 = await fetch(web5ConnectResponseUrl, { - method: 'GET', + await Poller.pollUntilSuccessOrTimeout(async () => { + const getWeb5ConnectResponseResult2 = await fetch(web5ConnectResponseUrl, { method: 'GET' }); + expect(getWeb5ConnectResponseResult2.status).to.equal(404); }); - expect(getWeb5ConnectResponseResult2.status).to.equal(404); }); it('should clean up objects that are expired', async () => { diff --git a/tests/ws-api.spec.ts b/tests/ws-api.spec.ts index a5a9ef4..19251b0 100644 --- a/tests/ws-api.spec.ts +++ b/tests/ws-api.spec.ts @@ -2,8 +2,6 @@ import type { Dwn, MessageEvent } from '@tbd54566975/dwn-sdk-js'; import { DataStream, Message, TestDataGenerator } from '@tbd54566975/dwn-sdk-js'; -import type { Server } from 'http'; - import { expect } from 'chai'; import { base64url } from 'multiformats/bases/base64'; import type { SinonFakeTimers } from 'sinon'; @@ -24,7 +22,6 @@ import { JsonRpcSocket } from '../src/json-rpc-socket.js'; describe('websocket api', function () { - let server: Server; let httpApi: HttpApi; let wsApi: WsApi; let dwn: Dwn; @@ -41,15 +38,14 @@ describe('websocket api', function () { beforeEach(async function () { dwn = await getTestDwn({ withEvents: true }); httpApi = await HttpApi.create(config, dwn); - server = await httpApi.start(9002); - wsApi = new WsApi(server, dwn); + await httpApi.start(9002); + wsApi = new WsApi(httpApi.server, dwn); wsApi.start(); }); afterEach(async function () { await wsApi.close(); - server.close(); - server.closeAllConnections(); + await httpApi.close(); await dwn.close(); });