diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..7a3c9a0 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/bun-preload.js"] diff --git a/package-lock.json b/package-lock.json index f0d65cb..d89ba74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -766,9 +766,9 @@ } }, "node_modules/harperdb": { - "version": "4.7.7", - "resolved": "https://registry.npmjs.org/harperdb/-/harperdb-4.7.7.tgz", - "integrity": "sha512-O/tfDiNyFxybRrDg6a+EhxSuq8kNbPUd+wfWY5zsGG0ydr5SMqW42Hl1fPodTq51gtLYQaHdN+n8908rxigBuA==", + "version": "4.7.12", + "resolved": "https://registry.npmjs.org/harperdb/-/harperdb-4.7.12.tgz", + "integrity": "sha512-A14hTiVZG8Gkkcv7BDyDUjbwAbpdxgjQILSSYZdlRtUPWcu6gqGPrAKBXayoDjpe1ud45X858DdZT/JE86kOFQ==", "hasInstallScript": true, "hasShrinkwrap": true, "license": "SEE LICENSE IN LICENSE", @@ -2734,9 +2734,9 @@ } }, "node_modules/harperdb/node_modules/@smithy/core": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.4.tgz", - "integrity": "sha512-o5tMqPZILBvvROfC8vC+dSVnWJl9a0u9ax1i1+Bq8515eYjUJqqk5XjjEsDLoeL5dSqGSh6WGdVx1eJ1E/Nwhw==", + "version": "3.18.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.7.tgz", + "integrity": "sha512-axG9MvKhMWOhFbvf5y2DuyTxQueO0dkedY9QC3mAfndLosRI/9LJv8WaL0mw7ubNhsO4IuXX9/9dYGPFvHrqlw==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -2969,13 +2969,13 @@ } }, "node_modules/harperdb/node_modules/@smithy/middleware-endpoint": { - "version": "4.3.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.11.tgz", - "integrity": "sha512-eJXq9VJzEer1W7EQh3HY2PDJdEcEUnv6sKuNt4eVjyeNWcQFS4KmnY+CKkYOIR6tSqarn6bjjCqg1UB+8UJiPQ==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.14.tgz", + "integrity": "sha512-v0q4uTKgBM8dsqGjqsabZQyH85nFaTnFcgpWU1uydKFsdyyMzfvOkNum9G7VK+dOP01vUnoZxIeRiJ6uD0kjIg==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@smithy/core": "^3.18.4", + "@smithy/core": "^3.18.7", "@smithy/middleware-serde": "^4.2.6", "@smithy/node-config-provider": "^4.3.5", "@smithy/shared-ini-file-loader": "^4.4.0", @@ -2989,16 +2989,16 @@ } }, "node_modules/harperdb/node_modules/@smithy/middleware-retry": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.11.tgz", - "integrity": "sha512-EL5OQHvFOKneJVRgzRW4lU7yidSwp/vRJOe542bHgExN3KNThr1rlg0iE4k4SnA+ohC+qlUxoK+smKeAYPzfAQ==", + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.14.tgz", + "integrity": "sha512-Z2DG8Ej7FyWG1UA+7HceINtSLzswUgs2np3sZX0YBBxCt+CXG4QUxv88ZDS3+2/1ldW7LqtSY1UO/6VQ1pND8Q==", "license": "Apache-2.0", "peer": true, "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/service-error-classification": "^4.2.5", - "@smithy/smithy-client": "^4.9.7", + "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", @@ -3176,14 +3176,14 @@ } }, "node_modules/harperdb/node_modules/@smithy/smithy-client": { - "version": "4.9.7", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.7.tgz", - "integrity": "sha512-pskaE4kg0P9xNQWihfqlTMyxyFR3CH6Sr6keHYghgyqqDXzjl2QJg5lAzuVe/LzZiOzcbcVtxKYi1/fZPt/3DA==", + "version": "4.9.10", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.10.tgz", + "integrity": "sha512-Jaoz4Jw1QYHc1EFww/E6gVtNjhoDU+gwRKqXP6C3LKYqqH2UQhP8tMP3+t/ePrhaze7fhLE8vS2q6vVxBANFTQ==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@smithy/core": "^3.18.4", - "@smithy/middleware-endpoint": "^4.3.11", + "@smithy/core": "^3.18.7", + "@smithy/middleware-endpoint": "^4.3.14", "@smithy/middleware-stack": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", @@ -3291,14 +3291,14 @@ } }, "node_modules/harperdb/node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.10.tgz", - "integrity": "sha512-3iA3JVO1VLrP21FsZZpMCeF93aqP3uIOMvymAT3qHIJz2YlgDeRvNUspFwCNqd/j3qqILQJGtsVQnJZICh/9YA==", + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.13.tgz", + "integrity": "sha512-hlVLdAGrVfyNei+pKIgqDTxfu/ZI2NSyqj4IDxKd5bIsIqwR/dSlkxlPaYxFiIaDVrBy0he8orsFy+Cz119XvA==", "license": "Apache-2.0", "peer": true, "dependencies": { "@smithy/property-provider": "^4.2.5", - "@smithy/smithy-client": "^4.9.7", + "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, @@ -3307,9 +3307,9 @@ } }, "node_modules/harperdb/node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.13.tgz", - "integrity": "sha512-PTc6IpnpSGASuzZAgyUtaVfOFpU0jBD2mcGwrgDuHf7PlFgt5TIPxCYBDbFQs06jxgeV3kd/d/sok1pzV0nJRg==", + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.16.tgz", + "integrity": "sha512-F1t22IUiJLHrxW9W1CQ6B9PN+skZ9cqSuzB18Eh06HrJPbjsyZ7ZHecAKw80DQtyGTRcVfeukKaCRYebFwclbg==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -3317,7 +3317,7 @@ "@smithy/credential-provider-imds": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", - "@smithy/smithy-client": "^4.9.7", + "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, @@ -4020,9 +4020,9 @@ } }, "node_modules/harperdb/node_modules/bare-fs": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.1.tgz", - "integrity": "sha512-zGUCsm3yv/ePt2PHNbVxjjn0nNB1MkIaR4wOCxJ2ig5pCf5cCVAYJXVhQg/3OhhJV6DB1ts7Hv0oUaElc2TPQg==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", "license": "Apache-2.0", "optional": true, "peer": true, @@ -4144,9 +4144,9 @@ } }, "node_modules/harperdb/node_modules/bl": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.4.tgz", - "integrity": "sha512-ZV/9asSuknOExbM/zPPA8z00lc1ihPKWaStHkkQrxHNeYx+yY+TmF+v80dpv2G0mv3HVXBu7ryoAsxbFFhf4eg==", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", "license": "MIT", "peer": true, "dependencies": { @@ -4209,9 +4209,9 @@ } }, "node_modules/harperdb/node_modules/bowser": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", - "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", "license": "MIT", "peer": true }, @@ -5539,9 +5539,9 @@ } }, "node_modules/harperdb/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", "peer": true, "dependencies": { @@ -6301,13 +6301,13 @@ } }, "node_modules/harperdb/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "peer": true, "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -6582,16 +6582,20 @@ } }, "node_modules/harperdb/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "peer": true, "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/harperdb/node_modules/mimic-fn": { @@ -6762,9 +6766,9 @@ "peer": true }, "node_modules/harperdb/node_modules/nan": { - "version": "2.23.1", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", - "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==", + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", "license": "MIT", "optional": true, "peer": true @@ -6857,9 +6861,9 @@ } }, "node_modules/harperdb/node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", "license": "(BSD-3-Clause OR GPL-2.0)", "peer": true, "engines": { @@ -8712,9 +8716,9 @@ } }, "node_modules/harperdb/node_modules/typed-function": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", - "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", "license": "MIT", "peer": true, "engines": { diff --git a/package.json b/package.json index 0527a81..3d63f09 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,8 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", - "test": "npm run build && node --test", - "test:coverage": "npm run build && node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude='test/**'", + "test": "npm run build && node --test test/**/*.test.js", + "test:coverage": "npm run build && node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude=test/** test/**/*.test.js", "lint": "eslint . --ignore-pattern 'dist/**'", "format": "prettier .", "format:check": "npm run format -- --check", diff --git a/src/index.ts b/src/index.ts index cfc927c..5e89409 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,14 +6,15 @@ */ import { initializeProviders, expandEnvVar } from './lib/config.ts'; -import { createOAuthResource } from './lib/resource.ts'; +import { OAuthResource } from './lib/resource.ts'; import { validateAndRefreshSession } from './lib/sessionValidator.ts'; import { clearOAuthSession } from './lib/handlers.ts'; import { HookManager } from './lib/hookManager.ts'; import type { Scope, OAuthPluginConfig, ProviderRegistry, OAuthHooks } from './types.ts'; -// Export HookManager class and types +// Export HookManager class, OAuthResource class, and types export { HookManager } from './lib/hookManager.ts'; +export { OAuthResource } from './lib/resource.ts'; export type { OAuthHooks, OAuthUser, TokenResponse } from './types.ts'; // Store hooks registered at module load time and active hookManager @@ -106,7 +107,7 @@ export async function handleApplication(scope: Scope): Promise { // Update the resource with new providers if (Object.keys(providers).length === 0) { - // No valid providers configured + // No valid providers configured - register a simple error resource scope.resources.set('oauth', { async get() { return { @@ -130,8 +131,11 @@ export async function handleApplication(scope: Scope): Promise { }, }); } else { - // Register the OAuth resource with configured providers - scope.resources.set('oauth', createOAuthResource(providers, debugMode, hookManager, logger)); + // Configure the OAuth resource with providers and settings + OAuthResource.configure(providers, debugMode, hookManager, logger); + + // Register the OAuth resource class + scope.resources.set('oauth', OAuthResource); // Log all configured providers logger?.info?.('OAuth plugin ready:', { diff --git a/src/lib/handlers.ts b/src/lib/handlers.ts index 2835025..49554a2 100644 --- a/src/lib/handlers.ts +++ b/src/lib/handlers.ts @@ -7,7 +7,8 @@ import { readFile } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { Request, RequestTarget, Logger, IOAuthProvider, OAuthProviderConfig } from '../types.ts'; +import type { RequestTarget } from 'harperdb'; +import type { Request, Logger, IOAuthProvider, OAuthProviderConfig } from '../types.ts'; import type { HookManager } from './hookManager.ts'; /** diff --git a/src/lib/resource.ts b/src/lib/resource.ts index b5042b1..62e90be 100644 --- a/src/lib/resource.ts +++ b/src/lib/resource.ts @@ -4,172 +4,314 @@ * Harper resource class for handling OAuth REST endpoints */ -import type { Request, RequestTarget, Logger, ProviderRegistry } from '../types.ts'; +import { Resource } from 'harperdb'; +import type { RequestTarget } from 'harperdb'; +import type { Request, Logger, ProviderRegistry } from '../types.ts'; import { handleLogin, handleCallback, handleLogout, handleUserInfo, handleTestPage } from './handlers.ts'; import type { HookManager } from './hookManager.ts'; /** - * Create an OAuth resource with the given configuration - * Returns a resource object that Harper can use directly + * Parsed route information from a request target */ -export function createOAuthResource( - providers: ProviderRegistry, - debugMode: boolean, - hookManager: HookManager, - logger?: Logger -): any { - const notFound = { - status: 404, - body: { error: 'Not found' }, - }; - - const resource = { - // Expose hookManager for programmatic hook registration - hookManager, - // Expose providers for use with withOAuthValidation - providers, - /** - * Handle GET requests - */ - async get(target: RequestTarget | string, request: Request): Promise { - // Target can be an object with id, pathname, or a string - const id = typeof target === 'string' ? target : target?.id || target?.pathname || ''; - const pathParts = (id || '').split('/').filter((p) => p); - const providerName = pathParts[0]; - const action = pathParts[1]; - - // Special case: /oauth/test without provider (debug mode only) - if (providerName === 'test' && !action) { - if (!debugMode) return notFound; - return handleTestPage(logger); - } - - // If no provider specified - if (!providerName) { - if (!debugMode) return notFound; - - // Debug mode: show full provider info - return { - message: 'OAuth providers', - logout: 'POST /oauth/logout', - providers: Object.keys(providers).map((name) => ({ - name, - provider: providers[name].config.provider, - endpoints: { - login: `/oauth/${name}/login`, - callback: `/oauth/${name}/callback`, - user: `/oauth/${name}/user`, - refresh: `/oauth/${name}/refresh`, - test: `/oauth/${name}/test`, - }, - })), - }; - } - - // Check if provider exists - const providerData = providers[providerName]; - if (!providerData) { - return { - status: 404, - body: { - error: 'Provider not found', - available: Object.keys(providers), - }, - }; - } - - const { provider, config } = providerData; - - switch (action) { - case 'login': - // Pass the target object for query params (e.g., ?redirect=/dashboard) - return handleLogin(request, target as RequestTarget, provider, config, logger); - case 'callback': - // Pass the target object directly - it should have a get() method for query params - return handleCallback(request, target as RequestTarget, provider, config, hookManager, logger); - case 'user': { - // Debug mode only - if (!debugMode) return notFound; - - // Session validation/refresh already handled by middleware - // Just return user info from the session - return handleUserInfo(request, false); - } - case 'refresh': { - // Debug mode only - if (!debugMode) return notFound; - - // Session validation/refresh already handled by middleware - // This endpoint is just for checking refresh status - const oauthData = request.session?.oauth; - if (!oauthData || !oauthData.accessToken) { - return { - status: 401, - body: { - error: 'No OAuth session', - message: 'OAuth session is no longer valid. Please log in again.', - }, - }; - } - - // Return current token status - return { - status: 200, - body: { - message: 'Token is valid', - provider: oauthData.provider, - expiresAt: oauthData.expiresAt, - lastRefreshed: oauthData.lastRefreshed, - }, - }; - } - case 'test': { - // Debug mode only - if (!debugMode) return notFound; - return handleTestPage(logger); - } - default: { - // Debug mode only - if (!debugMode) return notFound; - // Show provider configuration info - return { - message: `OAuth provider: ${providerName}`, - provider: config.provider, - configured: true, - logout: 'POST /oauth/logout', - endpoints: { - login: `/oauth/${providerName}/login`, - callback: `/oauth/${providerName}/callback`, - user: `/oauth/${providerName}/user`, - refresh: `/oauth/${providerName}/refresh`, - test: `/oauth/${providerName}/test`, - }, - }; - } - } - }, - - /** - * Handle POST requests - */ - async post(target: RequestTarget | string, _body: any, request: Request): Promise { - // Target can be an object with id, pathname, or a string - const id = typeof target === 'string' ? target : target?.id || target?.pathname || ''; - const pathParts = (id || '').split('/').filter((p) => p); - const providerName = pathParts[0]; - - // Handle generic logout endpoint (no provider required) - if (providerName === 'logout') { - return handleLogout(request, hookManager, logger); - } - - // For other POST endpoints, provider is required +export interface ParsedRoute { + providerName: string; + action: string; + path: string; +} + +/** + * OAuth Resource - proper Harper Resource class for handling OAuth endpoints + * Follows Resource API v2 pattern (loadAsInstance = false) + */ +export class OAuthResource extends Resource { + static loadAsInstance = false; // Use Resource API v2 + + // Store configuration as static properties (shared across all requests) + static providers: ProviderRegistry = {}; + static debugMode: boolean = false; + static hookManager: HookManager | null = null; + static logger: Logger | undefined = undefined; + + /** + * Configure the OAuth resource with providers and settings + * Called once during plugin initialization + */ + static configure(providers: ProviderRegistry, debugMode: boolean, hookManager: HookManager, logger?: Logger): void { + OAuthResource.providers = providers; + OAuthResource.debugMode = debugMode; + OAuthResource.hookManager = hookManager; + OAuthResource.logger = logger; + } + + /** + * Parse a request target into provider and action components + * Exported as static method for testability + */ + static parseRoute(target: RequestTarget): ParsedRoute { + const id = target.id || target.pathname || ''; + const path = typeof id === 'string' ? id : String(id); + + // Validate path length to prevent DoS attacks with extremely long URLs + if (path.length > 2048) { + // Return empty route for oversized paths (will result in 404) + return { + providerName: '', + action: '', + path: '', + }; + } + + const pathParts = path.split('/').filter((p) => p); + + return { + providerName: pathParts[0] || '', + action: pathParts[1] || '', + path, + }; + } + + /** + * Check if a route should return 404 in production mode + * Debug-only endpoints return 404 when debug mode is off + */ + static isDebugOnlyRoute(route: ParsedRoute): boolean { + const { providerName, action } = route; + + // Root path (provider list) - debug only + if (!providerName) return true; + + // Test endpoints - debug only + if (providerName === 'test' && !action) return true; + if (action === 'test') return true; + + // Debug info endpoints + if (action === 'user' || action === 'refresh') return true; + + // Provider info (no action) - debug only + if (providerName && !action) return true; + + return false; + } + + /** + * Build the standard 404 response + */ + static notFoundResponse() { + return { + status: 404, + body: { error: 'Not found' }, + }; + } + + /** + * Build provider list response for root path + */ + static buildProviderListResponse(providers: ProviderRegistry): any { + return { + message: 'OAuth providers', + logout: 'POST /oauth/logout', + providers: Object.keys(providers).map((name) => ({ + name, + provider: providers[name].config.provider, + endpoints: { + login: `/oauth/${name}/login`, + callback: `/oauth/${name}/callback`, + user: `/oauth/${name}/user`, + refresh: `/oauth/${name}/refresh`, + test: `/oauth/${name}/test`, + }, + })), + }; + } + + /** + * Build provider info response + */ + static buildProviderInfoResponse(providerName: string, providers: ProviderRegistry): any { + const providerData = providers[providerName]; + if (!providerData) { + return { + status: 404, + body: { + error: 'Provider not found', + available: Object.keys(providers), + }, + }; + } + + return { + message: `OAuth provider: ${providerName}`, + provider: providerData.config.provider, + configured: true, + logout: 'POST /oauth/logout', + endpoints: { + login: `/oauth/${providerName}/login`, + callback: `/oauth/${providerName}/callback`, + user: `/oauth/${providerName}/user`, + refresh: `/oauth/${providerName}/refresh`, + test: `/oauth/${providerName}/test`, + }, + }; + } + + /** + * Build token status response for /refresh endpoint + */ + static buildTokenStatusResponse(request: Request): any { + const oauthData = request.session?.oauth; + if (!oauthData || !oauthData.accessToken) { + return { + status: 401, + body: { + error: 'No OAuth session', + message: 'OAuth session is no longer valid. Please log in again.', + }, + }; + } + + return { + status: 200, + body: { + message: 'Token is valid', + provider: oauthData.provider, + expiresAt: oauthData.expiresAt, + lastRefreshed: oauthData.lastRefreshed, + }, + }; + } + + /** + * Handle GET requests to OAuth endpoints + * Resource API v2 signature: get(target) + */ + async get(target: RequestTarget): Promise { + const providers = OAuthResource.providers; + const debugMode = OAuthResource.debugMode; + const logger = OAuthResource.logger; + + // Parse the route + const route = OAuthResource.parseRoute(target); + const { providerName, action } = route; + + // Get request from context (HarperDB provides the HTTP request here) + const context = this.getContext(); + if (!context) { + logger?.error?.('Request context is null or undefined'); + return { + status: 500, + body: { error: 'Internal server error' }, + }; + } + const request = context as unknown as Request; + + // Check debug mode restrictions + if (!debugMode && OAuthResource.isDebugOnlyRoute(route)) { + return OAuthResource.notFoundResponse(); + } + + // Special case: /oauth/test without provider + if (providerName === 'test' && !action) { + return handleTestPage(logger); + } + + // Root path - show provider list + if (!providerName) { + return OAuthResource.buildProviderListResponse(providers); + } + + // Check if provider exists + const providerData = providers[providerName]; + if (!providerData) { return { status: 404, - body: { error: 'Not found' }, + body: { + error: 'Provider not found', + available: Object.keys(providers), + }, + }; + } + + const { provider, config } = providerData; + const hookManager = OAuthResource.hookManager!; + + // Handle specific actions + switch (action) { + case 'login': + return handleLogin(request, target, provider, config, logger); + + case 'callback': + return handleCallback(request, target, provider, config, hookManager, logger); + + case 'user': + return handleUserInfo(request, false); + + case 'refresh': + return OAuthResource.buildTokenStatusResponse(request); + + case 'test': + return handleTestPage(logger); + + default: + // Provider info (no action specified) + return OAuthResource.buildProviderInfoResponse(providerName, providers); + } + } + + /** + * Handle POST requests to OAuth endpoints + * Resource API v2 signature: post(target, data) + */ + async post(target: RequestTarget, _data: any): Promise { + const logger = OAuthResource.logger; + const hookManager = OAuthResource.hookManager!; + + // Parse the route + const route = OAuthResource.parseRoute(target); + const { providerName } = route; + + // Get request from context (HarperDB provides the HTTP request here) + const context = this.getContext(); + if (!context) { + logger?.error?.('Request context is null or undefined'); + return { + status: 500, + body: { error: 'Internal server error' }, }; - }, - }; + } + const request = context as unknown as Request; + + // Handle logout endpoint + if (providerName === 'logout') { + return handleLogout(request, hookManager, logger); + } + + // All other POST endpoints are not supported + return OAuthResource.notFoundResponse(); + } + + /** + * Expose hookManager for programmatic hook registration + * This allows access via: scope.resources.get('oauth').hookManager + */ + static getHookManager(): HookManager | null { + return OAuthResource.hookManager; + } + + /** + * Expose providers for use with withOAuthValidation + * This allows access via: scope.resources.get('oauth').providers + */ + static getProviders(): ProviderRegistry { + return OAuthResource.providers; + } - return resource; + /** + * Reset configuration (useful for testing) + */ + static reset(): void { + OAuthResource.providers = {}; + OAuthResource.debugMode = false; + OAuthResource.hookManager = null; + OAuthResource.logger = undefined; + } } diff --git a/test/bun-preload.js b/test/bun-preload.js new file mode 100644 index 0000000..e9f9a64 --- /dev/null +++ b/test/bun-preload.js @@ -0,0 +1,31 @@ +/** + * Bun test setup file + * This runs before any tests and sets up the environment for HarperDB mocks + */ + +import { mock } from 'bun:test'; + +// Mock the harperdb module to prevent initialization errors in Bun +// HarperDB's threadServer.js tries to call database() functions during module init +// which fail in test environment +// +// This mock Resource class provides enough functionality for OAuthResource to extend +// and for tests to work properly +mock.module('harperdb', () => { + return { + Resource: class Resource { + static loadAsInstance = false; + + // Context storage for request handling + _context = null; + + getContext() { + return this._context; + } + + setContext(context) { + this._context = context; + } + }, + }; +}); diff --git a/test/lib/OAuthResource.helpers.test.js b/test/lib/OAuthResource.helpers.test.js new file mode 100644 index 0000000..f0c617d --- /dev/null +++ b/test/lib/OAuthResource.helpers.test.js @@ -0,0 +1,241 @@ +/** + * Tests for OAuthResource Helper Methods + * Tests static methods extracted for testability + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// Note: Bun uses test/bun-preload.js to mock the harperdb module +// Node.js will load the real harperdb module, which may trigger async +// native module loading that continues after tests complete. This is harmless. + +import { OAuthResource } from '../../dist/lib/resource.js'; + +describe('OAuthResource - Helper Methods', () => { + afterEach(() => { + // Reset configuration after each test + OAuthResource.reset(); + }); + + describe('parseRoute()', () => { + it('should parse route with provider and action', () => { + const target = { id: 'github/login', pathname: 'github/login' }; + const route = OAuthResource.parseRoute(target); + + assert.equal(route.providerName, 'github'); + assert.equal(route.action, 'login'); + assert.equal(route.path, 'github/login'); + }); + + it('should parse route with provider only', () => { + const target = { id: 'github', pathname: 'github' }; + const route = OAuthResource.parseRoute(target); + + assert.equal(route.providerName, 'github'); + assert.equal(route.action, ''); + assert.equal(route.path, 'github'); + }); + + it('should parse empty route', () => { + const target = { id: '', pathname: '' }; + const route = OAuthResource.parseRoute(target); + + assert.equal(route.providerName, ''); + assert.equal(route.action, ''); + assert.equal(route.path, ''); + }); + + it('should parse route with extra path segments', () => { + const target = { id: 'github/callback/extra', pathname: 'github/callback/extra' }; + const route = OAuthResource.parseRoute(target); + + assert.equal(route.providerName, 'github'); + assert.equal(route.action, 'callback'); + assert.equal(route.path, 'github/callback/extra'); + }); + + it('should handle null/undefined id and pathname', () => { + const target = { id: null, pathname: undefined }; + const route = OAuthResource.parseRoute(target); + + assert.equal(route.providerName, ''); + assert.equal(route.action, ''); + assert.equal(route.path, ''); + }); + + it('should prefer id over pathname', () => { + const target = { id: 'github/login', pathname: 'google/callback' }; + const route = OAuthResource.parseRoute(target); + + assert.equal(route.providerName, 'github'); + assert.equal(route.action, 'login'); + }); + + it('should handle numeric id', () => { + const target = { id: 123, pathname: '' }; + const route = OAuthResource.parseRoute(target); + + assert.equal(route.path, '123'); + }); + + it('should filter out empty path segments', () => { + const target = { id: '/github//login/', pathname: '' }; + const route = OAuthResource.parseRoute(target); + + assert.equal(route.providerName, 'github'); + assert.equal(route.action, 'login'); + }); + }); + + describe('isDebugOnlyRoute()', () => { + it('should identify root path as debug-only', () => { + const route = { providerName: '', action: '', path: '' }; + assert.equal(OAuthResource.isDebugOnlyRoute(route), true); + }); + + it('should identify /test as debug-only', () => { + const route = { providerName: 'test', action: '', path: 'test' }; + assert.equal(OAuthResource.isDebugOnlyRoute(route), true); + }); + + it('should identify provider/test as debug-only', () => { + const route = { providerName: 'github', action: 'test', path: 'github/test' }; + assert.equal(OAuthResource.isDebugOnlyRoute(route), true); + }); + + it('should identify provider/user as debug-only', () => { + const route = { providerName: 'github', action: 'user', path: 'github/user' }; + assert.equal(OAuthResource.isDebugOnlyRoute(route), true); + }); + + it('should identify provider/refresh as debug-only', () => { + const route = { providerName: 'github', action: 'refresh', path: 'github/refresh' }; + assert.equal(OAuthResource.isDebugOnlyRoute(route), true); + }); + + it('should identify provider info (no action) as debug-only', () => { + const route = { providerName: 'github', action: '', path: 'github' }; + assert.equal(OAuthResource.isDebugOnlyRoute(route), true); + }); + + it('should NOT identify provider/login as debug-only', () => { + const route = { providerName: 'github', action: 'login', path: 'github/login' }; + assert.equal(OAuthResource.isDebugOnlyRoute(route), false); + }); + + it('should NOT identify provider/callback as debug-only', () => { + const route = { providerName: 'github', action: 'callback', path: 'github/callback' }; + assert.equal(OAuthResource.isDebugOnlyRoute(route), false); + }); + }); + + describe('notFoundResponse()', () => { + it('should return 404 status', () => { + const response = OAuthResource.notFoundResponse(); + assert.equal(response.status, 404); + }); + + it('should return error message', () => { + const response = OAuthResource.notFoundResponse(); + assert.equal(response.body.error, 'Not found'); + }); + + it('should return consistent response object', () => { + const response1 = OAuthResource.notFoundResponse(); + const response2 = OAuthResource.notFoundResponse(); + assert.deepEqual(response1, response2); + }); + }); + + describe('configure() and reset()', () => { + it('should configure all properties', () => { + const mockProviders = { github: { config: { provider: 'github' } } }; + const mockHookManager = { callOnLogin: () => {} }; + const mockLogger = { info: () => {} }; + + OAuthResource.configure(mockProviders, true, mockHookManager, mockLogger); + + assert.equal(OAuthResource.providers, mockProviders); + assert.equal(OAuthResource.debugMode, true); + assert.equal(OAuthResource.hookManager, mockHookManager); + assert.equal(OAuthResource.logger, mockLogger); + }); + + it('should allow configuration without logger', () => { + const mockProviders = { github: { config: { provider: 'github' } } }; + const mockHookManager = { callOnLogin: () => {} }; + + OAuthResource.configure(mockProviders, false, mockHookManager); + + assert.equal(OAuthResource.providers, mockProviders); + assert.equal(OAuthResource.debugMode, false); + assert.equal(OAuthResource.hookManager, mockHookManager); + assert.equal(OAuthResource.logger, undefined); + }); + + it('should reset all properties to defaults', () => { + const mockProviders = { github: { config: { provider: 'github' } } }; + const mockHookManager = { callOnLogin: () => {} }; + const mockLogger = { info: () => {} }; + + OAuthResource.configure(mockProviders, true, mockHookManager, mockLogger); + OAuthResource.reset(); + + assert.deepEqual(OAuthResource.providers, {}); + assert.equal(OAuthResource.debugMode, false); + assert.equal(OAuthResource.hookManager, null); + assert.equal(OAuthResource.logger, undefined); + }); + + it('should allow reconfiguration', () => { + const providers1 = { github: { config: { provider: 'github' } } }; + const providers2 = { google: { config: { provider: 'google' } } }; + const mockHookManager = { callOnLogin: () => {} }; + + OAuthResource.configure(providers1, true, mockHookManager); + assert.equal(Object.keys(OAuthResource.providers).length, 1); + + OAuthResource.configure(providers2, false, mockHookManager); + assert.equal(Object.keys(OAuthResource.providers).length, 1); + assert.ok(OAuthResource.providers.google); + assert.equal(OAuthResource.debugMode, false); + }); + }); + + describe('getHookManager() and getProviders()', () => { + beforeEach(() => { + OAuthResource.reset(); + }); + + it('should return null hookManager when not configured', () => { + assert.equal(OAuthResource.getHookManager(), null); + }); + + it('should return empty providers when not configured', () => { + assert.deepEqual(OAuthResource.getProviders(), {}); + }); + + it('should return configured hookManager', () => { + const mockHookManager = { callOnLogin: () => {} }; + const mockProviders = {}; + OAuthResource.configure(mockProviders, false, mockHookManager); + + assert.equal(OAuthResource.getHookManager(), mockHookManager); + }); + + it('should return configured providers', () => { + const mockProviders = { + github: { config: { provider: 'github' } }, + google: { config: { provider: 'google' } }, + }; + const mockHookManager = { callOnLogin: () => {} }; + OAuthResource.configure(mockProviders, false, mockHookManager); + + const providers = OAuthResource.getProviders(); + assert.equal(Object.keys(providers).length, 2); + assert.ok(providers.github); + assert.ok(providers.google); + }); + }); +}); diff --git a/test/lib/OAuthResource.responses.test.js b/test/lib/OAuthResource.responses.test.js new file mode 100644 index 0000000..6be2ad5 --- /dev/null +++ b/test/lib/OAuthResource.responses.test.js @@ -0,0 +1,306 @@ +/** + * Tests for OAuthResource Response Builders + * Tests static methods that build various response objects + */ + +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// Note: Bun uses test/bun-preload.js to mock the harperdb module +// Node.js will load the real harperdb module, which may trigger async +// native module loading that continues after tests complete. This is harmless. + +import { OAuthResource } from '../../dist/lib/resource.js'; + +describe('OAuthResource - Response Builders', () => { + afterEach(() => { + OAuthResource.reset(); + }); + + describe('buildProviderListResponse()', () => { + it('should build response with empty providers', () => { + const providers = {}; + const response = OAuthResource.buildProviderListResponse(providers); + + assert.equal(response.message, 'OAuth providers'); + assert.equal(response.logout, 'POST /oauth/logout'); + assert.ok(Array.isArray(response.providers)); + assert.equal(response.providers.length, 0); + }); + + it('should build response with single provider', () => { + const providers = { + github: { + config: { + provider: 'github', + clientId: 'test-id', + }, + }, + }; + const response = OAuthResource.buildProviderListResponse(providers); + + assert.equal(response.providers.length, 1); + assert.equal(response.providers[0].name, 'github'); + assert.equal(response.providers[0].provider, 'github'); + assert.ok(response.providers[0].endpoints); + assert.equal(response.providers[0].endpoints.login, '/oauth/github/login'); + assert.equal(response.providers[0].endpoints.callback, '/oauth/github/callback'); + assert.equal(response.providers[0].endpoints.user, '/oauth/github/user'); + assert.equal(response.providers[0].endpoints.refresh, '/oauth/github/refresh'); + assert.equal(response.providers[0].endpoints.test, '/oauth/github/test'); + }); + + it('should build response with multiple providers', () => { + const providers = { + github: { + config: { + provider: 'github', + }, + }, + google: { + config: { + provider: 'google', + }, + }, + azure: { + config: { + provider: 'azure', + }, + }, + }; + const response = OAuthResource.buildProviderListResponse(providers); + + assert.equal(response.providers.length, 3); + const providerNames = response.providers.map((p) => p.name); + assert.ok(providerNames.includes('github')); + assert.ok(providerNames.includes('google')); + assert.ok(providerNames.includes('azure')); + }); + + it('should include correct endpoint paths for each provider', () => { + const providers = { + 'custom-provider': { + config: { + provider: 'custom', + }, + }, + }; + const response = OAuthResource.buildProviderListResponse(providers); + + const endpoints = response.providers[0].endpoints; + assert.equal(endpoints.login, '/oauth/custom-provider/login'); + assert.equal(endpoints.callback, '/oauth/custom-provider/callback'); + assert.equal(endpoints.user, '/oauth/custom-provider/user'); + assert.equal(endpoints.refresh, '/oauth/custom-provider/refresh'); + assert.equal(endpoints.test, '/oauth/custom-provider/test'); + }); + }); + + describe('buildProviderInfoResponse()', () => { + it('should return 404 for non-existent provider', () => { + const providers = { + github: { config: { provider: 'github' } }, + }; + const response = OAuthResource.buildProviderInfoResponse('google', providers); + + assert.equal(response.status, 404); + assert.equal(response.body.error, 'Provider not found'); + assert.deepEqual(response.body.available, ['github']); + }); + + it('should build info response for existing provider', () => { + const providers = { + github: { + config: { + provider: 'github', + clientId: 'test-id', + }, + }, + }; + const response = OAuthResource.buildProviderInfoResponse('github', providers); + + assert.equal(response.message, 'OAuth provider: github'); + assert.equal(response.provider, 'github'); + assert.equal(response.configured, true); + assert.equal(response.logout, 'POST /oauth/logout'); + assert.ok(response.endpoints); + }); + + it('should include correct endpoints', () => { + const providers = { + google: { + config: { + provider: 'google', + }, + }, + }; + const response = OAuthResource.buildProviderInfoResponse('google', providers); + + assert.equal(response.endpoints.login, '/oauth/google/login'); + assert.equal(response.endpoints.callback, '/oauth/google/callback'); + assert.equal(response.endpoints.user, '/oauth/google/user'); + assert.equal(response.endpoints.refresh, '/oauth/google/refresh'); + assert.equal(response.endpoints.test, '/oauth/google/test'); + }); + + it('should list available providers when provider not found', () => { + const providers = { + github: { config: { provider: 'github' } }, + google: { config: { provider: 'google' } }, + azure: { config: { provider: 'azure' } }, + }; + const response = OAuthResource.buildProviderInfoResponse('invalid', providers); + + assert.equal(response.status, 404); + assert.equal(response.body.available.length, 3); + assert.ok(response.body.available.includes('github')); + assert.ok(response.body.available.includes('google')); + assert.ok(response.body.available.includes('azure')); + }); + }); + + describe('buildTokenStatusResponse()', () => { + it('should return 401 when no session', () => { + const request = {}; + const response = OAuthResource.buildTokenStatusResponse(request); + + assert.equal(response.status, 401); + assert.equal(response.body.error, 'No OAuth session'); + assert.ok(response.body.message.includes('log in again')); + }); + + it('should return 401 when session has no oauth data', () => { + const request = { + session: { + user: 'testuser', + }, + }; + const response = OAuthResource.buildTokenStatusResponse(request); + + assert.equal(response.status, 401); + assert.equal(response.body.error, 'No OAuth session'); + }); + + it('should return 401 when oauth data has no accessToken', () => { + const request = { + session: { + user: 'testuser', + oauth: { + provider: 'github', + // Missing accessToken + }, + }, + }; + const response = OAuthResource.buildTokenStatusResponse(request); + + assert.equal(response.status, 401); + assert.equal(response.body.error, 'No OAuth session'); + }); + + it('should return 200 with token status when valid session', () => { + const now = Date.now(); + const request = { + session: { + user: 'testuser', + oauth: { + provider: 'github', + accessToken: 'test-token', + expiresAt: now + 3600000, + lastRefreshed: now - 1000, + }, + }, + }; + const response = OAuthResource.buildTokenStatusResponse(request); + + assert.equal(response.status, 200); + assert.equal(response.body.message, 'Token is valid'); + assert.equal(response.body.provider, 'github'); + assert.equal(response.body.expiresAt, now + 3600000); + assert.equal(response.body.lastRefreshed, now - 1000); + }); + + it('should handle session with minimal oauth data', () => { + const request = { + session: { + oauth: { + accessToken: 'token', + provider: 'google', + }, + }, + }; + const response = OAuthResource.buildTokenStatusResponse(request); + + assert.equal(response.status, 200); + assert.equal(response.body.provider, 'google'); + assert.equal(response.body.expiresAt, undefined); + assert.equal(response.body.lastRefreshed, undefined); + }); + + it('should handle empty string accessToken as invalid', () => { + const request = { + session: { + oauth: { + accessToken: '', + provider: 'github', + }, + }, + }; + const response = OAuthResource.buildTokenStatusResponse(request); + + assert.equal(response.status, 401); + }); + + it('should handle null accessToken as invalid', () => { + const request = { + session: { + oauth: { + accessToken: null, + provider: 'github', + }, + }, + }; + const response = OAuthResource.buildTokenStatusResponse(request); + + assert.equal(response.status, 401); + }); + }); + + describe('Response Consistency', () => { + it('should return same structure for repeated calls', () => { + const providers = { + github: { config: { provider: 'github' } }, + }; + + const response1 = OAuthResource.buildProviderListResponse(providers); + const response2 = OAuthResource.buildProviderListResponse(providers); + + assert.deepEqual(response1, response2); + }); + + it('should not mutate input providers object', () => { + const providers = { + github: { config: { provider: 'github' } }, + }; + const originalProviders = JSON.parse(JSON.stringify(providers)); + + OAuthResource.buildProviderListResponse(providers); + OAuthResource.buildProviderInfoResponse('github', providers); + + assert.deepEqual(providers, originalProviders); + }); + + it('should handle undefined session properties gracefully', () => { + const request = { + session: { + oauth: { + accessToken: 'token', + }, + }, + }; + + const response = OAuthResource.buildTokenStatusResponse(request); + assert.equal(response.status, 200); + // Should not throw when provider, expiresAt, etc. are undefined + }); + }); +}); diff --git a/test/lib/resource.test.js b/test/lib/resource.test.js deleted file mode 100644 index 636574b..0000000 --- a/test/lib/resource.test.js +++ /dev/null @@ -1,298 +0,0 @@ -/** - * Tests for OAuth Resource - */ - -import { describe, it, beforeEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { createMockFn, createMockLogger } from '../helpers/mockFn.js'; - -// Mock Harper's Resource class for testing -global.Resource = class { - constructor() {} - static loadAsInstance = true; -}; - -import { createOAuthResource } from '../../dist/lib/resource.js'; - -describe('OAuth Resource', () => { - let mockProviders; - let mockLogger; - let mockHookManager; - - beforeEach(() => { - mockLogger = createMockLogger(); - - mockHookManager = { - callOnLogin: createMockFn(async () => {}), - callOnLogout: createMockFn(async () => {}), - callOnTokenRefresh: createMockFn(async () => {}), - }; - - mockProviders = { - github: { - provider: { - generateCSRFToken: createMockFn(), - getAuthorizationUrl: createMockFn(), - }, - config: { - provider: 'github', - clientId: 'github-client', - }, - }, - google: { - provider: { - generateCSRFToken: createMockFn(), - getAuthorizationUrl: createMockFn(), - }, - config: { - provider: 'google', - clientId: 'google-client', - }, - }, - }; - }); - - describe('createOAuthResource', () => { - describe('Debug Mode OFF (Production)', () => { - let resource; - - beforeEach(() => { - resource = createOAuthResource(mockProviders, false, mockHookManager, mockLogger); - }); - - it('should return 404 for root path', async () => { - const result = await resource.get('', {}); - assert.equal(result.status, 404); - assert.equal(result.body.error, 'Not found'); - }); - - it('should return 404 for provider info', async () => { - const result = await resource.get('github', {}); - assert.equal(result.status, 404); - assert.equal(result.body.error, 'Not found'); - }); - - it('should return 404 for test endpoint', async () => { - const result = await resource.get('test', {}); - assert.equal(result.status, 404); - assert.equal(result.body.error, 'Not found'); - }); - - it('should return 404 for provider test endpoint', async () => { - const result = await resource.get('github/test', {}); - assert.equal(result.status, 404); - assert.equal(result.body.error, 'Not found'); - }); - - it('should return 404 for user endpoint', async () => { - const result = await resource.get('github/user', {}); - assert.equal(result.status, 404); - assert.equal(result.body.error, 'Not found'); - }); - - it('should return 404 for refresh endpoint', async () => { - const result = await resource.get('github/refresh', {}); - assert.equal(result.status, 404); - assert.equal(result.body.error, 'Not found'); - }); - - it('should allow login endpoint', async () => { - const result = await resource.get('github/login', { - headers: {}, - session: { id: 'test' }, - }); - // Should not return 404 - assert.notEqual(result.status, 404); - }); - - it('should allow callback endpoint', async () => { - const target = { - id: 'github/callback', - get: () => 'test-code', - }; - const result = await resource.get(target, { - session: { id: 'test' }, - }); - // The actual callback will fail without proper setup, but it shouldn't be 404 - assert.notEqual(result.status, 404); - }); - - it('should handle unknown provider', async () => { - const result = await resource.get('unknown/login', {}); - assert.equal(result.status, 404); - assert.equal(result.body.error, 'Provider not found'); - assert.deepEqual(result.body.available, ['github', 'google']); - }); - }); - - describe('Debug Mode ON', () => { - let resource; - - beforeEach(() => { - resource = createOAuthResource(mockProviders, true, mockHookManager, mockLogger); - }); - - it('should show provider list at root', async () => { - const result = await resource.get('', {}); - assert.equal(result.message, 'OAuth providers'); - assert.ok(Array.isArray(result.providers)); - assert.equal(result.providers.length, 2); - assert.equal(result.providers[0].name, 'github'); - assert.ok(result.providers[0].endpoints); - }); - - it('should show provider info', async () => { - const result = await resource.get('github', {}); - assert.equal(result.message, 'OAuth provider: github'); - assert.equal(result.configured, true); - assert.ok(result.endpoints); - assert.ok(result.endpoints.login); - assert.ok(result.endpoints.callback); - assert.ok(result.endpoints.test); - }); - - it('should allow test endpoint', async () => { - const result = await resource.get('test', {}); - // Test page might fail to load in test env, but shouldn't be 404 - if (result.status === 404) { - assert.fail('Test endpoint should be available in debug mode'); - } - }); - - it('should allow provider test endpoint', async () => { - const result = await resource.get('github/test', {}); - // Test page might fail to load in test env, but shouldn't be 404 - if (result.status === 404) { - assert.fail('Provider test endpoint should be available in debug mode'); - } - }); - - it('should allow user endpoint', async () => { - const result = await resource.get('github/user', { session: {} }); - // Will return 401 without user, but not 404 - assert.notEqual(result.status, 404); - }); - - it('should allow refresh endpoint', async () => { - const result = await resource.get('github/refresh', { session: {} }); - // Will return 401 without refresh token, but not 404 - assert.notEqual(result.status, 404); - }); - - it('should still show available providers for unknown provider', async () => { - const result = await resource.get('unknown/login', {}); - assert.equal(result.status, 404); - assert.equal(result.body.error, 'Provider not found'); - assert.deepEqual(result.body.available, ['github', 'google']); - }); - }); - - describe('Request Target Handling', () => { - let resource; - - beforeEach(() => { - resource = createOAuthResource(mockProviders, false, mockHookManager, mockLogger); - }); - - it('should handle string target', async () => { - const result = await resource.get('github/login', { - headers: {}, - session: { id: 'test' }, - }); - assert.ok(result); - }); - - it('should handle object target with id', async () => { - const target = { - id: 'github/login', - pathname: null, - }; - const result = await resource.get(target, { - headers: {}, - session: { id: 'test' }, - }); - assert.ok(result); - }); - - it('should handle object target with pathname', async () => { - const target = { - id: null, - pathname: 'github/login', - }; - const result = await resource.get(target, { - headers: {}, - session: { id: 'test' }, - }); - assert.ok(result); - }); - - it('should handle target with query params for callback', async () => { - const target = { - id: 'github/callback', - get: createMockFn((key) => { - if (key === 'code') return 'auth-code'; - if (key === 'state') return 'csrf-token'; - return null; - }), - }; - - // Add verifyCSRFToken method to mock provider - mockProviders.github.provider.verifyCSRFToken = createMockFn(async () => null); - - const result = await resource.get(target, { - session: { id: 'test' }, - }); - // Callback will fail without proper setup but should be called - assert.ok(result); - }); - }); - - describe('POST Requests', () => { - let resource; - - beforeEach(() => { - resource = createOAuthResource(mockProviders, false, mockHookManager, mockLogger); - }); - - it('should handle logout POST request', async () => { - const request = { - session: { - user: 'test-user', - update: createMockFn(), - }, - }; - const result = await resource.post('logout', {}, request); - assert.equal(result.status, 200); - assert.equal(result.body.message, 'Logged out successfully'); - }); - - it('should reject non-logout POST requests', async () => { - const result = await resource.post('github/login', {}, {}); - assert.equal(result.status, 404); - assert.equal(result.body.error, 'Not found'); - }); - - it('should handle other POST endpoints', async () => { - const result = await resource.post('github/something', {}, {}); - assert.equal(result.status, 404); - assert.equal(result.body.error, 'Not found'); - }); - }); - }); - - describe('Resource Creation', () => { - it('should create resource with providers', () => { - const resource = createOAuthResource(mockProviders, false, mockHookManager, mockLogger); - assert.ok(resource); - assert.equal(typeof resource.get, 'function'); - assert.equal(typeof resource.post, 'function'); - }); - - it('should create different instances with different configs', () => { - const resource1 = createOAuthResource(mockProviders, false, mockHookManager, mockLogger); - const resource2 = createOAuthResource(mockProviders, true, mockHookManager, mockLogger); - // Each resource is a new object - assert.notEqual(resource1, resource2); - }); - }); -}); diff --git a/test/options-watcher.test.js b/test/options-watcher.test.js index 993bddd..8867d16 100644 --- a/test/options-watcher.test.js +++ b/test/options-watcher.test.js @@ -52,7 +52,13 @@ describe('OAuth Plugin Options Watcher', () => { }, resources: { set(name, resource) { - resources[name] = resource; + // If resource is a class with loadAsInstance = false (Resource API v2), + // instantiate it to match Harper's behavior + if (typeof resource === 'function' && resource.loadAsInstance === false) { + resources[name] = new resource(); + } else { + resources[name] = resource; + } lastResourceSet = { name, resource }; }, }, diff --git a/tsconfig.json b/tsconfig.json index 202f074..e6fc878 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "esModuleInterop": true, "rewriteRelativeImportExtensions": true, "strict": true, + "skipLibCheck": true, "noUnusedLocals": true, "noUnusedParameters": true },