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(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',
'nameid_format' => mb_strtolower(SamlNameIdFormatEnum::Persistent->name),
'nameid_source' => SamlNameIdSourceEnum::ImmutableId->value,
],
],
]);
$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 = 'urn:federation:TestApp';
$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 = 'urn:federation:TestApp';
$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
});
test('generated SAML Response signature can be cryptographically verified', function (): void {
$samlResponseBase64 = $this->service->generateResponse(
$this->user,
$this->connectedApp,
'_req_id_123',
'https://test-app.com/saml/acs'
);
$xml = base64_decode($samlResponseBase64, true);
$doc = new DOMDocument();
$doc->preserveWhiteSpace = true;
$doc->loadXML($xml);
$xmlSignature = new \RobRichards\XMLSecLibs\XMLSecurityDSig();
$xmlSignature->idKeys = ['ID'];
// Locate signature node in the document
$sigNode = $xmlSignature->locateSignature($doc);
expect($sigNode)->not->toBeNull();
// Validate reference
$xmlSignature->canonicalizeSignedInfo();
$refValidated = $xmlSignature->validateReference();
expect($refValidated)->toBeTrue();
// Locate the key and load the public key
$publicKey = $xmlSignature->locateKey();
expect($publicKey)->not->toBeNull();
$certPem = File::get(storage_path('app/saml/idp.crt'));
$publicKey->loadKey($certPem, false);
// Verify signature
$result = $xmlSignature->verify($publicKey);
expect($result)->toBe(1);
});