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,156 @@
<?php
declare(strict_types=1);
use App\Framework\Config\Environment;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Deployment\Ssl\Commands\SslInitCommand;
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
use App\Framework\Deployment\Ssl\ValueObjects\CertificateMode;
use App\Framework\Deployment\Ssl\ValueObjects\CertificateStatus;
use App\Framework\Deployment\Ssl\ValueObjects\DomainName;
use App\Framework\Deployment\Ssl\ValueObjects\SslConfiguration;
use App\Framework\Filesystem\FilePath;
describe('SslInitCommand', function () {
beforeEach(function () {
$this->sslService = Mockery::mock(SslCertificateService::class);
$this->environment = Mockery::mock(Environment::class);
// Setup environment mock
$this->environment->shouldReceive('get')
->with('DOMAIN_NAME', 'michaelschiemer.de')
->andReturn('example.com');
$this->environment->shouldReceive('get')
->with('SSL_EMAIL', 'mail@michaelschiemer.de')
->andReturn('admin@example.com');
$this->environment->shouldReceive('get')
->with('LETSENCRYPT_STAGING', '0')
->andReturn('1');
$this->command = new SslInitCommand(
$this->sslService,
$this->environment
);
$this->input = Mockery::mock(ConsoleInput::class);
});
afterEach(function () {
Mockery::close();
});
it('executes successfully when test and obtain succeed', function () {
// Mock test success
$this->sslService->shouldReceive('test')
->once()
->andReturn(true);
// Mock obtain success
$status = new CertificateStatus(
exists: true,
isValid: true,
notBefore: new DateTimeImmutable('-30 days'),
notAfter: new DateTimeImmutable('+60 days'),
issuer: 'Let\'s Encrypt',
subject: 'example.com',
daysUntilExpiry: 60,
isExpiring: false,
isExpired: false
);
$this->sslService->shouldReceive('obtain')
->once()
->andReturn($status);
$exitCode = $this->command->execute($this->input);
expect($exitCode)->toBe(ExitCode::SUCCESS);
});
it('fails when test fails', function () {
// Mock test failure
$this->sslService->shouldReceive('test')
->once()
->andReturn(false);
// obtain should not be called
$this->sslService->shouldNotReceive('obtain');
$exitCode = $this->command->execute($this->input);
expect($exitCode)->toBe(ExitCode::FAILURE);
});
it('fails when obtain throws exception', function () {
// Mock test success
$this->sslService->shouldReceive('test')
->once()
->andReturn(true);
// Mock obtain exception
$this->sslService->shouldReceive('obtain')
->once()
->andThrow(new \RuntimeException('Failed to obtain certificate'));
$exitCode = $this->command->execute($this->input);
expect($exitCode)->toBe(ExitCode::FAILURE);
});
it('uses staging mode from environment', function () {
$this->sslService->shouldReceive('test')
->once()
->with(Mockery::on(function ($config) {
return $config->mode === CertificateMode::STAGING;
}))
->andReturn(true);
$this->sslService->shouldReceive('obtain')
->once()
->with(Mockery::on(function ($config) {
return $config->mode === CertificateMode::STAGING;
}))
->andReturn(new CertificateStatus(
exists: true,
isValid: true,
notBefore: null,
notAfter: null,
issuer: null,
subject: null,
daysUntilExpiry: 60,
isExpiring: false,
isExpired: false
));
$this->command->execute($this->input);
});
it('displays certificate information after obtain', function () {
$this->sslService->shouldReceive('test')
->once()
->andReturn(true);
$status = new CertificateStatus(
exists: true,
isValid: true,
notBefore: new DateTimeImmutable('2024-01-01'),
notAfter: new DateTimeImmutable('2024-12-31'),
issuer: 'Let\'s Encrypt Authority X3',
subject: 'example.com',
daysUntilExpiry: 90,
isExpiring: false,
isExpired: false
);
$this->sslService->shouldReceive('obtain')
->once()
->andReturn($status);
$exitCode = $this->command->execute($this->input);
expect($exitCode)->toBe(ExitCode::SUCCESS);
});
});

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
use App\Framework\Config\Environment;
use App\Framework\Deployment\Ssl\HealthChecks\SslCertificateHealthCheck;
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
use App\Framework\Deployment\Ssl\ValueObjects\CertificateStatus;
use App\Framework\Deployment\Ssl\ValueObjects\DomainName;
use App\Framework\Health\HealthCheckCategory;
use App\Framework\Health\HealthStatus;
describe('SslCertificateHealthCheck', function () {
beforeEach(function () {
$this->sslService = Mockery::mock(SslCertificateService::class);
$this->environment = Mockery::mock(Environment::class);
// Setup environment mock
$this->environment->shouldReceive('get')
->with('DOMAIN_NAME', 'michaelschiemer.de')
->andReturn('example.com');
$this->environment->shouldReceive('get')
->with('SSL_EMAIL', 'mail@michaelschiemer.de')
->andReturn('admin@example.com');
$this->environment->shouldReceive('get')
->with('LETSENCRYPT_STAGING', '0')
->andReturn('0');
$this->healthCheck = new SslCertificateHealthCheck(
$this->sslService,
$this->environment
);
});
afterEach(function () {
Mockery::close();
});
it('returns healthy status for valid certificate', function () {
$status = new CertificateStatus(
exists: true,
isValid: true,
notBefore: new DateTimeImmutable('-30 days'),
notAfter: new DateTimeImmutable('+60 days'),
issuer: 'Let\'s Encrypt Authority X3',
subject: 'example.com',
daysUntilExpiry: 60,
isExpiring: false,
isExpired: false
);
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($status);
$result = $this->healthCheck->check();
expect($result->status)->toBe(HealthStatus::HEALTHY);
expect($result->operation)->toBe('SSL Certificate Check');
expect($result->details['domain'])->toBe('example.com');
expect($result->details['days_until_expiry'])->toBe(60);
});
it('returns unhealthy status when certificate does not exist', function () {
$status = CertificateStatus::notFound();
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($status);
$result = $this->healthCheck->check();
expect($result->status)->toBe(HealthStatus::UNHEALTHY);
expect($result->reason)->toBe('Certificate not found');
});
it('returns unhealthy status for expired certificate', function () {
$status = new CertificateStatus(
exists: true,
isValid: false,
notBefore: new DateTimeImmutable('-180 days'),
notAfter: new DateTimeImmutable('-10 days'),
issuer: 'Let\'s Encrypt Authority X3',
subject: 'example.com',
daysUntilExpiry: -10,
isExpiring: false,
isExpired: true
);
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($status);
$result = $this->healthCheck->check();
expect($result->status)->toBe(HealthStatus::UNHEALTHY);
expect($result->reason)->toBe('Certificate has expired');
expect($result->details['days_since_expiry'])->toBe(10);
});
it('returns warning status for expiring certificate', function () {
$status = new CertificateStatus(
exists: true,
isValid: true,
notBefore: new DateTimeImmutable('-60 days'),
notAfter: new DateTimeImmutable('+20 days'),
issuer: 'Let\'s Encrypt Authority X3',
subject: 'example.com',
daysUntilExpiry: 20,
isExpiring: true,
isExpired: false
);
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($status);
$result = $this->healthCheck->check();
expect($result->status)->toBe(HealthStatus::WARNING);
expect($result->reason)->toBe('Certificate expires in 20 days');
expect($result->details['days_until_expiry'])->toBe(20);
});
it('returns unhealthy status for invalid certificate', function () {
$status = new CertificateStatus(
exists: true,
isValid: false,
notBefore: new DateTimeImmutable('-30 days'),
notAfter: new DateTimeImmutable('+60 days'),
issuer: null,
subject: null,
daysUntilExpiry: 60,
isExpiring: false,
isExpired: false,
errors: ['Certificate verification failed']
);
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($status);
$result = $this->healthCheck->check();
expect($result->status)->toBe(HealthStatus::UNHEALTHY);
expect($result->reason)->toBe('Certificate is invalid');
expect($result->details['errors'])->toContain('Certificate verification failed');
});
it('returns unhealthy status on exception', function () {
$this->sslService->shouldReceive('getStatus')
->once()
->andThrow(new \RuntimeException('Connection failed'));
$result = $this->healthCheck->check();
expect($result->status)->toBe(HealthStatus::UNHEALTHY);
expect($result->reason)->toBe('Health check failed');
expect($result->exception)->toBeInstanceOf(\RuntimeException::class);
});
it('has correct name and category', function () {
expect($this->healthCheck->getName())->toBe('SSL Certificate');
expect($this->healthCheck->getCategory())->toBe(HealthCheckCategory::SECURITY);
});
it('has reasonable timeout', function () {
$timeout = $this->healthCheck->getTimeout();
expect($timeout)->toBeInt();
expect($timeout)->toBeGreaterThan(0);
expect($timeout)->toBeLessThanOrEqual(10000); // Max 10 seconds
});
it('measures response time', function () {
$status = new CertificateStatus(
exists: true,
isValid: true,
notBefore: null,
notAfter: null,
issuer: null,
subject: null,
daysUntilExpiry: 60,
isExpiring: false,
isExpired: false
);
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($status);
$result = $this->healthCheck->check();
expect($result->responseTimeMs)->toBeFloat();
expect($result->responseTimeMs)->toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
use App\Framework\Config\Environment;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Deployment\Ssl\Jobs\SslCertificateRenewalJob;
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
use App\Framework\Deployment\Ssl\ValueObjects\CertificateMode;
use App\Framework\Deployment\Ssl\ValueObjects\CertificateStatus;
use App\Framework\Deployment\Ssl\ValueObjects\DomainName;
use App\Framework\Deployment\Ssl\ValueObjects\SslConfiguration;
use App\Framework\Filesystem\FilePath;
use App\Framework\Logging\Logger;
use App\Framework\Worker\Every;
use App\Framework\Worker\Schedule;
describe('SslCertificateRenewalJob', function () {
beforeEach(function () {
$this->sslService = Mockery::mock(SslCertificateService::class);
$this->environment = Mockery::mock(Environment::class);
$this->logger = Mockery::mock(Logger::class);
// Setup environment mock
$this->environment->shouldReceive('get')
->with('DOMAIN_NAME', 'michaelschiemer.de')
->andReturn('example.com');
$this->environment->shouldReceive('get')
->with('SSL_EMAIL', 'mail@michaelschiemer.de')
->andReturn('admin@example.com');
$this->environment->shouldReceive('get')
->with('LETSENCRYPT_STAGING', '0')
->andReturn('0');
$this->job = new SslCertificateRenewalJob(
$this->sslService,
$this->environment,
$this->logger
);
});
afterEach(function () {
Mockery::close();
});
it('has schedule attribute', function () {
$reflection = new ReflectionClass(SslCertificateRenewalJob::class);
$attributes = $reflection->getAttributes(Schedule::class);
expect($attributes)->toHaveCount(1);
$schedule = $attributes[0]->newInstance();
expect($schedule->at)->toBeInstanceOf(Every::class);
expect($schedule->at->days)->toBe(1);
});
it('skips renewal when certificate does not exist', function () {
$status = CertificateStatus::notFound();
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($status);
// Renewal should not be attempted
$this->sslService->shouldNotReceive('renew');
$this->logger->shouldReceive('info')->once();
$this->logger->shouldReceive('warning')->once();
$result = $this->job->handle();
expect($result['success'])->toBeFalse();
expect($result['reason'])->toBe('certificate_not_found');
});
it('skips renewal when certificate is valid and not expiring', function () {
$status = new CertificateStatus(
exists: true,
isValid: true,
notBefore: new DateTimeImmutable('-30 days'),
notAfter: new DateTimeImmutable('+60 days'),
issuer: 'Let\'s Encrypt',
subject: 'example.com',
daysUntilExpiry: 60,
isExpiring: false,
isExpired: false
);
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($status);
// Renewal should not be attempted
$this->sslService->shouldNotReceive('renew');
$this->logger->shouldReceive('info')->twice();
$result = $this->job->handle();
expect($result['success'])->toBeTrue();
expect($result['renewed'])->toBeFalse();
expect($result['reason'])->toBe('not_needed');
expect($result['days_until_expiry'])->toBe(60);
});
it('renews certificate when expiring', function () {
$oldStatus = new CertificateStatus(
exists: true,
isValid: true,
notBefore: new DateTimeImmutable('-60 days'),
notAfter: new DateTimeImmutable('+20 days'),
issuer: 'Let\'s Encrypt',
subject: 'example.com',
daysUntilExpiry: 20,
isExpiring: true,
isExpired: false
);
$newStatus = new CertificateStatus(
exists: true,
isValid: true,
notBefore: new DateTimeImmutable('now'),
notAfter: new DateTimeImmutable('+90 days'),
issuer: 'Let\'s Encrypt',
subject: 'example.com',
daysUntilExpiry: 90,
isExpiring: false,
isExpired: false
);
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($oldStatus);
$this->sslService->shouldReceive('renew')
->once()
->andReturn($newStatus);
$this->logger->shouldReceive('info')->times(3);
$result = $this->job->handle();
expect($result['success'])->toBeTrue();
expect($result['renewed'])->toBeTrue();
expect($result['days_until_expiry'])->toBe(90);
});
it('renews certificate when expired', function () {
$oldStatus = new CertificateStatus(
exists: true,
isValid: false,
notBefore: new DateTimeImmutable('-180 days'),
notAfter: new DateTimeImmutable('-10 days'),
issuer: 'Let\'s Encrypt',
subject: 'example.com',
daysUntilExpiry: -10,
isExpiring: false,
isExpired: true
);
$newStatus = new CertificateStatus(
exists: true,
isValid: true,
notBefore: new DateTimeImmutable('now'),
notAfter: new DateTimeImmutable('+90 days'),
issuer: 'Let\'s Encrypt',
subject: 'example.com',
daysUntilExpiry: 90,
isExpiring: false,
isExpired: false
);
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($oldStatus);
$this->sslService->shouldReceive('renew')
->once()
->andReturn($newStatus);
$this->logger->shouldReceive('info')->times(3);
$result = $this->job->handle();
expect($result['success'])->toBeTrue();
expect($result['renewed'])->toBeTrue();
});
it('handles renewal exception gracefully', function () {
$status = new CertificateStatus(
exists: true,
isValid: true,
notBefore: null,
notAfter: null,
issuer: null,
subject: null,
daysUntilExpiry: 20,
isExpiring: true,
isExpired: false
);
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($status);
$this->sslService->shouldReceive('renew')
->once()
->andThrow(new \RuntimeException('Renewal failed'));
$this->logger->shouldReceive('info')->twice();
$this->logger->shouldReceive('error')->once();
$result = $this->job->handle();
expect($result['success'])->toBeFalse();
expect($result['error'])->toBe('Renewal failed');
});
it('logs renewal details', function () {
$oldStatus = new CertificateStatus(
exists: true,
isValid: true,
notBefore: new DateTimeImmutable('2024-01-01'),
notAfter: new DateTimeImmutable('2024-03-15'),
issuer: 'Let\'s Encrypt',
subject: 'example.com',
daysUntilExpiry: 20,
isExpiring: true,
isExpired: false
);
$newStatus = new CertificateStatus(
exists: true,
isValid: true,
notBefore: new DateTimeImmutable('2024-03-01'),
notAfter: new DateTimeImmutable('2024-05-30'),
issuer: 'Let\'s Encrypt',
subject: 'example.com',
daysUntilExpiry: 90,
isExpiring: false,
isExpired: false
);
$this->sslService->shouldReceive('getStatus')
->once()
->andReturn($oldStatus);
$this->sslService->shouldReceive('renew')
->once()
->andReturn($newStatus);
$this->logger->shouldReceive('info')
->with('Running scheduled SSL certificate renewal check', Mockery::any())
->once();
$this->logger->shouldReceive('info')
->with('SSL certificate renewal needed, starting renewal', Mockery::any())
->once();
$this->logger->shouldReceive('info')
->with('SSL certificate renewed successfully', Mockery::on(function ($context) {
return isset($context['old_expiry'])
&& isset($context['new_expiry'])
&& isset($context['new_days_until_expiry']);
}))
->once();
$result = $this->job->handle();
expect($result['success'])->toBeTrue();
expect($result['renewed'])->toBeTrue();
});
});

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
use App\Framework\Config\Environment;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Deployment\Ssl\Events\SslCertificateObtained;
use App\Framework\Deployment\Ssl\Events\SslCertificateRenewed;
use App\Framework\Deployment\Ssl\Events\SslCertificateRevoked;
use App\Framework\Deployment\Ssl\Exceptions\CertificateObtainFailedException;
use App\Framework\Deployment\Ssl\Exceptions\CertificateRenewalFailedException;
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
use App\Framework\Deployment\Ssl\ValueObjects\CertificateMode;
use App\Framework\Deployment\Ssl\ValueObjects\DomainName;
use App\Framework\Deployment\Ssl\ValueObjects\SslConfiguration;
use App\Framework\EventBus\EventBus;
use App\Framework\Filesystem\FilePath;
use App\Framework\Logging\Logger;
use App\Framework\Process\Process;
use App\Framework\Process\ProcessResult;
describe('SslCertificateService', function () {
beforeEach(function () {
$this->process = Mockery::mock(Process::class);
$this->eventBus = Mockery::mock(EventBus::class);
$this->logger = Mockery::mock(Logger::class);
$this->service = new SslCertificateService(
$this->process,
$this->eventBus,
$this->logger
);
$this->config = new SslConfiguration(
domain: DomainName::fromString('example.com'),
email: new Email('admin@example.com'),
mode: CertificateMode::STAGING,
certbotConfDir: FilePath::create('/tmp/certbot-conf'),
certbotWwwDir: FilePath::create('/tmp/certbot-www')
);
});
afterEach(function () {
Mockery::close();
});
describe('obtain', function () {
it('obtains certificate successfully', function () {
// Mock successful certbot execution
$this->process->shouldReceive('run')
->once()
->andReturn(new ProcessResult(
exitCode: 0,
output: 'Certificate obtained successfully',
errorOutput: ''
));
// Expect event dispatch
$this->eventBus->shouldReceive('dispatch')
->once()
->with(Mockery::type(SslCertificateObtained::class));
// Expect info logging
$this->logger->shouldReceive('info')
->twice();
$status = $this->service->obtain($this->config);
expect($status)->not->toBeNull();
});
it('throws exception on certbot failure', function () {
// Mock failed certbot execution
$this->process->shouldReceive('run')
->once()
->andReturn(new ProcessResult(
exitCode: 1,
output: '',
errorOutput: 'Failed to obtain certificate'
));
// Expect error logging
$this->logger->shouldReceive('info')->once();
$this->logger->shouldReceive('error')->once();
$this->service->obtain($this->config);
})->throws(CertificateObtainFailedException::class);
it('uses staging mode flag', function () {
$this->process->shouldReceive('run')
->once()
->with(Mockery::on(function ($command) {
return in_array('--staging', $command, true);
}))
->andReturn(new ProcessResult(0, 'Success', ''));
$this->eventBus->shouldReceive('dispatch')->once();
$this->logger->shouldReceive('info')->twice();
$this->service->obtain($this->config);
});
it('uses production mode without staging flag', function () {
$productionConfig = new SslConfiguration(
domain: DomainName::fromString('example.com'),
email: new Email('admin@example.com'),
mode: CertificateMode::PRODUCTION,
certbotConfDir: FilePath::create('/tmp/certbot-conf'),
certbotWwwDir: FilePath::create('/tmp/certbot-www')
);
$this->process->shouldReceive('run')
->once()
->with(Mockery::on(function ($command) {
return !in_array('--staging', $command, true);
}))
->andReturn(new ProcessResult(0, 'Success', ''));
$this->eventBus->shouldReceive('dispatch')->once();
$this->logger->shouldReceive('info')->twice();
$this->service->obtain($productionConfig);
});
});
describe('renew', function () {
it('renews certificate successfully', function () {
// Mock successful renewal
$this->process->shouldReceive('run')
->once()
->andReturn(new ProcessResult(
exitCode: 0,
output: 'Certificate renewed successfully',
errorOutput: ''
));
// Expect event dispatch
$this->eventBus->shouldReceive('dispatch')
->once()
->with(Mockery::type(SslCertificateRenewed::class));
// Expect info logging
$this->logger->shouldReceive('info')
->twice();
$status = $this->service->renew($this->config);
expect($status)->not->toBeNull();
});
it('throws exception on renewal failure', function () {
// Mock failed renewal
$this->process->shouldReceive('run')
->once()
->andReturn(new ProcessResult(
exitCode: 1,
output: '',
errorOutput: 'Renewal failed'
));
// Expect error logging
$this->logger->shouldReceive('info')->once();
$this->logger->shouldReceive('error')->once();
$this->service->renew($this->config);
})->throws(CertificateRenewalFailedException::class);
it('forces renewal with --force-renewal flag', function () {
$this->process->shouldReceive('run')
->once()
->with(Mockery::on(function ($command) {
return in_array('--force-renewal', $command, true);
}))
->andReturn(new ProcessResult(0, 'Success', ''));
$this->eventBus->shouldReceive('dispatch')->once();
$this->logger->shouldReceive('info')->twice();
$this->service->renew($this->config);
});
});
describe('test', function () {
it('returns true when dry-run succeeds', function () {
// Mock successful dry-run
$this->process->shouldReceive('run')
->once()
->with(Mockery::on(function ($command) {
return in_array('--dry-run', $command, true);
}))
->andReturn(new ProcessResult(0, 'Dry run successful', ''));
$this->logger->shouldReceive('info')->twice();
$result = $this->service->test($this->config);
expect($result)->toBeTrue();
});
it('returns false when dry-run fails', function () {
// Mock failed dry-run
$this->process->shouldReceive('run')
->once()
->andReturn(new ProcessResult(1, '', 'Dry run failed'));
$this->logger->shouldReceive('info')->once();
$this->logger->shouldReceive('error')->once();
$result = $this->service->test($this->config);
expect($result)->toBeFalse();
});
});
describe('revoke', function () {
it('revokes certificate successfully', function () {
// Mock successful revocation
$this->process->shouldReceive('run')
->once()
->andReturn(new ProcessResult(0, 'Certificate revoked', ''));
// Expect event dispatch
$this->eventBus->shouldReceive('dispatch')
->once()
->with(Mockery::type(SslCertificateRevoked::class));
$this->logger->shouldReceive('info')->twice();
$this->service->revoke(
DomainName::fromString('example.com'),
FilePath::create('/tmp/certbot-conf')
);
});
it('logs error on revocation failure', function () {
// Mock failed revocation
$this->process->shouldReceive('run')
->once()
->andReturn(new ProcessResult(1, '', 'Revocation failed'));
$this->logger->shouldReceive('info')->once();
$this->logger->shouldReceive('error')->once();
// No exception thrown, just logged
$this->service->revoke(
DomainName::fromString('example.com'),
FilePath::create('/tmp/certbot-conf')
);
});
});
describe('getStatus', function () {
it('returns not found status when certificate does not exist', function () {
$status = $this->service->getStatus(
DomainName::fromString('nonexistent.com'),
'/tmp/nonexistent'
);
expect($status->exists)->toBeFalse();
expect($status->isValid)->toBeFalse();
expect($status->errors)->toContain('Certificate files not found');
});
it('detects existing certificate files', function () {
// Create temporary certificate structure for testing
$tempDir = sys_get_temp_dir() . '/ssl-test-' . uniqid();
mkdir($tempDir . '/live/example.com', 0777, true);
// Create dummy certificate files
touch($tempDir . '/live/example.com/fullchain.pem');
touch($tempDir . '/live/example.com/privkey.pem');
touch($tempDir . '/live/example.com/chain.pem');
touch($tempDir . '/live/example.com/cert.pem');
// Write a simple certificate for testing (self-signed)
$certContent = <<<'CERT'
-----BEGIN CERTIFICATE-----
MIICljCCAX4CCQCKz8Qh0YBzITANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJE
RTAEFH0NTAJNDRA0TRBMDExOVFBMDMxMVFBMFE0NNRBMFoxDTA0TjA0TRBMDExO
VFBMDMxMVFBMFkGA1UEAwwJZXhhbXBsZS5jb20wHhcNMjQwMTAxMDAwMDAwWhcN
MjUwMTAxMDAwMDAwWjANMQswCQYDVQQGEwJERTCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBAK5I8vHLMvYwH8qxHC6pLULfZ1YFz2wGzv8eQyXYjE5eZ3zZ
-----END CERTIFICATE-----
CERT;
file_put_contents($tempDir . '/live/example.com/cert.pem', $certContent);
$status = $this->service->getStatus(
DomainName::fromString('example.com'),
$tempDir
);
expect($status->exists)->toBeTrue();
// Cleanup
array_map('unlink', glob($tempDir . '/live/example.com/*'));
rmdir($tempDir . '/live/example.com');
rmdir($tempDir . '/live');
rmdir($tempDir);
});
});
});

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Framework\Deployment\Ssl\ValueObjects\CertificateMode;
describe('CertificateMode', function () {
it('has production mode', function () {
expect(CertificateMode::PRODUCTION->value)->toBe('production');
});
it('has staging mode', function () {
expect(CertificateMode::STAGING->value)->toBe('staging');
});
it('detects production mode', function () {
$mode = CertificateMode::PRODUCTION;
expect($mode->isProduction())->toBeTrue();
expect($mode->isStaging())->toBeFalse();
});
it('detects staging mode', function () {
$mode = CertificateMode::STAGING;
expect($mode->isStaging())->toBeTrue();
expect($mode->isProduction())->toBeFalse();
});
it('returns correct certbot flag for production', function () {
$mode = CertificateMode::PRODUCTION;
expect($mode->toCertbotFlag())->toBe('');
});
it('returns correct certbot flag for staging', function () {
$mode = CertificateMode::STAGING;
expect($mode->toCertbotFlag())->toBe('--staging');
});
it('has descriptive text for production', function () {
$mode = CertificateMode::PRODUCTION;
expect($mode->getDescription())
->toContain('Production')
->toContain('Let\'s Encrypt');
});
it('has descriptive text for staging', function () {
$mode = CertificateMode::STAGING;
expect($mode->getDescription())
->toContain('Staging')
->toContain('Testing');
});
});

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
use App\Framework\Deployment\Ssl\ValueObjects\CertificateStatus;
describe('CertificateStatus', function () {
it('creates not found status', function () {
$status = CertificateStatus::notFound();
expect($status->exists)->toBeFalse();
expect($status->isValid)->toBeFalse();
expect($status->errors)->toContain('Certificate files not found');
});
it('creates status from certificate data', function () {
$notBefore = new DateTimeImmutable('2024-01-01');
$notAfter = new DateTimeImmutable('2024-12-31');
$status = CertificateStatus::fromCertificateData(
$notBefore,
$notAfter,
'Let\'s Encrypt Authority X3',
'example.com'
);
expect($status->exists)->toBeTrue();
expect($status->issuer)->toBe('Let\'s Encrypt Authority X3');
expect($status->subject)->toBe('example.com');
});
it('detects expired certificate', function () {
$notBefore = new DateTimeImmutable('-2 months');
$notAfter = new DateTimeImmutable('-1 day');
$status = CertificateStatus::fromCertificateData(
$notBefore,
$notAfter,
'Test Issuer',
'example.com'
);
expect($status->isExpired)->toBeTrue();
expect($status->isValid)->toBeFalse();
});
it('detects expiring certificate', function () {
$notBefore = new DateTimeImmutable('-2 months');
$notAfter = new DateTimeImmutable('+15 days');
$status = CertificateStatus::fromCertificateData(
$notBefore,
$notAfter,
'Test Issuer',
'example.com'
);
expect($status->isExpiring)->toBeTrue();
expect($status->isExpired)->toBeFalse();
});
it('detects valid certificate', function () {
$notBefore = new DateTimeImmutable('-1 month');
$notAfter = new DateTimeImmutable('+60 days');
$status = CertificateStatus::fromCertificateData(
$notBefore,
$notAfter,
'Test Issuer',
'example.com'
);
expect($status->isValid)->toBeTrue();
expect($status->isExpiring)->toBeFalse();
expect($status->isExpired)->toBeFalse();
});
it('determines renewal needed for expired certificate', function () {
$notBefore = new DateTimeImmutable('-2 months');
$notAfter = new DateTimeImmutable('-1 day');
$status = CertificateStatus::fromCertificateData(
$notBefore,
$notAfter,
'Test Issuer',
'example.com'
);
expect($status->needsRenewal())->toBeTrue();
});
it('determines renewal needed for expiring certificate', function () {
$notBefore = new DateTimeImmutable('-1 month');
$notAfter = new DateTimeImmutable('+20 days');
$status = CertificateStatus::fromCertificateData(
$notBefore,
$notAfter,
'Test Issuer',
'example.com'
);
expect($status->needsRenewal())->toBeTrue();
});
it('determines renewal not needed for valid certificate', function () {
$notBefore = new DateTimeImmutable('-1 month');
$notAfter = new DateTimeImmutable('+60 days');
$status = CertificateStatus::fromCertificateData(
$notBefore,
$notAfter,
'Test Issuer',
'example.com'
);
expect($status->needsRenewal())->toBeFalse();
});
it('returns correct health status for expired', function () {
$status = new CertificateStatus(
exists: true,
isValid: false,
notBefore: null,
notAfter: null,
issuer: null,
subject: null,
daysUntilExpiry: -10,
isExpiring: false,
isExpired: true
);
expect($status->getHealthStatus())->toBe('expired');
});
it('returns correct health status for expiring', function () {
$status = new CertificateStatus(
exists: true,
isValid: true,
notBefore: null,
notAfter: null,
issuer: null,
subject: null,
daysUntilExpiry: 20,
isExpiring: true,
isExpired: false
);
expect($status->getHealthStatus())->toBe('expiring');
});
it('returns correct health status for invalid', function () {
$status = new CertificateStatus(
exists: true,
isValid: false,
notBefore: null,
notAfter: null,
issuer: null,
subject: null,
daysUntilExpiry: null,
isExpiring: false,
isExpired: false
);
expect($status->getHealthStatus())->toBe('invalid');
});
it('returns correct health status for missing', function () {
$status = CertificateStatus::notFound();
expect($status->getHealthStatus())->toBe('missing');
});
it('returns correct health status for healthy', function () {
$notBefore = new DateTimeImmutable('-1 month');
$notAfter = new DateTimeImmutable('+60 days');
$status = CertificateStatus::fromCertificateData(
$notBefore,
$notAfter,
'Test Issuer',
'example.com'
);
expect($status->getHealthStatus())->toBe('healthy');
});
it('converts to array', function () {
$notBefore = new DateTimeImmutable('2024-01-01');
$notAfter = new DateTimeImmutable('2024-12-31');
$status = CertificateStatus::fromCertificateData(
$notBefore,
$notAfter,
'Test Issuer',
'example.com'
);
$array = $status->toArray();
expect($array)->toHaveKey('exists');
expect($array)->toHaveKey('is_valid');
expect($array)->toHaveKey('not_before');
expect($array)->toHaveKey('not_after');
expect($array)->toHaveKey('issuer');
expect($array)->toHaveKey('subject');
expect($array)->toHaveKey('days_until_expiry');
expect($array)->toHaveKey('health_status');
});
});

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
use App\Framework\Deployment\Ssl\ValueObjects\DomainName;
describe('DomainName', function () {
it('creates domain name from valid string', function () {
$domain = DomainName::fromString('example.com');
expect($domain->value)->toBe('example.com');
});
it('accepts valid domain formats', function () {
$validDomains = [
'example.com',
'subdomain.example.com',
'sub.domain.example.com',
'example-site.com',
'example123.com',
'123example.com', // Starting with number in label is OK if not first character
'a.com',
'very-long-subdomain-name.example.com',
];
foreach ($validDomains as $domainStr) {
expect(fn() => DomainName::fromString($domainStr))
->not->toThrow(InvalidArgumentException::class);
}
});
it('rejects invalid domain formats', function () {
expect(fn() => DomainName::fromString(''))
->toThrow(InvalidArgumentException::class, 'Domain name cannot be empty');
});
it('rejects domain starting with hyphen', function () {
expect(fn() => DomainName::fromString('-example.com'))
->toThrow(InvalidArgumentException::class);
});
it('rejects domain with invalid characters', function () {
expect(fn() => DomainName::fromString('example$.com'))
->toThrow(InvalidArgumentException::class, 'Domain contains invalid characters');
});
it('rejects domain exceeding maximum length', function () {
$longDomain = str_repeat('a', 254) . '.com';
expect(fn() => DomainName::fromString($longDomain))
->toThrow(InvalidArgumentException::class, 'exceeds maximum length');
});
it('rejects label exceeding maximum length', function () {
$longLabel = str_repeat('a', 64);
expect(fn() => DomainName::fromString($longLabel . '.com'))
->toThrow(InvalidArgumentException::class, 'exceeds maximum length');
});
it('detects wildcard domains', function () {
$wildcard = DomainName::fromString('*.example.com');
expect($wildcard->isWildcard())->toBeTrue();
});
it('detects non-wildcard domains', function () {
$normal = DomainName::fromString('example.com');
expect($normal->isWildcard())->toBeFalse();
});
it('extracts TLD correctly', function () {
$domain = DomainName::fromString('subdomain.example.com');
expect($domain->getTld())->toBe('com');
});
it('extracts subdomain correctly', function () {
$domain = DomainName::fromString('sub.example.com');
expect($domain->getSubdomain())->toBe('sub');
});
it('returns null for domain without subdomain', function () {
$domain = DomainName::fromString('example.com');
expect($domain->getSubdomain())->toBeNull();
});
it('returns labels array', function () {
$domain = DomainName::fromString('sub.example.com');
expect($domain->getLabels())->toBe(['sub', 'example', 'com']);
});
it('converts to string', function () {
$domain = DomainName::fromString('example.com');
expect($domain->toString())->toBe('example.com');
expect((string) $domain)->toBe('example.com');
});
it('compares domains correctly', function () {
$domain1 = DomainName::fromString('example.com');
$domain2 = DomainName::fromString('example.com');
$domain3 = DomainName::fromString('other.com');
expect($domain1->equals($domain2))->toBeTrue();
expect($domain1->equals($domain3))->toBeFalse();
});
it('compares domains case-insensitively', function () {
$domain1 = DomainName::fromString('Example.COM');
$domain2 = DomainName::fromString('example.com');
expect($domain1->equals($domain2))->toBeTrue();
});
});