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) ); } }