diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f2d6af2..05d70738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Client Reports — Every report waits for approval** (on by default): the monthly run compiles and checks every report but emails nothing; operators send them from the Reports list. Switch it on or off on the Scheduling tab (`client_reports.require_approval`, logged as `client_report_approval_changed`). +- **Client Reports — Approve & send all ready / Send selected** on the Reports list: send every Ready to send report of the period, or the ticked ones (held ones included), up to 25 per press, skipping outdated, replaced and already-sent reports. + +### Changed +- **Client Reports — Report schema 8**: unsent reports compiled before the checks below are outdated and must be recompiled before they can be sent. +- **Client Reports — More reports held for review before the first unattended run (Dec 1)**, each one a client email that would otherwise have been wrong or unexplained: + - a Sucuri SiteCheck hit (malware or a blocklisting) on a site whose malware row comes from a clean Companion scan; + - a core-file or blocklist problem earlier in the month that a later clean check hides; + - no malware scan or blocklist check finished all month while those scans are switched on; + - a vulnerability list older than 48 hours, or an installed plugin whose vulnerability lookup has never succeeded (the refresh now records each plugin's last successful lookup); + - the uptime monitor blocked (HTTP 429) for an hour or more, or maintenance mode for 2 hours or more, in total (both count as up); + - every listed backup files only, or the latest backup read failed or finished before the month ended; + - a care plan switched off after the month ended (the month is now compiled for it, not skipped). +- **Bill.com care-plan sync** logs each care-plan flip as a local `care_plan_toggled` entry (not mirrored to wp-admin). +- **Client Reports — Schedule form** no longer offers "Weekly" for new schedules (every run reports on the previous calendar month). + +### Fixed +- **Confirm dialogs** submit through the button that was clicked, so its `formaction` and value survive the dialog (a button's value was re-added as a hidden field, which stayed behind when the page came back from the browser's back button). +- **Client Reports — Schedule form**: unchecking **Enable** had no effect, and saving a paused schedule switched it back on. +- **Client Reports — Recipients**: editing a schedule's recipients now updates its unsent reports (Send used the list the report was compiled with); addresses are de-duplicated ignoring case; sends to one report are locked, so a double click or a hand send during the scheduled run can't email anyone twice; Send on an already-sent report says so first; a draft-mode Run no longer compiles a second live report beside a sent one; `client_reports.client_email` holds long recipient lists. +- **Client Reports — Email content**: updates run from a site's Updates tab show the plugin's name instead of the raw log line; vulnerable plugin names no longer show HTML entities; a speed test with no score is skipped instead of showing a red 0/100; requests with no visits count as suspect traffic; uptime is rounded down, so a short outage never reads 100%; the inbox preview text follows the template's sections; a site added late on Nov 1 (a 25-hour day in New York) is no longer held as a partial month. +- **Client Reports — Backups**: a SpinupWP run's database half is stored on its own (`site_backups.database_bytes`), so a broken dump can't hide behind the files. +- **Client emails — Logo**: a logo uploaded in Settings is embedded from Control's disk; a relative or Control-host logo URL is never linked (it showed as a broken image). +- **Client Reports — Recipient push** no longer writes an older Companion snapshot back over a newer one. +- **Plugin updates from a site's Updates tab** are logged in the update jobs' wording ("Plugin updated: Name 1.0 → 1.1"). + +### Upgrade notes +- Run `php artisan migrate` (`site_backups.database_bytes`; `client_reports.client_email` becomes text), then `php artisan clockwork:record-site-backups` and `php artisan clockwork:refresh-plugin-vulnerabilities` once. +- Report schema 8 marks every unsent report outdated: run `php artisan clockwork:recompile-held-client-reports --outdated` (never sends). +- Approval is on by default after deploy: from the next run nothing is emailed until someone sends it. Run `npm run build` (confirm dialog change). + ## [1.12.2] - 2026-11-06 ### Added diff --git a/app/Http/Controllers/SitesController.php b/app/Http/Controllers/SitesController.php index da35e202..274de9ef 100644 --- a/app/Http/Controllers/SitesController.php +++ b/app/Http/Controllers/SitesController.php @@ -1592,7 +1592,8 @@ public function updatePlugin(Site $site, Request $request, ActionLogger $logger) $summary = $result['ok'] ? ($result['before_version'] === $result['after_version'] ? "Plugin already up to date: {$slug} ({$result['before_version']}) on {$site->domain}." - : "Updated {$slug} {$result['before_version']} → {$result['after_version']} on {$site->domain}.") + // Worded as the update jobs word it: client reports list this. + : sprintf('Plugin updated: %s %s → %s', $this->snapshotPluginName($site, $slug), $result['before_version'], $result['after_version'])) : "Plugin update failed: {$slug} on {$site->domain}."; $logger->record( @@ -1609,6 +1610,18 @@ public function updatePlugin(Site $site, Request $request, ActionLogger $logger) return response()->json($result); } + /** The plugin's name from the site's Companion snapshot, or its slug. */ + private function snapshotPluginName(Site $site, string $slug): string + { + foreach ((array) ($site->companion_snapshot['plugins']['plugins'] ?? []) as $plugin) { + if (is_array($plugin) && ($plugin['slug'] ?? null) === $slug && trim((string) ($plugin['name'] ?? '')) !== '') { + return html_entity_decode(trim((string) $plugin['name']), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + } + + return $slug; + } + /** * Toggle the care_plan_enabled flag on a site. Also sets care_plan_override * so the daily Bill.com sync respects the manual choice (sync only writes diff --git a/app/Models/SiteBackup.php b/app/Models/SiteBackup.php index 61c68799..b6995097 100644 --- a/app/Models/SiteBackup.php +++ b/app/Models/SiteBackup.php @@ -14,6 +14,7 @@ * @property string $external_ref * @property Carbon $backup_at * @property ?int $bytes + * @property ?int $database_bytes the database half of a full backup, when known * @property bool $is_manual * @property ?array $manifest * @property Carbon $first_seen_at @@ -43,6 +44,7 @@ class SiteBackup extends Model 'external_ref', 'backup_at', 'bytes', + 'database_bytes', 'is_manual', 'manifest', 'first_seen_at', @@ -54,6 +56,7 @@ protected function casts(): array return [ 'backup_at' => 'datetime', 'bytes' => 'integer', + 'database_bytes' => 'integer', 'is_manual' => 'boolean', 'manifest' => 'array', 'first_seen_at' => 'datetime', diff --git a/app/Services/Backups/SiteBackupRecorder.php b/app/Services/Backups/SiteBackupRecorder.php index 37772a3f..d4ab7078 100644 --- a/app/Services/Backups/SiteBackupRecorder.php +++ b/app/Services/Backups/SiteBackupRecorder.php @@ -71,13 +71,14 @@ public function record(Site $site): ?array if ($rows !== []) { SiteBackup::query()->upsert( array_map(fn (array $row) => $row + [ + 'database_bytes' => null, 'site_id' => $site->id, 'source' => $source, 'first_seen_at' => $now, 'last_seen_at' => $now, ], $rows), ['site_id', 'source', 'kind', 'external_ref'], - ['backup_at', 'bytes', 'is_manual', 'last_seen_at'], + ['backup_at', 'bytes', 'database_bytes', 'is_manual', 'last_seen_at'], ); } } catch (Throwable $e) { @@ -137,7 +138,7 @@ protected function fromPressable(Site $site): array return $rows; } - /** @return list */ + /** @return list */ protected function fromSpaces(Site $site): array { $rows = []; @@ -158,6 +159,8 @@ protected function fromSpaces(Site $site): array 'external_ref' => (string) $run['date'], 'backup_at' => Carbon::parse($run['date'])->utc(), 'bytes' => (int) $database + (int) $files, + // Kept apart so a broken dump can't hide behind the files. + 'database_bytes' => $database, 'is_manual' => false, ]; } diff --git a/app/Services/Security/WpVulnerabilityClient.php b/app/Services/Security/WpVulnerabilityClient.php index 6210dde6..a2216861 100644 --- a/app/Services/Security/WpVulnerabilityClient.php +++ b/app/Services/Security/WpVulnerabilityClient.php @@ -2,6 +2,7 @@ namespace App\Services\Security; +use App\Support\Settings; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; @@ -32,6 +33,8 @@ class WpVulnerabilityClient { private const BASE_URL = 'https://www.wpvulnerability.net/plugin/'; + public const LOOKUPS_SETTING = 'plugin_vulnerabilities.last_lookups'; + private const PER_REQUEST_TIMEOUT = 10; private const REQUEST_DELAY_MS = 100; @@ -113,6 +116,7 @@ public function refresh(?callable $onProgress = null): array DB::table('plugin_vulnerabilities')->insert($batch); } }); + $this->recordLookups($slugs, $failedSlugs); return [ 'ok' => true, @@ -125,6 +129,41 @@ public function refresh(?callable $onProgress = null): array ]; } + /** + * When each fleet plugin last had a successful lookup (slug => ISO 8601 + * time), kept in settings under LOOKUPS_SETTING. A plugin whose lookup + * has never succeeded has no entries to keep, so it would read "None + * found" unchecked; client reports hold for it instead. + * + * @return array|null null before the first refresh that kept them + */ + public static function lastLookups(): ?array + { + $lookups = app(Settings::class)->get(self::LOOKUPS_SETTING); + + return is_array($lookups) ? $lookups : null; + } + + /** + * @param list $slugs + * @param list $failedSlugs + */ + private function recordLookups(array $slugs, array $failedSlugs): void + { + $previous = self::lastLookups() ?? []; + $now = now()->toIso8601String(); + $lookups = []; + foreach ($slugs as $slug) { + if (! in_array($slug, $failedSlugs, true)) { + $lookups[$slug] = $now; + } elseif (isset($previous[$slug])) { + $lookups[$slug] = $previous[$slug]; + } + } + + app(Settings::class)->put(self::LOOKUPS_SETTING, $lookups); + } + /** * Collect unique plugin directory-slugs from every site's companion * snapshot. Companion stores slugs as "directory/file.php"; we strip diff --git a/app/Support/Mail/BrandLogo.php b/app/Support/Mail/BrandLogo.php index bd6053f5..a38d4f14 100644 --- a/app/Support/Mail/BrandLogo.php +++ b/app/Support/Mail/BrandLogo.php @@ -2,8 +2,10 @@ namespace App\Support\Mail; +use App\Services\Companion\CompanionBrandingManager; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Throwable; @@ -14,9 +16,20 @@ */ class BrandLogo { - /** Raw image bytes, or null when the URL is unusable or the fetch fails. */ + /** + * Raw image bytes, or null when the URL is unusable or the fetch fails. + * + * A logo uploaded in Settings lives on Control's public disk, whose URL + * is often relative ("/storage/branding/…") or on Control's own host, + * which clients can't reach: that one is read from the disk instead. + */ public static function bytes(string $url): ?string { + $local = self::fromPublicDisk($url); + if ($local !== null) { + return $local; + } + if (! Str::startsWith($url, ['https://', 'http://'])) { return null; } @@ -35,4 +48,73 @@ public static function bytes(string $url): ?string return $encoded !== '' ? (base64_decode($encoded, true) ?: null) : null; } + + /** The image's MIME type, for embedding it ("image/png" when unknown). */ + public static function mimeType(string $bytes): string + { + try { + $type = (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes); + } catch (Throwable) { + $type = false; + } + + return is_string($type) && str_starts_with($type, 'image/') ? $type : 'image/png'; + } + + /** + * Whether an email may link to $url as a remote image: an absolute + * http(s) URL that isn't on Control's own (private) host. + */ + public static function linkable(string $url): bool + { + $host = parse_url($url, PHP_URL_HOST); + + return Str::startsWith($url, ['https://', 'http://']) && is_string($host) + && $host !== parse_url((string) config('app.url'), PHP_URL_HOST) + && self::publicDiskPath($url) === null; + } + + private static function fromPublicDisk(string $url): ?string + { + $path = self::publicDiskPath($url); + if ($path === null) { + return null; + } + + try { + $disk = Storage::disk(CompanionBrandingManager::LOGO_DISK); + $bytes = $disk->exists($path) ? $disk->get($path) : null; + } catch (Throwable) { + return null; + } + + return is_string($bytes) && $bytes !== '' ? $bytes : null; + } + + /** The path on the public disk that $url points at, or null when it's elsewhere. */ + private static function publicDiskPath(string $url): ?string + { + if ($url === '') { + return null; + } + + try { + $prefix = rtrim(Storage::disk(CompanionBrandingManager::LOGO_DISK)->url(''), '/').'/'; + } catch (Throwable) { + return null; + } + + $path = parse_url($url, PHP_URL_PATH); + $prefixPath = parse_url($prefix, PHP_URL_PATH) ?: $prefix; + $sameHost = ! Str::startsWith($url, ['https://', 'http://']) || Str::startsWith($url, $prefix) + || parse_url($url, PHP_URL_HOST) === parse_url((string) config('app.url'), PHP_URL_HOST); + + if (! $sameHost || ! is_string($path) || ! Str::startsWith($path, $prefixPath)) { + return null; + } + + $relative = ltrim(Str::after($path, $prefixPath), '/'); + + return $relative !== '' && ! str_contains($relative, '..') ? rawurldecode($relative) : null; + } } diff --git a/database/migrations/2026_11_06_120000_add_database_bytes_to_site_backups_table.php b/database/migrations/2026_11_06_120000_add_database_bytes_to_site_backups_table.php new file mode 100644 index 00000000..c392c8e6 --- /dev/null +++ b/database/migrations/2026_11_06_120000_add_database_bytes_to_site_backups_table.php @@ -0,0 +1,28 @@ +unsignedBigInteger('database_bytes')->nullable()->after('bytes'); + }); + } + + public function down(): void + { + Schema::table('site_backups', function (Blueprint $table) { + $table->dropColumn('database_bytes'); + }); + } +}; diff --git a/modules/BillCom/src/CarePlanSyncService.php b/modules/BillCom/src/CarePlanSyncService.php index 644c2dab..d0764cd2 100644 --- a/modules/BillCom/src/CarePlanSyncService.php +++ b/modules/BillCom/src/CarePlanSyncService.php @@ -2,6 +2,7 @@ namespace Modules\BillCom; +use App\Models\ActionLog; use App\Models\Site; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; @@ -212,7 +213,24 @@ public function sync(int $windowDays, string $itemRegex, bool $dryRun = false): 'reason' => $domainHit ? 'domain_match' : 'customer_match', ]); } else { + $previous = (bool) $site->care_plan_enabled; $site->forceFill(['care_plan_enabled' => $shouldBeOnPlan])->save(); + // Logged like the site page's toggle, so client reports can + // tell when coverage began or ended. Written directly rather + // than through ActionLogger, which would mirror a billing + // change into the client's own wp-admin activity log. + ActionLog::query()->create([ + 'site_id' => $site->id, + 'action_type' => ActionLog::TYPE_CARE_PLAN_TOGGLED, + 'target' => $shouldBeOnPlan ? 'on' : 'off', + 'summary' => $shouldBeOnPlan + ? "Marked {$site->domain} as on a care plan (Bill.com sync)." + : "Marked {$site->domain} as NOT on a care plan (Bill.com sync).", + 'details' => ['previous' => $previous, 'now' => $shouldBeOnPlan, 'source' => 'bill_com', 'reason' => $domainHit ? 'domain_match' : 'customer_match'], + 'ok' => true, + 'actor' => 'scheduled', + 'ran_at' => now(), + ]); } $shouldBeOnPlan ? $stats['sites_set_true']++ : $stats['sites_set_false']++; diff --git a/modules/ClientReports/database/migrations/2026_11_06_120100_widen_client_reports_client_email.php b/modules/ClientReports/database/migrations/2026_11_06_120100_widen_client_reports_client_email.php new file mode 100644 index 00000000..805f76e8 --- /dev/null +++ b/modules/ClientReports/database/migrations/2026_11_06_120100_widen_client_reports_client_email.php @@ -0,0 +1,27 @@ +text('client_email')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('client_reports', function (Blueprint $table) { + $table->string('client_email')->nullable()->change(); + }); + } +}; diff --git a/modules/ClientReports/resources/views/emails/report.blade.php b/modules/ClientReports/resources/views/emails/report.blade.php index 2a00f100..f2f8a794 100644 --- a/modules/ClientReports/resources/views/emails/report.blade.php +++ b/modules/ClientReports/resources/views/emails/report.blade.php @@ -131,7 +131,7 @@
- {{ $period }} for {!! $plainDomain !!}: {{ $updateCount }} {{ \Illuminate\Support\Str::plural('update', $updateCount) }}{{ $showUptime ? ', '.$uptimeLabel.' uptime' : '' }} — full details inside. + {{ $period }} for {!! $plainDomain !!}: {{ implode(', ', array_filter([$sections['updates'] ? $updateCount.' '.\Illuminate\Support\Str::plural('update', $updateCount) : null, $showUptime ? $uptimeLabel.' uptime' : null])) ?: 'your website report' }} — full details inside.
@@ -159,12 +170,17 @@ class="px-4 sm:px-5 py-3 border-b border-[var(--color-border-light)] flex flex-w @foreach ($reports as $r) - @php($state = $stateClass::of($r)) + @php + $state = $stateClass::of($r); + @endphp
@@ -140,8 +140,8 @@ {{-- Header: brand purple so the white logo is visible --}}
@if ($logoBytes) - {{ $branding['company_name'] }} - @elseif ($branding['logo_url'] !== '') + {{ $branding['company_name'] }} + @elseif (\App\Support\Mail\BrandLogo::linkable($branding['logo_url'])) {{ $branding['company_name'] }} @else
{{ $branding['company_name'] }}
diff --git a/modules/ClientReports/resources/views/index.blade.php b/modules/ClientReports/resources/views/index.blade.php index cf843874..5ff0159a 100644 --- a/modules/ClientReports/resources/views/index.blade.php +++ b/modules/ClientReports/resources/views/index.blade.php @@ -103,7 +103,7 @@ class="cr-filter-link {{ $activeStatus === $tileStatus ? 'is-active' : '' }}"> - {{-- Bulk actions for the period. Never send. --}} + {{-- Bulk actions for the period: recompile (never sends), or approve and send. --}}
@csrf @@ -129,6 +129,17 @@ class="px-4 sm:px-5 py-3 border-b border-[var(--color-border-light)] flex flex-w Recompile all outdated ({{ $outdatedCount }}) @endif + + + @if ($readyCount > 0) + + @endif
@@ -147,7 +158,7 @@ class="px-4 sm:px-5 py-3 border-b border-[var(--color-border-light)] flex flex-w
- Site & client
- canRecompile()) + @php + $selectable = $r->canRecompile() || in_array($state, [$stateClass::READY, $stateClass::NEEDS_REVIEW, $stateClass::PARTLY_SENT], true); + @endphp + + title="{{ $selectable ? 'Select to recompile or send' : 'Already sent or replaced' }}"> {{ $r->site?->domain ?? $r->client_name ?? $r->title }} diff --git a/modules/ClientReports/resources/views/schedules/_row.blade.php b/modules/ClientReports/resources/views/schedules/_row.blade.php index dc4b88f5..fb0c203b 100644 --- a/modules/ClientReports/resources/views/schedules/_row.blade.php +++ b/modules/ClientReports/resources/views/schedules/_row.blade.php @@ -72,7 +72,7 @@
@csrf
+ {{-- Every run reports on the previous calendar month, so "weekly" only + re-checks it each week. Offered only to a schedule already set to it + (from wp-admin, say), with what it really does. --}} + @if ($freq === 'weekly') + @endif @@ -147,6 +152,12 @@ class="w-full px-3.5 py-2.5 rounded-lg border border-[var(--color-border)] bg-[v + @if (\Modules\ClientReports\Services\ScheduledReportRunner::approvalRequired()) +

+ Approval is on for every schedule, so Automatic reports also wait for you to send them. + Change it on the Scheduling tab. +

