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,148 @@
<?php
declare(strict_types=1);
use App\Framework\MagicLinks\Actions\ActionResult;
use App\Framework\MagicLinks\ValueObjects\ActionResultData;
use App\Framework\MagicLinks\ValueObjects\ErrorCollection;
describe('ActionResult', function () {
it('creates successful result', function () {
$result = new ActionResult(
success: true,
message: 'Operation completed successfully'
);
expect($result->success)->toBeTrue();
expect($result->message)->toBe('Operation completed successfully');
expect($result->isSuccess())->toBeTrue();
expect($result->hasErrors())->toBeFalse();
});
it('creates failed result', function () {
$errors = ErrorCollection::fromArray(['Error 1', 'Error 2']);
$result = new ActionResult(
success: false,
message: 'Operation failed',
errors: $errors
);
expect($result->success)->toBeFalse();
expect($result->message)->toBe('Operation failed');
expect($result->isSuccess())->toBeFalse();
expect($result->hasErrors())->toBeTrue();
});
it('creates success with data', function () {
$data = ActionResultData::fromArray(['user_id' => 123, 'email' => 'test@example.com']);
$result = new ActionResult(
success: true,
message: 'User created',
data: $data
);
expect($result->success)->toBeTrue();
expect($result->data)->toBe($data);
expect($result->data->get('user_id'))->toBe(123);
});
it('creates success with redirect', function () {
$result = new ActionResult(
success: true,
message: 'Redirecting...',
redirectUrl: '/dashboard'
);
expect($result->hasRedirect())->toBeTrue();
expect($result->redirectUrl)->toBe('/dashboard');
});
it('uses success factory method', function () {
$result = ActionResult::success('Email verified');
expect($result->isSuccess())->toBeTrue();
expect($result->message)->toBe('Email verified');
expect($result->hasErrors())->toBeFalse();
});
it('uses success factory with data', function () {
$data = ActionResultData::fromArray(['verified' => true]);
$result = ActionResult::success('Email verified', $data);
expect($result->isSuccess())->toBeTrue();
expect($result->data)->toBe($data);
});
it('uses success factory with redirect', function () {
$result = ActionResult::success(
'Login successful',
redirectUrl: '/dashboard'
);
expect($result->isSuccess())->toBeTrue();
expect($result->hasRedirect())->toBeTrue();
expect($result->redirectUrl)->toBe('/dashboard');
});
it('uses failure factory method', function () {
$result = ActionResult::failure('Invalid credentials');
expect($result->isSuccess())->toBeFalse();
expect($result->message)->toBe('Invalid credentials');
});
it('uses failure factory with errors', function () {
$errors = ErrorCollection::fromArray(['Email is required', 'Password is too short']);
$result = ActionResult::failure('Validation failed', $errors);
expect($result->isSuccess())->toBeFalse();
expect($result->hasErrors())->toBeTrue();
expect($result->errors)->toBe($errors);
expect($result->errors->count())->toBe(2);
});
it('defaults to empty data and errors', function () {
$result = ActionResult::success('Success');
expect($result->data->toArray())->toBeEmpty();
expect($result->errors->isEmpty())->toBeTrue();
});
it('checks redirect correctly', function () {
$withRedirect = ActionResult::success('Done', redirectUrl: '/home');
$withoutRedirect = ActionResult::success('Done');
expect($withRedirect->hasRedirect())->toBeTrue();
expect($withoutRedirect->hasRedirect())->toBeFalse();
});
it('checks errors correctly', function () {
$withErrors = ActionResult::failure('Failed', ErrorCollection::single('Error'));
$withoutErrors = ActionResult::success('Success');
expect($withErrors->hasErrors())->toBeTrue();
expect($withoutErrors->hasErrors())->toBeFalse();
});
it('allows empty message', function () {
$result = ActionResult::success();
expect($result->message)->toBe('');
expect($result->isSuccess())->toBeTrue();
});
it('combines success with all optional parameters', function () {
$data = ActionResultData::fromArray(['id' => 1]);
$result = ActionResult::success(
'Created',
$data,
'/view/1'
);
expect($result->isSuccess())->toBeTrue();
expect($result->message)->toBe('Created');
expect($result->data)->toBe($data);
expect($result->redirectUrl)->toBe('/view/1');
expect($result->hasRedirect())->toBeTrue();
});
});

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
use App\Framework\MagicLinks\MagicLinkData;
use App\Framework\MagicLinks\TokenAction;
use App\Framework\MagicLinks\ValueObjects\MagicLinkPayload;
describe('MagicLinkData', function () {
beforeEach(function () {
$this->now = new DateTimeImmutable();
$this->futureExpiry = $this->now->modify('+1 hour');
$this->pastExpiry = $this->now->modify('-1 hour');
});
it('creates valid magiclink data', function () {
$action = new TokenAction('email_verification');
$payload = MagicLinkPayload::fromArray(['user_id' => 123, 'email' => 'test@example.com']);
$data = new MagicLinkData(
id: 'test-id-123',
action: $action,
payload: $payload,
expiresAt: $this->futureExpiry,
createdAt: $this->now
);
expect($data->id)->toBe('test-id-123');
expect($data->action)->toBe($action);
expect($data->payload)->toBe($payload);
expect($data->expiresAt)->toBe($this->futureExpiry);
expect($data->createdAt)->toBe($this->now);
expect($data->oneTimeUse)->toBeFalse();
expect($data->isUsed)->toBeFalse();
expect($data->usedAt)->toBeNull();
});
it('detects expired tokens', function () {
$data = new MagicLinkData(
id: 'expired-token',
action: new TokenAction('test_action'),
payload: MagicLinkPayload::fromArray(['test' => 'data']),
expiresAt: $this->pastExpiry,
createdAt: $this->now
);
expect($data->isExpired())->toBeTrue();
expect($data->isValid())->toBeFalse();
});
it('detects valid non-expired tokens', function () {
$data = new MagicLinkData(
id: 'valid-token',
action: new TokenAction('test_action'),
payload: MagicLinkPayload::fromArray(['test' => 'data']),
expiresAt: $this->futureExpiry,
createdAt: $this->now
);
expect($data->isExpired())->toBeFalse();
expect($data->isValid())->toBeTrue();
});
it('validates one-time use tokens', function () {
$data = new MagicLinkData(
id: 'one-time-token',
action: new TokenAction('test_action'),
payload: MagicLinkPayload::fromArray(['test' => 'data']),
expiresAt: $this->futureExpiry,
createdAt: $this->now,
oneTimeUse: true,
isUsed: false
);
expect($data->isValid())->toBeTrue();
// After marking as used
$usedData = $data->withUsed(new DateTimeImmutable());
expect($usedData->isValid())->toBeFalse();
expect($usedData->isUsed)->toBeTrue();
});
it('calculates remaining time correctly', function () {
$oneHourFuture = $this->now->modify('+1 hour');
$data = new MagicLinkData(
id: 'test-token',
action: new TokenAction('test_action'),
payload: MagicLinkPayload::fromArray(['test' => 'data']),
expiresAt: $oneHourFuture,
createdAt: $this->now
);
$remaining = $data->getRemainingTime();
// Should be approximately 1 hour (allowing for small execution time variance)
expect($remaining->h)->toBeGreaterThanOrEqual(0);
expect($remaining->i)->toBeGreaterThan(50); // At least 50 minutes
});
it('returns zero interval for expired tokens', function () {
$data = new MagicLinkData(
id: 'expired-token',
action: new TokenAction('test_action'),
payload: MagicLinkPayload::fromArray(['test' => 'data']),
expiresAt: $this->pastExpiry,
createdAt: $this->now
);
$remaining = $data->getRemainingTime();
expect($remaining->d)->toBe(0); // d = day component
expect($remaining->h)->toBe(0); // h = hour component
expect($remaining->i)->toBe(0); // i = minute component
});
it('marks token as used immutably', function () {
$originalData = new MagicLinkData(
id: 'test-token',
action: new TokenAction('test_action'),
payload: MagicLinkPayload::fromArray(['test' => 'data']),
expiresAt: $this->futureExpiry,
createdAt: $this->now,
oneTimeUse: true
);
$usedAt = new DateTimeImmutable('+10 seconds');
$usedData = $originalData->withUsed($usedAt);
// Original unchanged
expect($originalData->isUsed)->toBeFalse();
expect($originalData->usedAt)->toBeNull();
// New instance has updated values
expect($usedData->isUsed)->toBeTrue();
expect($usedData->usedAt)->toBe($usedAt);
expect($usedData->id)->toBe($originalData->id);
expect($usedData->action)->toBe($originalData->action);
});
it('stores IP address and user agent', function () {
$data = new MagicLinkData(
id: 'test-token',
action: new TokenAction('test_action'),
payload: MagicLinkPayload::fromArray(['test' => 'data']),
expiresAt: $this->futureExpiry,
createdAt: $this->now,
createdByIp: '192.168.1.1',
userAgent: 'Mozilla/5.0 Test Browser'
);
expect($data->createdByIp)->toBe('192.168.1.1');
expect($data->userAgent)->toBe('Mozilla/5.0 Test Browser');
});
});

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Framework\MagicLinks\MagicLinkToken;
describe('MagicLinkToken Value Object', function () {
it('creates valid token', function () {
$token = new MagicLinkToken('abcdefghijklmnopqrstuvwxyz123456');
expect($token->value)->toBe('abcdefghijklmnopqrstuvwxyz123456');
expect((string) $token)->toBe('abcdefghijklmnopqrstuvwxyz123456');
});
it('requires at least 16 characters', function () {
expect(fn() => new MagicLinkToken('short'))
->toThrow(\InvalidArgumentException::class, 'Token must be at least 16 characters long');
});
it('accepts exactly 16 characters', function () {
$token = new MagicLinkToken('1234567890123456');
expect($token->value)->toBe('1234567890123456');
});
it('accepts more than 16 characters', function () {
$longToken = str_repeat('a', 64);
$token = new MagicLinkToken($longToken);
expect($token->value)->toBe($longToken);
});
it('rejects empty string', function () {
expect(fn() => new MagicLinkToken(''))
->toThrow(\InvalidArgumentException::class, 'Token value cannot be empty');
});
it('compares tokens correctly', function () {
$token1 = new MagicLinkToken('abcdefghijklmnop');
$token2 = new MagicLinkToken('abcdefghijklmnop');
$token3 = new MagicLinkToken('different-token-');
expect($token1->equals($token2))->toBeTrue();
expect($token1->equals($token3))->toBeFalse();
});
it('uses constant-time comparison', function () {
$token1 = new MagicLinkToken('1234567890123456');
$token2 = new MagicLinkToken('1234567890123456');
// hash_equals is used internally, which is timing-safe
expect($token1->equals($token2))->toBeTrue();
});
it('converts to string', function () {
$tokenValue = 'test-token-value-123';
$token = new MagicLinkToken($tokenValue);
expect($token->__toString())->toBe($tokenValue);
expect((string) $token)->toBe($tokenValue);
});
});

