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.');
});