// internalAuth middleware (Session 10) — tests both the new short-form // header (x-internal-key) and the legacy long form // (X-VYNDR-Internal-Key), the timing-safe compare, and the optional // loopback restriction. const { requireInternalAuth, __internals } = require('../../src/middleware/internalAuth'); function fakeReqRes({ headers = {}, ip = '127.0.0.1' } = {}) { const req = { headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])), ip, socket: { remoteAddress: ip }, get(name) { return this.headers[name.toLowerCase()]; }, }; const res = { statusCode: null, body: null, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, }; return { req, res }; } function run(mw, req, res) { return new Promise((resolve) => { mw(req, res, () => resolve('next')); if (res.statusCode != null) resolve('blocked'); }); } beforeEach(() => { delete process.env.VYNDR_INTERNAL_KEY; }); describe('requireInternalAuth', () => { describe('configuration guard', () => { test('returns 503 when VYNDR_INTERNAL_KEY env var is unset', async () => { const mw = requireInternalAuth(); const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'anything' } }); const out = await run(mw, req, res); expect(out).toBe('blocked'); expect(res.statusCode).toBe(503); expect(res.body.error).toMatch(/not configured/); }); }); describe('header compatibility', () => { beforeEach(() => { process.env.VYNDR_INTERNAL_KEY = 'real-key-12345'; }); test('accepts the new short-form header (x-internal-key)', async () => { const mw = requireInternalAuth(); const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' } }); const out = await run(mw, req, res); expect(out).toBe('next'); }); test('accepts the legacy long-form header (X-VYNDR-Internal-Key)', async () => { const mw = requireInternalAuth(); const { req, res } = fakeReqRes({ headers: { 'x-vyndr-internal-key': 'real-key-12345' } }); const out = await run(mw, req, res); expect(out).toBe('next'); }); test('returns 401 on missing header', async () => { const mw = requireInternalAuth(); const { req, res } = fakeReqRes({}); const out = await run(mw, req, res); expect(out).toBe('blocked'); expect(res.statusCode).toBe(401); }); test('returns 401 on wrong key (timing-safe)', async () => { const mw = requireInternalAuth(); const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'wrong-key' } }); const out = await run(mw, req, res); expect(out).toBe('blocked'); expect(res.statusCode).toBe(401); }); test('returns 401 even when prefix matches but suffix differs', async () => { // Guards against early-exit string compare leaking timing info. const mw = requireInternalAuth(); const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-1234X' } }); const out = await run(mw, req, res); expect(out).toBe('blocked'); expect(res.statusCode).toBe(401); }); test('returns 401 on length mismatch (avoids any timingSafeEqual throw)', async () => { const mw = requireInternalAuth(); const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'short' } }); const out = await run(mw, req, res); expect(out).toBe('blocked'); expect(res.statusCode).toBe(401); }); }); describe('loopbackOnly option', () => { beforeEach(() => { process.env.VYNDR_INTERNAL_KEY = 'real-key-12345'; }); test('passes from 127.0.0.1', async () => { const mw = requireInternalAuth({ loopbackOnly: true }); const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '127.0.0.1' }); const out = await run(mw, req, res); expect(out).toBe('next'); }); test('passes from IPv6 loopback', async () => { const mw = requireInternalAuth({ loopbackOnly: true }); const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '::1' }); const out = await run(mw, req, res); expect(out).toBe('next'); }); test('passes from IPv4-mapped IPv6 loopback', async () => { const mw = requireInternalAuth({ loopbackOnly: true }); const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '::ffff:127.0.0.1' }); const out = await run(mw, req, res); expect(out).toBe('next'); }); test('returns 403 from off-host IP', async () => { const mw = requireInternalAuth({ loopbackOnly: true }); const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '10.0.0.42' }); const out = await run(mw, req, res); expect(out).toBe('blocked'); expect(res.statusCode).toBe(403); expect(res.body.error).toMatch(/Origin not permitted/); }); test('WITHOUT loopbackOnly, off-host IPs are accepted (n8n case)', async () => { const mw = requireInternalAuth({ loopbackOnly: false }); const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '172.18.0.3' }); const out = await run(mw, req, res); expect(out).toBe('next'); }); }); describe('timingSafeStringEqual', () => { const { timingSafeStringEqual } = __internals; test('equal strings → true', () => { expect(timingSafeStringEqual('abc123', 'abc123')).toBe(true); }); test('different strings same length → false', () => { expect(timingSafeStringEqual('abc123', 'abc124')).toBe(false); }); test('different lengths → false (does not throw)', () => { expect(() => timingSafeStringEqual('a', 'abc')).not.toThrow(); expect(timingSafeStringEqual('a', 'abc')).toBe(false); }); test('non-string inputs → false', () => { expect(timingSafeStringEqual(null, 'a')).toBe(false); expect(timingSafeStringEqual('a', undefined)).toBe(false); expect(timingSafeStringEqual(123, '123')).toBe(false); }); }); });