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>
543 lines
19 KiB
PHP
543 lines
19 KiB
PHP
<?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)
|
|
);
|
|
}
|
|
}
|