diff --git a/integrationTests/fixtures/path-segment-routing-app/config.yaml b/integrationTests/fixtures/path-segment-routing-app/config.yaml new file mode 100644 index 0000000..a547e1c --- /dev/null +++ b/integrationTests/fixtures/path-segment-routing-app/config.yaml @@ -0,0 +1,44 @@ +# Integration test fixture: verifies the OAuth Resource correctly extracts +# the providerName from the URL path segment, NOT the literal "oauth" prefix. +# +# Two providers are configured with distinctive client_ids. A request to +# /oauth//login should redirect with that provider's client_id. If +# parseRoute were to extract the literal "oauth" path segment as providerName +# (the symptom Dawson reported on 2026-05-22 from CM/Studio), the request +# would NOT produce a tenant-shaped 302 — it would either route to the +# oauth-named decoy provider with action='oac-oauth-tenant' (an unknown +# action → 404) or route to some other unintended state. The exact failure +# shape depends on parseRoute's specific regression; the assertion only +# requires that the tenant.test authorizationUrl + tenant client_id +# appear in the redirect. +# +# The tenant provider name deliberately contains the substring "oauth" so +# this fixture also guards against an over-aggressive stripping regression +# (e.g., parseRoute mangling /oauth/ matches that occur inside the segment +# rather than only at the prefix). + +rest: true + +'@harperfast/oauth': + package: '@harperfast/oauth' + providers: + # Provider literally named "oauth" — present specifically as a decoy. + # If parseRoute is broken and treats "oauth" as the providerName, this + # is what would (incorrectly) be selected as the provider. + oauth: + provider: generic + clientId: ${OAUTH_DECOY_CLIENT_ID} + clientSecret: decoy-secret + authorizationUrl: 'http://decoy.test/authorize' + tokenUrl: 'http://decoy.test/token' + userInfoUrl: 'http://decoy.test/userinfo' + # Tenant-style provider with a distinctive client_id we can assert on. + # The name contains "oauth" as a substring to catch over-aggressive + # path-stripping regressions in addition to the prefix-extraction case. + oac-oauth-tenant: + provider: generic + clientId: ${OAUTH_TENANT_CLIENT_ID} + clientSecret: tenant-secret + authorizationUrl: 'http://tenant.test/authorize' + tokenUrl: 'http://tenant.test/token' + userInfoUrl: 'http://tenant.test/userinfo' diff --git a/integrationTests/fixtures/path-segment-routing-app/package.json b/integrationTests/fixtures/path-segment-routing-app/package.json new file mode 100644 index 0000000..46a896d --- /dev/null +++ b/integrationTests/fixtures/path-segment-routing-app/package.json @@ -0,0 +1,6 @@ +{ + "name": "path-segment-routing-app-fixture", + "private": true, + "type": "module", + "description": "Integration-test fixture for OAuth Resource path-segment routing. The @harperfast/oauth dep is installed at test setup time via scripts/install-fixtures.js (npm pack + install); harper is resolved from the repo root." +} diff --git a/integrationTests/path-segment-routing.test.ts b/integrationTests/path-segment-routing.test.ts new file mode 100644 index 0000000..7d3ef45 --- /dev/null +++ b/integrationTests/path-segment-routing.test.ts @@ -0,0 +1,75 @@ +/** + * Regression guard for the symptom Dawson reported on 2026-05-22 (CM/Studio + * Okta SSO): + * + * "providerName is 'oauth', not the {configId} described, that isn't passed" + * + * If parseRoute extracted the literal "oauth" path prefix as providerName + * (rather than the segment after it), requests to /oauth//login + * would resolve to whatever provider is registered under the name "oauth" — + * with the rest of the URL becoming the action — and would NOT produce the + * expected tenant-shaped 302. + * + * This test configures two providers — one literally named "oauth" (a decoy) + * and one named "oac-oauth-tenant" (deliberately containing the substring + * "oauth" to also catch over-aggressive stripping) — with distinctive + * client_ids, then asserts that /oauth/oac-oauth-tenant/login redirects to + * the tenant provider's authorizationUrl with the tenant's client_id. Any + * regression that breaks path-segment routing will fail this assertion. + */ +import { suite, test, before, after } from 'node:test'; +import { strictEqual } from 'node:assert/strict'; +import { join, dirname } from 'node:path'; +import { createRequire } from 'node:module'; +import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; + +const require = createRequire(import.meta.url); + +function getHarperBinPath(): string { + return join(dirname(require.resolve('harper')), 'bin', 'harper.js'); +} + +const fixturePath = join(import.meta.dirname, 'fixtures', 'path-segment-routing-app'); + +const TENANT_CLIENT_ID = 'oauth-tenant-client-id'; +const DECOY_CLIENT_ID = 'decoy-oauth-client-id'; + +suite('OAuth Resource routes by URL path segment (not literal "oauth")', (ctx: ContextWithHarper) => { + before(async () => { + await setupHarperWithFixture(ctx, fixturePath, { + harperBinPath: getHarperBinPath(), + env: { + OAUTH_TENANT_CLIENT_ID: TENANT_CLIENT_ID, + OAUTH_DECOY_CLIENT_ID: DECOY_CLIENT_ID, + }, + config: { logging: { stdStreams: true } }, + }); + }); + + after(async () => { + await teardownHarper(ctx); + }); + + test('/oauth/oac-oauth-tenant/login dispatches to the tenant provider', async () => { + const response = await fetch(`${ctx.harper.httpURL}/oauth/oac-oauth-tenant/login`, { + redirect: 'manual', + }); + + strictEqual(response.status, 302, `expected 302 redirect, got ${response.status}`); + const location = response.headers.get('location'); + strictEqual(typeof location, 'string', 'Location header missing'); + const url = new URL(location!); + + // The tenant provider's authorizationUrl is http://tenant.test/authorize; + // the decoy's is http://decoy.test/authorize. A parseRoute that returned + // "oauth" as providerName would either 404 (action mismatch) or pull + // the decoy's client_id — both fail these assertions. + strictEqual(url.origin, 'http://tenant.test'); + strictEqual(url.pathname, '/authorize'); + strictEqual( + url.searchParams.get('client_id'), + TENANT_CLIENT_ID, + `client_id was ${url.searchParams.get('client_id')} — expected tenant's (${TENANT_CLIENT_ID}), decoy's is ${DECOY_CLIENT_ID}` + ); + }); +});