singleloginsystem/tests/Unit/SamlIdpServiceTest.php
= 57f0a4590c Feat: Add comprehensive support for SAML SSO integration
- Introduced `ddev` configuration for Laravel-based SAML SSO setup.
- Added detailed technical specifications for SAML 2.0 integration, including endpoints, flows, and cryptographic signing.
- Created extensive unit tests to validate `SamlIdpController` and `SamlIdpService` functionalities.
- Enhanced metadata generation, SAML request parsing, and cryptographic signing of responses.
- Implemented models, services, and tests to standardize IdP interactions with Service Providers.
2026-06-01 11:04:16 +00:00

123 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Unit;
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionProvider, ConnectionStatus, User};
use App\Services\SamlIdpService;
use Exception;
use Illuminate\Support\Facades\File;
beforeEach(function (): void {
$this->service = new SamlIdpService();
// Ensure cryptographic keys exist for the tests
$directory = storage_path('app/saml');
if (! File::exists($directory)) {
File::makeDirectory($directory, 0755, true);
}
if (! File::exists("{$directory}/idp.key") || ! File::exists("{$directory}/idp.crt")) {
$this->artisan('saml:generate-keys');
}
$this->seed(\Database\Seeders\DatabaseSeeder::class);
$this->protocol = ConnectionProtocol::query()->where('name', 'saml')->firstOrFail();
$this->provider = ConnectionProvider::query()->where('slug', 'azure-fd')->firstOrFail();
$this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail();
$this->connectedApp = ConnectedApp::query()->create([
'name' => 'Test SAML App',
'slug' => 'test-saml-app',
'connection_protocol_id' => $this->protocol->id,
'connection_provider_id' => $this->provider->id,
'connection_status_id' => $this->status->id,
'settings' => [
'saml' => [
'entity_id' => 'urn:federation:TestApp',
'acs_url' => 'https://test-app.com/saml/acs',
],
],
]);
$this->user = User::factory()->create([
'email' => 'unituser@sso.local',
'immutable_id' => 'user-unit-test-123',
]);
});
test('parseRequest correctly decodes and parses deflated GET SAMLRequest', function (): void {
$xml = '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_req_id" Version="2.0" AssertionConsumerServiceURL="https://test-app.com/saml/acs"><saml:Issuer>urn:federation:TestApp</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode(gzdeflate($xml));
$parsed = $this->service->parseRequest($samlRequest);
expect($parsed)->toBeArray()
->and($parsed['requestId'])->toBe('_req_id')
->and($parsed['issuer'])->toBe('urn:federation:TestApp')
->and($parsed['acsUrl'])->toBe('https://test-app.com/saml/acs');
});
test('parseRequest correctly decodes and parses raw POST SAMLRequest', function (): void {
$xml = '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_req_id_post" Version="2.0" AssertionConsumerServiceURL="https://test-app.com/saml/acs"><saml:Issuer>urn:federation:TestApp</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode($xml);
$parsed = $this->service->parseRequest($samlRequest);
expect($parsed)->toBeArray()
->and($parsed['requestId'])->toBe('_req_id_post')
->and($parsed['issuer'])->toBe('urn:federation:TestApp')
->and($parsed['acsUrl'])->toBe('https://test-app.com/saml/acs');
});
test('parseRequest throws exception on invalid XML or encoding', function (): void {
expect(fn () => $this->service->parseRequest('invalid-base64-string!@#'))
->toThrow(Exception::class);
});
test('findConnectedApp resolves matched active SAML ConnectedApp model', function (): void {
$found = $this->service->findConnectedApp('urn:federation:TestApp');
expect($found)->not->toBeNull()
->and($found->id)->toBe($this->connectedApp->id)
->and($found->name)->toBe('Test SAML App');
});
test('findConnectedApp returns null if SP EntityID is unregistered', function (): void {
$found = $this->service->findConnectedApp('urn:unregistered-entity');
expect($found)->toBeNull();
});
test('generateMetadata returns valid XML schema with certificate', function (): void {
$xml = $this->service->generateMetadata('https://sso.local/metadata', 'https://sso.local/sso');
expect($xml)->toContain('EntityDescriptor')
->toContain('entityID="https://sso.local/metadata"')
->toContain('Location="https://sso.local/sso"')
->toContain('X509Certificate');
});
test('generateResponse builds cryptographically signed SAML Response', function (): void {
$samlResponseBase64 = $this->service->generateResponse(
$this->user,
$this->connectedApp,
'_req_id_123',
'https://test-app.com/saml/acs'
);
expect($samlResponseBase64)->toBeString();
$xml = base64_decode($samlResponseBase64, true);
expect($xml)->toContain('samlp:Response')
->toContain('InResponseTo="_req_id_123"')
->toContain('Destination="https://test-app.com/saml/acs"')
->toContain('saml:Assertion')
->toContain('Signature') // Cryptographically signed
->toContain('user-unit-test-123') // NameID (Immutable ID)
->toContain('unituser@sso.local'); // User email in claims
});