forked from fa/breadcrumb-the-shire
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>
820 lines
30 KiB
PHP
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()
|
|
);
|
|
}
|
|
}
|