+ @endif {{-- Recipient Emails --}} @@ -170,6 +181,8 @@ class="w-full px-3.5 py-2.5 rounded-lg border border-[var(--color-border)] bg-[v {{-- Enabled Toggle --}}
+ {{-- The coverage gap: care-plan sites that get no report at all. --}} @if ($missingSites->isNotEmpty())
diff --git a/modules/ClientReports/routes/web.php b/modules/ClientReports/routes/web.php index 5911713c..bac6ec8e 100644 --- a/modules/ClientReports/routes/web.php +++ b/modules/ClientReports/routes/web.php @@ -23,6 +23,8 @@ Route::get('/client-reports/reports', [ClientReportsController::class, 'index'])->name('client-reports.reports'); // Recompiles a batch of unsent scheduled reports. Never sends. Route::post('/client-reports/reports/recompile', [ClientReportsController::class, 'recompileMany'])->name('client-reports.recompile-many'); + // Sends a batch: every Ready to send report of a period, or the ticked ones. + Route::post('/client-reports/reports/send', [ClientReportsController::class, 'sendMany'])->name('client-reports.send-many'); Route::post('/client-reports/generate', [ClientReportsController::class, 'generate'])->name('client-reports.generate'); // Templates CRUD Route::resource('/client-reports/templates', TemplatesController::class) @@ -35,6 +37,9 @@ ->name('client-reports.schedules.quick'); Route::post('/client-reports/schedules/quick-all', [SchedulesController::class, 'quickStoreAll']) ->name('client-reports.schedules.quick-all'); + // Whether every report waits for a person to send it (on by default). + Route::post('/client-reports/schedules/approval', [SchedulesController::class, 'setApproval']) + ->name('client-reports.schedules.approval'); Route::resource('/client-reports/schedules', SchedulesController::class) ->except(['show']) ->names('client-reports.schedules'); diff --git a/modules/ClientReports/src/Console/Commands/SendScheduledClientReports.php b/modules/ClientReports/src/Console/Commands/SendScheduledClientReports.php index 08932183..f7c9ca5a 100644 --- a/modules/ClientReports/src/Console/Commands/SendScheduledClientReports.php +++ b/modules/ClientReports/src/Console/Commands/SendScheduledClientReports.php @@ -16,7 +16,7 @@ class SendScheduledClientReports extends Command {--site= : Limit run to a specific site id or domain} {--force : Force generation even if not yet due}'; - protected $description = 'Generate and email scheduled client reports for care plan websites.'; + protected $description = 'Compile scheduled client reports for care plan websites; email the clean ones only when reports don\'t need approval.'; /** The action log entry listing the schedules a run skipped. */ public const ACTION_SKIPPED = 'client_report_skipped'; @@ -71,7 +71,7 @@ public function handle(ScheduledReportRunner $runner, ActionLogger $actions): in // Reports are for care-plan customers. A due schedule still moves on // to next month, so turning the care plan back on later doesn't send // a report for a month the site had none. - if (! ScheduledReportRunner::siteIsEligible($site)) { + if (! ScheduledReportRunner::siteIsEligibleFor($site, now()->subMonthNoOverflow()->endOfMonth())) { $schedule->advanceNextRun(); $noCarePlan[] = (string) $site->domain; $this->line(" - {$site->domain}: skipped this month (care plan is off; reports are for care-plan sites)"); @@ -80,7 +80,7 @@ public function handle(ScheduledReportRunner $runner, ActionLogger $actions): in } $recipients = array_values(array_filter((array) $schedule->recipients)); - $isAuto = $schedule->delivery_mode !== ClientReportSchedule::MODE_DRAFT; + $isAuto = ScheduledReportRunner::sendsByItself($schedule); if ($isAuto && empty($recipients)) { $this->line(" - {$site->domain}: skipped (auto delivery mode requires at least one recipient)"); @@ -94,7 +94,7 @@ public function handle(ScheduledReportRunner $runner, ActionLogger $actions): in if ($run->held()) { $this->warn(" ! {$site->domain}: held as a draft for review: ".implode(' ', $run->heldReasons)); } elseif ($run->isDraft) { - $this->line(" ✓ {$site->domain}: generated draft report (awaiting operator review)"); + $this->line(" ✓ {$site->domain}: compiled, waiting for approval (nothing sent)"); } elseif ($run->complete() && $run->sentTo === []) { $this->line(" ✓ {$site->domain}: every recipient already had this report; nothing sent"); } elseif ($run->complete()) { diff --git a/modules/ClientReports/src/Http/Controllers/ClientReportsController.php b/modules/ClientReports/src/Http/Controllers/ClientReportsController.php index fe541b2a..13f5ca1d 100644 --- a/modules/ClientReports/src/Http/Controllers/ClientReportsController.php +++ b/modules/ClientReports/src/Http/Controllers/ClientReportsController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\Site; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Mail\Mailables\Address; @@ -11,6 +12,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Str; use Illuminate\View\View; use InvalidArgumentException; use Modules\ClientReports\Mail\ClientReportMail; @@ -30,6 +32,9 @@ class ClientReportsController extends Controller /** Most reports one "Recompile" press works through, so the request finishes. */ public const RECOMPILE_BATCH = 100; + /** Reports one press of a bulk send emails, so the request finishes well inside the web server's timeout. */ + public const SEND_BATCH = 25; + /** * The reports list: one period at a time (the latest by default), * filtered by state, hold reason, client or search, with a summary of @@ -73,6 +78,8 @@ public function index(Request $request, ReportBrowser $browser): View 'periods' => $periods, 'summary' => $browser->summarize($periodReports), 'outdatedCount' => $outdatedCount, + 'readyCount' => $periodReports->filter(fn (ClientReport $r) => ReportState::of($r) === ReportState::READY)->count(), + 'approvalRequired' => ScheduledReportRunner::approvalRequired(), 'sites' => $sites, 'templates' => $templates, ]); @@ -260,7 +267,11 @@ public function send(ClientReport $report, Request $request, ClientReportSender $report->update(['client_email' => implode(', ', $recipients)]); } - $failed = $sender->send($report, $recipients); + try { + $failed = $sender->send($report, $recipients); + } catch (LockTimeoutException) { + return back()->with('status_error', 'This report is already being sent. Reload the page in a minute to see who has it.'); + } $delivered = array_values(array_diff($recipients, $failed)); // A held or draft report sent by hand counts as the schedule's send, @@ -369,6 +380,99 @@ public function recompileMany(Request $request, ReportBrowser $browser, Schedule : $back->with('status_error', $message.' Failed: '.implode(', ', $failed).' (see the log).'); } + /** + * Approve and send a batch: every Ready to send report of the period + * (nothing to fix), or the ones ticked on the list. A ticked report that + * is held for review is sent too: ticking it is the approval. Each goes + * through the same ClientReportSender as the Send button (one send per + * report at a time, every delivery recorded as it happens), so a press + * cut off part-way loses nothing and pressing again only emails who is + * still owed it. Outdated, replaced and already-sent reports are skipped. + */ + public function sendMany(Request $request, ReportBrowser $browser, ClientReportSender $sender): RedirectResponse + { + $filters = ReportListFilters::fromRequest($request, ReportListFilters::ALL); + $back = redirect()->route('client-reports.reports', $filters->toQuery()); + $readyOnly = $request->input('scope') !== 'selected'; + + if ($readyOnly) { + $ids = $browser->list(new ReportListFilters(period: $filters->period, status: ReportState::READY)) + ->filter(fn (ClientReport $r) => ReportState::of($r) === ReportState::READY) + ->pluck('id') + ->values(); + if ($ids->isEmpty()) { + return $back->with('status', 'No reports are ready to send in this period.'); + } + } else { + $ids = collect((array) $request->input('ids', [])) + ->filter(fn ($id) => is_scalar($id) && ctype_digit((string) $id)) + ->map(fn ($id) => (int) $id) + ->unique() + ->values(); + if ($ids->isEmpty()) { + return $back->with('status_error', 'Tick the reports to send first.'); + } + } + + @set_time_limit(600); + $remaining = max(0, $ids->count() - self::SEND_BATCH); + $sent = 0; + $skipped = []; + $failed = []; + + foreach ($ids->take(self::SEND_BATCH) as $id) { + $report = ClientReport::query()->with(['site', 'schedule'])->find($id); + $label = $report?->site->domain ?? "#{$id}"; + if ($report === null || self::bulkSendBlocked($report, $readyOnly)) { + $skipped[] = $label; + + continue; + } + + try { + $failedTo = $sender->send($report, $report->recipientsToSend()); + } catch (LockTimeoutException) { + $skipped[] = $label; + + continue; + } catch (Throwable $e) { + $failed[] = $label; + Log::error("Bulk send of client report #{$report->id} failed: {$e->getMessage()}", ['exception' => $e]); + + continue; + } + + if ($failedTo !== []) { + $failed[] = $label.' ('.implode(', ', $failedTo).')'; + + continue; + } + $sent++; + $report->schedule?->update(['last_sent_at' => now()]); + } + + $message = "Sent {$sent} ".Str::plural('report', $sent).'.'; + if ($skipped !== []) { + $message .= ' Skipped '.count($skipped).' that can\'t be sent as they are (outdated, replaced, already sent, no recipient, or no longer ready): '.implode(', ', array_slice($skipped, 0, 10)).(count($skipped) > 10 ? ' …' : '').'.'; + } + if ($remaining > 0) { + $message .= " {$remaining} more are left: press the button again to send them."; + } + + return $failed === [] + ? $back->with('status', $message) + : $back->with('status_error', $message.' Failed: '.implode('; ', $failed).'. Sending again only retries who is still owed it (see the log).'); + } + + /** Why a bulk send leaves this report alone: true when it can't or shouldn't go. */ + protected static function bulkSendBlocked(ClientReport $report, bool $readyOnly): bool + { + return $report->status !== ClientReport::STATUS_GENERATED + || ReportState::sendBlockedReason($report) !== null + || $report->recipientsToSend() === [] + || ($readyOnly && ReportState::of($report) !== ReportState::READY); + } + /** * Where the preview and email pages lead: back to the list (or client * page) they were opened from with its filters, and to the previous and diff --git a/modules/ClientReports/src/Http/Controllers/SchedulesController.php b/modules/ClientReports/src/Http/Controllers/SchedulesController.php index af30cf7c..2be65a0f 100644 --- a/modules/ClientReports/src/Http/Controllers/SchedulesController.php +++ b/modules/ClientReports/src/Http/Controllers/SchedulesController.php @@ -4,6 +4,8 @@ use App\Http\Controllers\Controller; use App\Models\Site; +use App\Services\ActionLog\ActionLogger; +use App\Support\Settings; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -19,6 +21,9 @@ class SchedulesController extends Controller { + /** The action log entry for turning "every report waits for approval" on or off. */ + public const ACTION_APPROVAL_CHANGED = 'client_report_approval_changed'; + /** Schedule list filters: who won't get a report, and why. */ public const FILTERS = [ 'all' => 'All', @@ -232,6 +237,18 @@ public function update(Request $request, ClientReportSchedule $schedule): Redire $schedule->update($updates); + // A held report keeps the recipients it was compiled with, and Send + // emails those: bring this schedule's unsent copies in line, so the + // fix the operator just made is the list that gets the report. + // Partly sent ones too; who already has it stays in sent_to. + if ($recipients !== []) { + ClientReport::query() + ->where('schedule_id', $schedule->id) + ->where('status', ClientReport::STATUS_GENERATED) + ->get() + ->each(fn (ClientReport $report) => $report->update(['client_email' => implode(', ', $recipients)])); + } + if ($schedule->site) { app(ReportRecipientsSyncService::class)->pushToSite($schedule->site, $schedule); } @@ -352,6 +369,29 @@ protected function createDefaultSchedule(Site $site): ClientReportSchedule return $schedule; } + /** + * Turn "every report waits for approval" on or off for all schedules. + * Logged, since turning it off lets clean reports reach clients with + * nobody checking them first. + */ + public function setApproval(Request $request, ActionLogger $actions): RedirectResponse + { + $required = $request->boolean('require_approval'); + app(Settings::class)->put(ScheduledReportRunner::APPROVAL_SETTING, $required); + + $actions->record( + actionType: self::ACTION_APPROVAL_CHANGED, + summary: $required + ? 'Client reports: every report now waits for approval before it is sent.' + : 'Client reports: clean reports from Automatic schedules now send themselves.', + details: ['require_approval' => $required, 'by' => $request->user()?->email], + ); + + return back()->with('status', $required + ? 'Approval is on: every report waits for you to send it.' + : 'Approval is off: Automatic schedules now email their clean reports by themselves.'); + } + public function sendNow(ClientReportSchedule $schedule, ScheduledReportRunner $runner): RedirectResponse { $site = $schedule->site; @@ -364,7 +404,7 @@ public function sendNow(ClientReportSchedule $schedule, ScheduledReportRunner $r } $recipients = array_values(array_filter((array) $schedule->recipients)); - $isAuto = $schedule->delivery_mode !== ClientReportSchedule::MODE_DRAFT; + $isAuto = ScheduledReportRunner::sendsByItself($schedule); if ($isAuto && empty($recipients)) { return back()->with('status_error', 'Cannot run auto delivery: no recipient email addresses configured.'); @@ -380,7 +420,7 @@ public function sendNow(ClientReportSchedule $schedule, ScheduledReportRunner $r if ($run->isDraft) { return redirect()->route('client-reports.show', $run->report) - ->with('status', 'Draft report compiled successfully. Preview and review below before sending.'); + ->with('status', 'Report compiled and waiting for approval: check it below, then send it. Nothing was sent.'); } if (! $run->complete()) { @@ -411,6 +451,6 @@ protected function parseRecipients(string $input): array { $raw = preg_split('/[\r\n,]+/', $input); - return array_values(array_unique(array_filter(array_map('trim', (array) $raw)))); + return ClientReport::uniqueAddresses(array_filter(array_map('trim', (array) $raw))); } } diff --git a/modules/ClientReports/src/Mail/ClientReportMail.php b/modules/ClientReports/src/Mail/ClientReportMail.php index bc851b69..44e3f884 100644 --- a/modules/ClientReports/src/Mail/ClientReportMail.php +++ b/modules/ClientReports/src/Mail/ClientReportMail.php @@ -166,6 +166,12 @@ public static function cleanUpdateSummary(string $summary, ?string $target = nul // Names come from WordPress with HTML entities ("Example & Co"); // the views escape what they print, so decode them here. $summary = html_entity_decode($summary, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + // An update run from the site's Updates tab was logged as "Updated + // contact-form-7/wp-contact-form-7.php 6.0.6 → 6.1 on example.com.": + // name it from its folder instead. + if (preg_match('/^\s*Updated\s+(\S+)\s+(\S+\s*→\s*\S+)\s+on\s+\S+?\.?\s*$/u', $summary, $m) === 1) { + return Str::headline(Str::before($m[1], '/')).' '.$m[2]; + } $clean = preg_replace('/^\s*(plugin|theme|core|wordpress(?: core)?)\s+updated\s*:\s*/i', '', $summary) ?? $summary; $clean = trim(preg_replace('/\s*\[[^\]]*\]\s*$/', '', $clean) ?? $clean); diff --git a/modules/ClientReports/src/Models/ClientReport.php b/modules/ClientReports/src/Models/ClientReport.php index a8347e7b..1882463c 100644 --- a/modules/ClientReports/src/Models/ClientReport.php +++ b/modules/ClientReports/src/Models/ClientReport.php @@ -81,7 +81,38 @@ public static function parseRecipients(?string $input): array { $parts = preg_split('/[\s,;]+/', (string) $input) ?: []; - return array_values(array_unique(array_filter($parts, fn (string $part) => $part !== ''))); + return self::uniqueAddresses(array_filter($parts, fn (string $part) => $part !== '')); + } + + /** + * Addresses with duplicates removed ignoring case ("Jane@x.com" and + * "jane@x.com" are one inbox), keeping the first spelling. + * + * @param iterable $addresses + * @return list + */ + public static function uniqueAddresses(iterable $addresses): array + { + $unique = []; + foreach ($addresses as $address) { + $unique[strtolower($address)] ??= $address; + } + + return array_values($unique); + } + + /** + * $addresses without those in $without, ignoring case. + * + * @param list $addresses + * @param list $without + * @return list + */ + public static function addressesWithout(array $addresses, array $without): array + { + $skip = array_map('strtolower', $without); + + return array_values(array_filter($addresses, fn (string $address) => ! in_array(strtolower($address), $skip, true))); } /** @return list */ @@ -102,7 +133,7 @@ public function recipientsToSend(): array return $this->recipients(); } - return array_values(array_diff($this->recipients(), $this->sent_to ?? [])); + return self::addressesWithout($this->recipients(), $this->sent_to ?? []); } /** diff --git a/modules/ClientReports/src/Services/ClientReportCompiler.php b/modules/ClientReports/src/Services/ClientReportCompiler.php index 6a91d99f..a68f4d35 100644 --- a/modules/ClientReports/src/Services/ClientReportCompiler.php +++ b/modules/ClientReports/src/Services/ClientReportCompiler.php @@ -21,6 +21,8 @@ use App\Services\Companion\CompanionBrandingManager; use App\Services\Security\CompanionMalwareScanner; use App\Services\Security\PluginVulnerabilityMatcher; +use App\Services\Security\WpVulnerabilityClient; +use App\Support\Settings; use App\Support\UpgraderOutput; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -32,7 +34,7 @@ class ClientReportCompiler { - public const SCHEMA_VERSION = 7; + public const SCHEMA_VERSION = 8; /** * security.malware_status for a clean scan that didn't cover the whole @@ -202,6 +204,10 @@ public function compile( // views say so and the report is held for review. 'coverage_start' => $joinedAt?->toIso8601String(), 'coverage_source' => $coverageSource, + // When the care plan was switched off after the period ended + // (Bill.com's sync on the 1st, say), for a site that's off now: + // the period was covered, but a person confirms it still goes. + 'care_plan_ended_at' => self::carePlanSwitchedOffAfter($site, $periodEnd->copy()->endOfDay())?->toIso8601String(), // Bumped when a section's meaning changes, so the views can // tell reports compiled under the old (overstating) rules apart. 'schema_version' => self::SCHEMA_VERSION, @@ -253,10 +259,45 @@ public function compile( * reads. Being added on the first day counts as the whole period, as it * does for backups (covers_period). */ + /** + * When a site that is off a care plan now was switched off, if that was + * after $end and it was on until then (the first toggle after $end turned + * it off), else null. With care plans off fleet-wide every site counts. + */ + public static function carePlanSwitchedOffAfter(Site $site, Carbon $end): ?Carbon + { + if (! Site::areCarePlansEnabled() || $site->care_plan_enabled) { + return null; + } + + $first = ActionLog::query() + ->where('site_id', $site->id) + ->where('action_type', ActionLog::TYPE_CARE_PLAN_TOGGLED) + ->where('ran_at', '>', $end) + ->orderBy('ran_at') + ->orderBy('id') + ->first(['ran_at', 'details']); + $details = is_array($first?->details) ? $first->details : []; + + return ($details['previous'] ?? null) === true && ($details['now'] ?? $details['enabled'] ?? null) === false + ? $first->ran_at + : null; + } + + /** + * The end of the period's first day in the report timezone, as UTC. Not + * $start + 24 hours: the day the clocks go back (Nov 1, 2026 in New + * York) has 25. + */ + protected static function afterFirstDay(Carbon $start): Carbon + { + return $start->copy()->setTimezone(ReportPhrases::timezone())->addDay()->startOfDay()->utc(); + } + protected function joinedAt(Site $site, Carbon $start, Carbon $end): ?Carbon { $created = $site->created_at; - if ($created === null || ! $created->betweenIncluded($start, $end) || $created->lessThan($start->copy()->addDay())) { + if ($created === null || ! $created->betweenIncluded($start, $end) || $created->lessThan(self::afterFirstDay($start))) { return null; } @@ -314,7 +355,7 @@ protected function carePlanStartedAt(Site $site, Carbon $start, Carbon $end): ?C }); $at = $switchedOn?->ran_at; - if ($at === null || $at->lessThan($start->copy()->addDay())) { + if ($at === null || $at->lessThan(self::afterFirstDay($start))) { return null; } @@ -349,7 +390,7 @@ protected function uptimeMonitoringStart(Site $site, Carbon $start, Carbon $end) default => null, }; - if ($began === null || $began->lessThan($start->copy()->addDay())) { + if ($began === null || $began->lessThan(self::afterFirstDay($start))) { return null; } @@ -416,7 +457,7 @@ protected function compileUpdates(Site $site, Carbon $start, Carbon $end): array 'at' => $log->ran_at, 'item' => [ 'target' => $log->target, - 'summary' => $log->summary, + 'summary' => self::updatesTabSummary($site, $log) ?? $log->summary, 'date' => $log->ran_at?->copy()->setTimezone($zone)->toDateString(), 'observed' => false, ], @@ -781,6 +822,30 @@ public static function updatesWereWaiting(array $updates): bool * the version it started from ("1.5.3 → 1.5.3"), a plugin that was already * current, or a translations run with no packages. */ + /** + * An update run from the site's Updates tab was logged as "Updated + * {file slug} {from} → {to} on {domain}." (SitesController::updatePlugin + * before it used the jobs' wording). Reworded as the jobs word it, with + * the plugin's name from the snapshot, or null for any other summary. + */ + public static function updatesTabSummary(Site $site, ActionLog $log): ?string + { + if ($log->action_type !== ActionLog::TYPE_PLUGIN_UPDATE + || preg_match('/^\s*Updated\s+(\S+)\s+(\S+\s*→\s*\S+)\s+on\s+\S+?\.?\s*$/u', (string) $log->summary, $m) !== 1) { + return null; + } + + $name = null; + foreach ((array) ($site->companion_snapshot['plugins']['plugins'] ?? []) as $plugin) { + if (is_array($plugin) && ($plugin['slug'] ?? null) === $m[1] && trim((string) ($plugin['name'] ?? '')) !== '') { + $name = html_entity_decode(trim((string) $plugin['name']), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + break; + } + } + + return 'Plugin updated: '.($name ?? Str::headline(Str::before($m[1], '/'))).' '.$m[2]; + } + public static function isNoOpUpdate(string $summary): bool { return (bool) preg_match('/(? $i['blocked']))); + // Maintenance windows count as up. A long one (a maintenance page + // left on for days) would read as a perfect month, so it is measured + // for the review flags: from each maintenance event to the next event. + $maintenanceSeconds = 0; + $maintenanceFrom = $priorEvent?->event_type === SiteUptimeEvent::TYPE_MAINTENANCE ? $from->copy() : null; + foreach ($events as $event) { + if ($maintenanceFrom !== null) { + $maintenanceSeconds += max(0, (int) $maintenanceFrom->diffInSeconds($event->event_at)); + $maintenanceFrom = null; + } + if ($event->event_type === SiteUptimeEvent::TYPE_MAINTENANCE) { + $maintenanceFrom = $event->event_at->copy(); + } + } + if ($maintenanceFrom !== null && $periodEnd->greaterThan($maintenanceFrom)) { + $maintenanceSeconds += (int) $maintenanceFrom->diffInSeconds($periodEnd); + } + $outages = count($outageIntervals) + ($open !== null && ! $open['blocked'] && ! $periodEnd->greaterThan($open['from']) ? 1 : 0); $downtimeSeconds = array_sum(array_map($seconds, $outageIntervals)); $downtimeMinutes = min($totalMinutes, (int) round($downtimeSeconds / 60)); - $uptimePercentage = max(0.0, min(100.0, round(100 - (($downtimeMinutes / $totalMinutes) * 100), 2))); + // Rounded down, so a minute or two of downtime reads 99.99%, not a + // "100%" beside a listed outage. + $uptimePercentage = max(0.0, min(100.0, floor((100 - (($downtimeMinutes / $totalMinutes) * 100)) * 100) / 100)); // No events reads as 100%, so the flag alone isn't enough: the probe // skips ignored and staging servers and ignore-listed domains (the @@ -928,6 +1013,8 @@ protected function compileUptime(Site $site, Carbon $start, Carbon $end, ?Carbon // downtime. probe_blocked_since is when a block still in place at // the period end began, so the report is held for a person to check. 'probe_blocked_minutes' => (int) round($blockedSeconds / 60), + // Internal: time in maintenance mode, which counts as up. + 'maintenance_minutes' => (int) round($maintenanceSeconds / 60), 'probe_blocked_since' => $open !== null && $open['blocked'] ? $open['began']->toIso8601String() : null, // Newest first, capped: the email lists the most recent outages. An // outage carried in from before the period starts at the period @@ -982,6 +1069,22 @@ protected function compileSecurity(Site $site, Carbon $start, Carbon $end, bool $issueScans = $companionScans->filter(fn (SiteSecurityScan $scan) => $effectiveStatus($scan) === SiteSecurityScan::STATUS_ISSUES_FOUND); $blacklistScan = $scans->firstWhere('scan_type', SiteSecurityScan::TYPE_BLACKLIST); + // Scans the email's rows don't show but a person should: SiteCheck + // runs daily beside Companion but only stands in for it, so its hits + // (malware served to visitors, a blocklisting) would otherwise never + // be read; and a core-file or blocklist problem earlier in the month + // is hidden by a later clean result. + $badStatuses = [SiteSecurityScan::STATUS_WARNING, SiteSecurityScan::STATUS_ISSUES_FOUND]; + $sitecheckHits = $scans + ->where('scan_type', SiteSecurityScan::TYPE_SITECHECK) + ->where('status', SiteSecurityScan::STATUS_ISSUES_FOUND) + ->reject(fn (SiteSecurityScan $scan) => $malwareScan !== null && $scan->is($malwareScan)) + ->values(); + $checksumIssue = $isWordPress + ? $scans->first(fn (SiteSecurityScan $scan) => $scan->scan_type === SiteSecurityScan::TYPE_CORE_CHECKSUMS && in_array($scan->status, $badStatuses, true)) + : null; + $blacklistIssue = $scans->first(fn (SiteSecurityScan $scan) => $scan->scan_type === SiteSecurityScan::TYPE_BLACKLIST && in_array($scan->status, $badStatuses, true)); + // The plugin list is the site's current Companion snapshot, so this is // the state on the snapshot date, not a history over the period. // An empty or stale vulnerability feed matches nothing, which would @@ -1026,9 +1129,15 @@ protected function compileSecurity(Site $site, Carbon $start, Carbon $end, bool 'clean_scans' => $scans->where('status', SiteSecurityScan::STATUS_CLEAN)->count(), 'warn_scans' => $scans->whereIn('status', [SiteSecurityScan::STATUS_WARNING, SiteSecurityScan::STATUS_ISSUES_FOUND])->count(), 'vulnerabilities_checked' => $vulnerabilitiesChecked, + // Internal: the site has a plugin list but the feed is too old to + // check it against, so the client would read "Not checked". + 'vulnerability_feed_stale' => is_array($snapshotPlugins) && ! $this->vulnerabilityFeedIsFresh(), 'vulnerabilities_as_of' => $vulnerabilitiesChecked ? $site->companion_snapshot_at?->toDateString() : null, 'active_vulnerabilities' => count($vulnerabilities), 'vulnerable_plugins' => array_values(array_unique(array_column($vulnerabilities, 'plugin_name'))), + // Internal: installed plugins whose vulnerability lookup has never + // succeeded, so "None found" doesn't cover them. + 'vulnerabilities_unchecked_plugins' => $vulnerabilitiesChecked ? self::neverLookedUp((array) $snapshotPlugins) : [], // Internal: each vulnerable plugin by its file slug, so the review // flags can tell whether a waiting update fixes it or its updates // are paused. Names aren't unique enough to join on. @@ -1089,6 +1198,21 @@ protected function compileSecurity(Site $site, Carbon $start, Carbon $end, bool 'malware_issue_days' => $issueScans->map(fn (SiteSecurityScan $s) => $s->scanned_at->toDateString())->unique()->count(), 'blacklist_status' => $blacklistScan?->status, 'blacklist_checked_at' => $blacklistScan?->scanned_at?->toDateString(), + // Internal: whether the daily scans that fill the malware and + // blocklist rows were switched on, so a month in which every one + // failed is held rather than sent as "Not checked this month". + 'malware_expected' => $this->settingOn('security_scans.companion_malware_enabled') + || $this->settingOn('security_scans.sitecheck_enabled'), + 'blacklist_expected' => $this->settingOn('security_scans.blacklist_enabled'), + // Internal, for the review flags: the latest in-period day each + // of these found a problem, or null. + 'checksum_last_issue_at' => $checksumIssue?->scanned_at?->toDateString(), + 'blacklist_last_issue_at' => $blacklistIssue?->scanned_at?->toDateString(), + 'sitecheck_last_issue_at' => $sitecheckHits->first()?->scanned_at?->toDateString(), + 'sitecheck_issues' => array_values(array_filter([ + $sitecheckHits->contains(fn (SiteSecurityScan $scan) => (bool) $scan->has_malware_hit) ? 'malware' : null, + $sitecheckHits->contains(fn (SiteSecurityScan $scan) => (bool) $scan->blacklist_hit) ? 'blocklist' : null, + ])), ]; } @@ -1412,6 +1536,36 @@ protected function reportsBlocks(Site $site): bool return false; } + /** + * The names of the snapshot's plugins that no vulnerability lookup has + * succeeded for yet; empty before the refresh began keeping lookups. + * + * @param array $plugins + * @return list + */ + protected static function neverLookedUp(array $plugins): array + { + $lookups = WpVulnerabilityClient::lastLookups(); + if ($lookups === null) { + return []; + } + + $names = []; + foreach ($plugins as $plugin) { + $slug = is_array($plugin) ? (string) strtok((string) ($plugin['slug'] ?? ''), '/') : ''; + if ($slug !== '' && ! isset($lookups[$slug])) { + $names[] = html_entity_decode(trim((string) ($plugin['name'] ?? '')) ?: $slug, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + } + + return array_values(array_unique($names)); + } + + protected function settingOn(string $key): bool + { + return (bool) app(Settings::class)->get($key, true); + } + protected function vulnerabilityFeedIsFresh(): bool { if ($this->vulnerabilityFeedFresh === null) { @@ -1435,6 +1589,9 @@ protected function compilePerformance(Site $site, Carbon $start, Carbon $end): a $inPeriod = fn () => SitePerformanceScan::query() ->where('site_id', $site->id) ->where('status', SitePerformanceScan::STATUS_OK) + // A "completed" test can come back without a score; shown, it + // would read as a red 0/100. + ->whereNotNull('performance_score') ->whereRaw('COALESCE(source_generated_at, scanned_at) BETWEEN ? AND ?', [$start, $end]) ->orderByRaw('COALESCE(source_generated_at, scanned_at) DESC'); @@ -1545,7 +1702,10 @@ protected function compileTraffic(Site $site, Carbon $start, Carbon $end): array $source = $site->isPressable() ? 'pressable' : 'nginx'; [$topIpShare, $topIpCount] = self::topIps($measured); $suspect = ($source === 'nginx' && $topIpShare !== null && $topIpShare > self::TRAFFIC_TOP_IP_SHARE_MAX) - || ($requests > self::TRAFFIC_BUSY_REQUESTS && $visits < $requests * self::TRAFFIC_MIN_VISITS_PER_REQUEST); + || ($requests > self::TRAFFIC_BUSY_REQUESTS && $visits < $requests * self::TRAFFIC_MIN_VISITS_PER_REQUEST) + // Requests with no visits at all: the visitor count never arrived + // (a feed can return one family of metrics without the other). + || ($requests > 0 && $visits === 0); $status = match (true) { // supportsTrafficReport() is the host's state now, so it only @@ -1713,7 +1873,14 @@ protected function compileBackups(Site $site, Carbon $start, Carbon $end, ?Carbo $dbSize = is_numeric($dbSize) && (int) $dbSize > 0 ? (int) $dbSize : null; $dbBackup = $inPeriod->first(fn (SiteBackup $b) => $b->bytes !== null && in_array($b->kind, [SiteBackup::KIND_DATABASE, SiteBackup::KIND_FULL], true)); - $dbBackupTooSmall = $dbSize !== null && $dbBackup !== null && self::backupTooSmall((int) $dbBackup->bytes, $dbSize); + // A full backup's own database half when the source reports it + // (SpinupWP): its combined size would hide an empty dump. + $dbBackupBytes = $dbBackup === null ? null : (int) ($dbBackup->kind === SiteBackup::KIND_FULL ? ($dbBackup->database_bytes ?? $dbBackup->bytes) : $dbBackup->bytes); + $dbBackupTooSmall = $dbSize !== null && $dbBackupBytes !== null && self::backupTooSmall($dbBackupBytes, $dbSize); + // Every backup listed is files only: the database isn't being backed + // up (SpinupWP drops a run's empty dump, leaving its files). + $noDatabaseBackup = $recent->isNotEmpty() + && $recent->every(fn (array $d) => array_diff($d['kinds'], [SiteBackup::KIND_FILES]) === []); // Our own relay keeps its failure streak on the site row: when it was // already failing by the end of the period, that's why there's @@ -1769,8 +1936,13 @@ protected function compileBackups(Site $site, Carbon $start, Carbon $end, ?Carbo 'check_error' => $check !== null && ! $check->ok ? $check->error : null, 'checked_at' => $check?->checked_at->toIso8601String(), // Internal: the newest database backup's size against the database's. - 'db_backup_bytes' => $dbBackup?->bytes, + 'db_backup_bytes' => $dbBackupBytes, 'db_size_bytes' => $dbSize, + // Internal: no database backup among the days listed. + 'no_database_backup' => $noDatabaseBackup, + // Internal: the latest read of the source finished before the + // period ended (the last day's backups may be missing). + 'read_before_period_end' => $check !== null && $check->ok && $end->lessThan(now()) && $check->checked_at->lessThan($end), // Internal: when our relay's current failure streak began, if that // was by the end of the period, and its last error (trimmed, URLs // without query strings). For the review flags and the preview. diff --git a/modules/ClientReports/src/Services/ClientReportSender.php b/modules/ClientReports/src/Services/ClientReportSender.php index f49233ed..27e19686 100644 --- a/modules/ClientReports/src/Services/ClientReportSender.php +++ b/modules/ClientReports/src/Services/ClientReportSender.php @@ -2,6 +2,7 @@ namespace Modules\ClientReports\Services; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Modules\ClientReports\Mail\ClientReportMail; @@ -10,6 +11,12 @@ class ClientReportSender { + /** Longer than a send to a long recipient list can take. */ + private const LOCK_SECONDS = 600; + + /** How long a second send waits for the first before giving up. */ + private const LOCK_WAIT_SECONDS = 120; + /** * Emails each recipient separately. Every successful delivery is written to * `sent_to` before the next one is attempted, so a failure (or a crash) @@ -22,6 +29,26 @@ class ClientReportSender * @return list recipients whose send threw */ public function send(ClientReport $report, array $recipients): array + { + $sentBefore = $report->sent_to ?? []; + + // One send per report at a time: a double click, two operators on + // the same held report, or a hand send during the scheduled run. + // A send that waited re-reads who got it meanwhile and skips them. + return Cache::lock('client-report-send:'.$report->id, self::LOCK_SECONDS) + ->block(self::LOCK_WAIT_SECONDS, function () use ($report, $recipients, $sentBefore) { + $report->refresh(); + $sentMeanwhile = ClientReport::addressesWithout($report->sent_to ?? [], $sentBefore); + + return $this->deliver($report, ClientReport::addressesWithout(ClientReport::uniqueAddresses($recipients), $sentMeanwhile)); + }); + } + + /** + * @param list $recipients + * @return list + */ + protected function deliver(ClientReport $report, array $recipients): array { $failed = []; @@ -40,7 +67,7 @@ public function send(ClientReport $report, array $recipients): array } $report->update([ - 'sent_to' => array_values(array_unique([...($report->sent_to ?? []), $email])), + 'sent_to' => ClientReport::uniqueAddresses([...($report->sent_to ?? []), $email]), ]); } diff --git a/modules/ClientReports/src/Services/ReportRecipientsSyncService.php b/modules/ClientReports/src/Services/ReportRecipientsSyncService.php index 658aa835..920f1b8d 100644 --- a/modules/ClientReports/src/Services/ReportRecipientsSyncService.php +++ b/modules/ClientReports/src/Services/ReportRecipientsSyncService.php @@ -91,8 +91,12 @@ public function pushToSite(Site $site, ?ClientReportSchedule $schedule = null, b $client = new ClockworkCompanionClient($site); $response = $client->pushClientNotifications($payload); - if (is_array($site->companion_snapshot)) { - $snapshot = $site->companion_snapshot; + // Merged into the snapshot as it is now, not as this Site was + // loaded: a nightly refresh that landed since (the report run can + // overlap it) mustn't be overwritten with an older one. + $current = Site::query()->whereKey($site->id)->first(['id', 'companion_snapshot'])?->companion_snapshot; + if (is_array($current)) { + $snapshot = $current; $snapshot['client_notifications'] = array_merge( is_array($snapshot['client_notifications'] ?? null) ? $snapshot['client_notifications'] : [], is_array($response['client_notifications'] ?? null) ? $response['client_notifications'] : $payload, diff --git a/modules/ClientReports/src/Services/ReportReviewFlags.php b/modules/ClientReports/src/Services/ReportReviewFlags.php index 862deb79..bbe4362a 100644 --- a/modules/ClientReports/src/Services/ReportReviewFlags.php +++ b/modules/ClientReports/src/Services/ReportReviewFlags.php @@ -2,10 +2,12 @@ namespace Modules\ClientReports\Services; +use App\Models\Site; use App\Models\SiteBackup; use App\Models\SiteSecurityScan; use App\Services\Security\CompanionMalwareScanner; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; use Modules\ClientReports\Support\ReportPhrases; /** @@ -49,6 +51,12 @@ public static function reasons(array $data): array ? "{$began}, after the end of this report's period, so it covers none of it." : "{$began}, so this report covers only part of the month."; } + $carePlanEnded = $data['meta']['care_plan_ended_at'] ?? null; + if (is_string($carePlanEnded) && $carePlanEnded !== '') { + $reasons[] = 'The care plan was switched off '.ReportPhrases::time($carePlanEnded) + .', when this period had ended. The period was covered, but confirm the client should still get this report.'; + } + // Uptime monitoring switched on later than that (or for a site added // long before): only the uptime figures are partial. $uptimeSince = $data['uptime']['monitored_since'] ?? null; @@ -71,6 +79,28 @@ public static function reasons(array $data): array if (in_array($security['blacklist_status'] ?? null, self::BAD_SCAN_STATUSES, true)) { $reasons[] = 'The site is on a spam or malware blocklist.'; } + array_push($reasons, ...self::earlierScanReasons($security)); + + // Daily scans that were switched on but never finished a run this + // month: the client would read "Not checked this month" beside an + // intro saying we scan for malware. Reports compiled before these + // keys were kept say nothing. + if (($security['malware_expected'] ?? false) === true && array_key_exists('malware_status', $security) && $security['malware_status'] === null) { + $reasons[] = 'No malware scan finished this month, so the client would read "Not checked this month".'; + } + if (($security['blacklist_expected'] ?? false) === true && array_key_exists('blacklist_status', $security) && $security['blacklist_status'] === null) { + $reasons[] = 'No blocklist check finished this month, so the client would read "Not checked this month".'; + } + $unchecked = array_values(array_filter((array) ($security['vulnerabilities_unchecked_plugins'] ?? []), fn ($n) => is_string($n) && $n !== '')); + if ($unchecked !== []) { + $reasons[] = 'No vulnerability lookup has succeeded yet for '.implode(', ', array_slice($unchecked, 0, 5)) + .(count($unchecked) > 5 ? ' and '.(count($unchecked) - 5).' more' : '') + .', so "Known plugin vulnerabilities" doesn\'t cover '.(count($unchecked) === 1 ? 'it' : 'them').'.'; + } + if (($security['vulnerability_feed_stale'] ?? false) === true) { + $reasons[] = 'The plugin vulnerability list has not refreshed in over '.ClientReportCompiler::VULNERABILITY_FEED_MAX_AGE_HOURS + .' hours (clockwork:refresh-plugin-vulnerabilities), so the client would read "Known plugin vulnerabilities: Not checked".'; + } // Lockouts that never became a ban (security.unbanned_lockouts) hide // the blocked-visitors count, and the operator preview says why. They @@ -154,8 +184,19 @@ public static function reasons(array $data): array // A host rate-limiting the monitor (HTTP 429) isn't an outage, but // nobody could see the site, so a person should check before it goes out. $blockedSince = $data['uptime']['probe_blocked_since'] ?? null; + $blockedMinutes = (int) ($data['uptime']['probe_blocked_minutes'] ?? 0); if (! $stillDown && is_string($blockedSince) && $blockedSince !== '') { $reasons[] = 'The uptime monitor was blocked (HTTP 429) from '.ReportPhrases::time($blockedSince).'; the site was not necessarily down.'; + } elseif ($blockedMinutes >= self::LONG_OUTAGE_MINUTES) { + // Blocked time counts as up, so a long block reads as "100%". + $reasons[] = 'The uptime monitor was blocked (HTTP 429) for '.ReportPhrases::duration($blockedMinutes) + .' this month. That time counts as up in the uptime figure, but nobody could see the site.'; + } + + $maintenanceMinutes = (int) ($data['uptime']['maintenance_minutes'] ?? 0); + if ($maintenanceMinutes >= Site::UPTIME_STUCK_MAINTENANCE_HOURS * 60) { + $reasons[] = 'The site was in maintenance mode for '.ReportPhrases::duration($maintenanceMinutes) + .' this month. That time counts as up in the uptime figure: check it was planned before the client reads it.'; } return $reasons; @@ -191,6 +232,20 @@ private static function backupReasons(array $backups): array return [$reason]; } + // The list looks fine, but something says it is incomplete. + $listReasons = []; + if (in_array($status, ['verified', 'suspect'], true)) { + if (($backups['no_database_backup'] ?? false) === true) { + $listReasons[] = 'Every backup in the last 10 days is files only: the database is not being backed up.'; + } + $error = is_string($backups['check_error'] ?? null) && trim($backups['check_error']) !== '' ? trim($backups['check_error']) : null; + if ($error !== null) { + $listReasons[] = 'The latest read of the backups failed ('.Str::limit($error, 160).'), so the newest backups may be missing from the list.'; + } elseif (($backups['read_before_period_end'] ?? false) === true) { + $listReasons[] = 'Backups were last read before the end of the month, so the last day may be missing from the list. Run clockwork:record-site-backups, then Recompile.'; + } + } + $days = (int) ($backups['days_with_backups'] ?? 0); // The days backups were expected (from when the site's coverage // began); reports compiled before that was kept use the period's. @@ -202,15 +257,15 @@ private static function backupReasons(array $backups): array if ($status === 'verified' && ($backups['source'] ?? null) === SiteBackup::SOURCE_S3_ARCHIVE && $began !== null) { $beganOn = Carbon::parse($began)->format('M j, Y'); - return ["Off-site backups began on {$beganOn}; none were made before that this month, although the site was enrolled earlier (backed up on {$days} of {$periodDays} days)."]; + return [...$listReasons, "Off-site backups began on {$beganOn}; none were made before that this month, although the site was enrolled earlier (backed up on {$days} of {$periodDays} days)."]; } if ($status === 'verified' && ($backups['covers_period'] ?? false) === true && ($backups['daily'] ?? false) === true && $periodDays > 0 && $days < $periodDays - 3) { - return ["Backed up on only {$days} of {$periodDays} days this month."]; + return [...$listReasons, "Backed up on only {$days} of {$periodDays} days this month."]; } - return []; + return $listReasons; } /** @@ -477,6 +532,44 @@ private static function malwareReasons(array $security): array return $reasons; } + /** + * Problems found in the period that the email's rows don't show: a core + * file or blocklist check that failed before a later clean one, and a + * Sucuri SiteCheck hit on a site whose malware row comes from Companion. + * + * @param array $security + * @return list + */ + private static function earlierScanReasons(array $security): array + { + $reasons = []; + $day = fn (mixed $date): ?string => is_string($date) && $date !== '' ? Carbon::parse($date)->format('M j') : null; + + $checksumOn = $day($security['checksum_last_issue_at'] ?? null); + if ($checksumOn !== null && ! in_array($security['checksum_status'] ?? null, self::BAD_SCAN_STATUSES, true)) { + $reasons[] = "A core-file check on {$checksumOn} found WordPress files that differ from the official release, but the latest check did not: check it was cleaned up."; + } + + $blacklistOn = $day($security['blacklist_last_issue_at'] ?? null); + if ($blacklistOn !== null && ! in_array($security['blacklist_status'] ?? null, self::BAD_SCAN_STATUSES, true)) { + $reasons[] = "The site was on a spam or malware blocklist on {$blacklistOn}, but the latest check found it clean: confirm it was delisted."; + } + + $sitecheckOn = $day($security['sitecheck_last_issue_at'] ?? null); + if ($sitecheckOn !== null) { + $what = array_values(array_intersect(['malware', 'blocklist'], (array) ($security['sitecheck_issues'] ?? []))); + $found = match ($what) { + ['malware', 'blocklist'] => 'malware and a blocklisting', + ['malware'] => 'malware', + ['blocklist'] => 'a blocklisting', + default => 'a problem', + }; + $reasons[] = "Sucuri SiteCheck reported {$found} (last on {$sitecheckOn}). The email's malware and blocklist rows come from other scans, so check the site before sending."; + } + + return $reasons; + } + /** @param array $security */ private static function partialScanReason(array $security, string $scan): string { diff --git a/modules/ClientReports/src/Services/ScheduledReportRunner.php b/modules/ClientReports/src/Services/ScheduledReportRunner.php index 8ca7bb29..9bf39a61 100644 --- a/modules/ClientReports/src/Services/ScheduledReportRunner.php +++ b/modules/ClientReports/src/Services/ScheduledReportRunner.php @@ -3,6 +3,7 @@ namespace Modules\ClientReports\Services; use App\Models\Site; +use App\Support\Settings; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -23,6 +24,27 @@ class ScheduledReportRunner { public const FIRST_REPORT_REASON = 'This is the first report from this schedule. Once it has been sent, clean reports send themselves.'; + /** Settings key: whether every report waits for a person to send it. On unless switched off. */ + public const APPROVAL_SETTING = 'client_reports.require_approval'; + + /** + * Whether every report waits for a person to approve (send) it, whatever + * its schedule's delivery mode. On by default: a scheduled run compiles + * and checks the report, then leaves it Ready to send or Needs review, + * and nothing reaches a client until someone sends it (one by one, or + * with Approve & send all ready on the Reports list). + */ + public static function approvalRequired(): bool + { + return (bool) app(Settings::class)->get(self::APPROVAL_SETTING, true); + } + + /** Whether this schedule's run emails a clean report by itself. */ + public static function sendsByItself(ClientReportSchedule $schedule): bool + { + return $schedule->delivery_mode !== ClientReportSchedule::MODE_DRAFT && ! self::approvalRequired(); + } + public function __construct( protected ClientReportCompiler $compiler, protected ClientReportSender $sender, @@ -34,6 +56,18 @@ public static function siteIsEligible(?Site $site): bool return $site !== null && (! Site::areCarePlansEnabled() || $site->care_plan_enabled); } + /** + * Whether a site gets the report for a period ending $periodEnd: on a + * care plan now, or on one until after the period ended (switched off on + * the 1st by the Bill.com sync, say), in which case it is held for a + * person to confirm (meta.care_plan_ended_at). + */ + public static function siteIsEligibleFor(?Site $site, Carbon $periodEnd): bool + { + return self::siteIsEligible($site) + || ($site !== null && ClientReportCompiler::carePlanSwitchedOffAfter($site, $periodEnd) !== null); + } + public function run(ClientReportSchedule $schedule): ScheduledReportRun { // NoOverflow: on the 29th-31st a plain subMonth() rolls into the @@ -43,7 +77,22 @@ public function run(ClientReportSchedule $schedule): ScheduledReportRun $end = $month->copy()->endOfMonth(); $recipients = array_values(array_filter((array) $schedule->recipients)); - if ($schedule->delivery_mode === ClientReportSchedule::MODE_DRAFT) { + // Waiting for approval: compiled and checked like a draft, never sent. + if (! self::sendsByItself($schedule)) { + // A copy for this period that already reached someone is the + // period's report: a second live copy beside it would show as + // ready to send and email the client again. + $delivered = $this->samePeriod($schedule, $start, $end) + ->whereIn('status', [ClientReport::STATUS_GENERATED, ClientReport::STATUS_SENT]) + ->latest('id') + ->get() + ->first(fn (ClientReport $r) => ! empty($r->sent_to)); + if ($delivered !== null) { + $schedule->advanceNextRun(); + + return new ScheduledReportRun($delivered, isDraft: true, resumed: true); + } + $report = $this->compileFresh($schedule, $start, $end, $recipients); $reasons = $this->flagForReview($report); $schedule->advanceNextRun(); @@ -76,7 +125,7 @@ public function run(ClientReportSchedule $schedule): ScheduledReportRun } } - $pending = array_values(array_diff($recipients, $report->sent_to ?? [])); + $pending = ClientReport::addressesWithout(ClientReport::uniqueAddresses($recipients), $report->sent_to ?? []); $alreadySent = $pending === [] && $report->status === ClientReport::STATUS_SENT; $failed = $alreadySent ? [] : $this->sender->send($report, $pending); @@ -138,7 +187,7 @@ public function recompile(ClientReport $report): ScheduledReportRun $fresh = $this->compileFresh($schedule, $start, $end, $recipients, is_string($notes) ? $notes : null); $reasons = $this->flagForReview( $fresh, - firstReport: $schedule->delivery_mode !== ClientReportSchedule::MODE_DRAFT && $schedule->last_sent_at === null, + firstReport: self::sendsByItself($schedule) && $schedule->last_sent_at === null, ); return new ScheduledReportRun($fresh, isDraft: true, resumed: false, heldReasons: $reasons); diff --git a/modules/ClientReports/src/Support/ReportPhrases.php b/modules/ClientReports/src/Support/ReportPhrases.php index f79e9dcb..90e84a9c 100644 --- a/modules/ClientReports/src/Support/ReportPhrases.php +++ b/modules/ClientReports/src/Support/ReportPhrases.php @@ -483,7 +483,9 @@ public static function vulnerablePlugins(array $security): array return []; } - $names = array_map(fn ($name) => trim((string) $name), (array) ($security['vulnerable_plugins'] ?? [])); + // Names come from WordPress with HTML entities ("Example & Co"); + // the views escape what they print. + $names = array_map(fn ($name) => trim(html_entity_decode((string) $name, ENT_QUOTES | ENT_HTML5, 'UTF-8')), (array) ($security['vulnerable_plugins'] ?? [])); return array_values(array_unique(array_filter($names, fn (string $name) => $name !== ''))); } diff --git a/modules/ClientReports/src/Support/ReportState.php b/modules/ClientReports/src/Support/ReportState.php index 53a5fdb1..570b3f97 100644 --- a/modules/ClientReports/src/Support/ReportState.php +++ b/modules/ClientReports/src/Support/ReportState.php @@ -3,7 +3,7 @@ namespace Modules\ClientReports\Support; use Modules\ClientReports\Models\ClientReport; -use Modules\ClientReports\Models\ClientReportSchedule; +use Modules\ClientReports\Services\ScheduledReportRunner; /** * Where a report stands for the operator screens, worked out the same way @@ -39,7 +39,7 @@ final class ReportState private const META = [ self::NEEDS_REVIEW => ['label' => 'Needs review', 'pill' => 'status-orange', 'icon' => 'fa-hand', 'hint' => 'Held: something in it needs checking or fixing before it goes out.'], self::OUTDATED => ['label' => 'Outdated', 'pill' => 'cr-pill-purple', 'icon' => 'fa-clock-rotate-left', 'hint' => 'Compiled before the latest report fixes. Recompile it before sending.'], - self::READY => ['label' => 'Ready to send', 'pill' => 'cr-pill-blue', 'icon' => 'fa-paper-plane', 'hint' => 'Nothing to fix: waiting for someone to send it (a schedule\'s first report always waits).'], + self::READY => ['label' => 'Ready to send', 'pill' => 'cr-pill-blue', 'icon' => 'fa-paper-plane', 'hint' => 'Nothing to fix: waiting for someone to approve and send it.'], self::PARTLY_SENT => ['label' => 'Partly sent', 'pill' => 'status-yellow', 'icon' => 'fa-circle-half-stroke', 'hint' => 'Some recipients have it; sending again only emails the rest.'], self::SENT => ['label' => 'Sent', 'pill' => 'status-green', 'icon' => 'fa-circle-check', 'hint' => 'Everyone on it has it.'], self::SUPERSEDED => ['label' => 'Replaced', 'pill' => 'status-unknown', 'icon' => 'fa-clone', 'hint' => 'A newer copy for this period replaced it. It can\'t be sent.'], @@ -117,10 +117,13 @@ public static function sendConfirmation(ClientReport $report, string $glue = ' ' $lines = [$to === [] ? 'Email this report? You will be asked for the address to send it to.' : 'Email report to '.implode(', ', $to).'?']; + if ($report->status === ClientReport::STATUS_SENT) { + array_unshift($lines, 'This report was already sent'.($report->sent_at ? ' on '.$report->sent_at->format('M j, Y') : '').'. Sending it again emails everyone on it a second copy.'); + } $lines[] = 'They get it straight away and the report is marked as sent.'; $schedule = $report->schedule; - if ($schedule !== null && $schedule->last_sent_at === null && $schedule->delivery_mode === ClientReportSchedule::MODE_AUTO) { + if ($schedule !== null && $schedule->last_sent_at === null && ScheduledReportRunner::sendsByItself($schedule)) { $lines[] = 'This is the first report from this schedule: once it has been sent, the schedule emails its clean reports automatically every month (anything that needs a look is still held for review).'; } elseif ($schedule !== null && $schedule->last_sent_at === null) { $lines[] = 'This is the first report sent from this schedule.'; diff --git a/modules/EmailAuth/resources/views/emails/client-notice.blade.php b/modules/EmailAuth/resources/views/emails/client-notice.blade.php index 660cf37f..72599786 100644 --- a/modules/EmailAuth/resources/views/emails/client-notice.blade.php +++ b/modules/EmailAuth/resources/views/emails/client-notice.blade.php @@ -39,8 +39,8 @@
@if ($logoBytes) - {{ $branding['company_name'] }} - @elseif ($branding['logo_url'] !== '') + {{ $branding['company_name'] }} + @elseif (\App\Support\Mail\BrandLogo::linkable($branding['logo_url'])) {{ $branding['company_name'] }} @else
{{ $branding['company_name'] }}
diff --git a/resources/docs/architecture/data-model.md b/resources/docs/architecture/data-model.md index 13c602dc..fb716c4c 100644 --- a/resources/docs/architecture/data-model.md +++ b/resources/docs/architecture/data-model.md @@ -2,7 +2,7 @@ title: Data model section: Architecture order: 20 -updated: 2026-10-15 +updated: 2026-11-06 author: Aaron Reimann tags: [architecture, database, schema, pressable, modules] tracks: [database/migrations/**, app/Models/**] @@ -142,7 +142,7 @@ One row per site × form factor (`phone`/`desktop`) × scope (`origin`/`url`) × Backup evidence for client reports, written daily (05:30) by `clockwork:record-site-backups` (`App\Services\Backups\SiteBackupRecorder`) from each site's real backup source, never from a setting that says backups should be happening. -`site_backups` has one row per backup the source reported. Columns: `source` (`pressable` = Pressable's backup API, `spinupwp_spaces` = the DigitalOcean Spaces bucket SpinupWP writes to, `s3_archive` = our own S3 archives for unhosted relay sites), `kind` (`fs` / `db` / `full`), `external_ref` (the source's own id), `backup_at`, `bytes` (nullable), `is_manual`, `first_seen_at`, `last_seen_at`. It's unique on `(site_id, source, kind, external_ref)` and indexed on `(site_id, backup_at)`. Rows are kept after the source prunes them, so a report for an older period can still be rebuilt. +`site_backups` has one row per backup the source reported. Columns: `source` (`pressable` = Pressable's backup API, `spinupwp_spaces` = the DigitalOcean Spaces bucket SpinupWP writes to, `s3_archive` = our own S3 archives for unhosted relay sites), `kind` (`fs` / `db` / `full`), `external_ref` (the source's own id), `backup_at`, `bytes` (nullable), `database_bytes` (the database half of a SpinupWP `full` run, so client reports can compare it with the database's size; null elsewhere), `is_manual`, `first_seen_at`, `last_seen_at`. It's unique on `(site_id, source, kind, external_ref)` and indexed on `(site_id, backup_at)`. Rows are kept after the source prunes them, so a report for an older period can still be rebuilt. `site_backup_checks` holds the latest read attempt per `(site_id, source)`: `checked_at`, `ok`, `backups_seen`, `error`. It lets "checked, found none" be told apart from "couldn't check". Both tables cascade on site delete. See [Features → Client reports](/docs/features/client-reports). diff --git a/resources/docs/features/client-reports.md b/resources/docs/features/client-reports.md index acdab73b..a10381f6 100644 --- a/resources/docs/features/client-reports.md +++ b/resources/docs/features/client-reports.md @@ -2,7 +2,7 @@ title: Client reports section: Features order: 101 -updated: 2026-11-05 +updated: 2026-11-06 author: Aaron Reimann tags: [client-reports, reporting, email, care-plans, scheduling, templates] tracks: [modules/ClientReports/**] @@ -95,23 +95,25 @@ Section keys and labels are defined once in `ClientReportTemplate::SECTIONS`. Ad ## Scheduled sending +**Every report waits for approval (on by default).** With approval on, the monthly run compiles and checks every report but **emails nothing**, whatever a schedule's delivery mode: each report lands as **Ready to send** (nothing to fix) or **Needs review** (held, with reasons). Operators send them from the **Reports** tab: one by one with **Send**, the ticked ones with **Send selected** (a ticked held report is sent too: ticking it is the approval), or every Ready to send report of the period with **Approve & send all ready (N)**. The bulk buttons send up to 25 reports per press through the same `ClientReportSender` as Send, skip outdated, replaced and already-sent reports, and stamp each schedule's `last_sent_at`; a press cut off part-way loses nothing, and pressing again only emails who is still owed it. The switch is on the **Scheduling** tab (**Let clean reports send themselves** / **Require approval**), stored as the `client_reports.require_approval` setting (`ScheduledReportRunner::approvalRequired()`), and each change is logged as `client_report_approval_changed`. With approval off, the rules below apply as written: Automatic schedules email clean reports by themselves. + `clockwork:send-client-reports` is registered by the module itself (`ClientReportsServiceProvider::scheduledTasks()`), not in `routes/console.php`. It runs daily at **06:00 app time** (UTC on our install). On each run: -1. It skips paused schedules, inactive sites, and schedules whose `next_run_at` is still in the future. It also skips sites **without a care plan**: reports are a care-plan service, and those sites get no nightly updates or security scans, so their report would be empty or wrong. The schedule still moves on to next month, so turning the care plan back on never sends a report for a month the site didn't have one. The Schedules list marks these **No care plan — won't send**, and **Send now** refuses them. Each run writes **one** `action_logs` entry (`client_report_skipped`, actor `scheduled`) listing the domains it skipped for having no care plan, with `details.reason = no_care_plan`, since a scheduled run's console output isn't kept. +1. It skips paused schedules, inactive sites, and schedules whose `next_run_at` is still in the future. It also skips sites **without a care plan**: reports are a care-plan service, and those sites get no nightly updates or security scans, so their report would be empty or wrong. The schedule still moves on to next month, so turning the care plan back on never sends a report for a month the site didn't have one. A site whose care plan was switched off only **after** the month ended (the Bill.com sync flips it at 01:30 UTC on the 1st, before this run) still gets that month compiled, held for a person to confirm (`meta.care_plan_ended_at`). The Bill.com sync logs each flip as a `care_plan_toggled` entry (`details.source = bill_com`), local only, never mirrored to the client's wp-admin. The Schedules list marks these **No care plan — won't send**, and **Send now** refuses them. Each run writes **one** `action_logs` entry (`client_report_skipped`, actor `scheduled`) listing the domains it skipped for having no care plan, with `details.reason = no_care_plan`, since a scheduled run's console output isn't kept. 2. It skips `auto` schedules that have no recipients. 3. It compiles **last calendar month** using the schedule's template, and saves the report with `schedule_id` set. For `auto`, if a report from this schedule already exists for the same period and isn't held, it **reuses that one** instead (see below), including one that's already been **sent** (for example finished by hand after a failed run), so nobody is emailed twice; a held one is recompiled. 4. **Hold for review.** Any report that hasn't reached anyone yet is checked by `ReportReviewFlags`. If anything needs a person first, an `auto` schedule keeps it as a draft instead of emailing it, the reasons are saved in `meta.review_reasons`, and the schedule moves on to next month. The history table shows **Needs review**, and the preview lists the reasons. Send it by hand from the history table once it's checked, or press **Recompile** first to refresh it (see [Refreshing held drafts](#refreshing-held-drafts-recompile)). Held reports are never sent automatically: running the schedule again (**Send now**, `--force`) in the same month recompiles last month from current data and checks again, and the older copy is marked **superseded** so it can't be sent by mistake. Draft-mode reports get the same check, so the first-run drafts show **Needs review** too, and any report that hasn't reached anyone is checked again before an auto send. The reasons show only in our preview, never on the public view. The rules (one reason each, worded for the reviewer): - - **Coverage:** the site was added after the period's first day ("Monitoring began Oct 19, so this report covers only part of the month."); separately, uptime monitoring began later than that ("Uptime monitoring began Oct 17, so uptime covers only part of the month."), or only after the period, when the Uptime section is left out. A care plan switched on part-way through the month holds it too ("The care plan was switched on Oct 28, so this report covers only part of the month."). Dates are read in the report timezone. - - **Malware:** the latest scan came back `warning` or `issues_found` (the reason names the finding kinds and the scan id, e.g. "php_in_uploads ×4 (scan #123)", and says when the scan also stopped early); the latest clean scan was partial ("Partial scan": stopped at a limit, an SSH check hit its result cap, or an SSH scan from before cut-short scans were tracked); or an earlier scan in the period found malware that the latest one didn't. Review-tier findings a person marked reviewed no longer hold it (see **Mark findings reviewed** above). - - **Core files and blocklists:** core files differ from the release; no core-file check ran although the host supports one; the site is on a blocklist. - - **Vulnerable plugins:** joined by the plugin's file slug, a plugin with a known vulnerability whose update waiting at the end of the month reaches the fixed version but wasn't applied, or whose updates are paused (auto-paused after failures, or by hand). A vulnerability on its own doesn't hold a report (about a third of sites have one at any time). - - **Backups:** none found, stopped before the last 10 days, couldn't be checked, `suspect` (database backups far smaller than the database), `failing` (our relay has been failing, with its error), a daily source that missed more than 3 days of the days backups were expected (counted from when coverage began, `backups.expected_days`), or our relay's first archive coming 2 or more days after the period start (or after the site joined, if later) on a relay site: the S3 listing is the whole history, so its oldest archive is when archiving began, not where records start ("Off-site backups began on Oct 29, 2026; none were made before that this month …", kept as `archives_began`). + - **Coverage:** the care plan was switched off after the period ended (see step 1); the site was added after the period's first day ("Monitoring began Oct 19, so this report covers only part of the month."); separately, uptime monitoring began later than that ("Uptime monitoring began Oct 17, so uptime covers only part of the month."), or only after the period, when the Uptime section is left out. A care plan switched on part-way through the month holds it too ("The care plan was switched on Oct 28, so this report covers only part of the month."). Dates are read in the report timezone. + - **Malware:** the latest scan came back `warning` or `issues_found` (the reason names the finding kinds and the scan id, e.g. "php_in_uploads ×4 (scan #123)", and says when the scan also stopped early); the latest clean scan was partial ("Partial scan": stopped at a limit, an SSH check hit its result cap, or an SSH scan from before cut-short scans were tracked); or an earlier scan in the period found malware that the latest one didn't; Sucuri SiteCheck reported malware or a blocklisting in the period on a site whose malware row comes from Companion (`sitecheck_last_issue_at`, `sitecheck_issues`); or no malware scan finished all month while the Companion or SiteCheck scans are switched on (`malware_expected`), so the client would read "Not checked this month". Review-tier findings a person marked reviewed no longer hold it (see **Mark findings reviewed** above). + - **Core files and blocklists:** core files differ from the release; no core-file check ran although the host supports one; the site is on a blocklist; a core-file check or blocklist check earlier in the month found a problem that a later clean one hides (`checksum_last_issue_at`, `blacklist_last_issue_at`); no blocklist check finished all month while blocklist checks are on (`blacklist_expected`). + - **Vulnerable plugins:** joined by the plugin's file slug, a plugin with a known vulnerability whose update waiting at the end of the month reaches the fixed version but wasn't applied, or whose updates are paused (auto-paused after failures, or by hand). A vulnerability on its own doesn't hold a report (about a third of sites have one at any time). The vulnerability list hasn't refreshed in 48 hours (`vulnerability_feed_stale`: every report would read "Not checked"), or an installed plugin has never had a successful lookup (`vulnerabilities_unchecked_plugins`, from the per-plugin lookup times `clockwork:refresh-plugin-vulnerabilities` keeps in the `plugin_vulnerabilities.last_lookups` setting), does hold it. + - **Backups:** none found, stopped before the last 10 days, couldn't be checked, `suspect` (database backups far smaller than the database; for SpinupWP the run's own database half, `site_backups.database_bytes`, not the whole backup), every listed backup is files only (`no_database_backup`), the latest read of the source failed (`check_error`) or finished before the month ended (`read_before_period_end`), so the last days may be missing, `failing` (our relay has been failing, with its error), a daily source that missed more than 3 days of the days backups were expected (counted from when coverage began, `backups.expected_days`), or our relay's first archive coming 2 or more days after the period start (or after the site joined, if later) on a relay site: the S3 listing is the whole history, so its oldest archive is when archiving began, not where records start ("Off-site backups began on Oct 29, 2026; none were made before that this month …", kept as `archives_began`). - **Traffic:** `partial`, `suspect` or `no_data` (the section is left out and the reason says why). - **Contact forms:** lab checks not all accepted ("the forms accepted 18 of 19 automated check submissions"), or live tests where the site didn't report the email as sent. - - **Uptime:** the probe hasn't checked the site lately; the site was still down at the end of the month; the monitor was blocked (HTTP 429) at the end of the month, which is said instead of "still down"; an outage of an hour or more during the month, with its date and length. + - **Uptime:** the probe hasn't checked the site lately; the site was still down at the end of the month; the monitor was blocked (HTTP 429) at the end of the month, which is said instead of "still down"; an outage of an hour or more during the month, with its date and length; the monitor was blocked (HTTP 429) for an hour or more in total (that time counts as up); the site was in maintenance mode for 2 hours or more in total (`maintenance_minutes`, also counted as up). - **Updates:** Companion hasn't reported in the 48h before the period end, so waiting updates and vulnerabilities are out of date; or no plugin, theme or core update was applied all month although some were known to be waiting (named in the snapshot, up to five, or applied after the period), so the client would read "No updates were applied this month" while they were failing or paused; or an update was undone after it ran (left out of the list, `updates.not_kept`). - **First report:** it's the schedule's first (`last_sent_at` is empty), so a new client's first email is always seen by a person. Send it by hand; after that, clean reports send themselves. This applies to `auto` schedules only, since a `draft` schedule's reports wait for a person anyway. -5. For `auto` schedules, it sends one email per recipient who isn't already in the report's `sent_to`. Once everyone has it, it marks the report `sent` and stamps `last_sent_at`. +5. For `auto` schedules, it sends one email per recipient who isn't already in the report's `sent_to` (addresses compared ignoring case). Sends to one report run one at a time (a cache lock in `ClientReportSender`), so a double click or a hand send during the run can't email anyone twice. Once everyone has it, it marks the report `sent` and stamps `last_sent_at`. 6. It moves `next_run_at` forward: the 1st of next month for monthly schedules, or now plus 7 days at midnight for weekly ones. The midnight snap is on purpose, so the next 06:00 run sees it as due. Before this, runs used to slip a day every cycle. If any recipient failed, `next_run_at` **doesn't** move, so tomorrow's run retries. 7. If a site's report **crashed** (an exception while compiling or saving it), or any recipient's email **failed** (a mail outage, say), that site stays due and is retried tomorrow, the other sites carry on, and the command exits as **failed**, so the Scheduled Jobs row turns red. Crashes are in `laravel.log` as "Failed sending scheduled client report for …", failed recipients as "Client report #… failed to send to …". Scheduled Jobs only stores the status and duration, not the per-site lines; to see those, run the command by hand: sites already handled print "skipped (not due until …)", anything still due runs again. diff --git a/resources/docs/features/email-auth.md b/resources/docs/features/email-auth.md index f126f4aa..c563b740 100644 --- a/resources/docs/features/email-auth.md +++ b/resources/docs/features/email-auth.md @@ -2,7 +2,7 @@ title: Email Authentication (SPF, DMARC, DKIM) section: Features order: 42 -updated: 2026-10-04 +updated: 2026-11-06 author: Aaron Reimann tags: [email-auth, security, spf, dmarc, dkim, dns, doh] tracks: [modules/EmailAuth/**] @@ -72,7 +72,7 @@ Open a domain's drawer on `/email-auth` and click **Compose email** (or go to `/ - **Your note** comes first in the email (default: *"We've noticed some issues with your domain's email settings (DNS)… Would you like us to fix this for you?"*), then **What we found** — each selected finding in plain English (`Modules\EmailAuth\Support\FindingExplainer`), then a **reply** call-to-action, then **Technical details** for whoever manages DNS: the scanner message + code for each finding and the current SPF / DMARC / DKIM / MX state. - **Findings**: `fail` and `warn` are pre-ticked, `info` is offered unticked, `pass` is never offered. Unknown codes posted to the form are dropped. - **Live preview** renders the exact email beside the form. -- **Branding** = White Labeling → Client Reports (header colour, accent, logo, company name, support email) — same template family as the monthly client report. The logo is embedded inline (`App\Support\Mail\BrandLogo`). Reply-To is the support email; the only link in the email is the support `mailto:` (Control isn't publicly reachable). +- **Branding** = White Labeling → Client Reports (header colour, accent, logo, company name, support email) — same template family as the monthly client report. The logo is embedded inline (`App\Support\Mail\BrandLogo`); a logo uploaded in Settings is read from Control's disk, and a relative or Control-host logo URL is never linked. Reply-To is the support email; the only link in the email is the support `mailto:` (Control isn't publicly reachable). - **Send me a copy** BCCs the operator. Each send writes an `email_auth_client_notice_sent` action-log row (recipients, subject, finding codes, mailer). - If `MAIL_MAILER=log`, the composer shows a warning and the send is written to the log instead of delivered. diff --git a/resources/docs/features/updates.md b/resources/docs/features/updates.md index 2ff9ed4a..e89adf7c 100644 --- a/resources/docs/features/updates.md +++ b/resources/docs/features/updates.md @@ -2,7 +2,7 @@ title: Updates section: Features order: 35 -updated: 2026-11-05 +updated: 2026-11-06 author: Aaron Reimann tags: [updates, plugins, themes, wp-core, translations, care-plan, pressable] tracks: [app/Services/Updates/UpdateGrouping.php, app/Services/Updates/UpdateFailureStreakRecorder.php, app/Services/Updates/ThemeIdentityGuard.php, app/Services/Updates/ObservedUpdateRecorder.php, app/Http/Controllers/UpdatesController.php, app/Http/Controllers/MaintenanceHistoryController.php, app/Http/Controllers/SitesController.php, app/Jobs/**, app/Models/PluginUpdateJob.php, app/Models/PluginUpdateIgnore.php, app/Models/PluginUpdateFailureStreak.php, app/Console/Commands/RunNightlyPluginUpdates.php, app/Console/Commands/PushUpdateExceptions.php, app/Console/Commands/NightlyUpdateSummary.php, app/Console/Commands/RefreshCompanionSnapshot.php, app/Console/Commands/DetectStuckCompanionState.php, app/Console/Commands/ReapStaleUpdateJobs.php, app/Services/Companion/ClockworkCompanionClient.php, app/Services/Companion/CompanionInstaller.php, modules/Pressable/src/PressableCompanionInstaller.php, database/migrations/**add_state_to_plugin_update_jobs*, database/migrations/**plugin_update_failure_streaks*, database/migrations/**add_failure_tracking_to_plugin_update_ignores_table*] @@ -131,6 +131,7 @@ Sites that advertise the `update-exceptions` capability (Companion / Renegade bu - *Empty state*: "Automatic plugin and theme updates are on for this site when a care plan is active. Nothing is currently paused." - *Populated state*: Displays a clear table of paused plugins, version stopped on, date paused, and public explanation. - **Plugins / themes notice**: A dismissible warning notice on `plugins.php` and `themes.php` alerts administrators when paused items exist (plugins and/or themes) and links to Update coverage. +- **Updates from a site's Updates tab** are logged like the update jobs' ("Plugin updated: Name 1.0 → 1.1"), so client reports list them by name. - **Manual updates remain unlocked**: WordPress's native "Update now" links and controls are never disabled. Clients or other vendors can still apply updates manually by hand. #### Client email drafts (Phase 3) diff --git a/resources/docs/internal/client-reports-tracker.md b/resources/docs/internal/client-reports-tracker.md index b21d9301..2b74ce8b 100644 --- a/resources/docs/internal/client-reports-tracker.md +++ b/resources/docs/internal/client-reports-tracker.md @@ -2,34 +2,38 @@ title: Client reports tracker section: Internal order: 30 -updated: 2026-11-03 +updated: 2026-11-06 author: Aaron Reimann tags: [client-reports, tracker, roadmap, known-issues] --- The running to-do list for the Client Reports module. For how the feature works, see [Features → Client reports](/docs/features/client-reports). Keep this page current: when something here gets fixed, move it to **Done** with the commit or PR, and bump `updated:`. -## Where things stand (2026-10-22) +## Where things stand (2026-11-06) - **Goal:** recreate the ManageWP client report in Control, then retire ManageWP. -- **Branch:** `feature/client-reports` (draft PR #50). Code-complete for 2026-11-01, **not merged or deployed yet**; see the checklist below. What's left is mostly operations (deploy, AWS key, data fixes, decisions). -- **2026-10-22:** each schedule's first report is now held for review automatically, so the first send can't go out unreviewed even if nobody switches the schedules to draft. Also fixed: first-month backup counts, the Security intro wording (#20), the email preview (#21), partial vulnerability-feed refreshes (#14), and re-sending a partly sent report. -- The original plan, `plans/client-reports-templates-scheduling.md`, is **fully shipped** now: phases 1–3 (scheduler fixes, templates, scheduling UI) were already live, and phase 4 (conditional delivery) shipped as **hold for review**. That plan's own "not yet implemented" header is out of date. Treat this page as the source of truth. -- **Decisions (2026-10-15):** reports are for **care-plan customers only**; the **first send on 2026-11-01 goes out as drafts** for review; after that, clean reports send themselves and flagged ones are held (**review only the exceptions**); unchecked sections are hidden from the client and flagged in the preview. - -## Heads-up: first send on 2026-11-01 - -As of the 2026-10-15 production DB snapshot there are **72 schedules**, all enabled, `monthly`, `auto`, on the default template, none ever sent, and all with `next_run_at = 2026-11-01 00:00`. **45** of those sites have a care plan and will get a report; the other **27** are skipped until someone decides about them (checklist). - -None of them has ever sent, so the 06:00 UTC run holds **every** report for review ("This is the first report from this schedule"), plus any other reasons. **Nothing is emailed automatically on Nov 1.** Switching the schedules to Draft is no longer needed. - -### On the day (runbook) - -1. **06:00 UTC:** `clockwork:send-client-reports` compiles October for the 45 care-plan sites and holds each one. The 27 without a care plan are skipped and move on to Dec 1. An `auto` schedule with no recipients is skipped and retried daily, so give it one. **Expect 45 Needs review reports in history.** Scheduled Jobs only shows OK/failed and a duration; it goes red if any site's report crashed or any recipient's email failed. To see per-site results, run `php artisan clockwork:send-client-reports` by hand: handled sites print "skipped (not due until Dec 1)", and any site that crashed or has no recipients is still due and runs again (errors are also in `laravel.log`, "Failed sending scheduled client report for …"). -2. **Client reports → history:** every report shows **Needs review**. Open each one (↗). The amber box lists why it was held. Click **Email version** to see exactly what the client gets. -3. **Only "first report" listed:** send it with the paper-plane button. -4. **Other reasons too:** fix the cause (or confirm it's fine), then either send as-is, or press **Recompile** on the report (history ↻ or the preview) to compile its own period again from current data. Recompile never sends: the new copy is held again as a first report; check it and send by hand. The older copy is marked superseded. A recompile reads today's vulnerability list (shown "checked" today); updates applied since Oct 31 still count as waiting at month end, so the Updates section won't turn into "already up to date". Don't use **Send now** for this: it compiles last month only (from Dec 1 that's November), and once a schedule has sent it emails a clean report straight away. See [Refreshing held drafts](/docs/features/client-reports#refreshing-held-drafts-recompile). -5. **From December:** sending a report by hand stamps the schedule's `last_sent_at`, so the Dec 1 run emails clean reports by itself and holds only flagged ones. +- **Shipped:** PR #50 (the Nov 1 work) and PR #63 (Clients tab, report verification fixes, schema 7) are merged into `main` as **v1.12.1**. The Nov 1 run held every report as a first report, as planned. +- **Every report now waits for approval (2026-11-06).** The Dec 1 run compiles and checks every report and **emails nothing**. Send them from the Reports tab: **Approve & send all ready** for the clean ones, then look at the held ones and send them one by one or ticked. The switch is on the Scheduling tab; turning it off would let Automatic schedules email clean reports by themselves again. +- **2026-11-06 audit for Dec 1** (5 reviewers over the run path, every section, the emails and the operator screens; each finding checked against the code before fixing). The fixes are on `feature/client-reports`, **not merged or deployed yet**; see Done and the checklist below. What wasn't fixed is listed under Open issues (#23 on). +- The original plan, `plans/client-reports-templates-scheduling.md`, is fully shipped; this page is the source of truth. +- **Decisions (2026-10-15):** reports are for **care-plan customers only**; after the first send, clean reports send themselves and flagged ones are held (**review only the exceptions**); unchecked sections are hidden from the client and flagged in the preview. + +## Before 2026-12-01 (checklist) + +- [ ] **Merge `feature/client-reports` and deploy** before Nov 30. Then: + - `php artisan migrate` (adds `site_backups.database_bytes`, widens `client_reports.client_email` to text). + - `php artisan clockwork:record-site-backups` once, so SpinupWP rows get their database size. + - `php artisan clockwork:refresh-plugin-vulnerabilities` once, to start the per-plugin lookup record (#22). + - Report schema is now **8**: any unsent report is outdated. `php artisan clockwork:recompile-held-client-reports --outdated --dry-run`, then without `--dry-run` (never sends). + - `npm run build`, clear the view cache, `php artisan queue:restart`. +- [ ] **On Dec 1:** open Client Reports → Reports (November). Press **Approve & send all ready** (25 per press; press again until none are left), then work through **Needs review**: fix, Recompile or send. Nothing reaches a client until you do. +- [ ] **Expect more held reports than before.** New holds: SiteCheck hits, earlier core-file/blocklist problems, no malware or blocklist scan all month, stale vulnerability list, never-looked-up plugins, files-only backups, a failed or early backup read, 429 blocks or maintenance of hours, a care plan switched off on Dec 1. Each one is a client email that would have been wrong or unexplained. +- [ ] **Off-site backups (#11):** put a valid AWS key in production `.env`. Until then every relay site is held. +- [ ] **Check the 04:45 and 05:30 UTC jobs finish before 06:00** (#34): Scheduled Jobs → `pressable-traffic-report` and `record-site-backups` end times in November. +- [ ] **Logo:** if the report logo was uploaded in Settings, check the Email version shows it. It's now embedded from disk; before, an uploaded logo's `/storage/…` link was broken in every inbox. +- [ ] Mark the known-safe "PHP in uploads" paths with `clockwork:suppress-malware-path` (#17). +- [ ] After `migrate`, confirm sites 315, 330, 331 and 332 now have `is_wordpress = 1` (#15). +- [ ] Don't **Run** schedules or **Create all** between 7 PM ET on Nov 30 and the 06:00 UTC run (#28). ## Open issues @@ -37,38 +41,38 @@ Numbers stay stable as items are fixed; fixed ones move to **Done**. | # | Issue | Why it matters | Where | |---|---|---|---| -| 1 | **Weekly schedules report on the wrong period.** The scheduled path hard-codes "last calendar month", whatever the frequency. | A weekly schedule sends last month's report once, on its first weekly run of the month (the 1st to the 7th), and nothing the other weeks; a held one is recompiled and held again each week. No weekly schedules exist yet. Fix the period or drop "weekly" from the form. | `ScheduledReportRunner::run()` | -| 10 | **The work log is empty** (`site_work_logs` has 0 rows), but `work_log` is in the default template. | The section is hidden unless hours get logged, so ManageWP-style "extra work" never appears. | Data / process | -| 11 | **⚠️ Off-site backups have stopped (operations).** Every backup-relay run since 2026-09-13 failed with S3 `403 Forbidden`. Listing the bucket with the key in the production `.env` copy fails with `InvalidAccessKeyId`, so the key was deleted or rotated in AWS and not updated in Control. | Unhosted sites aren't being backed up, and their report section shows **Could not check** (held for review). | `AWS_ACCESS_KEY_ID`, `modules/BackupRelay/` | -| 12 | **Vulnerability count is "as of the snapshot", not the month.** No history of installed plugin versions is kept. It's labelled "(as of …)". | The count can differ from what was true during the month. Acceptable. | `ClientReportCompiler::compileSecurity()` | -| 15 | **4 scheduled sites are flagged `is_wordpress = 0` but run WordPress** (ids 315, 330, 331, 332). The report detects them via Companion's `wp_core`, but the flag keeps the core-checksum scanner off them. | No core-file checks run on those sites. | Data fix on `sites.is_wordpress` | -| 16 | **20 SpinupWP sites have no backups in the Spaces bucket** (first recorder run, 2026-10-15); 2 have report schedules. List kept locally, not in the repo. | Their backups section is hidden and the report held. Either SpinupWP backups are off for them or stored elsewhere. | SpinupWP dashboard / `SpacesClient` | -| 17 | **Harmless "PHP in uploads" malware warnings hold reports.** Plugins such as WP All Import, Sucuri, BackWPup and Smush put PHP files in `uploads/`; the scan marks them for review, which holds that site's report. | Clean sites get held every month until the paths are marked safe. | `clockwork:suppress-malware-path` (operator) | -| 18 | **Pressable can skip a month's speed test.** Pressable tests each site about once a month on its own schedule (no way to trigger one); about 1 site in 70 had a gap in Aug–Sep. | That site's Speed section is hidden for that month. Acceptable unless it becomes common. | Pressable | -| 19 | **Non-Pressable sites get desktop speed only.** GTmetrix on our plan has no phone emulation. | No mobile score for the ~17 non-Pressable report sites. Options: GTmetrix plan with mobile, or PSI for mobile only (noisier). | `RunPerformanceScans` | -| 21 | **The operator preview's layout still differs from the email** below the header (e.g. updates as a table of plugin file names). Reviewers now have **Email version** (the real email), so this is cosmetic. | The preview is what prints as the PDF. | `reports/show.blade.php` | -| 22 | **A plugin whose vulnerability lookup has never succeeded reads "None found".** Since #14 a failed lookup keeps the plugin's previous entries, but a plugin with no earlier entries has nothing to keep, and the feed still counts as fresh. | Rare (a new plugin plus a lookup failure that night), but it's a "None found" nobody checked. Fix: store failed slugs and report those plugins as not checked. | `WpVulnerabilityClient`, `ClientReportCompiler::compileSecurity()` | - -## Before 2026-11-01 (deploy checklist) - -- [ ] **Merge PR #50 and deploy this week** (by 2026-10-28 to be safe), then run `php artisan migrate` (adds `site_backups` + `site_backup_checks`) before the scheduler fires again. Pressable keeps daily backups for only ~8 days and the email lists Oct 22–31, so the recorder must have run by about Oct 29 for Oct 22 to still be readable. (The month's "backed up on N days" count is left out automatically for sites whose records start mid-month.) -- [ ] Run `php artisan clockwork:record-site-backups` once by hand straight after deploy to backfill; check the output for `[fail]` lines. (Local run 2026-10-15: 261 sites, 251 OK, ~5 min; the 10 failures were all S3, see #11.) -- [ ] Put a valid AWS key in production `.env` (#11). Without it the unhosted sites' backups can't be read **or** made. -- [x] ~~Switch the first run to drafts~~: no longer needed. Every schedule's first report is held for review automatically (2026-10-22). Leave the schedules on Auto. -- [ ] Decide each of the **27 scheduled sites with no care plan**: turn the care plan back on, or delete the schedule. Until then each month is skipped. -- [ ] Set the report colour in production: **Settings → Companion & White Label → Client Reports → Primary `#5B21B6`** (it's a setting, not code). -- [ ] Mark the known-safe "PHP in uploads" paths with `clockwork:suppress-malware-path` (#17), and look at anything that isn't a known plugin's files by hand. -- [ ] Set `is_wordpress = 1` for the 4 misflagged sites (#15): `UPDATE sites SET is_wordpress = 1 WHERE id IN (315, 330, 331, 332);` (check the ids match the domains first). -- [ ] **Load the ManageWP report recipients.** Copy the ManageWP export CSV to the server (outside the repo: it holds client emails and must never be committed), then run `php artisan clockwork:import-report-recipients ~/managewp-recipients.csv` for a dry run and check the table. If it looks right, run it again with `--apply`. Add any sites it lists as "Site not found in Control" by hand on the **Scheduling** page. -- [ ] Optional: log any October extra work on the site pages (#10) before Nov 1, or the Additional work section stays hidden. +| 1 | **"Weekly" still exists.** Every run reports on the last calendar month; a weekly schedule re-checks it each week and never emails a month twice. The form no longer offers weekly for new schedules (2026-11-06), but wp-admin can still set it. | Decide: drop weekly everywhere (Control, Companion's wp-admin screen and the sync), or build a real weekly report. | `ScheduledReportRunner::run()`, `ReportRecipientsSyncService` | +| 10 | **The work log is empty** (`site_work_logs` had 0 rows), but `work_log` is in the default template. | The section is hidden unless hours get logged, so ManageWP-style "extra work" never appears. | Data / process | +| 11 | **⚠️ Off-site backups have stopped (operations).** Every backup-relay run since 2026-09-13 failed with S3 `403 Forbidden`; the production key returns `InvalidAccessKeyId`. | Unhosted sites aren't being backed up, and their reports are held. | `AWS_ACCESS_KEY_ID`, `modules/BackupRelay/` | +| 12 | **Vulnerability count is "as of the snapshot", not the month.** It's labelled "(as of …)". | Acceptable. | `ClientReportCompiler::compileSecurity()` | +| 15 | **4 scheduled sites flagged `is_wordpress = 0` but run WordPress** (ids 315, 330, 331, 332). v1.12.1's backfill migration should fix custom-host Companion sites; check these four after `migrate`. | No core-file checks on them until it's fixed. | `sites.is_wordpress` | +| 16 | **20 SpinupWP sites have no backups in the Spaces bucket**; 2 have report schedules. | Their reports are held. | SpinupWP dashboard / `SpacesClient` | +| 17 | **Harmless "PHP in uploads" warnings hold reports** (WP All Import, Sucuri, BackWPup, Smush). | Clean sites held every month until the paths are marked safe. | `clockwork:suppress-malware-path` | +| 18 | **Pressable can skip a month's speed test.** | That site's Speed section is hidden that month. Acceptable. | Pressable | +| 19 | **Non-Pressable sites get desktop speed only** (GTmetrix plan). | No mobile score for ~17 sites. | `RunPerformanceScans` | +| 21 | **The preview/PDF still differs from the email** in small ways: no blocklist row; raw ISO dates (updates, work log, "Checked …"); "1 scans"; "LCP: ms" when missing; downtime in minutes; "Not checked this period". | Cosmetic, but it's what prints as the PDF. | `reports/show.blade.php` | +| 23 | **wp-admin recipient sync compares against the schedule's `updated_at`**, which every run and hand send bumps. A client's wp-admin edit made just before a run can be pushed back over. | A recipient change made in wp-admin around report day may be lost. | `ReportRecipientsSyncService::reconcileFromSnapshot()` | +| 24 | **Re-enabling a paused schedule** whose `next_run_at` is in the past sends the missed month next morning (one click on the Paused pill, no confirm). | A stale month's report goes out. | `SchedulesController::toggle()` | +| 25 | **The Reports list follows the newest period with any report**: a one-off generated with this month's dates switches the list and its badge to it. | November's held queue hides behind the period picker. | `ReportBrowser::periods()` | +| 26 | **Deleting and recreating a schedule** resets it to "first report", and its old reports become one-offs that can't be recompiled. | Everything is held again. | `SchedulesController::destroy()` | +| 27 | **The Sent tile counts partly sent reports** (also counted under Waiting). | Tile and list disagree. | `index.blade.php` | +| 28 | **Before 06:00 UTC on the 1st it's still the evening before in New York.** A manual Run, Send now or a schedule created between 7 PM ET and the 06:00 run compiles the month before it has ended there, or schedules the first report a month later. | Partial last day, or a missed month. | `ScheduledReportRunner::run()`, `ClientReportSchedule::computeNextRun()` | +| 29 | **Updates made outside Control are recorded only from the v1.12.1 deploy.** WordPress auto-updates between Nov 1 and the deploy aren't listed in November. | Undercount; a site with nothing else could read "No updates were applied this month". | `ObservedUpdateRecorder` | +| 30 | **A missing `update_available` key counts as "nothing waiting"** (Companion before 1.15.0 doesn't send it). | Such a site could read "Everything was up to date". | `ClientReportCompiler::pendingUpdates()` | +| 31 | **A failed Spamhaus DNS lookup reads as "not listed"**; with no Google key, "Not listed" can rest on one timed-out lookup. | Blocklist row overstates. | `BlacklistChecker` | +| 32 | **"Data transferred" for a site that moved host mid-month** counts only the old host's days. | Understated figure, no hold. | `compileTraffic()` | +| 33 | **The work-log form defaults to the UTC date**, so hours logged after 7 PM ET land on the next day (and month). | Late Nov 30 hours miss November. | `_widget-work_logs.blade.php` | +| 34 | **04:45 and 05:30 UTC jobs run one after another** with the SSH-heavy jobs due at the same minute; nothing makes the 06:00 run wait. | If late, reports are held (safe), not wrong. Check the job end times. | `routes/console.php`, `PressableServiceProvider` | +| 35 | **Wording between the two emails:** "1 live test submissions", "· 1 hrs", outages "125 min" in text vs "2h 5m" in HTML, section order differs; the greeting is worked out from the report's stored recipients, not a typed address. | Cosmetic. | `emails/report-text.blade.php`, `ClientReportMail` | +| 36 | **Silent skips:** an inactive site or an auto schedule with no recipients is skipped with no action-log entry and the run still exits OK. | A site can quietly never get a report. | `SendScheduledClientReports` | ## Ideas and backlog - **SEO Ranking section** (ManageWP parity; decided for after Nov 1). The SEMrush data behind the monthly SEO reports is the obvious source. -- **Google Analytics section** (ManageWP parity; after Nov 1). Control only has server-log visit counts; GA4 needs an integration. +- **Google Analytics section** (ManageWP parity). Control only has server-log visit counts; GA4 needs an integration. - **PDF attachment** for clients who forward the report. - **A personal note per month** on the schedule (the field exists on manual reports only). -- **Sent-email audit.** `sent_to` records *who* got each report, not *when* or *by whom*. `action_logs` entries would show it on the site's activity log. +- **Sent-email audit.** `sent_to` records *who* got each report, not *when* or *by whom*. - **A "preview the next batch" view.** What the next scheduled run would send, and to whom. - **Remove the public `/reports/view/{token}` route, or decide its purpose.** Clients can't reach it and the email doesn't link to it. @@ -76,6 +80,21 @@ Numbers stay stable as items are fixed; fixed ones move to **Done**. | Date | What | Ref | |---|---|---| +| 2026-11-06 | **Every report waits for approval** (on by default; switch on the Scheduling tab), with **Approve & send all ready** and **Send selected** on the Reports list. The confirm dialog now submits through the clicked button, so a button's `formaction` and value survive it. | `feature/client-reports` | +| 2026-11-06 | **Audit for the first unattended run (Dec 1)**, 5 reviewers, every finding checked against the code. Below, from "Schedule form" to "Report schema 8". | `feature/client-reports` | +| 2026-11-06 | **Schedule form:** unchecking **Enable** did nothing, and saving a paused schedule switched it back on (an unchecked box sends nothing; the controller read that as on). | `feature/client-reports` | +| 2026-11-06 | **Security holds:** a Sucuri SiteCheck hit is held even when the malware row comes from a clean Companion scan; a core-file or blocklist problem earlier in the month is held when a later clean check hides it; a month in which no malware scan or blocklist check finished is held (when those scans are on). | `feature/client-reports` | +| 2026-11-06 | **#22:** the vulnerability refresh records each plugin's last successful lookup; a report with a plugin never looked up is held, naming it. A vulnerability list older than 48h holds every report instead of sending "Not checked". | `feature/client-reports` | +| 2026-11-06 | **Uptime:** 429-blocked time of an hour or more, and maintenance mode of 2 hours or more, are held (both count as up); uptime is rounded down, so a 2-minute outage reads 99.99%, not 100%; a site added late on Nov 1 (a 25-hour day) no longer counts as a partial month. | `feature/client-reports` | +| 2026-11-06 | **Updates run from a site's Updates tab** showed the raw log line ("Updated example-forms/example-forms.php 6.0.6 → 6.1 on …") in the email; now the plugin's name, and the tab logs in the jobs' wording. | `feature/client-reports` | +| 2026-11-06 | **Backups:** SpinupWP's database half is stored apart (`database_bytes`) so a broken dump can't hide behind the files; files-only months, a failed latest read and a read from before the month ended are held. | `feature/client-reports` | +| 2026-11-06 | **Small guards:** a speed test with no score is skipped (it showed a red 0/100); requests with zero visits are suspect traffic; vulnerable plugin names are entity-decoded ("&amp;"); the inbox preview text follows the template. | `feature/client-reports` | +| 2026-11-06 | **Sending:** editing a schedule's recipients updates its unsent reports (Send used the compile-time list); recipients are de-duplicated ignoring case; sends to one report are locked, so a double click can't email twice; Send on a sent report warns first; a draft-mode Run doesn't add a second live report beside a sent one. | `feature/client-reports` | +| 2026-11-06 | **Logo:** an uploaded logo is embedded from disk; a relative or Control-host logo URL is never hot-linked (it was broken in every inbox). | `feature/client-reports` | +| 2026-11-06 | **Care plan ended with the month:** Bill.com's 01:30 UTC sync on the 1st turned it off before the run, so the month's report was skipped. It's now compiled and held; the sync logs its flips (local only). | `feature/client-reports` | +| 2026-11-06 | **Recipient push** no longer writes an older Companion snapshot back over a newer one; `client_reports.client_email` is text (8+ long addresses crashed the insert under MySQL strict mode). | `feature/client-reports` | +| 2026-11-06 | **Report schema 8**, so unsent reports compiled before these checks are recompiled before sending. | `feature/client-reports` | +| 2026-11-06 | **Done since the last update:** Clients tab, Send test to me, Recompile, Mark findings reviewed, schema 7 report-content fixes (v1.12.1); #20, #21 (mostly) and #14 earlier. | PR #63 | | 2026-10-22 | **Independent review of the Nov 1 changes** (5 reviewers, each finding checked by a skeptic; 7 confirmed, all fixed). Below, from "Sent reports reused" to "Plain-text email". | PR #50 | | 2026-10-22 | **Sent reports reused for their period:** a run that was finished by hand after a failure, or **Send now** pressed again, no longer recompiles and emails everyone a second copy. | PR #50 | | 2026-10-22 | **"Already up to date" only when nothing was waiting at month end:** themes and WordPress core now count (only plugins did), updates applied after the period count as waiting then, and a Companion snapshot more than 48h older than the period end makes it unknown and holds the report. | PR #50 | diff --git a/resources/docs/reference/api-endpoints-we-call.md b/resources/docs/reference/api-endpoints-we-call.md index 91067932..9d0b05d1 100644 --- a/resources/docs/reference/api-endpoints-we-call.md +++ b/resources/docs/reference/api-endpoints-we-call.md @@ -2,7 +2,7 @@ title: APIs we call section: Reference order: 10 -updated: 2026-11-05 +updated: 2026-11-06 author: Aaron Reimann tags: [reference, api, integrations] tracks: [app/Services/*/*Client.php, modules/*/src/*Client.php, app/Services/Companion/ClockworkCompanionClient.php] @@ -154,7 +154,7 @@ Full details on the `architecture/companion-plugin` page. Control only mints res ## wpvulnerability.net — `https://www.wpvulnerability.net/plugin/{slug}` -`app/Services/Security/WpVulnerabilityClient.php` · No auth, keyless. `GET` per unique plugin slug installed anywhere in the fleet (~150-250 slugs), 100ms between requests, replaces the local `plugin_vulnerabilities` mirror in one transaction. Per-slug failures are recorded but don't abort the run. Replaced Wordfence's free Threat Intelligence v2 feed after it moved to authenticated v3 — wpvulnerability.net aggregates CVE/Patchstack/WPScan/Wordfence into one free feed. Feeds the Issues page's vulnerable-plugin flags. Daily at 03:15 (`clockwork:refresh-plugin-vulnerabilities`). Separate from Pressable's own CVE feed (`security-alerts/plugins`/`themes` on `PressableClient`) — Pressable-hosted sites get both. Each advisory's `operator` block becomes the stored range: `lt`/`lte` set the upper bound, `gt`/`gte` the lower bound (the operator decides the side, whichever slot it arrives in), and `eq` an exact release (`from_version = to_version`, both inclusive). `patched_in` is only set for an `lt` bound on a fixed advisory. +`app/Services/Security/WpVulnerabilityClient.php` · No auth, keyless. `GET` per unique plugin slug installed anywhere in the fleet (~150-250 slugs), 100ms between requests, replaces the local `plugin_vulnerabilities` mirror in one transaction. Per-slug failures are recorded but don't abort the run, and a failed slug keeps its earlier entries. Each slug's last successful lookup is kept in the `plugin_vulnerabilities.last_lookups` setting, so client reports can tell a plugin that was never looked up from one with nothing found. Replaced Wordfence's free Threat Intelligence v2 feed after it moved to authenticated v3 — wpvulnerability.net aggregates CVE/Patchstack/WPScan/Wordfence into one free feed. Feeds the Issues page's vulnerable-plugin flags. Daily at 03:15 (`clockwork:refresh-plugin-vulnerabilities`). Separate from Pressable's own CVE feed (`security-alerts/plugins`/`themes` on `PressableClient`) — Pressable-hosted sites get both. Each advisory's `operator` block becomes the stored range: `lt`/`lte` set the upper bound, `gt`/`gte` the lower bound (the operator decides the side, whichever slot it arrives in), and `eq` an exact release (`from_version = to_version`, both inclusive). `patched_in` is only set for an `lt` bound on a fixed advisory. ## WordPress.org Plugin Information — `https://api.wordpress.org/plugins/info/1.2/` diff --git a/resources/js/confirm-modal.js b/resources/js/confirm-modal.js index fa806707..11ae1d1b 100644 --- a/resources/js/confirm-modal.js +++ b/resources/js/confirm-modal.js @@ -343,6 +343,15 @@ export function initConfirmModalSystem(Alpine) { matchInput.value = requireMatch; } } + if (submitter && typeof form.requestSubmit === 'function') { + // Submit through the button that was clicked, so its + // name/value and any formaction/formmethod still apply + // (the second submit event passes the bypass above). On + // the next tick: a submit requested while the first + // submit event is still being dispatched is ignored. + setTimeout(() => form.requestSubmit(submitter), 0); + return; + } if (submitter?.name) { // Include submitter name/value if present const hidden = document.createElement('input'); @@ -351,6 +360,9 @@ export function initConfirmModalSystem(Alpine) { hidden.value = submitter.value; form.appendChild(hidden); } + if (submitter?.hasAttribute('formaction')) { + form.action = submitter.formAction; + } form.submit(); } }, diff --git a/tests/Feature/BillCom/CarePlanAndCustomerSyncTest.php b/tests/Feature/BillCom/CarePlanAndCustomerSyncTest.php index dcfebdea..46bc34b2 100644 --- a/tests/Feature/BillCom/CarePlanAndCustomerSyncTest.php +++ b/tests/Feature/BillCom/CarePlanAndCustomerSyncTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\BillCom; +use App\Models\ActionLog; use App\Models\Site; use Mockery; use Modules\BillCom\BillComClient; @@ -240,6 +241,12 @@ function billComClientMock(array $customers = [], array $items = [], array $invo ->and($stats['sites_set_false'])->toBe(1) ->and($stats['sites_set_true'])->toBe(0) ->and($stats['sites_skipped_due_to_override'])->toBe(0); + + // Logged like the site page's toggle, so client reports know when + // coverage ended, but never mirrored to the client's wp-admin. + $log = ActionLog::query()->where('site_id', $site->id)->where('action_type', ActionLog::TYPE_CARE_PLAN_TOGGLED)->sole(); + expect($log->details)->toMatchArray(['previous' => true, 'now' => false, 'source' => 'bill_com']) + ->and($log->companion_pushed_at)->toBeNull(); }); }); diff --git a/tests/Feature/Console/RecordSiteBackupsTest.php b/tests/Feature/Console/RecordSiteBackupsTest.php index 5f98ce34..1478e840 100644 --- a/tests/Feature/Console/RecordSiteBackupsTest.php +++ b/tests/Feature/Console/RecordSiteBackupsTest.php @@ -100,7 +100,9 @@ function pressableBackupsSite(): Site expect(SiteBackup::where('site_id', $site->id)->sole()) ->source->toBe(SiteBackup::SOURCE_SPINUPWP_SPACES) ->kind->toBe(SiteBackup::KIND_FULL) - ->bytes->toBe(6000); + ->bytes->toBe(6000) + // The database half on its own, so a broken dump can't hide behind the files. + ->database_bytes->toBe(1000); }); it('records our own S3 archives for sites we do not host', function () { diff --git a/tests/Feature/Console/RefreshPluginVulnerabilitiesTest.php b/tests/Feature/Console/RefreshPluginVulnerabilitiesTest.php index e9ac3a6e..d80bc58f 100644 --- a/tests/Feature/Console/RefreshPluginVulnerabilitiesTest.php +++ b/tests/Feature/Console/RefreshPluginVulnerabilitiesTest.php @@ -2,6 +2,8 @@ use App\Models\PluginVulnerability; use App\Models\Site; +use App\Services\Security\WpVulnerabilityClient; +use App\Support\Settings; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; @@ -166,6 +168,28 @@ function pluginVulnEntry(array $overrides = []): array ->toBe(['cf7-vuln', 'flaky-vuln']); }); + it('records when each plugin was last looked up, keeping the earlier time of one that failed this run', function () { + Site::factory()->create([ + 'companion_snapshot' => ['plugins' => ['plugins' => [ + ['slug' => 'contact-form-7/wp-contact-form-7.php'], + ['slug' => 'flaky-plugin/flaky.php'], + ['slug' => 'never-plugin/never.php'], + ]]], + ]); + app(Settings::class)->put(WpVulnerabilityClient::LOOKUPS_SETTING, ['flaky-plugin' => '2026-11-20T03:15:00+00:00']); + Http::fake([ + 'wpvulnerability.net/plugin/contact-form-7*' => Http::response(pluginVulnResponse()), + 'wpvulnerability.net/plugin/flaky-plugin*' => Http::response('', 500), + 'wpvulnerability.net/plugin/never-plugin*' => Http::response('', 500), + ]); + + $this->artisan('clockwork:refresh-plugin-vulnerabilities')->assertSuccessful(); + + $lookups = WpVulnerabilityClient::lastLookups(); + expect(array_keys($lookups))->toBe(['contact-form-7', 'flaky-plugin']) + ->and($lookups['flaky-plugin'])->toBe('2026-11-20T03:15:00+00:00'); + }); + it('changes nothing and fails when every lookup fails', function () { Site::factory()->create([ 'companion_snapshot' => ['plugins' => ['plugins' => [['slug' => 'contact-form-7/wp-contact-form-7.php']]]], diff --git a/tests/Feature/Mail/BrandLogoTest.php b/tests/Feature/Mail/BrandLogoTest.php new file mode 100644 index 00000000..4d900592 --- /dev/null +++ b/tests/Feature/Mail/BrandLogoTest.php @@ -0,0 +1,33 @@ + 'https://control.example.test']); +}); + +it('embeds an uploaded logo from the public disk, whatever its URL looks like', function () { + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + Storage::disk('public')->put('branding/companion-logo-1.png', $png); + Http::fake(); + + expect(BrandLogo::bytes(Storage::disk('public')->url('branding/companion-logo-1.png')))->toBe($png) + ->and(BrandLogo::bytes('/storage/branding/companion-logo-1.png'))->toBe($png) + ->and(BrandLogo::mimeType($png))->toBe('image/png'); + Http::assertNothingSent(); +}); + +it('never hot-links a relative URL or one on Control\'s own host', function () { + expect(BrandLogo::linkable('/storage/branding/missing.png'))->toBeFalse() + ->and(BrandLogo::linkable('https://control.example.test/images/logo.png'))->toBeFalse() + ->and(BrandLogo::linkable('https://cdn.agency.example/logo.png'))->toBeTrue(); +}); diff --git a/tests/Feature/Modules/ClientReportAccuracyTest.php b/tests/Feature/Modules/ClientReportAccuracyTest.php index c95c7593..661b0f5f 100644 --- a/tests/Feature/Modules/ClientReportAccuracyTest.php +++ b/tests/Feature/Modules/ClientReportAccuracyTest.php @@ -17,10 +17,12 @@ use App\Services\Chat\ChatNotifier; use App\Services\Security\CompanionMalwareScanner; use App\Services\Security\SecurityScanRecorder; +use App\Services\Security\WpVulnerabilityClient; use App\Services\Ssh\SshClient; use App\Services\Ssh\SshCommandTimedOut; use App\Services\Uptime\UptimeProbeResult; use App\Services\Uptime\UptimeStateUpdater; +use App\Support\Settings; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; @@ -148,6 +150,20 @@ function llarSnapshot(): array ->and($report['html'])->toContain('None found'); }); + it('holds a report that says "None found" for a plugin whose lookup has never succeeded', function () { + PluginVulnerability::factory()->create(['slug' => 'other-plugin', 'to_version' => '1.0.0']); + app(Settings::class)->put(WpVulnerabilityClient::LOOKUPS_SETTING, ['checked-plugin' => now()->toIso8601String()]); + $site = accuracySite(['companion_snapshot' => ['plugins' => ['plugins' => [ + ['slug' => 'checked-plugin/checked.php', 'name' => 'Checked Plugin', 'version' => '1.0.0', 'active' => true], + ['slug' => 'new-plugin/new.php', 'name' => 'New & Shiny', 'version' => '1.0.0', 'active' => true], + ]]]]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['vulnerabilities_checked' => true, 'vulnerabilities_unchecked_plugins' => ['New & Shiny']]) + ->and($report['reasons'])->toContain('No vulnerability lookup has succeeded yet for New & Shiny, so "Known plugin vulnerabilities" doesn\'t cover it.'); + }); + it('says "Not checked" when there is no plugin list to check', function () { $report = compileSeptember(accuracySite(['companion_snapshot' => null, 'companion_snapshot_at' => null])); @@ -167,7 +183,10 @@ function llarSnapshot(): array $report = compileSeptember($site); expect($report['data']['security']['vulnerabilities_checked'])->toBeFalse() - ->and($report['html'])->not->toContain('None found'); + ->and($report['data']['security']['vulnerability_feed_stale'])->toBeTrue() + ->and($report['html'])->not->toContain('None found') + // The whole fleet reads "Not checked" until the feed refreshes. + ->and($report['reasons'])->toContain('The plugin vulnerability list has not refreshed in over 48 hours (clockwork:refresh-plugin-vulnerabilities), so the client would read "Known plugin vulnerabilities: Not checked".'); }); it('records each vulnerable plugin by its file slug, with the version that fixes it, the update offered and any pause', function () { @@ -450,6 +469,55 @@ function recordCheck(Site $site, bool $ok, ?Carbon $at = null): void ->and($report['html'])->toContain('backed up on 16 days this month')->not->toContain('daily'); }); + it('compares a SpinupWP database dump with the database on its own, not the whole backup', function () { + $site = accuracySite(['hosting_provider' => Site::HOSTING_PROVIDER_SPINUPWP, 'companion_snapshot' => [ + 'plugins' => ['plugins' => []], 'environment' => ['db_size_bytes' => 400 * 1024 ** 2], + ]]); + foreach (range(1, 30) as $day) { + $at = sprintf('2026-09-%02d', $day); + SiteBackup::create([ + 'site_id' => $site->id, 'source' => SiteBackup::SOURCE_SPINUPWP_SPACES, 'kind' => SiteBackup::KIND_FULL, + 'external_ref' => $at, 'backup_at' => Carbon::parse($at.' 03:00'), 'bytes' => 2 * 1024 ** 3 + 40 * 1024, 'database_bytes' => 40 * 1024, + 'is_manual' => false, 'first_seen_at' => now(), 'last_seen_at' => now(), + ]); + } + SiteBackupCheck::create(['site_id' => $site->id, 'source' => SiteBackup::SOURCE_SPINUPWP_SPACES, 'checked_at' => now(), 'ok' => true]); + + $report = compileSeptember($site); + + expect($report['data']['backups'])->toMatchArray(['status' => 'suspect', 'db_backup_bytes' => 40 * 1024]) + ->and($report['reasons'])->toContain('Database backups are 40 KB but the database is 400 MB, so they look empty or broken and the Backups section was left out.'); + }); + + it('holds a month whose listed backups are all files only', function () { + $site = pressableBackupSite(); + foreach (range(1, 30) as $day) { + recordBackup($site, SiteBackup::KIND_FILES, sprintf('2026-09-%02d 00:00:00', $day)); + } + recordCheck($site, true); + + $report = compileSeptember($site); + + expect($report['data']['backups'])->toMatchArray(['status' => 'verified', 'no_database_backup' => true]) + ->and($report['reasons'])->toContain('Every backup in the last 10 days is files only: the database is not being backed up.'); + }); + + it('holds when the latest read of the backups failed or came before the month ended, though earlier days were found', function () { + $failed = pressableBackupSite(); + $early = pressableBackupSite(); + foreach ([$failed, $early] as $site) { + foreach (range(1, 30) as $day) { + recordBackup($site, SiteBackup::KIND_FILES, sprintf('2026-09-%02d 00:00:00', $day)); + recordBackup($site, SiteBackup::KIND_DATABASE, sprintf('2026-09-%02d 23:00:00', $day), 10 * 1024 ** 2); + } + } + recordCheck($failed, false); + recordCheck($early, true, Carbon::parse('2026-09-30 05:30')); + + expect(compileSeptember($failed)['reasons'])->toContain('The latest read of the backups failed (Pressable API returned 503), so the newest backups may be missing from the list.') + ->and(compileSeptember($early)['reasons'])->toContain('Backups were last read before the end of the month, so the last day may be missing from the list. Run clockwork:record-site-backups, then Recompile.'); + }); + it('does not hold a weekly SpinupWP schedule or a month missing only a few days', function () { $weekly = accuracySite(['hosting_provider' => Site::HOSTING_PROVIDER_SPINUPWP]); foreach (['2026-08-30', '2026-09-06', '2026-09-13', '2026-09-20', '2026-09-27'] as $at) { @@ -1286,6 +1354,118 @@ function trafficSite(): Site }); }); +describe('problems the email rows do not show', function () { + it('holds a SiteCheck hit on a site whose malware row comes from a clean Companion scan', function () { + $site = accuracySite(); + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_COMPANION_MALWARE, 'scanned_at' => Carbon::parse('2026-09-25 02:45'), 'status' => SiteSecurityScan::STATUS_CLEAN]); + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_SITECHECK, 'scanned_at' => Carbon::parse('2026-09-18 02:00'), 'status' => SiteSecurityScan::STATUS_ISSUES_FOUND, 'has_malware_hit' => true, 'blacklist_hit' => true]); + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_SITECHECK, 'scanned_at' => Carbon::parse('2026-09-25 02:00'), 'status' => SiteSecurityScan::STATUS_CLEAN]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['malware_status' => 'clean', 'sitecheck_last_issue_at' => '2026-09-18', 'sitecheck_issues' => ['malware', 'blocklist']]) + ->and($report['reasons'])->toContain("Sucuri SiteCheck reported malware and a blocklisting (last on Sep 18). The email's malware and blocklist rows come from other scans, so check the site before sending."); + }); + + it('does not repeat a SiteCheck hit that is already the malware row', function () { + $site = accuracySite(); + SiteSecurityScan::factory()->malwareFound()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_SITECHECK, 'scanned_at' => Carbon::parse('2026-09-18 02:00')]); + + $security = compileSeptember($site)['data']['security']; + + expect($security['malware_status'])->toBe('issues_found') + ->and($security['sitecheck_last_issue_at'])->toBeNull(); + }); + + it('holds a core-file or blocklist problem from earlier in the month that a later clean check hides', function () { + $site = accuracySite(); + SiteSecurityScan::factory()->coreChecksums()->create(['site_id' => $site->id, 'scanned_at' => Carbon::parse('2026-09-10 02:30'), 'status' => SiteSecurityScan::STATUS_ISSUES_FOUND]); + SiteSecurityScan::factory()->coreChecksums()->create(['site_id' => $site->id, 'scanned_at' => Carbon::parse('2026-09-28 02:30'), 'status' => SiteSecurityScan::STATUS_CLEAN]); + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_BLACKLIST, 'scanned_at' => Carbon::parse('2026-09-14 02:15'), 'status' => SiteSecurityScan::STATUS_ISSUES_FOUND, 'blacklist_hit' => true]); + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_BLACKLIST, 'scanned_at' => Carbon::parse('2026-09-28 02:15'), 'status' => SiteSecurityScan::STATUS_CLEAN]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['checksum_status' => 'clean', 'blacklist_status' => 'clean']) + ->and($report['html'])->toContain('Not listed') + ->and($report['reasons'])->toContain('A core-file check on Sep 10 found WordPress files that differ from the official release, but the latest check did not: check it was cleaned up.') + ->toContain('The site was on a spam or malware blocklist on Sep 14, but the latest check found it clean: confirm it was delisted.'); + }); + + it('holds a month in which no malware scan or blocklist check finished, unless those scans are switched off', function () { + $site = accuracySite(); + + $reasons = compileSeptember($site)['reasons']; + expect($reasons)->toContain('No malware scan finished this month, so the client would read "Not checked this month".') + ->toContain('No blocklist check finished this month, so the client would read "Not checked this month".'); + + app(Settings::class)->putMany([ + 'security_scans.companion_malware_enabled' => false, + 'security_scans.sitecheck_enabled' => false, + 'security_scans.blacklist_enabled' => false, + ]); + expect(implode(' ', compileSeptember($site)['reasons']))->not->toContain('No malware scan')->not->toContain('No blocklist check'); + }); +}); + +describe('guards on figures the client reads', function () { + it('skips a speed test that came back without a score instead of showing a red 0/100', function () { + $site = accuracySite(); + SitePerformanceScan::factory()->create(['site_id' => $site->id, 'scanned_at' => Carbon::parse('2026-09-10'), 'strategy' => SitePerformanceScan::STRATEGY_DESKTOP, 'engine' => SitePerformanceScan::ENGINE_GTMETRIX, 'performance_score' => 91]); + SitePerformanceScan::factory()->create(['site_id' => $site->id, 'scanned_at' => Carbon::parse('2026-09-28'), 'strategy' => SitePerformanceScan::STRATEGY_DESKTOP, 'engine' => SitePerformanceScan::ENGINE_GTMETRIX, 'performance_score' => null]); + + $report = compileSeptember($site); + + expect($report['data']['performance']['desktop']['score'])->toBe(91) + ->and($report['html'])->not->toContain('0/100'); + }); + + it('treats requests with no visits as traffic that cannot be right', function () { + $site = accuracySite(['server_id' => Server::factory()->create(['last_ssh_ok_at' => now()])->id]); + recordTraffic($site, 1, 30, ['requests' => 2000, 'visits' => 0]); + + $report = compileSeptember($site); + + expect($report['data']['traffic']['status'])->toBe('suspect') + ->and($report['html'])->not->toContain('Visits') + ->and($report['reasons'])->toContain("0 visits for 60,000 requests doesn't look like real visitors, so the Traffic section was left out."); + }); + + it('decodes HTML entities in vulnerable plugin names in both emails', function () { + PluginVulnerability::factory()->create(['slug' => 'example-forms', 'to_version' => '2.0.0']); + $site = accuracySite(['companion_snapshot' => ['plugins' => ['plugins' => [ + ['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms & Surveys', 'version' => '1.5.0', 'active' => true], + ]]]]); + + $report = compileSeptember($site); + + expect($report['html'])->toContain('Example Forms & Surveys')->not->toContain('&amp;') + ->and($report['text'])->toContain('Example Forms & Surveys')->not->toContain('&'); + }); + + it('never rounds a month with an outage up to 100%', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-12 08:00:00', status: 502); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-12 08:02:00', status: 200); + + $report = compileSeptember($site); + + expect($report['data']['uptime'])->toMatchArray(['outages_count' => 1, 'downtime_minutes' => 2, 'uptime_percentage' => 99.99]) + ->and($report['html'])->toContain('99.99%'); + }); + + it('counts a site added late on Nov 1, the day the clocks go back, as there for the whole month', function () { + // 23:30 EST on Nov 1 is 04:30 UTC on Nov 2: 24 hours after the + // period began, but still its first day. + $site = accuracySite(['created_at' => Carbon::parse('2026-11-02 04:30:00')]); + + $data = app(ClientReportCompiler::class)->compile($site, Carbon::parse('2026-11-01'), Carbon::parse('2026-11-30')); + + expect($data['meta']['coverage_start'] ?? null)->toBeNull() + ->and(ReportReviewFlags::reasons($data))->not->toContain('Monitoring began Nov 1, so this report covers only part of the month.'); + }); +}); + describe('security scan counts', function () { it('counts warning and issues-found scans, and does not count failed scans as run', function () { $site = accuracySite(); @@ -1525,7 +1705,22 @@ function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false, ->and($uptime['outages'][0])->toMatchArray(['started_at' => '2026-09-20T10:00:00+00:00', 'minutes' => 30]) ->and($report['html'])->not->toContain('Down Sep 12')->not->toContain('12h 0m') ->and($report['text'])->not->toContain('Sep 12') - ->and(ReportReviewFlags::reasons(['meta' => $report['data']['meta'], 'uptime' => $uptime]))->toBe([]); + // Not downtime, but twelve hours nobody could see the site, which + // the uptime figure counts as up: a person looks first. + ->and(ReportReviewFlags::reasons(['meta' => $report['data']['meta'], 'uptime' => $uptime]))->toBe([ + 'The uptime monitor was blocked (HTTP 429) for 12h 0m this month. That time counts as up in the uptime figure, but nobody could see the site.', + ]); + }); + + it('lets a short rate-limited stretch through without holding the report', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_PROBE_BLOCKED, '2026-09-12 08:00:00', status: 429); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-12 08:45:00', status: 200); + + $uptime = compileSeptember($site)['data']['uptime']; + + expect($uptime['probe_blocked_minutes'])->toBe(45) + ->and(ReportReviewFlags::reasons(['uptime' => $uptime]))->toBe([]); }); it('says the monitor was blocked, not that the site was still down, when a 429 is open at the end of the period', function () { @@ -1577,7 +1772,8 @@ function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false, 'probe_blocked_minutes' => 720, 'probe_blocked_since' => null, ]) ->and($report['html'])->not->toContain('Down Sep 12') - ->and(implode(' ', $report['reasons']))->not->toContain('down')->not->toContain('HTTP 429'); + ->and(implode(' ', $report['reasons']))->not->toContain('down') + ->toContain('The uptime monitor was blocked (HTTP 429) for 12h 0m this month.'); }); it('ends an outage where the site starts answering 429, and counts the block apart (down → probe_blocked → up)', function () { @@ -1733,6 +1929,21 @@ function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false, expect(compileSeptember($site)['data']['uptime'])->toMatchArray(['outages_count' => 1, 'downtime_minutes' => 30]); }); + + it('holds a month with a long maintenance window, which counts as up, but not a short one', function () { + $long = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($long, SiteUptimeEvent::TYPE_MAINTENANCE, '2026-09-10 09:00:00'); + uptimeEvent($long, SiteUptimeEvent::TYPE_UP, '2026-09-13 09:00:00'); + $short = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($short, SiteUptimeEvent::TYPE_MAINTENANCE, '2026-09-10 09:00:00'); + uptimeEvent($short, SiteUptimeEvent::TYPE_UP, '2026-09-10 10:00:00'); + + $report = compileSeptember($long); + + expect($report['data']['uptime'])->toMatchArray(['uptime_percentage' => 100, 'maintenance_minutes' => 3 * 24 * 60]) + ->and($report['reasons'])->toContain('The site was in maintenance mode for 72h 0m this month. That time counts as up in the uptime figure: check it was planned before the client reads it.') + ->and(implode(' ', compileSeptember($short)['reasons']))->not->toContain('maintenance mode'); + }); }); describe('the period in the report timezone', function () { @@ -2041,6 +2252,21 @@ function carePlanToggle(Site $site, string $at, array $details, string $target = }); describe('updates and site facts', function () { + it("names an update run from the site's Updates tab by the plugin, not the raw log line", function () { + $site = accuracySite(['companion_snapshot' => ['plugins' => ['plugins' => [ + ['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms & Surveys', 'version' => '6.1', 'active' => true], + ]]]]); + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE, 'target' => 'example-forms/example-forms.php', + 'summary' => 'Updated example-forms/example-forms.php 6.0.6 → 6.1 on '.$site->domain.'.', 'ok' => true, 'ran_at' => Carbon::parse('2026-09-05 14:00'), 'actor' => 'test']); + + $report = compileSeptember($site); + + expect($report['html'])->toContain('Example Forms & Surveys 6.0.6 → 6.1')->not->toContain('example-forms.php')->not->toContain(' on '.$site->domain.'.') + ->and($report['text'])->toContain('Example Forms & Surveys 6.0.6 → 6.1')->not->toContain('example-forms.php'); + // A report compiled before this keeps the raw line; it is cleaned when shown. + expect(ClientReportMail::cleanUpdateSummary('Updated example-forms/example-forms.php 6.0.6 → 6.1 on example.com.'))->toBe('Example Forms 6.0.6 → 6.1'); + }); + it('leaves out "updates" that ended on the version they started from', function () { $site = accuracySite(); foreach (['Plugin updated: Beaver Themer 1.5.3 → 1.5.3', 'Plugin updated: Yoast SEO 26.1 → 26.2', 'Plugin updated: Thing 1.5 → 1.5.1'] as $i => $summary) { diff --git a/tests/Feature/Modules/ClientReportApprovalTest.php b/tests/Feature/Modules/ClientReportApprovalTest.php new file mode 100644 index 00000000..c32c3c25 --- /dev/null +++ b/tests/Feature/Modules/ClientReportApprovalTest.php @@ -0,0 +1,188 @@ +travelTo(Carbon::parse('2026-12-01 06:00:00')); +}); + +/** A care-plan site whose November report has nothing to hold for review. */ +function approvalSite(string $domain): Site +{ + $site = Site::factory()->carePlan()->create([ + 'domain' => $domain, 'hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'backup_relay_enabled' => false, + 'uptime_last_checked_at' => now(), 'created_at' => now()->subYear(), + ]); + cleanSecurityScans($site); + SiteSecurityScan::factory()->coreChecksums()->create(['site_id' => $site->id, 'scanned_at' => Carbon::parse('2026-11-10 02:30')]); + + return $site; +} + +/** A November report in the state asked for. */ +function approvalReport(string $domain, string $state, array $attributes = []): ClientReport +{ + $site = approvalSite($domain); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => ["client@{$domain}"], 'is_enabled' => true, 'next_run_at' => Carbon::parse('2027-01-01'), + ]); + $meta = ['schema_version' => $state === ReportState::OUTDATED ? 1 : ClientReportCompiler::SCHEMA_VERSION]; + if ($state === ReportState::NEEDS_REVIEW) { + $meta['review_reasons'] = ['Backups could not be checked.']; + } + + return ClientReport::create(array_merge([ + 'site_id' => $site->id, 'schedule_id' => $schedule->id, 'title' => 'Maintenance Report (Nov 2026)', + 'period_start' => '2026-11-01', 'period_end' => '2026-11-30', 'sections_data' => ['meta' => $meta], + 'client_email' => "client@{$domain}", 'status' => ClientReport::STATUS_GENERATED, + ], $attributes)); +} + +it('is on unless switched off', function () { + expect(ScheduledReportRunner::approvalRequired())->toBeTrue(); + + clientReportsSendThemselves(); + + expect(ScheduledReportRunner::approvalRequired())->toBeFalse(); +}); + +it('compiles and checks a clean report on the monthly run, emails nothing, and leaves it ready to send', function () { + Mail::fake(); + $site = approvalSite('approval.test'); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => ['client@approval.test'], 'is_enabled' => true, + 'last_sent_at' => Carbon::parse('2026-11-01 07:00'), // has sent before: would have gone out by itself + 'next_run_at' => Carbon::parse('2026-12-01 00:00'), + ]); + + $this->artisan('clockwork:send-client-reports') + ->expectsOutputToContain('approval.test: compiled, waiting for approval (nothing sent)') + ->assertSuccessful(); + + $report = ClientReport::query()->where('schedule_id', $schedule->id)->sole(); + expect(ReportState::of($report))->toBe(ReportState::READY) + ->and($report->sent_to)->toBeEmpty() + ->and($schedule->refresh()->next_run_at->toDateString())->toBe('2027-01-01'); + Mail::assertNothingSent(); +}); + +it('still holds a flagged report as needing review', function () { + Mail::fake(); + $site = Site::factory()->carePlan()->create(['domain' => 'flagged-approval.test', 'hosting_provider' => Site::HOSTING_PROVIDER_SPINUPWP, 'uptime_last_checked_at' => now(), 'created_at' => now()->subYear()]); + cleanSecurityScans($site); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => ['client@flagged-approval.test'], 'is_enabled' => true, 'last_sent_at' => Carbon::parse('2026-11-01 07:00'), + 'next_run_at' => Carbon::parse('2026-12-01 00:00'), + ]); + + $run = app(ScheduledReportRunner::class)->run($schedule); + + expect($run->held())->toBeTrue() + ->and(ReportState::of($run->report))->toBe(ReportState::NEEDS_REVIEW); + Mail::assertNothingSent(); +}); + +it('approves and sends every ready report of the period, leaving the rest alone', function () { + Mail::fake(); + $this->mockIssueCounterZero(); + $ready = [approvalReport('ready-one.test', ReportState::READY), approvalReport('ready-two.test', ReportState::READY)]; + $held = approvalReport('held.test', ReportState::NEEDS_REVIEW); + $outdated = approvalReport('outdated.test', ReportState::OUTDATED); + $sent = approvalReport('sent.test', ReportState::SENT, ['status' => ClientReport::STATUS_SENT, 'sent_to' => ['client@sent.test'], 'sent_at' => now()]); + + $this->actingAs(User::factory()->create()) + ->get(route('client-reports.reports', ['period' => '2026-11'])) + ->assertOk() + ->assertSee('Approve & send all ready (2)', false) + ->assertSee('formaction="'.route('client-reports.send-many').'"', false); + + $this->post(route('client-reports.send-many'), ['scope' => 'ready', 'period' => '2026-11']) + ->assertRedirect(route('client-reports.reports', ['period' => '2026-11'])) + ->assertSessionHas('status', 'Sent 2 reports.'); + + Mail::assertSent(ClientReportMail::class, 2); + foreach ($ready as $report) { + expect($report->refresh()->status)->toBe(ClientReport::STATUS_SENT) + ->and($report->sent_to)->toBe(["client@{$report->site->domain}"]) + ->and($report->schedule->last_sent_at)->not->toBeNull(); + } + expect($held->refresh()->status)->toBe(ClientReport::STATUS_GENERATED) + ->and($outdated->refresh()->status)->toBe(ClientReport::STATUS_GENERATED) + ->and($sent->refresh()->sent_to)->toBe(['client@sent.test']); +}); + +it('sends the ticked reports, held ones included, and skips what cannot be sent', function () { + Mail::fake(); + $held = approvalReport('ticked-held.test', ReportState::NEEDS_REVIEW); + $outdated = approvalReport('ticked-outdated.test', ReportState::OUTDATED); + $sent = approvalReport('ticked-sent.test', ReportState::SENT, ['status' => ClientReport::STATUS_SENT, 'sent_to' => ['client@ticked-sent.test'], 'sent_at' => now()]); + + $this->actingAs(User::factory()->create()) + ->post(route('client-reports.send-many'), ['scope' => 'selected', 'period' => '2026-11', 'ids' => [$held->id, $outdated->id, $sent->id]]) + ->assertSessionHas('status', fn (string $message) => str_starts_with($message, 'Sent 1 report. Skipped 2 that can\'t be sent as they are')); + + Mail::assertSent(ClientReportMail::class, 1); + expect($held->refresh()->status)->toBe(ClientReport::STATUS_SENT) + ->and($outdated->refresh()->sent_to)->toBeEmpty(); +}); + +it('says so when nothing is ready or nothing was ticked', function () { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('client-reports.send-many'), ['scope' => 'ready', 'period' => '2026-11']) + ->assertSessionHas('status', 'No reports are ready to send in this period.'); + $this->actingAs($user)->post(route('client-reports.send-many'), ['scope' => 'selected', 'period' => '2026-11']) + ->assertSessionHas('status_error', 'Tick the reports to send first.'); +}); + +it('needs a signed-in operator to send in bulk', function () { + $report = approvalReport('guest.test', ReportState::READY); + + $this->post(route('client-reports.send-many'), ['scope' => 'ready'])->assertRedirect(route('login')); + + expect($report->refresh()->status)->toBe(ClientReport::STATUS_GENERATED); +}); + +it('shows the approval switch on the Scheduling tab and logs turning it off and on', function () { + $this->mockIssueCounterZero(); + $user = User::factory()->create(); + + $this->actingAs($user)->get(route('client-reports.schedules.index')) + ->assertOk() + ->assertSee('Approval on') + ->assertSee('Let clean reports send themselves'); + + $this->actingAs($user)->post(route('client-reports.schedules.approval'), ['require_approval' => '0']) + ->assertSessionHas('status', 'Approval is off: Automatic schedules now email their clean reports by themselves.'); + expect(ScheduledReportRunner::approvalRequired())->toBeFalse() + ->and(app(Settings::class)->get(ScheduledReportRunner::APPROVAL_SETTING))->toBeFalse(); + + $this->actingAs($user)->post(route('client-reports.schedules.approval'), ['require_approval' => '1']); + expect(ScheduledReportRunner::approvalRequired())->toBeTrue() + ->and(ActionLog::query()->where('action_type', SchedulesController::ACTION_APPROVAL_CHANGED)->count())->toBe(2); +}); diff --git a/tests/Feature/Modules/ClientReportClientsTest.php b/tests/Feature/Modules/ClientReportClientsTest.php index 354e5a8e..16dad1ae 100644 --- a/tests/Feature/Modules/ClientReportClientsTest.php +++ b/tests/Feature/Modules/ClientReportClientsTest.php @@ -287,6 +287,7 @@ function clientsReport(Site $site, string $month, array $attributes = [], array }); it('explains the consequences before a real send: who gets it and that the schedule then sends by itself', function () { + clientReportsSendThemselves(); $client = Client::create(['name' => 'Confirm Example', 'email' => 'confirm@example.com']); $site = clientsSite($client, 'confirm.example.com'); $schedule = clientsSchedule($site, ['recipients' => ['confirm@example.com']]); diff --git a/tests/Feature/Modules/ClientReportCompilerTest.php b/tests/Feature/Modules/ClientReportCompilerTest.php index fa2a0e8d..981a21f5 100644 --- a/tests/Feature/Modules/ClientReportCompilerTest.php +++ b/tests/Feature/Modules/ClientReportCompilerTest.php @@ -109,6 +109,6 @@ function compileMonth(Site $site, string $start, string $end, ?array $sections = }); it('stamps the current schema version', function () { - expect(ClientReportCompiler::SCHEMA_VERSION)->toBe(7) - ->and(compileMonth(Site::factory()->create(), '2026-10-01', '2026-10-31', [])['meta']['schema_version'])->toBe(7); + expect(ClientReportCompiler::SCHEMA_VERSION)->toBe(8) + ->and(compileMonth(Site::factory()->create(), '2026-10-01', '2026-10-31', [])['meta']['schema_version'])->toBe(8); }); diff --git a/tests/Feature/Modules/ClientReportListTest.php b/tests/Feature/Modules/ClientReportListTest.php index 9eee510b..cb0570af 100644 --- a/tests/Feature/Modules/ClientReportListTest.php +++ b/tests/Feature/Modules/ClientReportListTest.php @@ -353,6 +353,7 @@ function listReport(string $domain, string $month, array $attributes = [], array }); it('groups the hold reasons by category with a link to the site tab, and disables Send with the reason when it can\'t be sent', function () { + clientReportsSendThemselves(); $held = listReport('held.example.com', '2026-10', meta: ['review_reasons' => [ ScheduledReportRunner::FIRST_REPORT_REASON, 'The malware scan flagged something (scan #3).', 'No backups were found this month.', ]], compiled: true); diff --git a/tests/Feature/Modules/ClientReportMalwareReviewTest.php b/tests/Feature/Modules/ClientReportMalwareReviewTest.php index a606cd9b..2d969a14 100644 --- a/tests/Feature/Modules/ClientReportMalwareReviewTest.php +++ b/tests/Feature/Modules/ClientReportMalwareReviewTest.php @@ -27,6 +27,7 @@ */ beforeEach(function () { + clientReportsSendThemselves(); Http::fake(); // A month later than the reports: "last month" is November, not October. $this->travelTo(Carbon::parse('2026-12-10 09:00:00')); @@ -34,10 +35,13 @@ function reviewSite(): Site { - return Site::factory()->carePlan()->create([ + $site = Site::factory()->carePlan()->create([ 'domain' => 'review-'.Str::lower(Str::random(6)).'.example.com', 'hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'backup_relay_enabled' => false, 'uptime_last_checked_at' => now(), 'created_at' => now()->subYear(), ]); + cleanSecurityScans($site); + + return $site; } /** A Companion malware scan in October with these findings. */ diff --git a/tests/Feature/Modules/ClientReportSchedulesTest.php b/tests/Feature/Modules/ClientReportSchedulesTest.php index 903bd7af..efbf23a0 100644 --- a/tests/Feature/Modules/ClientReportSchedulesTest.php +++ b/tests/Feature/Modules/ClientReportSchedulesTest.php @@ -16,12 +16,19 @@ use Modules\ClientReports\Models\ClientReportSchedule; use Modules\ClientReports\Models\ClientReportTemplate; use Modules\ClientReports\Services\ClientReportCompiler; +use Modules\ClientReports\Services\ClientReportSender; use Modules\ClientReports\Services\ReportRecipientsSyncService; use Modules\ClientReports\Services\ScheduledReportRunner; +use Modules\ClientReports\Support\ReportState; use Tests\Concerns\RendersAuthenticatedPages; uses(RendersAuthenticatedPages::class); +// Most of these tests cover Automatic schedules emailing clean reports by +// themselves, which needs approval switched off; the approval-on run has +// its own describe block below. +beforeEach(fn () => clientReportsSendThemselves()); + /** * A care-plan site whose compiled report has nothing to hold for review (no * backup source to check, uptime probe running, and enrolled long before any @@ -29,7 +36,10 @@ */ function reportableSite(array $attributes = []): Site { - return Site::factory()->carePlan()->create(array_merge(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'backup_relay_enabled' => false, 'uptime_last_checked_at' => now(), 'created_at' => now()->subYear()], $attributes)); + $site = Site::factory()->carePlan()->create(array_merge(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'backup_relay_enabled' => false, 'uptime_last_checked_at' => now(), 'created_at' => now()->subYear()], $attributes)); + cleanSecurityScans($site); + + return $site; } /** @@ -225,6 +235,34 @@ function checkedCoreFiles(Site $site): void ->and($schedule->is_enabled)->toBeFalse(); }); + it('sends is_enabled=0 when the Enable box is unchecked, so saving a paused schedule keeps it paused', function () { + $user = User::factory()->create(); + $site = Site::factory()->create(['domain' => 'paused-sched.test']); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, + 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => ['client@example.com'], + 'is_enabled' => false, + ]); + + // A browser omits an unchecked checkbox; the hidden input before it + // is what reaches the controller. + $html = $this->actingAs($user)->get(route('client-reports.schedules.edit', $schedule)) + ->assertOk() + ->getContent(); + expect(preg_match('/\s*toBe(1); + + $this->actingAs($user)->put(route('client-reports.schedules.update', $schedule), [ + 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => 'client@example.com', + 'is_enabled' => '0', + ])->assertRedirect(route('client-reports.schedules.index')); + + expect($schedule->refresh()->is_enabled)->toBeFalse(); + }); + it('toggles schedule is_enabled status via toggle endpoint', function () { $user = User::factory()->create(); $site = Site::factory()->create(); @@ -419,7 +457,7 @@ function checkedCoreFiles(Site $site): void ]); $this->artisan('clockwork:send-client-reports') - ->expectsOutputToContain('generated draft report (awaiting operator review)') + ->expectsOutputToContain('compiled, waiting for approval (nothing sent)') ->assertExitCode(0); Mail::assertNothingSent(); @@ -755,6 +793,7 @@ function dueAutoSchedule(array $recipients): ClientReportSchedule // A SpinupWP site whose backups were never checked: "Backups could not be checked." $site = Site::factory()->carePlan()->create(['domain' => 'flagged.test', 'hosting_provider' => Site::HOSTING_PROVIDER_SPINUPWP, 'uptime_last_checked_at' => now()]); checkedCoreFiles($site); + cleanSecurityScans($site); $schedule = ClientReportSchedule::create([ 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => ['client@flagged.test'], @@ -784,6 +823,7 @@ function dueAutoSchedule(array $recipients): ClientReportSchedule Mail::fake(); $site = Site::factory()->carePlan()->create(['domain' => 'was-draft.test', 'hosting_provider' => Site::HOSTING_PROVIDER_SPINUPWP, 'uptime_last_checked_at' => now()]); checkedCoreFiles($site); + cleanSecurityScans($site); $schedule = ClientReportSchedule::create([ 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_DRAFT, 'recipients' => ['client@was-draft.test'], @@ -1210,3 +1250,139 @@ function dueAutoSchedule(array $recipients): ClientReportSchedule expect($service->pushToSite($site, null, scheduleRemoved: true))->toBeFalse(); }); }); + +describe('sending safely around the monthly run', function () { + it('applies edited schedule recipients to its unsent reports, not to sent ones', function () { + $user = User::factory()->create(); + $site = reportableSite(); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => ['old@client.test', 'wrong@client.test'], 'is_enabled' => true, + ]); + $report = fn (string $status, array $sentTo = []) => ClientReport::create([ + 'site_id' => $site->id, 'schedule_id' => $schedule->id, 'title' => 'Report', 'period_start' => '2026-10-01', 'period_end' => '2026-10-31', + 'sections_data' => [], 'client_email' => 'old@client.test, wrong@client.test', 'status' => $status, 'sent_to' => $sentTo, + ]); + $held = $report(ClientReport::STATUS_GENERATED); + $sent = $report(ClientReport::STATUS_SENT, ['old@client.test', 'wrong@client.test']); + + $this->actingAs($user)->put(route('client-reports.schedules.update', $schedule), [ + 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => "old@client.test\nnew@client.test", 'is_enabled' => '1', + ])->assertRedirect(); + + expect($held->refresh()->recipients())->toBe(['old@client.test', 'new@client.test']) + ->and($held->recipientsToSend())->toBe(['old@client.test', 'new@client.test']) + ->and($sent->refresh()->client_email)->toBe('old@client.test, wrong@client.test'); + }); + + it('treats addresses that differ only in case as one recipient', function () { + expect(ClientReport::parseRecipients('Jane@Client.test, jane@client.test; bob@client.test'))->toBe(['Jane@Client.test', 'bob@client.test']); + + $report = new ClientReport(['client_email' => 'Jane@Client.test, bob@client.test', 'status' => ClientReport::STATUS_GENERATED]); + $report->sent_to = ['jane@client.test']; + expect($report->recipientsToSend())->toBe(['bob@client.test']); + }); + + it('does not email someone a second send already reached while it waited', function () { + Mail::fake(); + $site = reportableSite(); + $report = ClientReport::create([ + 'site_id' => $site->id, 'title' => 'Report', 'period_start' => '2026-10-01', 'period_end' => '2026-10-31', + 'sections_data' => ['meta' => ['schema_version' => ClientReportCompiler::SCHEMA_VERSION]], 'client_email' => 'a@client.test, b@client.test', 'status' => ClientReport::STATUS_GENERATED, + ]); + // A double click: both requests loaded the report before either sent. + $second = ClientReport::find($report->id); + app(ClientReportSender::class)->send($report, ['a@client.test', 'b@client.test']); + + $failed = app(ClientReportSender::class)->send($second, ['a@client.test', 'b@client.test']); + + expect($failed)->toBe([]); + Mail::assertSent(ClientReportMail::class, 2); + }); + + it('does not compile a second live report when a draft schedule is run after its report was sent', function () { + Mail::fake(); + $schedule = ClientReportSchedule::create([ + 'site_id' => reportableSite()->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_DRAFT, 'recipients' => ['client@client.test'], 'is_enabled' => true, + 'next_run_at' => now()->subHour(), + ]); + $month = now()->subMonthNoOverflow(); + $sent = ClientReport::create([ + 'site_id' => $schedule->site_id, 'schedule_id' => $schedule->id, 'title' => 'Report', + 'period_start' => $month->copy()->startOfMonth(), 'period_end' => $month->copy()->endOfMonth()->startOfDay(), + 'sections_data' => [], 'client_email' => 'client@client.test', 'status' => ClientReport::STATUS_SENT, 'sent_to' => ['client@client.test'], + ]); + + $run = app(ScheduledReportRunner::class)->run($schedule); + + expect($run->report->is($sent))->toBeTrue() + ->and(ClientReport::count())->toBe(1); + Mail::assertNothingSent(); + }); + + it('warns before sending a report that was already sent', function () { + $report = new ClientReport(['client_email' => 'client@client.test', 'status' => ClientReport::STATUS_SENT]); + $report->sent_at = Carbon::parse('2026-12-01 06:00'); + + expect(ReportState::sendConfirmation($report)) + ->toStartWith('This report was already sent on Dec 1, 2026. Sending it again emails everyone on it a second copy.'); + }); +}); + +describe('a care plan that ended as the month did', function () { + it('still compiles the month, held, when the care plan was switched off after it ended', function () { + Mail::fake(); + $this->travelTo(Carbon::parse('2026-12-01 06:00:00')); + $site = reportableSite(['domain' => 'lapsed.test', 'care_plan_enabled' => false]); + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_CARE_PLAN_TOGGLED, 'target' => 'off', + 'summary' => 'Marked lapsed.test as NOT on a care plan (Bill.com sync).', 'details' => ['previous' => true, 'now' => false, 'source' => 'bill_com'], + 'ok' => true, 'actor' => 'scheduled', 'ran_at' => Carbon::parse('2026-12-01 01:30:00')]); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => ['client@lapsed.test'], 'is_enabled' => true, 'last_sent_at' => Carbon::parse('2026-11-01 06:10'), + 'next_run_at' => Carbon::parse('2026-12-01 00:00:00'), + ]); + checkedCoreFiles($site); + + $this->artisan('clockwork:send-client-reports')->assertSuccessful(); + + $report = ClientReport::query()->where('schedule_id', $schedule->id)->sole(); + expect($report->period_start->toDateString())->toBe('2026-11-01') + ->and($report->sections_data['meta']['review_reasons'])->toContain('The care plan was switched off Nov 30, 8:30 PM EST, when this period had ended. The period was covered, but confirm the client should still get this report.'); + Mail::assertNothingSent(); + }); + + it('still skips a site whose care plan was already off during the month', function () { + $this->travelTo(Carbon::parse('2026-12-01 06:00:00')); + $site = reportableSite(['domain' => 'off-all-month.test', 'care_plan_enabled' => false]); + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_CARE_PLAN_TOGGLED, 'target' => 'off', + 'summary' => 'off', 'details' => ['previous' => true, 'now' => false], 'ok' => true, 'actor' => 'manual', 'ran_at' => Carbon::parse('2026-10-20 12:00:00')]); + ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => ['client@off.test'], 'is_enabled' => true, 'next_run_at' => Carbon::parse('2026-12-01 00:00:00'), + ]); + + $this->artisan('clockwork:send-client-reports')->assertSuccessful(); + + expect(ClientReport::count())->toBe(0); + }); +}); + +it('merges the pushed recipients into the snapshot as it is now, not as the run loaded it', function () { + $site = reportableSite(['domain' => 'snap.test', 'companion_installed' => true, 'companion_secret' => str_repeat('s', 64), + 'companion_snapshot' => ['marker' => 'loaded-by-the-run']]); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => ['client@snap.test'], 'is_enabled' => true, + ]); + // The nightly refresh lands after the run loaded the site. + Site::query()->whereKey($site->id)->update(['companion_snapshot' => json_encode(['marker' => 'refreshed-since'])]); + Http::fake(['snap.test/*' => Http::response(['client_notifications' => ['report_recipients' => ['client@snap.test']]])]); + + expect(app(ReportRecipientsSyncService::class)->pushToSite($site, $schedule))->toBeTrue(); + + expect($site->fresh()->companion_snapshot)->toMatchArray(['marker' => 'refreshed-since']) + ->and($site->fresh()->companion_snapshot['client_notifications']['report_recipients'])->toBe(['client@snap.test']); +}); diff --git a/tests/Feature/Modules/ClientReportsControllerTest.php b/tests/Feature/Modules/ClientReportsControllerTest.php index f4127e97..13374086 100644 --- a/tests/Feature/Modules/ClientReportsControllerTest.php +++ b/tests/Feature/Modules/ClientReportsControllerTest.php @@ -430,10 +430,13 @@ function sendableReport(?string $clientEmail, array $attributes = []): ClientRep */ function recompileSite(): Site { - return Site::factory()->carePlan()->create([ + $site = Site::factory()->carePlan()->create([ 'domain' => 'site-'.Str::lower(Str::random(6)).'.example.com', 'hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'backup_relay_enabled' => false, 'uptime_last_checked_at' => now(), 'created_at' => now()->subYear(), ]); + cleanSecurityScans($site); + + return $site; } function recompileSchedule(Site $site, array $attributes = []): ClientReportSchedule @@ -461,6 +464,9 @@ function heldOctoberReport(ClientReportSchedule $schedule, array $attributes = [ } beforeEach(function () { + // A schedule that has never sent holds its first report only when + // clean reports send themselves (approval off). + clientReportsSendThemselves(); // A month later than the reports: "last month" is November, not October. $this->travelTo(Carbon::parse('2026-12-10 09:00:00')); }); diff --git a/tests/Pest.php b/tests/Pest.php index c61f3881..c7d6108d 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,7 +1,11 @@ shouldReceive('total')->andReturn(0); }); } + +/** + * A clean Sucuri SiteCheck and blocklist check on the 10th of each month + * from three months back to next month, so a report compiled for any of + * those months has its malware and blocklist rows filled and isn't held + * for "No malware scan finished this month". SiteCheck only stands in for + * the malware row when no Companion scan ran, so a test's own Companion + * scan still decides it. + */ +function cleanSecurityScans(Site $site): void +{ + foreach (range(-3, 1) as $offset) { + $day = now()->startOfMonth()->addMonthsNoOverflow($offset)->addDays(9)->setTime(2, 0); + foreach ([SiteSecurityScan::TYPE_SITECHECK, SiteSecurityScan::TYPE_BLACKLIST] as $type) { + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => $type, 'scanned_at' => $day]); + } + } +} + +/** + * Switch off "every report waits for approval" (on by default), for tests + * of the mode where Automatic schedules email clean reports by themselves. + */ +function clientReportsSendThemselves(): void +{ + app(Settings::class)->put(ScheduledReportRunner::APPROVAL_SETTING, false); +} diff --git a/tests/Unit/ReportReviewFlagsTest.php b/tests/Unit/ReportReviewFlagsTest.php index 581e1073..e340f953 100644 --- a/tests/Unit/ReportReviewFlagsTest.php +++ b/tests/Unit/ReportReviewFlagsTest.php @@ -437,3 +437,23 @@ function reviewedSecurity(array $overrides = [], array $mark = []): array ->and(ReportReviewFlags::canMarkMalwareReviewed($marked))->toBeFalse(); }); }); + +describe('rows the client would read as "Not checked"', function () { + it('holds a missing malware or blocklist result only when that scan was switched on', function () { + $missing = ['malware_status' => null, 'blacklist_status' => null]; + + expect(ReportReviewFlags::reasons(['security' => [...$missing, 'malware_expected' => true, 'blacklist_expected' => true]]))->toBe([ + 'No malware scan finished this month, so the client would read "Not checked this month".', + 'No blocklist check finished this month, so the client would read "Not checked this month".', + ]) + ->and(ReportReviewFlags::reasons(['security' => [...$missing, 'malware_expected' => false, 'blacklist_expected' => false]]))->toBe([]) + // Reports compiled before these keys were kept say nothing. + ->and(ReportReviewFlags::reasons(['security' => $missing]))->toBe([]); + }); + + it('holds a long rate-limited stretch that counts as up, but not a short one', function () { + expect(ReportReviewFlags::reasons(['uptime' => ['probe_blocked_minutes' => 125]])) + ->toBe(['The uptime monitor was blocked (HTTP 429) for 2h 5m this month. That time counts as up in the uptime figure, but nobody could see the site.']) + ->and(ReportReviewFlags::reasons(['uptime' => ['probe_blocked_minutes' => 59]]))->toBe([]); + }); +});