import { DyNTS_OAuth2_ControlService } from './oauth2.control-service'; import { DyNTS_OAuth2_AuthService } from './oauth2.auth-service'; import { Request, Response } from 'express'; import { DyFM_Error } from '@futdevpro/fsm-dynamo'; import { DyNTS_global_settings } from '../../../_collections/global-settings.const'; describe('| DyNTS_OAuth2_ControlService', () => { let service: DyNTS_OAuth2_ControlService; let mockAuthService: jasmine.SpyObj; let mockRequest: Partial; let mockResponse: Partial; let cryptoJsOrigLib: unknown; beforeAll(() => { const cjs = require('crypto-js'); const ref = (cjs && (cjs as { cryptoJs?: unknown }).cryptoJs) || (cjs && (cjs as { default?: unknown }).default) || cjs; if (ref && typeof ref === 'object') { cryptoJsOrigLib = (ref as { lib?: unknown }).lib; (ref as { lib: unknown }).lib = { WordArray: { random: (_n?: number): { toString: () => string } => ({ toString: (): string => 'mock-token-' + Math.random().toString(36).slice(2, 12), }), }, }; } }); afterAll(() => { if (cryptoJsOrigLib !== undefined) { const cjs = require('crypto-js'); const cryptoJsRef = (cjs && (cjs as { cryptoJs?: { lib?: unknown } }).cryptoJs) || cjs.default || cjs; if (cryptoJsRef && typeof cryptoJsRef === 'object') { (cryptoJsRef as { lib: unknown }).lib = cryptoJsOrigLib; } } }); beforeEach(() => { // Reset singleton instances to prevent state leakage between tests (DyNTS_OAuth2_ControlService as any).instance = undefined; (DyNTS_OAuth2_AuthService as any).instance = undefined; // Mock the AuthService.getInstance() to prevent circular dependency mockAuthService = jasmine.createSpyObj('DyNTS_OAuth2_AuthService', [ 'getTokenFromRequest', 'authenticate_token', ]); spyOn(DyNTS_OAuth2_AuthService, 'getInstance').and.returnValue(mockAuthService); // Now we can safely get the ControlService instance service = DyNTS_OAuth2_ControlService.getInstance(); // Replace the authService with our mock (cryptoJs is patched at module level in beforeAll) (service as any).authService = mockAuthService; mockRequest = { query: {}, body: {}, headers: {}, }; mockResponse = { redirect: jasmine.createSpy('redirect'), json: jasmine.createSpy('json'), status: jasmine.createSpy('status').and.returnValue({ send: jasmine.createSpy('send'), }), }; // Clear all maps before each test (service as any).authorizationCodes.clear(); (service as any).accessTokens.clear(); (service as any).refreshTokens.clear(); (service as any).clients.clear(); (service as any).users.clear(); }); it('| should be a singleton instance', () => { const instance1 = DyNTS_OAuth2_ControlService.getInstance(); const instance2 = DyNTS_OAuth2_ControlService.getInstance(); expect(instance1).toBe(instance2); expect(instance1).toBeInstanceOf(DyNTS_OAuth2_ControlService); }); it('| should have correct service name', () => { expect(service.serviceName).toBe('OAuth2ControlService'); }); describe('| registerClient', () => { it('| should register a new client', () => { const result = service.registerClient( 'client-123', 'secret-123', ['http://localhost:3000/callback'], ['read', 'write'] ); expect(result).toBe(true); }); it('| should not register duplicate client', () => { service.registerClient('client-123', 'secret-123', ['http://localhost:3000/callback'], ['read']); const result = service.registerClient('client-123', 'secret-456', ['http://localhost:3000/callback'], ['write']); expect(result).toBe(false); }); }); describe('| registerUser', () => { it('| should register a new user', () => { const result = service.registerUser('user-123', 'password-123', ['read', 'write']); expect(result).toBe(true); }); it('| should not register duplicate user', () => { service.registerUser('user-123', 'password-123', ['read']); const result = service.registerUser('user-123', 'password-456', ['write']); expect(result).toBe(false); }); }); describe('| handleAuthorizationRequest', () => { beforeEach(() => { service.registerClient( 'client-123', 'secret-123', ['http://localhost:3000/callback'], ['read', 'write'] ); }); it('| should handle authorization code flow', async () => { mockRequest.query = { response_type: 'code', client_id: 'client-123', redirect_uri: 'http://localhost:3000/callback', scope: 'read write', state: 'state-123', }; // Spy to bypass cryptoJs.lib in generateAuthorizationCode (crypto-js nem mockolható ebben a környezetben) spyOn(service as any, 'generateAuthorizationCode').and.returnValue(Promise.resolve('mock-code-123')); await service.handleAuthorizationRequest(mockRequest as Request, mockResponse as Response); expect(mockResponse.redirect).toHaveBeenCalled(); const redirectUrl = (mockResponse.redirect as jasmine.Spy).calls.mostRecent().args[0]; expect(redirectUrl).toContain('http://localhost:3000/callback'); expect(redirectUrl).toContain('code='); expect(redirectUrl).toContain('state=state-123'); }); it('| should handle implicit flow', async () => { mockRequest.query = { response_type: 'token', client_id: 'client-123', redirect_uri: 'http://localhost:3000/callback', scope: 'read', state: 'state-123', }; // Spy to bypass cryptoJs.lib in generateAccessToken (crypto-js nem mockolható ebben a környezetben) spyOn(service as any, 'generateAccessToken').and.returnValue(Promise.resolve('mock-access-token-123')); await service.handleAuthorizationRequest(mockRequest as Request, mockResponse as Response); expect(mockResponse.redirect).toHaveBeenCalled(); const redirectUrl = (mockResponse.redirect as jasmine.Spy).calls.mostRecent().args[0]; expect(redirectUrl).toContain('http://localhost:3000/callback'); expect(redirectUrl).toContain('#access_token='); }); it('| should throw error when missing required parameters', async () => { mockRequest.query = { response_type: 'code', // Missing client_id and redirect_uri }; await expectAsync( service.handleAuthorizationRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when client_id is invalid', async () => { mockRequest.query = { response_type: 'code', client_id: 'invalid-client', redirect_uri: 'http://localhost:3000/callback', }; await expectAsync( service.handleAuthorizationRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when redirect_uri is invalid', async () => { mockRequest.query = { response_type: 'code', client_id: 'client-123', redirect_uri: 'http://evil.com/callback', }; await expectAsync( service.handleAuthorizationRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when scope is invalid', async () => { mockRequest.query = { response_type: 'code', client_id: 'client-123', redirect_uri: 'http://localhost:3000/callback', scope: 'invalid-scope', }; await expectAsync( service.handleAuthorizationRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when response_type is unsupported', async () => { mockRequest.query = { response_type: 'unsupported', client_id: 'client-123', redirect_uri: 'http://localhost:3000/callback', }; await expectAsync( service.handleAuthorizationRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); }); describe('| handleTokenRequest', () => { beforeEach(() => { service.registerClient( 'client-123', 'secret-123', ['http://localhost:3000/callback'], ['read', 'write'] ); // Bypass cryptoJs.lib (crypto-js nem mockolható ebben a környezetben) spyOn(service as any, 'generateAuthorizationCode').and.returnValue(Promise.resolve('mock-code')); spyOn(service as any, 'generateAccessToken').and.returnValue(Promise.resolve('mock-access-token')); spyOn(service as any, 'generateRefreshToken').and.returnValue(Promise.resolve('mock-refresh-token')); }); it('| should handle refresh_token grant type', async () => { // First, get a refresh token const refreshToken = await (service as any).generateRefreshToken('client-123'); (service as any).refreshTokens.set(refreshToken, { clientId: 'client-123', scope: 'read', accessToken: 'old-access-token', }); mockRequest.body = { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: 'client-123', client_secret: 'secret-123', }; mockResponse.json = jasmine.createSpy('json'); await service.handleTokenRequest(mockRequest as Request, mockResponse as Response); expect(mockResponse.json).toHaveBeenCalled(); const response = (mockResponse.json as jasmine.Spy).calls.mostRecent().args[0]; expect(response.access_token).toBeDefined(); expect(response.refresh_token).toBeDefined(); }); it('| should handle client_credentials grant type', async () => { mockRequest.body = { grant_type: 'client_credentials', client_id: 'client-123', client_secret: 'secret-123', }; mockResponse.json = jasmine.createSpy('json'); await service.handleTokenRequest(mockRequest as Request, mockResponse as Response); expect(mockResponse.json).toHaveBeenCalled(); const response = (mockResponse.json as jasmine.Spy).calls.mostRecent().args[0]; expect(response.access_token).toBeDefined(); expect(response.token_type).toBe('Bearer'); }); it('| should handle password grant type', async () => { service.registerUser('user-123', 'password-123', ['read', 'write']); mockRequest.body = { grant_type: 'password', client_id: 'client-123', client_secret: 'secret-123', username: 'user-123', password: 'password-123', }; mockResponse.json = jasmine.createSpy('json'); await service.handleTokenRequest(mockRequest as Request, mockResponse as Response); expect(mockResponse.json).toHaveBeenCalled(); const response = (mockResponse.json as jasmine.Spy).calls.mostRecent().args[0]; expect(response.access_token).toBeDefined(); expect(response.refresh_token).toBeDefined(); }); it('| should throw error when missing required parameters', async () => { mockRequest.body = { grant_type: 'authorization_code', // Missing client_id and client_secret }; await expectAsync( service.handleTokenRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when client credentials are invalid', async () => { mockRequest.body = { grant_type: 'authorization_code', client_id: 'client-123', client_secret: 'wrong-secret', }; await expectAsync( service.handleTokenRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when authorization code is missing', async () => { mockRequest.body = { grant_type: 'authorization_code', client_id: 'client-123', client_secret: 'secret-123', // Missing code }; await expectAsync( service.handleTokenRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when authorization code is invalid', async () => { mockRequest.body = { grant_type: 'authorization_code', code: 'invalid-code', client_id: 'client-123', client_secret: 'secret-123', }; await expectAsync( service.handleTokenRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when refresh token is missing', async () => { mockRequest.body = { grant_type: 'refresh_token', client_id: 'client-123', client_secret: 'secret-123', // Missing refresh_token }; await expectAsync( service.handleTokenRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when refresh token is invalid', async () => { mockRequest.body = { grant_type: 'refresh_token', refresh_token: 'invalid-refresh-token', client_id: 'client-123', client_secret: 'secret-123', }; await expectAsync( service.handleTokenRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when username or password is missing', async () => { mockRequest.body = { grant_type: 'password', client_id: 'client-123', client_secret: 'secret-123', // Missing username and password }; await expectAsync( service.handleTokenRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when user credentials are invalid', async () => { mockRequest.body = { grant_type: 'password', client_id: 'client-123', client_secret: 'secret-123', username: 'user-123', password: 'wrong-password', }; await expectAsync( service.handleTokenRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when grant_type is unsupported', async () => { mockRequest.body = { grant_type: 'unsupported', client_id: 'client-123', client_secret: 'secret-123', }; await expectAsync( service.handleTokenRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); }); describe('| handleUserInfoRequest', () => { beforeEach(() => { service.registerClient( 'client-123', 'secret-123', ['http://localhost:3000/callback'], ['read', 'write'] ); // Don't spy here, let each test set up its own spy }); // Skipped: Token validation fails even with manually created token it('| should return user info for valid token', async () => { // Manually create a token and store it in accessTokens map const accessToken = 'test-access-token-' + Date.now(); const tokenData = { clientId: 'client-123', scope: 'profile email', expiresAt: Date.now() + 3600000, // 1 hour }; (service as any).accessTokens.set(accessToken, tokenData); mockAuthService.getTokenFromRequest.and.returnValue(accessToken); mockResponse.json = jasmine.createSpy('json'); await service.handleUserInfoRequest(mockRequest as Request, mockResponse as Response); expect(mockResponse.json).toHaveBeenCalled(); const response = (mockResponse.json as jasmine.Spy).calls.mostRecent().args[0]; expect(response.sub).toBeDefined(); expect(response.name).toBeDefined(); expect(response.email).toBeDefined(); }); it('| should throw error when token is missing', async () => { mockAuthService.getTokenFromRequest.and.throwError(new Error('Token missing')); await expectAsync( service.handleUserInfoRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when token is invalid', async () => { mockAuthService.getTokenFromRequest.and.returnValue('invalid-token'); await expectAsync( service.handleUserInfoRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when token is expired', async () => { // Manually create an expired token const expiredToken = 'expired-token-' + Date.now(); (service as any).accessTokens.set(expiredToken, { clientId: 'client-123', scope: 'read', expiresAt: Date.now() - 1000, // Expired }); mockAuthService.getTokenFromRequest.and.returnValue(`Bearer ${expiredToken}`); await expectAsync( service.handleUserInfoRequest(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); }); describe('| handleTokenRevocation', () => { beforeEach(() => { spyOn(service as any, 'generateAccessToken').and.returnValue(Promise.resolve('mock-access-token')); spyOn(service as any, 'generateRefreshToken').and.returnValue(Promise.resolve('mock-refresh-token')); }); it('| should revoke access token', async () => { const accessToken = await (service as any).generateAccessToken('client-123', 'read'); (service as any).accessTokens.set(accessToken, { clientId: 'client-123', scope: 'read', expiresAt: Date.now() + 3600000, }); mockRequest.body = { token: accessToken, token_type_hint: 'access_token', }; await service.handleTokenRevocation(mockRequest as Request, mockResponse as Response); expect(mockResponse.status).toHaveBeenCalledWith(200); expect((service as any).accessTokens.has(accessToken)).toBe(false); }); it('| should revoke refresh token and associated access token', async () => { const accessToken = await (service as any).generateAccessToken('client-123', 'read'); (service as any).accessTokens.set(accessToken, { clientId: 'client-123', scope: 'read', expiresAt: Date.now() + 3600000, }); const refreshToken = await (service as any).generateRefreshToken('client-123'); (service as any).refreshTokens.set(refreshToken, { clientId: 'client-123', scope: 'read', accessToken: accessToken, }); mockRequest.body = { token: refreshToken, token_type_hint: 'refresh_token', }; await service.handleTokenRevocation(mockRequest as Request, mockResponse as Response); expect(mockResponse.status).toHaveBeenCalledWith(200); expect((service as any).refreshTokens.has(refreshToken)).toBe(false); expect((service as any).accessTokens.has(accessToken)).toBe(false); }); it('| should throw error when token is missing', async () => { mockRequest.body = {}; await expectAsync( service.handleTokenRevocation(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); it('| should throw error when token is not found', async () => { mockRequest.body = { token: 'non-existent-token', }; await expectAsync( service.handleTokenRevocation(mockRequest as Request, mockResponse as Response) ).toBeRejected(); }); }); describe('| getAccessTokenData', () => { it('| should return access token data when token exists', async () => { // Manually create a token and store it in accessTokens map const accessToken = 'test-access-token-' + Date.now(); const tokenData = { clientId: 'client-123', scope: 'read', expiresAt: Date.now() + 3600000, // 1 hour }; (service as any).accessTokens.set(accessToken, tokenData); const result = service.getAccessTokenData(accessToken); expect(result).toBeDefined(); expect(result?.clientId).toBe('client-123'); expect(result?.scope).toBe('read'); expect(result?.expiresAt).toBeGreaterThan(Date.now()); }); it('| should return undefined when token does not exist', () => { const tokenData = service.getAccessTokenData('non-existent-token'); expect(tokenData).toBeUndefined(); }); }); });