|
|
@@ -17,17 +17,16 @@ final class AuthEndpointsTest extends AppTestCase
|
|
|
public function testUpsertLocalCreatesUserOnFirstCall(): void
|
|
|
{
|
|
|
$token = $this->createToken(TokenKind::Service);
|
|
|
- $adminId = $this->createUser(Role::Admin, isLocal: true);
|
|
|
|
|
|
+ // No local row exists yet; the first upsert mints it.
|
|
|
$response = $this->request(
|
|
|
'POST',
|
|
|
'/api/v1/auth/users/upsert-local',
|
|
|
[
|
|
|
'Authorization' => 'Bearer ' . $token,
|
|
|
- 'X-Acting-User-Id' => (string) $adminId,
|
|
|
'Content-Type' => 'application/json',
|
|
|
],
|
|
|
- json_encode(['username' => 'second']) ?: null
|
|
|
+ json_encode(['username' => 'admin']) ?: null
|
|
|
);
|
|
|
self::assertSame(200, $response->getStatusCode());
|
|
|
|
|
|
@@ -35,8 +34,76 @@ final class AuthEndpointsTest extends AppTestCase
|
|
|
self::assertIsInt($body['user_id']);
|
|
|
self::assertSame('admin', $body['role']);
|
|
|
self::assertNull($body['email']);
|
|
|
- self::assertSame('second', $body['display_name']);
|
|
|
+ self::assertSame('admin', $body['display_name']);
|
|
|
self::assertTrue($body['is_local']);
|
|
|
+
|
|
|
+ self::assertSame(
|
|
|
+ 1,
|
|
|
+ (int) $this->db->fetchOne('SELECT COUNT(*) FROM users WHERE is_local = 1'),
|
|
|
+ 'first call must create exactly one local row'
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F3 regression. A service-token holder must not be able
|
|
|
+ * to mint additional Admin user rows by rotating the `username` field
|
|
|
+ * over `POST /api/v1/auth/users/upsert-local`. Before the fix every
|
|
|
+ * fresh username produced a new is_local=1, role=admin row that the
|
|
|
+ * caller could then impersonate via X-Acting-User-Id.
|
|
|
+ */
|
|
|
+ public function testRotatingUsernamesNeverCreatesAdditionalLocalAdmins(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
+ $adminId = $this->createUser(Role::Admin, isLocal: true);
|
|
|
+
|
|
|
+ $headers = [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ];
|
|
|
+
|
|
|
+ $userIds = [];
|
|
|
+ foreach (['attacker-1', 'attacker-2', 'attacker-3'] as $name) {
|
|
|
+ $body = $this->decode($this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/auth/users/upsert-local',
|
|
|
+ $headers,
|
|
|
+ json_encode(['username' => $name]) ?: null
|
|
|
+ ));
|
|
|
+ $userIds[] = $body['user_id'];
|
|
|
+ self::assertSame($adminId, $body['user_id']);
|
|
|
+ self::assertSame('admin', $body['role']);
|
|
|
+ self::assertSame($name, $body['display_name']);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Same user_id returned every call.
|
|
|
+ self::assertSame([$adminId, $adminId, $adminId], $userIds);
|
|
|
+
|
|
|
+ // And only one is_local=1 row in the DB.
|
|
|
+ self::assertSame(
|
|
|
+ 1,
|
|
|
+ (int) $this->db->fetchOne('SELECT COUNT(*) FROM users WHERE is_local = 1'),
|
|
|
+ 'rotating usernames must not mint additional local-admin rows'
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Defense-in-depth: even if application code regresses, the partial
|
|
|
+ * unique index added in 20260504100000_add_unique_local_user_index
|
|
|
+ * must reject a second is_local=1 insert.
|
|
|
+ */
|
|
|
+ public function testDbLayerRejectsSecondLocalAdminInsert(): void
|
|
|
+ {
|
|
|
+ $this->createUser(Role::Admin, isLocal: true);
|
|
|
+
|
|
|
+ $this->expectException(\Doctrine\DBAL\Exception\UniqueConstraintViolationException::class);
|
|
|
+
|
|
|
+ $this->db->insert('users', [
|
|
|
+ 'subject' => null,
|
|
|
+ 'email' => null,
|
|
|
+ 'display_name' => 'second-local',
|
|
|
+ 'role' => Role::Admin->value,
|
|
|
+ 'is_local' => 1,
|
|
|
+ ]);
|
|
|
}
|
|
|
|
|
|
public function testUpsertLocalIsIdempotent(): void
|