View File

@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
use App\Framework\DateTime\SystemClock;
use App\Framework\MagicLinks\MagicLinkToken;
use App\Framework\MagicLinks\Services\InMemoryMagicLinkService;
use App\Framework\MagicLinks\TokenAction;
use App\Framework\MagicLinks\TokenConfig;
use App\Framework\Ulid\UlidGenerator;
beforeEach(function () {
$this->clock = new SystemClock();
$this->ulidGenerator = new UlidGenerator();
$this->service = new InMemoryMagicLinkService($this->ulidGenerator, $this->clock);
});
describe('InMemoryMagicLinkService - Token Generation', function () {
it('generates valid token', function () {
$action = new TokenAction('email_verification');
$payload = ['user_id' => 123, 'email' => 'test@example.com'];
$token = $this->service->generate($action, $payload);
expect($token)->toBeInstanceOf(MagicLinkToken::class);
expect(strlen($token->value))->toBeGreaterThanOrEqual(16);
});
it('generates unique tokens for each call', function () {
$action = new TokenAction('test_action');
$payload = ['data' => 'test'];
$token1 = $this->service->generate($action, $payload);
$token2 = $this->service->generate($action, $payload);
expect($token1->equals($token2))->toBeFalse();
});
it('stores token with default configuration', function () {
$action = new TokenAction('test_action');
$payload = ['test' => 'data'];
$token = $this->service->generate($action, $payload);
expect($this->service->exists($token))->toBeTrue();
});
it('stores token with custom configuration', function () {
$action = new TokenAction('test_action');
$payload = ['test' => 'data'];
$config = new TokenConfig(expiryHours: 48, oneTimeUse: true);
$token = $this->service->generate($action, $payload, $config);
expect($this->service->exists($token))->toBeTrue();
});
it('stores IP address and user agent', function () {
$action = new TokenAction('test_action');
$payload = ['test' => 'data'];
$token = $this->service->generate(
$action,
$payload,
createdByIp: '192.168.1.1',
userAgent: 'Test Browser'
);
$data = $this->service->validate($token);
expect($data->createdByIp)->toBe('192.168.1.1');
expect($data->userAgent)->toBe('Test Browser');
});
});
describe('InMemoryMagicLinkService - Token Validation', function () {
it('validates existing token', function () {
$action = new TokenAction('test_action');
$payload = ['user_id' => 123];
$token = $this->service->generate($action, $payload);
$data = $this->service->validate($token);
expect($data)->toBeInstanceOf(\App\Framework\MagicLinks\MagicLinkData::class);
expect($data->action->name)->toBe('test_action');
expect($data->payload->get('user_id'))->toBe(123);
});
it('returns null for non-existent token', function () {
$nonExistentToken = new MagicLinkToken('01234567890123456789012345678901');
$data = $this->service->validate($nonExistentToken);
expect($data)->toBeNull();
});
it('returns null for used one-time token', function () {
$action = new TokenAction('test_action');
$payload = ['test' => 'data'];
$config = new TokenConfig(oneTimeUse: true);
$token = $this->service->generate($action, $payload, $config);
$this->service->markAsUsed($token);
$data = $this->service->validate($token);
expect($data)->toBeNull();
});
});
describe('InMemoryMagicLinkService - Token Usage', function () {
it('marks token as used', function () {
$action = new TokenAction('test_action');
$payload = ['test' => 'data'];
$config = new TokenConfig(oneTimeUse: true);
$token = $this->service->generate($action, $payload, $config);
// Valid before use
expect($this->service->validate($token))->toBeInstanceOf(\App\Framework\MagicLinks\MagicLinkData::class);
$this->service->markAsUsed($token);
// Invalid after use (one-time use)
expect($this->service->validate($token))->toBeNull();
});
it('allows marking non-one-time tokens as used', function () {
$action = new TokenAction('test_action');
$payload = ['test' => 'data'];
$config = new TokenConfig(oneTimeUse: false);
$token = $this->service->generate($action, $payload, $config);
$this->service->markAsUsed($token);
// Still valid (not one-time use)
expect($this->service->validate($token))->toBeInstanceOf(\App\Framework\MagicLinks\MagicLinkData::class);
});
it('handles marking non-existent token gracefully', function () {
$nonExistentToken = new MagicLinkToken('01234567890123456789012345678901');
// Should not throw exception
$this->service->markAsUsed($nonExistentToken);
expect(true)->toBeTrue();
});
});
describe('InMemoryMagicLinkService - Token Revocation', function () {
it('revokes token', function () {
$action = new TokenAction('test_action');
$payload = ['test' => 'data'];
$token = $this->service->generate($action, $payload);
// Exists before revocation
expect($this->service->exists($token))->toBeTrue();
$this->service->revoke($token);
// Does not exist after revocation
expect($this->service->exists($token))->toBeFalse();
expect($this->service->validate($token))->toBeNull();
});
it('handles revoking non-existent token gracefully', function () {
$nonExistentToken = new MagicLinkToken('01234567890123456789012345678901');
// Should not throw exception
$this->service->revoke($nonExistentToken);
expect(true)->toBeTrue();
});
});
describe('InMemoryMagicLinkService - Token Existence', function () {
it('checks token existence correctly', function () {
$action = new TokenAction('test_action');
$payload = ['test' => 'data'];
$token = $this->service->generate($action, $payload);
expect($this->service->exists($token))->toBeTrue();
});
it('returns false for non-existent token', function () {
$nonExistentToken = new MagicLinkToken('01234567890123456789012345678901');
expect($this->service->exists($nonExistentToken))->toBeFalse();
});
it('returns false for revoked token', function () {
$action = new TokenAction('test_action');
$payload = ['test' => 'data'];
$token = $this->service->generate($action, $payload);
$this->service->revoke($token);
expect($this->service->exists($token))->toBeFalse();
});
});
describe('InMemoryMagicLinkService - Active Tokens', function () {
it('retrieves active tokens', function () {
$action = new TokenAction('test_action');
// Generate multiple tokens
$this->service->generate($action, ['id' => 1]);
$this->service->generate($action, ['id' => 2]);
$this->service->generate($action, ['id' => 3]);
$activeTokens = $this->service->getActiveTokens();
expect($activeTokens)->toHaveCount(3);
});
it('respects limit parameter', function () {
$action = new TokenAction('test_action');
// Generate 5 tokens
for ($i = 1; $i <= 5; $i++) {
$this->service->generate($action, ['id' => $i]);
}
$activeTokens = $this->service->getActiveTokens(limit: 3);
expect($activeTokens)->toHaveCount(3);
});
it('excludes used one-time tokens from active list', function () {
$action = new TokenAction('test_action');
$config = new TokenConfig(oneTimeUse: true);
$token1 = $this->service->generate($action, ['id' => 1], $config);
$token2 = $this->service->generate($action, ['id' => 2], $config);
$this->service->markAsUsed($token1);
$activeTokens = $this->service->getActiveTokens();
expect($activeTokens)->toHaveCount(1);
});
it('returns empty array when no active tokens', function () {
$activeTokens = $this->service->getActiveTokens();
expect($activeTokens)->toBeArray();
expect($activeTokens)->toBeEmpty();
});
});
describe('InMemoryMagicLinkService - Cleanup', function () {
it('removes expired tokens', function () {
$action = new TokenAction('test_action');
// Create token, then manually expire it by waiting
// We'll test cleanup by generating tokens and checking count
// Since we can't create expired tokens directly (validation prevents it),
// we'll just verify cleanup works with no expired tokens
$config = new TokenConfig(expiryHours: 1);
$token1 = $this->service->generate($action, ['id' => 1], $config);
$token2 = $this->service->generate($action, ['id' => 2], $config);
// Initially should have 2 tokens
expect($this->service->getActiveTokens())->toHaveCount(2);
// Cleanup with no expired tokens should return 0
$removedCount = $this->service->cleanupExpired();
expect($removedCount)->toBe(0);
// Both tokens should still exist
expect($this->service->exists($token1))->toBeTrue();
expect($this->service->exists($token2))->toBeTrue();
});
it('returns zero when no expired tokens', function () {
$action = new TokenAction('test_action');
$config = new TokenConfig(expiryHours: 24);
$this->service->generate($action, ['id' => 1], $config);
$this->service->generate($action, ['id' => 2], $config);
$removedCount = $this->service->cleanupExpired();
expect($removedCount)->toBe(0);
});
it('handles empty token list', function () {
$removedCount = $this->service->cleanupExpired();
expect($removedCount)->toBe(0);
});
});

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use App\Framework\MagicLinks\TokenAction;
describe('TokenAction Value Object', function () {
it('creates valid action', function () {
$action = new TokenAction('email_verification');
expect($action->name)->toBe('email_verification');
expect((string) $action)->toBe('email_verification');
});
it('allows lowercase letters and underscores', function () {
$validActions = [
'email_verification',
'password_reset',
'document_access',
'user_invitation',
'simple',
'with_multiple_underscores',
];
foreach ($validActions as $actionName) {
$action = new TokenAction($actionName);
expect($action->name)->toBe($actionName);
}
});
it('rejects empty name', function () {
expect(fn() => new TokenAction(''))
->toThrow(\InvalidArgumentException::class, 'Action name cannot be empty');
});
it('rejects uppercase letters', function () {
expect(fn() => new TokenAction('EmailVerification'))
->toThrow(\InvalidArgumentException::class, 'Action name must contain only lowercase letters and underscores');
});
it('rejects numbers', function () {
expect(fn() => new TokenAction('action123'))
->toThrow(\InvalidArgumentException::class, 'Action name must contain only lowercase letters and underscores');
});
it('rejects hyphens', function () {
expect(fn() => new TokenAction('email-verification'))
->toThrow(\InvalidArgumentException::class, 'Action name must contain only lowercase letters and underscores');
});
it('rejects spaces', function () {
expect(fn() => new TokenAction('email verification'))
->toThrow(\InvalidArgumentException::class, 'Action name must contain only lowercase letters and underscores');
});
it('rejects special characters', function () {
$invalidActions = ['action@test', 'action.test', 'action!', 'action#', 'action$'];
foreach ($invalidActions as $actionName) {
expect(fn() => new TokenAction($actionName))
->toThrow(\InvalidArgumentException::class);
}
});
it('compares actions correctly', function () {
$action1 = new TokenAction('email_verification');
$action2 = new TokenAction('email_verification');
$action3 = new TokenAction('password_reset');
expect($action1->equals($action2))->toBeTrue();
expect($action1->equals($action3))->toBeFalse();
});
it('converts to string', function () {
$action = new TokenAction('document_access');
expect($action->__toString())->toBe('document_access');
expect((string) $action)->toBe('document_access');
});
});