From 6df09d7b47507bf4a840ee51246603b719972def Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Fri, 23 Oct 2026 11:30:20 +0200 Subject: [PATCH 1/5] chore: consistent health checks (P1) Adds the standard health check set agreed for all Laravel Cloud apps: CacheCheck, OptimizedAppCheck, JobsCheck, FailedJobsCheck, ProductionConfigCheck (production only), SchedulePolicyCheck and PendingMigrationsCheck. HealthCheckTest now expects that set instead of asserting that cache and optimize checks are absent. SecurityAdvisoriesCheck loses ->lastDayOfMonth(): spatie only runs a check when its cron is due in the minute of the request, and Oh Dear asks once an hour, so the check was skipped on practically every run. Co-Authored-By: Claude Opus 5.5 --- app/Checks/FailedJobsCheck.php | 26 ++++++ app/Checks/JobsCheck.php | 54 +++++++++++++ app/Checks/PendingMigrationsCheck.php | 67 ++++++++++++++++ app/Checks/ProductionConfigCheck.php | 47 +++++++++++ app/Checks/SchedulePolicyCheck.php | 51 ++++++++++++ app/Providers/AppServiceProvider.php | 22 ++++- tests/Feature/HealthCheckTest.php | 17 +++- tests/Unit/Checks/FailedJobsCheckTest.php | 36 +++++++++ tests/Unit/Checks/JobsCheckTest.php | 80 +++++++++++++++++++ .../Checks/PendingMigrationsCheckTest.php | 24 ++++++ .../Unit/Checks/ProductionConfigCheckTest.php | 42 ++++++++++ tests/Unit/Checks/SchedulePolicyCheckTest.php | 34 ++++++++ 12 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 app/Checks/FailedJobsCheck.php create mode 100644 app/Checks/JobsCheck.php create mode 100644 app/Checks/PendingMigrationsCheck.php create mode 100644 app/Checks/ProductionConfigCheck.php create mode 100644 app/Checks/SchedulePolicyCheck.php create mode 100644 tests/Unit/Checks/FailedJobsCheckTest.php create mode 100644 tests/Unit/Checks/JobsCheckTest.php create mode 100644 tests/Unit/Checks/PendingMigrationsCheckTest.php create mode 100644 tests/Unit/Checks/ProductionConfigCheckTest.php create mode 100644 tests/Unit/Checks/SchedulePolicyCheckTest.php diff --git a/app/Checks/FailedJobsCheck.php b/app/Checks/FailedJobsCheck.php new file mode 100644 index 0000000..130b06a --- /dev/null +++ b/app/Checks/FailedJobsCheck.php @@ -0,0 +1,26 @@ +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..019c650 --- /dev/null +++ b/app/Checks/JobsCheck.php @@ -0,0 +1,54 @@ +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..352101d --- /dev/null +++ b/app/Checks/PendingMigrationsCheck.php @@ -0,0 +1,67 @@ +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/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/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1de5ac1..35c1da2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,7 +4,12 @@ namespace App\Providers; +use App\Checks\FailedJobsCheck; +use App\Checks\JobsCheck; use App\Checks\NightwatchIssuesCheck; +use App\Checks\PendingMigrationsCheck; +use App\Checks\ProductionConfigCheck; +use App\Checks\SchedulePolicyCheck; use App\Http\Middleware\ResolveProject; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -12,9 +17,11 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Spatie\Activitylog\Models\Activity; +use Spatie\Health\Checks\Checks\CacheCheck; use Spatie\Health\Checks\Checks\DatabaseCheck; 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,9 +36,9 @@ 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 { @@ -39,9 +46,16 @@ private function configureHealthChecks(): void DatabaseCheck::new(), DebugModeCheck::new(), EnvironmentCheck::new()->if($this->app->isProduction()), - SecurityAdvisoriesCheck::new()->lastDayOfMonth(), + SecurityAdvisoriesCheck::new(), NightwatchIssuesCheck::new()->if(filled(config('default.nightwatch.api_token')) && filled(config('default.nightwatch.application_id'))), ScheduleCheck::new()->heartbeatMaxAgeInMinutes(120), + CacheCheck::new(), + OptimizedAppCheck::new(), + JobsCheck::new(), + FailedJobsCheck::new(), + PendingMigrationsCheck::new(), + ProductionConfigCheck::new()->if($this->app->isProduction()), + SchedulePolicyCheck::new(), ]); } diff --git a/tests/Feature/HealthCheckTest.php b/tests/Feature/HealthCheckTest.php index 4f11173..3b72d98 100644 --- a/tests/Feature/HealthCheckTest.php +++ b/tests/Feature/HealthCheckTest.php @@ -27,9 +27,20 @@ $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', + 'DebugModeCheck', + 'EnvironmentCheck', + 'SecurityAdvisoriesCheck', + 'ScheduleCheck', + 'CacheCheck', + 'OptimizedAppCheck', + 'JobsCheck', + 'FailedJobsCheck', + 'PendingMigrationsCheck', + 'ProductionConfigCheck', + 'SchedulePolicyCheck', + ); test()->artisan('schedule:list')->expectsOutputToContain('health:check')->assertExitCode(0); }); diff --git a/tests/Unit/Checks/FailedJobsCheckTest.php b/tests/Unit/Checks/FailedJobsCheckTest.php new file mode 100644 index 0000000..987f14f --- /dev/null +++ b/tests/Unit/Checks/FailedJobsCheckTest.php @@ -0,0 +1,36 @@ +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'); diff --git a/tests/Unit/Checks/JobsCheckTest.php b/tests/Unit/Checks/JobsCheckTest.php new file mode 100644 index 0000000..e643ea1 --- /dev/null +++ b/tests/Unit/Checks/JobsCheckTest.php @@ -0,0 +1,80 @@ +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'); diff --git a/tests/Unit/Checks/PendingMigrationsCheckTest.php b/tests/Unit/Checks/PendingMigrationsCheckTest.php new file mode 100644 index 0000000..93afd1b --- /dev/null +++ b/tests/Unit/Checks/PendingMigrationsCheckTest.php @@ -0,0 +1,24 @@ +run()->status->equals(Status::ok()))->toBeTrue(); +})->group('unit', 'checks'); + +it('fails when a migration has not run', function () { + $latest = DB::table('migrations')->orderByDesc('id')->value('migration'); + DB::table('migrations')->where('migration', $latest)->delete(); + + $result = (new PendingMigrationsCheck)->run(); + + expect($result->status->equals(Status::failed()))->toBeTrue() + ->and($result->getNotificationMessage())->toContain($latest); +})->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'); From 787166bbf7364833cbc28f3c346b056b23d3c049 Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Fri, 23 Oct 2026 11:55:21 +0200 Subject: [PATCH 2/5] chore: add sessions table for the database session driver All Laravel Cloud apps move to SESSION_DRIVER=database, and this app had no sessions table yet. The migration skips the table if it already exists. Co-Authored-By: Claude Opus 5.5 --- ...026_10_23_120000_create_sessions_table.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 database/migrations/2026_10_23_120000_create_sessions_table.php 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'); + } +}; From e9ea7a9bf7b9a0e6d44dcc714ef78c21fa4751fc Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Fri, 23 Oct 2026 12:40:36 +0200 Subject: [PATCH 3/5] chore: same health check set in every app Every codebar Laravel app now registers the same checks in the same order. Checks that do not apply report ok instead of being left out: - DatabaseCheck, DatabaseConnectionCountCheck (warn > 50, fail > 80), JobsCheck, FailedJobsCheck and PendingMigrationsCheck report "no database" when config('health.uses_database') is false. - PostmarkTokenCheck reports ok when the mailer is not postmark. - EntraClientSecretCheck finds its credentials in microsoft-entra-sso or services.microsoft and reports ok when no SSO is configured. - StorageRoundTripCheck warns instead of failing while the default disk is local (a bucket will be added to every app). StorageRoundTripCheck, EntraClientSecretCheck and PostmarkTokenCheck are new in this app. Co-Authored-By: Claude Opus 5.5 --- app/Checks/DatabaseAvailability.php | 19 ++++ app/Checks/DatabaseCheck.php | 23 +++++ app/Checks/DatabaseConnectionCountCheck.php | 35 +++++++ app/Checks/EntraClientSecretCheck.php | 91 +++++++++++++++++++ app/Checks/FailedJobsCheck.php | 4 + app/Checks/JobsCheck.php | 4 + app/Checks/PendingMigrationsCheck.php | 4 + app/Checks/PostmarkTokenCheck.php | 53 +++++++++++ app/Checks/StorageRoundTripCheck.php | 48 ++++++++++ app/Providers/AppServiceProvider.php | 22 +++-- tests/Feature/HealthCheckTest.php | 6 +- tests/Unit/Checks/DatabaseChecksTest.php | 35 +++++++ .../Checks/EntraClientSecretCheckTest.php | 55 +++++++++++ tests/Unit/Checks/PostmarkTokenCheckTest.php | 43 +++++++++ .../Unit/Checks/StorageRoundTripCheckTest.php | 31 +++++++ 15 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 app/Checks/DatabaseAvailability.php create mode 100644 app/Checks/DatabaseCheck.php create mode 100644 app/Checks/DatabaseConnectionCountCheck.php create mode 100644 app/Checks/EntraClientSecretCheck.php create mode 100644 app/Checks/PostmarkTokenCheck.php create mode 100644 app/Checks/StorageRoundTripCheck.php create mode 100644 tests/Unit/Checks/DatabaseChecksTest.php create mode 100644 tests/Unit/Checks/EntraClientSecretCheckTest.php create mode 100644 tests/Unit/Checks/PostmarkTokenCheckTest.php create mode 100644 tests/Unit/Checks/StorageRoundTripCheckTest.php 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..6bd51d5 --- /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..a387c59 --- /dev/null +++ b/app/Checks/EntraClientSecretCheck.php @@ -0,0 +1,91 @@ + config key and tenant key, in lookup order */ + private 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 configKey(string $configKey, string $tenantKey = 'tenant_id'): self + { + $this->sources = [[$configKey, $tenantKey]]; + + return $this; + } + + 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 ($this->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 index 130b06a..49074d2 100644 --- a/app/Checks/FailedJobsCheck.php +++ b/app/Checks/FailedJobsCheck.php @@ -12,6 +12,10 @@ class FailedJobsCheck extends Check { public function run(): Result { + if (! DatabaseAvailability::isAvailable()) { + return Result::make()->shortSummary('no database')->ok(); + } + $count = DB::table('failed_jobs')->count(); $result = Result::make(); diff --git a/app/Checks/JobsCheck.php b/app/Checks/JobsCheck.php index 019c650..b7dfaa2 100644 --- a/app/Checks/JobsCheck.php +++ b/app/Checks/JobsCheck.php @@ -20,6 +20,10 @@ class JobsCheck extends Check public function run(): Result { + if (! DatabaseAvailability::isAvailable()) { + return Result::make()->shortSummary('no database')->ok(); + } + $count = $this->currentJobsTableCount(); $result = Result::make(); diff --git a/app/Checks/PendingMigrationsCheck.php b/app/Checks/PendingMigrationsCheck.php index 352101d..470c3d1 100644 --- a/app/Checks/PendingMigrationsCheck.php +++ b/app/Checks/PendingMigrationsCheck.php @@ -20,6 +20,10 @@ final class PendingMigrationsCheck extends Check public function run(): Result { + if (! DatabaseAvailability::isAvailable()) { + return Result::make()->shortSummary('no database')->ok(); + } + $result = Result::make(); try { 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/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 35c1da2..6ae236c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,12 +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; @@ -18,7 +23,6 @@ use Illuminate\Support\ServiceProvider; use Spatie\Activitylog\Models\Activity; use Spatie\Health\Checks\Checks\CacheCheck; -use Spatie\Health\Checks\Checks\DatabaseCheck; use Spatie\Health\Checks\Checks\DebugModeCheck; use Spatie\Health\Checks\Checks\EnvironmentCheck; use Spatie\Health\Checks\Checks\OptimizedAppCheck; @@ -43,19 +47,23 @@ public function boot(): void private function configureHealthChecks(): void { Health::checks([ - DatabaseCheck::new(), DebugModeCheck::new(), - EnvironmentCheck::new()->if($this->app->isProduction()), - SecurityAdvisoriesCheck::new(), - NightwatchIssuesCheck::new()->if(filled(config('default.nightwatch.api_token')) && filled(config('default.nightwatch.application_id'))), - ScheduleCheck::new()->heartbeatMaxAgeInMinutes(120), + 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($this->app->isProduction()), + ProductionConfigCheck::new()->if(app()->isProduction()), SchedulePolicyCheck::new(), + StorageRoundTripCheck::new(), + EntraClientSecretCheck::new(), + PostmarkTokenCheck::new(), ]); } diff --git a/tests/Feature/HealthCheckTest.php b/tests/Feature/HealthCheckTest.php index 3b72d98..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 { @@ -29,6 +29,7 @@ expect(collect(Health::registeredChecks())->map(fn ($c): string => class_basename($c))->all()) ->toContain( 'DatabaseCheck', + 'DatabaseConnectionCountCheck', 'DebugModeCheck', 'EnvironmentCheck', 'SecurityAdvisoriesCheck', @@ -40,6 +41,9 @@ 'PendingMigrationsCheck', 'ProductionConfigCheck', 'SchedulePolicyCheck', + 'StorageRoundTripCheck', + 'EntraClientSecretCheck', + 'PostmarkTokenCheck', ); test()->artisan('schedule:list')->expectsOutputToContain('health:check')->assertExitCode(0); diff --git a/tests/Unit/Checks/DatabaseChecksTest.php b/tests/Unit/Checks/DatabaseChecksTest.php new file mode 100644 index 0000000..7813a69 --- /dev/null +++ b/tests/Unit/Checks/DatabaseChecksTest.php @@ -0,0 +1,35 @@ + false]); + + /** @var Check $instance */ + $instance = new $check; + $result = $instance->run(); + + expect($result->status->equals(Status::ok()))->toBeTrue() + ->and($result->shortSummary)->toBe('no database'); +})->with([DatabaseCheck::class, DatabaseConnectionCountCheck::class, JobsCheck::class, FailedJobsCheck::class, PendingMigrationsCheck::class])->group('unit', 'checks'); + +it('checks the database when the app has one', function () { + expect((new DatabaseCheck)->run()->status->equals(Status::ok()))->toBeTrue(); +})->group('unit', 'checks'); + +it('uses the agreed connection thresholds', function () { + $check = new DatabaseConnectionCountCheck; + + expect((fn () => [$this->warningThreshold, $this->errorThreshold])->call($check))->toBe([50, 80]); +})->group('unit', 'checks'); diff --git a/tests/Unit/Checks/EntraClientSecretCheckTest.php b/tests/Unit/Checks/EntraClientSecretCheckTest.php new file mode 100644 index 0000000..94ac2d1 --- /dev/null +++ b/tests/Unit/Checks/EntraClientSecretCheckTest.php @@ -0,0 +1,55 @@ + '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'); diff --git a/tests/Unit/Checks/PostmarkTokenCheckTest.php b/tests/Unit/Checks/PostmarkTokenCheckTest.php new file mode 100644 index 0000000..0441bab --- /dev/null +++ b/tests/Unit/Checks/PostmarkTokenCheckTest.php @@ -0,0 +1,43 @@ + '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'); diff --git a/tests/Unit/Checks/StorageRoundTripCheckTest.php b/tests/Unit/Checks/StorageRoundTripCheckTest.php new file mode 100644 index 0000000..2dce04d --- /dev/null +++ b/tests/Unit/Checks/StorageRoundTripCheckTest.php @@ -0,0 +1,31 @@ + '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'); From cce43dffc75b3659298e4ba7bafdd4ce5d1a5e6a Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Fri, 23 Oct 2026 13:06:55 +0200 Subject: [PATCH 4/5] test: a dedicated test per health check and full coverage The standard health checks get one test file per class (DatabaseCheck, DatabaseConnectionCountCheck, DatabaseAvailability) and tests for every branch, so the coverage gate holds. PendingMigrationsCheck is now tested against a mocked migrator; DatabaseConnectionCountCheck is no longer final so its thresholds can be tested without a real connection count. EntraClientSecretCheck drops the unused configKey() override. JobsCheck and FailedJobsCheck declare strict types, as the repo's architecture rule requires for every app file. Co-Authored-By: Claude Opus 5.5 --- app/Checks/DatabaseConnectionCountCheck.php | 2 +- app/Checks/EntraClientSecretCheck.php | 11 +--- .../Unit/Checks/DatabaseAvailabilityTest.php | 17 +++++ tests/Unit/Checks/DatabaseCheckTest.php | 21 ++++++ tests/Unit/Checks/DatabaseChecksTest.php | 35 ---------- .../DatabaseConnectionCountCheckTest.php | 43 ++++++++++++ .../Checks/EntraClientSecretCheckTest.php | 16 +++++ tests/Unit/Checks/FailedJobsCheckTest.php | 9 +++ tests/Unit/Checks/JobsCheckTest.php | 9 +++ .../Checks/PendingMigrationsCheckTest.php | 65 +++++++++++++++++-- tests/Unit/Checks/PostmarkTokenCheckTest.php | 15 +++++ .../Unit/Checks/StorageRoundTripCheckTest.php | 12 ++++ 12 files changed, 204 insertions(+), 51 deletions(-) create mode 100644 tests/Unit/Checks/DatabaseAvailabilityTest.php create mode 100644 tests/Unit/Checks/DatabaseCheckTest.php delete mode 100644 tests/Unit/Checks/DatabaseChecksTest.php create mode 100644 tests/Unit/Checks/DatabaseConnectionCountCheckTest.php diff --git a/app/Checks/DatabaseConnectionCountCheck.php b/app/Checks/DatabaseConnectionCountCheck.php index 6bd51d5..e669a02 100644 --- a/app/Checks/DatabaseConnectionCountCheck.php +++ b/app/Checks/DatabaseConnectionCountCheck.php @@ -12,7 +12,7 @@ * above 50, fail above 80), reporting ok for apps without a database and * for SQLite, which has no connection count. */ -final class DatabaseConnectionCountCheck extends SpatieDatabaseConnectionCountCheck +class DatabaseConnectionCountCheck extends SpatieDatabaseConnectionCountCheck { protected ?int $warningThreshold = 50; diff --git a/app/Checks/EntraClientSecretCheck.php b/app/Checks/EntraClientSecretCheck.php index a387c59..9b797ea 100644 --- a/app/Checks/EntraClientSecretCheck.php +++ b/app/Checks/EntraClientSecretCheck.php @@ -19,18 +19,11 @@ final class EntraClientSecretCheck extends Check { /** @var list config key and tenant key, in lookup order */ - private array $sources = [['microsoft-entra-sso', 'tenant_id'], ['services.microsoft', 'tenant']]; + 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 configKey(string $configKey, string $tenantKey = 'tenant_id'): self - { - $this->sources = [[$configKey, $tenantKey]]; - - return $this; - } - public function run(): Result { $result = Result::make(); @@ -69,7 +62,7 @@ public function run(): Result */ private function credentials(): ?array { - foreach ($this->sources as [$configKey, $tenantKey]) { + foreach (self::SOURCES as [$configKey, $tenantKey]) { $tenant = $this->configString("{$configKey}.{$tenantKey}"); $clientId = $this->configString("{$configKey}.client_id"); $clientSecret = $this->configString("{$configKey}.client_secret"); 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/DatabaseChecksTest.php b/tests/Unit/Checks/DatabaseChecksTest.php deleted file mode 100644 index 7813a69..0000000 --- a/tests/Unit/Checks/DatabaseChecksTest.php +++ /dev/null @@ -1,35 +0,0 @@ - false]); - - /** @var Check $instance */ - $instance = new $check; - $result = $instance->run(); - - expect($result->status->equals(Status::ok()))->toBeTrue() - ->and($result->shortSummary)->toBe('no database'); -})->with([DatabaseCheck::class, DatabaseConnectionCountCheck::class, JobsCheck::class, FailedJobsCheck::class, PendingMigrationsCheck::class])->group('unit', 'checks'); - -it('checks the database when the app has one', function () { - expect((new DatabaseCheck)->run()->status->equals(Status::ok()))->toBeTrue(); -})->group('unit', 'checks'); - -it('uses the agreed connection thresholds', function () { - $check = new DatabaseConnectionCountCheck; - - expect((fn () => [$this->warningThreshold, $this->errorThreshold])->call($check))->toBe([50, 80]); -})->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 index 94ac2d1..7ae50d5 100644 --- a/tests/Unit/Checks/EntraClientSecretCheckTest.php +++ b/tests/Unit/Checks/EntraClientSecretCheckTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Checks\EntraClientSecretCheck; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Spatie\Health\Enums\Status; @@ -53,3 +54,18 @@ ->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 index 987f14f..2158c69 100644 --- a/tests/Unit/Checks/FailedJobsCheckTest.php +++ b/tests/Unit/Checks/FailedJobsCheckTest.php @@ -34,3 +34,12 @@ 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 index e643ea1..8795989 100644 --- a/tests/Unit/Checks/JobsCheckTest.php +++ b/tests/Unit/Checks/JobsCheckTest.php @@ -78,3 +78,12 @@ protected function currentJobsTableCount(): int 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 index 93afd1b..4a33bf8 100644 --- a/tests/Unit/Checks/PendingMigrationsCheckTest.php +++ b/tests/Unit/Checks/PendingMigrationsCheckTest.php @@ -3,22 +3,75 @@ declare(strict_types=1); use App\Checks\PendingMigrationsCheck; +use Illuminate\Database\Migrations\MigrationRepositoryInterface; +use Illuminate\Database\Migrations\Migrator; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\DB; use Spatie\Health\Enums\Status; uses(RefreshDatabase::class); +/** + * @param list $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 () { - expect((new PendingMigrationsCheck)->run()->status->equals(Status::ok()))->toBeTrue(); + 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 when a migration has not run', function () { - $latest = DB::table('migrations')->orderByDesc('id')->value('migration'); - DB::table('migrations')->where('migration', $latest)->delete(); +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->getNotificationMessage())->toContain($latest); + ->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 index 0441bab..6c28eb9 100644 --- a/tests/Unit/Checks/PostmarkTokenCheckTest.php +++ b/tests/Unit/Checks/PostmarkTokenCheckTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Checks\PostmarkTokenCheck; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Spatie\Health\Enums\Status; @@ -41,3 +42,17 @@ 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/StorageRoundTripCheckTest.php b/tests/Unit/Checks/StorageRoundTripCheckTest.php index 2dce04d..96d8781 100644 --- a/tests/Unit/Checks/StorageRoundTripCheckTest.php +++ b/tests/Unit/Checks/StorageRoundTripCheckTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Checks\StorageRoundTripCheck; +use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Support\Facades\Storage; use Spatie\Health\Enums\Status; @@ -29,3 +30,14 @@ 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'); From 3787cf601381d79b626c8fde968dc449b0803a6a Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Fri, 23 Oct 2026 14:12:48 +0200 Subject: [PATCH 5/5] test: make the tampered-link e2e check deterministic The check forged the share link by replacing the first signature character with '0'. Whenever the real signature already started with '0' (1 in 16 per browser, so about one run in four across the four browsers) the URL stayed valid and the server rightly answered 200, failing the test on a different browser each time. The first character is now flipped to a different value, and the test asserts the URL changed. Co-Authored-By: Claude Opus 5.5 --- e2e/feedback.e2e.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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);