diff --git a/package.json b/package.json index 1d37291..91326c4 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "c8": "8.0.1", "chai": "4.3.6", "chai-as-promised": "7.1.1", + "crypto-browserify": "^3.12.0", "esbuild": "0.16.17", "eslint": "8.33.0", "eslint-config-prettier": "^9.0.0", @@ -64,11 +65,18 @@ "eslint-plugin-mocha": "10.1.0", "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-todo-plz": "^1.3.0", + "http-proxy": "^1.18.1", "husky": "^8.0.0", + "karma": "^6.4.2", + "karma-chrome-launcher": "^3.2.0", + "karma-esbuild": "^2.2.5", + "karma-mocha": "^2.0.1", "lint-staged": "^14.0.1", "mocha": "^10.2.0", "prettier": "3.0.3", + "puppeteer": "^21.4.0", "sinon": "16.1.0", + "stream-browserify": "^3.0.0", "supertest": "6.3.3", "typescript": "^5.1.6" }, diff --git a/tests/cors.spec.ts b/tests/cors.spec.ts new file mode 100644 index 0000000..d55d571 --- /dev/null +++ b/tests/cors.spec.ts @@ -0,0 +1,196 @@ +import { expect } from 'chai'; +import * as http from 'http'; +import { default as httpProxy } from 'http-proxy'; +import { default as karma } from 'karma'; +import type { AddressInfo } from 'net'; +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'; + +let noBrowser; +try { + process.env.CHROME_BIN = executablePath(); +} catch (e) { + noBrowser = e; +} + +class CorsProxySetup { + server = null; + proxy = null; + dwnServer = null; + karmaPort = 9876; + proxyPort = 9875; + + public async start(): Promise { + const dwnServer = new DwnServer({ + dwn: dwn, + config: { + ...defaultConfig, + port: 0, // UNSPEC to obtain test specific free port + }, + }); + const dwnPort = await new Promise((resolve) => { + dwnServer.start(() => { + const port = (dwnServer.httpServer.address() as AddressInfo).port; + resolve(port); + }); + }); + // setup proxy server + const proxy = httpProxy.createProxyServer({}); + const server = http.createServer((req, res) => { + const [host] = req.headers.host.split(':', 2); + if (host == 'dwn.localhost') { + proxy.web(req, res, { target: `http://127.0.0.1:${dwnPort}` }); + } else if (host == 'app.localhost') { + proxy.web(req, res, { target: `http://127.0.0.1:${this.karmaPort}` }); + } else { + res.write('unexpected'); + } + }); + await new Promise((done) => { + server.listen(0, () => { + this.proxyPort = (server.address() as AddressInfo).port; + done(null); + }); + }); + + this.dwnServer = dwnServer; + this.proxy = proxy; + this.server = server; + } + public async stop(): Promise { + const server = this.server; + const dwnServer = this.dwnServer; + const proxy = this.proxy; + + // shutdown proxy server + proxy.close(); + await new Promise((resolve) => { + server.close(() => { + server.closeAllConnections(); + resolve(null); + }); + }); + // shutdown dwn + await new Promise((resolve) => { + dwnServer.stop(resolve); + }); + await clearDwn(); + } +} + +async function karmaRun(proxy, specfile): Promise { + const runResults: any = {}; + const browserErrors = []; + const specResults = []; + await new Promise((karmaRunDone) => { + function karmaResultCapture(config): void { + proxy.karmaPort = config.port; // karma port may change on startup + this.onRunComplete = (browsers, results): void => { + Object.assign(runResults, results); + }; + this.onSpecComplete = (browser, result): void => { + specResults.push(result); + }; + this.onBrowserError = (browser, error): void => { + browserErrors.push(error); + }; + this.onBrowserLog = (browser, log): void => { + console.log(log); + }; + } + karmaResultCapture.$inject = ['config']; + + const conf = karma.config.parseConfig( + null, + { + logLevel: karma.constants.LOG_WARN, + singleRun: true, + autoWatch: false, + files: [specfile], + preprocessors: { [specfile]: ['esbuild'] }, + plugins: [ + 'karma-mocha', + 'karma-esbuild', + 'karma-chrome-launcher', + { 'reporter:capture': ['type', karmaResultCapture] }, + ], + frameworks: ['mocha'], + customLaunchers: { + ChromeHeadless_with_proxy: { + base: 'ChromeHeadless', + flags: [ + `--proxy-server=http=127.0.0.1:${proxy.proxyPort}`, + '--proxy-bypass-list=<-loopback>', + ], + }, + }, + browsers: ['ChromeHeadless_with_proxy'], + reporters: ['capture'], + upstreamProxy: { + hostname: 'app.localhost', + }, + esbuild: { + target: 'chrome80', + define: { + global: 'window', + }, + alias: { + crypto: 'crypto-browserify', + stream: 'stream-browserify', + }, + }, + }, + { throwErrors: true }, + ); + + const kserver = new karma.Server(conf, () => { + // avoid process.exit call + // Use mocha --exit flag because + // esbuild service process still runs in background + karmaRunDone(null); + }); + kserver.start(); + }); + for (const error of browserErrors) { + throw error; + } + for (const result of specResults) { + if (!result.success) { + throw new Error(result.log.join('')); + } + } + expect(runResults.error).to.be.false; + expect(runResults.failed).to.be.equal(0); + expect(runResults.success).to.be.above(0); +} + +describe('CORS setup', function () { + // create proxy server to create cross-origin hostnames. + // mocha test app runs on app.localhost + // dwn-server runs on dwn.localhost + const proxy = new CorsProxySetup(); + before(async () => { + await proxy.start(); + }); + after(async () => { + await proxy.stop(); + }); + this.timeout(5000); + it('should run blank browser karma test', async function () { + if (noBrowser) { + this.skip(); + } else { + await karmaRun(proxy, 'dist/esm/tests/cors/ping.browser.js'); + } + }); + it('should run http-api browser karma test', async function () { + if (noBrowser) { + this.skip(); + } else { + await karmaRun(proxy, 'dist/esm/tests/cors/http-api.browser.js'); + } + }); +}); diff --git a/tests/cors/http-api.browser.ts b/tests/cors/http-api.browser.ts new file mode 100644 index 0000000..51ced57 --- /dev/null +++ b/tests/cors/http-api.browser.ts @@ -0,0 +1,74 @@ +import { + DidKeyResolver, + RecordsRead, + RecordsWrite, + Jws, +} from '@tbd54566975/dwn-sdk-js'; + +import { expect } from 'chai'; + +describe('http-api', () => { + it('sends dwn-response header', async function () { + // Some crypto functions used in key generation and signing, + // work only under secure context. + // Test code runs on secure context of http://dwn.localhost + // by cors setup. + const alice = await DidKeyResolver.generate(); + const encoder = new TextEncoder(); + const data = encoder.encode('Hello, World!'); + const recordsWrite = ( + await RecordsWrite.create({ + data, + dataFormat: 'text/plalin', + published: true, + authorizationSigner: Jws.createSigner(alice), + }) + ).toJSON(); + const recordsRead = ( + await RecordsRead.create({ + filter: { + recordId: recordsWrite.recordId, + }, + authorizationSigner: Jws.createSigner(alice), + }) + ).toJSON(); + + // Records Write + const recordsWriteResponse = await fetch('http://dwn.localhost', { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify({ + method: 'dwn.processMessage', + params: { + target: alice.did, + message: recordsWrite, + }, + }), + }, + body: data, + }); + expect(recordsWriteResponse.status).to.equal(200); + const recordsWriteResponseJson = await recordsWriteResponse.json(); + expect(recordsWriteResponseJson.result?.reply?.status?.code).to.equal(202); + + // Records Read + const recordsReadResponse = await fetch('http://dwn.localhost', { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify({ + method: 'dwn.processMessage', + params: { + target: alice.did, + message: recordsRead, + }, + }), + }, + }); + expect(recordsReadResponse.status).to.equal(200); + const recordsReadResponseJson = JSON.parse( + recordsReadResponse.headers.get('dwn-response'), + ); + expect(recordsReadResponseJson.result?.reply?.status?.code).to.equal(200); + expect(await recordsReadResponse.text()).to.equal('Hello, World!'); + }); +}); diff --git a/tests/cors/ping.browser.ts b/tests/cors/ping.browser.ts new file mode 100644 index 0000000..4306452 --- /dev/null +++ b/tests/cors/ping.browser.ts @@ -0,0 +1,7 @@ +import { expect } from 'chai'; + +describe('cors-test', () => { + it('always true', () => { + expect(true).to.true; + }); +});