Files
breadcrumb-the-shire/tests/Service/Auth/ApiTokenServiceTest.php
fs 07fbb96246 test: add PHPUnit coverage for 7 critical Tier 1 services (GR-TEST-001)
199 new tests across 9 files covering auth, user lifecycle, and settings:
- ApiTokenService: create/validate/rotate/revoke, timing-safe hash, tenant scope
- ApiTokenEndpointService: pagination, scope filtering, tenant resolution, expiry
- SsoUserLinkService: 2-phase identity lookup, provisioning, profile+avatar sync
- UserAssignmentService: sync methods, assignable-role freeze, transactions
- UserLifecycleAuditService: encrypted snapshots, enum normalization, retention
- UserLifecycleRestoreService: full restore flow, 9 error exits, rollback
- AdminSettingsService: API/lifecycle, Microsoft/telemetry, color/SMTP validation

Workflow: TEST-TIER1-001 (Analyst → Planner → Executor → Code Review → Security Review → Acceptance → Finalizer — all pass)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:29:07 +01:00

820 lines
30 KiB
PHP

<?php
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Repository\Auth\ApiTokenRepositoryInterface;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Auth\ApiTokenService;
use MintyPHP\Service\Auth\AuthSettingsGateway;
use MintyPHP\Service\Tenant\TenantScopeService;
use PHPUnit\Framework\TestCase;
class ApiTokenServiceTest extends TestCase
{
// ---------------------------------------------------------------
// create()
// ---------------------------------------------------------------
public function testCreateReturnsNameRequiredWhenNameIsBlank(): void
{
$service = $this->newService();
$result = $service->create(1, ' ');
$this->assertFalse($result['ok']);
$this->assertSame('name_required', $result['error']);
}
public function testCreateReturnsInvalidUserWhenUserIdIsZero(): void
{
$service = $this->newService();
$result = $service->create(0, 'My Token');
$this->assertFalse($result['ok']);
$this->assertSame('invalid_user', $result['error']);
}
public function testCreateReturnsInvalidUserWhenUserIdIsNegative(): void
{
$service = $this->newService();
$result = $service->create(-5, 'My Token');
$this->assertFalse($result['ok']);
$this->assertSame('invalid_user', $result['error']);
}
public function testCreateReturnsUserInactiveWhenUserNotFound(): void
{
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->expects($this->any())->method('find')->with(42)->willReturn(null);
$service = $this->newService(userReadRepository: $userRepo);
$result = $service->create(42, 'Token');
$this->assertFalse($result['ok']);
$this->assertSame('user_inactive', $result['error']);
}
public function testCreateReturnsUserInactiveWhenUserIsInactive(): void
{
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->expects($this->any())->method('find')->with(42)->willReturn(['id' => 42, 'active' => 0]);
$service = $this->newService(userReadRepository: $userRepo);
$result = $service->create(42, 'Token');
$this->assertFalse($result['ok']);
$this->assertSame('user_inactive', $result['error']);
}
public function testCreateReturnsMaxTokensReachedWhenLimitHit(): void
{
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]);
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->expects($this->any())->method('countActiveForUser')->with(1)->willReturn(10);
$service = $this->newService(userReadRepository: $userRepo, apiTokenRepository: $tokenRepo);
$result = $service->create(1, 'Token');
$this->assertFalse($result['ok']);
$this->assertSame('max_tokens_reached', $result['error']);
}
public function testCreateReturnsTenantNotAssignedWhenUserLacksTenant(): void
{
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]);
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('countActiveForUser')->willReturn(0);
$scopeGateway = $this->createMock(TenantScopeService::class);
$scopeGateway->expects($this->any())->method('getUserTenantIds')->with(1)->willReturn([2, 3]);
$service = $this->newService(
userReadRepository: $userRepo,
apiTokenRepository: $tokenRepo,
scopeGateway: $scopeGateway
);
$result = $service->create(1, 'Token', tenantId: 99);
$this->assertFalse($result['ok']);
$this->assertSame('tenant_not_assigned', $result['error']);
}
public function testCreateReturnsInvalidExpiresAtForPastDate(): void
{
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]);
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('countActiveForUser')->willReturn(0);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(90);
$service = $this->newService(
userReadRepository: $userRepo,
apiTokenRepository: $tokenRepo,
settingsGateway: $settingsGateway
);
$result = $service->create(1, 'Token', expiresAt: '2020-01-01 00:00:00');
$this->assertFalse($result['ok']);
$this->assertSame('invalid_expires_at', $result['error']);
}
public function testCreateReturnsCreateFailedWhenRepositoryReturnsNull(): void
{
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]);
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('countActiveForUser')->willReturn(0);
$tokenRepo->method('create')->willReturn(null);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(365);
$settingsGateway->method('getApiTokenDefaultTtlDays')->willReturn(30);
$service = $this->newService(
userReadRepository: $userRepo,
apiTokenRepository: $tokenRepo,
settingsGateway: $settingsGateway
);
$result = $service->create(1, 'Token');
$this->assertFalse($result['ok']);
$this->assertSame('create_failed', $result['error']);
}
public function testCreateReturnsCreateFailedWhenFindAfterInsertFails(): void
{
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]);
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('countActiveForUser')->willReturn(0);
$tokenRepo->method('create')->willReturn(55);
$tokenRepo->expects($this->any())->method('find')->with(55)->willReturn(null);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(365);
$settingsGateway->method('getApiTokenDefaultTtlDays')->willReturn(30);
$service = $this->newService(
userReadRepository: $userRepo,
apiTokenRepository: $tokenRepo,
settingsGateway: $settingsGateway
);
$result = $service->create(1, 'Token');
$this->assertFalse($result['ok']);
$this->assertSame('create_failed', $result['error']);
}
public function testCreateSuccessReturnsTokenAndId(): void
{
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]);
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('countActiveForUser')->willReturn(0);
$tokenRepo->method('create')->willReturn(99);
$tokenRepo->expects($this->any())->method('find')->with(99)->willReturn(['id' => 99, 'uuid' => 'abc-uuid-123']);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(365);
$settingsGateway->method('getApiTokenDefaultTtlDays')->willReturn(30);
$service = $this->newService(
userReadRepository: $userRepo,
apiTokenRepository: $tokenRepo,
settingsGateway: $settingsGateway
);
$result = $service->create(1, 'My API Token');
$this->assertTrue($result['ok']);
$this->assertSame(99, $result['id']);
$this->assertSame('abc-uuid-123', $result['uuid']);
$this->assertStringContainsString(':', $result['token']);
$this->assertNotEmpty($result['expires_at']);
// Selector is 24-char hex, plain token is 64-char hex
[$selector, $plain] = explode(':', $result['token'], 2);
$this->assertSame(24, strlen($selector));
$this->assertSame(64, strlen($plain));
}
public function testCreateWithTenantSucceedsWhenUserHasTenant(): void
{
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]);
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('countActiveForUser')->willReturn(0);
$tokenRepo->method('create')->willReturn(100);
$tokenRepo->expects($this->any())->method('find')->with(100)->willReturn(['id' => 100, 'uuid' => 'uuid-t']);
$scopeGateway = $this->createMock(TenantScopeService::class);
$scopeGateway->expects($this->any())->method('getUserTenantIds')->with(1)->willReturn([5, 10]);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(365);
$settingsGateway->method('getApiTokenDefaultTtlDays')->willReturn(30);
$service = $this->newService(
userReadRepository: $userRepo,
apiTokenRepository: $tokenRepo,
settingsGateway: $settingsGateway,
scopeGateway: $scopeGateway
);
$result = $service->create(1, 'Tenant Token', tenantId: 5);
$this->assertTrue($result['ok']);
}
// ---------------------------------------------------------------
// validate()
// ---------------------------------------------------------------
public function testValidateReturnsNullForEmptyToken(): void
{
$service = $this->newService();
$this->assertNull($service->validate(''));
}
public function testValidateReturnsNullForMalformedBearerWithoutColon(): void
{
$service = $this->newService();
$this->assertNull($service->validate('no-colon-here'));
}
public function testValidateReturnsNullWhenSelectorIsEmpty(): void
{
$service = $this->newService();
$this->assertNull($service->validate(':plaintoken'));
}
public function testValidateReturnsNullWhenPlainTokenIsEmpty(): void
{
$service = $this->newService();
$this->assertNull($service->validate('selector:'));
}
public function testValidateReturnsNullWhenSelectorNotFoundPerformsDummyHash(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->expects($this->any())->method('findBySelector')->with('abc123')->willReturn(null);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$this->assertNull($service->validate('abc123:sometoken'));
}
public function testValidateReturnsNullWhenTokenIsRevoked(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 1,
'user_id' => 5,
'revoked_at' => '2024-01-01 00:00:00',
'expires_at' => null,
'token_hash' => hash('sha256', 'secret'),
]);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$this->assertNull($service->validate('sel:secret'));
}
public function testValidateReturnsNullWhenTokenIsExpired(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 1,
'user_id' => 5,
'revoked_at' => null,
'expires_at' => '2020-01-01 00:00:00',
'token_hash' => hash('sha256', 'secret'),
]);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$this->assertNull($service->validate('sel:secret'));
}
public function testValidateReturnsNullWhenHashDoesNotMatch(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 1,
'user_id' => 5,
'revoked_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600),
'token_hash' => hash('sha256', 'correct-secret'),
]);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$this->assertNull($service->validate('sel:wrong-secret'));
}
public function testValidateReturnsNullWhenUserIsInactive(): void
{
$plainToken = 'mytesttoken123';
$tokenHash = hash('sha256', $plainToken);
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 1,
'user_id' => 5,
'revoked_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600),
'token_hash' => $tokenHash,
]);
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->expects($this->any())->method('find')->with(5)->willReturn(['id' => 5, 'active' => 0]);
$service = $this->newService(userReadRepository: $userRepo, apiTokenRepository: $tokenRepo);
$this->assertNull($service->validate('sel:' . $plainToken));
}
public function testValidateReturnsNullWhenUserNotFound(): void
{
$plainToken = 'mytesttoken456';
$tokenHash = hash('sha256', $plainToken);
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 1,
'user_id' => 5,
'revoked_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600),
'token_hash' => $tokenHash,
]);
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->expects($this->any())->method('find')->with(5)->willReturn(null);
$service = $this->newService(userReadRepository: $userRepo, apiTokenRepository: $tokenRepo);
$this->assertNull($service->validate('sel:' . $plainToken));
}
public function testValidateSuccessReturnsUserAndTokenAndUpdatesLastUsed(): void
{
$plainToken = 'validplaintoken789';
$tokenHash = hash('sha256', $plainToken);
$tokenRecord = [
'id' => 10,
'user_id' => 7,
'revoked_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400),
'token_hash' => $tokenHash,
'tenant_id' => 3,
];
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->expects($this->any())->method('findBySelector')->with('mysel')->willReturn($tokenRecord);
$tokenRepo->expects($this->once())
->method('updateLastUsed')
->with(10, '192.168.1.1');
$userRecord = ['id' => 7, 'active' => 1, 'email' => 'user@test.com'];
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->expects($this->any())->method('find')->with(7)->willReturn($userRecord);
$requestRuntime = $this->makeRequestRuntime('192.168.1.1');
$service = $this->newService(
userReadRepository: $userRepo,
apiTokenRepository: $tokenRepo,
requestRuntime: $requestRuntime
);
$result = $service->validate('mysel:' . $plainToken);
$this->assertNotNull($result);
$this->assertSame($userRecord, $result['user']);
$this->assertSame($tokenRecord, $result['token_record']);
$this->assertSame(3, $result['tenant_id']);
}
public function testValidateSuccessWithNullTenantId(): void
{
$plainToken = 'notenant123';
$tokenHash = hash('sha256', $plainToken);
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 11,
'user_id' => 8,
'revoked_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400),
'token_hash' => $tokenHash,
'tenant_id' => null,
]);
$tokenRepo->expects($this->once())->method('updateLastUsed');
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->expects($this->any())->method('find')->with(8)->willReturn(['id' => 8, 'active' => 1]);
$service = $this->newService(
userReadRepository: $userRepo,
apiTokenRepository: $tokenRepo,
requestRuntime: $this->makeRequestRuntime('10.0.0.1')
);
$result = $service->validate('sel:' . $plainToken);
$this->assertNotNull($result);
$this->assertNull($result['tenant_id']);
}
// ---------------------------------------------------------------
// rotateCurrentToken()
// ---------------------------------------------------------------
public function testRotateReturnsNotFoundWhenUserIdIsZero(): void
{
$service = $this->newService();
$result = $service->rotateCurrentToken(0, 1);
$this->assertFalse($result['ok']);
$this->assertSame('current_token_not_found', $result['error']);
}
public function testRotateReturnsNotFoundWhenTokenIdIsZero(): void
{
$service = $this->newService();
$result = $service->rotateCurrentToken(1, 0);
$this->assertFalse($result['ok']);
$this->assertSame('current_token_not_found', $result['error']);
}
public function testRotateReturnsNotFoundWhenTokenDoesNotExist(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('find')->willReturn(null);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$result = $service->rotateCurrentToken(1, 99);
$this->assertFalse($result['ok']);
$this->assertSame('current_token_not_found', $result['error']);
}
public function testRotateReturnsNotFoundWhenTokenBelongsToDifferentUser(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('find')->willReturn(['id' => 5, 'user_id' => 99, 'revoked_at' => null, 'expires_at' => null]);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$result = $service->rotateCurrentToken(1, 5);
$this->assertFalse($result['ok']);
$this->assertSame('current_token_not_found', $result['error']);
}
public function testRotateReturnsInvalidWhenTokenIsRevoked(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('find')->willReturn([
'id' => 5,
'user_id' => 1,
'revoked_at' => '2024-01-01 00:00:00',
'expires_at' => null,
'name' => 'Test',
]);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$result = $service->rotateCurrentToken(1, 5);
$this->assertFalse($result['ok']);
$this->assertSame('current_token_invalid', $result['error']);
}
public function testRotateReturnsInvalidWhenTokenIsExpired(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('find')->willReturn([
'id' => 5,
'user_id' => 1,
'revoked_at' => null,
'expires_at' => '2020-01-01 00:00:00',
'name' => 'Test',
]);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$result = $service->rotateCurrentToken(1, 5);
$this->assertFalse($result['ok']);
$this->assertSame('current_token_invalid', $result['error']);
}
public function testRotateSuccessRevokesOldAndCreatesNew(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('find')->willReturnCallback(function (int $id) {
if ($id === 5) {
return [
'id' => 5,
'user_id' => 1,
'revoked_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400),
'name' => 'Rotate Me',
'tenant_id' => 2,
];
}
// The newly created token
return ['id' => $id, 'uuid' => 'new-uuid'];
});
$tokenRepo->method('countActiveForUserExcludingId')->willReturn(0);
$tokenRepo->method('countActiveForUser')->willReturn(0);
$tokenRepo->expects($this->any())->method('revoke')->with(5)->willReturn(true);
$tokenRepo->method('create')->willReturn(50);
$userRepo = $this->createMock(UserReadRepositoryInterface::class);
$userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]);
$scopeGateway = $this->createMock(TenantScopeService::class);
$scopeGateway->method('getUserTenantIds')->willReturn([2]);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(365);
$settingsGateway->method('getApiTokenDefaultTtlDays')->willReturn(30);
$dbSession = $this->createMock(DatabaseSessionRepository::class);
$dbSession->expects($this->once())->method('beginTransaction');
$dbSession->expects($this->once())->method('commitTransaction');
$dbSession->expects($this->never())->method('rollbackTransaction');
$service = $this->newService(
userReadRepository: $userRepo,
apiTokenRepository: $tokenRepo,
settingsGateway: $settingsGateway,
scopeGateway: $scopeGateway,
databaseSessionRepository: $dbSession
);
$result = $service->rotateCurrentToken(1, 5);
$this->assertTrue($result['ok']);
$this->assertStringContainsString(':', $result['token']);
$this->assertSame(2, $result['tenant_id']);
}
public function testRotateRollsBackWhenRevokeFails(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('find')->willReturn([
'id' => 5,
'user_id' => 1,
'revoked_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400),
'name' => 'Test',
'tenant_id' => null,
]);
$tokenRepo->method('countActiveForUserExcludingId')->willReturn(0);
$tokenRepo->method('revoke')->willReturn(false);
$dbSession = $this->createMock(DatabaseSessionRepository::class);
$dbSession->expects($this->once())->method('beginTransaction');
$dbSession->expects($this->once())->method('rollbackTransaction');
$dbSession->expects($this->never())->method('commitTransaction');
$service = $this->newService(
apiTokenRepository: $tokenRepo,
databaseSessionRepository: $dbSession
);
$result = $service->rotateCurrentToken(1, 5);
$this->assertFalse($result['ok']);
$this->assertSame('rotate_failed', $result['error']);
}
public function testRotateRollsBackOnException(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('find')->willReturn([
'id' => 5,
'user_id' => 1,
'revoked_at' => null,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400),
'name' => 'Test',
'tenant_id' => null,
]);
$tokenRepo->method('countActiveForUserExcludingId')->willReturn(0);
$tokenRepo->method('revoke')->willThrowException(new \RuntimeException('DB down'));
$dbSession = $this->createMock(DatabaseSessionRepository::class);
$dbSession->expects($this->once())->method('beginTransaction');
$dbSession->expects($this->once())->method('rollbackTransaction');
$service = $this->newService(
apiTokenRepository: $tokenRepo,
databaseSessionRepository: $dbSession
);
$result = $service->rotateCurrentToken(1, 5);
$this->assertFalse($result['ok']);
$this->assertSame('rotate_failed', $result['error']);
}
// ---------------------------------------------------------------
// revoke()
// ---------------------------------------------------------------
public function testRevokeReturnsNotFoundWhenTokenDoesNotExist(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('find')->willReturn(null);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$result = $service->revoke(999);
$this->assertFalse($result['ok']);
$this->assertSame(404, $result['status']);
$this->assertSame('not_found', $result['error']);
}
public function testRevokeSuccessReturnsOk(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->expects($this->any())->method('find')->with(10)->willReturn(['id' => 10]);
$tokenRepo->expects($this->once())->method('revoke')->with(10);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$result = $service->revoke(10);
$this->assertTrue($result['ok']);
}
// ---------------------------------------------------------------
// revokeAllForUser()
// ---------------------------------------------------------------
public function testRevokeAllForUserReturnsCount(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->expects($this->once())
->method('revokeAllForUser')
->with(5, null)
->willReturn(3);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$result = $service->revokeAllForUser(5);
$this->assertTrue($result['ok']);
$this->assertSame(3, $result['count']);
}
public function testRevokeAllForUserWithTenantReturnsCount(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->expects($this->once())
->method('revokeAllForUser')
->with(5, 2)
->willReturn(1);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$result = $service->revokeAllForUser(5, 2);
$this->assertTrue($result['ok']);
$this->assertSame(1, $result['count']);
}
// ---------------------------------------------------------------
// listForUser()
// ---------------------------------------------------------------
public function testListForUserReturnsArray(): void
{
$expected = [
['id' => 1, 'name' => 'Token A'],
['id' => 2, 'name' => 'Token B'],
];
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->expects($this->once())
->method('listByUserId')
->with(7)
->willReturn($expected);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$this->assertSame($expected, $service->listForUser(7));
}
public function testListForUserReturnsEmptyArray(): void
{
$tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class);
$tokenRepo->method('listByUserId')->willReturn([]);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$this->assertSame([], $service->listForUser(1));
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
/**
* RequestRuntimeInterface has a method named "method()" which PHPUnit 12
* cannot mock (reserved name). Use a simple anonymous-class stub instead.
*/
private function makeRequestRuntime(string $ip = '127.0.0.1'): RequestRuntimeInterface
{
return new class ($ip) implements RequestRuntimeInterface {
public function __construct(private readonly string $stubIp)
{
}
public function method(): string
{
return 'GET';
}
public function path(): string
{
return '/';
}
public function ip(): string
{
return $this->stubIp;
}
public function userAgent(): string
{
return 'test';
}
public function queryParams(): array
{
return [];
}
public function isSecure(): bool
{
return false;
}
};
}
private function newService(
?UserReadRepositoryInterface $userReadRepository = null,
?ApiTokenRepositoryInterface $apiTokenRepository = null,
?AuthSettingsGateway $settingsGateway = null,
?TenantScopeService $scopeGateway = null,
?DatabaseSessionRepository $databaseSessionRepository = null,
?RequestRuntimeInterface $requestRuntime = null
): ApiTokenService {
return new ApiTokenService(
$userReadRepository ?? $this->createMock(UserReadRepositoryInterface::class),
$apiTokenRepository ?? $this->createMock(ApiTokenRepositoryInterface::class),
$settingsGateway ?? $this->createMock(AuthSettingsGateway::class),
$scopeGateway ?? $this->createMock(TenantScopeService::class),
$databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class),
$requestRuntime ?? $this->makeRequestRuntime()
);
}
}