mockService = Mockery::mock(SamlIdpService::class);
$this->controller = new SamlIdpController($this->mockService);
});
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(\Symfony\Component\HttpKernel\Exception\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(\Symfony\Component\HttpKernel\Exception\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(\Symfony\Component\HttpKernel\Exception\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(\Symfony\Component\HttpKernel\Exception\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->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->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('');
});