diff --git a/app/Checks/DatabaseAvailability.php b/app/Checks/DatabaseAvailability.php new file mode 100644 index 0000000..d8b28ee --- /dev/null +++ b/app/Checks/DatabaseAvailability.php @@ -0,0 +1,19 @@ +shortSummary('no database')->ok(); + } + + return parent::run(); + } +} diff --git a/app/Checks/DatabaseConnectionCountCheck.php b/app/Checks/DatabaseConnectionCountCheck.php new file mode 100644 index 0000000..e669a02 --- /dev/null +++ b/app/Checks/DatabaseConnectionCountCheck.php @@ -0,0 +1,35 @@ +shortSummary('no database')->ok(); + } + + $connection = $this->connectionName ?? $this->getDefaultConnectionName(); + + if (config("database.connections.{$connection}.driver") === 'sqlite') { + return Result::make()->shortSummary('sqlite')->ok(); + } + + return parent::run(); + } +} diff --git a/app/Checks/EntraClientSecretCheck.php b/app/Checks/EntraClientSecretCheck.php new file mode 100644 index 0000000..9b797ea --- /dev/null +++ b/app/Checks/EntraClientSecretCheck.php @@ -0,0 +1,84 @@ + config key and tenant key, in lookup order */ + private const array SOURCES = [['microsoft-entra-sso', 'tenant_id'], ['services.microsoft', 'tenant']]; + + /** Placeholder secrets some configs fall back to when SSO is not set up. */ + private const array PLACEHOLDER_SECRETS = ['', 'not-configured']; + + public function run(): Result + { + $result = Result::make(); + $credentials = $this->credentials(); + + if ($credentials === null) { + return $result->shortSummary('no sso configured')->ok(); + } + + [$tenant, $clientId, $clientSecret] = $credentials; + + try { + $response = Http::asForm()->timeout(3)->post("https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/token", [ + 'grant_type' => 'client_credentials', + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'scope' => 'https://graph.microsoft.com/.default', + ]); + } catch (Throwable $exception) { + return $result->shortSummary('entra unreachable')->failed("Entra request failed: {$exception->getMessage()}"); + } + + if ($response->successful()) { + return $result->shortSummary('secret valid')->ok(); + } + + $error = $response->json('error'); + $error = is_string($error) ? $error : ''; + + return $result->shortSummary($error !== '' ? $error : "http {$response->status()}") + ->failed('Entra rejected the client credentials ('.($error ?: $response->status()).'). Create a new client secret and update the environment.'); + } + + /** + * @return array{0: string, 1: string, 2: string}|null + */ + private function credentials(): ?array + { + foreach (self::SOURCES as [$configKey, $tenantKey]) { + $tenant = $this->configString("{$configKey}.{$tenantKey}"); + $clientId = $this->configString("{$configKey}.client_id"); + $clientSecret = $this->configString("{$configKey}.client_secret"); + + if ($tenant !== '' && $clientId !== '' && ! in_array($clientSecret, self::PLACEHOLDER_SECRETS, true)) { + return [$tenant, $clientId, $clientSecret]; + } + } + + return null; + } + + private function configString(string $key): string + { + $value = config($key); + + return is_string($value) ? $value : ''; + } +} diff --git a/app/Checks/FailedJobsCheck.php b/app/Checks/FailedJobsCheck.php new file mode 100644 index 0000000..49074d2 --- /dev/null +++ b/app/Checks/FailedJobsCheck.php @@ -0,0 +1,30 @@ +shortSummary('no database')->ok(); + } + + $count = DB::table('failed_jobs')->count(); + + $result = Result::make(); + $result->shortSummary("failed jobs table count: {$count}"); + + if ($count > 0) { + return $result->failed(); + } + + return $result->ok(); + } +} diff --git a/app/Checks/JobsCheck.php b/app/Checks/JobsCheck.php new file mode 100644 index 0000000..b7dfaa2 --- /dev/null +++ b/app/Checks/JobsCheck.php @@ -0,0 +1,58 @@ +shortSummary('no database')->ok(); + } + + $count = $this->currentJobsTableCount(); + + $result = Result::make(); + $result->shortSummary("jobs table count: {$count}"); + + if ($count <= 0) { + return $result->ok(); + } + + $newCounts = Collection::times(self::SAMPLE_COUNT, function () { + Sleep::for(self::SAMPLE_INTERVAL_MILLISECONDS)->milliseconds(); + + return $this->currentJobsTableCount(); + }); + + $allMatch = $newCounts->every(fn (int $value) => $value === $count); + + $result->shortSummary('jobs table count: '.($allMatch ? 'stable' : 'changed')); + + if (! $allMatch) { + return $result->ok(); + } + + return $result->failed(); + + } + + protected function currentJobsTableCount(): int + { + return (int) DB::table('jobs')->count(); + } +} diff --git a/app/Checks/PendingMigrationsCheck.php b/app/Checks/PendingMigrationsCheck.php new file mode 100644 index 0000000..470c3d1 --- /dev/null +++ b/app/Checks/PendingMigrationsCheck.php @@ -0,0 +1,71 @@ +shortSummary('no database')->ok(); + } + + $result = Result::make(); + + try { + $pending = $this->pending(); + } catch (Throwable $exception) { + return $result->shortSummary('unreadable')->failed("Cannot tell which migrations have run: {$exception->getMessage()}"); + } + + if ($pending === null) { + return $result->shortSummary('no migrations table')->failed('The migrations table does not exist, so this database has never been migrated.'); + } + + $result->shortSummary($pending === [] ? 'up to date' : count($pending).' pending'); + + if ($pending === []) { + return $result->ok(); + } + + $reported = array_slice($pending, 0, self::REPORTED_MIGRATIONS); + $hidden = count($pending) - count($reported); + + return $result->failed( + count($pending).' migration(s) have not run: '.implode(', ', $reported) + .($hidden > 0 ? ", and {$hidden} more" : '') + .'. Run `php artisan migrate --force`.', + ); + } + + /** + * @return list|null + */ + private function pending(): ?array + { + /** @var Migrator $migrator */ + $migrator = app('migrator'); + + if (! $migrator->repositoryExists()) { + return null; + } + + $files = $migrator->getMigrationFiles(array_merge([database_path('migrations')], $migrator->paths())); + + return array_values(array_diff(array_keys($files), $migrator->getRepository()->getRan())); + } +} diff --git a/app/Checks/PostmarkTokenCheck.php b/app/Checks/PostmarkTokenCheck.php new file mode 100644 index 0000000..259b3ec --- /dev/null +++ b/app/Checks/PostmarkTokenCheck.php @@ -0,0 +1,53 @@ +shortSummary('mailer: '.(is_string($mailer) ? $mailer : 'none'))->ok(); + } + + $token = config('services.postmark.token') ?? config('services.postmark.key'); + $token = is_string($token) ? $token : ''; + $result = Result::make(); + + if ($token === '') { + return $result->shortSummary('no token')->failed('MAIL_MAILER is postmark, but no Postmark server token is configured.'); + } + + try { + $response = Http::withHeaders(['X-Postmark-Server-Token' => $token]) + ->acceptJson() + ->timeout(3) + ->get('https://api.postmarkapp.com/server'); + } catch (Throwable $exception) { + return $result->shortSummary('postmark unreachable')->failed("Postmark request failed: {$exception->getMessage()}"); + } + + if ($response->successful()) { + return $result->shortSummary('token valid')->ok(); + } + + $message = $response->json('Message'); + + return $result->shortSummary("http {$response->status()}") + ->failed('Postmark rejected the server token: '.(is_string($message) && $message !== '' ? $message : $response->status()).'.'); + } +} diff --git a/app/Checks/ProductionConfigCheck.php b/app/Checks/ProductionConfigCheck.php new file mode 100644 index 0000000..3f86e2e --- /dev/null +++ b/app/Checks/ProductionConfigCheck.php @@ -0,0 +1,47 @@ +shortSummary($problems === [] ? 'ok' : count($problems).' problem(s)'); + + return $problems === [] ? $result->ok() : $result->failed(implode('; ', $problems).'.'); + } +} diff --git a/app/Checks/SchedulePolicyCheck.php b/app/Checks/SchedulePolicyCheck.php new file mode 100644 index 0000000..bf2a14c --- /dev/null +++ b/app/Checks/SchedulePolicyCheck.php @@ -0,0 +1,51 @@ +between()` or `->environments()` do not help: Cloud only sees the cron. + */ +final class SchedulePolicyCheck extends Check +{ + public const int MIN_INTERVAL_MINUTES = 5; + + public function run(): Result + { + $offenders = collect(app(Schedule::class)->events()) + ->filter(fn (Event $event): bool => $this->runsMoreOftenThanAllowed($event->expression)) + ->map(fn (Event $event): string => trim(($event->description ?: (string) $event->command)." ({$event->expression})")) + ->values() + ->all(); + + $result = Result::make()->shortSummary($offenders === [] ? 'ok' : count($offenders).' task(s) too frequent'); + + return $offenders === [] + ? $result->ok() + : $result->failed('Tasks run more often than every '.self::MIN_INTERVAL_MINUTES.' minutes: '.implode(', ', $offenders).'.'); + } + + private function runsMoreOftenThanAllowed(string $expression): bool + { + $minute = explode(' ', trim($expression))[0]; + + if ($minute === '*') { + return true; + } + + if (preg_match('/^\*\/(\d+)$/', $minute, $matches) === 1) { + return (int) $matches[1] < self::MIN_INTERVAL_MINUTES; + } + + return count(explode(',', $minute)) > intdiv(60, self::MIN_INTERVAL_MINUTES); + } +} diff --git a/app/Checks/StorageRoundTripCheck.php b/app/Checks/StorageRoundTripCheck.php new file mode 100644 index 0000000..0c2a78e --- /dev/null +++ b/app/Checks/StorageRoundTripCheck.php @@ -0,0 +1,48 @@ +shortSummary("{$disk} ({$driver})"); + + if ($driver === 'local') { + return $result->warning("The default disk `{$disk}` is local; files are lost on the next deploy. Attach a bucket and set FILESYSTEM_DISK."); + } + + $path = 'health/'.Str::uuid()->toString().'.txt'; + $content = 'ok '.now()->toIso8601String(); + + try { + Storage::disk($disk)->put($path, $content); + $read = Storage::disk($disk)->get($path); + Storage::disk($disk)->delete($path); + } catch (Throwable $exception) { + return $result->failed("Storage round trip on `{$disk}` failed: {$exception->getMessage()}"); + } + + return $read === $content + ? $result->ok() + : $result->failed("Storage round trip on `{$disk}` read back different content."); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1de5ac1..6ae236c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,7 +4,17 @@ namespace App\Providers; +use App\Checks\DatabaseCheck; +use App\Checks\DatabaseConnectionCountCheck; +use App\Checks\EntraClientSecretCheck; +use App\Checks\FailedJobsCheck; +use App\Checks\JobsCheck; use App\Checks\NightwatchIssuesCheck; +use App\Checks\PendingMigrationsCheck; +use App\Checks\PostmarkTokenCheck; +use App\Checks\ProductionConfigCheck; +use App\Checks\SchedulePolicyCheck; +use App\Checks\StorageRoundTripCheck; use App\Http\Middleware\ResolveProject; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -12,9 +22,10 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Spatie\Activitylog\Models\Activity; -use Spatie\Health\Checks\Checks\DatabaseCheck; +use Spatie\Health\Checks\Checks\CacheCheck; use Spatie\Health\Checks\Checks\DebugModeCheck; use Spatie\Health\Checks\Checks\EnvironmentCheck; +use Spatie\Health\Checks\Checks\OptimizedAppCheck; use Spatie\Health\Checks\Checks\ScheduleCheck; use Spatie\Health\Facades\Health; use Spatie\SecurityAdvisoriesHealthCheck\SecurityAdvisoriesCheck; @@ -29,19 +40,30 @@ public function boot(): void } /** - * Checks for Oh Dear (OH_DEAR_HEALTH_CHECK_*) and `php artisan health:check`. - * No cache or optimize checks: Laravel Cloud hibernates the app and does - * not cache config, so those would always fail. + * Checks for Oh Dear (OH_DEAR_HEALTH_CHECK_*) and `php artisan health:check`: + * the standard set agreed for all Laravel Cloud apps. OptimizedAppCheck + * needs `php artisan optimize` in the deploy command. */ private function configureHealthChecks(): void { Health::checks([ - DatabaseCheck::new(), DebugModeCheck::new(), - EnvironmentCheck::new()->if($this->app->isProduction()), - SecurityAdvisoriesCheck::new()->lastDayOfMonth(), - NightwatchIssuesCheck::new()->if(filled(config('default.nightwatch.api_token')) && filled(config('default.nightwatch.application_id'))), + EnvironmentCheck::new()->if(app()->isProduction()), + DatabaseCheck::new(), + DatabaseConnectionCountCheck::new(), + CacheCheck::new(), + OptimizedAppCheck::new(), ScheduleCheck::new()->heartbeatMaxAgeInMinutes(120), + SecurityAdvisoriesCheck::new(), + NightwatchIssuesCheck::new()->if(filled(config('default.nightwatch.api_token')) && filled(config('default.nightwatch.application_id'))), + JobsCheck::new(), + FailedJobsCheck::new(), + PendingMigrationsCheck::new(), + ProductionConfigCheck::new()->if(app()->isProduction()), + SchedulePolicyCheck::new(), + StorageRoundTripCheck::new(), + EntraClientSecretCheck::new(), + PostmarkTokenCheck::new(), ]); } diff --git a/database/migrations/2026_10_23_120000_create_sessions_table.php b/database/migrations/2026_10_23_120000_create_sessions_table.php new file mode 100644 index 0000000..2362eb4 --- /dev/null +++ b/database/migrations/2026_10_23_120000_create_sessions_table.php @@ -0,0 +1,31 @@ +string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/e2e/feedback.e2e.ts b/e2e/feedback.e2e.ts index 72768ef..c9e1b67 100644 --- a/e2e/feedback.e2e.ts +++ b/e2e/feedback.e2e.ts @@ -240,7 +240,10 @@ test('the share link on the thank you screen and the team link open the feedback expect(artisanJson('feedback:show', id).completed_at).toBeNull(); // Tampered links reveal nothing. A request, not a navigation: Firefox never fires "load" for an empty 403. - const forged = await viewer.request.get(senderUrl.replace(/signature=./, 'signature=0'), { + // Flip the first signature character; replacing it with a fixed '0' left the link valid whenever the signature already started with 0. + const forgedUrl = senderUrl.replace(/signature=([0-9a-f])/, (_, first: string) => `signature=${first === '0' ? '1' : '0'}`); + expect(forgedUrl).not.toBe(senderUrl); + const forged = await viewer.request.get(forgedUrl, { headers: { 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' }, }); expect(forged.status()).toBe(403); diff --git a/tests/Feature/HealthCheckTest.php b/tests/Feature/HealthCheckTest.php index 4f11173..be3c838 100644 --- a/tests/Feature/HealthCheckTest.php +++ b/tests/Feature/HealthCheckTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Spatie\Health\Checks\Checks\DatabaseCheck; +use App\Checks\DatabaseCheck; use Spatie\Health\Facades\Health; beforeEach(function (): void { @@ -27,9 +27,24 @@ $this->refreshApplication(); expect(collect(Health::registeredChecks())->map(fn ($c): string => class_basename($c))->all()) - ->toContain('DatabaseCheck', 'DebugModeCheck', 'EnvironmentCheck', 'SecurityAdvisoriesCheck') - ->not->toContain('CacheCheck') - ->not->toContain('OptimizedAppCheck'); + ->toContain( + 'DatabaseCheck', + 'DatabaseConnectionCountCheck', + 'DebugModeCheck', + 'EnvironmentCheck', + 'SecurityAdvisoriesCheck', + 'ScheduleCheck', + 'CacheCheck', + 'OptimizedAppCheck', + 'JobsCheck', + 'FailedJobsCheck', + 'PendingMigrationsCheck', + 'ProductionConfigCheck', + 'SchedulePolicyCheck', + 'StorageRoundTripCheck', + 'EntraClientSecretCheck', + 'PostmarkTokenCheck', + ); test()->artisan('schedule:list')->expectsOutputToContain('health:check')->assertExitCode(0); }); diff --git a/tests/Unit/Checks/DatabaseAvailabilityTest.php b/tests/Unit/Checks/DatabaseAvailabilityTest.php new file mode 100644 index 0000000..ada0d20 --- /dev/null +++ b/tests/Unit/Checks/DatabaseAvailabilityTest.php @@ -0,0 +1,17 @@ + null]); + + expect(DatabaseAvailability::isAvailable())->toBeTrue(); +})->group('unit', 'checks'); + +it('treats the database as unavailable when the app opts out', function () { + config(['health.uses_database' => false]); + + expect(DatabaseAvailability::isAvailable())->toBeFalse(); +})->group('unit', 'checks'); diff --git a/tests/Unit/Checks/DatabaseCheckTest.php b/tests/Unit/Checks/DatabaseCheckTest.php new file mode 100644 index 0000000..84e230c --- /dev/null +++ b/tests/Unit/Checks/DatabaseCheckTest.php @@ -0,0 +1,21 @@ + false]); + + $result = (new DatabaseCheck)->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue() + ->and($result->shortSummary)->toBe('no database'); +})->group('unit', 'checks'); + +it('connects to the database when the app has one', function () { + config(['health.uses_database' => true]); + + expect((new DatabaseCheck)->run()->status->equals(Status::ok()))->toBeTrue(); +})->group('unit', 'checks'); diff --git a/tests/Unit/Checks/DatabaseConnectionCountCheckTest.php b/tests/Unit/Checks/DatabaseConnectionCountCheckTest.php new file mode 100644 index 0000000..da0dd13 --- /dev/null +++ b/tests/Unit/Checks/DatabaseConnectionCountCheckTest.php @@ -0,0 +1,43 @@ + true, 'database.connections.health_pgsql' => ['driver' => 'pgsql']]); + + $check = Mockery::mock(DatabaseConnectionCountCheck::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $check->shouldReceive('getConnectionCount')->andReturn($connections); + $check->connectionName('health_pgsql'); + + return $check; +} + +it('reports ok with "no database" when the app has none', function () { + config(['health.uses_database' => false]); + + $result = (new DatabaseConnectionCountCheck)->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue() + ->and($result->shortSummary)->toBe('no database'); +})->group('unit', 'checks'); + +it('reports ok for sqlite, which has no connection count', function () { + config(['health.uses_database' => true, 'database.connections.health_sqlite' => ['driver' => 'sqlite']]); + + $result = (new DatabaseConnectionCountCheck)->connectionName('health_sqlite')->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue() + ->and($result->shortSummary)->toBe('sqlite'); +})->group('unit', 'checks'); + +it('applies the agreed thresholds', function (int $connections, Status $status) { + expect(connectionCountCheckWith($connections)->run()->status->equals($status))->toBeTrue(); +})->with([ + 'ok' => [10, Status::ok()], + 'warning above 50' => [60, Status::warning()], + 'failed above 80' => [90, Status::failed()], +])->group('unit', 'checks'); diff --git a/tests/Unit/Checks/EntraClientSecretCheckTest.php b/tests/Unit/Checks/EntraClientSecretCheckTest.php new file mode 100644 index 0000000..7ae50d5 --- /dev/null +++ b/tests/Unit/Checks/EntraClientSecretCheckTest.php @@ -0,0 +1,71 @@ + 'tenant', + 'microsoft-entra-sso.client_id' => 'client', + 'microsoft-entra-sso.client_secret' => 'secret', + 'services.microsoft' => [], + ]); +}); + +it('passes when Entra issues a token', function () { + Http::fake(['login.microsoftonline.com/*' => Http::response(['access_token' => 'x'])]); + + expect((new EntraClientSecretCheck)->run()->status->equals(Status::ok()))->toBeTrue(); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token'); +})->group('unit', 'checks'); + +it('fails when Entra rejects the secret', function () { + Http::fake(['login.microsoftonline.com/*' => Http::response(['error' => 'invalid_client'], 401)]); + + $result = (new EntraClientSecretCheck)->run(); + + expect($result->status->equals(Status::failed()))->toBeTrue() + ->and($result->shortSummary)->toBe('invalid_client'); +})->group('unit', 'checks'); + +it('reads socialite credentials from services.microsoft', function () { + config([ + 'microsoft-entra-sso' => [], + 'services.microsoft' => ['tenant' => 'social-tenant', 'client_id' => 'client', 'client_secret' => 'secret'], + ]); + Http::fake(['login.microsoftonline.com/*' => Http::response(['access_token' => 'x'])]); + + expect((new EntraClientSecretCheck)->run()->status->equals(Status::ok()))->toBeTrue(); + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/social-tenant/')); +})->group('unit', 'checks'); + +it('passes without a request when no SSO is configured', function (?string $secret) { + config(['microsoft-entra-sso.client_secret' => $secret]); + Http::fake(); + + $result = (new EntraClientSecretCheck)->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue() + ->and($result->shortSummary)->toBe('no sso configured'); + Http::assertNothingSent(); +})->with([null, '', 'not-configured'])->group('unit', 'checks'); + +it('reports the HTTP status when Entra gives no error code', function () { + Http::fake(['login.microsoftonline.com/*' => Http::response('', 500)]); + + expect((new EntraClientSecretCheck)->run()->shortSummary)->toBe('http 500'); +})->group('unit', 'checks'); + +it('fails when Entra cannot be reached', function () { + Http::fake(fn () => throw new ConnectionException('timeout')); + + $result = (new EntraClientSecretCheck)->run(); + + expect($result->status->equals(Status::failed()))->toBeTrue() + ->and($result->shortSummary)->toBe('entra unreachable'); +})->group('unit', 'checks'); diff --git a/tests/Unit/Checks/FailedJobsCheckTest.php b/tests/Unit/Checks/FailedJobsCheckTest.php new file mode 100644 index 0000000..2158c69 --- /dev/null +++ b/tests/Unit/Checks/FailedJobsCheckTest.php @@ -0,0 +1,45 @@ +delete(); + + $result = (new FailedJobsCheck)->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue(); +})->group('unit', 'checks'); + +it('returns failed when failed_jobs has rows', function () { + DB::table('failed_jobs')->delete(); + DB::table('failed_jobs')->insert([ + 'uuid' => (string) Str::uuid(), + 'connection' => 'database', + 'queue' => 'default', + 'payload' => json_encode([]), + 'exception' => 'test', + ]); + + $result = (new FailedJobsCheck)->run(); + + expect($result->status->equals(Status::failed()))->toBeTrue(); + + DB::table('failed_jobs')->delete(); +})->group('unit', 'checks'); + +it('reports ok with "no database" when the app has none', function () { + config(['health.uses_database' => false]); + + $result = (new FailedJobsCheck)->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue() + ->and($result->shortSummary)->toBe('no database'); +})->group('unit', 'checks'); diff --git a/tests/Unit/Checks/JobsCheckTest.php b/tests/Unit/Checks/JobsCheckTest.php new file mode 100644 index 0000000..8795989 --- /dev/null +++ b/tests/Unit/Checks/JobsCheckTest.php @@ -0,0 +1,89 @@ +delete(); + + $result = (new JobsCheck)->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue(); +})->group('unit', 'checks'); + +it('returns failed when multiple jobs remain stable', function () { + Sleep::fake(); + + DB::table('jobs')->delete(); + $row = [ + 'queue' => 'default', + 'payload' => json_encode(['job' => 'x']), + 'attempts' => 0, + 'available_at' => time(), + 'created_at' => time(), + ]; + DB::table('jobs')->insert([$row, $row]); + + $result = (new JobsCheck)->run(); + + expect($result->status->equals(Status::failed()))->toBeTrue(); + + DB::table('jobs')->delete(); +})->group('unit', 'checks'); + +it('returns failed when exactly one job stays queued', function () { + Sleep::fake(); + + DB::table('jobs')->delete(); + DB::table('jobs')->insert([ + 'queue' => 'default', + 'payload' => json_encode(['job' => 'x']), + 'attempts' => 0, + 'available_at' => time(), + 'created_at' => time(), + ]); + + $result = (new JobsCheck)->run(); + + expect($result->status->equals(Status::failed()))->toBeTrue(); + + DB::table('jobs')->delete(); +})->group('unit', 'checks'); + +it('returns ok when job count drops during polling', function () { + Sleep::fake(); + + $check = new class extends JobsCheck + { + private int $polls = 0; + + protected function currentJobsTableCount(): int + { + $this->polls++; + + return $this->polls === 1 ? 2 : 1; + } + }; + + $result = $check->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue(); +})->group('unit', 'checks'); + +it('reports ok with "no database" when the app has none', function () { + config(['health.uses_database' => false]); + + $result = (new JobsCheck)->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue() + ->and($result->shortSummary)->toBe('no database'); +})->group('unit', 'checks'); diff --git a/tests/Unit/Checks/PendingMigrationsCheckTest.php b/tests/Unit/Checks/PendingMigrationsCheckTest.php new file mode 100644 index 0000000..4a33bf8 --- /dev/null +++ b/tests/Unit/Checks/PendingMigrationsCheckTest.php @@ -0,0 +1,77 @@ + $files + * @param list $ran + */ +function pendingMigrationsWith(array $files, array $ran, bool $repositoryExists = true): void +{ + $repository = Mockery::mock(MigrationRepositoryInterface::class); + $repository->shouldReceive('getRan')->andReturn($ran); + + $migrator = Mockery::mock(Migrator::class); + $migrator->shouldReceive('repositoryExists')->andReturn($repositoryExists); + $migrator->shouldReceive('paths')->andReturn([]); + $migrator->shouldReceive('getMigrationFiles')->andReturn(array_combine($files, $files)); + $migrator->shouldReceive('getRepository')->andReturn($repository); + + app()->instance('migrator', $migrator); +} + +it('passes when every migration has run', function () { + pendingMigrationsWith(['a', 'b'], ['a', 'b']); + + $result = (new PendingMigrationsCheck)->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue() + ->and($result->shortSummary)->toBe('up to date'); +})->group('unit', 'checks'); + +it('fails and names the migrations that have not run', function () { + pendingMigrationsWith(['a', 'b', 'c'], ['a']); + + $result = (new PendingMigrationsCheck)->run(); + + expect($result->status->equals(Status::failed()))->toBeTrue() + ->and($result->shortSummary)->toBe('2 pending') + ->and($result->getNotificationMessage())->toContain('b, c'); +})->group('unit', 'checks'); + +it('lists at most five pending migrations', function () { + pendingMigrationsWith(['m1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7'], []); + + expect((new PendingMigrationsCheck)->run()->getNotificationMessage())->toContain('and 2 more'); +})->group('unit', 'checks'); + +it('fails when the migrations table does not exist', function () { + pendingMigrationsWith([], [], repositoryExists: false); + + expect((new PendingMigrationsCheck)->run()->shortSummary)->toBe('no migrations table'); +})->group('unit', 'checks'); + +it('fails when the migrations cannot be read', function () { + $migrator = Mockery::mock(Migrator::class); + $migrator->shouldReceive('repositoryExists')->andThrow(new RuntimeException('connection lost')); + app()->instance('migrator', $migrator); + + $result = (new PendingMigrationsCheck)->run(); + + expect($result->shortSummary)->toBe('unreadable') + ->and($result->getNotificationMessage())->toContain('connection lost'); +})->group('unit', 'checks'); + +it('reports ok with "no database" when the app has none', function () { + config(['health.uses_database' => false]); + + expect((new PendingMigrationsCheck)->run()->shortSummary)->toBe('no database'); +})->group('unit', 'checks'); diff --git a/tests/Unit/Checks/PostmarkTokenCheckTest.php b/tests/Unit/Checks/PostmarkTokenCheckTest.php new file mode 100644 index 0000000..6c28eb9 --- /dev/null +++ b/tests/Unit/Checks/PostmarkTokenCheckTest.php @@ -0,0 +1,58 @@ + 'postmark']); +}); + +it('passes when Postmark accepts the server token', function () { + config(['services.postmark.token' => 'token']); + Http::fake(['api.postmarkapp.com/server' => Http::response(['Name' => 'app'])]); + + expect((new PostmarkTokenCheck)->run()->status->equals(Status::ok()))->toBeTrue(); + Http::assertSent(fn (Request $request): bool => $request->hasHeader('X-Postmark-Server-Token', 'token')); +})->group('unit', 'checks'); + +it('fails when Postmark rejects the server token', function () { + config(['services.postmark.token' => 'token']); + Http::fake(['api.postmarkapp.com/server' => Http::response(['Message' => 'Invalid token'], 401)]); + + expect((new PostmarkTokenCheck)->run()->status->equals(Status::failed()))->toBeTrue(); +})->group('unit', 'checks'); + +it('fails without a token', function () { + config(['services.postmark.token' => null, 'services.postmark.key' => null]); + Http::fake(); + + expect((new PostmarkTokenCheck)->run()->status->equals(Status::failed()))->toBeTrue(); + Http::assertNothingSent(); +})->group('unit', 'checks'); + +it('passes without a request when the mailer is not postmark', function () { + config(['mail.default' => 'log', 'services.postmark.token' => null]); + Http::fake(); + + expect((new PostmarkTokenCheck)->run()->status->equals(Status::ok()))->toBeTrue(); + Http::assertNothingSent(); +})->group('unit', 'checks'); + +it('fails when Postmark cannot be reached', function () { + config(['services.postmark.token' => 'token']); + Http::fake(fn () => throw new ConnectionException('timeout')); + + expect((new PostmarkTokenCheck)->run()->shortSummary)->toBe('postmark unreachable'); +})->group('unit', 'checks'); + +it('reports the HTTP status when Postmark gives no message', function () { + config(['services.postmark.token' => 'token']); + Http::fake(['api.postmarkapp.com/server' => Http::response('', 500)]); + + expect((new PostmarkTokenCheck)->run()->getNotificationMessage())->toContain('500'); +})->group('unit', 'checks'); diff --git a/tests/Unit/Checks/ProductionConfigCheckTest.php b/tests/Unit/Checks/ProductionConfigCheckTest.php new file mode 100644 index 0000000..7a81e01 --- /dev/null +++ b/tests/Unit/Checks/ProductionConfigCheckTest.php @@ -0,0 +1,42 @@ + 'https://example.ch', + 'cache.default' => 'database', + 'session.driver' => 'database', + ]); +}); + +it('passes for a production-ready configuration', function () { + expect((new ProductionConfigCheck)->run()->status->equals(Status::ok()))->toBeTrue(); +})->group('unit', 'checks'); + +it('fails when APP_URL is not https', function () { + config(['app.url' => 'http://example.ch']); + + expect((new ProductionConfigCheck)->run()->status->equals(Status::failed()))->toBeTrue(); +})->group('unit', 'checks'); + +it('fails when APP_URL points at localhost', function () { + config(['app.url' => 'https://localhost']); + + expect((new ProductionConfigCheck)->run()->status->equals(Status::failed()))->toBeTrue(); +})->group('unit', 'checks'); + +it('fails for array cache or session stores', function (string $key) { + config([$key => 'array']); + + expect((new ProductionConfigCheck)->run()->status->equals(Status::failed()))->toBeTrue(); +})->with(['cache.default', 'session.driver'])->group('unit', 'checks'); + +it('does not check the mailer', function () { + config(['mail.default' => 'log']); + + expect((new ProductionConfigCheck)->run()->status->equals(Status::ok()))->toBeTrue(); +})->group('unit', 'checks'); diff --git a/tests/Unit/Checks/SchedulePolicyCheckTest.php b/tests/Unit/Checks/SchedulePolicyCheckTest.php new file mode 100644 index 0000000..6816d39 --- /dev/null +++ b/tests/Unit/Checks/SchedulePolicyCheckTest.php @@ -0,0 +1,34 @@ +command('inspire')->cron($expression); + app()->instance(Schedule::class, $schedule); + + return $schedule; +} + +it('passes for hourly and five-minute tasks', function (string $expression) { + schedulePolicyWith($expression); + + expect((new SchedulePolicyCheck)->run()->status->equals(Status::ok()))->toBeTrue(); +})->with(['0 * * * *', '*/5 * * * 1-5', '0 22 28-31 * *', '0,30 * * * *'])->group('unit', 'checks'); + +it('fails for tasks that run more often than every five minutes', function (string $expression) { + schedulePolicyWith($expression); + + expect((new SchedulePolicyCheck)->run()->status->equals(Status::failed()))->toBeTrue(); +})->with(['* * * * *', '*/2 * * * *', '0,1,2,3,4,5,6,7,8,9,10,11,12 * * * *'])->group('unit', 'checks'); + +it('passes the schedule this app defines', function () { + $this->artisan('schedule:list')->assertSuccessful(); + + expect((new SchedulePolicyCheck)->run()->status->equals(Status::ok()))->toBeTrue(); +})->group('unit', 'checks'); diff --git a/tests/Unit/Checks/StorageRoundTripCheckTest.php b/tests/Unit/Checks/StorageRoundTripCheckTest.php new file mode 100644 index 0000000..96d8781 --- /dev/null +++ b/tests/Unit/Checks/StorageRoundTripCheckTest.php @@ -0,0 +1,43 @@ + 's3', 'filesystems.disks.s3.driver' => 's3']); + Storage::fake('s3'); + + expect((new StorageRoundTripCheck)->run()->status->equals(Status::ok()))->toBeTrue() + ->and(Storage::disk('s3')->allFiles('health'))->toBe([]); +})->group('unit', 'checks'); + +it('warns while the default disk is local', function () { + config(['filesystems.default' => 'local', 'filesystems.disks.local.driver' => 'local']); + + expect((new StorageRoundTripCheck)->run()->status->equals(Status::warning()))->toBeTrue(); +})->group('unit', 'checks'); + +it('fails when the disk throws', function () { + config(['filesystems.default' => 's3', 'filesystems.disks.s3.driver' => 's3']); + Storage::shouldReceive('disk')->with('s3')->andThrow(new RuntimeException('bucket gone')); + + $result = (new StorageRoundTripCheck)->run(); + + expect($result->status->equals(Status::failed()))->toBeTrue() + ->and($result->getNotificationMessage())->toContain('bucket gone'); +})->group('unit', 'checks'); + +it('fails when the file reads back different content', function () { + config(['filesystems.default' => 's3', 'filesystems.disks.s3.driver' => 's3']); + $disk = Mockery::mock(Filesystem::class); + $disk->shouldReceive('put')->andReturnTrue(); + $disk->shouldReceive('get')->andReturn('something else'); + $disk->shouldReceive('delete')->andReturnTrue(); + Storage::shouldReceive('disk')->with('s3')->andReturn($disk); + + expect((new StorageRoundTripCheck)->run()->getNotificationMessage())->toContain('different content'); +})->group('unit', 'checks');