From c40e167ab575add33b5d65a7bda0e31861d38c77 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Tue, 11 Aug 2026 00:44:26 +0200 Subject: [PATCH] 95 - Add a platform credential health check --- app/Actions/CreatePlatformAccountAction.php | 3 +- app/Enums/AccountStatusEnum.php | 19 ++ app/Jobs/CheckPlatformCredentialsJob.php | 79 +++++++++ app/Models/PlatformAccount.php | 35 +++- database/factories/PlatformAccountFactory.php | 8 +- ...credential_health_to_platform_accounts.php | 28 +++ resources/views/livewire/channels.blade.php | 9 +- routes/console.php | 7 + .../Jobs/CheckPlatformCredentialsJobTest.php | 163 ++++++++++++++++++ .../CreatePlatformAccountActionTest.php | 3 +- tests/Unit/Models/PlatformAccountTest.php | 13 +- 11 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 app/Enums/AccountStatusEnum.php create mode 100644 app/Jobs/CheckPlatformCredentialsJob.php create mode 100644 database/migrations/2024_01_01_000020_add_credential_health_to_platform_accounts.php create mode 100644 tests/Feature/Jobs/CheckPlatformCredentialsJobTest.php diff --git a/app/Actions/CreatePlatformAccountAction.php b/app/Actions/CreatePlatformAccountAction.php index 2cf3d76e..f0a27c38 100644 --- a/app/Actions/CreatePlatformAccountAction.php +++ b/app/Actions/CreatePlatformAccountAction.php @@ -2,6 +2,7 @@ namespace App\Actions; +use App\Enums\AccountStatusEnum; use App\Exceptions\PlatformAuthException; use App\Models\PlatformAccount; use App\Models\PlatformInstance; @@ -46,7 +47,7 @@ public function execute(string $instanceDomain, string $username, string $passwo 'api_token' => $authResponse['jwt'] ?? null, ], 'is_active' => true, - 'status' => 'active', + 'status' => AccountStatusEnum::HEALTHY, ]); }); } diff --git a/app/Enums/AccountStatusEnum.php b/app/Enums/AccountStatusEnum.php new file mode 100644 index 00000000..6a213411 --- /dev/null +++ b/app/Enums/AccountStatusEnum.php @@ -0,0 +1,19 @@ + 'Untested', + self::HEALTHY => 'Healthy', + self::UNHEALTHY => 'Unhealthy', + }; + } +} diff --git a/app/Jobs/CheckPlatformCredentialsJob.php b/app/Jobs/CheckPlatformCredentialsJob.php new file mode 100644 index 00000000..f9f850d6 --- /dev/null +++ b/app/Jobs/CheckPlatformCredentialsJob.php @@ -0,0 +1,79 @@ +get(); + + foreach ($accounts as $account) { + $this->check($account, $notificationService); + } + } + + private function check(PlatformAccount $account, NotificationService $notificationService): void + { + if ($this->canLogIn($account)) { + $account->recordCredentialCheckPassed(); + + return; + } + + $wasUnhealthy = $account->isUnhealthy(); + $account->recordCredentialCheckFailed(); + + if (! $wasUnhealthy && $account->refresh()->isUnhealthy()) { + $this->notify($account, $notificationService); + } + } + + private function canLogIn(PlatformAccount $account): bool + { + try { + return $this->makeApiService($account)->login($account->username, $account->password) !== null; + } catch (Throwable) { + return false; + } + } + + protected function makeApiService(PlatformAccount $account): LemmyApiService + { + return new LemmyApiService($account->instance_url); + } + + private function notify(PlatformAccount $account, NotificationService $notificationService): void + { + $alreadyNotified = Notification::query() + ->where('type', NotificationTypeEnum::CREDENTIAL_EXPIRED) + ->where('notifiable_type', $account->getMorphClass()) + ->where('notifiable_id', $account->getKey()) + ->unread() + ->exists(); + + if ($alreadyNotified) { + return; + } + + $notificationService->send( + type: NotificationTypeEnum::CREDENTIAL_EXPIRED, + severity: NotificationSeverityEnum::ERROR, + title: "Credentials failed for {$account->username}", + message: "Could not log in to {$account->instance_url} after ".PlatformAccount::FAILURES_BEFORE_UNHEALTHY.' attempts. Publishing to this account will fail until it is fixed.', + notifiable: $account, + ); + } +} diff --git a/app/Models/PlatformAccount.php b/app/Models/PlatformAccount.php index 856bc65d..50f098fa 100644 --- a/app/Models/PlatformAccount.php +++ b/app/Models/PlatformAccount.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\AccountStatusEnum; use App\Enums\PlatformEnum; use Database\Factories\PlatformAccountFactory; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -21,7 +22,8 @@ * @property array $settings * @property bool $is_active * @property Carbon|null $last_tested_at - * @property string $status + * @property AccountStatusEnum $status + * @property int $consecutive_failures * @property Carbon $created_at * @property Carbon $updated_at * @property Collection $activeChannels @@ -44,10 +46,12 @@ class PlatformAccount extends Model 'is_active', 'last_tested_at', 'status', + 'consecutive_failures', ]; protected $casts = [ 'platform' => PlatformEnum::class, + 'status' => AccountStatusEnum::class, 'settings' => 'array', 'is_active' => 'boolean', 'last_tested_at' => 'datetime', @@ -136,4 +140,33 @@ public function activeChannels(): BelongsToMany ->wherePivot('is_active', true) ->orderByPivot('priority', 'desc'); } + + public const FAILURES_BEFORE_UNHEALTHY = 3; + + public function recordCredentialCheckPassed(): void + { + $this->update([ + 'status' => AccountStatusEnum::HEALTHY, + 'consecutive_failures' => 0, + 'last_tested_at' => now(), + ]); + } + + public function recordCredentialCheckFailed(): void + { + $failures = $this->consecutive_failures + 1; + + $this->update([ + 'status' => $failures >= self::FAILURES_BEFORE_UNHEALTHY + ? AccountStatusEnum::UNHEALTHY + : $this->status, + 'consecutive_failures' => $failures, + 'last_tested_at' => now(), + ]); + } + + public function isUnhealthy(): bool + { + return $this->status === AccountStatusEnum::UNHEALTHY; + } } diff --git a/database/factories/PlatformAccountFactory.php b/database/factories/PlatformAccountFactory.php index 398fe864..630446ae 100644 --- a/database/factories/PlatformAccountFactory.php +++ b/database/factories/PlatformAccountFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\AccountStatusEnum; use App\Enums\PlatformEnum; use App\Models\PlatformAccount; use Illuminate\Database\Eloquent\Factories\Factory; @@ -23,7 +24,7 @@ public function definition(): array 'settings' => [], 'is_active' => true, 'last_tested_at' => null, - 'status' => 'untested', + 'status' => AccountStatusEnum::UNTESTED, ]; } @@ -38,7 +39,7 @@ public function tested(): static { return $this->state(fn (array $attributes) => [ 'last_tested_at' => now()->subHours(2), - 'status' => 'working', + 'status' => AccountStatusEnum::HEALTHY, ]); } @@ -46,7 +47,8 @@ public function failed(): static { return $this->state(fn (array $attributes) => [ 'last_tested_at' => now()->subHours(2), - 'status' => 'failed', + 'status' => AccountStatusEnum::UNHEALTHY, + 'consecutive_failures' => PlatformAccount::FAILURES_BEFORE_UNHEALTHY, ]); } } diff --git a/database/migrations/2024_01_01_000020_add_credential_health_to_platform_accounts.php b/database/migrations/2024_01_01_000020_add_credential_health_to_platform_accounts.php new file mode 100644 index 00000000..e77c26d0 --- /dev/null +++ b/database/migrations/2024_01_01_000020_add_credential_health_to_platform_accounts.php @@ -0,0 +1,28 @@ +unsignedTinyInteger('consecutive_failures')->default(0)->after('status'); + }); + + DB::table('platform_accounts')->where('status', 'active')->update(['status' => 'healthy']); + } + + public function down(): void + { + // Not a true inverse: accounts the health check marked healthy also become 'active'. + DB::table('platform_accounts')->where('status', 'healthy')->update(['status' => 'active']); + + Schema::table('platform_accounts', function (Blueprint $table) { + $table->dropColumn('consecutive_failures'); + }); + } +}; diff --git a/resources/views/livewire/channels.blade.php b/resources/views/livewire/channels.blade.php index 49693b23..cecf64f9 100644 --- a/resources/views/livewire/channels.blade.php +++ b/resources/views/livewire/channels.blade.php @@ -69,7 +69,14 @@ class="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
@foreach ($channel->platformAccounts->take(3) as $account)
- {{ $account->username }} + + {{ $account->username }} + @if ($account->isUnhealthy()) + + Credentials failing + + @endif +