feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Exception\FrameworkException;
use App\Framework\OAuth\OAuthProvider;
use App\Framework\OAuth\OAuthService;
use App\Framework\OAuth\Storage\InMemoryOAuthTokenRepository;
use App\Framework\OAuth\Storage\StoredOAuthToken;
use App\Framework\OAuth\ValueObjects\AccessToken;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\OAuth\ValueObjects\RefreshToken;
use App\Framework\OAuth\ValueObjects\TokenType;
beforeEach(function () {
$this->repository = new InMemoryOAuthTokenRepository();
$this->provider = Mockery::mock(OAuthProvider::class);
$this->provider->allows('getName')->andReturn('test-provider');
$this->service = new OAuthService($this->repository, ['test-provider' => $this->provider]);
});
afterEach(function () {
Mockery::close();
});
describe('OAuthService - Token Retrieval', function () {
it('retrieves valid non-expired token', function () {
$validAccessToken = AccessToken::create('valid_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$oauthToken = new OAuthToken($validAccessToken, null, TokenType::BEARER, null);
// Save token to repository
$this->repository->saveForUser('user-123', 'test-provider', $oauthToken);
$result = $this->service->getTokenForUser('user-123', 'test-provider');
expect($result->userId)->toBe('user-123');
expect($result->provider)->toBe('test-provider');
expect($result->isExpired())->toBeFalse();
});
it('automatically refreshes expired token when refresh token exists', function () {
// Expired token with refresh token
$expiredAccessToken = AccessToken::create('expired_access_1234567890', Timestamp::now()->subtract(Duration::fromSeconds(100)));
$refreshToken = RefreshToken::create('refresh_1234567890');
$expiredOAuthToken = new OAuthToken($expiredAccessToken, $refreshToken, TokenType::BEARER, null);
// Save expired token
$this->repository->saveForUser('user-123', 'test-provider', $expiredOAuthToken);
// New refreshed token from provider
$newAccessToken = AccessToken::create('new_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$newRefreshToken = RefreshToken::create('new_refresh_1234567890');
$newOAuthToken = new OAuthToken($newAccessToken, $newRefreshToken, TokenType::BEARER, null);
$this->provider->expects('refreshToken')
->once()
->andReturn($newOAuthToken);
$result = $this->service->getTokenForUser('user-123', 'test-provider');
expect($result->token->accessToken->toString())->toBe('new_access_1234567890');
expect($result->isExpired())->toBeFalse();
});
it('throws exception for expired token without refresh token', function () {
$expiredAccessToken = AccessToken::create('expired_1234567890', Timestamp::now()->subtract(Duration::fromSeconds(100)));
$expiredOAuthToken = new OAuthToken($expiredAccessToken, null, TokenType::BEARER, null);
$this->repository->saveForUser('user-123', 'test-provider', $expiredOAuthToken);
$this->service->getTokenForUser('user-123', 'test-provider');
})->throws(FrameworkException::class);
it('throws exception when token not found', function () {
$this->service->getTokenForUser('user-123', 'test-provider');
})->throws(FrameworkException::class);
});
describe('OAuthService - Explicit Token Refresh', function () {
it('refreshes token successfully', function () {
$oldAccessToken = AccessToken::create('old_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(100)));
$refreshToken = RefreshToken::create('refresh_1234567890');
$oldOAuthToken = new OAuthToken($oldAccessToken, $refreshToken, TokenType::BEARER, null);
$storedToken = $this->repository->saveForUser('user-123', 'test-provider', $oldOAuthToken);
$newAccessToken = AccessToken::create('new_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$newRefreshToken = RefreshToken::create('new_refresh_1234567890');
$newOAuthToken = new OAuthToken($newAccessToken, $newRefreshToken, TokenType::BEARER, null);
$this->provider->expects('refreshToken')
->once()
->andReturn($newOAuthToken);
$result = $this->service->refreshToken($storedToken);
expect($result->token->accessToken->toString())->toBe('new_access_1234567890');
});
it('handles provider refresh failures', function () {
$oldAccessToken = AccessToken::create('old_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(100)));
$refreshToken = RefreshToken::create('refresh_1234567890');
$oldOAuthToken = new OAuthToken($oldAccessToken, $refreshToken, TokenType::BEARER, null);
$storedToken = $this->repository->saveForUser('user-123', 'test-provider', $oldOAuthToken);
$this->provider->expects('refreshToken')
->once()
->andThrow(new \RuntimeException('Provider refresh failed'));
$this->service->refreshToken($storedToken);
})->throws(\RuntimeException::class, 'Provider refresh failed');
});
describe('OAuthService - Batch Token Refresh', function () {
it('refreshes expiring tokens in batch', function () {
// Token 1: Expiring soon (200 seconds) with refresh token
$accessToken1 = AccessToken::create('access1_1234567890', Timestamp::now()->add(Duration::fromSeconds(200)));
$refreshToken1 = RefreshToken::create('refresh1_1234567890');
$oauthToken1 = new OAuthToken($accessToken1, $refreshToken1, TokenType::BEARER, null);
$this->repository->saveForUser('user-1', 'test-provider', $oauthToken1);
// Token 2: Expiring soon (150 seconds) with refresh token
$accessToken2 = AccessToken::create('access2_1234567890', Timestamp::now()->add(Duration::fromSeconds(150)));
$refreshToken2 = RefreshToken::create('refresh2_1234567890');
$oauthToken2 = new OAuthToken($accessToken2, $refreshToken2, TokenType::BEARER, null);
$this->repository->saveForUser('user-2', 'test-provider', $oauthToken2);
// Mock refresh for both tokens
$this->provider->allows('refreshToken')
->andReturnUsing(function ($token) {
return new OAuthToken(
AccessToken::create('new_' . $token->accessToken->toString(), Timestamp::now()->add(Duration::fromSeconds(3600))),
RefreshToken::create('new_refresh_1234567890'),
TokenType::BEARER,
null
);
});
$refreshedCount = $this->service->refreshExpiringTokens(300);
expect($refreshedCount)->toBe(2);
});
it('continues batch refresh even if some tokens fail', function () {
// Token 1: Will fail to refresh
$accessToken1 = AccessToken::create('access1_1234567890', Timestamp::now()->add(Duration::fromSeconds(200)));
$refreshToken1 = RefreshToken::create('refresh1_1234567890');
$oauthToken1 = new OAuthToken($accessToken1, $refreshToken1, TokenType::BEARER, null);
$this->repository->saveForUser('user-1', 'test-provider', $oauthToken1);
// Token 2: Will succeed
$accessToken2 = AccessToken::create('access2_1234567890', Timestamp::now()->add(Duration::fromSeconds(150)));
$refreshToken2 = RefreshToken::create('refresh2_1234567890');
$oauthToken2 = new OAuthToken($accessToken2, $refreshToken2, TokenType::BEARER, null);
$this->repository->saveForUser('user-2', 'test-provider', $oauthToken2);
// Mock to fail first, succeed second
$callCount = 0;
$this->provider->allows('refreshToken')
->andReturnUsing(function () use (&$callCount) {
$callCount++;
if ($callCount === 1) {
throw new \RuntimeException('Provider error');
}
return new OAuthToken(
AccessToken::create('new_access2_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))),
RefreshToken::create('new_refresh2_1234567890'),
TokenType::BEARER,
null
);
});
$refreshedCount = $this->service->refreshExpiringTokens(300);
expect($refreshedCount)->toBe(1); // Only 1 succeeded
});
it('returns zero when no tokens are expiring', function () {
$refreshedCount = $this->service->refreshExpiringTokens(300);
expect($refreshedCount)->toBe(0);
});
});
describe('OAuthService - Token Cleanup', function () {
it('cleans up expired tokens without refresh capability', function () {
// Create 5 expired tokens without refresh token
for ($i = 1; $i <= 5; $i++) {
$expiredAccessToken = AccessToken::create("expired_{$i}_1234567890", Timestamp::now()->subtract(Duration::fromSeconds(100)));
$expiredToken = new OAuthToken($expiredAccessToken, null, TokenType::BEARER, null);
$this->repository->saveForUser("user-{$i}", 'test-provider', $expiredToken);
}
// Create 1 expired token WITH refresh token (should not be deleted)
$expiredWithRefresh = AccessToken::create('expired_with_refresh_1234567890', Timestamp::now()->subtract(Duration::fromSeconds(100)));
$refreshToken = RefreshToken::create('refresh_1234567890');
$tokenWithRefresh = new OAuthToken($expiredWithRefresh, $refreshToken, TokenType::BEARER, null);
$this->repository->saveForUser('user-6', 'test-provider', $tokenWithRefresh);
$deletedCount = $this->service->cleanupExpiredTokens();
expect($deletedCount)->toBe(5);
expect($this->repository->all())->toHaveCount(1); // Only token with refresh remains
});
it('returns zero when no tokens to cleanup', function () {
$deletedCount = $this->service->cleanupExpiredTokens();
expect($deletedCount)->toBe(0);
});
});
describe('OAuthService - Provider Configuration', function () {
it('finds configured provider', function () {
$provider = $this->service->getProvider('test-provider');
expect($provider)->toBe($this->provider);
});
it('throws exception for unconfigured provider', function () {
$this->service->getProvider('unknown-provider');
})->throws(FrameworkException::class);
});

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\OAuth\Storage\StoredOAuthToken;
use App\Framework\OAuth\ValueObjects\AccessToken;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\OAuth\ValueObjects\RefreshToken;
use App\Framework\OAuth\ValueObjects\TokenType;
describe('StoredOAuthToken Entity', function () {
it('creates new stored token', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$oauthToken = new OAuthToken($accessToken, null, TokenType::BEARER, null);
$stored = StoredOAuthToken::create('user-123', 'spotify', $oauthToken);
expect($stored->id)->toBeNull(); // Not persisted yet
expect($stored->userId)->toBe('user-123');
expect($stored->provider)->toBe('spotify');
expect($stored->token)->toBe($oauthToken);
});
it('sets timestamps on creation', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$oauthToken = new OAuthToken($accessToken, null, TokenType::BEARER, null);
$beforeCreate = Timestamp::now();
$stored = StoredOAuthToken::create('user-123', 'spotify', $oauthToken);
$afterCreate = Timestamp::now();
expect($stored->createdAt->toTimestamp())->toBeGreaterThanOrEqual($beforeCreate->toTimestamp());
expect($stored->createdAt->toTimestamp())->toBeLessThanOrEqual($afterCreate->toTimestamp());
expect($stored->updatedAt->toTimestamp())->toBe($stored->createdAt->toTimestamp());
});
it('creates from database row', function () {
$now = Timestamp::now();
$row = [
'id' => 1,
'user_id' => 'user-456',
'provider' => 'github',
'access_token' => 'access_1234567890',
'refresh_token' => 'refresh_1234567890',
'token_type' => 'Bearer',
'expires_at' => $now->add(Duration::fromSeconds(3600))->format('Y-m-d H:i:s'),
'scope' => 'read write',
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
];
$stored = StoredOAuthToken::fromArray($row);
expect($stored->id)->toBe(1);
expect($stored->userId)->toBe('user-456');
expect($stored->provider)->toBe('github');
expect($stored->token->accessToken->toString())->toBe('access_1234567890');
expect($stored->token->refreshToken->toString())->toBe('refresh_1234567890');
});
it('updates token immutably', function () {
$originalAccessToken = AccessToken::create('original_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$originalToken = new OAuthToken($originalAccessToken, null, TokenType::BEARER, null);
$originalStored = StoredOAuthToken::create('user-123', 'spotify', $originalToken);
$newAccessToken = AccessToken::create('new_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(7200)));
$newToken = new OAuthToken($newAccessToken, null, TokenType::BEARER, null);
$updatedStored = $originalStored->withRefreshedToken($newToken);
// Original unchanged
expect($originalStored->token->accessToken->toString())->toBe('original_access_1234567890');
expect($originalStored->userId)->toBe('user-123');
// New instance updated
expect($updatedStored->token->accessToken->toString())->toBe('new_access_1234567890');
expect($updatedStored->userId)->toBe('user-123');
expect($updatedStored->provider)->toBe('spotify');
});
it('updates updatedAt timestamp on token refresh', function () {
$originalAccessToken = AccessToken::create('original_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$originalToken = new OAuthToken($originalAccessToken, null, TokenType::BEARER, null);
$originalStored = StoredOAuthToken::create('user-123', 'spotify', $originalToken);
// Wait a full second to ensure different timestamp
sleep(1);
$newAccessToken = AccessToken::create('new_1234567890', Timestamp::now()->add(Duration::fromSeconds(7200)));
$newToken = new OAuthToken($newAccessToken, null, TokenType::BEARER, null);
$updatedStored = $originalStored->withRefreshedToken($newToken);
expect($updatedStored->updatedAt->toTimestamp())
->toBeGreaterThanOrEqual($originalStored->updatedAt->toTimestamp() + 1);
expect($updatedStored->createdAt->toTimestamp())
->toBe($originalStored->createdAt->toTimestamp());
});
it('preserves id during token refresh', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$oauthToken = new OAuthToken($accessToken, null, TokenType::BEARER, null);
$stored = new StoredOAuthToken(
id: 42,
userId: 'user-123',
provider: 'spotify',
token: $oauthToken,
createdAt: Timestamp::now(),
updatedAt: Timestamp::now()
);
$newAccessToken = AccessToken::create('new_1234567890', Timestamp::now()->add(Duration::fromSeconds(7200)));
$newToken = new OAuthToken($newAccessToken, null, TokenType::BEARER, null);
$updated = $stored->withRefreshedToken($newToken);
expect($updated->id)->toBe(42);
});
it('detects expired token', function () {
$expiredAccessToken = AccessToken::create('expired_1234567890', Timestamp::now()->subtract(Duration::fromSeconds(100)));
$expiredToken = new OAuthToken($expiredAccessToken, null, TokenType::BEARER, null);
$stored = StoredOAuthToken::create('user-123', 'spotify', $expiredToken);
expect($stored->isExpired())->toBeTrue();
});
it('detects valid non-expired token', function () {
$validAccessToken = AccessToken::create('valid_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$validToken = new OAuthToken($validAccessToken, null, TokenType::BEARER, null);
$stored = StoredOAuthToken::create('user-123', 'spotify', $validToken);
expect($stored->isExpired())->toBeFalse();
});
it('checks if token can refresh', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$refreshToken = RefreshToken::create('refresh_1234567890');
$oauthToken = new OAuthToken($accessToken, $refreshToken, TokenType::BEARER, null);
$stored = StoredOAuthToken::create('user-123', 'spotify', $oauthToken);
expect($stored->canRefresh())->toBeTrue();
});
it('checks if token cannot refresh without refresh token', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$oauthToken = new OAuthToken($accessToken, null, TokenType::BEARER, null);
$stored = StoredOAuthToken::create('user-123', 'spotify', $oauthToken);
expect($stored->canRefresh())->toBeFalse();
});
it('converts to array for database storage', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$refreshToken = RefreshToken::create('refresh_1234567890');
$oauthToken = new OAuthToken($accessToken, $refreshToken, TokenType::BEARER, null);
$stored = new StoredOAuthToken(
id: 1,
userId: 'user-123',
provider: 'spotify',
token: $oauthToken,
createdAt: Timestamp::now(),
updatedAt: Timestamp::now()
);
$array = $stored->toArray();
expect($array['id'])->toBe(1);
expect($array['user_id'])->toBe('user-123');
expect($array['provider'])->toBe('spotify');
expect($array['access_token'])->toBe('access_1234567890');
expect($array['refresh_token'])->toBe('refresh_1234567890');
expect($array['token_type'])->toBe('Bearer');
expect($array)->toHaveKey('expires_at');
expect($array)->toHaveKey('created_at');
expect($array)->toHaveKey('updated_at');
});
it('handles null id in array conversion', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$oauthToken = new OAuthToken($accessToken, null, TokenType::BEARER, null);
$stored = StoredOAuthToken::create('user-123', 'spotify', $oauthToken);
$array = $stored->toArray();
expect(array_key_exists('id', $array))->toBeFalse();
});
});

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\OAuth\ValueObjects\AccessToken;
describe('AccessToken Value Object', function () {
it('creates valid access token', function () {
$expiresAt = Timestamp::now()->add(Duration::fromSeconds(3600));
$token = AccessToken::create('valid_access_token_1234567890', $expiresAt);
expect($token->toString())->toBe('valid_access_token_1234567890');
expect($token->getExpiresAt())->toBe($expiresAt);
});
it('creates from provider response with expires_in', function () {
$token = AccessToken::fromProviderResponse('provider_token_1234567890', 3600);
expect($token->toString())->toBe('provider_token_1234567890');
expect($token->getSecondsUntilExpiration())->toBeGreaterThan(3500);
expect($token->getSecondsUntilExpiration())->toBeLessThanOrEqual(3600);
});
it('rejects empty token', function () {
$expiresAt = Timestamp::now()->add(Duration::fromSeconds(3600));
expect(fn() => AccessToken::create('', $expiresAt))
->toThrow(\InvalidArgumentException::class, 'Access token cannot be empty');
});
it('rejects too short token', function () {
$expiresAt = Timestamp::now()->add(Duration::fromSeconds(3600));
expect(fn() => AccessToken::create('short', $expiresAt))
->toThrow(\InvalidArgumentException::class, 'Access token appears invalid (too short)');
});
it('detects expired tokens', function () {
$expiredAt = Timestamp::now()->subtract(Duration::fromSeconds(100));
$token = AccessToken::create('expired_token_1234567890', $expiredAt);
expect($token->isExpired())->toBeTrue();
expect($token->isValid())->toBeFalse();
});
it('detects valid non-expired tokens', function () {
$expiresAt = Timestamp::now()->add(Duration::fromSeconds(3600));
$token = AccessToken::create('valid_token_1234567890', $expiresAt);
expect($token->isExpired())->toBeFalse();
expect($token->isValid())->toBeTrue();
});
it('uses 60 second buffer for expiry check', function () {
// Token expires in 30 seconds - should be considered expired due to 60s buffer
$almostExpired = Timestamp::now()->add(Duration::fromSeconds(30));
$token = AccessToken::create('almost_expired_token_1234567890', $almostExpired);
expect($token->isExpired())->toBeTrue();
expect($token->isValid())->toBeFalse();
});
it('calculates seconds until expiration correctly', function () {
$expiresAt = Timestamp::now()->add(Duration::fromSeconds(1800)); // 30 minutes
$token = AccessToken::create('token_1234567890', $expiresAt);
$secondsLeft = $token->getSecondsUntilExpiration();
expect($secondsLeft)->toBeGreaterThan(1700);
expect($secondsLeft)->toBeLessThanOrEqual(1800);
});
it('returns zero seconds for expired tokens', function () {
$expiredAt = Timestamp::now()->subtract(Duration::fromSeconds(100));
$token = AccessToken::create('expired_token_1234567890', $expiredAt);
expect($token->getSecondsUntilExpiration())->toBe(0);
});
it('masks token for logging', function () {
$token = AccessToken::create('1234567890abcdefghijklmnop', Timestamp::now()->add(Duration::fromSeconds(3600)));
$masked = $token->getMasked();
expect($masked)->toStartWith('1234');
expect($masked)->toEndWith('mnop');
expect($masked)->toContain('*');
expect(strlen($masked))->toBe(strlen('1234567890abcdefghijklmnop'));
});
it('fully masks very short tokens', function () {
$token = AccessToken::create('short_token_', Timestamp::now()->add(Duration::fromSeconds(3600)));
$masked = $token->getMasked();
expect($masked)->toBe('************');
expect(str_contains($masked, 's'))->toBeFalse();
expect(str_contains($masked, '_'))->toBeFalse();
});
it('converts to string as masked', function () {
$token = AccessToken::create('1234567890abcdefghijklmnop', Timestamp::now()->add(Duration::fromSeconds(3600)));
expect((string) $token)->toBe($token->getMasked());
});
it('creates new token with updated expiration immutably', function () {
$originalExpiry = Timestamp::now()->add(Duration::fromSeconds(3600));
$originalToken = AccessToken::create('token_1234567890', $originalExpiry);
$newExpiry = Timestamp::now()->add(Duration::fromSeconds(7200));
$updatedToken = $originalToken->withExpiresAt($newExpiry);
// Original unchanged
expect($originalToken->getExpiresAt())->toBe($originalExpiry);
expect($originalToken->toString())->toBe('token_1234567890');
// New instance updated
expect($updatedToken->getExpiresAt())->toBe($newExpiry);
expect($updatedToken->toString())->toBe('token_1234567890');
});
});

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\OAuth\ValueObjects\AccessToken;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\OAuth\ValueObjects\RefreshToken;
use App\Framework\OAuth\ValueObjects\TokenScope;
use App\Framework\OAuth\ValueObjects\TokenType;
describe('OAuthToken Composite Value Object', function () {
it('creates complete OAuth token', function () {
$accessToken = AccessToken::create('access_token_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$refreshToken = RefreshToken::create('refresh_token_1234567890');
$tokenType = TokenType::BEARER;
$scope = TokenScope::fromArray(['read', 'write']);
$oauthToken = new OAuthToken($accessToken, $refreshToken, $tokenType, $scope);
expect($oauthToken->accessToken)->toBe($accessToken);
expect($oauthToken->refreshToken)->toBe($refreshToken);
expect($oauthToken->tokenType)->toBe($tokenType);
expect($oauthToken->scope)->toBe($scope);
});
it('creates from provider response with all fields', function () {
$response = [
'access_token' => 'provider_access_token_1234567890',
'refresh_token' => 'provider_refresh_token_1234567890',
'token_type' => 'Bearer',
'expires_in' => 3600,
'scope' => 'read write admin',
];
$token = OAuthToken::fromProviderResponse($response);
expect($token->accessToken->toString())->toBe('provider_access_token_1234567890');
expect($token->refreshToken->toString())->toBe('provider_refresh_token_1234567890');
expect($token->tokenType)->toBe(TokenType::BEARER);
expect($token->scope->toArray())->toBe(['read', 'write', 'admin']);
});
it('creates from provider response with minimal fields', function () {
$response = [
'access_token' => 'minimal_access_token_1234567890',
];
$token = OAuthToken::fromProviderResponse($response);
expect($token->accessToken->toString())->toBe('minimal_access_token_1234567890');
expect($token->refreshToken)->toBeNull();
expect($token->tokenType)->toBe(TokenType::BEARER); // Default
expect($token->scope)->toBeNull();
});
it('uses default expires_in when not provided', function () {
$response = [
'access_token' => 'token_1234567890',
];
$token = OAuthToken::fromProviderResponse($response);
// Default is 3600 seconds (1 hour)
$secondsLeft = $token->accessToken->getSecondsUntilExpiration();
expect($secondsLeft)->toBeGreaterThan(3500);
expect($secondsLeft)->toBeLessThanOrEqual(3600);
});
it('rejects provider response without access_token', function () {
OAuthToken::fromProviderResponse([]);
})->throws(\InvalidArgumentException::class, 'Provider response missing access_token');
it('detects expired token', function () {
$expiredAccessToken = AccessToken::create('expired_1234567890', Timestamp::now()->subtract(Duration::fromSeconds(100)));
$token = new OAuthToken($expiredAccessToken, null, TokenType::BEARER, null);
expect($token->isExpired())->toBeTrue();
});
it('detects valid non-expired token', function () {
$validAccessToken = AccessToken::create('valid_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$token = new OAuthToken($validAccessToken, null, TokenType::BEARER, null);
expect($token->isExpired())->toBeFalse();
});
it('checks if token can refresh when refresh token exists', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$refreshToken = RefreshToken::create('refresh_1234567890');
$token = new OAuthToken($accessToken, $refreshToken, TokenType::BEARER, null);
expect($token->canRefresh())->toBeTrue();
});
it('checks if token cannot refresh without refresh token', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$token = new OAuthToken($accessToken, null, TokenType::BEARER, null);
expect($token->canRefresh())->toBeFalse();
});
it('creates new token with refreshed access token immutably', function () {
$originalAccessToken = AccessToken::create('original_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$originalRefreshToken = RefreshToken::create('original_refresh_1234567890');
$originalToken = new OAuthToken($originalAccessToken, $originalRefreshToken, TokenType::BEARER, null);
$newAccessToken = AccessToken::create('new_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(7200)));
$newRefreshToken = RefreshToken::create('new_refresh_1234567890');
$refreshedToken = $originalToken->withRefreshedAccessToken($newAccessToken, $newRefreshToken);
// Original unchanged
expect($originalToken->accessToken->toString())->toBe('original_access_1234567890');
expect($originalToken->refreshToken->toString())->toBe('original_refresh_1234567890');
// New instance updated
expect($refreshedToken->accessToken->toString())->toBe('new_access_1234567890');
expect($refreshedToken->refreshToken->toString())->toBe('new_refresh_1234567890');
expect($refreshedToken->tokenType)->toBe(TokenType::BEARER);
});
it('refreshes access token while keeping original refresh token', function () {
$originalAccessToken = AccessToken::create('original_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$originalRefreshToken = RefreshToken::create('original_refresh_1234567890');
$originalToken = new OAuthToken($originalAccessToken, $originalRefreshToken, TokenType::BEARER, null);
$newAccessToken = AccessToken::create('new_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(7200)));
$refreshedToken = $originalToken->withRefreshedAccessToken($newAccessToken);
// Original refresh token preserved
expect($refreshedToken->refreshToken->toString())->toBe('original_refresh_1234567890');
expect($refreshedToken->accessToken->toString())->toBe('new_access_1234567890');
});
it('converts to array correctly', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$refreshToken = RefreshToken::create('refresh_1234567890');
$scope = TokenScope::fromArray(['read', 'write']);
$token = new OAuthToken($accessToken, $refreshToken, TokenType::BEARER, $scope);
$array = $token->toArray();
expect($array['access_token'])->toBe('access_1234567890');
expect($array['refresh_token'])->toBe('refresh_1234567890');
expect($array['token_type'])->toBe('Bearer');
expect($array['scope'])->toBe('read write');
expect($array)->toHaveKey('expires_at');
});
it('converts to array with masked tokens', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$refreshToken = RefreshToken::create('refresh_1234567890');
$token = new OAuthToken($accessToken, $refreshToken, TokenType::BEARER, null);
$masked = $token->toArrayMasked();
expect($masked['access_token'])->toContain('*');
expect($masked['refresh_token'])->toContain('*');
expect($masked['access_token'] !== 'access_1234567890')->toBeTrue();
expect($masked['refresh_token'] !== 'refresh_1234567890')->toBeTrue();
});
it('handles null refresh token in array conversion', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$token = new OAuthToken($accessToken, null, TokenType::BEARER, null);
$array = $token->toArray();
expect($array)->toHaveKey('access_token');
expect(array_key_exists('refresh_token', $array))->toBeFalse();
});
it('handles null scope in array conversion', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$token = new OAuthToken($accessToken, null, TokenType::BEARER, null);
$array = $token->toArray();
expect($array)->toHaveKey('access_token');
expect(array_key_exists('scope', $array))->toBeFalse();
});
it('creates authorization header with Bearer type', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$token = new OAuthToken($accessToken, null, TokenType::BEARER, null);
$header = $token->getAuthorizationHeader();
expect($header)->toBe('Bearer access_1234567890');
});
it('creates authorization header with MAC type', function () {
$accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600)));
$token = new OAuthToken($accessToken, null, TokenType::MAC, null);
$header = $token->getAuthorizationHeader();
expect($header)->toBe('MAC access_1234567890');
});
it('creates from array correctly', function () {
$expiresAt = Timestamp::now()->add(Duration::fromSeconds(3600));
$array = [
'access_token' => 'access_1234567890',
'refresh_token' => 'refresh_1234567890',
'token_type' => 'Bearer',
'expires_at' => $expiresAt->format('Y-m-d H:i:s'),
'scope' => 'read write',
];
$token = OAuthToken::fromArray($array);
expect($token->accessToken->toString())->toBe('access_1234567890');
expect($token->refreshToken->toString())->toBe('refresh_1234567890');
expect($token->tokenType)->toBe(TokenType::BEARER);
expect($token->scope->toArray())->toBe(['read', 'write']);
});
});

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Framework\OAuth\ValueObjects\RefreshToken;
describe('RefreshToken Value Object', function () {
it('creates valid refresh token', function () {
$token = RefreshToken::create('valid_refresh_token_1234567890');
expect($token->toString())->toBe('valid_refresh_token_1234567890');
});
it('rejects empty token', function () {
RefreshToken::create('');
})->throws(\InvalidArgumentException::class, 'Refresh token cannot be empty');
it('rejects too short token', function () {
RefreshToken::create('short');
})->throws(\InvalidArgumentException::class, 'Refresh token appears invalid (too short)');
it('masks token for logging', function () {
$token = RefreshToken::create('1234567890abcdefghijklmnop');
$masked = $token->getMasked();
expect($masked)->toStartWith('1234');
expect($masked)->toEndWith('mnop');
expect($masked)->toContain('*');
expect(strlen($masked))->toBe(strlen('1234567890abcdefghijklmnop'));
});
it('fully masks very short tokens', function () {
$token = RefreshToken::create('short_token_');
$masked = $token->getMasked();
expect($masked)->toBe('************');
expect(str_contains($masked, 's'))->toBeFalse();
expect(str_contains($masked, '_'))->toBeFalse();
});
it('converts to string as masked', function () {
$token = RefreshToken::create('1234567890abcdefghijklmnop');
expect((string) $token)->toBe($token->getMasked());
});
});

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
use App\Framework\OAuth\ValueObjects\TokenScope;
describe('TokenScope Value Object', function () {
it('creates from array of scopes', function () {
$scope = TokenScope::fromArray(['read', 'write', 'admin']);
expect($scope->toArray())->toBe(['read', 'write', 'admin']);
expect($scope->toString())->toBe('read write admin');
});
it('creates from space-separated string', function () {
$scope = TokenScope::fromString('read write admin');
expect($scope->toArray())->toBe(['read', 'write', 'admin']);
});
it('handles extra whitespace in string', function () {
$scope = TokenScope::fromString(' read write admin ');
expect($scope->toArray())->toBe(['read', 'write', 'admin']);
});
it('rejects empty scope array', function () {
TokenScope::fromArray([]);
})->throws(\InvalidArgumentException::class, 'Token scope cannot be empty');
it('rejects empty scope string', function () {
TokenScope::fromString('');
})->throws(\InvalidArgumentException::class, 'Token scope cannot be empty');
it('rejects scope array with empty strings', function () {
TokenScope::fromArray(['read', '', 'write']);
})->throws(\InvalidArgumentException::class, 'Invalid scope value');
it('rejects scope array with non-strings', function () {
TokenScope::fromArray(['read', 123, 'write']);
})->throws(\InvalidArgumentException::class, 'Invalid scope value');
it('checks if scope includes specific permission', function () {
$scope = TokenScope::fromArray(['read', 'write', 'admin']);
expect($scope->includes('read'))->toBeTrue();
expect($scope->includes('write'))->toBeTrue();
expect($scope->includes('admin'))->toBeTrue();
expect($scope->includes('delete'))->toBeFalse();
});
it('checks if scope includes all required permissions', function () {
$scope = TokenScope::fromArray(['read', 'write', 'admin']);
expect($scope->includesAll(['read', 'write']))->toBeTrue();
expect($scope->includesAll(['read', 'admin']))->toBeTrue();
expect($scope->includesAll(['read', 'write', 'admin']))->toBeTrue();
expect($scope->includesAll(['read', 'delete']))->toBeFalse();
});
it('checks if scope includes any of specified permissions', function () {
$scope = TokenScope::fromArray(['read', 'write']);
expect($scope->includesAny(['read', 'admin']))->toBeTrue();
expect($scope->includesAny(['write', 'delete']))->toBeTrue();
expect($scope->includesAny(['admin', 'delete']))->toBeFalse();
});
it('adds additional scopes immutably', function () {
$originalScope = TokenScope::fromArray(['read', 'write']);
$extendedScope = $originalScope->withAdditional(['admin', 'delete']);
// Original unchanged
expect($originalScope->toArray())->toBe(['read', 'write']);
// New instance extended
expect($extendedScope->toArray())->toBe(['read', 'write', 'admin', 'delete']);
});
it('removes specific scopes immutably', function () {
$originalScope = TokenScope::fromArray(['read', 'write', 'admin', 'delete']);
$reducedScope = $originalScope->without(['admin', 'delete']);
// Original unchanged
expect($originalScope->toArray())->toBe(['read', 'write', 'admin', 'delete']);
// New instance reduced
expect($reducedScope->toArray())->toBe(['read', 'write']);
});
it('prevents removing all scopes', function () {
$scope = TokenScope::fromArray(['read', 'write']);
$scope->without(['read', 'write']);
})->throws(\InvalidArgumentException::class, 'Cannot remove all scopes');
it('converts to string correctly', function () {
$scope = TokenScope::fromArray(['read', 'write', 'admin']);
expect($scope->toString())->toBe('read write admin');
expect((string) $scope)->toBe('read write admin');
});
});

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Framework\OAuth\ValueObjects\TokenType;
describe('TokenType Enum', function () {
it('creates Bearer token type', function () {
$tokenType = TokenType::BEARER;
expect($tokenType->value)->toBe('Bearer');
expect($tokenType->isBearer())->toBeTrue();
expect($tokenType->isMac())->toBeFalse();
});
it('creates MAC token type', function () {
$tokenType = TokenType::MAC;
expect($tokenType->value)->toBe('MAC');
expect($tokenType->isMac())->toBeTrue();
expect($tokenType->isBearer())->toBeFalse();
});
it('parses lowercase bearer to BEARER', function () {
$tokenType = TokenType::fromString('bearer');
expect($tokenType)->toBe(TokenType::BEARER);
});
it('parses capitalized Bearer to BEARER', function () {
$tokenType = TokenType::fromString('Bearer');
expect($tokenType)->toBe(TokenType::BEARER);
});
it('parses lowercase mac to MAC', function () {
$tokenType = TokenType::fromString('mac');
expect($tokenType)->toBe(TokenType::MAC);
});
it('parses uppercase MAC to MAC', function () {
$tokenType = TokenType::fromString('MAC');
expect($tokenType)->toBe(TokenType::MAC);
});
it('falls back to Bearer for unknown types', function () {
$unknownType = TokenType::fromString('unknown_type');
expect($unknownType)->toBe(TokenType::BEARER);
});
it('handles whitespace in parsing', function () {
$tokenType = TokenType::fromString(' bearer ');
expect($tokenType)->toBe(TokenType::BEARER);
});
it('returns correct authorization header prefix', function () {
expect(TokenType::BEARER->getHeaderPrefix())->toBe('Bearer');
expect(TokenType::MAC->getHeaderPrefix())->toBe('MAC');
});
});