1
0
Files
breadcrumb-the-shire/tests/Service/Auth/ApiTokenEndpointServiceTest.php

543 lines
19 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Repository\Auth\ApiTokenRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\Auth\ApiTokenEndpointService;
use MintyPHP\Service\Tenant\TenantScopeService;
use PHPUnit\Framework\TestCase;
class ApiTokenEndpointServiceTest extends TestCase
{
// ---------------------------------------------------------------
// normalizeLimit()
// ---------------------------------------------------------------
public function testNormalizeLimitWithinRange(): void
{
$service = $this->newService();
$this->assertSame(25, $service->normalizeLimit(25));
}
public function testNormalizeLimitBelowMinReturnsDefault(): void
{
$service = $this->newService();
$this->assertSame(50, $service->normalizeLimit(0));
}
public function testNormalizeLimitAboveMaxReturnsCappedMax(): void
{
$service = $this->newService();
$this->assertSame(100, $service->normalizeLimit(999));
}
public function testNormalizeLimitNonNumericReturnsDefault(): void
{
$service = $this->newService();
$this->assertSame(50, $service->normalizeLimit('abc'));
}
public function testNormalizeLimitAtExactMinReturnsValue(): void
{
$service = $this->newService();
$this->assertSame(1, $service->normalizeLimit(1));
}
public function testNormalizeLimitAtExactMaxReturnsValue(): void
{
$service = $this->newService();
$this->assertSame(100, $service->normalizeLimit(100));
}
public function testNormalizeLimitNegativeReturnsDefault(): void
{
$service = $this->newService();
$this->assertSame(50, $service->normalizeLimit(-5));
}
// ---------------------------------------------------------------
// listSelfTokens()
// ---------------------------------------------------------------
public function testListSelfTokensReturnsEmptyWhenUserIdIsZero(): void
{
$service = $this->newService();
$this->assertSame([], $service->listSelfTokens(0, 50, null, null));
}
public function testListSelfTokensReturnsEmptyWhenUserIdIsNegative(): void
{
$service = $this->newService();
$this->assertSame([], $service->listSelfTokens(-1, 50, null, null));
}
public function testListSelfTokensReturnsFormattedTokensWithoutHash(): void
{
$tokenRepo = $this->createMock(ApiTokenRepository::class);
$tokenRepo->expects($this->any())->method('listByUserId')->with(5, 50)->willReturn([
[
'id' => 10,
'uuid' => 'uuid-a',
'name' => 'Token A',
'selector' => 'abcdef1234567890abcdefgh',
'token_hash' => 'should-not-appear',
'tenant_id' => null,
'last_used_at' => '2025-01-01 10:00:00',
'last_ip' => '10.0.0.1',
'expires_at' => '2025-12-31 23:59:59',
'revoked_at' => null,
'created' => '2025-01-01 00:00:00',
],
]);
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->method('listByIds')->willReturn([]);
$service = $this->newService(apiTokenRepository: $tokenRepo, tenantRepository: $tenantRepo);
$result = $service->listSelfTokens(5, 50, null, null);
$this->assertCount(1, $result);
$this->assertSame('uuid-a', $result[0]['uuid']);
$this->assertSame('Token A', $result[0]['name']);
$this->assertSame('abcdef12', $result[0]['selector_prefix']);
$this->assertArrayNotHasKey('token_hash', $result[0]);
$this->assertFalse($result[0]['is_current_token']);
}
public function testListSelfTokensMarksCurrentToken(): void
{
$tokenRepo = $this->createMock(ApiTokenRepository::class);
$tokenRepo->method('listByUserId')->willReturn([
[
'id' => 10,
'uuid' => 'uuid-a',
'name' => 'Token A',
'selector' => 'abcdef1234567890',
'tenant_id' => null,
'last_used_at' => null,
'last_ip' => '',
'expires_at' => null,
'revoked_at' => null,
'created' => null,
],
[
'id' => 20,
'uuid' => 'uuid-b',
'name' => 'Token B',
'selector' => 'zyxwvuts87654321',
'tenant_id' => null,
'last_used_at' => null,
'last_ip' => '',
'expires_at' => null,
'revoked_at' => null,
'created' => null,
],
]);
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->method('listByIds')->willReturn([]);
$service = $this->newService(apiTokenRepository: $tokenRepo, tenantRepository: $tenantRepo);
$result = $service->listSelfTokens(5, 50, null, 20);
$this->assertCount(2, $result);
$this->assertFalse($result[0]['is_current_token']);
$this->assertTrue($result[1]['is_current_token']);
}
public function testListSelfTokensFiltersByScopedTenant(): void
{
$tokenRepo = $this->createMock(ApiTokenRepository::class);
$tokenRepo->method('listByUserId')->willReturn([
[
'id' => 10,
'uuid' => 'uuid-a',
'name' => 'Token A',
'selector' => 'abcdef1234567890',
'tenant_id' => 1,
'last_used_at' => null,
'last_ip' => '',
'expires_at' => null,
'revoked_at' => null,
'created' => null,
],
[
'id' => 20,
'uuid' => 'uuid-b',
'name' => 'Token B',
'selector' => 'zyxwvuts87654321',
'tenant_id' => 2,
'last_used_at' => null,
'last_ip' => '',
'expires_at' => null,
'revoked_at' => null,
'created' => null,
],
]);
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->method('listByIds')->willReturn([
['id' => 1, 'uuid' => 'tenant-uuid-1'],
]);
$service = $this->newService(apiTokenRepository: $tokenRepo, tenantRepository: $tenantRepo);
// Only tokens with tenant_id=1 should be returned
$result = $service->listSelfTokens(5, 50, 1, null);
$this->assertCount(1, $result);
$this->assertSame('uuid-a', $result[0]['uuid']);
}
// ---------------------------------------------------------------
// findSelfToken()
// ---------------------------------------------------------------
public function testFindSelfTokenReturnsNullForInvalidUuid(): void
{
$tokenRepo = $this->createMock(ApiTokenRepository::class);
$tokenRepo->expects($this->any())->method('isUuid')->with('not-valid')->willReturn(false);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$this->assertNull($service->findSelfToken('not-valid', 5, null, null));
}
public function testFindSelfTokenReturnsNullWhenUserIdIsZero(): void
{
$service = $this->newService();
$this->assertNull($service->findSelfToken('some-uuid', 0, null, null));
}
public function testFindSelfTokenReturnsNullWhenTokenNotFound(): void
{
$tokenRepo = $this->createMock(ApiTokenRepository::class);
$tokenRepo->method('isUuid')->willReturn(true);
$tokenRepo->method('findByUuidForUser')->willReturn(null);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$this->assertNull($service->findSelfToken('valid-uuid', 5, null, null));
}
public function testFindSelfTokenReturnsNullWhenScopedTenantDoesNotMatch(): void
{
$tokenRepo = $this->createMock(ApiTokenRepository::class);
$tokenRepo->method('isUuid')->willReturn(true);
$tokenRepo->method('findByUuidForUser')->willReturn([
'id' => 10,
'uuid' => 'valid-uuid',
'name' => 'Token',
'selector' => 'abcdef12',
'tenant_id' => 2,
'last_used_at' => null,
'last_ip' => '',
'expires_at' => null,
'revoked_at' => null,
'created' => null,
]);
$service = $this->newService(apiTokenRepository: $tokenRepo);
// Scoped to tenant 1 but token is for tenant 2
$this->assertNull($service->findSelfToken('valid-uuid', 5, 1, null));
}
public function testFindSelfTokenSuccess(): void
{
$tokenRepo = $this->createMock(ApiTokenRepository::class);
$tokenRepo->method('isUuid')->willReturn(true);
$tokenRepo->method('findByUuidForUser')->willReturn([
'id' => 10,
'uuid' => 'valid-uuid',
'name' => 'My Token',
'selector' => 'abcdef1234567890',
'tenant_id' => null,
'last_used_at' => '2025-01-01 00:00:00',
'last_ip' => '10.0.0.1',
'expires_at' => '2026-01-01 00:00:00',
'revoked_at' => null,
'created' => '2025-01-01 00:00:00',
]);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$result = $service->findSelfToken('valid-uuid', 5, null, 10);
$this->assertNotNull($result);
$this->assertSame('valid-uuid', $result['uuid']);
$this->assertSame('My Token', $result['name']);
$this->assertSame('abcdef12', $result['selector_prefix']);
$this->assertTrue($result['is_current_token']);
$this->assertArrayNotHasKey('token_hash', $result);
}
// ---------------------------------------------------------------
// revokeSelfToken()
// ---------------------------------------------------------------
public function testRevokeSelfTokenReturnsFalseForInvalidUuid(): void
{
$tokenRepo = $this->createMock(ApiTokenRepository::class);
$tokenRepo->method('isUuid')->willReturn(false);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$this->assertFalse($service->revokeSelfToken('bad-uuid', 5));
}
public function testRevokeSelfTokenReturnsFalseWhenUserIdIsZero(): void
{
$service = $this->newService();
$this->assertFalse($service->revokeSelfToken('some-uuid', 0));
}
public function testRevokeSelfTokenSuccessReturnsTrue(): void
{
$tokenRepo = $this->createMock(ApiTokenRepository::class);
$tokenRepo->method('isUuid')->willReturn(true);
$tokenRepo->expects($this->once())
->method('revokeByUuidForUser')
->with('valid-uuid', 5)
->willReturn(true);
$service = $this->newService(apiTokenRepository: $tokenRepo);
$this->assertTrue($service->revokeSelfToken('valid-uuid', 5));
}
// ---------------------------------------------------------------
// resolveSelfCreateTenant()
// ---------------------------------------------------------------
public function testResolveSelfCreateTenantSuccessWithEmptyInput(): void
{
$service = $this->newService();
$result = $service->resolveSelfCreateTenant(5, null, '');
$this->assertTrue($result['ok']);
$this->assertNull($result['tenant_id']);
$this->assertNull($result['tenant_uuid']);
}
public function testResolveSelfCreateTenantReturnsInvalidTenantUuid(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->expects($this->any())->method('findByUuid')->with('bad-uuid')->willReturn(null);
$service = $this->newService(tenantRepository: $tenantRepo);
$result = $service->resolveSelfCreateTenant(5, null, 'bad-uuid');
$this->assertFalse($result['ok']);
$this->assertSame(422, $result['status']);
$this->assertSame('invalid_tenant_uuid', $result['error']);
}
public function testResolveSelfCreateTenantReturnsTenantScopedForbiddenWhenScopeResolveFails(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
// resolveTenantUuidById calls find() to resolve the scoped tenant
$tenantRepo->method('find')->willReturn(null);
$service = $this->newService(tenantRepository: $tenantRepo);
// scopedTenantId is set but its UUID cannot be resolved
$result = $service->resolveSelfCreateTenant(5, 99, '');
$this->assertFalse($result['ok']);
$this->assertSame(403, $result['status']);
$this->assertSame('tenant_scoped_token_forbidden', $result['error']);
}
public function testResolveSelfCreateTenantReturnsTenantScopedForbiddenWhenTenantMismatch(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->expects($this->any())->method('findByUuid')->with('tenant-uuid-2')->willReturn(['id' => 2, 'uuid' => 'tenant-uuid-2']);
$tenantRepo->expects($this->any())->method('find')->with(1)->willReturn(['id' => 1, 'uuid' => 'tenant-uuid-1']);
$service = $this->newService(tenantRepository: $tenantRepo);
// Scoped to tenant 1, but requesting tenant 2
$result = $service->resolveSelfCreateTenant(5, 1, 'tenant-uuid-2');
$this->assertFalse($result['ok']);
$this->assertSame(403, $result['status']);
$this->assertSame('tenant_scoped_token_forbidden', $result['error']);
}
public function testResolveSelfCreateTenantReturnsTenantNotAssigned(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->expects($this->any())->method('findByUuid')->with('tenant-uuid-3')->willReturn(['id' => 3, 'uuid' => 'tenant-uuid-3']);
$scopeService = $this->createMock(TenantScopeService::class);
$scopeService->expects($this->any())->method('getUserTenantIds')->with(5)->willReturn([1, 2]);
$service = $this->newService(
tenantRepository: $tenantRepo,
authScopeGateway: $scopeService
);
$result = $service->resolveSelfCreateTenant(5, null, 'tenant-uuid-3');
$this->assertFalse($result['ok']);
$this->assertSame(403, $result['status']);
$this->assertSame('tenant_not_assigned', $result['error']);
}
public function testResolveSelfCreateTenantSuccessWithValidTenant(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->expects($this->any())->method('findByUuid')->with('tenant-uuid-1')->willReturn(['id' => 1, 'uuid' => 'tenant-uuid-1']);
$scopeService = $this->createMock(TenantScopeService::class);
$scopeService->expects($this->any())->method('getUserTenantIds')->with(5)->willReturn([1, 2]);
$service = $this->newService(
tenantRepository: $tenantRepo,
authScopeGateway: $scopeService
);
$result = $service->resolveSelfCreateTenant(5, null, 'tenant-uuid-1');
$this->assertTrue($result['ok']);
$this->assertSame(1, $result['tenant_id']);
$this->assertSame('tenant-uuid-1', $result['tenant_uuid']);
}
// ---------------------------------------------------------------
// parseExpiresAt()
// ---------------------------------------------------------------
public function testParseExpiresAtEmptyReturnsNull(): void
{
$service = $this->newService();
$result = $service->parseExpiresAt('');
$this->assertTrue($result['ok']);
$this->assertNull($result['expires_at']);
}
public function testParseExpiresAtValidFutureDate(): void
{
$service = $this->newService();
$futureDate = gmdate('Y-m-d H:i:s', time() + 86400 * 30);
$result = $service->parseExpiresAt($futureDate);
$this->assertTrue($result['ok']);
$this->assertNotEmpty($result['expires_at']);
}
public function testParseExpiresAtPastDateReturnsMustBeInFuture(): void
{
$service = $this->newService();
$result = $service->parseExpiresAt('2020-01-01 00:00:00');
$this->assertFalse($result['ok']);
$this->assertSame('must_be_in_future', $result['error']);
}
public function testParseExpiresAtInvalidFormatReturnsInvalidDatetime(): void
{
$service = $this->newService();
$result = $service->parseExpiresAt('not-a-date-at-all!!!');
$this->assertFalse($result['ok']);
$this->assertSame('invalid_datetime', $result['error']);
}
// ---------------------------------------------------------------
// capExpiryToParent()
// ---------------------------------------------------------------
public function testCapExpiryKeepsRequestedWhenBeforeParent(): void
{
$service = $this->newService();
$result = $service->capExpiryToParent('2025-06-01 00:00:00', '2025-12-31 00:00:00');
$this->assertSame('2025-06-01 00:00:00', $result);
}
public function testCapExpiryCapsToParentWhenRequestedIsAfter(): void
{
$service = $this->newService();
$result = $service->capExpiryToParent('2026-06-01 00:00:00', '2025-12-31 00:00:00');
$this->assertSame('2025-12-31 00:00:00', $result);
}
public function testCapExpiryKeepsRequestedWhenParentIsNull(): void
{
$service = $this->newService();
$result = $service->capExpiryToParent('2026-06-01 00:00:00', null);
$this->assertSame('2026-06-01 00:00:00', $result);
}
public function testCapExpiryKeepsRequestedWhenParentIsEmpty(): void
{
$service = $this->newService();
$result = $service->capExpiryToParent('2026-06-01 00:00:00', '');
$this->assertSame('2026-06-01 00:00:00', $result);
}
public function testCapExpiryReturnsParentWhenRequestedIsNull(): void
{
$service = $this->newService();
$result = $service->capExpiryToParent(null, '2025-12-31 00:00:00');
$this->assertSame('2025-12-31 00:00:00', $result);
}
public function testCapExpiryReturnsNullWhenBothAreNull(): void
{
$service = $this->newService();
$result = $service->capExpiryToParent(null, null);
$this->assertNull($result);
}
// ---------------------------------------------------------------
// Factory helper
// ---------------------------------------------------------------
private function newService(
?ApiTokenRepository $apiTokenRepository = null,
?TenantRepository $tenantRepository = null,
?TenantScopeService $authScopeGateway = null
): ApiTokenEndpointService {
return new ApiTokenEndpointService(
$apiTokenRepository ?? $this->createMock(ApiTokenRepository::class),
$tenantRepository ?? $this->createMock(TenantRepository::class),
$authScopeGateway ?? $this->createMock(TenantScopeService::class)
);
}
}