diff --git a/.env.example b/.env.example index 38c980fe..1b6d8348 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,7 @@ MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hi@whisper.money" MAIL_FROM_NAME="Whisper Money" +ADMIN_EMAIL= # Resend Email Service (set MAIL_MAILER=resend to use in production) RESEND_API_KEY= diff --git a/app/Console/Commands/CheckBankLogosCommand.php b/app/Console/Commands/CheckBankLogosCommand.php new file mode 100644 index 00000000..78b5e349 --- /dev/null +++ b/app/Console/Commands/CheckBankLogosCommand.php @@ -0,0 +1,112 @@ +whereNotNull('logo') + ->get(['id', 'name', 'logo']); + + if ($banks->isEmpty()) { + $this->info('No bank logos found to validate.'); + + return self::SUCCESS; + } + + $updatedBanks = []; + + foreach ($banks as $bank) { + $logoUrl = (string) $bank->logo; + + if ($this->hasWorkingImage($logoUrl)) { + continue; + } + + $bank->update(['logo' => null]); + + $updatedBanks[] = [ + 'id' => $bank->id, + 'name' => $bank->name, + 'previous_logo' => $logoUrl, + ]; + + $this->warn("Cleared broken logo for {$bank->name}."); + } + + if ($updatedBanks === []) { + $this->info('All bank logos are valid.'); + + return self::SUCCESS; + } + + $updatedCount = count($updatedBanks); + + $this->info("Cleared broken logos for {$updatedCount} bank(s)."); + + $adminEmail = (string) config('mail.admin_email'); + + if ($adminEmail === '') { + $this->warn('ADMIN_EMAIL is not configured. Skipping report email.'); + + return self::SUCCESS; + } + + Mail::to($adminEmail)->send(new BrokenBankLogosReportEmail($updatedBanks)); + + $this->info("Sent broken logo report to {$adminEmail}."); + + return self::SUCCESS; + } + + private function hasWorkingImage(string $logoUrl): bool + { + if (! filter_var($logoUrl, FILTER_VALIDATE_URL)) { + return false; + } + + try { + $headResponse = Http::timeout(10)->head($logoUrl); + + if ($headResponse->successful() && $this->isImageResponse($headResponse)) { + return true; + } + + if ($headResponse->failed() && $headResponse->status() !== 405) { + return false; + } + + $getResponse = Http::timeout(10)->get($logoUrl); + + return $getResponse->successful() && $this->isImageResponse($getResponse); + } catch (ConnectionException) { + return false; + } + } + + private function isImageResponse(Response $response): bool + { + $contentType = strtolower((string) $response->header('Content-Type')); + + if ($contentType === '') { + return false; + } + + return str_starts_with($contentType, 'image/') + || str_contains($contentType, 'application/octet-stream'); + } +} diff --git a/app/Mail/BrokenBankLogosReportEmail.php b/app/Mail/BrokenBankLogosReportEmail.php new file mode 100644 index 00000000..277db385 --- /dev/null +++ b/app/Mail/BrokenBankLogosReportEmail.php @@ -0,0 +1,45 @@ + $updatedBanks + */ + public function __construct( + public array $updatedBanks, + ) {} + + public function envelope(): Envelope + { + $updatedCount = count($this->updatedBanks); + + return new Envelope( + subject: trans_choice('Weekly bank logo audit: :count broken logo|Weekly bank logo audit: :count broken logos', $updatedCount, ['count' => $updatedCount]), + )->from(config('mail.from.address', 'hello@example.com'), 'Victor'); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.broken-bank-logos-report', + with: [ + 'updatedBanks' => $this->updatedBanks, + ], + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/config/mail.php b/config/mail.php index af5c76e4..f76c4557 100644 --- a/config/mail.php +++ b/config/mail.php @@ -15,6 +15,8 @@ return [ 'drip_emails_enabled' => env('DRIP_EMAILS_ENABLED', true), + 'admin_email' => env('ADMIN_EMAIL'), + /* |-------------------------------------------------------------------------- | Email Verification diff --git a/resources/views/mail/broken-bank-logos-report.blade.php b/resources/views/mail/broken-bank-logos-report.blade.php new file mode 100644 index 00000000..3cee5479 --- /dev/null +++ b/resources/views/mail/broken-bank-logos-report.blade.php @@ -0,0 +1,15 @@ + +# Weekly bank logo audit report + +The weekly logo validation command found broken bank logo links and replaced them with `logo = null`. + +**Updated banks:** {{ count($updatedBanks) }} + +@foreach ($updatedBanks as $bank) +- **{{ $bank['name'] }}** (ID: {{ $bank['id'] }}) + Previous logo: {{ $bank['previous_logo'] }} +@endforeach + +Thanks,
+{{ config('app.name') }} +
diff --git a/routes/console.php b/routes/console.php index 303850ec..d860e9bc 100644 --- a/routes/console.php +++ b/routes/console.php @@ -6,3 +6,4 @@ Schedule::command('demo:reset')->twiceDaily(); Schedule::command('horizon:snapshot')->everyFiveMinutes(); Schedule::command('budgets:generate-periods')->daily(); Schedule::command('banking:sync')->everySixHours(); +Schedule::command('banks:check-logos')->weekly(); diff --git a/tests/Feature/Console/CheckBankLogosCommandTest.php b/tests/Feature/Console/CheckBankLogosCommandTest.php new file mode 100644 index 00000000..8ae3205f --- /dev/null +++ b/tests/Feature/Console/CheckBankLogosCommandTest.php @@ -0,0 +1,117 @@ + 'admin@example.com']); + + $validBank = Bank::factory()->create([ + 'name' => 'Valid Bank', + 'logo' => 'https://bank-valid.test/logo.png', + ]); + + $brokenBank = Bank::factory()->create([ + 'name' => 'Broken Bank', + 'logo' => 'https://bank-broken.test/logo.png', + ]); + + Http::fake([ + 'https://bank-valid.test/*' => Http::response('', 200, ['Content-Type' => 'image/png']), + 'https://bank-broken.test/*' => Http::response('', 404), + ]); + + artisan('banks:check-logos') + ->expectsOutputToContain('Cleared broken logos for 1 bank(s).') + ->expectsOutputToContain('Sent broken logo report to admin@example.com.') + ->assertSuccessful(); + + expect($validBank->fresh()->logo)->toBe('https://bank-valid.test/logo.png'); + expect($brokenBank->fresh()->logo)->toBeNull(); + + Mail::assertSent(BrokenBankLogosReportEmail::class, function (BrokenBankLogosReportEmail $mail) use ($brokenBank) { + return $mail->updatedBanks === [[ + 'id' => $brokenBank->id, + 'name' => 'Broken Bank', + 'previous_logo' => 'https://bank-broken.test/logo.png', + ]]; + }); +}); + +test('command does not send report when no broken logos are found', function () { + Mail::fake(); + + config(['mail.admin_email' => 'admin@example.com']); + + $bank = Bank::factory()->create([ + 'logo' => 'https://bank-valid.test/logo.png', + ]); + + Http::fake([ + 'https://bank-valid.test/*' => Http::response('', 200, ['Content-Type' => 'image/png']), + ]); + + artisan('banks:check-logos') + ->expectsOutputToContain('All bank logos are valid.') + ->assertSuccessful(); + + expect($bank->fresh()->logo)->toBe('https://bank-valid.test/logo.png'); + Mail::assertNothingSent(); +}); + +test('command clears broken logos without emailing when ADMIN_EMAIL is missing', function () { + Mail::fake(); + + config(['mail.admin_email' => null]); + + $brokenBank = Bank::factory()->create([ + 'name' => 'Broken Bank', + 'logo' => 'https://bank-broken.test/logo.png', + ]); + + Http::fake([ + 'https://bank-broken.test/*' => Http::response('', 404), + ]); + + artisan('banks:check-logos') + ->expectsOutputToContain('Cleared broken logos for 1 bank(s).') + ->expectsOutputToContain('ADMIN_EMAIL is not configured. Skipping report email.') + ->assertSuccessful(); + + expect($brokenBank->fresh()->logo)->toBeNull(); + Mail::assertNothingSent(); +}); + +test('command falls back to get request when head request is not allowed', function () { + Mail::fake(); + + config(['mail.admin_email' => null]); + + $bank = Bank::factory()->create([ + 'logo' => 'https://bank-head-fallback.test/logo.png', + ]); + + Http::fake([ + 'https://bank-head-fallback.test/*' => function (Request $request) { + if ($request->method() === 'HEAD') { + return Http::response('', 405); + } + + return Http::response('', 200, ['Content-Type' => 'image/png']); + }, + ]); + + artisan('banks:check-logos') + ->expectsOutputToContain('All bank logos are valid.') + ->assertSuccessful(); + + expect($bank->fresh()->logo)->toBe('https://bank-head-fallback.test/logo.png'); + Mail::assertNothingSent(); +});