diff --git a/README.md b/README.md index 0782fe1..16050e2 100644 --- a/README.md +++ b/README.md @@ -277,15 +277,18 @@ cloudflared tunnel --url http://localhost:3000 Configuration can be set using environment variables -| Env Var | Description | Default | -| ------------------------- | -------------------------------------------------------------------------------------- | ---------------------- | -| `DS_PORT` | Port that the server listens on | `3000` | -| `DS_MAX_RECORD_DATA_SIZE` | maximum size for `RecordsWrite` data. use `b`, `kb`, `mb`, `gb` for value | `1gb` | -| `DS_WEBSOCKET_SERVER` | whether to enable listening over `ws:`. values: `on`,`off` | `on` | -| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | -| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | -| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | -| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | +| Env Var | Description | Default | +| -------------------------- | ----------------------------------------------------------------------------------------- | ---------------------- | +| `DS_PORT` | Port that the server listens on | `3000` | +| `DS_MAX_RECORD_DATA_SIZE` | maximum size for `RecordsWrite` data. use `b`, `kb`, `mb`, `gb` for value | `1gb` | +| `DS_WEBSOCKET_SERVER` | whether to enable listening over `ws:`. values: `on`,`off` | `on` | +| `DWN_REGISTRATION_POW` | require new users to complete a proof-of-work challenge | `false` | +| `DWN_REGISTRATION_TOS` | require users to agree to a terms of service. Value is path to the terms of service file. | unset | +| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | +| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | +| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | +| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | +| `DWN_STORAGE_REGISTRATION` | URL to use for storage of registered DIDs | `sqlite://data/dwn.db` | ### Storage Options @@ -297,3 +300,26 @@ Several storage formats are supported, and may be configured with the `DWN_STORA | Sqlite | `sqlite://dwn.db` | use three slashes for absolute paths, two for relative. Example shown creates a file `dwn.db` in the current working directory | | MySQL | `mysql://user:pass@host/db?debug=true&timezone=-0700` | [all URL options documented here](https://github.com/mysqljs/mysql#connection-options) | | PostgreSQL | `postgres:///dwn` | any options other than the URL scheme (`postgres://`) may also be specified via [standard environment variables](https://node-postgres.com/features/connecting#environment-variables) | + +## Registration Requirements + +There are multiple optional registration gates, each of which can be enabled (all are disabled by default). Tenants (DIDs) must comply with whatever +requirements are enabled before they are allowed to use the server. Tenants that have not completed the registration requirements will be met with a 401. Note that registration is tracked in a database, and only SQL-based databases are supported (LevelDB is not supported). Current registration +requirements are available at the `/info.json` endpoint. + +- **Proof of Work** (`DWN_REGISTRATION_POW=true`) - new tenants must GET `/register/pow` for a challenge, then generate a nonce that produces a string that has a sha256 hex sum starting with the specified (`complexity`) number of zeros (`0`) when added to the end of the challenge (`sha256(challenge + nonce)`). This nonce should be POSTed to `/register/pow` with a JSON body including the `challenge`, the nonce in field `response` and `did`. Challenges expire after 5 minutes, and complexity will increase based on the number of successful proof-of-work registrations that have been completed within the last hour. This registration requirement is listed in `/info.json` as `proof-of-work-sha256-v0`. +- **Terms of Service** (`DWN_REGISTRATION_TOS=/path/to/tos.txt`) - new tenants must GET `/register/tos` to fetch the terms. These terms must be displayed to the human end-user, who must actively accept them. When the user accepts the terms, send the sha256 hash of the accepted terms and the user's did via POST `/register/tos`. The JSON body should have fields `tosHash` and `did`. To change the terms, update the file and restart the server. Users that accepted the old terms will be blocked until they accept the new terms. This registration requirement is listed in `/info.json` as `terms-of-service`. + +## Server info + +the server exposes information about itself via the `/info.json` endpoint, which returns data in the following format: + +```json +{ + "server": "@web5/dwn-server", + "maxFileSize": 1073741824, + "registrationRequirements": ["proof-of-work-sha256-v0", "terms-of-service"], + "version": "0.1.5", + "sdkVersion": "0.2.6" +} +``` diff --git a/package-lock.json b/package-lock.json index 6c6a4dd..13e2544 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "@tbd54566975/dwn-sdk-js": "0.2.6", "@tbd54566975/dwn-sql-store": "0.2.2", "better-sqlite3": "^8.5.0", + "body-parser": "^1.20.2", "bytes": "3.1.2", "cors": "2.8.5", "express": "4.18.2", + "kysely": "^0.26.3", "loglevel": "^1.8.1", "loglevel-plugin-prefix": "^0.8.4", "multiformats": "11.0.2", @@ -2130,12 +2132,12 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -2143,7 +2145,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -4358,6 +4360,29 @@ "node": ">= 0.10.0" } }, + "node_modules/express/node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -4371,6 +4396,20 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/express/node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/express/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -8353,9 +8392,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", diff --git a/package.json b/package.json index 0e897b2..11c4da0 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,11 @@ "@tbd54566975/dwn-sdk-js": "0.2.6", "@tbd54566975/dwn-sql-store": "0.2.2", "better-sqlite3": "^8.5.0", + "body-parser": "^1.20.2", "bytes": "3.1.2", "cors": "2.8.5", "express": "4.18.2", + "kysely": "^0.26.3", "loglevel": "^1.8.1", "loglevel-plugin-prefix": "^0.8.4", "multiformats": "11.0.2", diff --git a/src/config.ts b/src/config.ts index 4e52380..38c7a1e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,15 @@ export const config = { eventLog: process.env.DWN_STORAGE_EVENTS || process.env.DWN_STORAGE || 'level://data', + // require POW-based registration for new tenants + registrationRequirementPow: process.env.DWN_REGISTRATION_POW == 'true', + tenantRegistrationStore: + process.env.DWN_STORAGE_REGISTRATION || + process.env.DWN_STORAGE || + 'sqlite://data/dwn.db', + + registrationRequirementTos: process.env.DWN_REGISTRATION_TOS, + // log level - trace/debug/info/warn/error logLevel: process.env.DWN_SERVER_LOG_LEVEL || 'INFO', }; diff --git a/src/dwn-server.ts b/src/dwn-server.ts index c54c2a3..8c800c2 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -1,5 +1,6 @@ import { Dwn } from '@tbd54566975/dwn-sdk-js'; +import { readFileSync } from 'fs'; import type { Server } from 'http'; import log from 'loglevel'; import prefix from 'loglevel-plugin-prefix'; @@ -10,7 +11,8 @@ import { HttpServerShutdownHandler } from './lib/http-server-shutdown-handler.js import { type Config, config as defaultConfig } from './config.js'; import { HttpApi } from './http-api.js'; import { setProcessHandlers } from './process-handlers.js'; -import { getDWNConfig } from './storage.js'; +import { getDWNConfig, getDialectFromURI } from './storage.js'; +import { TenantGate } from './tenant-gate.js'; import { WsApi } from './ws-api.js'; export type DwnServerOptions = { @@ -46,12 +48,27 @@ export class DwnServer { * The DWN creation is secondary and only happens if it hasn't already been done. */ async #setupServer(): Promise { + let tenantGate: TenantGate; if (!this.dwn) { - this.dwn = await Dwn.create(getDWNConfig(this.config)); + const tenantGateDB = getDialectFromURI( + new URL(this.config.tenantRegistrationStore), + ); + const tos = + this.config.registrationRequirementTos !== undefined + ? readFileSync(this.config.registrationRequirementTos).toString() + : null; + tenantGate = new TenantGate( + tenantGateDB, + this.config.registrationRequirementPow, + this.config.registrationRequirementTos !== undefined, + tos, + ); + + this.dwn = await Dwn.create(getDWNConfig(this.config, tenantGate)); } - this.#httpApi = new HttpApi(this.dwn); - this.#httpApi.start(this.config.port, () => { + this.#httpApi = new HttpApi(this.dwn, tenantGate); + await this.#httpApi.start(this.config.port, () => { log.info(`HttpServer listening on port ${this.config.port}`); }); diff --git a/src/http-api.ts b/src/http-api.ts index d40d90e..5147b96 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -7,6 +7,7 @@ import { import cors from 'cors'; import type { Express, Request, Response } from 'express'; import express from 'express'; +import { readFileSync } from 'fs'; import http from 'http'; import log from 'loglevel'; import { register } from 'prom-client'; @@ -20,18 +21,26 @@ import { JsonRpcErrorCodes, } from './lib/json-rpc.js'; +import { config } from './config.js'; import { jsonRpcApi } from './json-rpc-api.js'; import { requestCounter, responseHistogram } from './metrics.js'; +import type { TenantGate } from './tenant-gate.js'; + +const packagejson = process.env.npm_package_json + ? JSON.parse(readFileSync(process.env.npm_package_json).toString()) + : {}; export class HttpApi { #api: Express; #server: http.Server; + tenantGate: TenantGate; dwn: Dwn; - constructor(dwn: Dwn) { + constructor(dwn: Dwn, tenantGate: TenantGate) { this.#api = express(); this.#server = http.createServer(this.#api); this.dwn = dwn; + this.tenantGate = tenantGate; this.#setupMiddleware(); this.#setupRoutes(); @@ -47,6 +56,7 @@ export class HttpApi { #setupMiddleware(): void { this.#api.use(cors({ exposedHeaders: 'dwn-response' })); + this.#api.use(express.json()); this.#api.use( responseTime((req: Request, res: Response, time) => { @@ -181,13 +191,39 @@ export class HttpApi { return res.json(jsonRpcResponse); } }); + + if (this.tenantGate) { + this.tenantGate.setupRoutes(this.#api); + } + + this.#api.get('/info.json', (req, res) => { + res.setHeader('content-type', 'application/json'); + const registrationRequirements: string[] = []; + if (config.registrationRequirementPow) { + registrationRequirements.push('proof-of-work-sha256-v0'); + } + if (config.registrationRequirementTos) { + registrationRequirements.push('terms-of-service'); + } + + res.json({ + server: process.env.npm_package_name, + maxFileSize: config.maxRecordDataSize, + registrationRequirements: registrationRequirements, + version: packagejson.version, + sdkVersion: packagejson.dependencies['@tbd54566975/dwn-sdk-js'], + }); + }); } #listen(port: number, callback?: () => void): void { this.#server.listen(port, callback); } - start(port: number, callback?: () => void): http.Server { + async start(port: number, callback?: () => void): Promise { + if (this.tenantGate) { + await this.tenantGate.initialize(); + } this.#listen(port, callback); return this.#server; } diff --git a/src/storage.ts b/src/storage.ts index 6719bd6..3cc5ca6 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -25,6 +25,7 @@ import pg from 'pg'; import Cursor from 'pg-cursor'; import type { Config } from './config.js'; +import type { TenantGate } from './tenant-gate.js'; export enum EStoreType { DataStore, @@ -41,7 +42,10 @@ export enum BackendTypes { export type StoreType = DataStore | EventLog | MessageStore; -export function getDWNConfig(config: Config): DwnConfig { +export function getDWNConfig( + config: Config, + tenantGate: TenantGate, +): DwnConfig { const dataStore: DataStore = getStore(config.dataStore, EStoreType.DataStore); const eventLog: EventLog = getStore(config.eventLog, EStoreType.EventLog); const messageStore: MessageStore = getStore( @@ -49,7 +53,7 @@ export function getDWNConfig(config: Config): DwnConfig { EStoreType.MessageStore, ); - return { eventLog, dataStore, messageStore }; + return { eventLog, dataStore, messageStore, tenantGate }; } function getLevelStore( @@ -113,14 +117,14 @@ function getStore(storeString: string, storeType: EStoreType): StoreType { case BackendTypes.SQLITE: case BackendTypes.MYSQL: case BackendTypes.POSTGRES: - return getDBStore(getDBFromURI(storeURI), storeType); + return getDBStore(getDialectFromURI(storeURI), storeType); default: throw invalidStorageSchemeMessage(storeURI.protocol); } } -function getDBFromURI(u: URL): Dialect { +export function getDialectFromURI(u: URL): Dialect { switch (u.protocol.slice(0, -1)) { case BackendTypes.SQLITE: return new SqliteDialect({ diff --git a/src/tenant-gate.ts b/src/tenant-gate.ts new file mode 100644 index 0000000..4aacb6b --- /dev/null +++ b/src/tenant-gate.ts @@ -0,0 +1,262 @@ +import { createHash, randomBytes } from 'crypto'; +import type { Request, Response } from 'express'; +import type { Express } from 'express'; +import type { Dialect } from 'kysely'; +import { Kysely } from 'kysely'; + +const recentChallenges: { [challenge: string]: number } = {}; +const CHALLENGE_TIMEOUT = 5 * 60 * 1000; // challenges are valid this long after issuance +const COMPLEXITY_LOOKBACK = 5 * 60 * 1000; // complexity is based on number of successful registrations in this timeframe +const COMPLEXITY_MINIMUM = 5; + +export class TenantGate { + #db: Kysely; + #powRequired: boolean; + #tosRequired: boolean; + #tos?: string; + #tosHash?: string; + #logRejections: boolean; + + constructor( + dialect: Dialect, + powRequired: boolean, + tosRequired: boolean, + currentTOS?: string, + logRejections?: boolean, + ) { + this.#db = new Kysely({ dialect: dialect }); + this.#powRequired = powRequired; + this.#tosRequired = tosRequired; + if (tosRequired) { + this.#tos = currentTOS; + const tosHash = createHash('sha256'); + tosHash.update(currentTOS); + this.#tosHash = tosHash.digest('hex'); + } + this.#logRejections = logRejections || false; + } + + async initialize(): Promise { + setInterval(() => { + for (const challenge of Object.keys(recentChallenges)) { + if ( + recentChallenges[challenge] && + Date.now() - recentChallenges[challenge] > CHALLENGE_TIMEOUT + ) { + delete recentChallenges[challenge]; + } + } + }, CHALLENGE_TIMEOUT / 4); + + await this.#db.schema + .createTable('authorizedTenants') + .ifNotExists() + .addColumn('did', 'text', (column) => column.primaryKey()) + .addColumn('powTime', 'timestamp') + .addColumn('tos', 'boolean') + .execute(); + } + + setupRoutes(server: Express): void { + if (this.#powRequired) { + server.get('/register/pow', (req: Request, res: Response) => + this.getProofOfWorkChallenge(req, res), + ); + server.post('/register/pow', (req: Request, res: Response) => + this.verifyProofOfWorkChallenge(req, res), + ); + } + if (this.#tosRequired) { + server.get('/register/tos', (req: Request, res: Response) => + res.send(this.#tos), + ); + server.post('/register/tos', (req: Request, res: Response) => + this.acceptTOS(req, res), + ); + } + } + + async isTenant(tenant: string): Promise { + if (!this.#powRequired && !this.#tosRequired) { + return true; + } + + const result = await this.#db + .selectFrom('authorizedTenants') + .select('powTime') + .select('tos') + .where('did', '=', tenant) + .execute(); + + if (result.length == 0) { + console.log('rejecting tenant that is not in the database', { tenant }); + return false; + } + + const row = result[0]; + + if (this.#powRequired && row.powTime == undefined) { + console.log('rejecting tenant that has not completed the proof of work', { + tenant, + }); + return false; + } + + if (this.#tosRequired && row.tos != this.#tosHash) { + console.log( + 'rejecting tenant that has not accepted the current terms of service', + { row, tenant, expected: this.#tosHash }, + ); + return false; + } + + return true; + } + + async authorizeTenantPOW(tenant: string): Promise { + await this.#db + .insertInto('authorizedTenants') + .values({ + did: tenant, + powTime: Date.now(), + }) + .onConflict((oc) => + oc.column('did').doUpdateSet((eb) => ({ + powTime: eb.ref('excluded.powTime'), + })), + ) + .executeTakeFirst(); + } + + private async getProofOfWorkChallenge( + _req: Request, + res: Response, + ): Promise { + const challenge = randomBytes(10).toString('base64'); + recentChallenges[challenge] = Date.now(); + res.json({ + challenge: challenge, + complexity: await this.getComplexity(), + }); + } + + private async verifyProofOfWorkChallenge( + req: Request, + res: Response, + ): Promise { + const body: { + did: string; + challenge: string; + response: string; + } = req.body; + + const challengeIssued = recentChallenges[body.challenge]; + if ( + challengeIssued == undefined || + Date.now() - challengeIssued > CHALLENGE_TIMEOUT + ) { + res + .status(401) + .json({ success: false, reason: 'challenge invalid or expired' }); + return; + } + + const hash = createHash('sha256'); + hash.update(body.challenge); + hash.update(body.response); + + const complexity = await this.getComplexity(); + const digest = hash.digest('hex'); + if (!digest.startsWith('0'.repeat(complexity))) { + res.status(401).json({ + success: false, + reason: 'insufficiently complex', + requiredComplexity: complexity, + }); + return; + } + + try { + await this.authorizeTenantPOW(body.did); + } catch (e) { + console.log('error inserting did', e); + res.status(500).json({ success: false }); + return; + } + res.json({ success: true }); + } + + private async getComplexity(): Promise { + const result = await this.#db + .selectFrom('authorizedTenants') + .where('powTime', '>', Date.now() - COMPLEXITY_LOOKBACK) + .select((eb) => eb.fn.countAll().as('recent_reg_count')) + .executeTakeFirstOrThrow(); + const recent = result.recent_reg_count as number; + if (recent == 0) { + return COMPLEXITY_MINIMUM; + } + + const complexity = Math.floor(recent / 10); + if (complexity < COMPLEXITY_MINIMUM) { + return COMPLEXITY_MINIMUM; + } + + return complexity; + } + + private async acceptTOS(req: Request, res: Response): Promise { + const body: { + did: string; + tosHash: string; + } = req.body; + + if (body.tosHash != this.#tosHash) { + res.status(400).json({ + success: false, + reason: 'incorrect TOS hash', + }); + } + + console.log('accepting tos', body); + + await this.#db + .insertInto('authorizedTenants') + .values({ + did: body.did, + tos: body.tosHash, + }) + .onConflict((oc) => + oc.column('did').doUpdateSet((eb) => ({ + tos: eb.ref('excluded.tos'), + })), + ) + .executeTakeFirstOrThrow(); + res.status(200).json({ success: true }); + } + + async authorizeTenantTOS(tenant: string): Promise { + await this.#db + .insertInto('authorizedTenants') + .values({ + did: tenant, + tos: this.#tosHash, + }) + .onConflict((oc) => + oc.column('did').doUpdateSet((eb) => ({ + tos: eb.ref('excluded.tos'), + })), + ) + .executeTakeFirst(); + } +} + +interface AuthorizedTenants { + did: string; + tos: string; + powTime: number; +} + +interface TenantRegistrationDatabase { + authorizedTenants: AuthorizedTenants; +} diff --git a/tests/cors.spec.ts b/tests/cors.spec.ts index d55d571..fda1078 100644 --- a/tests/cors.spec.ts +++ b/tests/cors.spec.ts @@ -7,7 +7,7 @@ import { executablePath } from 'puppeteer'; import { config as defaultConfig } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; let noBrowser; try { @@ -24,19 +24,18 @@ class CorsProxySetup { proxyPort = 9875; public async start(): Promise { + const testdwn = await getTestDwn(); const dwnServer = new DwnServer({ - dwn: dwn, + dwn: testdwn.dwn, config: { ...defaultConfig, port: 0, // UNSPEC to obtain test specific free port + registrationRequirementPow: false, }, }); - const dwnPort = await new Promise((resolve) => { - dwnServer.start(() => { - const port = (dwnServer.httpServer.address() as AddressInfo).port; - resolve(port); - }); - }); + await dwnServer.start(); + const dwnPort = (dwnServer.httpServer.address() as AddressInfo).port; + // setup proxy server const proxy = httpProxy.createProxyServer({}); const server = http.createServer((req, res) => { @@ -77,7 +76,6 @@ class CorsProxySetup { await new Promise((resolve) => { dwnServer.stop(resolve); }); - await clearDwn(); } } diff --git a/tests/dwn-process-message.spec.ts b/tests/dwn-process-message.spec.ts index e6da39e..346db67 100644 --- a/tests/dwn-process-message.spec.ts +++ b/tests/dwn-process-message.spec.ts @@ -4,14 +4,10 @@ import { v4 as uuidv4 } from 'uuid'; import { handleDwnProcessMessage } from '../src/json-rpc-handlers/dwn/process-message.js'; import type { RequestContext } from '../src/lib/json-rpc-router.js'; import { createJsonRpcRequest } from '../src/lib/json-rpc.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; import { createProfile, createRecordsWriteMessage } from './utils.js'; describe('handleDwnProcessMessage', function () { - afterEach(async function () { - await clearDwn(); - }); - it('returns a JSON RPC Success Response when DWN returns a 2XX status code', async function () { const alice = await createProfile(); @@ -23,6 +19,7 @@ describe('handleDwnProcessMessage', function () { target: alice.did, }); + const dwn = (await getTestDwn()).dwn; const context: RequestContext = { dwn, transport: 'http', dataStream }; const { jsonRpcResponse } = await handleDwnProcessMessage( @@ -47,6 +44,7 @@ describe('handleDwnProcessMessage', function () { target: 'did:key:abc1234', }); + const dwn = (await getTestDwn()).dwn; const context: RequestContext = { dwn, transport: 'http' }; const { jsonRpcResponse } = await handleDwnProcessMessage( diff --git a/tests/dwn-server.spec.ts b/tests/dwn-server.spec.ts index da17745..8dc94e1 100644 --- a/tests/dwn-server.spec.ts +++ b/tests/dwn-server.spec.ts @@ -2,20 +2,16 @@ import { expect } from 'chai'; import { config } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; -import { clear, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; describe('DwnServer', function () { let dwnServer: DwnServer; - const options = { - dwn: dwn, - config: config, - }; - before(function () { - dwnServer = new DwnServer(options); + before(async function () { + const testdwn = await getTestDwn(); + dwnServer = new DwnServer({ dwn: testdwn.dwn, config: config }); }); after(async function () { dwnServer.stop(() => console.log('server stop')); - await clear(); }); it('should create an instance of DwnServer', function () { expect(dwnServer).to.be.an.instanceOf(DwnServer); diff --git a/tests/fixtures/tos.txt b/tests/fixtures/tos.txt new file mode 100644 index 0000000..bfbe414 --- /dev/null +++ b/tests/fixtures/tos.txt @@ -0,0 +1,39 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla eget efficitur lorem. Duis vel viverra urna. In eget lobortis arcu. Interdum et malesuada fames ac ante ipsum primis in faucibus. Morbi aliquet purus non lacus scelerisque pellentesque. Nullam tempus arcu auctor nisi placerat cursus. Nunc sed odio sit amet dui eleifend iaculis. Sed enim augue, suscipit non metus eu, vehicula maximus enim. Sed auctor rhoncus tortor ac commodo. Nulla suscipit justo vel purus faucibus varius. Vestibulum mollis, libero vel scelerisque maximus, justo diam laoreet est, elementum suscipit nulla massa quis odio. Nam at consequat ipsum, in rhoncus diam. Suspendisse mattis augue id luctus tincidunt. Vivamus fringilla nisl imperdiet ligula tincidunt eleifend. + +Nam et convallis ipsum. Aenean cursus porta rutrum. Nam efficitur a risus ut gravida. Nulla viverra molestie porta. Suspendisse et risus vitae ante hendrerit tempus. Mauris iaculis magna eros, ac lacinia nisl elementum non. Suspendisse ultrices, libero quis faucibus facilisis, purus dolor aliquet nibh, vel scelerisque dolor ante maximus nulla. Aenean nec porta nisi. Nulla suscipit augue sit amet enim eleifend gravida. Quisque tristique finibus mattis. Quisque faucibus eros id nisl lobortis, at rhoncus dui ullamcorper. Phasellus vel risus malesuada, molestie elit eu, condimentum erat. Ut vel elit eu elit pellentesque luctus. Duis venenatis vehicula nisi, in iaculis mi eleifend at. Quisque arcu velit, suscipit in urna sit amet, ullamcorper malesuada ipsum. Donec maximus orci eget tellus blandit, sed tincidunt ante scelerisque. + +Curabitur rhoncus egestas consequat. Nunc sed ex turpis. Aliquam rhoncus fringilla arcu id dictum. Nullam dignissim lorem non lectus tempor porttitor. Donec nec nisl nec enim pulvinar tristique. Pellentesque tincidunt sem quis ex varius, quis viverra eros semper. Donec tristique tortor et odio placerat, a auctor nunc interdum. Fusce ultricies ut orci nec viverra. Sed a ex purus. Nunc id dolor eu ante posuere commodo. + +Nullam neque magna, maximus sit amet luctus nec, laoreet ac arcu. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam semper posuere laoreet. Curabitur orci velit, venenatis non metus eu, semper varius mi. Quisque non leo non quam molestie commodo ac lobortis turpis. Vestibulum rhoncus iaculis leo, eu tincidunt purus pretium ut. Cras eleifend metus sit amet mi suscipit consequat vel vitae diam. Ut iaculis ullamcorper leo in tincidunt. + +Morbi sem ex, vehicula ut augue et, interdum placerat quam. Sed ac ligula nulla. Ut rhoncus dapibus ipsum, sit amet condimentum turpis fermentum ut. Sed in nulla at ipsum vulputate tincidunt vel in ante. Donec nec suscipit nunc. Ut ultricies sem quis metus finibus pharetra. Vestibulum vestibulum nibh augue, ut pellentesque nisi congue at. Etiam pretium dolor ac fringilla cursus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vestibulum posuere placerat pharetra. Vestibulum tempus massa ac nulla pretium, id gravida felis luctus. Sed venenatis sollicitudin odio. Phasellus pellentesque ornare semper. Aenean libero turpis, varius et sapien sed, cursus laoreet lacus. Integer diam tortor, placerat interdum nunc quis, hendrerit tincidunt massa. + +Suspendisse et lacus elit. Nunc finibus dolor eget mattis lacinia. Fusce ac libero orci. Vestibulum gravida ligula eget sem venenatis fermentum. Fusce auctor volutpat est a dignissim. Nam eu mollis quam, in imperdiet mi. Mauris nec purus turpis. Nam volutpat metus ac eros eleifend malesuada. Aenean ut erat non lectus suscipit fringilla ac vel ipsum. Donec non mauris quis sem iaculis facilisis. Mauris convallis orci rutrum elit maximus imperdiet. + +Integer pellentesque non diam aliquet semper. Mauris ornare, quam vel condimentum pulvinar, purus quam rhoncus ipsum, sed congue dolor lorem in lectus. Pellentesque sed congue sapien. Aenean vitae lectus mollis, molestie purus vitae, tristique diam. Cras tristique consequat orci sit amet laoreet. Donec porta, risus sed fringilla tincidunt, dui magna mattis mauris, sed dignissim libero magna semper velit. Nullam enim tortor, interdum ac lacus eget, pulvinar volutpat libero. Mauris auctor lacinia tortor. Fusce at dolor sit amet dui pellentesque facilisis at ut nisi. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Praesent dictum risus eget enim bibendum, sed cursus orci faucibus. + +Pellentesque pulvinar, massa ac tincidunt consequat, enim lacus varius turpis, ut pellentesque nulla nulla ac leo. Pellentesque ac tincidunt neque, sit amet tempor mi. Phasellus imperdiet ornare lacus. Nam viverra vel ligula sed gravida. Aliquam in orci scelerisque, malesuada lorem tristique, hendrerit urna. In vel lacus tortor. Quisque ornare sem a orci convallis interdum. Aenean maximus laoreet velit sed laoreet. Aenean pellentesque a quam imperdiet sagittis. Sed et ultrices arcu, eget molestie lacus. Nullam cursus eros id metus porttitor ullamcorper. Nulla mattis dui ac nibh varius dapibus. Vestibulum quis pharetra ipsum. Morbi cursus sagittis nunc vitae luctus. Suspendisse gravida lacinia diam, ac venenatis justo varius eget. Sed sodales erat justo, ac tincidunt ante commodo eu. + +Quisque elit massa, commodo eget rhoncus sit amet, porta vel nunc. Vestibulum id leo leo. Aenean ac est ut justo tincidunt mattis quis sed leo. Nunc libero turpis, congue ut ligula sed, laoreet cursus mi. Pellentesque blandit eget est vel porta. Pellentesque malesuada, magna eu vulputate pharetra, nulla odio mollis nisl, vel rhoncus dui dolor volutpat elit. Aliquam tincidunt ultricies massa, vel finibus turpis aliquet ut. Nunc id nulla et risus pretium blandit. Pellentesque blandit rutrum ornare. Ut id venenatis urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. In maximus, tellus mollis dictum malesuada, lacus lectus blandit orci, id varius tellus libero non nisi. Sed maximus pellentesque vulputate. Praesent placerat, ligula vitae semper feugiat, mi quam finibus lacus, quis maximus tortor ex id lectus. Morbi dapibus eget orci tristique efficitur. Etiam dui magna, efficitur quis venenatis ac, suscipit eu orci. + +Aliquam turpis mi, luctus ut auctor vel, finibus sed neque. Integer eleifend sit amet justo vehicula sollicitudin. Aenean nec rutrum magna. In bibendum turpis ullamcorper, commodo metus nec, eleifend neque. Vivamus a interdum massa, ac tincidunt nibh. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque imperdiet, magna ac volutpat mattis, velit lacus rhoncus nisl, quis vehicula justo purus vel eros. Morbi ut dapibus justo, in cursus mauris. Curabitur imperdiet iaculis convallis. Ut mauris diam, venenatis non convallis et, euismod sed ex. Mauris tellus sapien, fermentum a mollis eu, sagittis non mi. Nunc ac vulputate purus. Phasellus orci tellus, interdum sit amet nunc in, vehicula convallis lacus. Curabitur nisl orci, gravida ut turpis a, bibendum molestie eros. + +Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Ut tempor vitae sapien ac sagittis. Vivamus imperdiet, est non venenatis blandit, justo tortor porttitor purus, nec pulvinar lectus ligula ut enim. Donec non erat id velit maximus imperdiet vel vitae quam. Duis eu efficitur massa. Fusce nec sem scelerisque, egestas lectus id, lacinia nunc. Vestibulum sollicitudin consectetur lectus. Donec tempor interdum faucibus. Morbi eget eleifend arcu. + +Phasellus ut auctor elit. Vivamus pretium nulla a bibendum rutrum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed justo odio, egestas quis felis vel, mollis pretium mauris. Integer lacinia odio porta justo auctor lacinia. Quisque faucibus eu nisi vel imperdiet. Curabitur et urna orci. Quisque vestibulum interdum ligula, ut rutrum mauris gravida id. Pellentesque iaculis mi in laoreet egestas. Donec lobortis facilisis eros, vel ultricies odio luctus vel. Curabitur in leo nunc. Donec hendrerit risus vitae augue hendrerit lobortis. In scelerisque tempus nunc, eu pellentesque lacus. + +Nunc nunc purus, suscipit non dignissim ac, cursus vitae metus. Nulla fringilla leo in libero mollis posuere. Phasellus ornare dignissim risus, at efficitur nisl porta et. Donec mollis fringilla massa, sed venenatis libero vehicula id. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed condimentum turpis ipsum. Ut consectetur turpis orci, sed tincidunt quam maximus nec. Curabitur fermentum nisi in dolor elementum aliquam. Integer dictum pharetra lectus at malesuada. Nunc tincidunt nunc ac bibendum semper. In fringilla purus ex, vel egestas justo vehicula nec. Cras non justo leo. Fusce posuere sapien eget felis dictum semper. Donec hendrerit condimentum magna vel faucibus. + +Maecenas tempor auctor augue, vitae lobortis lacus gravida sit amet. Nam ac mi nec sem ultricies luctus in et odio. Cras nibh nulla, finibus non ex et, accumsan euismod libero. Cras pretium ex sit amet sem luctus, sed ultrices enim posuere. Donec faucibus, mauris eu lacinia congue, lectus velit cursus est, ac vestibulum metus dolor eget nisl. Morbi varius quam a sem ullamcorper malesuada. Maecenas velit nibh, accumsan a eleifend ut, iaculis quis magna. Nam ante tortor, venenatis et dignissim sit amet, ornare eget mi. Morbi nec dui nisi. + +Integer scelerisque dictum lectus, sit amet feugiat eros pharetra a. Donec scelerisque nibh eget mi venenatis, a bibendum tortor rhoncus. Sed eleifend sit amet quam vitae ullamcorper. Integer libero mi, imperdiet eu ornare vitae, eleifend et orci. Nullam a rutrum dui. Donec eu finibus purus, eu lacinia nunc. Donec nec velit a massa ullamcorper ultrices a a augue. In hac habitasse platea dictumst. Morbi euismod purus at ipsum sollicitudin ullamcorper. Sed interdum dolor vel feugiat consequat. Praesent ac urna malesuada, bibendum turpis suscipit, blandit lacus. Maecenas placerat nisl facilisis pretium dignissim. + +Nunc nec sapien malesuada, malesuada nibh vitae, fringilla libero. Aliquam lobortis hendrerit leo, ut dignissim orci. Nunc ut dui nec tellus imperdiet bibendum. In vitae diam et urna luctus viverra in sit amet dui. Nulla ligula dui, laoreet non tempus nec, feugiat sit amet dolor. In condimentum posuere urna, at iaculis enim mollis eu. Ut consectetur odio at congue vulputate. Maecenas iaculis, ex sed fermentum mollis, neque urna pulvinar tellus, ac porttitor lacus dui ut est. Aliquam erat volutpat. Morbi vel tellus eu purus blandit blandit vitae vel elit. Sed est erat, sodales a sollicitudin at, semper vel dui. Pellentesque posuere nibh erat, ac finibus purus mattis id. Aliquam sagittis varius enim, id dignissim mauris hendrerit ut. + +Nam in posuere ligula. Pellentesque vehicula vulputate libero, ac porta risus mollis in. Aenean euismod nunc ut nisl interdum elementum. Fusce interdum imperdiet imperdiet. Donec sed massa vitae nunc iaculis rutrum. Quisque scelerisque commodo congue. Aenean porttitor elementum dolor, in accumsan elit pretium sit amet. + +Maecenas vitae est eget ex venenatis posuere. Fusce elementum turpis nec maximus semper. Nunc ultricies pulvinar mauris, vel rhoncus lorem molestie vitae. Sed sit amet facilisis urna. Cras ultrices lacus nec porttitor euismod. Ut elementum scelerisque tempor. Phasellus id elit viverra, tincidunt ex eget, vestibulum ligula. Integer non purus purus. Vestibulum dignissim posuere ligula ut vulputate. + +Fusce pretium felis in ullamcorper egestas. Sed sit amet gravida metus. Donec hendrerit tellus nec elit hendrerit, a tempor urna mattis. Donec mattis lobortis nulla, sit amet facilisis arcu euismod in. Nunc eget porttitor orci, a varius augue. Aliquam et aliquet eros, ac euismod mauris. Ut ac lacus est. Integer neque odio, efficitur vel tempus id, placerat quis magna. Donec a quam turpis. Ut eget mi maximus, fermentum mauris a, auctor massa. + +Quisque a tempus eros. Suspendisse mi felis, feugiat vestibulum bibendum vel, pellentesque sit amet risus. Nam commodo, elit molestie suscipit tristique, diam magna blandit nulla, ut tincidunt dui enim efficitur est. Curabitur rhoncus justo at lorem scelerisque porttitor. Aliquam vitae egestas nibh. Morbi at magna porttitor, cursus leo quis, hendrerit dui. Nulla accumsan libero ac lobortis bibendum. Pellentesque vel velit nunc. Duis volutpat posuere tellus ut tincidunt. \ No newline at end of file diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index b9e878f..6070a5d 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -5,14 +5,19 @@ import { RecordsQuery, RecordsRead, } from '@tbd54566975/dwn-sdk-js'; +import type { Dwn } from '@tbd54566975/dwn-sdk-js'; import { expect } from 'chai'; +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; import type { Server } from 'http'; import fetch from 'node-fetch'; import { webcrypto } from 'node:crypto'; +import { useFakeTimers } from 'sinon'; import request from 'supertest'; import { v4 as uuidv4 } from 'uuid'; +import { config } from '../src/config.js'; import { HttpApi } from '../src/http-api.js'; import type { JsonRpcErrorResponse, @@ -22,12 +27,16 @@ import { createJsonRpcRequest, JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import type { TenantGate } from '../src/tenant-gate.js'; +import { getTestDwn } from './test-dwn.js'; +import type { Profile } from './utils.js'; import { createProfile, createRecordsWriteMessage, getFileAsReadStream, streamHttpRequest, + checkNonce, + generateNonce, } from './utils.js'; if (!globalThis.crypto) { @@ -38,126 +47,527 @@ if (!globalThis.crypto) { describe('http api', function () { let httpApi: HttpApi; let server: Server; + let profile: Profile; + let tenantGate: TenantGate; + let dwn: Dwn; + let clock; before(async function () { - httpApi = new HttpApi(dwn); + clock = useFakeTimers({ shouldAdvanceTime: true }); + + config.registrationRequirementPow = true; + config.registrationRequirementTos = './tests/fixtures/tos.txt'; + const testdwn = await getTestDwn(true, true); + dwn = testdwn.dwn; + tenantGate = testdwn.tenantGate; + + httpApi = new HttpApi(dwn, tenantGate); + + await tenantGate.initialize(); + profile = await createProfile(); + await tenantGate.authorizeTenantPOW(profile.did); + await tenantGate.authorizeTenantTOS(profile.did); }); beforeEach(async function () { - server = httpApi.start(3000); + server = await httpApi.start(3000); }); afterEach(async function () { server.close(); server.closeAllConnections(); - await clearDwn(); }); - it('responds with a 400 if no dwn-request header is provided', async function () { - const response = await request(httpApi.api).post('/').send(); + after(function () { + clock.restore(); + }); - expect(response.statusCode).to.equal(400); + describe('/register/pow', function () { + it('returns a register challenge', async function () { + const response = await fetch('http://localhost:3000/register/pow'); + expect(response.status).to.equal(200); + const body = (await response.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(16); + expect(body.complexity).to.equal(5); + }); - const body = response.body as JsonRpcErrorResponse; - expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); - expect(body.error.message).to.equal('request payload required.'); - }); + it('accepts a correct registration challenge', async function () { + const challengeResponse = await fetch( + 'http://localhost:3000/register/pow', + ); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(16); + expect(body.complexity).to.equal(5); + + // solve the challenge + let response = ''; + while (!checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + } + + const p = await createProfile(); + const submitResponse = await fetch('http://localhost:3000/register/pow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: p.did, + }), + }); - it('responds with a 400 if parsing dwn request fails', async function () { - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', ';;;;@!#@!$$#!@%') - .send(); + expect(submitResponse.status).to.equal(200); - expect(response.statusCode).to.equal(400); + await tenantGate.authorizeTenantTOS(p.did); - const body = response.body as JsonRpcErrorResponse; - expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); - expect(body.error.message).to.include('JSON'); - }); + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: p.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: p.did, + }); + + const rpcResponse = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + console.log(rpcResponse.body.result.reply.status); + expect(rpcResponse.statusCode).to.equal(200); + expect(rpcResponse.body.id).to.equal(requestId); + expect(rpcResponse.body.result.reply.status.code).to.equal(200); + }).timeout(30000); + + it('rejects a registration challenge 5 minutes after it was issued', async function () { + const challengeResponse = await fetch( + 'http://localhost:3000/register/pow', + ); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(16); + expect(body.complexity).to.equal(5); + + clock.tick(5 * 60 * 1000); + clock.runToLast(); + + // solve the challenge + let response = ''; + while (!checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + } + + const p = await createProfile(); + const submitResponse = await fetch('http://localhost:3000/register/pow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: p.did, + }), + }); + + expect(submitResponse.status).to.equal(401); + }).timeout(30000); + + it('increase complexity as more challenges are completed', async function () { + for (let i = 1; i <= 60; i++) { + tenantGate.authorizeTenantPOW((await createProfile()).did); + } + + const p = await createProfile(); + const challengeResponse = await fetch( + 'http://localhost:3000/register/pow', + ); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(16); + + // solve the challenge + let response = ''; + let iterations = 0; + const start = Date.now(); + while (!checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + iterations++; + if (iterations % 10000000 == 0) { + console.log( + 'complexity:', + body.complexity, + 'iteration count:', + iterations, + 'duration:', + Date.now() - start, + 'ms', + ); + } + } + + console.log( + 'complexity:', + body.complexity, + 'iteration count:', + iterations, + 'duration:', + Date.now() - start, + 'ms', + ); + + const submitResponse = await fetch('http://localhost:3000/register/pow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: p.did, + }), + }); + + expect(submitResponse.status).to.equal(200); + }).timeout(120000); + + it('rejects an invalid nonce', async function () { + const challengeResponse = await fetch( + 'http://localhost:3000/register/pow', + ); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(16); + + // generate a nonce + let response = generateNonce(5); + // make sure the nonce is INVALID + // loop continues until checkNonce returns false, which is will probably do on the first iteration + while (checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + } + + const p = await createProfile(); + const submitResponse = await fetch('http://localhost:3000/register/pow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: p.did, + }), + }); + + expect(submitResponse.status).to.equal(401); + }); - it('responds with a 2XX HTTP status if JSON RPC handler returns 4XX/5XX DWN status code', async function () { - const alice = await createProfile(); - const { recordsWrite, dataStream } = await createRecordsWriteMessage(alice); + it('rejects a challenge it did not issue', async function () { + const challenge = generateNonce(10); - // Intentionally delete a required property to produce an invalid RecordsWrite message. - const message = recordsWrite.toJSON(); - delete message['descriptor']['interface']; + // solve the challenge + let response = ''; + while (!checkNonce(challenge, response, 2)) { + response = generateNonce(5); + } - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: message, - target: alice.did, + const p = await createProfile(); + const submitResponse = await fetch('http://localhost:3000/register/pow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: challenge, + response: response, + did: p.did, + }), + }); + + expect(submitResponse.status).to.equal(401); }); - const dataBytes = await DataStream.toBytes(dataStream); + it('rejects tenants that have not accepted the TOS and have not completed POW', async function () { + const unauthorized = await createProfile(); + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: unauthorized.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: unauthorized.did, + }); + + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); - // Attempt an initial RecordsWrite with the invalid message to ensure the DWN returns an error. - const responseInitialWrite = await fetch('http://localhost:3000', { - method: 'POST', - headers: { - 'dwn-request': JSON.stringify(dwnRequest), - }, - body: new Blob([dataBytes]), + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(requestId); + expect(response.body.result.reply.status.code).to.equal(401); }); - expect(responseInitialWrite.status).to.equal(200); + it('rejects tenants that have accepted the TOS but not completed POW', async function () { + const unauthorized = await createProfile(); + await tenantGate.authorizeTenantTOS(unauthorized.did); + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: unauthorized.signer, + }); - const body = (await responseInitialWrite.json()) as JsonRpcResponse; - expect(body.id).to.equal(requestId); - expect(body.error).to.not.exist; + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: unauthorized.did, + }); - const { reply } = body.result; - expect(reply.status.code).to.equal(400); - expect(reply.status.detail).to.include( - 'Both interface and method must be present', - ); + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(requestId); + expect(response.body.result.reply.status.code).to.equal(401); + }); }); - it('exposes dwn-response header', async function () { - // This test verifies that the Express web server includes `dwn-response` in the list of - // `access-control-expose-headers` returned in each HTTP response. This is necessary to enable applications - // that have CORS enabled to read and parse DWeb Messages that are returned as Response headers, particularly - // in the case of RecordsRead messages. + describe('/register/tos', function () { + it('allow tenant that after accepting the terms of service', async function () { + const response = await fetch('http://localhost:3000/register/tos'); + expect(response.status).to.equal(200); + + const terms = await response.text(); - // TODO: github.com/TBD54566975/dwn-server/issues/50 - // Consider replacing this test with a more robust method of testing, such as writing Playwright tests - // that run in a browser to verify that the `dwn-response` header can be read from the `fetch()` response - // when CORS mode is enabled. - const response = await request(httpApi.api).post('/').send(); + expect(terms).to.equal( + readFileSync('./tests/fixtures/tos.txt').toString(), + ); + + const hash = createHash('sha256'); + hash.update(terms); + + const p = await createProfile(); + + const acceptResponse = await fetch('http://localhost:3000/register/tos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + did: p.did, + tosHash: hash.digest('hex'), + }), + }); + expect(acceptResponse.status).to.equal(200); + await tenantGate.authorizeTenantPOW(p.did); + + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: p.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: p.did, + }); - // Check if the 'access-control-expose-headers' header is present - expect(response.headers).to.have.property('access-control-expose-headers'); + const rpcResponse = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + console.log(rpcResponse.body.result.reply.status); + expect(rpcResponse.statusCode).to.equal(200); + expect(rpcResponse.body.id).to.equal(requestId); + expect(rpcResponse.body.result.reply.status.code).to.equal(200); + }); - // Check if the 'dwn-response' header is listed in 'access-control-expose-headers' - const exposedHeaders = response.headers['access-control-expose-headers']; - expect(exposedHeaders).to.include('dwn-response'); + it('rejects tenants that have completed POW but have not accepted the TOS', async function () { + const unauthorized = await createProfile(); + await tenantGate.authorizeTenantPOW(unauthorized.did); + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: unauthorized.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: unauthorized.did, + }); + + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(requestId); + expect(response.body.result.reply.status.code).to.equal(401); + }); + + it('rejects TOS acceptance with incorrect hash', async function () { + const hash = createHash('sha256'); + hash.update('i do not agree'); + + const p = await createProfile(); + + const acceptResponse = await fetch('http://localhost:3000/register/tos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + did: p.did, + tosHash: hash.digest('hex'), + }), + }); + expect(acceptResponse.status).to.equal(400); + await tenantGate.authorizeTenantPOW(p.did); + + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: p.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: p.did, + }); + + const rpcResponse = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + console.log(rpcResponse.body.result.reply.status); + expect(rpcResponse.statusCode).to.equal(200); + expect(rpcResponse.body.id).to.equal(requestId); + expect(rpcResponse.body.result.reply.status.code).to.equal(401); + }); }); - it('works fine when no request body is provided', async function () { - const alice = await createProfile(); - const recordsQuery = await RecordsQuery.create({ - filter: { - schema: 'woosa', - }, - signer: alice.signer, + describe('/ (rpc)', function () { + it('responds with a 400 if no dwn-request header is provided', async function () { + const response = await request(httpApi.api).post('/').send(); + + expect(response.statusCode).to.equal(400); + + const body = response.body as JsonRpcErrorResponse; + expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); + expect(body.error.message).to.equal('request payload required.'); }); - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: alice.did, + it('responds with a 400 if parsing dwn request fails', async function () { + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', ';;;;@!#@!$$#!@%') + .send(); + + expect(response.statusCode).to.equal(400); + + const body = response.body as JsonRpcErrorResponse; + expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); + expect(body.error.message).to.include('JSON'); }); - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); + it('responds with a 2XX HTTP status if JSON RPC handler returns 4XX/5XX DWN status code', async function () { + const { recordsWrite, dataStream } = + await createRecordsWriteMessage(profile); + + // Intentionally delete a required property to produce an invalid RecordsWrite message. + const message = recordsWrite.toJSON(); + delete message['descriptor']['interface']; + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: message, + target: profile.did, + }); + + const dataBytes = await DataStream.toBytes(dataStream); - expect(response.statusCode).to.equal(200); - expect(response.body.id).to.equal(requestId); - expect(response.body.error).to.not.exist; - expect(response.body.result.reply.status.code).to.equal(200); + // Attempt an initial RecordsWrite with the invalid message to ensure the DWN returns an error. + const responseInitialWrite = await fetch('http://localhost:3000', { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(dwnRequest), + }, + body: new Blob([dataBytes]), + }); + + expect(responseInitialWrite.status).to.equal(200); + + const body = (await responseInitialWrite.json()) as JsonRpcResponse; + expect(body.id).to.equal(requestId); + expect(body.error).to.not.exist; + + const { reply } = body.result; + expect(reply.status.code).to.equal(400); + expect(reply.status.detail).to.include( + 'Both interface and method must be present', + ); + }); + + it('exposes dwn-response header', async function () { + // This test verifies that the Express web server includes `dwn-response` in the list of + // `access-control-expose-headers` returned in each HTTP response. This is necessary to enable applications + // that have CORS enabled to read and parse DWeb Messages that are returned as Response headers, particularly + // in the case of RecordsRead messages. + + // TODO: github.com/TBD54566975/dwn-server/issues/50 + // Consider replacing this test with a more robust method of testing, such as writing Playwright tests + // that run in a browser to verify that the `dwn-response` header can be read from the `fetch()` response + // when CORS mode is enabled. + const response = await request(httpApi.api).post('/').send(); + + // Check if the 'access-control-expose-headers' header is present + expect(response.headers).to.have.property( + 'access-control-expose-headers', + ); + + // Check if the 'dwn-response' header is listed in 'access-control-expose-headers' + const exposedHeaders = response.headers['access-control-expose-headers']; + expect(exposedHeaders).to.include('dwn-response'); + }); + + it('works fine when no request body is provided', async function () { + const recordsQuery = await RecordsQuery.create({ + filter: { + schema: 'woosa', + }, + signer: profile.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: profile.did, + }); + + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(requestId); + expect(response.body.error).to.not.exist; + expect(response.body.result.reply.status.code).to.equal(200); + }); }); describe('RecordsWrite', function () { @@ -165,8 +575,7 @@ describe('http api', function () { const filePath = './fixtures/test.jpeg'; const { cid, size, stream } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: cid, dataSize: size, }); @@ -174,7 +583,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); const resp = await streamHttpRequest( @@ -200,16 +609,18 @@ describe('http api', function () { }); it('handles RecordsWrite overwrite that does not mutate data', async function () { - const alice = await createProfile(); + const p = await createProfile(); + await tenantGate.authorizeTenantPOW(p.did); + await tenantGate.authorizeTenantTOS(p.did); // First RecordsWrite that creates the record. const { recordsWrite: initialWrite, dataStream } = - await createRecordsWriteMessage(alice); + await createRecordsWriteMessage(p); const dataBytes = await DataStream.toBytes(dataStream); let requestId = uuidv4(); let dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: initialWrite.toJSON(), - target: alice.did, + target: p.did, }); const responseInitialWrite = await fetch('http://localhost:3000', { @@ -223,21 +634,18 @@ describe('http api', function () { expect(responseInitialWrite.status).to.equal(200); // Subsequent RecordsWrite that mutates the published property of the record. - const { recordsWrite: overWrite } = await createRecordsWriteMessage( - alice, - { - recordId: initialWrite.message.recordId, - dataCid: initialWrite.message.descriptor.dataCid, - dataSize: initialWrite.message.descriptor.dataSize, - dateCreated: initialWrite.message.descriptor.dateCreated, - published: true, - }, - ); + const { recordsWrite: overWrite } = await createRecordsWriteMessage(p, { + recordId: initialWrite.message.recordId, + dataCid: initialWrite.message.descriptor.dataCid, + dataSize: initialWrite.message.descriptor.dataSize, + dateCreated: initialWrite.message.descriptor.dateCreated, + published: true, + }); requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: overWrite.toJSON(), - target: alice.did, + target: p.did, }); const responseOverwrite = await fetch('http://localhost:3000', { method: 'POST', @@ -254,18 +662,18 @@ describe('http api', function () { expect(body.error).to.not.exist; const { reply } = body.result; + console.log(reply); expect(reply.status.code).to.equal(202); }); it('handles a RecordsWrite tombstone', async function () { - const alice = await createProfile(); const { recordsWrite: tombstone } = - await createRecordsWriteMessage(alice); + await createRecordsWriteMessage(profile); const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: tombstone.toJSON(), - target: alice.did, + target: profile.did, }); const responeTombstone = await fetch('http://localhost:3000', { @@ -306,8 +714,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: expectedCid, dataSize: size, }); @@ -315,7 +722,7 @@ describe('http api', function () { let requestId = uuidv4(); let dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); let response = await fetch('http://localhost:3000', { @@ -336,7 +743,7 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); const recordsRead = await RecordsRead.create({ - signer: alice.signer, + signer: profile.signer, filter: { recordId: recordsWrite.message.recordId, }, @@ -344,7 +751,7 @@ describe('http api', function () { requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - target: alice.did, + target: profile.did, message: recordsRead.toJSON(), }); @@ -390,8 +797,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: expectedCid, dataSize: size, published: true, @@ -400,7 +806,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); let response = await fetch('http://localhost:3000', { @@ -421,7 +827,7 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); response = await fetch( - `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, ); const blob = await response.blob(); @@ -436,8 +842,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: expectedCid, dataSize: size, }); @@ -445,7 +850,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); let response = await fetch('http://localhost:3000', { @@ -466,38 +871,50 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); response = await fetch( - `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); it('returns a 404 if record doesnt exist', async function () { - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice); + const { recordsWrite } = await createRecordsWriteMessage(profile); const response = await fetch( - `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); - it('returns a 404 for invalid did', async function () { - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice); + it('returns a 404 for invalid or unauthorized did', async function () { + const unauthorized = await createProfile(); + const { recordsWrite } = await createRecordsWriteMessage(unauthorized); const response = await fetch( - `http://localhost:3000/1234567892345678/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${unauthorized.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); it('returns a 404 for invalid record id', async function () { - const alice = await createProfile(); const response = await fetch( - `http://localhost:3000/${alice.did}/records/kaka`, + `http://localhost:3000/${profile.did}/records/kaka`, ); expect(response.status).to.equal(404); }); }); + + describe('/info.json', function () { + it('verify /info.json has some of the fields it is supposed to have', async function () { + const resp = await fetch(`http://localhost:3000/info.json`); + expect(resp.status).to.equal(200); + + const info = await resp.json(); + expect(info['server']).to.equal('@web5/dwn-server'); + expect(info['registrationRequirements']).to.include('terms-of-service'); + expect(info['registrationRequirements']).to.include( + 'proof-of-work-sha256-v0', + ); + }); + }); }); diff --git a/tests/process-handler.spec.ts b/tests/process-handler.spec.ts index caa13c6..ba18a22 100644 --- a/tests/process-handler.spec.ts +++ b/tests/process-handler.spec.ts @@ -3,20 +3,15 @@ import sinon from 'sinon'; import { config } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; -import { clear, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; describe('Process Handlers', function () { let dwnServer: DwnServer; - const options = { - dwn: dwn, - config: config, - }; let processExitStub: sinon.SinonStub; - before(async function () { - dwnServer = new DwnServer(options); - }); beforeEach(async function () { + const testdwn = await getTestDwn(); + dwnServer = new DwnServer({ dwn: testdwn.dwn, config: config }); await dwnServer.start(); processExitStub = sinon.stub(process, 'exit'); }); @@ -24,7 +19,6 @@ describe('Process Handlers', function () { process.removeAllListeners('SIGINT'); process.removeAllListeners('SIGTERM'); process.removeAllListeners('uncaughtException'); - await clear(); processExitStub.restore(); }); it('should stop when SIGINT is emitted', async function () { diff --git a/tests/test-dwn.ts b/tests/test-dwn.ts index 515ece5..f02979e 100644 --- a/tests/test-dwn.ts +++ b/tests/test-dwn.ts @@ -1,21 +1,45 @@ +import { Dwn } from '@tbd54566975/dwn-sdk-js'; import { - Dwn, - DataStoreLevel, - EventLogLevel, - MessageStoreLevel, -} from '@tbd54566975/dwn-sdk-js'; + DataStoreSql, + EventLogSql, + MessageStoreSql, +} from '@tbd54566975/dwn-sql-store'; -const dataStore = new DataStoreLevel({ blockstoreLocation: 'data/DATASTORE' }); -const eventLog = new EventLogLevel({ location: 'data/EVENTLOG' }); -const messageStore = new MessageStoreLevel({ - blockstoreLocation: 'data/MESSAGESTORE', - indexLocation: 'data/INDEX', -}); +import { readFileSync } from 'node:fs'; -export const dwn = await Dwn.create({ eventLog, dataStore, messageStore }); +import { getDialectFromURI } from '../src/storage.js'; +import { TenantGate } from '../src/tenant-gate.js'; -export async function clear(): Promise { - await dataStore.clear(); - await eventLog.clear(); - await messageStore.clear(); +export async function getTestDwn( + powRequired?: boolean, + tosRequired?: boolean, +): Promise<{ + dwn: Dwn; + tenantGate: TenantGate; +}> { + const db = getDialectFromURI(new URL('sqlite://')); + const dataStore = new DataStoreSql(db); + const eventLog = new EventLogSql(db); + const messageStore = new MessageStoreSql(db); + const tenantGate = new TenantGate( + db, + powRequired, + tosRequired, + tosRequired ? readFileSync('./tests/fixtures/tos.txt').toString() : null, + true, + ); + + let dwn: Dwn; + try { + dwn = await Dwn.create({ + eventLog, + dataStore, + messageStore, + tenantGate, + }); + } catch (e) { + throw e; + } + + return { dwn, tenantGate }; } diff --git a/tests/utils.ts b/tests/utils.ts index 703a76c..9991203 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -7,6 +7,7 @@ import { RecordsWrite, } from '@tbd54566975/dwn-sdk-js'; +import { createHash } from 'crypto'; import type { ReadStream } from 'node:fs'; import fs from 'node:fs'; import http from 'node:http'; @@ -188,3 +189,28 @@ export async function sendWsMessage( }; }); } + +const nonceChars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +export function generateNonce(size: number): string { + let challenge = ''; + while (challenge.length < size) { + challenge += nonceChars.charAt( + Math.floor(Math.random() * nonceChars.length), + ); + } + return challenge; +} + +export function checkNonce( + challenge: string, + nonce: string, + complexity: number, +): boolean { + const hash = createHash('sha256'); + hash.update(challenge); + hash.update(nonce); + + return hash.digest('hex').startsWith('0'.repeat(complexity)); +} diff --git a/tests/ws-api.spec.ts b/tests/ws-api.spec.ts index 6e836aa..5a49dab 100644 --- a/tests/ws-api.spec.ts +++ b/tests/ws-api.spec.ts @@ -11,7 +11,7 @@ import { JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; import { WsApi } from '../src/ws-api.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; import { createProfile, createRecordsWriteMessage, @@ -26,14 +26,11 @@ describe('websocket api', function () { server = http.createServer(); server.listen(9002, '127.0.0.1'); - const wsApi = new WsApi(server, dwn); + const testdwn = await getTestDwn(); + const wsApi = new WsApi(server, testdwn.dwn); wsServer = wsApi.start(); }); - afterEach(async function () { - await clearDwn(); - }); - after(function () { wsServer.close(); server.close(); @@ -61,6 +58,7 @@ describe('websocket api', function () { it('handles RecordsWrite messages', async function () { const alice = await createProfile(); + const { recordsWrite, dataStream } = await createRecordsWriteMessage(alice); const dataBytes = await DataStream.toBytes(dataStream); const encodedData = base64url.baseEncode(dataBytes); @@ -78,6 +76,7 @@ describe('websocket api', function () { ); const resp = JSON.parse(data.toString()); expect(resp.id).to.equal(requestId); + console.log(resp.error); expect(resp.error).to.not.exist; const { reply } = resp.result;