- Implemented `EnumMorphsToOptionsContract` interface to enable enums with options and labels for dropdowns. - Created `SamlNameIdFormatEnum` and `SamlNameIdSourceEnum` for standardized SAML NameID configuration. - Added reusable `SAML Configuration` Blade component for easier integration into connected app forms. - Enabled custom SAML Attribute mapping with dynamic add/remove functionality and validation. - Improved `SamlIdpController` to enforce ACS URL matching and user authorization checks. - Refactored SAML-related tests and added scenarios for role-based SAML access and custom configurations.
297 lines
9.8 KiB
PHP
297 lines
9.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Data\Ui\Dashboard\ServiceAppDto;
|
|
use App\Http\Controllers\SamlIdpController;
|
|
use App\Models\{ConnectedApp, ConnectionStatus, User};
|
|
use App\Services\{SamlIdpService, UserAppAccessService};
|
|
use Exception;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Mockery;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class);
|
|
|
|
beforeEach(function (): void {
|
|
$this->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',
|
|
days_remaining: 300,
|
|
is_warning: 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('<EntityDescriptor></EntityDescriptor>');
|
|
|
|
$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('<EntityDescriptor></EntityDescriptor>');
|
|
});
|
|
|
|
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.');
|
|
});
|