From c14e8db302d4ac06c27e957d6e9ab4e619a650c5 Mon Sep 17 00:00:00 2001 From: Josep Lopez Date: Tue, 8 Sep 2026 11:05:22 +0200 Subject: [PATCH 1/4] fix(http-utils): drop GET /slack/events from the anonymous bypass (VULN-39365) `ANONYMOUS_ENDPOINTS` is a library-level authentication bypass that every consumer of `authWrapper` inherits. It listed both GET and POST `/slack/events`. `GET /slack/events` is removed. Slack only ever POSTs events and interactive payloads, and a GET carries no body to sign, so that entry could never be backed by a Slack signature check -- it was purely an unauthenticated entry point. `POST /slack/events` stays, deliberately. The Slack signature check in the consuming service (spacecat-api-service's `slackSignatureWrapper`, added for VULN-39365) is mounted outside this wrapper and runs first, and Slack presents no SpaceCat credential, so removing the entry would 401 all legitimate Slack traffic. The security contract is now documented on the constant: an entry here means this library authenticates nothing, so the consumer MUST authenticate the route by other means. Also adds an optional `opts.anonymousEndpoints` override so a service that does not verify Slack request signatures can pass `[]` and opt out of the inherited bypass entirely. Defaults to the existing list, so this is backwards compatible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/auth/auth-wrapper.js | 34 +++++++++++++- .../test/auth/auth-wrapper.test.js | 47 ++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js b/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js index 40f5cdc92..e611e6371 100644 --- a/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js +++ b/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js @@ -16,20 +16,50 @@ import { isObject } from '@adobe/spacecat-shared-utils'; import AuthenticationManager from './authentication-manager.js'; import { checkScopes } from './check-scopes.js'; +/** + * Routes that bypass authentication entirely. + * + * SECURITY CONTRACT (VULN-39365): an entry here means this library performs NO authentication + * for that route, so the consuming service MUST authenticate it by other means. For + * `POST /slack/events` that means verifying the Slack request signature (`X-Slack-Signature` + + * `X-Slack-Request-Timestamp`) before the payload reaches any handler. spacecat-api-service does + * this in `slackSignatureWrapper`, which is mounted OUTSIDE this wrapper so it runs first. + * + * Historically this list also contained `GET /slack/events`. That was removed because Slack only + * ever POSTs events and interactive payloads, and a GET carries no body to sign — so a GET could + * never be signature-verified and existed purely as an unauthenticated entry point. + * + * Do NOT add entries here. A service that needs an unauthenticated route should pass its own + * `anonymousEndpoints` (see below) rather than widening the default for every consumer. + */ const ANONYMOUS_ENDPOINTS = [ - 'GET /slack/events', 'POST /slack/events', ]; +/** + * Wraps a function with authentication. + * + * @param {UniversalFunction} fn - the function to wrap. + * @param {object} [opts] - options. + * @param {Array} [opts.authHandlers] - the authentication handler classes to try, in order. + * @param {string[]} [opts.anonymousEndpoints] - overrides the default set of routes that bypass + * authentication, as `'METHOD /path'` strings. Pass `[]` to disable the bypass entirely. A + * service that does not verify Slack request signatures SHOULD pass `[]`, otherwise it + * inherits an unauthenticated `POST /slack/events` it may not be defending. + * @returns {UniversalFunction} the wrapped function. + */ export function authWrapper(fn, opts = {}) { let authenticationManager; + const anonymousEndpoints = Array.isArray(opts.anonymousEndpoints) + ? opts.anonymousEndpoints + : ANONYMOUS_ENDPOINTS; return async (request, context) => { const { log, pathInfo: { method, suffix } } = context; const route = `${method.toUpperCase()} ${suffix}`; - if (ANONYMOUS_ENDPOINTS.includes(route) + if (anonymousEndpoints.includes(route) || route.startsWith('POST /hooks/site-detection/') || method.toUpperCase() === 'OPTIONS') { return fn(request, context); diff --git a/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js b/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js index 26bc3fdec..802590fbe 100644 --- a/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js @@ -76,14 +76,59 @@ describe('auth wrapper', () => { }); it('passes anonymous route', async () => { + // Slack only ever POSTs; GET /slack/events is no longer anonymous (VULN-39365). context.pathInfo.suffix = '/slack/events'; - const resp = await action(new Request('https://space.cat/slack/events'), context); + const resp = await action(new Request('https://space.cat/slack/events', { method: 'POST' }), context); expect(resp).to.equal(42); expect(context.attributes.authInfo).to.be.undefined; }); + it('does NOT treat GET /slack/events as anonymous (VULN-39365)', async () => { + // A GET carries no body to sign, so it could never be Slack-signature-verified. It must + // fall through to the authentication manager, which rejects it. + context.pathInfo.suffix = '/slack/events'; + + const resp = await action(new Request('https://space.cat/slack/events'), context); + + expect(resp.status).to.equal(401); + expect(context.attributes.authInfo).to.be.undefined; + }); + + it('honours an anonymousEndpoints override that disables the bypass', async () => { + const locked = wrap(() => 42) + .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: [] }) + .with(enrichPathInfo); + context.pathInfo.suffix = '/slack/events'; + + const resp = await locked(new Request('https://space.cat/slack/events', { method: 'POST' }), context); + + expect(resp.status).to.equal(401); + }); + + it('honours an anonymousEndpoints override that names a different route', async () => { + const custom = wrap(() => 42) + .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: ['POST /custom/hook'] }) + .with(enrichPathInfo); + context.pathInfo.suffix = '/custom/hook'; + + const resp = await custom(new Request('https://space.cat/custom/hook', { method: 'POST' }), context); + + expect(resp).to.equal(42); + }); + + it('ignores a non-array anonymousEndpoints and keeps the default', async () => { + const bogus = wrap(() => 42) + .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: 'POST /slack/events' }) + .with(enrichPathInfo); + context.pathInfo.suffix = '/slack/events'; + + const resp = await bogus(new Request('https://space.cat/slack/events', { method: 'POST' }), context); + + expect(resp).to.equal(42); + }); + it('passes options method', async () => { context.pathInfo.method = 'OPTIONS'; context.pathInfo.suffix = '/sites'; From 6bc8ef37dfa4d0701a2fdc342a0ed912d9dd9b26 Mon Sep 17 00:00:00 2001 From: Josep Lopez Date: Tue, 8 Sep 2026 12:00:07 +0200 Subject: [PATCH 2/4] fix(http-utils): fail closed on a malformed anonymousEndpoints option Follow-up to the VULN-39365 change, addressing an architecture review finding. `anonymousEndpoints` previously fell back to the default list whenever the supplied value was not an array -- and a test locked that behaviour in. That is the wrong failure mode for security-sensitive configuration: a service passing a malformed value while trying to DISABLE the bypass would silently re-enable an unauthenticated `POST /slack/events` instead of failing. It now throws at wrapper-construction time when the option is present but is not an array of strings, so the misconfiguration surfaces at boot rather than as a silently widened attack surface. Omitting the option is unchanged and still yields the documented default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/auth/auth-wrapper.js | 17 ++++++++++++++--- .../test/auth/auth-wrapper.test.js | 17 ++++++++++------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js b/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js index e611e6371..5dba91b27 100644 --- a/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js +++ b/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js @@ -46,13 +46,24 @@ const ANONYMOUS_ENDPOINTS = [ * authentication, as `'METHOD /path'` strings. Pass `[]` to disable the bypass entirely. A * service that does not verify Slack request signatures SHOULD pass `[]`, otherwise it * inherits an unauthenticated `POST /slack/events` it may not be defending. + * + * Supplying a value that is not an array of strings THROWS at wrapper-construction time + * rather than falling back to the default. This is security-sensitive configuration: a typo + * by a service trying to *disable* the bypass must not silently re-enable it. * @returns {UniversalFunction} the wrapped function. + * @throws {Error} when `opts.anonymousEndpoints` is present but not an array of strings. */ export function authWrapper(fn, opts = {}) { let authenticationManager; - const anonymousEndpoints = Array.isArray(opts.anonymousEndpoints) - ? opts.anonymousEndpoints - : ANONYMOUS_ENDPOINTS; + let anonymousEndpoints = ANONYMOUS_ENDPOINTS; + + if (opts.anonymousEndpoints !== undefined) { + if (!Array.isArray(opts.anonymousEndpoints) + || opts.anonymousEndpoints.some((route) => typeof route !== 'string')) { + throw new Error('authWrapper: anonymousEndpoints must be an array of "METHOD /path" strings'); + } + anonymousEndpoints = opts.anonymousEndpoints; + } return async (request, context) => { const { log, pathInfo: { method, suffix } } = context; diff --git a/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js b/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js index 802590fbe..d7e600f56 100644 --- a/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js @@ -118,15 +118,18 @@ describe('auth wrapper', () => { expect(resp).to.equal(42); }); - it('ignores a non-array anonymousEndpoints and keeps the default', async () => { - const bogus = wrap(() => 42) + it('throws on a non-array anonymousEndpoints instead of silently using the default', () => { + // Security-sensitive config: a typo by a service trying to DISABLE the bypass must not + // silently re-enable an unauthenticated POST /slack/events. + expect(() => wrap(() => 42) .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: 'POST /slack/events' }) - .with(enrichPathInfo); - context.pathInfo.suffix = '/slack/events'; - - const resp = await bogus(new Request('https://space.cat/slack/events', { method: 'POST' }), context); + .with(enrichPathInfo)).to.throw('anonymousEndpoints must be an array'); + }); - expect(resp).to.equal(42); + it('throws on an anonymousEndpoints array containing a non-string', () => { + expect(() => wrap(() => 42) + .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: ['POST /slack/events', 42] }) + .with(enrichPathInfo)).to.throw('anonymousEndpoints must be an array'); }); it('passes options method', async () => { From f6a8ef0b7631196b5cda8f974adae96771e0e145 Mon Sep 17 00:00:00 2001 From: Josep Lopez Date: Tue, 8 Sep 2026 14:47:00 +0200 Subject: [PATCH 3/4] fix(http-utils): correct the anonymousEndpoints security contract in docs Addresses review feedback from @dzehnder on #1920. Must-fix: the JSDoc claimed `anonymousEndpoints: []` "disables the bypass entirely", but the option governs only the first clause of the guard. `OPTIONS` requests and `POST /hooks/site-detection/*` are separate unconditional clauses the option never touches, so a consumer passing `[]` for lockdown still ships two unauthenticated bypasses. On a VULN-remediation change whose whole value is an accurate mental model of the auth surface, a doc implying full lockdown is itself a hazard. The docs now name the complete unauthenticated surface, on both the ANONYMOUS_ENDPOINTS contract and the option itself. Behaviour is unchanged: routing the OPTIONS and hooks bypasses through the override would alter semantics for every consumer, which does not belong in a security fix. Also from the review: - Pin the corrected contract with tests: `anonymousEndpoints: []` still bypasses OPTIONS and `POST /hooks/site-detection/*` (the latter's true branch was previously unexercised), and a non-empty override drops the default `POST /slack/events`. - Defensively copy the supplied array so a caller mutating it after construction cannot widen the bypass, with a test. - Document the option in `src/auth/readme.md`, including that `[]` does not mean "authenticate everything" and that malformed config throws. 532 passing, 100% coverage on auth-wrapper.js; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/auth/auth-wrapper.js | 26 +++++++--- .../src/auth/readme.md | 23 +++++++++ .../test/auth/auth-wrapper.test.js | 51 +++++++++++++++++++ 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js b/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js index 5dba91b27..178d90ec2 100644 --- a/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js +++ b/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js @@ -17,7 +17,7 @@ import AuthenticationManager from './authentication-manager.js'; import { checkScopes } from './check-scopes.js'; /** - * Routes that bypass authentication entirely. + * Route-based entries that bypass authentication. * * SECURITY CONTRACT (VULN-39365): an entry here means this library performs NO authentication * for that route, so the consuming service MUST authenticate it by other means. For @@ -25,6 +25,12 @@ import { checkScopes } from './check-scopes.js'; * `X-Slack-Request-Timestamp`) before the payload reaches any handler. spacecat-api-service does * this in `slackSignatureWrapper`, which is mounted OUTSIDE this wrapper so it runs first. * + * IMPORTANT: this list is NOT the whole unauthenticated surface. Two further bypasses are + * unconditional clauses in the wrapper below and are not represented here or overridable via + * `anonymousEndpoints`: + * - every `OPTIONS` request (CORS preflight), and + * - any route matching the `POST /hooks/site-detection/` prefix. + * * Historically this list also contained `GET /slack/events`. That was removed because Slack only * ever POSTs events and interactive payloads, and a GET carries no body to sign — so a GET could * never be signature-verified and existed purely as an unauthenticated entry point. @@ -42,14 +48,19 @@ const ANONYMOUS_ENDPOINTS = [ * @param {UniversalFunction} fn - the function to wrap. * @param {object} [opts] - options. * @param {Array} [opts.authHandlers] - the authentication handler classes to try, in order. - * @param {string[]} [opts.anonymousEndpoints] - overrides the default set of routes that bypass - * authentication, as `'METHOD /path'` strings. Pass `[]` to disable the bypass entirely. A - * service that does not verify Slack request signatures SHOULD pass `[]`, otherwise it - * inherits an unauthenticated `POST /slack/events` it may not be defending. + * @param {string[]} [opts.anonymousEndpoints] - overrides the default set of **route-based** + * anonymous entries, as `'METHOD /path'` strings (exact match, method upper-case). + * + * Pass `[]` to remove the route-based entries. Note this does NOT authenticate everything: + * `OPTIONS` requests and `POST /hooks/site-detection/*` bypass authentication + * unconditionally and are not affected by this option. A service that does not verify Slack + * request signatures SHOULD pass `[]`, otherwise it inherits an unauthenticated + * `POST /slack/events` it may not be defending. * * Supplying a value that is not an array of strings THROWS at wrapper-construction time * rather than falling back to the default. This is security-sensitive configuration: a typo - * by a service trying to *disable* the bypass must not silently re-enable it. + * by a service trying to *disable* the bypass must not silently re-enable it. Entries are + * copied, so mutating the caller's array afterwards cannot widen the bypass. * @returns {UniversalFunction} the wrapped function. * @throws {Error} when `opts.anonymousEndpoints` is present but not an array of strings. */ @@ -62,7 +73,8 @@ export function authWrapper(fn, opts = {}) { || opts.anonymousEndpoints.some((route) => typeof route !== 'string')) { throw new Error('authWrapper: anonymousEndpoints must be an array of "METHOD /path" strings'); } - anonymousEndpoints = opts.anonymousEndpoints; + // Defensive copy: the caller must not be able to widen the bypass after construction. + anonymousEndpoints = [...opts.anonymousEndpoints]; } return async (request, context) => { diff --git a/packages/spacecat-shared-http-utils/src/auth/readme.md b/packages/spacecat-shared-http-utils/src/auth/readme.md index cb662198b..999a1830d 100644 --- a/packages/spacecat-shared-http-utils/src/auth/readme.md +++ b/packages/spacecat-shared-http-utils/src/auth/readme.md @@ -50,6 +50,29 @@ export const main = wrap(run) .with(auth, { authHandlers: [LegacyApiKeyHandler, AdobeImsHandler] }); ``` +#### Anonymous routes (`anonymousEndpoints`) + +Some routes bypass authentication. By default that is `POST /slack/events`, which the +consuming service is expected to authenticate by other means — spacecat-api-service verifies +the Slack request signature in a wrapper mounted outside this one (VULN-39365). + +A service that does **not** verify Slack request signatures should opt out explicitly, so it +does not inherit an unauthenticated route it is not defending: + +```javascript +export const main = wrap(run) + .with(auth, { authHandlers: [...], anonymousEndpoints: [] }); +``` + +Two caveats: + +- `anonymousEndpoints` governs only the **route-based** list (exact `'METHOD /path'` matches). + `OPTIONS` requests and `POST /hooks/site-detection/*` bypass authentication unconditionally + and are **not** affected by this option — `[]` does not mean "authenticate everything". +- Passing anything other than an array of strings **throws at construction** rather than + falling back to the default, so a typo while locking things down cannot silently re-open the + bypass. + ### Implementing an Authentication Handler To implement a new authentication handler, extend the `AbstractHandler` class and implement the `checkAuth` method. diff --git a/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js b/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js index d7e600f56..527d5e072 100644 --- a/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js @@ -107,6 +107,57 @@ describe('auth wrapper', () => { expect(resp.status).to.equal(401); }); + // The option governs ONLY the route-based list. These pin the two unconditional bypasses it + // does not reach, so the documented contract cannot silently drift from the behaviour. + it('anonymousEndpoints: [] does NOT authenticate OPTIONS requests', async () => { + const locked = wrap(() => 42) + .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: [] }) + .with(enrichPathInfo); + context.pathInfo.suffix = '/sites'; + + const resp = await locked(new Request('https://space.cat/sites', { method: 'OPTIONS' }), context); + + expect(resp).to.equal(42); + }); + + it('anonymousEndpoints: [] does NOT authenticate POST /hooks/site-detection/*', async () => { + const locked = wrap(() => 42) + .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: [] }) + .with(enrichPathInfo); + context.pathInfo.suffix = '/hooks/site-detection/cdn/some-secret'; + + const resp = await locked( + new Request('https://space.cat/hooks/site-detection/cdn/some-secret', { method: 'POST' }), + context, + ); + + expect(resp).to.equal(42); + }); + + it('a non-empty override drops the default POST /slack/events', async () => { + const custom = wrap(() => 42) + .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: ['POST /custom/hook'] }) + .with(enrichPathInfo); + context.pathInfo.suffix = '/slack/events'; + + const resp = await custom(new Request('https://space.cat/slack/events', { method: 'POST' }), context); + + expect(resp.status).to.equal(401); + }); + + it('copies anonymousEndpoints so later mutation cannot widen the bypass', async () => { + const caller = []; + const locked = wrap(() => 42) + .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: caller }) + .with(enrichPathInfo); + caller.push('POST /slack/events'); + context.pathInfo.suffix = '/slack/events'; + + const resp = await locked(new Request('https://space.cat/slack/events', { method: 'POST' }), context); + + expect(resp.status).to.equal(401); + }); + it('honours an anonymousEndpoints override that names a different route', async () => { const custom = wrap(() => 42) .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: ['POST /custom/hook'] }) From 85b801072443a1e5b30234a38b65df6c679fec1c Mon Sep 17 00:00:00 2001 From: Josep Lopez Date: Tue, 8 Sep 2026 14:48:32 +0200 Subject: [PATCH 4/4] fix(http-utils): reject malformed anonymousEndpoints route shapes Entries are compared verbatim against `${METHOD.toUpperCase()} ${suffix}`, so an entry like 'post /slack/events' (lower-case method) or 'POST slack/events' (no leading slash) would pass the array-of-strings check, never match anything, and silently fail closed. A service that believed it had allowed a route would instead get 401s with no signal as to why. That is the same class of silent misconfiguration the throw-on-non-array check already guards against, so validate the shape too and name the offending entries in the error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/auth/auth-wrapper.js | 13 +++++++++++++ .../test/auth/auth-wrapper.test.js | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js b/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js index 178d90ec2..b99c5f1c8 100644 --- a/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js +++ b/packages/spacecat-shared-http-utils/src/auth/auth-wrapper.js @@ -42,6 +42,10 @@ const ANONYMOUS_ENDPOINTS = [ 'POST /slack/events', ]; +// Entries are compared verbatim against `${METHOD.toUpperCase()} ${suffix}`, so they must be an +// upper-case method, a single space, then an absolute path. +const ANONYMOUS_ROUTE_SHAPE = /^[A-Z]+ \/\S*$/; + /** * Wraps a function with authentication. * @@ -73,6 +77,15 @@ export function authWrapper(fn, opts = {}) { || opts.anonymousEndpoints.some((route) => typeof route !== 'string')) { throw new Error('authWrapper: anonymousEndpoints must be an array of "METHOD /path" strings'); } + // Shape-check each entry too. Matching is an exact string compare against + // `${METHOD.toUpperCase()} ${suffix}`, so an entry like 'post /slack/events' or a missing + // leading slash would validate, never match, and silently fail closed -- a debugging trap + // for a service that believes it has allowed a route. Same rationale as the throw above: + // security-sensitive config must not misconfigure quietly. + const malformed = opts.anonymousEndpoints.filter((route) => !ANONYMOUS_ROUTE_SHAPE.test(route)); + if (malformed.length > 0) { + throw new Error(`authWrapper: anonymousEndpoints entries must look like "METHOD /path" with an upper-case method; got: ${malformed.join(', ')}`); + } // Defensive copy: the caller must not be able to widen the bypass after construction. anonymousEndpoints = [...opts.anonymousEndpoints]; } diff --git a/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js b/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js index 527d5e072..3a00b2dd8 100644 --- a/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/auth-wrapper.test.js @@ -183,6 +183,18 @@ describe('auth wrapper', () => { .with(enrichPathInfo)).to.throw('anonymousEndpoints must be an array'); }); + it('throws on a lower-case method, which would validate but never match', () => { + expect(() => wrap(() => 42) + .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: ['post /slack/events'] }) + .with(enrichPathInfo)).to.throw('upper-case method'); + }); + + it('throws on an entry with no leading slash on the path', () => { + expect(() => wrap(() => 42) + .with(authWrapper, { authHandlers: [DummyHandler], anonymousEndpoints: ['POST slack/events'] }) + .with(enrichPathInfo)).to.throw('upper-case method'); + }); + it('passes options method', async () => { context.pathInfo.method = 'OPTIONS'; context.pathInfo.suffix = '/sites';