Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nockBack): substitutions option #2556

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Expand Up @@ -1497,6 +1497,18 @@ To set the mode call `nockBack.setMode(mode)` or run the tests with the `NOCK_BA

- lockdown: use recorded nocks, disables all http calls even when not nocked, doesn't record

### Hiding Secrets from NockBack

Nockback will happily record secrets that appear in URL parameters or HTTP request bodies. To prevent this you can use simple substitution to dynamically replace these values whenever the fixture is written or read from disk. To do this, use the `substitutions` options on Nock.

```js
context { nockDone } = nockBack('exampleFixture.json', { substitutions: { A_SECRET: "the-value-of-your-secret" }})
```

In the recorded cassette where ever `the-value-of-your-secret` would appear `{{ A_SECRET }}` will appear instead.

This feature should only be used for strings that are unlikely to occur naturally - as this works with simple string substitution.

## Common issues

**"No match for response" when using got with error responses**
Expand Down
45 changes: 32 additions & 13 deletions lib/back.js
Expand Up @@ -27,21 +27,22 @@ try {
/**
* nock the current function with the fixture given
*
* @param {string} fixtureName - the name of the fixture, e.x. 'foo.json'
* @param {object} options - [optional] extra options for nock with, e.x. `{ assert: true }`
* @param {function} nockedFn - [optional] callback function to be executed with the given fixture being loaded;
* if defined the function will be called with context `{ scopes: loaded_nocks || [] }`
* set as `this` and `nockDone` callback function as first and only parameter;
* if not defined a promise resolving to `{nockDone, context}` where `context` is
* aforementioned `{ scopes: loaded_nocks || [] }`
* @param {string} fixtureName - the name of the fixture, e.x. 'foo.json'
* @param {object} options - [optional] extra options for nock with, e.x. `{ assert: true }`
* @param {function} nockedFn - [optional] callback function to be executed with the given fixture being loaded;
* if defined the function will be called with context `{ scopes: loaded_nocks || [] }`
* set as `this` and `nockDone` callback function as first and only parameter;
* if not defined a promise resolving to `{nockDone, context}` where `context` is
* aforementioned `{ scopes: loaded_nocks || [] }`
*
* List of options:
*
* @param {function} before - a preprocessing function, gets called before nock.define
* @param {function} after - a postprocessing function, gets called after nock.define
* @param {function} afterRecord - a postprocessing function, gets called after recording. Is passed the array
* of scopes recorded and should return the array scopes to save to the fixture
* @param {function} recorder - custom options to pass to the recorder
* @param {object} substitutions - a list of key/value pairs to use as substitutions in a templated fixture
* @param {function} before - a preprocessing function, gets called before nock.define
* @param {function} after - a postprocessing function, gets called after nock.define
* @param {function} afterRecord - a postprocessing function, gets called after recording. Is passed the array
* of scopes recorded and should return the array scopes to save to the fixture
* @param {function} recorder - custom options to pass to the recorder
*
*/
function Back(fixtureName, options, nockedFn) {
Expand Down Expand Up @@ -167,6 +168,15 @@ const record = {

outputs =
typeof outputs === 'string' ? outputs : JSON.stringify(outputs, null, 4)

if (options.substitutions) {
Object.entries(options.substitutions).forEach(pair => {
const [key, value] = pair
debug(`substituting ${value} with ${key}`)
outputs = outputs.replace(new RegExp(value, 'g'), `{{ ${key} }}`)
})
}

debug('recorder outputs:', outputs)

fs.mkdirSync(path.dirname(fixture), { recursive: true })
Expand Down Expand Up @@ -209,6 +219,15 @@ const update = {

outputs =
typeof outputs === 'string' ? outputs : JSON.stringify(outputs, null, 4)

if (options.substitutions) {
Object.entries(options.substitutions).forEach(pair => {
const [key, value] = pair
debug(`substituting ${value} with ${key}`)
outputs = outputs.replace(new RegExp(value, 'g'), `{{ ${key} }}`)
})
}

debug('recorder outputs:', outputs)

fs.mkdirSync(path.dirname(fixture), { recursive: true })
Expand Down Expand Up @@ -243,7 +262,7 @@ function load(fixture, options) {
}

if (fixture && fixtureExists(fixture)) {
let scopes = loadDefs(fixture)
let scopes = loadDefs(fixture, options.substitutions)
applyHook(scopes, options.before)

scopes = define(scopes)
Expand Down
10 changes: 6 additions & 4 deletions lib/scope.js
Expand Up @@ -10,6 +10,7 @@ const url = require('url')
const debug = require('debug')('nock.scope')
const { EventEmitter } = require('events')
const Interceptor = require('./interceptor')
const Mustache = require('mustache')

const { URL, Url: LegacyUrl } = url
let fs
Expand Down Expand Up @@ -288,17 +289,18 @@ class Scope extends EventEmitter {
}
}

function loadDefs(path) {
function loadDefs(path, view = {}) {
if (!fs) {
throw new Error('No fs')
}

const contents = fs.readFileSync(path)
return JSON.parse(contents)
const rendered = Mustache.render(contents.toString(), view)
return JSON.parse(rendered)
}

function load(path) {
return define(loadDefs(path))
function load(path, view = {}) {
return define(loadDefs(path, view))
}

function getStatusFromDefinition(nockDef) {
Expand Down
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Expand Up @@ -24,6 +24,7 @@
"dependencies": {
"debug": "^4.1.0",
"json-stringify-safe": "^5.0.1",
"mustache": "^4.2.0",
"propagate": "^2.0.0"
},
"devDependencies": {
Expand Down
21 changes: 21 additions & 0 deletions tests/fixtures/test-template-substitution-fixture.json
@@ -0,0 +1,21 @@
[
{
"scope": "http://example.test:80",
"method": "GET",
"path": "/?secret={{ SECRET }}",
"body": "",
"status": 217,
"response": "server served a response",
"rawHeaders": [
"Date",
"Fri, 17 Nov 2023 03:19:12 GMT",
"Connection",
"keep-alive",
"Keep-Alive",
"timeout=5",
"Transfer-Encoding",
"chunked"
],
"responseIsBinary": false
}
]
95 changes: 94 additions & 1 deletion tests/test_back.js
Expand Up @@ -115,6 +115,26 @@ describe('Nock Back', () => {
})
})

it('should allow template substitutions in recorded fixtures', done => {
const mySecretApiKey = 'blah-blah'

nockBack(
'test-template-substitution-fixture.json',
{
substitutions: { SECRET: mySecretApiKey },
after: scope => {
expect(scope.interceptors[0].uri).to.eql('/?secret=blah-blah')
},
},
function (nockDone) {
http.get('http://example.test/?secret=blah-blah', () => {
nockDone()
done()
})
},
)
})

it('should throw an exception when a hook is not a function', () => {
expect(() =>
nockBack('good_request.json', { before: 'not-a-function-innit' }),
Expand Down Expand Up @@ -271,7 +291,6 @@ describe('Nock Back', () => {
},
response => {
nockDone()

expect(response.statusCode).to.equal(217)
expect(fs.existsSync(fixtureLoc)).to.be.true()
done()
Expand All @@ -284,6 +303,44 @@ describe('Nock Back', () => {
})
})

it('should record template keys into fixtures rather than secrets', done => {
expect(fs.existsSync(fixtureLoc)).to.be.false()

const mySecretApiKey = 'sooper-secret'

nockBack(
fixture,
{
substitutions: { SECRET: mySecretApiKey },
},
function (nockDone) {
startHttpServer(requestListener).then(server => {
const request = http.request(
{
host: 'localhost',
path: '/?secret=sooper-secret',
port: server.address().port,
},
response => {
response.once('end', () => {
nockDone()
const fixtureContent = fs.readFileSync(fixtureLoc, 'utf8')
expect(response.statusCode).to.equal(217)
expect(fixtureContent).to.contain('{{ SECRET }}')
done()
})

response.resume()
},
)

request.on('error', () => expect.fail())
request.end()
})
},
)
})

it('should record the expected data', done => {
nockBack(fixture, nockDone => {
startHttpServer(requestListener).then(server => {
Expand Down Expand Up @@ -530,6 +587,42 @@ describe('Nock Back', () => {
})
})

it('should record template keys into fixtures rather than secrets', done => {
const mySecretApiKey = 'sooper-secret'

nockBack(
fixture,
{
substitutions: { SECRET: mySecretApiKey },
},
function (nockDone) {
startHttpServer(requestListener).then(server => {
const request = http.request(
{
host: 'localhost',
path: '/?secret=sooper-secret',
port: server.address().port,
},
response => {
response.once('end', () => {
nockDone()
const fixtureContent = fs.readFileSync(fixtureLoc, 'utf8')
expect(response.statusCode).to.equal(217)
expect(fixtureContent).to.contain('{{ SECRET }}')
done()
})

response.resume()
},
)

request.on('error', () => expect.fail())
request.end()
})
},
)
})

it('should record the expected data', done => {
nockBack(fixture, nockDone => {
startHttpServer(requestListener).then(server => {
Expand Down
1 change: 1 addition & 0 deletions types/index.d.ts
Expand Up @@ -274,6 +274,7 @@ declare namespace nock {
}

interface BackOptions {
substitutions?: { [key: string]: string }
before?: (def: Definition) => void
after?: (scope: Scope) => void
afterRecord?: (defs: Definition[]) => Definition[]
Expand Down