mockService = Mockery::mock(SamlIdpService::class); $this->mockAccessService = Mockery::mock(UserAppAccessService::class); $this->controller = new SamlIdpController($this->mockService, $this->mockAccessService); }); afterEach(function (): void { Mockery::close(); }); test('sso throws 400 bad request when SAMLRequest is empty', function (): void { $request = Request::create('/saml/sso', 'GET'); expect(fn () => $this->controller->sso($request)) ->toThrow(HttpException::class, 'Missing SAMLRequest parameter.'); }); test('sso throws 400 bad request when SAMLRequest fails to parse', function (): void { $request = Request::create('/saml/sso', 'GET', ['SAMLRequest' => 'invalid-base64']); $this->mockService->shouldReceive('parseRequest') ->once() ->with('invalid-base64') ->andThrow(new Exception('SAML XML parsing error.')); expect(fn () => $this->controller->sso($request)) ->toThrow(HttpException::class, 'Invalid SAMLRequest: SAML XML parsing error.'); }); test('sso throws 403 forbidden when Service Provider is unregistered', function (): void { $request = Request::create('/saml/sso', 'GET', ['SAMLRequest' => 'valid-request']); $this->mockService->shouldReceive('parseRequest') ->once() ->with('valid-request') ->andReturn([ 'requestId' => '_req_1', 'issuer' => 'urn:unregistered', 'acsUrl' => 'https://acs.url', ]); $this->mockService->shouldReceive('findConnectedApp') ->once() ->with('urn:unregistered') ->andReturn(null); expect(fn () => $this->controller->sso($request)) ->toThrow(HttpException::class, 'The Service Provider [urn:unregistered] is not authorized in this system.'); }); test('sso throws 403 forbidden when Service Provider is registered but disconnected', function (): void { $request = Request::create('/saml/sso', 'GET', ['SAMLRequest' => 'valid-request']); $status = new ConnectionStatus(); $status->name = 'disconnected'; $app = new ConnectedApp(); $app->name = 'Test Disconnected App'; $app->setRelation('status', $status); $this->mockService->shouldReceive('parseRequest') ->once() ->with('valid-request') ->andReturn([ 'requestId' => '_req_1', 'issuer' => 'urn:disconnected', 'acsUrl' => 'https://acs.url', ]); $this->mockService->shouldReceive('findConnectedApp') ->once() ->with('urn:disconnected') ->andReturn($app); expect(fn () => $this->controller->sso($request)) ->toThrow(HttpException::class, 'The Service Provider [Test Disconnected App] is currently deactivated.'); }); test('sso redirects guest user to login page and stores SAML parameters in session', function (): void { $request = Request::create('/saml/sso', 'GET', [ 'SAMLRequest' => 'valid-request', 'RelayState' => 'https://relay.state', ]); $status = new ConnectionStatus(); $status->name = 'connected'; $app = new ConnectedApp(); $app->name = 'Active SAML App'; $app->setRelation('status', $status); $app->settings = ['saml' => ['acs_url' => 'https://acs.url']]; $this->mockService->shouldReceive('parseRequest') ->once() ->with('valid-request') ->andReturn([ 'requestId' => '_req_1', 'issuer' => 'urn:active', 'acsUrl' => 'https://acs.url', ]); $this->mockService->shouldReceive('findConnectedApp') ->once() ->with('urn:active') ->andReturn($app); // Mock guest Auth state Auth::shouldReceive('check')->once()->andReturn(false); $response = $this->controller->sso($request); expect($response->isRedirect(route('login')))->toBeTrue() ->and(session('saml_pending_request'))->toBe('valid-request') ->and(session('saml_pending_relay_state'))->toBe('https://relay.state'); }); test('sso generates SAML Response and auto-submitting POST view for authenticated user', function (): void { $request = Request::create('/saml/sso', 'GET', [ 'SAMLRequest' => 'valid-request', 'RelayState' => 'https://relay.state', ]); $status = new ConnectionStatus(); $status->name = 'connected'; $app = new ConnectedApp(); $app->id = 1; $app->name = 'Active SAML App'; $app->setRelation('status', $status); $app->settings = ['saml' => ['acs_url' => 'https://acs.url']]; $user = new User(); $user->email = 'activeuser@sso.local'; $this->mockService->shouldReceive('parseRequest') ->once() ->with('valid-request') ->andReturn([ 'requestId' => '_req_1', 'issuer' => 'urn:active', 'acsUrl' => 'https://acs.url', ]); $this->mockService->shouldReceive('findConnectedApp') ->once() ->with('urn:active') ->andReturn($app); // Mock authenticated Auth state Auth::shouldReceive('check')->once()->andReturn(true); Auth::shouldReceive('user')->once()->andReturn($user); $this->mockAccessService->shouldReceive('getActiveServices') ->once() ->with($user) ->andReturn(collect([ new ServiceAppDto( id: 1, name: 'Active SAML App', slug: 'active-saml-app', duration: '2026-12-31', daysRemaining: 300, isWarning: false ), ])); $this->mockService->shouldReceive('generateResponse') ->once() ->with($user, $app, '_req_1', 'https://acs.url') ->andReturn('base64-signed-saml-response-xml-payload'); // Populate session to assert it gets cleared session([ 'saml_pending_request' => 'valid-request', 'saml_pending_relay_state' => 'https://relay.state', ]); $view = $this->controller->sso($request); expect($view->name())->toBe('saml.post_response') ->and($view->getData()['appName'])->toBe('Active SAML App') ->and($view->getData()['acsUrl'])->toBe('https://acs.url') ->and($view->getData()['samlResponse'])->toBe('base64-signed-saml-response-xml-payload') ->and($view->getData()['relayState'])->toBe('https://relay.state') ->and(session('saml_pending_request'))->toBeNull() ->and(session('saml_pending_relay_state'))->toBeNull(); }); test('metadata returns XML response containing clean endpoints', function (): void { $this->mockService->shouldReceive('generateMetadata') ->once() ->with(route('saml.metadata'), route('saml.sso')) ->andReturn(''); $response = $this->controller->metadata(); expect($response->getStatusCode())->toBe(200) ->and($response->headers->get('Content-Type'))->toBe('application/xml; charset=utf-8') ->and($response->getContent())->toBe(''); }); test('sso throws 400 bad request when request ACS URL does not match configured ACS URL', function (): void { $request = Request::create('/saml/sso', 'GET', [ 'SAMLRequest' => 'valid-request', ]); $status = new ConnectionStatus(); $status->name = 'connected'; $app = new ConnectedApp(); $app->id = 1; $app->name = 'Active SAML App'; $app->setRelation('status', $status); $app->settings = ['saml' => ['acs_url' => 'https://configured.url']]; $this->mockService->shouldReceive('parseRequest') ->once() ->with('valid-request') ->andReturn([ 'requestId' => '_req_1', 'issuer' => 'urn:active', 'acsUrl' => 'https://mismatching.url', ]); $this->mockService->shouldReceive('findConnectedApp') ->once() ->with('urn:active') ->andReturn($app); expect(fn () => $this->controller->sso($request)) ->toThrow(HttpException::class, 'The Assertion Consumer Service (ACS) URL does not match the configured callback URL.'); }); test('sso throws 403 forbidden when authenticated user is not authorized for the app', function (): void { $request = Request::create('/saml/sso', 'GET', [ 'SAMLRequest' => 'valid-request', ]); $status = new ConnectionStatus(); $status->name = 'connected'; $app = new ConnectedApp(); $app->id = 1; $app->name = 'Active SAML App'; $app->setRelation('status', $status); $app->settings = ['saml' => ['acs_url' => 'https://acs.url']]; $user = new User(); $user->email = 'unauthorized@sso.local'; $this->mockService->shouldReceive('parseRequest') ->once() ->with('valid-request') ->andReturn([ 'requestId' => '_req_1', 'issuer' => 'urn:active', 'acsUrl' => 'https://acs.url', ]); $this->mockService->shouldReceive('findConnectedApp') ->once() ->with('urn:active') ->andReturn($app); // Mock authenticated Auth state Auth::shouldReceive('check')->once()->andReturn(true); Auth::shouldReceive('user')->once()->andReturn($user); // Mock UserAppAccessService to return empty services $this->mockAccessService->shouldReceive('getActiveServices') ->once() ->with($user) ->andReturn(collect()); expect(fn () => $this->controller->sso($request)) ->toThrow(HttpException::class, 'You are not authorized to access this application.'); });