diff --git a/apps/api/src/app/__snapshots__/app.module.spec.ts.snap b/apps/api/src/app/__snapshots__/app.module.spec.ts.snap new file mode 100644 index 0000000000..34cdbb907a --- /dev/null +++ b/apps/api/src/app/__snapshots__/app.module.spec.ts.snap @@ -0,0 +1,1183 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AppModule should keep the security of each route 1`] = ` +{ + "DELETE /access/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteAccess", + }, + "DELETE /account-balance/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "deleteAccountBalance", + "scopes": [ + "account:update", + ], + }, + "DELETE /account/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "deleteAccount", + "scopes": [ + "account:delete", + ], + }, + "DELETE /activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "deleteActivity", + "scopes": [ + "activity:delete", + ], + }, + "DELETE /activities/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "deleteActivity", + "scopes": [ + "activity:delete", + ], + }, + "DELETE /admin/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /admin/queue/job": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /admin/queue/job/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /asset-profiles/:dataSource/:symbol/splits/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "DELETE /auth-device/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteAuthDevice", + }, + "DELETE /benchmarks/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /platform/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deletePlatform", + }, + "DELETE /tags/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteTag", + }, + "DELETE /user": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + ], + "interceptors": [], + "permission": "deleteOwnUser", + }, + "DELETE /user/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteUser", + }, + "DELETE /watchlist/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "deleteWatchlistItem", + "scopes": [ + "watchlist:delete", + ], + }, + "GET /access": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /account": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "account:read", + ], + }, + "GET /account/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "account:read", + ], + }, + "GET /account/:id/balances": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "account:read", + ], + }, + "GET /activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "activity:read", + ], + }, + "GET /activities/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "activity:read", + ], + }, + "GET /admin": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/demo-user/sync": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "syncDemoUserAccount", + }, + "GET /admin/queue/job": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/queue/job/:id/execute": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/user": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/user/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /ai/prompt/:mode": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "readAiPrompt", + "scopes": [ + "portfolio:read", + ], + }, + "GET /asset-profiles": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /asset-profiles/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /asset/:dataSource/:symbol": { + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /assets/:languageCode/site.webmanifest (version neutral)": { + "guards": [], + "interceptors": [], + }, + "GET /auth/google": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('google')", + ], + "interceptors": [], + }, + "GET /auth/google/callback (version neutral)": { + "allowDuringImpersonation": true, + "guards": [ + "OAuthCallbackGuardMixin('google')", + ], + "interceptors": [], + }, + "GET /auth/oidc (version neutral)": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('oidc')", + ], + "interceptors": [], + }, + "GET /auth/oidc/callback (version neutral)": { + "allowDuringImpersonation": true, + "guards": [ + "OAuthCallbackGuardMixin('oidc')", + ], + "interceptors": [], + }, + "GET /auth/webauthn/generate-registration-options": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /benchmarks": { + "allowDuringImpersonation": true, + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /benchmarks/:dataSource/:symbol/:startDateString": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /data-providers/ghostfolio/asset-profile/:symbol": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/dividends/:symbol (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/historical/:symbol (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/lookup (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/markets": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/quotes (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/status (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /exchange-rate/:symbol/:dateString": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /export": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /health": { + "guards": [], + "interceptors": [], + }, + "GET /health/ai": { + "guards": [], + "interceptors": [], + }, + "GET /health/data-enhancer/:name": { + "guards": [], + "interceptors": [], + }, + "GET /health/data-provider/:dataSource": { + "guards": [], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /health/liveness": { + "guards": [], + "interceptors": [], + }, + "GET /import/dividends/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /info": { + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + }, + "GET /logo": { + "guards": [], + "interceptors": [], + }, + "GET /logo/:dataSource/:symbol": { + "guards": [], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /market-data/markets": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readMarketDataOfMarkets", + }, + "GET /platform": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readPlatformsWithAccountCount", + }, + "GET /platforms": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readPlatforms", + }, + "GET /portfolio/details": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/dividends": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/holding/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/holdings": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/investments": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/performance (version 2)": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "PerformanceLoggingInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/report": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [], + "scopes": [ + "portfolio:read", + ], + }, + "GET /public/:accessId/portfolio": { + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "RedactValuesInResponseInterceptor", + ], + }, + "GET /sitemap.xml (version neutral)": { + "guards": [], + "interceptors": [], + }, + "GET /subscription/stripe/callback": { + "allowDuringImpersonation": true, + "guards": [], + "interceptors": [], + }, + "GET /symbol/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /symbol/:dataSource/:symbol/:dateString": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /symbol/lookup": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + }, + "GET /tags": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readTags", + }, + "GET /user": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "RedactValuesInResponseInterceptor", + ], + }, + "GET /watchlist": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + "permission": "readWatchlist", + "scopes": [ + "watchlist:read", + ], + }, + "PATCH /admin/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "PATCH /asset-profiles/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /access": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "createAccess", + }, + "POST /account": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "createAccount", + "scopes": [ + "account:create", + ], + }, + "POST /account-balance": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "createAccountBalance", + "scopes": [ + "account:update", + ], + }, + "POST /account/transfer-balance": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [], + "permission": "updateAccount", + "scopes": [ + "account:update", + ], + }, + "POST /activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "permission": "createActivity", + "scopes": [ + "activity:create", + ], + }, + "POST /admin/gather": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/:dataSource/:symbol/:dateString": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/max": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/profile-data": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/market-data/:dataSource/:symbol/test": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "accessAdminControl", + }, + "POST /admin/profile-data/:dataSource/:symbol/merge": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "accessAdminControl", + }, + "POST /ai/activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "CustomThrottlerGuard", + ], + "interceptors": [ + "MixinInterceptor", + ], + "permission": "enableGhostfolioAi", + }, + "POST /api-keys": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "createApiKey", + }, + "POST /asset-profiles/:dataSource/:symbol/splits": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "POST /auth/anonymous": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /auth/webauthn/generate-authentication-options": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /auth/webauthn/verify-attestation": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "POST /auth/webauthn/verify-authentication": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /benchmarks": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /cache/flush": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /import": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + "permission": "createActivity", + }, + "POST /market-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [], + }, + "POST /platform": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "createPlatform", + }, + "POST /subscription/redeem-coupon": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "CustomThrottlerGuard", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "POST /subscription/stripe/checkout-session": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "CustomThrottlerGuard", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "POST /tags": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [], + }, + "POST /user": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /user/:id/access-token": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /user/access-token": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updateOwnAccessToken", + }, + "POST /watchlist": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "createWatchlistItem", + "scopes": [ + "watchlist:create", + ], + }, + "PUT /access/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updateAccess", + }, + "PUT /account/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "updateAccount", + "scopes": [ + "account:update", + ], + }, + "PUT /activities/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "permission": "updateActivity", + "scopes": [ + "activity:update", + ], + }, + "PUT /admin/settings/:key": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "PUT /platform/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updatePlatform", + }, + "PUT /portfolio/holding/:dataSource/:symbol/tags": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "updateActivity", + "scopes": [ + "activity:update", + ], + }, + "PUT /tags/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updateTag", + }, + "PUT /user/setting": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + }, +} +`; diff --git a/apps/api/src/app/app.module.spec.ts b/apps/api/src/app/app.module.spec.ts index 976e08393b..f04485ccdd 100644 --- a/apps/api/src/app/app.module.spec.ts +++ b/apps/api/src/app/app.module.spec.ts @@ -1,17 +1,26 @@ +import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; +import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; + import { DynamicModule, ForwardReference, RequestMethod, - Type + Type, + VERSION_NEUTRAL } from '@nestjs/common'; import { GUARDS_METADATA, + INTERCEPTORS_METADATA, METHOD_METADATA, MODULE_METADATA, - PATH_METADATA + PATH_METADATA, + VERSION_METADATA } from '@nestjs/common/constants'; +import { VersionValue } from '@nestjs/common/interfaces'; import { MetadataScanner } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { isUndefined, omitBy } from 'lodash-es'; import { AppModule } from './app.module'; @@ -26,6 +35,13 @@ jest.mock('ai', () => { type ModuleDefinition = DynamicModule | ForwardReference<() => Type> | Promise | Type; +/** + * The strategies of the authentication which the routes apply via + * AuthGuard(). The class of such a guard has a random name, hence the + * snapshot of the routes names it by its strategy + */ +const AUTHENTICATION_STRATEGIES = ['api-key', 'google', 'jwt', 'oidc']; + /** * The routes of the controllers which answer a request without the * authentication of a user or of an API key, sorted by the request method and @@ -98,6 +114,57 @@ async function getControllers( return [...new Set(controllers)]; } +/** + * Gives the guards or the interceptors which the decorators of the controller + * and of the route handler set, in the order in which they run + */ +function getEnhancers(key: string, controller: Type, routeHandler: object) { + return [controller, routeHandler].flatMap((target) => { + return (Reflect.getMetadata(key, target) ?? []) as Type[]; + }); +} + +/** + * Gives the metadata of the route handler or, if it has none, of the + * controller, as Reflector.getAllAndOverride() does + */ +function getMetadataOfRoute( + key: string, + controller: Type, + routeHandler: object +) { + return (Reflect.getMetadata(key, routeHandler) ?? + Reflect.getMetadata(key, controller)) as T | undefined; +} + +/** + * Gives the name of a guard or of an interceptor. The function mixin() + * replaces the name of a class by a random one, hence the name is read from + * the declaration of the class. A guard which extends AuthGuard() is named + * with its strategy + */ +function getName(enhancer: Type) { + const authenticationStrategy = AUTHENTICATION_STRATEGIES.find((strategy) => { + return AuthGuard(strategy) === enhancer; + }); + + if (authenticationStrategy) { + return `AuthGuard('${authenticationStrategy}')`; + } + + const name = /^class\s+([\w$]+)/.exec(enhancer.toString())?.[1]; + + if (!name || name === 'MixinAuthGuard') { + throw new Error('The name of a guard or of an interceptor is unknown'); + } + + const parentStrategy = AUTHENTICATION_STRATEGIES.find((strategy) => { + return Object.getPrototypeOf(enhancer) === AuthGuard(strategy); + }); + + return parentStrategy ? `${name}('${parentStrategy}')` : name; +} + /** * Gives the paths which the decorator of a controller or of a route sets */ @@ -108,14 +175,10 @@ function getPaths(target: object) { } /** - * Gives the routes of the controller which apply neither AuthGuard('api-key') - * nor AuthGuard('jwt'), each as the request method and the path + * Gives the routes of the controller, each as the request method and the path + * with its route handler */ -function getRoutesWithoutAuthentication(controller: Type) { - const authenticationGuards: unknown[] = [ - AuthGuard('api-key'), - AuthGuard('jwt') - ]; +function getRoutes(controller: Type) { const routeHandlersByName = controller.prototype as Record; return new MetadataScanner() @@ -131,19 +194,6 @@ function getRoutesWithoutAuthentication(controller: Type) { return []; } - const guards = [controller, routeHandler].flatMap((target) => { - return (Reflect.getMetadata(GUARDS_METADATA, target) ?? - []) as unknown[]; - }); - - const hasAuthentication = guards.some((guard) => { - return authenticationGuards.includes(guard); - }); - - if (hasAuthentication) { - return []; - } - return getPaths(controller).flatMap((controllerPath) => { return getPaths(routeHandler).map((routePath) => { const path = `${controllerPath}/${routePath}` @@ -151,12 +201,110 @@ function getRoutesWithoutAuthentication(controller: Type) { .filter(Boolean) .join('/'); - return `${RequestMethod[requestMethod]} /${path}`; + return { + routeHandler, + route: `${RequestMethod[requestMethod]} /${path}` + }; }); }); }); } +/** + * Gives the routes of the controller which apply neither AuthGuard('api-key') + * nor AuthGuard('jwt'), each as the request method and the path + */ +function getRoutesWithoutAuthentication(controller: Type) { + const authenticationGuards: unknown[] = [ + AuthGuard('api-key'), + AuthGuard('jwt') + ]; + + return getRoutes(controller) + .filter(({ routeHandler }) => { + const hasAuthentication = getEnhancers( + GUARDS_METADATA, + controller, + routeHandler + ).some((guard) => { + return authenticationGuards.includes(guard); + }); + + return !hasAuthentication; + }) + .map(({ route }) => { + return route; + }); +} + +/** + * Gives the routes of the controller, each as the request method, the path + * and the version with the guards and the interceptors in the order in which + * they run, the permission, the scopes and whether the route stays available + * while an impersonation is active + */ +function getRoutesWithSecurity(controller: Type) { + return getRoutes(controller).map(({ route, routeHandler }) => { + const security = omitBy( + { + allowDuringImpersonation: getMetadataOfRoute( + ALLOW_DURING_IMPERSONATION_KEY, + controller, + routeHandler + ), + guards: getEnhancers(GUARDS_METADATA, controller, routeHandler).map( + (guard) => { + return getName(guard); + } + ), + interceptors: getEnhancers( + INTERCEPTORS_METADATA, + controller, + routeHandler + ).map((interceptor) => { + return getName(interceptor); + }), + // HasPermissionGuard reads the permission of the route handler only + permission: Reflect.getMetadata(HAS_PERMISSION_KEY, routeHandler) as + string | undefined, + scopes: getMetadataOfRoute( + REQUIRES_SCOPE_KEY, + controller, + routeHandler + ) + }, + isUndefined + ); + + return [ + `${route}${getVersionSuffix(controller, routeHandler)}`, + security + ] as const; + }); +} + +/** + * Gives the version of the route as a suffix of its key, which is empty for + * the default version + */ +function getVersionSuffix(controller: Type, routeHandler: object) { + const version = getMetadataOfRoute( + VERSION_METADATA, + controller, + routeHandler + ); + + if (version === undefined) { + return ''; + } + + const versions = [version].flat().map((value) => { + return value === VERSION_NEUTRAL ? 'neutral' : value; + }); + + return ` (version ${versions.join(', ')})`; +} + describe('AppModule', () => { it('should require the authentication for each route which is not public', async () => { const controllers = await getControllers(AppModule); @@ -169,4 +317,22 @@ describe('AppModule', () => { expect(routesWithoutAuthentication).toEqual(PUBLIC_ROUTES); }); + + // An intended change of a route is confirmed by an update of the snapshot + // (--updateSnapshot), hence each change shows in the review + it('should keep the security of each route', async () => { + const controllers = await getControllers(AppModule); + + const routesWithSecurity = controllers.flatMap((controller) => { + return getRoutesWithSecurity(controller); + }); + + const routes = routesWithSecurity.map(([route]) => { + return route; + }); + + // The snapshot is an object, which would hide a route declared twice + expect(new Set(routes).size).toBe(routes.length); + expect(Object.fromEntries(routesWithSecurity)).toMatchSnapshot(); + }); });