From c997b9169812c95f1f272220332f680e61ba1af8 Mon Sep 17 00:00:00 2001 From: anusree-bruno Date: Wed, 22 Oct 2025 14:57:19 +0530 Subject: [PATCH] added jsonwebtoken as inbuilt library (#5535) * added jsonwebtoken as inbuilt library * removed bundling * handle callback in quickjs * chore: tests folder restructure * chore: lint fix --------- Co-authored-by: Sid --- packages/bruno-js/package.json | 1 + .../bruno-js/src/runtime/script-runtime.js | 3 + packages/bruno-js/src/runtime/test-runtime.js | 5 +- .../src/sandbox/quickjs/shims/lib/index.js | 2 + .../src/sandbox/quickjs/shims/lib/jwt.js | 181 ++++++++++++++++++ .../src/sandbox/quickjs/utils/index.js | 50 ++++- .../fixtures/collection/bruno.json | 9 + .../fixtures/collection/decode/decode.bru | 66 +++++++ .../fixtures/collection/decode/folder.bru | 8 + .../fixtures/collection/environments/Prod.bru | 4 + .../fixtures/collection/sign/folder.bru | 8 + .../sign/sign with callback err.bru | 74 +++++++ .../sign/sign with callback token.bru | 160 ++++++++++++++++ .../fixtures/collection/sign/sign.bru | 109 +++++++++++ .../fixtures/collection/verify/folder.bru | 8 + .../verify/verify with callback err.bru | 85 ++++++++ .../verify/verify with callback token.bru | 114 +++++++++++ .../fixtures/collection/verify/verify.bru | 104 ++++++++++ .../init-user-data/preferences.json | 16 ++ .../init-user-data/ui-state-snapshot.json | 8 + .../jsonwebtoken/jsonwebtoken.spec.ts | 64 +++++++ 21 files changed, 1077 insertions(+), 2 deletions(-) create mode 100644 packages/bruno-js/src/sandbox/quickjs/shims/lib/jwt.js create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/bruno.json create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/decode/decode.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/decode/folder.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/environments/Prod.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/folder.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign with callback err.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign with callback token.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/folder.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify with callback err.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify with callback token.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify.bru create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/init-user-data/preferences.json create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/init-user-data/ui-state-snapshot.json create mode 100644 tests/scripting/inbuilt-libraries/jsonwebtoken/jsonwebtoken.spec.ts diff --git a/packages/bruno-js/package.json b/packages/bruno-js/package.json index bcae171a4..11cdf1df0 100644 --- a/packages/bruno-js/package.json +++ b/packages/bruno-js/package.json @@ -27,6 +27,7 @@ "cheerio": "^1.0.0", "crypto-js": "^4.2.0", "json-query": "^2.2.2", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "moment": "^2.29.4", "nanoid": "3.3.8", diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js index 83ef18276..8ab3ddc03 100644 --- a/packages/bruno-js/src/runtime/script-runtime.js +++ b/packages/bruno-js/src/runtime/script-runtime.js @@ -33,6 +33,7 @@ const NodeVault = require('node-vault'); const xml2js = require('xml2js'); const cheerio = require('cheerio'); const tv4 = require('tv4'); +const jsonwebtoken = require('jsonwebtoken'); const { executeQuickJsVmAsync } = require('../sandbox/quickjs'); class ScriptRuntime { @@ -185,6 +186,7 @@ class ScriptRuntime { 'node-fetch': fetch, 'crypto-js': CryptoJS, xml2js: xml2js, + jsonwebtoken, cheerio, tv4, ...whitelistedModules, @@ -354,6 +356,7 @@ class ScriptRuntime { 'node-fetch': fetch, 'crypto-js': CryptoJS, 'xml2js': xml2js, + jsonwebtoken, cheerio, tv4, ...whitelistedModules, diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js index 1b7263c67..e8ee51f76 100644 --- a/packages/bruno-js/src/runtime/test-runtime.js +++ b/packages/bruno-js/src/runtime/test-runtime.js @@ -35,6 +35,7 @@ const NodeVault = require('node-vault'); const xml2js = require('xml2js'); const cheerio = require('cheerio'); const tv4 = require('tv4'); +const jsonwebtoken = require('jsonwebtoken'); const { executeQuickJsVmAsync } = require('../sandbox/quickjs'); class TestRuntime { @@ -103,7 +104,8 @@ class TestRuntime { res, expect: chai.expect, assert: chai.assert, - __brunoTestResults: __brunoTestResults + __brunoTestResults: __brunoTestResults, + jwt: jsonwebtoken }; if (onConsoleLog && typeof onConsoleLog === 'function') { @@ -176,6 +178,7 @@ class TestRuntime { 'xml2js': xml2js, cheerio, tv4, + 'jsonwebtoken': jsonwebtoken, ...whitelistedModules, fs: allowScriptFilesystemAccess ? fs : undefined, 'node-vault': NodeVault diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/index.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/index.js index 64f239c7f..cc0890a07 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/lib/index.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/index.js @@ -2,12 +2,14 @@ const addAxiosShimToContext = require('./axios'); const addNanoidShimToContext = require('./nanoid'); const addPathShimToContext = require('./path'); const addUuidShimToContext = require('./uuid'); +const addJwtShimToContext = require('./jwt'); const addLibraryShimsToContext = async (vm) => { await addNanoidShimToContext(vm); await addAxiosShimToContext(vm); await addUuidShimToContext(vm); await addPathShimToContext(vm); + await addJwtShimToContext(vm); }; module.exports = addLibraryShimsToContext; diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/jwt.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/jwt.js new file mode 100644 index 000000000..e99117096 --- /dev/null +++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/jwt.js @@ -0,0 +1,181 @@ +const jwt = require('jsonwebtoken'); +const { marshallToVm, invokeFunction } = require('../../utils'); + +const addJwtShimToContext = async (vm) => { + // --- sign --- + const _jwtSign = vm.newFunction('sign', function (payload, secret, options, callback) { + const nativePayload = vm.dump(payload); + const nativeSecret = vm.dump(secret); + + let nativeOptions; + let callbackHandle = callback; + const optionsType = options === undefined ? 'undefined' : vm.typeof(options); + if (optionsType === 'function') { + callbackHandle = options; + nativeOptions = undefined; + } else if (optionsType === 'object' && options !== null) { + nativeOptions = vm.dump(options); + } + + // If a callback is provided + if (callbackHandle && vm.typeof(callbackHandle) === 'function') { + let tokenResult; + let hostError; + try { + tokenResult = nativeOptions + ? jwt.sign(nativePayload, nativeSecret, nativeOptions) + : jwt.sign(nativePayload, nativeSecret); + } catch (err) { + hostError = err; + } + + try { + if (hostError) { + const errVm = vm.newError(hostError.message || String(hostError)); + invokeFunction(vm, callbackHandle, [errVm, vm.undefined]) + .catch((e) => { + console.warn('[JWT SHIM][sign.cb] callback invocation error:', e); + }) + .finally(() => { + errVm.dispose(); + callbackHandle.dispose(); + }); + } else { + const tokenVm = marshallToVm(String(tokenResult), vm); + invokeFunction(vm, callbackHandle, [vm.null, tokenVm]) + .catch((e) => { + console.warn('[JWT SHIM][sign.cb] callback invocation error:', e); + }) + .finally(() => { + tokenVm.dispose(); + callbackHandle.dispose(); + }); + } + } catch (e) { + console.warn('[JWT SHIM][sign.cb] unexpected error:', e); + callbackHandle.dispose(); + } + + return vm.undefined; + } + + try { + const token = nativeOptions + ? jwt.sign(nativePayload, nativeSecret, nativeOptions) + : jwt.sign(nativePayload, nativeSecret); + return marshallToVm(token, vm); + } catch (err) { + throw vm.newError(err.message || String(err)); + } + }); + + vm.setProp(vm.global, '__bruno__jwt__sign', _jwtSign); + _jwtSign.dispose(); + + // --- verify --- + const _jwtVerify = vm.newFunction('verify', function (token, secret, options, callback) { + const nativeToken = vm.dump(token); + const nativeSecret = vm.dump(secret); + + let nativeOptions; + let actualCallback = callback; + + const optionsType = options === undefined ? 'undefined' : vm.typeof(options); + if (optionsType === 'function') { + actualCallback = options; + nativeOptions = undefined; + } else if (optionsType === 'object' && options !== null) { + nativeOptions = vm.dump(options); + } + + if (actualCallback && vm.typeof(actualCallback) === 'function') { + let decodedResult; + let hostError; + try { + decodedResult = nativeOptions + ? jwt.verify(nativeToken, nativeSecret, nativeOptions) + : jwt.verify(nativeToken, nativeSecret); + } catch (err) { + hostError = err; + } + + try { + if (hostError) { + const vmErr = vm.newError(hostError.message || String(hostError)); + invokeFunction(vm, actualCallback, [vmErr, vm.undefined]) + .catch((e) => { + console.warn('[JWT SHIM][verify.cb] callback invocation error:', e); + }) + .finally(() => { + vmErr.dispose(); + actualCallback.dispose(); + }); + } else { + const vmNull = vm.null; + const vmDecoded = marshallToVm(decodedResult, vm); + invokeFunction(vm, actualCallback, [vmNull, vmDecoded]) + .catch((e) => { + console.warn('[JWT SHIM][verify.cb] callback invocation error:', e); + }) + .finally(() => { + vmDecoded.dispose(); + actualCallback.dispose(); + }); + } + } catch (e) { + console.warn('[JWT SHIM][verify.cb] unexpected error:', e); + actualCallback.dispose(); + } + + return vm.undefined; + } + + try { + const decoded = nativeOptions + ? jwt.verify(nativeToken, nativeSecret, nativeOptions) + : jwt.verify(nativeToken, nativeSecret); + return marshallToVm(decoded, vm); + } catch (err) { + throw vm.newError(err.message || String(err)); + } + }); + + vm.setProp(vm.global, '__bruno__jwt__verify', _jwtVerify); + _jwtVerify.dispose(); + + // --- decode --- + const _jwtDecode = vm.newFunction('decode', function (token, options) { + const nativeToken = vm.dump(token); + + let nativeOptions; + const optionsType = options === undefined ? 'undefined' : vm.typeof(options); + if (optionsType === 'object' && options !== null) { + nativeOptions = vm.dump(options); + } + + try { + const decoded = nativeOptions + ? jwt.decode(nativeToken, nativeOptions) + : jwt.decode(nativeToken); + return marshallToVm(decoded, vm); + } catch (err) { + throw vm.newError(err.message || String(err)); + } + }); + + vm.setProp(vm.global, '__bruno__jwt__decode', _jwtDecode); + _jwtDecode.dispose(); + + vm.evalCode(` + globalThis.jwt = {}; + globalThis.jwt.sign = globalThis.__bruno__jwt__sign; + globalThis.jwt.verify = globalThis.__bruno__jwt__verify; + globalThis.jwt.decode = globalThis.__bruno__jwt__decode; + globalThis.requireObject = { + ...globalThis.requireObject, + 'jsonwebtoken': globalThis.jwt, + }; + `); +}; + +module.exports = addJwtShimToContext; diff --git a/packages/bruno-js/src/sandbox/quickjs/utils/index.js b/packages/bruno-js/src/sandbox/quickjs/utils/index.js index 47be92f5f..336a1d7ae 100644 --- a/packages/bruno-js/src/sandbox/quickjs/utils/index.js +++ b/packages/bruno-js/src/sandbox/quickjs/utils/index.js @@ -30,6 +30,54 @@ const marshallToVm = (value, vm) => { } }; +/** + * Invokes a QuickJS function handle. + * - Returns a Promise + * + * @param {Object} vm - QuickJS VM instance + * @param {QuickJSHandle} quickFn - A QuickJS function handle + * @param {Array} args - Arguments to pass to the function + * @returns {Promise} - The result as a Promise + */ +async function invokeFunction(vm, quickFn, args = []) { + if (vm.typeof(quickFn) !== 'function') { + throw new TypeError('Target is not a QuickJS function'); + } + + const result = vm.callFunction(quickFn, vm.global, ...args); + + if (result.error) { + const error = vm.dump(result.error); + result.error.dispose(); + throw error; + } + + // Check if the result is a QuickJS Promise handle (async functions) + if (vm.typeof(result.value) === 'object' && result.value.constructor && vm.typeof(result.value.constructor) === 'function') { + try { + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + promiseHandle.dispose(); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const value = vm.dump(resolvedHandle); + resolvedHandle.dispose(); + return Promise.resolve(value); + } catch (promiseError) { + // If it's not a valid Promise, throw an error + result.value.dispose(); + throw new Error(`Invalid Promise handle: ${promiseError.message}`); + } + } + + const value = vm.dump(result.value); + result.value.dispose(); + + return (value && typeof value.then === 'function') + ? value + : Promise.resolve(value); +} + module.exports = { - marshallToVm + marshallToVm, + invokeFunction }; diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/bruno.json b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/bruno.json new file mode 100644 index 000000000..f9b60c258 --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "jsonwebtoken", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/decode/decode.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/decode/decode.bru new file mode 100644 index 000000000..ca4158c38 --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/decode/decode.bru @@ -0,0 +1,66 @@ +meta { + name: decode + type: http + seq: 1 +} + +post { + url: {{host}}/api/echo + body: none + auth: inherit +} + +script:pre-request { + const jwt = require('jsonwebtoken'); + + const testPayload = { + userId: 456, + username: 'decodeuser', + role: 'user', + iat: Math.floor(Date.now() / 1000) + }; + + const secret = bru.getEnvVar('secret') || 'test-secret-key'; + const testToken = jwt.sign(testPayload, secret, { algorithm: 'HS256', expiresIn: '1h' }); + + try { + console.log('Testing JWT decoding...'); + console.log('Test token:', testToken); + + const decoded = jwt.decode(testToken); + + bru.setEnvVar('decoded_payload', JSON.stringify(decoded)); + + } catch (error) { + console.error('JWT decoding failed:', error.message); + throw error; + } +} + +tests { + test("Decoded payload should exist", function() { + const decodedPayload = bru.getEnvVar('decoded_payload'); + expect(decodedPayload).to.exist; + }); + + test("Decoded payload should contain correct user data", function() { + const decodedPayload = JSON.parse(bru.getEnvVar('decoded_payload')); + + expect(decodedPayload.userId).to.equal(456); + expect(decodedPayload.username).to.equal('decodeuser'); + expect(decodedPayload.role).to.equal('user'); + }); + + test("Decoded payload should have timestamp fields", function() { + const decodedPayload = JSON.parse(bru.getEnvVar('decoded_payload')); + + expect(decodedPayload.iat).to.exist; + expect(decodedPayload.exp).to.exist; + expect(typeof decodedPayload.iat).to.equal('number'); + expect(typeof decodedPayload.exp).to.equal('number'); + }); +} + +settings { + encodeUrl: true +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/decode/folder.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/decode/folder.bru new file mode 100644 index 000000000..eb523890b --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/decode/folder.bru @@ -0,0 +1,8 @@ +meta { + name: decode + seq: 3 +} + +auth { + mode: inherit +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/environments/Prod.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/environments/Prod.bru new file mode 100644 index 000000000..39afc13b9 --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/environments/Prod.bru @@ -0,0 +1,4 @@ +vars { + host: http://httpfaker.org + secret: my-secret-key +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/folder.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/folder.bru new file mode 100644 index 000000000..06a56a1f5 --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/folder.bru @@ -0,0 +1,8 @@ +meta { + name: sign + seq: 1 +} + +auth { + mode: inherit +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign with callback err.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign with callback err.bru new file mode 100644 index 000000000..3dc6fe589 --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign with callback err.bru @@ -0,0 +1,74 @@ +meta { + name: sign with callback err + type: http + seq: 2 +} + +post { + url: {{host}}/api/echo + body: none + auth: inherit +} + +tests { + const jwt = require('jsonwebtoken'); + + const HS_SECRET = 'supersecret'; + + /** + * Helper that calls jwt.sign **with a callback** and resolves/rejects + * based on the callback's (err, token) — so tests can `await` it. + */ + function signViaCallback(payload, secret, options = {}) { + return new Promise((resolve, reject) => { + jwt.sign(payload, secret, options, (err, token) => { + if (err) return reject(err); + resolve(token); + }); + }); + } + + /* ============================================================ + ERROR TESTS — jwt.sign should call callback with `err` + ============================================================ */ + + test('ERROR (callback) — missing secret for HS256', async function () { + try { + await signViaCallback({ sub: 'no_secret' }, undefined, { algorithm: 'HS256' }); + throw new Error('Expected jwt.sign to error via callback'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect(String(err.message)).to.match(/secret|private key must have a value/i); + } + }); + + test('ERROR (callback) — invalid expiresIn format', async function () { + try { + await signViaCallback({ sub: 'bad_exp' }, HS_SECRET, { expiresIn: 'not-a-time' }); + throw new Error('Expected jwt.sign to error via callback'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect(String(err.message)).to.match(/expiresIn/i); + } + }); + + test('ERROR (callback) — unsupported/invalid algorithm', async function () { + try { + await signViaCallback({ sub: 'bad_alg' }, HS_SECRET, { algorithm: 'FOO256' }); + throw new Error('Expected jwt.sign to error via callback'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect(String(err.message)).to.match(/algorithm/i); + } + }); + + test('CONTROL (callback) — succeeds when options are valid', async function () { + const token = await jwt.sign({ sub: 'ok' }, HS_SECRET, { algorithm: 'HS256', expiresIn: '10m' }); + expect(token).to.be.a('string'); + }); + +} + +settings { + encodeUrl: true +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign with callback token.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign with callback token.bru new file mode 100644 index 000000000..f577c8a4f --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign with callback token.bru @@ -0,0 +1,160 @@ +meta { + name: sign with callback token + type: http + seq: 3 +} + +post { + url: {{host}}/api/echo + body: none + auth: inherit +} + +tests { + const jwt = require('jsonwebtoken'); + const HS_SECRET = 'supersecret'; + + const payload = { sub: 'user123' }; + + function once(fn) { + let called = false; + return (...args) => { + if (!called) { + called = true; + fn(...args); + } + }; + } + + function signAsync(payload, secret, options = {}) { + return new Promise((resolve, reject) => { + jwt.sign(payload, secret, options, (err, token) => { + if (err) reject(err); + else resolve(token); + }); + }); + } + + // ------------------------------------------------------------ + // 1. Named Normal Callback + // ------------------------------------------------------------ + test('sign — named normal callback', function () { + function signCallback(err, token) { + expect(err).to.be.null; + expect(token).to.be.a('string'); + + // Verify token to ensure correctness + const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }); + expect(decoded.sub).to.equal('user123'); + + console.log('Named callback signed token:', token); + } + + jwt.sign(payload, HS_SECRET, { algorithm: 'HS256', expiresIn: '15m' }, signCallback); + }); + + // ------------------------------------------------------------ + // 2. Anonymous Callback + // ------------------------------------------------------------ + test('sign — anonymous callback', function () { + jwt.sign(payload, HS_SECRET, { algorithm: 'HS256' }, function (err, token) { + expect(err).to.be.null; + expect(token).to.be.a('string'); + + const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }); + expect(decoded.sub).to.equal('user123'); + + console.log('Anonymous callback signed token:', token); + }); + }); + + // ------------------------------------------------------------ + // 3. Arrow Function Callback + // ------------------------------------------------------------ + test('sign — arrow function callback', function () { + jwt.sign(payload, HS_SECRET, { algorithm: 'HS256' }, (err, token) => { + expect(err).to.be.null; + expect(token).to.be.a('string'); + + const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }); + expect(decoded.sub).to.equal('user123'); + + console.log('Arrow callback signed token:', token); + }); + }); + + // ------------------------------------------------------------ + // 4. Bound Method Callback + // ------------------------------------------------------------ + test('sign — bound method callback', function () { + const signer = { + prefix: '[SIGN]', + done(err, token) { + expect(err).to.be.null; + expect(token).to.be.a('string'); + + const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }); + expect(decoded.sub).to.equal('user123'); + + console.log(this.prefix, 'Bound callback signed token:', token); + }, + }; + + jwt.sign(payload, HS_SECRET, { algorithm: 'HS256' }, signer.done.bind(signer)); + }); + + // ------------------------------------------------------------ + // 5. Higher-Order Callback + // ------------------------------------------------------------ + function makeSignCallback(label) { + return (err, token) => { + expect(err).to.be.null; + expect(token).to.be.a('string'); + + const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }); + expect(decoded.sub).to.equal('user123'); + + console.log(label, 'Higher-order callback signed token:', token); + }; + } + + test('sign — higher-order callback', function () { + const cb = makeSignCallback('[CUSTOM LABEL]'); + jwt.sign(payload, HS_SECRET, { algorithm: 'HS256' }, cb); + }); + + // ------------------------------------------------------------ + // 6. Once-Wrapped Callback + // ------------------------------------------------------------ + test('sign — once-wrapped callback', function () { + const cb = once((err, token) => { + expect(err).to.be.null; + expect(token).to.be.a('string'); + + const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }); + expect(decoded.sub).to.equal('user123'); + + console.log('Once callback executed and signed token:', token); + }); + + jwt.sign(payload, HS_SECRET, { algorithm: 'HS256' }, cb); + }); + + // ------------------------------------------------------------ + // 7. Promise / Async-Await + // ------------------------------------------------------------ + test('sign — promise wrapper with async/await', async function () { + const token = await signAsync(payload, HS_SECRET, { algorithm: 'HS256', expiresIn: '15m' }); + expect(token).to.be.a('string'); + + const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }); + expect(decoded.sub).to.equal('user123'); + + console.log('Promise/async signed token:', token); + }); + +} + +settings { + encodeUrl: true +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign.bru new file mode 100644 index 000000000..dc24b5987 --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/sign/sign.bru @@ -0,0 +1,109 @@ +meta { + name: sign + type: http + seq: 1 +} + +post { + url: {{host}}/api/echo + body: none + auth: inherit +} + +script:pre-request { + const jwt = require('jsonwebtoken'); + + const payload = { + userId: 123, + username: 'testuser', + role: 'admin', + iat: Math.floor(Date.now() / 1000) + }; + + const secret = bru.getEnvVar('secret'); + + const options = { + algorithm: 'HS256', + expiresIn: '1h' + }; + + try { + console.log('Testing JWT encoding...'); + const token = jwt.sign(payload, secret, options); + + console.log('JWT Token encoded successfully:', token); + + bru.setEnvVar('jwt_token', token); + + bru.setEnvVar('original_payload', JSON.stringify(payload)); + + console.log('JWT encoding test passed!'); + + } catch (error) { + console.error('JWT encoding failed:', error.message); + throw error; + } +} + +tests { + const atob = require('atob') + + test("JWT token should be generated", function() { + const jwtToken = bru.getEnvVar('jwt_token'); + expect(jwtToken).to.exist; + }); + + test("JWT token should be a string", function() { + const jwtToken = bru.getEnvVar('jwt_token'); + expect(typeof jwtToken).to.equal('string'); + }); + + test("JWT token should have 3 parts (header.payload.signature)", function() { + const jwtToken = bru.getEnvVar('jwt_token'); + const parts = jwtToken.split('.'); + expect(parts.length).to.equal(3); + }); + + test("JWT token should be valid base64", function() { + const jwtToken = bru.getEnvVar('jwt_token'); + const parts = jwtToken.split('.'); + + // Test that each part is valid base64 + parts.forEach((part, index) => { + try { + atob(part); + } catch (e) { + throw new Error(`JWT part ${index + 1} is not valid base64`); + } + }); + }); + + test("JWT token should contain expected payload data", function() { + const jwtToken = bru.getEnvVar('jwt_token'); + const originalPayload = JSON.parse(bru.getEnvVar('original_payload')); + + // Decode the payload part (second part of JWT) + const parts = jwtToken.split('.'); + const payloadPart = parts[1]; + const decodedPayload = JSON.parse(atob(payloadPart)); + console.log(decodedPayload) + + expect(decodedPayload.userId).to.equal(originalPayload.userId); + expect(decodedPayload.username).to.equal(originalPayload.username); + expect(decodedPayload.role).to.equal(originalPayload.role); + }); + + test("JWT token should have proper header", function() { + const jwtToken = bru.getEnvVar('jwt_token'); + const parts = jwtToken.split('.'); + const headerPart = parts[0]; + const header = JSON.parse(atob(headerPart)); + + expect(header.alg).to.equal('HS256'); + expect(header.typ).to.equal('JWT'); + }); +} + +settings { + encodeUrl: true +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/folder.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/folder.bru new file mode 100644 index 000000000..c90a3bac2 --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/folder.bru @@ -0,0 +1,8 @@ +meta { + name: verify + seq: 2 +} + +auth { + mode: inherit +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify with callback err.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify with callback err.bru new file mode 100644 index 000000000..80676feba --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify with callback err.bru @@ -0,0 +1,85 @@ +meta { + name: verify with callback err + type: http + seq: 2 +} + +post { + url: {{host}}/api/echo + body: none + auth: inherit +} + +tests { + const jwt = require('jsonwebtoken'); + + const HS_SECRET = 'supersecret'; + + function verifyViaCallback(token, secret, options = {}) { + return new Promise((resolve, reject) => { + jwt.verify(token, secret, options, (err, decoded) => { + if (err) return reject(err); + resolve(decoded); + }); + }); + } + + function createValidToken(payload = { sub: 'user123' }, secret = HS_SECRET) { + return jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn: '1h' }); + } + + /* ============================================================ + ERROR TESTS — jwt.verify should call callback with `err` + ============================================================ */ + + test('ERROR (callback) — malformed token', async function () { + const malformedToken = 'abc.def'; // not a valid JWT + try { + await verifyViaCallback(malformedToken, HS_SECRET, { algorithms: ['HS256'] }); + throw new Error('Expected jwt.verify to error via callback'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect(String(err.message)).to.match(/jwt malformed|invalid token/i); + } + }); + + test('ERROR (callback) — invalid signature (wrong secret)', async function () { + const token = createValidToken(); // signed with HS_SECRET + try { + await verifyViaCallback(token, 'wrong_secret', { algorithms: ['HS256'] }); + throw new Error('Expected jwt.verify to error via callback'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect(String(err.message)).to.match(/invalid signature/i); + } + }); + + test('ERROR (callback) — invalid algorithm', async function () { + const token = createValidToken(); + try { + // Pass unsupported algorithm intentionally + await verifyViaCallback(token, HS_SECRET, { algorithms: ['RS256'] }); + throw new Error('Expected jwt.verify to error due to invalid algorithm'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect(String(err.message)).to.match(/invalid algorithm/i); + } + }); + + test('ERROR (callback) — missing secret', async function () { + const token = createValidToken(); + try { + await verifyViaCallback(token, undefined, { algorithms: ['HS256'] }); + throw new Error('Expected jwt.verify to error due to missing secret'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect(String(err.message)).to.match(/secret|key must be provided/i); + } + }); + + +} + +settings { + encodeUrl: true +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify with callback token.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify with callback token.bru new file mode 100644 index 000000000..8f0f69a21 --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify with callback token.bru @@ -0,0 +1,114 @@ +meta { + name: verify with callback token + type: http + seq: 3 +} + +post { + url: {{host}}/api/echo + body: none + auth: inherit +} + +tests { + const jwt = require('jsonwebtoken'); + + const HS_SECRET = 'supersecret'; + + const token = jwt.sign({ sub: 'user123' }, HS_SECRET, { + algorithm: 'HS256', + expiresIn: '15m', + }); + + function once(fn) { + let called = false; + return (...args) => { + if (!called) { + called = true; + fn(...args); + } + }; + } + + function verifyAsync(token, secret, options = {}) { + return new Promise((resolve, reject) => { + jwt.verify(token, secret, options, (err, decoded) => { + if (err) reject(err); + else resolve(decoded); + }); + }); + } + + test('verify — named normal callback', function () { + function verifyCallback(err, decoded) { + expect(err).to.be.null; + expect(decoded.sub).to.equal('user123'); + console.log('Named callback verified user:', decoded.sub); + } + + jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, verifyCallback); + }); + + test('verify — anonymous callback', function () { + jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, function (err, decoded) { + expect(err).to.be.null; + expect(decoded.sub).to.equal('user123'); + console.log('Anonymous callback verified user:', decoded.sub); + }); + }); + + test('verify — arrow function callback', function () { + jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, (err, decoded) => { + expect(err).to.be.null; + expect(decoded.sub).to.equal('user123'); + console.log('Arrow callback verified user:', decoded.sub); + }); + }); + + test('verify — bound method callback', function () { + const handler = { + prefix: '[VERIFY]', + done(err, decoded) { + expect(err).to.be.null; + expect(decoded.sub).to.equal('user123'); + console.log(this.prefix, 'Bound callback verified user:', decoded.sub); + }, + }; + + jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, handler.done.bind(handler)); + }); + + function makeVerifyCallback(label) { + return (err, decoded) => { + expect(err).to.be.null; + expect(decoded.sub).to.equal('user123'); + console.log(label, 'Higher-order callback verified user:', decoded.sub); + }; + } + + test('verify — higher-order callback', function () { + const cb = makeVerifyCallback('[CUSTOM LABEL]'); + jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, cb); + }); + + test('verify — once-wrapped callback', function () { + const cb = once((err, decoded) => { + expect(err).to.be.null; + expect(decoded.sub).to.equal('user123'); + console.log('Once callback executed and verified user:', decoded.sub); + }); + + jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, cb); + }); + + test('verify — promise wrapper with async/await', async function () { + const decoded = await verifyAsync(token, HS_SECRET, { algorithms: ['HS256'] }); + expect(decoded.sub).to.equal('user123'); + console.log('Promise/async verified user:', decoded.sub); + }); + +} + +settings { + encodeUrl: true +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify.bru b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify.bru new file mode 100644 index 000000000..631736461 --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection/verify/verify.bru @@ -0,0 +1,104 @@ +meta { + name: verify + type: http + seq: 1 +} + +post { + url: {{host}}/api/echo + body: none + auth: inherit +} + +script:pre-request { + const jwt = require('jsonwebtoken'); + + const validPayload = { + userId: 789, + username: 'verifyuser', + role: 'admin', + iat: Math.floor(Date.now() / 1000) + }; + + const secret = bru.getEnvVar('secret') || 'test-secret-key'; + const wrongSecret = 'wrong-secret-key'; + + const validToken = jwt.sign(validPayload, secret, { algorithm: 'HS256', expiresIn: '1h' }); + const invalidToken = jwt.sign(validPayload, wrongSecret, { algorithm: 'HS256', expiresIn: '1h' }); + + + bru.setEnvVar('valid_token', validToken); + bru.setEnvVar('invalid_token', invalidToken); + + try { + console.log('Testing JWT verification...'); + console.log('Valid token:', validToken); + + const verified = jwt.verify(validToken, secret); + + const verifiedWithOptions = jwt.verify(validToken, secret, { + algorithms: ['HS256'], + ignoreExpiration: false + }); + if (!verifiedWithOptions) { + throw new Error('Verification with options should work'); + } + + console.log('JWT verification test passed!'); + + bru.setEnvVar('verified_payload', JSON.stringify(verified)); + + } catch (error) { + console.error('JWT verification failed:', error.message); + throw error; + } +} + +tests { + test("Verified payload should exist", function() { + const verifiedPayload = bru.getEnvVar('verified_payload'); + expect(verifiedPayload).to.exist; + }); + + test("Verified payload should be valid JSON", function() { + const verifiedPayload = bru.getEnvVar('verified_payload'); + const parsed = JSON.parse(verifiedPayload); + expect(typeof parsed).to.equal('object'); + }); + + test("Verified payload should contain correct user data", function() { + const verifiedPayload = JSON.parse(bru.getEnvVar('verified_payload')); + + expect(verifiedPayload.userId).to.equal(789); + expect(verifiedPayload.username).to.equal('verifyuser'); + expect(verifiedPayload.role).to.equal('admin'); + }); + + test("Verified payload should have timestamp fields", function() { + const verifiedPayload = JSON.parse(bru.getEnvVar('verified_payload')); + + expect(verifiedPayload.iat).to.exist; + expect(verifiedPayload.exp).to.exist; + expect(typeof verifiedPayload.iat).to.equal('number'); + expect(typeof verifiedPayload.exp).to.equal('number'); + }); + + test("Invalid token with wrong secret should throw error", function() { + const jwt = require('jsonwebtoken'); + const invalidToken = bru.getEnvVar('invalid_token'); + const secret = bru.getEnvVar('secret') || 'test-secret-key'; + + try { + jwt.verify(invalidToken, secret); + expect.fail('Expected JWT verification to throw an error for invalid token'); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.equal('invalid signature'); + console.log('Invalid token correctly threw error:', error.message); + } + }); +} + +settings { + encodeUrl: true +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/init-user-data/preferences.json b/tests/scripting/inbuilt-libraries/jsonwebtoken/init-user-data/preferences.json new file mode 100644 index 000000000..76d380e0a --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/init-user-data/preferences.json @@ -0,0 +1,16 @@ +{ + "maximized": true, + "lastOpenedCollections": ["{{projectRoot}}/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection"], + "preferences": { + "request": { + "sslVerification": true, + "customCaCertificate": { + "enabled": false, + "filePath": "" + }, + "keepDefaultCaCertificates": { + "enabled": true + } + } + } +} \ No newline at end of file diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/init-user-data/ui-state-snapshot.json b/tests/scripting/inbuilt-libraries/jsonwebtoken/init-user-data/ui-state-snapshot.json new file mode 100644 index 000000000..7890a6dc8 --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/init-user-data/ui-state-snapshot.json @@ -0,0 +1,8 @@ +{ + "collections": [ + { + "pathname": "{{projectRoot}}/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection", + "selectedEnvironment": "Prod" + } + ] +} diff --git a/tests/scripting/inbuilt-libraries/jsonwebtoken/jsonwebtoken.spec.ts b/tests/scripting/inbuilt-libraries/jsonwebtoken/jsonwebtoken.spec.ts new file mode 100644 index 000000000..74bb23c6d --- /dev/null +++ b/tests/scripting/inbuilt-libraries/jsonwebtoken/jsonwebtoken.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '../../../../playwright'; + +test.describe.serial('jwt collection success', () => { + test('developer mode', async ({ pageWithUserData: page }) => { + // init dev mode + await page.getByTitle('jsonwebtoken').click(); + await page.getByLabel('Developer Mode(use only if').check(); + await page.getByRole('button', { name: 'Save' }).click(); + + test.setTimeout(2 * 60 * 1000); + + // Run the collection + await page.locator('.collection-actions').hover(); + await page.locator('.collection-actions .icon').click(); + await page.getByText('Run', { exact: true }).click(); + await page.getByRole('button', { name: 'Run Collection' }).click(); + await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 }); + + // Parse and validate test results + const result = await page.getByText('Total Requests: ').innerText(); + const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/); + if (!matches) { + throw new Error('Could not parse test results'); + } + const [totalRequests, passed, failed, skipped] = matches.slice(1); + + await expect(parseInt(totalRequests)).toBe(7); + await expect(parseInt(passed)).toBe(7); + await expect(parseInt(failed)).toBe(0); + await expect(parseInt(skipped)).toBe(0); + await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed)); + }); + + test('safe mode', async ({ pageWithUserData: page }) => { + // init safe mode + await page.getByTitle('jsonwebtoken').click(); + await page.getByText('Developer Mode').click(); + await page.getByLabel('Safe Mode').check(); + await page.getByRole('button', { name: 'Save' }).click(); + + test.setTimeout(2 * 60 * 1000); + + // Run the collection + await page.locator('.collection-actions').hover(); + await page.locator('.collection-actions .icon').click(); + await page.getByText('Run', { exact: true }).click(); + await page.getByRole('button', { name: 'Run Collection' }).click(); + await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 }); + + // Parse and validate test results + const result = await page.getByText('Total Requests: ').innerText(); + const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/); + if (!matches) { + throw new Error('Could not parse test results'); + } + const [totalRequests, passed, failed, skipped] = matches.slice(1); + + await expect(parseInt(totalRequests)).toBe(7); + await expect(parseInt(passed)).toBe(7); + await expect(parseInt(failed)).toBe(0); + await expect(parseInt(skipped)).toBe(0); + await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed)); + }); +});