Skip to content

Commit 6367c74

Browse files
committed
test: add unit, functional and integration tests for NameID lookup API
1 parent 8ec4b54 commit 6367c74

6 files changed

Lines changed: 882 additions & 4 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2026 SURFnet B.V.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
declare(strict_types=1);
20+
21+
namespace OpenConext\EngineBlock\Doctrine\Migrations;
22+
23+
use Doctrine\DBAL\Schema\Schema;
24+
25+
/**
26+
* Corrects the column comment on saml_persistent_id.persistent_id.
27+
*
28+
* The original comment read "SHA1 of service_provider_uuid + user_uuid", which was
29+
* inaccurate in two ways: the operand order was wrong, and the COIN: salt was omitted.
30+
* The actual value stored is sha1('COIN:' + user_uuid + service_provider_uuid), as
31+
* defined in EngineBlock_Saml2_NameIdResolver::PERSISTENT_NAMEID_SALT.
32+
*
33+
* NOTE: This migration is NOT mandatory. It only updates a database-level column comment
34+
* and has no effect on data integrity or application behaviour. It is safe to skip on
35+
* existing installations where updating the comment is not considered necessary.
36+
*/
37+
final class Version20260331000000 extends AbstractEngineBlockMigration
38+
{
39+
public function getDescription(): string
40+
{
41+
return 'Corrects the column comment on saml_persistent_id.persistent_id to accurately reflect the SHA1 formula.';
42+
}
43+
44+
public function up(Schema $schema): void
45+
{
46+
$this->addSql(
47+
"ALTER TABLE `saml_persistent_id` MODIFY COLUMN `persistent_id` CHAR(40) NOT NULL COMMENT 'SHA1 of COIN: + user_uuid + service_provider_uuid'"
48+
);
49+
}
50+
51+
public function down(Schema $schema): void
52+
{
53+
$this->addSql(
54+
"ALTER TABLE `saml_persistent_id` MODIFY COLUMN `persistent_id` CHAR(40) NOT NULL COMMENT 'SHA1 of service_provider_uuid + user_uuid'"
55+
);
56+
}
57+
}

src/OpenConext/EngineBlock/Service/NameIdLookupService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?php
22

