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