Skip to content

Commit 64293f6

Browse files
author
Abhishek Garg
committed
fix(vault-secrets): use @adobe/fetch Response to restore .raw() compatibility
The error response returned on Vault failure used the native Node.js `new Response()` which produces native Web API Headers. Those headers lack the `.raw()` method that `helix-universal`'s `aws-adapter.js` calls unconditionally on every response. When the dev Vault egress was blocked (403), every Lambda invocation hit the vault error handler, returned a native Response, and crashed in the adapter with `response.headers.raw is not a function` — surfaced as HTTP 500 on all API endpoints. Fix: import Response from `@adobe/fetch` (already a declared dependency). Its Headers class implements `.raw()`, making it compatible with the helix-universal adapter. Added regression tests that assert `.raw()` is callable on the 502 error response, covering bootstrap failure, Vault 403, and secret read failure scenarios. Made-with: Cursor
1 parent 290a488 commit 64293f6

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

packages/spacecat-shared-vault-secrets/src/vault-secrets-wrapper.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import { Response } from '@adobe/fetch';
14+
1315
import VaultClient from './vault-client.js';
1416
import { loadBootstrapConfig } from './bootstrap.js';
1517

packages/spacecat-shared-vault-secrets/test/vault-secrets-wrapper.test.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,97 @@ describe('vaultSecrets wrapper', () => {
180180
expect(innerFn.called).to.equal(false);
181181
});
182182

183+
it('502 response uses @adobe/fetch Headers — .raw() is available for helix-universal adapter', async () => {
184+
// Regression test for: response.headers.raw is not a function
185+
// helix-universal aws-adapter.js calls response.headers.raw() on every response.
186+
// When vault fails, the wrapper must return a Response with @adobe/fetch Headers
187+
// (which have .raw()), not native Node.js Headers (which do not).
188+
nock(AWS_ENDPOINT)
189+
.post('/')
190+
.replyWithError('connection refused');
191+
192+
const ctx = makeContext();
193+
const wrapped = vaultSecrets(sinon.stub(), { bootstrapPath: BOOTSTRAP_PATH });
194+
195+
const response = await wrapped(new Request('https://example.com'), ctx);
196+
197+
expect(response.status).to.equal(502);
198+
// response.headers.raw must exist — native Node.js Headers lack it and break helix-universal
199+
expect(typeof response.headers.raw).to.equal('function');
200+
const raw = response.headers.raw();
201+
expect(raw).to.be.an('object');
202+
expect(raw['x-error']).to.equal('error fetching secrets.');
203+
});
204+
205+
it('returns 502 when Vault AppRole login returns 403', async () => {
206+
mockBootstrap();
207+
nock(VAULT_ADDR)
208+
.post('/v1/auth/approle/login')
209+
.reply(403, { errors: ['permission denied'] });
210+
211+
const ctx = makeContext();
212+
const innerFn = sinon.stub();
213+
const wrapped = vaultSecrets(innerFn, { bootstrapPath: BOOTSTRAP_PATH });
214+
215+
const response = await wrapped(new Request('https://example.com'), ctx);
216+
217+
expect(response.status).to.equal(502);
218+
expect(response.headers.get('x-error')).to.equal('error fetching secrets.');
219+
expect(innerFn.called).to.equal(false);
220+
expect(ctx.log.error.calledOnce).to.equal(true);
221+
});
222+
223+
it('502 from Vault 403 also has @adobe/fetch Headers with .raw()', async () => {
224+
// This is the exact production failure path:
225+
// Vault NAT/WAF blocks egress → 403 → native Response → helix-universal crashes
226+
mockBootstrap();
227+
nock(VAULT_ADDR)
228+
.post('/v1/auth/approle/login')
229+
.reply(403, { errors: ['permission denied'] });
230+
231+
const ctx = makeContext();
232+
const wrapped = vaultSecrets(sinon.stub(), { bootstrapPath: BOOTSTRAP_PATH });
233+
234+
const response = await wrapped(new Request('https://example.com'), ctx);
235+
236+
expect(response.status).to.equal(502);
237+
expect(typeof response.headers.raw).to.equal('function');
238+
const raw = response.headers.raw();
239+
expect(raw['x-error']).to.equal('error fetching secrets.');
240+
});
241+
242+
it('returns 502 when Vault secret read fails', async () => {
243+
mockBootstrap();
244+
mockAppRoleLogin();
245+
nock(VAULT_ADDR)
246+
.get(`/v1/${MOUNT_POINT}/data/prod/api-service`)
247+
.reply(500, { errors: ['internal server error'] });
248+
249+
const ctx = makeContext();
250+
const innerFn = sinon.stub();
251+
const wrapped = vaultSecrets(innerFn, { bootstrapPath: BOOTSTRAP_PATH });
252+
253+
const response = await wrapped(new Request('https://example.com'), ctx);
254+
255+
expect(response.status).to.equal(502);
256+
expect(response.headers.get('x-error')).to.equal('error fetching secrets.');
257+
expect(innerFn.called).to.equal(false);
258+
});
259+
260+
it('logs the error message when vault fails', async () => {
261+
nock(AWS_ENDPOINT)
262+
.post('/')
263+
.replyWithError('ECONNREFUSED vault unreachable');
264+
265+
const ctx = makeContext();
266+
const wrapped = vaultSecrets(sinon.stub(), { bootstrapPath: BOOTSTRAP_PATH });
267+
268+
await wrapped(new Request('https://example.com'), ctx);
269+
270+
expect(ctx.log.error.calledOnce).to.equal(true);
271+
expect(ctx.log.error.firstCall.args[0]).to.include('Failed to load secrets:');
272+
});
273+
183274
it('returns empty object when VAULT_SECRETS_DISABLED is true', async () => {
184275
process.env.VAULT_SECRETS_DISABLED = 'true';
185276
const ctx = makeContext();

0 commit comments

Comments
 (0)