33
/**
4-
* Copyright 2010 SURFnet B.V.
4+
* Copyright 2026 SURFnet B.V.
55
*
66
* Licensed under the Apache License, Version 2.0 (the "License");
77
* you may not use this file except in compliance with the License.

src/OpenConext/EngineBlockBundle/Controller/Api/UserController.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?php
22

33
/**
4-
* Copyright 2010 SURFnet B.V.
4+
* Copyright 2026 SURFnet B.V.
55
*
66
* Licensed under the Apache License, Version 2.0 (the "License");
77
* you may not use this file except in compliance with the License.
@@ -51,7 +51,7 @@ private function getCallerUsername(): string
5151
return $this->tokenStorage->getToken()?->getUserIdentifier() ?? 'unknown';
5252
}
5353

54-
#[Route(path: '/info/users/nameid', name: 'api_users_nameid', methods: ['POST'], defaults: ['_format' => 'json'])]
54+
#[Route(path: '/info/users/nameid', name: 'api_users_nameid', defaults: ['_format' => 'json'], methods: ['POST'])]
5555
public function nameIdAction(Request $request): JsonResponse
5656
{
5757
if (!$this->featureConfiguration->isEnabled('api.users_nameid')) {
@@ -80,7 +80,7 @@ public function nameIdAction(Request $request): JsonResponse
8080
return new JsonResponse($results, Response::HTTP_OK);
8181
}
8282

83-
#[Route(path: '/info/users/id', name: 'api_users_id', methods: ['POST'], defaults: ['_format' => 'json'])]
83+
#[Route(path: '/info/users/id', name: 'api_users_id', defaults: ['_format' => 'json'], methods: ['POST'])]
8484
public function userIdentityAction(Request $request): JsonResponse
8585
{
8686
if (!$this->featureConfiguration->isEnabled('api.users_id')) {
@@ -101,11 +101,13 @@ public function userIdentityAction(Request $request): JsonResponse
101101
if (!is_string($nameId)) {
102102
throw new BadApiRequestHttpException('Each entry in the request must be a string NameID');
103103
}
104+
104105
if (!preg_match('/^[0-9a-f]{40}$/i', $nameId)) {
105106
throw new BadApiRequestHttpException(
106107
sprintf('Invalid NameID format "%s": must be a 40-character hexadecimal SHA1 string', $nameId)
107108
);
108109
}
110+
109111
$results[] = $this->nameIdLookupService->resolveUserIdentity($nameId);
110112
}
111113

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2026 SURFnet B.V.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
namespace OpenConext\EngineBlockBundle\Tests;
20+
21+
use Doctrine\DBAL\Connection;
22+
use Doctrine\DBAL\Query\QueryBuilder;
23+
use OpenConext\EngineBlock\Authentication\Value\CollabPersonUuid;
24+
use OpenConext\EngineBlockBundle\Authentication\Repository\SamlPersistentIdRepository;
25+
use OpenConext\EngineBlockBundle\Authentication\Repository\ServiceProviderUuidRepository;
26+
use OpenConext\EngineBlockBundle\Authentication\Repository\UserRepository;
27+
use PHPUnit\Framework\Attributes\Group;
28+
use PHPUnit\Framework\Attributes\Test;
29+
use Ramsey\Uuid\Uuid;
30+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
31+
32+
final class NameIdLookupRepositoryTest extends KernelTestCase
33+
{
34+
protected function tearDown(): void
35+
{
36+
$this->clearFixtures();
37+
parent::tearDown();
38+
restore_exception_handler();
39+
}
40+
41+
#[Group('Repository')]
42+
#[Group('NameIdLookup')]
43+
#[Test]
44+
public function find_by_collab_person_uuid_returns_the_correct_user(): void
45+
{
46+
$userUuid = Uuid::uuid4()->toString();
47+
$collabPersonId = 'urn:collab:person:example.edu:' . uniqid();
48+
49+
$this->insertUser($collabPersonId, $userUuid);
50+
51+
$repo = self::getContainer()->get(UserRepository::class);
52+
$user = $repo->findByCollabPersonUuid(new CollabPersonUuid($userUuid));
53+
54+
$this->assertNotNull($user);
55+
$this->assertSame($collabPersonId, $user->collabPersonId->getCollabPersonId());
56+
$this->assertSame($userUuid, $user->collabPersonUuid->getUuid());
57+
}
58+
59+
#[Group('Repository')]
60+
#[Group('NameIdLookup')]
61+
#[Test]
62+
public function find_by_collab_person_uuid_returns_null_when_uuid_is_unknown(): void
63+
{
64+
$repo = self::getContainer()->get(UserRepository::class);
65+
$user = $repo->findByCollabPersonUuid(new CollabPersonUuid('00000000-0000-0000-0000-000000000000'));
66+
67+
$this->assertNull($user);
68+
}
69+
70+
#[Group('Repository')]
71+
#[Group('NameIdLookup')]
72+
#[Test]
73+
public function find_uuid_by_entity_id_returns_the_uuid_for_a_known_sp(): void
74+
{
75+
$spUuid = Uuid::uuid4()->toString();
76+
$spEntityId = 'https://sp-' . uniqid() . '.example.com/';
77+
78+
$this->insertServiceProvider($spUuid, $spEntityId);
79+
80+
$repo = self::getContainer()->get(ServiceProviderUuidRepository::class);
81+
$uuid = $repo->findUuidByEntityId($spEntityId);
82+
83+
$this->assertSame($spUuid, $uuid);
84+
}
85+
86+
#[Group('Repository')]
87+
#[Group('NameIdLookup')]
88+
#[Test]
89+
public function find_uuid_by_entity_id_returns_null_when_sp_is_unknown(): void
90+
{
91+
$repo = self::getContainer()->get(ServiceProviderUuidRepository::class);
92+
$uuid = $repo->findUuidByEntityId('https://unknown-sp.example.com/');
93+
94+
$this->assertNull($uuid);
95+
}
96+
97+
#[Group('Repository')]
98+
#[Group('NameIdLookup')]
99+
#[Test]
100+
public function find_by_user_and_sp_uuid_returns_entry_when_persistent_id_exists(): void
101+
{
102+
$userUuid = Uuid::uuid4()->toString();
103+
$spUuid = Uuid::uuid4()->toString();
104+
$persistentId = sha1('COIN:' . $userUuid . $spUuid);
105+
106+
$this->insertPersistentId($persistentId, $userUuid, $spUuid);
107+
108+
$repo = self::getContainer()->get(SamlPersistentIdRepository::class);
109+
$entry = $repo->findByUserAndSpUuid($userUuid, $spUuid);
110+
111+
$this->assertNotNull($entry);
112+
$this->assertSame($persistentId, $entry->persistentId);
113+
$this->assertSame($userUuid, $entry->userUuid);
114+
$this->assertSame($spUuid, $entry->serviceProviderUuid);
115+
}
116+
117+
#[Group('Repository')]
118+
#[Group('NameIdLookup')]
119+
#[Test]
120+
public function find_by_user_and_sp_uuid_returns_null_when_no_persistent_id_stored(): void
121+
{
122+
$repo = self::getContainer()->get(SamlPersistentIdRepository::class);
123+
$entry = $repo->findByUserAndSpUuid(Uuid::uuid4()->toString(), Uuid::uuid4()->toString());
124+
125+
$this->assertNull($entry);
126+
}
127+
128+
private function insertUser(string $collabPersonId, string $uuid): void
129+
{
130+
$qb = $this->connection()->createQueryBuilder();
131+
assert($qb instanceof QueryBuilder);
132+
$qb->insert('user')
133+
->values(['collab_person_id' => ':collab_person_id', 'uuid' => ':uuid'])
134+
->setParameters(['collab_person_id' => $collabPersonId, 'uuid' => $uuid])
135+
->executeStatement();
136+
}
137+
138+
private function insertServiceProvider(string $uuid, string $entityId): void
139+
{
140+
$qb = $this->connection()->createQueryBuilder();
141+
assert($qb instanceof QueryBuilder);
142+
$qb->insert('service_provider_uuid')
143+
->values(['uuid' => ':uuid', 'service_provider_entity_id' => ':entity_id'])
144+
->setParameters(['uuid' => $uuid, 'entity_id' => $entityId])
145+
->executeStatement();
146+
}
147+
148+
private function insertPersistentId(string $persistentId, string $userUuid, string $spUuid): void
149+
{
150+
$qb = $this->connection()->createQueryBuilder();
151+
assert($qb instanceof QueryBuilder);
152+
$qb->insert('saml_persistent_id')
153+
->values([
154+
'persistent_id' => ':persistent_id',
155+
'user_uuid' => ':user_uuid',
156+
'service_provider_uuid' => ':sp_uuid',
157+
])
158+
->setParameters([
159+
'persistent_id' => $persistentId,
160+
'user_uuid' => $userUuid,
161+
'sp_uuid' => $spUuid,
162+
])
163+
->executeStatement();
164+
}
165+
166+
private function clearFixtures(): void
167+
{
168+
$conn = $this->connection();
169+
$conn->executeStatement('DELETE FROM saml_persistent_id');
170+
$conn->executeStatement('DELETE FROM service_provider_uuid');
171+
$conn->executeStatement('DELETE FROM user');
172+
}
173+
174+
private function connection(): Connection
175+
{
176+
return self::getContainer()->get('doctrine')->getConnection();
177+
}
178+
}

0 commit comments

Comments
 (0)