diff --git a/.env.example b/.env.example index 49133c74..a59cfe09 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,11 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" +# Client reports show outage times (and measure uptime) in this timezone +# (any IANA zone name, e.g. Europe/London). Stored times are UTC; an empty +# or invalid zone falls back to UTC. +# CLOCKWORK_REPORTS_TIMEZONE=America/New_York + CLOCKWORK_ARCJET_BOTS_URL=https://raw.githubusercontent.com/arcjet/well-known-bots/main/well-known-bots.json CLOCKWORK_MATTERMOST_ENABLED=false @@ -369,6 +374,10 @@ CLOCKWORK_GTMETRIX_REGION=4 # CLOCKWORK_GTMETRIX_TIMEOUT=120 # CLOCKWORK_GTMETRIX_POLL_INTERVAL=5 # CLOCKWORK_GTMETRIX_POLL_MAX_ATTEMPTS=36 +# API credits the plan refills daily; the rotation's month-end catch-up +# never schedules more scans than this in one night. 10 is the smallest +# (Core) plan's allowance; set it to your plan's daily credits. +# CLOCKWORK_GTMETRIX_DAILY_CREDITS=10 # Google PageSpeed Insights v5 — FALLBACK engine. Kept wired up for ~30 # days post-cutover so a transient GTmetrix outage doesn't lose a site's diff --git a/.gitleaksignore b/.gitleaksignore index e613d29b..6e65f6a8 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -61,3 +61,11 @@ tests/Feature/Installer/InstallerWizardTest.php:generic-api-key:73 # private-key rule on the PEM markers alone, same false-positive shape as # ReencryptSecretsTest.php above. database/seeders/AnonymizedSnapshotSeeder.php:private-key:105 + +# UpgraderOutputTest.php's FAKE_JWT constant is an invented, unsigned +# JWT-shaped fixture (header {"typ":"JWT","alg":"HS256"}, payload +# {"license":"fake"}, signature segment = base64url of "fakesignature"). +# It only exercises UpgraderOutput's redaction of JWT-in-path download URLs +# and is never sent anywhere. Matched by gitleaks' default jwt rule on shape +# alone. (Not quoted here, so this comment does not trip the same rule.) +tests/Unit/Support/UpgraderOutputTest.php:jwt:20 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6447c129..b497120b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Client Reports — Clients tab**: `/client-reports` opens on every client grouped by care plan ("Care plan clients — reported monthly" first, "No care plan" collapsed), with each site's care-plan badge, schedule, recipients and a six-month report history whose cells open that month's report. Care plan / No care plan / All, Needs attention / A–Z order, search and filters; a page per client with its full report history, previous/next client and a client switcher. A "Client reports" link sits in each site's dashboard header. +- **Client Reports — Send test to me**: emails a report's exact client email only to the signed-in operator, marked `[TEST]`, without changing the report or its schedule. Send now spells out who receives the report and that a schedule's first send switches it to sending clean reports automatically. +- **Client Reports — Schedules for care-plan sites that have none**: one-click (and "Create all") schedules from the Clients and Scheduling tabs; Scheduling lists care-plan schedules first and folds away the ones that won't send. +- **Client Reports — Reports list** (now `/client-reports/reports`): one period at a time with replaced copies hidden, filters for status, hold-reason category and search, summary counts that act as filters, reason chips, labelled actions, and "Recompile all outdated / selected" (never sends). +- **Client Reports — Report preview toolbar**: operator-only previous/next through the filtered list, Email version, Recompile, Print, Test to me and Send (disabled with the reason), keyboard shortcuts, and review reasons grouped by category with links to the site's dashboard tab. The email version page uses the app layout with HTML and plain-text tabs. +- **Client Reports — Mark findings reviewed**: an operator can mark a held report's review-tier malware findings (never `issues_found`) as reviewed with a note; both emails then read "Reviewed — no threat found", and the mark survives Recompile while the scan is unchanged. +- **Updates made outside Control are recorded**: snapshot changes write informational `plugin_update_observed` / `theme_update_observed` / `core_update_observed` activity (local only, never mirrored to the client's wp-admin, no effect on streaks or pauses). +- **Client Reports — Recompile held drafts without sending**: a **Recompile** action (reports list and operator preview) and `clockwork:recompile-held-client-reports` (`--outdated`, `--site`, `--dry-run`) rebuild an unsent scheduled report for its own period from current data, supersede the old copy and re-check it for review. Neither ever sends email. Unsent reports compiled under older rules (schema < 6) can't be sent until recompiled; the history shows **Outdated: recompile**. +- **Uptime — Probe-blocked state**: a site that answers the monitor with HTTP 429 (rate limiting) as many times in a row as the failure threshold moves to `probe_blocked` instead of down. No downtime, down alert or client email; one ops chat notice after a configurable number of hours (default 6, Monitoring → Settings), new `uptimeProbeBlocked` chat event. +- **Malware scans — New PHP file in uploads notice**: a chat notice (`phpInUploadsDetected`) fires once for each PHP file that newly appears under `wp-content/uploads`, diffed against the previous scan, so a dropped file is noticed without re-alerting on long-standing ones. Companion scans now set `has_malware_hit`, so the malware alert fires for them too. +- **Backup relay — Per-site failure state**: each site keeps its last relay attempt, last error and failing-since time (shown on the site's Backups card). + +### Changed +- **Pressable traffic** is recorded at 04:45 UTC, before the 06:00 client-report run, for every Pressable site (with or without Companion), so a reported month's last day is complete. +- **Performance scans**: the weekly rotation gives each site a fixed weekday from its domain (enrolments no longer reshuffle everyone), adds a month-end catch-up for care-plan sites with no scan that month within the daily GTmetrix budget (`CLOCKWORK_GTMETRIX_DAILY_CREDITS`), and starts a baseline scan on enrolment or when a care plan is switched on. +- **Nightly plugin updates** run at most once per local (America/New_York) date, so the DST fall-back night no longer queues a second batch; `--force` overrides. +- **Client Reports — Email**: sent From the configured address under the report brand's company name; outage and coverage dates use `CLOCKWORK_REPORTS_TIMEZONE` (default America/New_York); one show/hide rule now drives the HTML email, the plain-text part and the preview/PDF. +- **Client Reports — Recipient import** creates schedules for sites without a care plan disabled unless `--include-no-care-plan` is passed, and each scheduled run logs (`client_report_skipped`) the schedules it skipped for having no care plan. + +### Fixed +- **Client Reports — Report content verified against real data**: the greeting uses the recipient's first name only when there's one recipient who is a known contact, otherwise "Hi there,"; update dates use the report timezone; updates applied outside Control are listed and "no updates were needed" is never claimed; the agency's own Companion/Renegade plugins and updates that didn't stick are left out; "Malicious visitors blocked" excludes false-positive bans and shows only when lockouts reached Control all month; a care plan switched on mid-month starts the coverage period; outages of an hour or more hold the report; uptime is coloured by value; the plain-text email matches the HTML (data transferred, backup kinds); review reasons give exact figures. The preview, printout and public page use the same wording as the email. Report schema is now 7. +- **Client Reports — wp-admin** no longer shows reports as active for a site without a Control schedule. +- **Vulnerabilities — Exact-version advisories** (`== X`) no longer match every older release; a migration repairs existing rows. +- **Updates — Theme identity guard**: the nightly updater holds back a theme update that would install a different wordpress.org theme sharing the folder name (e.g. over a custom child theme), lists it in the nightly summary and announces it once; it doesn't count as a failure. +- **Client Reports — Accuracy fixes from an audit of a month of held drafts**: + - The support URL never falls back to Control's own (private) URL; the settings form rejects localhost/private URLs. Period lengths are whole days. + - Sites enrolled mid-month are measured from when they were added (uptime from when monitoring began), say so, and are held as partial-month reports. + - Rate-limited monitor checks (HTTP 429) aren't downtime; an open block at month end holds as "monitor was blocked", not "still down". + - A malware scan that stopped early shows "Partial scan", never "No malware found"; an earlier in-period hit, the finding kinds and scan id are given to the reviewer; matches on the scanner's own signature file are ignored. + - The WordPress core-files row is left out on hosts with no way to run the check; "Threats blocked" appears only when measured, and "at the server" only where a fail2ban jail exists. + - Vulnerable-plugin holds match plugins by slug and fire when a waiting update would fix the plugin or its updates are paused; a month where updates were waiting but none applied is held. + - Backups: database dumps far smaller than the database are held as suspect; a relay that has been failing, began mid-month, or missed many days is held with the reason. + - Traffic gets a status (ok / partial / suspect / no data / unsupported) and is shown only when complete and believable (e.g. not when every request comes from a load balancer's IP). + - Failed contact-form checks hold the report; live tests say the site "reported sending" the email rather than "delivered", and template, module and docs wording no longer promise inbox delivery checks. + - The preview lists core, theme and plugin updates, has a Contact forms section, and keeps staff names and operator notes off the printout and public page. Report schema is now 6. +- **Uptime**: HTTP 429 answers no longer mark a site down; during a real outage they don't close and reopen it (which re-sent down alerts and client emails every cycle). +- **Plugin updates**: vulnerable plugins are queued first (advisories now match on the plugin directory); auto-paused plugins are retried once per new release or security fix (7-day back-off, not for licence/offer failures), and a successful update lifts the pause and clears the wp-admin notice. Real upgrader errors (not writable, unauthorized download, bad/missing package) are recorded with a `reason_code` instead of "no longer offered", and repeated file-permission failures pause the site once with one alert instead of pausing every plugin. +- **Backup relay**: can't be enabled for a site whose Companion lacks `backup-create`, and such sites fail with an actionable message; a stale archive hash is no longer paired with a newer archive. +- **Malware scans**: finding paths are normalized to the WordPress root, so suppressions and the own-scanner exclusion work on every host layout (`clockwork:suppress-malware-path --kind`); SSH scans prove each check finished, so timeouts, cut-off output or a missing docroot no longer read as clean. +- **Contact form tests** no longer skip days: daily/weekly due checks use a grace window instead of a strict 24h/7d cutoff. +- **Pressable traffic** no longer overwrites earlier history with zeros for days the API returned no data. +- **Bill.com syncs** match invoice domains after normalization (including a leading `www.`) and recognise every TLD the fleet uses; archived-site domains and collisions are reported separately. +- **Scan coverage**: standalone (custom-host) sites are marked as WordPress (with a backfill migration and a snapshot self-heal), and the nightly core-checksum run skips sites with no shell transport instead of failing every night. + +### Security +- **Upgrader output redaction**: licence keys, tokens and signed download URLs in WordPress upgrader output are redacted before update results are stored, logged, sent to chat or mirrored to Companion (including `clockwork:backfill-companion-action-log --reset-since` re-pushes of older rows). + +### Upgrade notes +- Run `php artisan migrate` (new columns on `sites`, `plugin_update_jobs` and `plugin_update_failure_streaks`, and a backfill of `sites.is_wordpress` for standalone Companion sites). +- Run `php artisan clockwork:sync-bill-customers --dry-run` and `clockwork:sync-bill-care-plans --dry-run` and review the output before the next real sync: normalized matching can link `www.` sites and change care-plan flags. +- Refresh held client-report drafts with `php artisan clockwork:recompile-held-client-reports --outdated --dry-run`, then without `--dry-run`. It never sends. +- Set `CLOCKWORK_GTMETRIX_DAILY_CREDITS` to the GTmetrix plan's daily credits (default 10). Optionally set `CLOCKWORK_REPORTS_TIMEZONE`. +- Stored `plugin_update_jobs.messages` and `action_logs.details` rows written before this release still contain unredacted upgrader output; scrub them separately. +- After deploy: `npm run build` (new Tailwind classes in the Client Reports views) and clear the view cache. Report schema 7 marks every unsent report outdated: run `php artisan clockwork:recompile-held-client-reports` (never sends). `/client-reports` now opens the Clients tab; the reports list moved to `/client-reports/reports`. Run `php artisan queue:restart` so workers load the observed-update hook. The app host needs HTTPS access to `api.wordpress.org` for the theme identity check. + ## [1.12.0] - 2026-11-04 ### Added diff --git a/app/Console/Commands/BackfillCompanionActionLog.php b/app/Console/Commands/BackfillCompanionActionLog.php index a7aa226c..78090b7b 100644 --- a/app/Console/Commands/BackfillCompanionActionLog.php +++ b/app/Console/Commands/BackfillCompanionActionLog.php @@ -4,6 +4,7 @@ use App\Models\ActionLog; use App\Models\Site; +use App\Services\ActionLog\ActionLogger; use App\Services\Companion\ClockworkCompanionClient; use Illuminate\Console\Command; use Illuminate\Database\Eloquent\Collection; @@ -78,9 +79,12 @@ public function handle(): int ->update(['companion_pushed_at' => null]); } + // Local-only rows (observed or refused updates) never go to the + // client's mirror; ActionLogger doesn't push them either. $rows = ActionLog::query() ->where('site_id', $site->id) ->whereNull('companion_pushed_at') + ->whereNotIn('action_type', ActionLog::LOCAL_ONLY_TYPES) ->orderBy('ran_at') ->limit($limit) ->get(); @@ -97,7 +101,10 @@ public function handle(): int foreach ($rows as $row) { try { - $client->appendActionLog([ + // Rows written before upgrader output was redacted still + // hold licence keys and signed download URLs; never + // write those back into the client's wp-admin. + $client->appendActionLog(ActionLogger::forCompanionMirror([ 'action_type' => $row->action_type, 'target' => $row->target, 'summary' => $row->summary, @@ -108,7 +115,7 @@ public function handle(): int 'actor' => $row->actor, 'care_plan_enabled' => (bool) ($site->care_plan_enabled ?? false), 'ran_at' => $row->ran_at?->toIso8601String(), - ]); + ])); $row->forceFill(['companion_pushed_at' => now()])->save(); $okCount++; } catch (Throwable $e) { diff --git a/app/Console/Commands/CheckSiteUptime.php b/app/Console/Commands/CheckSiteUptime.php index 451f9905..8cfed5cc 100644 --- a/app/Console/Commands/CheckSiteUptime.php +++ b/app/Console/Commands/CheckSiteUptime.php @@ -47,6 +47,7 @@ public function handle(UptimeProber $prober, UptimeStateUpdater $updater, Indexa $up = 0; $down = 0; + $blocked = 0; $seoFailures = 0; $seoLastError = null; $start = microtime(true); @@ -66,7 +67,13 @@ public function handle(UptimeProber $prober, UptimeStateUpdater $updater, Indexa $seoFailures++; $seoLastError = $seoError->getMessage(); } - $probe->succeeded ? $up++ : $down++; + if ($probe->isProbeBlocked) { + $blocked++; + } elseif ($probe->succeeded) { + $up++; + } else { + $down++; + } } catch (Throwable $e) { // Synthesize a transport-failed probe and run it through the // state machine so consecutive_failures still ticks up — a @@ -88,7 +95,7 @@ public function handle(UptimeProber $prober, UptimeStateUpdater $updater, Indexa } $elapsed = (int) round(microtime(true) - $start); - $this->info("Done. up={$up} down={$down} elapsed={$elapsed}s"); + $this->info("Done. up={$up} down={$down} blocked={$blocked} elapsed={$elapsed}s"); if ($seoFailures > 0) { $this->warn("SEO indexability piggyback failed on {$seoFailures} of {$sites->count()} sites. Last error: {$seoLastError}"); diff --git a/app/Console/Commands/PressableTrafficReport.php b/app/Console/Commands/PressableTrafficReport.php index 3531b364..6c683a7b 100644 --- a/app/Console/Commands/PressableTrafficReport.php +++ b/app/Console/Commands/PressableTrafficReport.php @@ -39,13 +39,39 @@ * "Past 1 month" returns clean daily (86400s) buckets, but Edge Logs * metrics (requests, status, etc.) over the same range return 8-hour * (28800s) buckets instead — aggregated into calendar days here. + * + * Two jobs, deliberately independent of each other: + * 1. History. Every Pressable site with a pressable_site_id gets the days + * Pressable actually returned upserted into site_traffic_daily, which + * is what the monthly client report and /capacity read. This runs + * whether or not Companion is installed, reachable, or new enough, so a + * Companion problem never leaves a report without traffic. + * 2. Companion push. Sites that can take it also get the zero-filled + * 30-day payload for their wp-admin Traffic page. */ class PressableTrafficReport extends Command { protected $signature = 'clockwork:pressable-traffic-report {--site= : Limit to a single site (id or domain)}'; - protected $description = 'Push a real daily traffic rollup (sourced from Pressable\'s own metrics API) to each Companion-equipped Pressable site.'; + protected $description = 'Record daily traffic for every Pressable site from Pressable\'s own metrics API, and push the 30-day rollup to each Companion-equipped one.'; + + /** Days in the zero-filled Companion payload, today included. */ + private const PAYLOAD_DAYS = 30; + + /** + * Days written to site_traffic_daily, today included. Shorter than the + * payload on purpose: the API's "Past 1 month" window starts part-way + * through a day that can be as recent as 28 days ago (after February), + * and that first, partial day must not overwrite a complete row an + * earlier run wrote. Every day is still rewritten 28 times while it + * stays in the window, so history coverage is unchanged. + */ + private const HISTORY_DAYS = 28; + + private const REQUEST_COLUMNS = ['requests', 'status_2xx', 'status_3xx', 'status_4xx', 'status_5xx']; + + private const VISIT_COLUMNS = ['unique_ips', 'visits']; /** * Path-categorisation rules ported from RollupTraffic::applyBucketFilter() @@ -72,31 +98,51 @@ public function handle(PressableClient $pressable): int $sites = $this->resolveSites(); if ($sites->isEmpty()) { - $this->warn('No Companion-equipped Pressable sites matched.'); + $this->warn('No Pressable sites matched.'); return self::SUCCESS; } - $stats = ['ok' => 0, 'failed' => 0, 'skipped' => 0]; + // recorded = history written; pushed = also pushed to Companion; + // push_skipped = recorded, but the site can't take the push. + $stats = ['recorded' => 0, 'pushed' => 0, 'push_skipped' => 0, 'failed' => 0]; foreach ($sites as $site) { - $caps = $site->companion_capabilities ?? []; - if (! in_array('traffic-report', $caps, true)) { - $stats['skipped']++; - $this->line(" [skip] {$site->domain} — capability 'traffic-report' not advertised (Companion 1.16.0+ required)"); + try { + $requestsByDay = $this->requestsByDayAndStatus($pressable, $site->pressable_site_id); + $visitsByDay = $this->uniquesByDay($pressable, $site->pressable_site_id); + } catch (Throwable $e) { + $this->fetchFailed($site, $e, $stats); continue; } try { - $report = $this->buildReport($pressable, $site); + $days = $this->recordDailyTraffic($site, $requestsByDay, $visitsByDay); } catch (Throwable $e) { $stats['failed']++; - Log::warning('companion.pressable_traffic_report.fetch_failed', [ + Log::warning('companion.pressable_traffic_report.record_failed', [ 'site' => $site->domain, 'error' => $e->getMessage(), ]); - $this->warn(" [fail] {$site->domain}: Pressable fetch failed — {$e->getMessage()}"); + $this->warn(" [fail] {$site->domain}: recording daily traffic failed — {$e->getMessage()}"); + + continue; + } + $stats['recorded']++; + + $skipReason = $this->pushSkipReason($site); + if ($skipReason !== null) { + $stats['push_skipped']++; + $this->line(" [rec] {$site->domain} — days={$days}; push skipped: {$skipReason}"); + + continue; + } + + try { + $report = $this->buildReport($pressable, $site, $requestsByDay, $visitsByDay); + } catch (Throwable $e) { + $this->fetchFailed($site, $e, $stats); continue; } @@ -114,42 +160,18 @@ public function handle(PressableClient $pressable): int continue; } - if (! empty($report['daily'])) { - $now = now(); - $upsertRows = array_map(fn (array $r) => [ - 'site_id' => $site->id, - 'date' => $r['date'], - 'requests' => (int) ($r['requests'] ?? 0), - 'unique_ips' => (int) ($r['visits'] ?? 0), - 'visits' => (int) ($r['visits'] ?? 0), - 'bytes_sent' => 0, - 'status_2xx' => (int) ($r['status_2xx'] ?? 0), - 'status_3xx' => (int) ($r['status_3xx'] ?? 0), - 'status_4xx' => (int) ($r['status_4xx'] ?? 0), - 'status_5xx' => (int) ($r['status_5xx'] ?? 0), - 'created_at' => $now, - 'updated_at' => $now, - ], $report['daily']); - - SiteTrafficDaily::upsert( - $upsertRows, - ['site_id', 'date'], - ['requests', 'unique_ips', 'visits', 'status_2xx', 'status_3xx', 'status_4xx', 'status_5xx', 'updated_at'] - ); - } - - $stats['ok']++; + $stats['pushed']++; $today = (int) ($report['totals']['today'] ?? 0); $month = (int) ($report['totals']['month_30d'] ?? 0); - $rows = count($report['daily']); - $this->line(" [ok] {$site->domain} — today={$today} 30d={$month} rows={$rows}"); + $this->line(" [ok] {$site->domain} — today={$today} 30d={$month} days={$days}"); } $msg = sprintf( - 'companion.pressable_traffic_report.push complete: ok=%d failed=%d skipped=%d', - $stats['ok'], + 'companion.pressable_traffic_report complete: recorded=%d pushed=%d push_skipped=%d failed=%d', + $stats['recorded'], + $stats['pushed'], + $stats['push_skipped'], $stats['failed'], - $stats['skipped'], ); Log::info($msg); $this->info($msg); @@ -158,13 +180,108 @@ public function handle(PressableClient $pressable): int } /** + * @param array $stats + */ + private function fetchFailed(Site $site, Throwable $e, array &$stats): void + { + $stats['failed']++; + Log::warning('companion.pressable_traffic_report.fetch_failed', [ + 'site' => $site->domain, + 'error' => $e->getMessage(), + ]); + $this->warn(" [fail] {$site->domain}: Pressable fetch failed — {$e->getMessage()}"); + } + + /** + * Why this site can't take the Companion push, or null when it can. + */ + private function pushSkipReason(Site $site): ?string + { + // Raw value: a null check doesn't need the encrypted secret decrypted. + if (! $site->companion_installed || $site->getRawOriginal('companion_secret') === null) { + return 'Companion not installed'; + } + + if (! in_array('traffic-report', $site->companion_capabilities ?? [], true)) { + return "capability 'traffic-report' not advertised (Companion 1.16.0+ required)"; + } + + return null; + } + + /** + * Upsert into site_traffic_daily only what Pressable actually returned. + * + * A day the API has no bucket for (before the site moved to Pressable, + * or a gap on Pressable's side) is left alone rather than written as + * zeros, so it can't erase an earlier row — including one the previous + * host's access-log rollup wrote. The same goes per metric family: a + * day with request buckets but no uniques bucket updates only the + * request columns. bytes_sent and top_paths are never updated; Pressable + * has no per-day figure for either. + * + * @param array $requestsByDay + * @param array $visitsByDay + * @return int days written + */ + private function recordDailyTraffic(Site $site, array $requestsByDay, array $visitsByDay): int + { + $from = Carbon::today()->subDays(self::HISTORY_DAYS - 1)->toDateString(); + $to = Carbon::today()->toDateString(); + + $dates = array_filter( + array_unique([...array_keys($requestsByDay), ...array_keys($visitsByDay)]), + fn ($date) => $date >= $from && $date <= $to, + ); + + $now = now(); + $batches = []; + foreach ($dates as $date) { + $statuses = $requestsByDay[$date] ?? null; + $visits = $visitsByDay[$date] ?? null; + + $update = [ + ...($statuses !== null ? self::REQUEST_COLUMNS : []), + ...($visits !== null ? self::VISIT_COLUMNS : []), + 'updated_at', + ]; + + $key = implode(',', $update); + $batches[$key]['update'] = $update; + // Columns outside $update only take effect when the row is new. + $batches[$key]['rows'][] = [ + 'site_id' => $site->id, + 'date' => $date, + 'requests' => $statuses !== null ? array_sum($statuses) : 0, + 'unique_ips' => $visits ?? 0, + 'visits' => $visits ?? 0, + 'bytes_sent' => 0, + 'status_2xx' => $statuses['2xx'] ?? 0, + 'status_3xx' => $statuses['3xx'] ?? 0, + 'status_4xx' => $statuses['4xx'] ?? 0, + 'status_5xx' => $statuses['5xx'] ?? 0, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + foreach ($batches as $batch) { + SiteTrafficDaily::upsert($batch['rows'], ['site_id', 'date'], $batch['update']); + } + + return count($dates); + } + + /** + * @param array $requestsByDay + * @param array $visitsByDay * @return array */ - private function buildReport(PressableClient $pressable, Site $site): array + private function buildReport(PressableClient $pressable, Site $site, array $requestsByDay, array $visitsByDay): array { $pressableSiteId = $site->pressable_site_id; - $daily = $this->buildDailyRows($pressable, $pressableSiteId); + $daily = $this->buildDailyRows($requestsByDay, $visitsByDay); $today = Carbon::today()->toDateString(); $byDate = collect($daily)->keyBy('date'); @@ -196,15 +313,19 @@ private function buildReport(PressableClient $pressable, Site $site): array } /** + * The Companion payload's `daily` array: every day of the window, with + * zeros for days Pressable returned nothing for, because the wp-admin + * chart expects a continuous series. Never written to the DB as-is — + * see recordDailyTraffic(). + * + * @param array $requestsByDay + * @param array $visitsByDay * @return array> */ - private function buildDailyRows(PressableClient $pressable, string $pressableSiteId): array + private function buildDailyRows(array $requestsByDay, array $visitsByDay): array { - $requestsByDay = $this->requestsByDayAndStatus($pressable, $pressableSiteId); - $visitsByDay = $this->uniquesByDay($pressable, $pressableSiteId); - $today = Carbon::today(); - $start = $today->copy()->subDays(29); + $start = $today->copy()->subDays(self::PAYLOAD_DAYS - 1); $daily = []; for ($cursor = $start->copy(); $cursor->lte($today); $cursor->addDay()) { @@ -227,7 +348,8 @@ private function buildDailyRows(PressableClient $pressable, string $pressableSit /** * Edge Logs family: requests × http_status, auto-resolved to 8-hour * buckets over a 1-month window (not daily) — aggregated into calendar - * days here. + * days here. A bucket with no counts in it is not data, so a day made + * only of empty buckets is left out, the same as a day with none. * * @return array */ @@ -237,10 +359,15 @@ private function requestsByDayAndStatus(PressableClient $pressable, string $pres $out = []; foreach ($periods as $period) { + $dimension = $period['dimension'] ?? []; + if (! is_array($dimension) || $dimension === []) { + continue; + } + $date = gmdate('Y-m-d', (int) ($period['timestamp'] ?? 0)); $out[$date] ??= ['2xx' => 0, '3xx' => 0, '4xx' => 0, '5xx' => 0]; - foreach ($period['dimension'] ?? [] as $status => $count) { + foreach ($dimension as $status => $count) { $bucket = match (true) { (int) $status >= 200 && (int) $status < 300 => '2xx', (int) $status >= 300 && (int) $status < 400 => '3xx', @@ -260,7 +387,7 @@ private function requestsByDayAndStatus(PressableClient $pressable, string $pres /** * Uniques & Views family: uniques × hostname, real daily (86400s) * buckets. Summed across every hostname variant (bare + www) for one - * unified per-day visitor count. + * unified per-day visitor count. Empty buckets are skipped, as above. * * @return array */ @@ -270,9 +397,14 @@ private function uniquesByDay(PressableClient $pressable, string $pressableSiteI $out = []; foreach ($periods as $period) { + $counts = $period['hostname']['uniques'] ?? $period['dimension'] ?? []; + if (! is_array($counts) || $counts === []) { + continue; + } + $date = gmdate('Y-m-d', (int) ($period['timestamp'] ?? 0)); $sum = 0; - foreach ($period['hostname']['uniques'] ?? $period['dimension'] ?? [] as $count) { + foreach ($counts as $count) { $sum += (int) $count; } $out[$date] = ($out[$date] ?? 0) + $sum; @@ -342,13 +474,15 @@ private function categorisePath(string $path): string } /** + * Every Pressable site the API can be asked about, Companion or not — + * the history write needs all of them; pushSkipReason() decides which + * also get the Companion push. + * * @return Collection */ private function resolveSites(): Collection { $q = Site::query() - ->where('companion_installed', true) - ->whereNotNull('companion_secret') ->where('hosting_provider', Site::HOSTING_PROVIDER_PRESSABLE) ->whereNotNull('pressable_site_id'); diff --git a/app/Console/Commands/RunNightlyPluginUpdates.php b/app/Console/Commands/RunNightlyPluginUpdates.php index 781872b9..c792c25f 100644 --- a/app/Console/Commands/RunNightlyPluginUpdates.php +++ b/app/Console/Commands/RunNightlyPluginUpdates.php @@ -4,28 +4,100 @@ use App\Jobs\RunPluginUpdate; use App\Jobs\RunThemeUpdate; +use App\Models\ActionLog; +use App\Models\PluginUpdateFailureStreak; use App\Models\PluginUpdateIgnore; use App\Models\PluginUpdateJob; use App\Models\Site; +use App\Services\ActionLog\ActionLogger; +use App\Services\Chat\ChatNotifier; use App\Services\Security\PluginVulnerabilityMatcher; +use App\Services\Updates\ThemeIdentityGuard; +use App\Support\UpgraderOutput; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; +use Illuminate\Contracts\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -#[Signature('clockwork:run-nightly-plugin-updates {--dry-run : print candidates only, no DB writes / dispatch} {--site= : limit to one site (id or domain)}')] +#[Signature('clockwork:run-nightly-plugin-updates {--dry-run : print candidates only, no DB writes / dispatch} {--site= : limit to one site (id or domain)} {--force : queue even if a fleet-wide nightly batch already ran for today\'s local date}')] #[Description('Queue plugin AND theme updates for every care-plan site that has either pending. Fired nightly at 02:00 ET; workers chew through the queue during the 4-hour window. Name predates theme support (added 2026-08-31) — kept as-is to avoid renaming a live scheduled job.')] class RunNightlyPluginUpdates extends Command { - public function handle(PluginVulnerabilityMatcher $vulnMatcher): int + /** + * Timezone the nightly is scheduled in (routes/console.php). "Today" + * for the once-per-night guard and the date in the batch id both use + * this zone, so a night maps to exactly one local calendar date. + */ + public const LOCAL_TIMEZONE = 'America/New_York'; + + /** + * Every nightly batch id starts with this. UpdateFailureStreakRecorder, + * AbstractRunUpdate's chat ping, the stale-job reaper and the summary + * mail all key off the prefix, so it must not change. + */ + public const BATCH_PREFIX = 'nightly-'; + + /** + * Marks a batch queued by an operator's --site run, so it never counts + * as (or blocks) the fleet-wide nightly for that date. + */ + public const SITE_BATCH_MARKER = 'site-'; + + public const BYPASS_NEW_VERSION = 'new_version'; + + public const BYPASS_SECURITY_FIX = 'security_fix'; + + /** + * A security fix whose version already failed (or was already retried) + * is retried at most once in this many days, so a pause that a retry + * cannot clear doesn't become a nightly failure ping again. + */ + public const SECURITY_RETRY_BACKOFF_DAYS = 7; + + /** + * Failures a retry of the same version can't fix: the licence or the + * update offer needs an operator. Only a new version is tried again. + */ + public const OPERATOR_ONLY_REASONS = [ + UpgraderOutput::REASON_DOWNLOAD_UNAUTHORIZED, + UpgraderOutput::REASON_NO_PACKAGE, + UpgraderOutput::REASON_NOT_OFFERED, + ]; + + public function handle(PluginVulnerabilityMatcher $vulnMatcher, ThemeIdentityGuard $themeGuard): int { $isDryRun = (bool) $this->option('dry-run'); $siteFilter = $this->option('site'); + // Once per local night. The chained schedule fires at 01:30 + // America/New_York, and on the DST fall-back day 01:30 happens + // twice, so the whole refresh + update chain ran twice and every + // failure was counted twice toward the auto-pause threshold. + // Operator --site runs are explicit and are not guarded; --force + // overrides the guard for a deliberate fleet-wide re-run. + if (! $siteFilter && ! $this->option('force')) { + $existingBatch = $this->fleetBatchForLocalDate(); + if ($existingBatch !== null) { + $localDate = Carbon::now(self::LOCAL_TIMEZONE)->format('Y-m-d'); + if ($isDryRun) { + $this->warn("Nightly batch {$existingBatch} already ran for {$localDate}; a real run would be skipped (use --force to override)."); + } else { + $this->info("Nightly batch {$existingBatch} already ran for {$localDate}. Skipping (use --force to override)."); + Log::info('plugin_update.nightly_already_ran', [ + 'local_date' => $localDate, + 'batch_id' => $existingBatch, + ]); + + return self::SUCCESS; + } + } + } + $candidates = $this->loadCandidateSites($siteFilter); if ($candidates->isEmpty()) { @@ -67,6 +139,10 @@ public function handle(PluginVulnerabilityMatcher $vulnMatcher): int if ($slug === '') { continue; } + // The snapshot slug is "directory/file.php"; the matcher keys + // on the directory only, so compare on that. + $dirSlug = self::directorySlug($slug); + $isVulnerable = array_key_exists($dirSlug, $vulnSlugs); $tuples[] = [ 'site' => $site, 'kind' => PluginUpdateJob::KIND_PLUGIN, @@ -74,7 +150,9 @@ public function handle(PluginVulnerabilityMatcher $vulnMatcher): int 'name' => $entry['name'] ?? null, 'before_version' => $entry['version'] ?? null, 'target_version' => $entry['new_version'] ?? null, - 'is_vulnerable' => isset($vulnSlugs[$slug]), + 'is_vulnerable' => $isVulnerable, + 'patched_in' => $isVulnerable ? $vulnSlugs[$dirSlug] : null, + 'pause_bypass' => null, ]; } @@ -91,6 +169,9 @@ public function handle(PluginVulnerabilityMatcher $vulnMatcher): int 'before_version' => $entry['version'] ?? null, 'target_version' => $entry['new_version'] ?? null, 'is_vulnerable' => false, + 'patched_in' => null, + 'pause_bypass' => null, + 'snapshot_item' => $entry, ]; } } @@ -101,13 +182,20 @@ public function handle(PluginVulnerabilityMatcher $vulnMatcher): int return self::SUCCESS; } - // Drop tuples in PluginUpdateIgnore. + // Drop tuples in PluginUpdateIgnore (auto-failure pauses can be let + // through for a new release or a security fix; see filterIgnored). $tuples = $this->filterIgnored($tuples); // Drop tuples that already have a live PluginUpdateJob for this kind/slug. $tuples = $this->filterAlreadyLive($tuples); - if ($tuples === []) { + // Hold back theme updates that would put a different theme in place + // of the installed one (a custom theme sharing its folder name with + // an unrelated wordpress.org theme). Checked last, so ignored and + // already-queued themes cost no directory lookup. + [$tuples, $refused] = $this->splitIdentityMismatches($tuples, $themeGuard); + + if ($tuples === [] && $refused === []) { $this->info('All candidates filtered out (ignored or already live). Nothing to queue.'); if (! $isDryRun) { $this->stampEligibleSites($siteFilter); @@ -128,6 +216,7 @@ public function handle(PluginVulnerabilityMatcher $vulnMatcher): int $vulnerableCount = collect($tuples)->where('is_vulnerable', true)->count(); $sitesTouched = collect($tuples)->pluck('site.id')->unique()->count(); + $retried = array_values(array_filter($tuples, fn ($t) => $t['pause_bypass'] !== null)); $this->info(sprintf( 'Queue: %d tuples (%d vulnerable-first) across %d site%s.', @@ -137,18 +226,48 @@ public function handle(PluginVulnerabilityMatcher $vulnMatcher): int $sitesTouched === 1 ? '' : 's', )); + if ($retried !== []) { + $this->info(sprintf( + 'Retrying %d auto-paused update%s (%d new release, %d security fix).', + count($retried), + count($retried) === 1 ? '' : 's', + count(array_filter($retried, fn ($t) => $t['pause_bypass'] === self::BYPASS_NEW_VERSION)), + count(array_filter($retried, fn ($t) => $t['pause_bypass'] === self::BYPASS_SECURITY_FIX)), + )); + } + + if ($refused !== []) { + $this->warn(sprintf( + 'Holding back %d theme update%s: the installed theme does not look like the theme on offer.', + count($refused), + count($refused) === 1 ? '' : 's', + )); + foreach ($refused as $t) { + $this->line(sprintf( + ' HOLD %s [%s] %s (%s → %s): %s', + str_pad((string) $t['site']->domain, 36), + $t['kind'], + $t['slug'], + $t['before_version'] ?: '?', + $t['target_version'] ?: '?', + $t['refusal'], + )); + } + } + if ($isDryRun) { // Print the first ~25 tuples so the operator can sanity-check // ordering without flooding stdout on a 200-row night. foreach (array_slice($tuples, 0, 25) as $t) { $this->line(sprintf( - ' %s %s [%s] %s (%s → %s)', + ' %s %s [%s] %s (%s → %s)%s', $t['is_vulnerable'] ? '!CVE' : ' ', str_pad((string) $t['site']->domain, 36), $t['kind'], $t['slug'], $t['before_version'] ?: '?', $t['target_version'] ?: '?', + $t['pause_bypass'] !== null ? " [auto-paused, retry: {$t['pause_bypass']}]" : '', )); } if (count($tuples) > 25) { @@ -160,11 +279,30 @@ public function handle(PluginVulnerabilityMatcher $vulnMatcher): int // Real run. One batch_id for the whole night so the summary command // can find them with a LIKE 'nightly-%' query and the dashboard can - // group them as a single batch. - $batchId = 'nightly-'.Carbon::now()->format('Y-m-d').'-'.Str::random(8); + // group them as a single batch. The date is the local night's date + // (same zone as the once-per-night guard above). + $batchId = self::BATCH_PREFIX + .Carbon::now(self::LOCAL_TIMEZONE)->format('Y-m-d') + .'-'.($siteFilter ? self::SITE_BATCH_MARKER : '') + .Str::random(8); $accepted = 0; $failed = 0; + foreach ($retried as $t) { + Log::info('plugin_update.nightly_retrying_auto_paused', [ + 'site_id' => $t['site']->id, + 'kind' => $t['kind'], + 'slug' => $t['slug'], + 'target_version' => $t['target_version'], + 'reason' => $t['pause_bypass'], + 'batch_id' => $batchId, + ]); + } + + foreach ($refused as $t) { + $this->recordIdentityRefusal($t, $batchId); + } + foreach ($tuples as $t) { try { DB::transaction(function () use ($t, $batchId) { @@ -181,6 +319,21 @@ public function handle(PluginVulnerabilityMatcher $vulnMatcher): int 'queued_at' => Carbon::now(), ]); + // A retry of an auto-paused slug counts as that version's + // attempt from now, however the job ends: a timeout, the + // reaper or a transport error never reaches the streak + // recorder, and the pause must still hold for it tomorrow. + if ($t['pause_bypass'] !== null) { + PluginUpdateFailureStreak::updateOrCreate( + ['site_id' => $t['site']->id, 'target_kind' => $t['kind'], 'target_slug' => $t['slug']], + [ + 'last_target_version' => $t['target_version'], + 'last_from_version' => $t['before_version'], + 'last_retry_queued_at' => Carbon::now(), + ], + ); + } + match ($t['kind']) { PluginUpdateJob::KIND_THEME => RunThemeUpdate::dispatch($row->id), default => RunPluginUpdate::dispatch($row->id), @@ -206,16 +359,167 @@ public function handle(PluginVulnerabilityMatcher $vulnMatcher): int $this->stampEligibleSites($siteFilter); $this->info(sprintf( - 'Done. batch_id=%s accepted=%d failed=%d sites_touched=%d', + 'Done. batch_id=%s accepted=%d failed=%d sites_touched=%d held_back=%d', $batchId, $accepted, $failed, $sitesTouched, + count($refused), )); return self::SUCCESS; } + /** + * Split theme tuples whose installed theme doesn't look like the theme + * on offer (see ThemeIdentityGuard) off the queue. Plugin tuples pass + * through: the snapshot carries nothing comparable for plugins, and + * wordpress.org plugin names carry taglines and edition names ("Lite"), + * so comparing them would hold back legitimate updates. + * + * A failed lookup never stops the night: the tuple is queued as before. + * + * @param list> $tuples + * @return array{0: list>, 1: list>} [to queue, held back with 'refusal'] + */ + private function splitIdentityMismatches(array $tuples, ThemeIdentityGuard $guard): array + { + $queue = []; + $refused = []; + + foreach ($tuples as $t) { + if ($t['kind'] !== PluginUpdateJob::KIND_THEME) { + $queue[] = $t; + + continue; + } + + try { + $reason = $guard->mismatchReason((array) ($t['snapshot_item'] ?? ['slug' => $t['slug']])); + } catch (\Throwable $e) { + Log::warning('plugin_update.theme_identity_check_failed', [ + 'site_id' => $t['site']->id, + 'slug' => $t['slug'], + 'error' => $e->getMessage(), + ]); + $reason = null; + } + + if ($reason === null) { + $queue[] = $t; + } else { + $t['refusal'] = $reason; + $refused[] = $t; + } + } + + return [$queue, $refused]; + } + + /** + * Record a held-back theme update once, where ops will see it: + * - a skipped plugin_update_jobs row in tonight's batch (reason_code + * identity_mismatch), so the nightly summary email lists it; + * - an ignore (source identity_mismatch, not shown in wp-admin), so + * the slug stays out of later nights and of the Updates page's bulk + * actions until an operator lifts it; + * - a local-only action log and one chat message. + * Nothing here touches failure streaks or auto-pauses: no update ran. + * + * @param array $t + */ + private function recordIdentityRefusal(array $t, string $batchId): void + { + /** @var Site $site */ + $site = $t['site']; + $reason = (string) $t['refusal']; + $name = (string) ($t['name'] ?: $t['slug']); + + try { + DB::transaction(function () use ($t, $site, $reason, $batchId) { + PluginUpdateJob::create([ + 'site_id' => $site->id, + 'target_kind' => $t['kind'], + 'target_slug' => $t['slug'], + 'target_name' => $t['name'], + 'status' => PluginUpdateJob::STATUS_SKIPPED, + 'before_version' => $t['before_version'], + 'target_version' => $t['target_version'], + 'error' => $reason, + 'reason_code' => ThemeIdentityGuard::REASON_CODE, + 'requested_by_user_id' => null, + 'batch_id' => $batchId, + 'queued_at' => Carbon::now(), + 'completed_at' => Carbon::now(), + ]); + + PluginUpdateIgnore::updateOrCreate( + ['site_id' => $site->id, 'target_kind' => $t['kind'], 'target_slug' => $t['slug']], + [ + 'source' => PluginUpdateIgnore::SOURCE_IDENTITY_MISMATCH, + 'failure_count' => null, + 'last_error' => $reason, + 'client_visible' => false, + 'note' => mb_strimwidth('Held by the nightly loop: '.$reason, 0, 255, '…'), + 'ignored_at' => Carbon::now(), + 'ignored_by_user_id' => null, + ], + ); + }); + } catch (\Throwable $e) { + Log::error('plugin_update.theme_identity_record_failed', [ + 'site_id' => $site->id, + 'slug' => $t['slug'], + 'batch_id' => $batchId, + 'error' => $e->getMessage(), + ]); + + return; + } + + app(ActionLogger::class)->record( + actionType: ActionLog::TYPE_THEME_UPDATE_REFUSED, + summary: sprintf('Theme update held back: %s %s → %s does not look like the installed theme', $name, $t['before_version'] ?: '?', $t['target_version'] ?: '?'), + site: $site, + target: $t['slug'], + details: [ + 'slug' => $t['slug'], + 'name' => $t['name'], + 'from_version' => $t['before_version'], + 'to_version' => $t['target_version'], + 'reason_code' => ThemeIdentityGuard::REASON_CODE, + 'reason' => $reason, + 'batch_id' => $batchId, + ], + ok: true, + actor: 'auto', + ); + + // Same gate as the other nightly alerts: inactive sites stay quiet. + if ($site->is_inactive) { + return; + } + + try { + $title = sprintf(':no_entry: Held back a theme update on %s: %s', $site->domain, $name); + app(ChatNotifier::class)->send($title, [[ + 'fallback' => $title, + 'color' => '#f59e0b', + 'title' => $title, + 'text' => $reason.' Automatic updates for this theme are now held on this site. If the theme really is the one on offer, unignore it on the site\'s Updates tab and update it by hand.', + 'fields' => [ + ['title' => 'Site', 'value' => $site->domain, 'short' => true], + ['title' => 'Offered', 'value' => sprintf('%s → %s', $t['before_version'] ?: '?', $t['target_version'] ?: '?'), 'short' => true], + ], + ]]); + } catch (\Throwable $e) { + Log::warning('chat_notifier.theme_update_held_back_failed', [ + 'site_id' => $site->id, + 'error' => $e->getMessage(), + ]); + } + } + /** * @return Collection */ @@ -302,7 +606,12 @@ private function pendingThemes(Site $site): array } /** - * @return array slug => true map (for fast lookup) + * Plugins on this site whose installed version has a known + * vulnerability, keyed by directory slug (the matcher's plugin_slug). + * The value is the highest patched_in among those advisories that + * have a fix newer than the installed version, or null when none do. + * + * @return array directory slug => highest patched_in */ private function vulnerableSlugsForSite(Site $site, PluginVulnerabilityMatcher $matcher): array { @@ -310,9 +619,19 @@ private function vulnerableSlugsForSite(Site $site, PluginVulnerabilityMatcher $ try { foreach ($matcher->forSite($site) as $row) { $slug = (string) ($row['plugin_slug'] ?? ''); - if ($slug !== '') { - $hits[$slug] = true; + if ($slug === '') { + continue; + } + + $highest = $hits[$slug] ?? null; + $patchedIn = ($row['patch_available'] ?? false) + ? trim((string) ($row['vulnerability']->patched_in ?? '')) + : ''; + if ($patchedIn !== '' && ($highest === null || version_compare($patchedIn, $highest, '>'))) { + $highest = $patchedIn; } + + $hits[$slug] = $highest; } } catch (\Throwable $e) { // Vuln matcher failure is not fatal for the auto-update loop — @@ -351,7 +670,20 @@ private function stampEligibleSites(?string $siteFilter): void } /** - * Drop tuples whose (site, plugin) is in PluginUpdateIgnore. + * Drop tuples whose (site, kind, slug) is in PluginUpdateIgnore. + * + * Manual ignores always win. An auto-failure pause (the slug failed the + * nightly loop N times in a row) is let through, and the tuple tagged + * with why, when either: + * - the offered version differs from the version last tried (a new + * release since the pause). One attempt per new version: queueing + * the retry records it as the last target, however the job ends. + * - the installed version is vulnerable and the offered version is + * at or above the highest patched_in, so the update is the fix. A + * fix version not tried yet is tried at once; one that was tried is + * retried at most once every SECURITY_RETRY_BACKOFF_DAYS, and never + * when it last failed for a licence or offer reason (an operator + * has to sort that out first). * * @param list> $tuples * @return list> @@ -366,22 +698,137 @@ private function filterIgnored(array $tuples): array // in principle share a slug string, and an ignore entry for one // kind must never suppress the other. $triples = collect($tuples)->map(fn ($t) => [$t['site']->id, $t['kind'], $t['slug']])->all(); - $ignored = PluginUpdateIgnore::query() - ->where(function ($q) use ($triples) { - foreach ($triples as [$siteId, $kind, $slug]) { - $q->orWhere(function ($qq) use ($siteId, $kind, $slug) { - $qq->where('site_id', $siteId)->where('target_kind', $kind)->where('target_slug', $slug); - }); - } - }) - ->get(['site_id', 'target_kind', 'target_slug']) - ->mapWithKeys(fn ($row) => ["{$row->target_kind}:{$row->site_id}:{$row->target_slug}" => true]) - ->all(); + $ignores = PluginUpdateIgnore::query() + ->where(fn ($q) => $this->whereAnyTriple($q, $triples)) + ->get(['site_id', 'target_kind', 'target_slug', 'source']) + ->keyBy(fn ($row) => "{$row->target_kind}:{$row->site_id}:{$row->target_slug}"); - return array_values(array_filter( - $tuples, - fn ($t) => ! isset($ignored["{$t['kind']}:{$t['site']->id}:{$t['slug']}"]), - )); + if ($ignores->isEmpty()) { + return $tuples; + } + + $autoPaused = $ignores->filter(fn (PluginUpdateIgnore $row) => $row->isAutoFailure()); + $streaks = $autoPaused->isEmpty() + ? collect() + : PluginUpdateFailureStreak::query() + ->where(fn ($q) => $this->whereAnyTriple( + $q, + $autoPaused->map(fn ($row) => [$row->site_id, $row->target_kind, $row->target_slug])->values()->all(), + )) + ->get(['site_id', 'target_kind', 'target_slug', 'last_target_version', 'reason_code', 'last_failed_at', 'last_retry_queued_at']) + ->keyBy(fn ($row) => "{$row->target_kind}:{$row->site_id}:{$row->target_slug}"); + + $kept = []; + foreach ($tuples as $t) { + $key = "{$t['kind']}:{$t['site']->id}:{$t['slug']}"; + $ignore = $ignores->get($key); + if ($ignore === null) { + $kept[] = $t; + + continue; + } + + if (! $ignore->isAutoFailure()) { + continue; + } + + $bypass = $this->autoPauseBypassReason($t, $streaks->get($key)); + if ($bypass !== null) { + $t['pause_bypass'] = $bypass; + $kept[] = $t; + } + } + + return $kept; + } + + /** + * Why an auto-failure pause should not hold this tuple back tonight, + * or null to keep it paused. + * + * @param array $tuple + */ + private function autoPauseBypassReason(array $tuple, ?PluginUpdateFailureStreak $streak): ?string + { + $offered = trim((string) ($tuple['target_version'] ?? '')); + if ($offered === '') { + return null; + } + + // Without a recorded version there is nothing to compare against, + // so a new release can't be told apart and the pause stands. + $triedVersion = trim((string) ($streak?->last_target_version ?? '')); + $isNewVersion = $triedVersion !== '' && $offered !== $triedVersion; + + $patchedIn = (string) ($tuple['patched_in'] ?? ''); + if ($tuple['is_vulnerable'] && $patchedIn !== '' && version_compare($offered, $patchedIn, '>=')) { + if ($isNewVersion) { + return self::BYPASS_SECURITY_FIX; + } + + if (in_array($streak?->reason_code, self::OPERATOR_ONLY_REASONS, true)) { + return null; + } + + $lastTried = collect([$streak?->last_failed_at, $streak?->last_retry_queued_at])->filter()->max(); + + return $lastTried === null || $lastTried->lessThanOrEqualTo(Carbon::now()->subDays(self::SECURITY_RETRY_BACKOFF_DAYS)) + ? self::BYPASS_SECURITY_FIX + : null; + } + + return $isNewVersion ? self::BYPASS_NEW_VERSION : null; + } + + /** + * The first fleet-wide nightly batch already queued during the current + * local (LOCAL_TIMEZONE) calendar day, or null. Looks at created_at + * rather than the date in the batch id, so it is right on 23- and + * 25-hour DST days and for batches named before the id used the local + * date. --site batches are excluded so an operator's single-site run + * never stops the fleet's nightly. + */ + private function fleetBatchForLocalDate(): ?string + { + $appTimezone = (string) config('app.timezone'); + $localStart = Carbon::now(self::LOCAL_TIMEZONE)->startOfDay(); + $localEnd = $localStart->copy()->addDay(); + + $batchId = PluginUpdateJob::query() + ->where('batch_id', 'like', self::BATCH_PREFIX.'%') + ->where('batch_id', 'not like', self::BATCH_PREFIX.'%-'.self::SITE_BATCH_MARKER.'%') + ->where('created_at', '>=', $localStart->copy()->setTimezone($appTimezone)) + ->where('created_at', '<', $localEnd->copy()->setTimezone($appTimezone)) + ->orderBy('id') + ->value('batch_id'); + + return is_string($batchId) ? $batchId : null; + } + + /** + * Directory part of a Companion plugin slug ("dir/file.php" → "dir"); + * a single-file plugin ("hello.php") is returned as-is. Same rule as + * PluginVulnerabilityMatcher. + */ + private static function directorySlug(string $slug): string + { + $dir = strtok($slug, '/'); + + return $dir === false ? $slug : $dir; + } + + /** + * OR together one (site_id, target_kind, target_slug) match per triple. + * + * @param list $triples + */ + private function whereAnyTriple(QueryBuilder $query, array $triples): void + { + foreach ($triples as [$siteId, $kind, $slug]) { + $query->orWhere(function ($qq) use ($siteId, $kind, $slug) { + $qq->where('site_id', $siteId)->where('target_kind', $kind)->where('target_slug', $slug); + }); + } } /** diff --git a/app/Console/Commands/RunPerformanceScans.php b/app/Console/Commands/RunPerformanceScans.php index 6a0a0c71..b782c479 100644 --- a/app/Console/Commands/RunPerformanceScans.php +++ b/app/Console/Commands/RunPerformanceScans.php @@ -8,6 +8,7 @@ use App\Services\Performance\PerformanceScanRecorder; use App\Services\Performance\PerformanceScanResult; use App\Support\Settings; +use Carbon\CarbonInterface; use Illuminate\Console\Command; use Illuminate\Database\Eloquent\Collection; use Modules\Core\Contracts\HostingProvider; @@ -43,11 +44,18 @@ class RunPerformanceScans extends Command {--strategy=mobile : "mobile" or "desktop"} {--include-unavailable : Include sites currently marked psi_unavailable_at — useful when manually probing whether either engine has recovered} {--engine= : Force a specific engine ("gtmetrix" or "psi"). Default: gtmetrix primary, psi fallback.} - {--weekly-rotation : Scan only tonight\'s 1/7th slice of the fleet (sites ordered by domain, index % 7 == UTC weekday), so every site gets one scan per week within the GTmetrix daily credit budget. Ignored when --site is given.} + {--weekly-rotation : Scan only tonight\'s 1/7th slice of the fleet (each site has a fixed weekday, crc32(domain) % 7 == UTC weekday), so every site gets one scan per week within the GTmetrix daily credit budget. In the last 7 days of a month, sites with no scan that month are added within the same budget. Ignored when --site is given.} {--host-reports : Only sites whose host runs its own Lighthouse report (Pressable). Reading it is free, so these are read every night instead of joining the rotation. Ignored when --site is given.}'; protected $description = 'Daily performance scan per care-plan site (GTmetrix primary, PSI fallback). Records performance score, Core Web Vitals, page weight.'; + /** + * Month-end catch-up window: on each of a month's last this-many nights + * the rotation also takes care-plan sites with no OK scan that month. + * Seven, so every weekday's bucket comes round once inside the window. + */ + public const CATCH_UP_DAYS = 7; + public function handle( GtmetrixClient $gtmetrix, PageSpeedInsightsClient $psi, @@ -276,28 +284,124 @@ private function targetSites() $sites = $sites->reject($hasHostReport)->values(); } - // Weekly rotation: slice the fleet into 7 near-equal buckets by - // domain-sorted position and scan only tonight's bucket. GTmetrix - // (Core tier) refills 10 API credits daily at ~04:27 UTC and credits - // do NOT accumulate, so a full-fleet nightly run exhausted the day's - // credits after ~10 sites and silently pushed the rest onto the PSI - // fallback (mobile-throttled scores that read 30-60 where GTmetrix - // desktop reads 70-95). ~75 sites / 7 nights ≈ 11 per night keeps - // (nearly) every scan on the primary engine. Position-based - // bucketing keeps bucket sizes even at the cost of a site's scan - // weekday shifting when sites are added/removed — fine, since - // scores are compared week-over-week either way. + // Weekly rotation: split the fleet into 7 buckets and scan only + // tonight's. GTmetrix (Core tier) refills 10 API credits daily at + // ~04:27 UTC and credits do NOT accumulate, so a full-fleet nightly + // run exhausted the day's credits after ~10 sites and silently pushed + // the rest onto the PSI fallback (mobile-throttled scores that read + // 30-60 where GTmetrix desktop reads 70-95). ~75 sites / 7 nights ≈ 11 + // per night keeps (nearly) every scan on the primary engine. + // + // Each site's bucket comes from its own domain (rotationBucket), not + // its position in the list. Position-based buckets moved every site's + // weekday whenever a site was enrolled or removed, so some sites + // went more than a week without a scan and others were scanned twice. if ($this->option('weekly-rotation')) { $day = now('UTC')->dayOfWeek; + $tonight = $sites->filter(fn (Site $site) => self::rotationBucket($site) === $day)->values(); - return $sites->values() - ->filter(fn (Site $site, int $i) => $i % 7 === $day) - ->values(); + return $this->withMonthEndCatchUp($sites, $tonight); } return $sites; } + /** + * A site's weekday in the weekly rotation (0 = Sunday, like Carbon's + * dayOfWeek). Depends on the domain alone, so enrolling or removing + * other sites never moves it. + */ + public static function rotationBucket(Site $site): int + { + return crc32(strtolower((string) $site->domain)) % 7; + } + + /** + * Month-end catch-up for the weekly rotation. A client report only shows + * a speed test run inside its month, and a site can miss its weekday: the + * scan failed, or the site came under care after its slot had passed. On + * each of the month's last CATCH_UP_DAYS nights, rotation sites with no + * OK scan since the 1st join tonight's run, within the daily GTmetrix + * credit budget: + * + * - credits tonight's bucket leaves free go to those sites, the ones + * whose own weekday won't come round again this month first; + * - if those still don't fit, tonight's sites that already have a scan + * this month give up their slot (most recently scanned first). Their + * report is covered and they're scanned again next week; + * - anything left waits for the next night. It is never pushed onto the + * PSI fallback, whose mobile scores read far lower than GTmetrix's. + * + * Credits are spent in run order, so sites with no scan this month run + * before those that already have one. + * + * @param Collection $eligible every site in the rotation + * @param Collection $tonight tonight's bucket + * @return Collection + */ + private function withMonthEndCatchUp(Collection $eligible, Collection $tonight): Collection + { + $now = now('UTC'); + $nightsLeft = $now->daysInMonth - $now->day; // after tonight + if ($nightsLeft >= self::CATCH_UP_DAYS || $eligible->isEmpty()) { + return $tonight; + } + + $lastOk = $this->lastOkScanSince($eligible, $now->copy()->startOfMonth()); + $candidates = $eligible + ->reject(fn (Site $site) => isset($lastOk[$site->id]) || $tonight->contains($site)) + ->values(); + if ($candidates->isEmpty()) { + return $tonight; + } + + // A candidate isn't in tonight's bucket, so its weekday is 1-6 nights + // away; it still gets its own slot this month if that's within the + // nights left. + [$slotLeft, $noSlotLeft] = $candidates->partition( + fn (Site $site) => (self::rotationBucket($site) - $now->dayOfWeek + 7) % 7 <= $nightsLeft + ); + $noSlotLeft = $noSlotLeft->values(); + [$covered, $uncovered] = $tonight->partition(fn (Site $site) => isset($lastOk[$site->id])); + + $budget = max(0, (int) config('clockwork.gtmetrix.daily_credits', 10)); + $free = max(0, $budget - $tonight->count()); + $room = max(0, $budget - $uncovered->count()); + + $added = $noSlotLeft->concat($slotLeft)->take($free)->values(); + $swappedIn = $noSlotLeft->slice($added->count())->take(max(0, $room - $added->count()))->values(); + $deferred = $covered->sortByDesc(fn (Site $site) => $lastOk[$site->id])->take($swappedIn->count()); + $keptCovered = $covered->reject(fn (Site $site) => $deferred->contains($site)); + $waiting = $candidates->count() - $added->count() - $swappedIn->count(); + + $this->line(sprintf( + 'Month-end catch-up: %d site(s) with no scan this month added, %d already-scanned site(s) moved to next week to make room, %d waiting for the next night.', + $added->count() + $swappedIn->count(), $deferred->count(), $waiting, + )); + + return $uncovered->concat($added)->concat($swappedIn)->concat($keptCovered)->values(); + } + + /** + * Latest OK scan per site since $since, keyed by site id. Dated the way + * the client report dates a scan (the host's own run time when it has + * one), so "covered" here means covered in the report. + * + * @param Collection $sites + * @return array + */ + private function lastOkScanSince(Collection $sites, CarbonInterface $since): array + { + return SitePerformanceScan::query() + ->whereIn('site_id', $sites->modelKeys()) + ->where('status', SitePerformanceScan::STATUS_OK) + ->whereRaw('COALESCE(source_generated_at, scanned_at) >= ?', [$since]) + ->groupBy('site_id') + ->selectRaw('site_id, MAX(COALESCE(source_generated_at, scanned_at)) AS last_ok_at') + ->pluck('last_ok_at', 'site_id') + ->all(); + } + /** * After 3 consecutive failures (across either engine, since the row's * fallback chain already collapses to one row per site/strategy), flip diff --git a/app/Console/Commands/SuppressMalwarePath.php b/app/Console/Commands/SuppressMalwarePath.php index 4d877ca7..301ce8c9 100644 --- a/app/Console/Commands/SuppressMalwarePath.php +++ b/app/Console/Commands/SuppressMalwarePath.php @@ -3,28 +3,50 @@ namespace App\Console\Commands; use App\Models\Site; +use App\Services\Security\CompanionMalwareScanner; use Illuminate\Console\Command; +/** + * Per-site, per-kind suppression list for review-tier malware findings. + * + * Paths are stored normalized (CompanionMalwareScanner::normalizeFindingPath), + * i.e. relative to the WordPress root, so pasting the absolute path a host + * reported and typing the relative one store the same entry. The scanner + * normalizes stored entries again when comparing, so lists written before + * normalization existed keep matching. + */ class SuppressMalwarePath extends Command { protected $signature = 'clockwork:suppress-malware-path {--site= : Site ID or domain (required)} - {--path= : Relative path to suppress, e.g. wp-content/uploads/alm_templates/default.php (required)} + {--path= : Path to suppress, as shown in the finding, e.g. wp-content/uploads/alm_templates/default.php. An absolute path under the site\'s docroot is stored relative to the WordPress root (required)} + {--kind=php_in_uploads : Finding kind to suppress for this path: php_in_uploads or preg_replace_e_modifier} {--unsuppress : Remove the path from the suppression list instead of adding it}'; - protected $description = 'Add or remove a php_in_uploads path from the per-site malware scan suppression list.'; + protected $description = 'Add or remove a path from the per-site malware scan suppression list for one review-tier finding kind (php_in_uploads by default).'; public function handle(): int { $siteId = $this->option('site'); - $path = trim((string) $this->option('path')); + $rawPath = trim((string) $this->option('path')); + $kind = trim((string) $this->option('kind')); - if (! $siteId || $path === '') { + if (! $siteId || $rawPath === '') { $this->error('Both --site and --path are required.'); return self::FAILURE; } + if (! in_array($kind, CompanionMalwareScanner::SUPPRESSIBLE_KINDS, true)) { + $this->error(sprintf( + "Kind '%s' cannot be suppressed. Suppressible kinds: %s.", + $kind, + implode(', ', CompanionMalwareScanner::SUPPRESSIBLE_KINDS), + )); + + return self::FAILURE; + } + $site = Site::where('id', $siteId)->orWhere('domain', $siteId)->first(); if (! $site) { $this->error("Site not found: {$siteId}"); @@ -32,32 +54,44 @@ public function handle(): int return self::FAILURE; } + $normalize = CompanionMalwareScanner::findingPathNormalizer($site); + $path = $normalize($rawPath); + if ($path === '') { + $this->error("'{$rawPath}' is the site's docroot, not a file path."); + + return self::FAILURE; + } + + $stateKey = CompanionMalwareScanner::suppressionStateKey($kind); $state = is_array($site->companion_finding_state) ? $site->companion_finding_state : []; - $suppressed = array_values((array) ($state['php_in_uploads_suppressed'] ?? [])); + $suppressed = array_values((array) ($state[$stateKey] ?? [])); + // Stored entries may predate normalization (absolute, or with + // backslashes), so compare on the normalized form. + $matches = fn ($entry) => is_string($entry) && $normalize($entry) === $path; if ($this->option('unsuppress')) { - if (! in_array($path, $suppressed, true)) { - $this->warn("{$site->domain}: '{$path}' was not in the suppression list."); + if (! collect($suppressed)->contains($matches)) { + $this->warn("{$site->domain}: '{$path}' was not in the suppression list for {$kind}."); return self::SUCCESS; } - $suppressed = array_values(array_filter($suppressed, fn ($p) => $p !== $path)); - $state['php_in_uploads_suppressed'] = $suppressed; + $suppressed = array_values(array_filter($suppressed, fn ($entry) => ! $matches($entry))); + $state[$stateKey] = $suppressed; $site->forceFill(['companion_finding_state' => $state])->save(); - $this->info("{$site->domain}: removed '{$path}' from php_in_uploads suppression list."); + $this->info("{$site->domain}: removed '{$path}' from the {$kind} suppression list."); } else { - if (in_array($path, $suppressed, true)) { - $this->warn("{$site->domain}: '{$path}' is already suppressed."); + if (collect($suppressed)->contains($matches)) { + $this->warn("{$site->domain}: '{$path}' is already suppressed for {$kind}."); return self::SUCCESS; } $suppressed[] = $path; - $state['php_in_uploads_suppressed'] = $suppressed; + $state[$stateKey] = $suppressed; $site->forceFill(['companion_finding_state' => $state])->save(); - $this->info("{$site->domain}: suppressed '{$path}'."); + $this->info("{$site->domain}: suppressed {$kind} for '{$path}'."); } - $this->line('Current suppression list:'); + $this->line("Current {$kind} suppression list:"); foreach ($suppressed as $p) { $this->line(" - {$p}"); } diff --git a/app/Console/Commands/VerifyWpCoreChecksums.php b/app/Console/Commands/VerifyWpCoreChecksums.php index 75044443..088833f0 100644 --- a/app/Console/Commands/VerifyWpCoreChecksums.php +++ b/app/Console/Commands/VerifyWpCoreChecksums.php @@ -8,13 +8,16 @@ use App\Support\Settings; use Illuminate\Console\Command; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Log; /** * Server-side counterpart to clockwork:scan-sitecheck — runs `wp core * verify-checksums` against every WordPress site, capturing any modified, * missing, or unexpectedly-present core files. Transport is per-site: SSH * for SpinupWP, Pressable's async command API for Pressable — see - * WpCoreChecksumVerifier for the branch. + * WpCoreChecksumVerifier for the branch. Sites with no transport at all + * (WpCoreChecksumVerifier::hasTransport) are skipped and counted, not + * recorded as failures. * * Catches the case Sucuri can't see by design: PHP shells and base64 * backdoors that aren't rendered into the public HTML. Anything dropped @@ -37,6 +40,24 @@ public function handle(WpCoreChecksumVerifier $verifier, SecurityScanRecorder $r return self::SUCCESS; } + // A site with no way to run wp-cli (a standalone site on a host we + // have no shell access to) can never be checked. Recording a failed + // row for it every night only turned the job red and buried real + // failures, so it's skipped and counted instead. + [$sites, $noTransport] = $sites->partition(fn (Site $site) => $verifier->hasTransport($site)); + $skipped = $noTransport->count(); + if ($skipped > 0) { + Log::info('verify-wp-core-checksums: skipped sites with no checksum transport', [ + 'count' => $skipped, + 'site_ids' => $noTransport->pluck('id')->values()->all(), + ]); + if ($this->option('site') || $this->getOutput()->isVerbose()) { + foreach ($noTransport as $site) { + $this->line(" - {$site->domain}: skipped, no SSH, Pressable or host command runner to run wp-cli"); + } + } + } + $clean = 0; $issues = 0; $failed = 0; @@ -75,7 +96,7 @@ public function handle(WpCoreChecksumVerifier $verifier, SecurityScanRecorder $r $bar->finish(); $this->line(''); - $this->info("Done. clean={$clean}, issues={$issues}, failed={$failed}"); + $this->info("Done. clean={$clean}, issues={$issues}, failed={$failed}, skipped={$skipped}"); $settings->put('security_scans.checksums_last_run_at', now()->toIso8601String()); diff --git a/app/Http/Controllers/CompanionSettingsController.php b/app/Http/Controllers/CompanionSettingsController.php index 6a892467..acb86219 100644 --- a/app/Http/Controllers/CompanionSettingsController.php +++ b/app/Http/Controllers/CompanionSettingsController.php @@ -7,6 +7,7 @@ use App\Models\Site; use App\Services\Companion\CompanionBrandingManager; use App\Services\Process\BackgroundArtisan; +use Closure; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -139,7 +140,7 @@ public function update(Request $request, CompanionBrandingManager $brandingManag 'enabled' => 'nullable|boolean', 'company_name' => 'nullable|string|max:120', 'support_email' => 'nullable|email|max:120', - 'support_url' => 'nullable|string|max:255', + 'support_url' => self::publicUrlRules(), 'primary_color' => CompanionBrandingManager::hexColorRules(), 'accent_color' => CompanionBrandingManager::hexColorRules(), 'footer_text' => 'nullable|string|max:500', @@ -209,9 +210,9 @@ public function update(Request $request, CompanionBrandingManager $brandingManag $validated = $request->validate([ 'enabled' => 'nullable|boolean', 'company_name' => 'nullable|string|max:120', - 'company_url' => 'nullable|string|max:255', + 'company_url' => self::publicUrlRules(), 'support_email' => 'nullable|email|max:120', - 'support_url' => 'nullable|string|max:255', + 'support_url' => self::publicUrlRules(), 'plugin_name' => 'nullable|string|max:120', 'plugin_description' => 'nullable|string|max:500', 'menu_title' => 'nullable|string|max:60', @@ -419,4 +420,59 @@ public function sendTestEmail(Request $request, CompanionBrandingManager $brandi ], 500); } } + + /** + * Rules for an address clients are sent to (agency website, support + * portal). Blank is allowed. A value must be a public http(s) URL: not + * localhost, a private or reserved IP, an internal-only name, or + * Control's own URL (APP_URL), which runs on a private host clients + * can't reach. + * + * @return list + */ + private static function publicUrlRules(): array + { + return [ + 'nullable', + 'string', + 'max:255', + 'url:http,https', + function (string $attribute, mixed $value, Closure $fail): void { + $host = is_string($value) ? parse_url($value, PHP_URL_HOST) : null; + + // Not a URL at all: the url rule already says so. + if (! is_string($host) || $host === '') { + return; + } + + if (! self::isPublicHost($host)) { + $fail('The :attribute must be a public web address clients can reach, not localhost, a private network address or this app\'s own URL.'); + } + }, + ]; + } + + private static function isPublicHost(string $host): bool + { + $host = rtrim(strtolower(trim($host, '[]')), '.'); + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false; + } + + // Single-label names (localhost, intranet hosts) don't resolve publicly. + if (! str_contains($host, '.')) { + return false; + } + + foreach (['.localhost', '.local', '.internal', '.lan', '.home.arpa', '.test', '.invalid'] as $suffix) { + if (str_ends_with($host, $suffix)) { + return false; + } + } + + $appHost = parse_url((string) config('app.url'), PHP_URL_HOST); + + return ! is_string($appHost) || $host !== rtrim(strtolower($appHost), '.'); + } } diff --git a/app/Http/Controllers/MonitoringController.php b/app/Http/Controllers/MonitoringController.php index 8c03a7f7..ce370c0b 100644 --- a/app/Http/Controllers/MonitoringController.php +++ b/app/Http/Controllers/MonitoringController.php @@ -43,6 +43,10 @@ class MonitoringController extends Controller public const DEFAULT_FAILURE_THRESHOLD = UptimeStateUpdater::FAILURE_THRESHOLD_FOR_DOWN; + public const SETTING_PROBE_BLOCKED_ALERT_HOURS = UptimeStateUpdater::SETTING_PROBE_BLOCKED_ALERT_HOURS; + + public const DEFAULT_PROBE_BLOCKED_ALERT_HOURS = UptimeStateUpdater::PROBE_BLOCKED_ALERT_HOURS; + public function index(UptimeStatsCalculator $calc): View { // Sites whose servers aren't ignored AND have monitoring enabled — @@ -64,7 +68,8 @@ public function index(UptimeStatsCalculator $calc): View $excused = $this->siteOutageIsExcused($site, $latestDownEvents); $tier = match (true) { $site->uptime_state === 'down' && $site->uptime_ignored_at === null && ! $excused => 1, - $site->uptime_state === 'unknown' => 2, + // Rate-limiting the probe: like unknown, we can't see it. + $site->uptime_state === 'unknown', $site->uptime_state === Site::UPTIME_STATE_PROBE_BLOCKED => 2, $site->uptime_state === 'down' && $excused => 3, $site->uptime_state === 'down' => 4, $site->uptime_state === 'maintenance' => 5, @@ -94,6 +99,7 @@ public function index(UptimeStatsCalculator $calc): View $currentlyUp = $sites->where('uptime_state', 'up')->count(); $currentlyMaintenance = $sites->where('uptime_state', 'maintenance')->count(); $unknown = $sites->where('uptime_state', 'unknown')->count(); + $currentlyProbeBlocked = $sites->where('uptime_state', Site::UPTIME_STATE_PROBE_BLOCKED)->count(); $currentlyIgnored = $sites->whereNotNull('uptime_ignored_at')->count(); // Fleet aggregate uptime: arithmetic mean of per-site uptimes, @@ -123,6 +129,7 @@ public function index(UptimeStatsCalculator $calc): View 'currentlyUp', 'currentlyMaintenance', 'unknown', + 'currentlyProbeBlocked', 'currentlyIgnored', 'avg7d', 'avg30d', @@ -136,6 +143,7 @@ public function settings(Settings $settings, DomainIgnoreList $ignoreList): View { $intervalMin = (int) $settings->get(self::SETTING_INTERVAL_MIN, self::DEFAULT_INTERVAL_MIN); $failureThreshold = (int) $settings->get(self::SETTING_FAILURE_THRESHOLD, self::DEFAULT_FAILURE_THRESHOLD); + $probeBlockedAlertHours = (int) $settings->get(self::SETTING_PROBE_BLOCKED_ALERT_HOURS, self::DEFAULT_PROBE_BLOCKED_ALERT_HOURS); // Sites toggled off (per-site override). Surface so the operator can quickly // re-enable from the same screen as the global settings. @@ -164,6 +172,7 @@ public function settings(Settings $settings, DomainIgnoreList $ignoreList): View return view('monitoring.settings', compact( 'intervalMin', 'failureThreshold', + 'probeBlockedAlertHours', 'disabledSites', 'schedulerHeartbeat', 'ignoredPatterns', @@ -203,6 +212,8 @@ public function updateSettings(Request $request, Settings $settings): RedirectRe $validated = $request->validate([ 'interval_minutes' => 'required|integer|in:1,5,10,15', 'failure_threshold' => 'required|integer|min:1|max:6', + // Optional so a form posted without it keeps the stored value. + 'probe_blocked_alert_hours' => 'sometimes|integer|min:1|max:168', 'ignored_domains' => 'nullable|string|max:10000', ]); @@ -217,11 +228,13 @@ public function updateSettings(Request $request, Settings $settings): RedirectRe ]); } - $settings->putMany([ + $settings->putMany(array_merge([ self::SETTING_INTERVAL_MIN => $validated['interval_minutes'], self::SETTING_FAILURE_THRESHOLD => $validated['failure_threshold'], DomainIgnoreList::SETTING_KEY => $parsed['patterns'], - ]); + ], isset($validated['probe_blocked_alert_hours']) ? [ + self::SETTING_PROBE_BLOCKED_ALERT_HOURS => (int) $validated['probe_blocked_alert_hours'], + ] : [])); return redirect() ->route('monitoring.settings') diff --git a/app/Http/Controllers/Settings/BackupRelaySettingsController.php b/app/Http/Controllers/Settings/BackupRelaySettingsController.php index 9336046e..c03f5ce7 100644 --- a/app/Http/Controllers/Settings/BackupRelaySettingsController.php +++ b/app/Http/Controllers/Settings/BackupRelaySettingsController.php @@ -16,6 +16,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\View\View; use Modules\BackupRelay\Services\BackupArchiveEnumerator; +use Modules\BackupRelay\Services\CompanionBackupRelayAdapter; use Modules\Core\Contracts\HostingProvider; use Throwable; @@ -126,6 +127,22 @@ public function update(Request $request, Settings $settings): RedirectResponse|J ]); $site = Site::query()->findOrFail($validated['site_id']); + + if ((bool) $validated['enabled'] && ! $site->backup_relay_enabled + && ($blocked = CompanionBackupRelayAdapter::enableBlockedReason($site)) !== null) { + $msg = "Backup relay was not enabled for {$site->domain}: {$blocked}"; + + if ($request->wantsJson() || $request->ajax()) { + return response()->json([ + 'success' => false, + 'message' => $msg, + 'enabled' => false, + ], 422); + } + + return back()->with('warning', $msg); + } + $site->update([ 'backup_relay_enabled' => (bool) $validated['enabled'], ]); @@ -158,10 +175,26 @@ public function update(Request $request, Settings $settings): RedirectResponse|J 'sites.*' => 'integer|exists:sites,id', ]); - $enabledIds = $validated['sites'] ?? []; + $enabledIds = array_map('intval', $validated['sites'] ?? []); - Site::query() + // Only sites being switched on are checked. A site that is already on + // stays on, so its failing runs keep showing up instead of going quiet. + $blocked = Site::query() ->whereIn('id', $enabledIds) + ->where('backup_relay_enabled', false) + ->get() + ->map(fn (Site $site) => [ + 'site_id' => $site->id, + 'domain' => $site->domain, + 'reason' => CompanionBackupRelayAdapter::enableBlockedReason($site), + ]) + ->filter(fn (array $row) => $row['reason'] !== null) + ->values(); + + $toEnable = array_values(array_diff($enabledIds, $blocked->pluck('site_id')->all())); + + Site::query() + ->whereIn('id', $toEnable) ->update(['backup_relay_enabled' => true]); Site::query() @@ -177,17 +210,23 @@ public function update(Request $request, Settings $settings): RedirectResponse|J } } - $msg = 'Backup relay settings updated for '.count($enabledIds).' site(s).'; + $msg = 'Backup relay settings updated for '.count($toEnable).' site(s).'; + $blockedMsg = $blocked->isEmpty() ? null : 'Not enabled: '.$blocked + ->map(fn (array $row) => "{$row['domain']} ({$row['reason']})") + ->implode('; '); if ($request->wantsJson() || $request->ajax()) { return response()->json([ 'success' => true, - 'message' => $msg, - 'enabled_count' => count($enabledIds), + 'message' => $blockedMsg === null ? $msg : "{$msg} {$blockedMsg}", + 'enabled_count' => count($toEnable), + 'blocked' => $blocked->all(), ]); } - return back()->with('status', $msg); + $redirect = back()->with('status', $msg); + + return $blockedMsg === null ? $redirect : $redirect->with('warning', $blockedMsg); } public function runNow(Request $request, Settings $settings): RedirectResponse|JsonResponse diff --git a/app/Http/Controllers/SetupController.php b/app/Http/Controllers/SetupController.php index 0ee2afa9..3ef8d4ab 100644 --- a/app/Http/Controllers/SetupController.php +++ b/app/Http/Controllers/SetupController.php @@ -175,7 +175,7 @@ public function step1(?ModuleStateResolver $resolver = null): View 'title' => 'Maintenance & QA', 'badge' => 'Care Plans', 'badge_color' => 'purple', - 'description' => 'Automated synthetic testing and deliverability QA for client care plans. Probes and exercises live contact forms (Gravity Forms, WPForms, Contact Form 7, Fluent Forms) on your WordPress sites with end-to-end SMTP deliverability verification, streak tracking, and instant failure alerting.', + 'description' => 'Automated synthetic testing for client care plans. Submits test entries to live contact forms (Gravity Forms, WPForms, Contact Form 7, Fluent Forms) on your WordPress sites to check they still accept messages, with streak tracking and instant failure alerting. Scheduled tests suppress the outgoing email, so they don\'t verify inbox delivery.', 'services' => [], ], 'notifications' => [ diff --git a/app/Http/Controllers/SitesController.php b/app/Http/Controllers/SitesController.php index 5a161b75..da35e202 100644 --- a/app/Http/Controllers/SitesController.php +++ b/app/Http/Controllers/SitesController.php @@ -36,6 +36,7 @@ use App\Services\Uptime\UptimeStateUpdater; use App\Support\CompanionExclusion; use App\Support\FleetSelfIps; +use App\Support\UpgraderOutput; use Carbon\CarbonImmutable; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -50,6 +51,7 @@ use Illuminate\Validation\Rule; use Illuminate\View\View; use Modules\BackupRelay\Services\BackupArchiveEnumerator; +use Modules\BackupRelay\Services\CompanionBackupRelayAdapter; use Modules\ClientReports\Services\ReportRecipientsSyncService; use Modules\Core\Contracts\HostingProvider; use Modules\Core\ModuleStateResolver; @@ -111,7 +113,7 @@ public function index(Request $request): View break; case 'status': - $statusOrder = "CASE sites.uptime_state WHEN 'down' THEN 1 WHEN 'maintenance' THEN 2 WHEN 'unknown' THEN 3 WHEN 'up' THEN 4 ELSE 5 END"; + $statusOrder = "CASE sites.uptime_state WHEN 'down' THEN 1 WHEN 'maintenance' THEN 2 WHEN 'unknown' THEN 3 WHEN 'probe_blocked' THEN 3 WHEN 'up' THEN 4 ELSE 5 END"; $sitesQuery->select('sites.*') ->orderByRaw("{$statusOrder} ".($dir === 'desc' ? 'DESC' : 'ASC')) ->orderBy('sites.domain', 'asc'); @@ -677,9 +679,11 @@ public function recheckUptime(Site $site, UptimeProber $prober, UptimeStateUpdat return response()->json([ 'ok' => true, - 'message' => $probe->succeeded - ? "Up (HTTP {$probe->statusCode})" - : 'Down ('.($probe->statusCode ? "HTTP {$probe->statusCode}" : ($probe->error ?? 'no response')).')', + 'message' => match (true) { + $probe->isProbeBlocked => "Probe blocked (HTTP {$probe->statusCode}) — not counted as down", + $probe->succeeded => "Up (HTTP {$probe->statusCode})", + default => 'Down ('.($probe->statusCode ? "HTTP {$probe->statusCode}" : ($probe->error ?? 'no response')).')', + }, 'state' => $site->uptime_state, 'status_code' => $probe->statusCode, ]); @@ -1565,7 +1569,9 @@ public function updatePlugin(Site $site, Request $request, ActionLogger $logger) } try { - $result = (new ClockworkCompanionClient($site))->updatePlugin($slug); + // Redacted before it is logged or returned: the upgrader's + // download lines carry licence keys and signed URLs. + $result = UpgraderOutput::redact((new ClockworkCompanionClient($site))->updatePlugin($slug)); } catch (Throwable $e) { $logger->record( actionType: ActionLog::TYPE_PLUGIN_UPDATE, @@ -1635,6 +1641,10 @@ public function toggleCarePlan(Site $site, Request $request, ActionLogger $logge details: ['previous' => $previous, 'now' => $enabled, 'override' => true], ); + if ($enabled && ! $previous) { + $this->kickPerformanceBaseline($site); + } + if ($request->expectsJson()) { return response()->json([ 'ok' => true, @@ -2193,6 +2203,9 @@ public function backupsHistory(Site $site, SpacesClient $spaces): JsonResponse 'last_archived_at' => $site->backup_relay_last_archived_at?->toIso8601String(), 'last_size_bytes' => $site->backup_relay_last_size_bytes, 'next_scheduled_at' => $site->backupRelayNextScheduledAt()?->toIso8601String(), + 'last_attempt_at' => $site->backup_relay_last_attempt_at?->toIso8601String(), + 'last_error' => $site->backup_relay_last_error, + 'failing_since' => $site->backup_relay_failing_since?->toIso8601String(), ], ]); } @@ -2341,10 +2354,17 @@ public function store(Request $request): RedirectResponse 'companion_capabilities' => $caps, 'companion_secret' => $secret, 'companion_last_seen_at' => now(), + // The /health handshake above only answers from the Companion or + // Renegade WordPress plugin, so this is a WordPress site. Without + // the flag it sits out every WordPress-only job (core checksums, + // lockout and firewall pulls). + 'is_wordpress' => true, 'is_multisite' => $isMultisite, 'uptime_monitoring_enabled' => true, 'cert_source' => Site::CERT_SOURCE_LIVE_PROBE, - 'backup_relay_enabled' => true, + // Off-site backups need the plugin's /backup/create. Turning + // relay on without it only produces a failed backup every day. + 'backup_relay_enabled' => CompanionBackupRelayAdapter::supportsBackupCreate($caps), 'backup_relay_frequency' => 'daily', // Schema default is paused (opt-in flip). SpinupWP/Pressable // imports unpause on create; standalone enroll must do the same @@ -2407,8 +2427,38 @@ public function store(Request $request): RedirectResponse ); $this->kickEnrollmentSecurityBaseline($site); + $this->kickPerformanceBaseline($site); + + $flash = "Site {$site->domain} successfully connected and enrolled!"; + if (! $site->backup_relay_enabled) { + $flash .= ' Off-site backups were not turned on: '.CompanionBackupRelayAdapter::missingBackupCreateMessage($site); + } - return redirect()->route('sites.show', $site)->with('flash', "Site {$site->domain} successfully connected and enrolled!"); + return redirect()->route('sites.show', $site)->with('flash', $flash); + } + + /** + * One performance scan for a site that just came under care (standalone + * enrolment, or its care plan switched on). The nightly rotation only + * reaches a site on its own weekday, so a site enrolled late in the + * month could otherwise have no speed test for that month's report. + * Its own lock so a failed security baseline can't stop it, and detached + * because a scan takes 15-60s or more. `--site=` bypasses the care-plan + * gate. + */ + private function kickPerformanceBaseline(Site $site): void + { + try { + app(BackgroundArtisan::class)->start( + lockKey: 'performance-baseline:'.$site->id, + commands: ['clockwork:run-performance-scans --site='.$site->id.' --strategy=mobile'], + ttlSeconds: 600, + logBasename: 'performance-baseline', + ); + } catch (Throwable) { + // Non-fatal: the nightly rotation and its month-end catch-up + // still reach the site. + } } /** @@ -2471,6 +2521,25 @@ public function updateBackupRelay(Site $site, Request $request, ActionLogger $lo $previousEnabled = (bool) $site->backup_relay_enabled; $previousFrequency = $site->backup_relay_frequency; + // Refuse only the off -> on switch. A site that is already on keeps + // its cadence editable, and its failures stay visible. + if ((bool) $validated['enabled'] && ! $previousEnabled + && ($blocked = CompanionBackupRelayAdapter::enableBlockedReason($site)) !== null) { + $msg = "Glacier backups for {$site->domain} were not turned on: {$blocked}"; + + if ($request->expectsJson()) { + return response()->json([ + 'ok' => false, + 'message' => $msg, + 'enabled' => false, + 'frequency' => $site->backupRelayFrequency(), + 'domain' => $site->domain, + ], 422); + } + + return back()->with('warning', $msg); + } + $site->forceFill([ 'backup_relay_enabled' => (bool) $validated['enabled'], 'backup_relay_frequency' => $validated['frequency'], diff --git a/app/Http/Controllers/UpdatesController.php b/app/Http/Controllers/UpdatesController.php index 208292db..e53fe6f0 100644 --- a/app/Http/Controllers/UpdatesController.php +++ b/app/Http/Controllers/UpdatesController.php @@ -325,6 +325,7 @@ public function bulkUnignore(Request $request): RedirectResponse ->where('target_slug', $slug) ->update([ 'consecutive_failures' => 0, + ...UpdateFailureStreakRecorder::FS_RUN_RESET, 'ignored_at' => null, 'ignore_id' => null, ]); diff --git a/app/Jobs/AbstractRunUpdate.php b/app/Jobs/AbstractRunUpdate.php index c2837135..c98ec998 100644 --- a/app/Jobs/AbstractRunUpdate.php +++ b/app/Jobs/AbstractRunUpdate.php @@ -8,6 +8,7 @@ use App\Services\Chat\ChatNotifier; use App\Services\Companion\ClockworkCompanionClient; use App\Services\Updates\UpdateFailureStreakRecorder; +use App\Support\UpgraderOutput; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -125,7 +126,14 @@ public function handle(ActionLogger $logger): void } $client = new ClockworkCompanionClient($site); - $result = $this->run($site, $row, $client); + + // Redact before anything is derived from or persisted out of the + // result: the upgrader's "Downloading update from " lines + // carry licence keys and signed download URLs, and everything + // below (messages + error columns, action_log details/summary, + // the Companion activity mirror, the failure streak, chat) reads + // from $result. + $result = UpgraderOutput::redact($this->run($site, $row, $client)); $ok = (bool) ($result['ok'] ?? false); @@ -148,6 +156,16 @@ public function handle(ActionLogger $logger): void // while target_version was 9.0.1. Only applies when we had a // real target_version to compare against (core/plugin/theme; // translations has none, so this never fires for that kind). + // + // Not every stalled no-op is a stale update offer, though. The + // Companion/Renegade versions in the field also report ok=true + // (and upgrade_completed=true) when the WordPress upgrader + // itself failed and logged "Error: …" — "Could not copy file", + // "Could not create directory", "Download failed. Unauthorized", + // a bad zip. Those must surface as what they are (file + // permissions, licence, package), not as "no longer offered", + // or ops chase a premium licence while the real problem is + // file ownership. reason_code records the cause for grouping. $afterVersion = $result['after_version'] ?? null; $beforeVersion = $result['before_version'] ?? $row->before_version; if ( @@ -158,11 +176,38 @@ public function handle(ActionLogger $logger): void && $afterVersion !== $row->target_version ) { $ok = false; - $result['error'] = sprintf( - "WordPress's own update-checker no longer offered this update when the job ran (still on %s, expected %s). Common for premium/licensed plugins whose update source went stale or unreachable between our last check and now. Nothing was changed on the site.", - $afterVersion, - $row->target_version, - ); + $errorLines = UpgraderOutput::errorLines($result['messages'] ?? null); + + if ($errorLines !== []) { + $result['reason_code'] = UpgraderOutput::classify($errorLines); + $result['error'] = sprintf( + '%s (WordPress upgrader error; still on %s, expected %s.)', + implode(' — ', $errorLines), + $afterVersion, + $row->target_version, + ); + // The upgrader stopped with an error and the version did + // not move, so the file swap did not happen whatever + // Companion claimed — there is nothing to verify/repair. + $result['upgrade_completed'] = false; + } else { + $result['reason_code'] = UpgraderOutput::REASON_NOT_OFFERED; + $result['error'] = sprintf( + "WordPress's own update-checker no longer offered this update when the job ran (still on %s, expected %s). Common for premium/licensed plugins whose update source went stale or unreachable between our last check and now. Nothing was changed on the site.", + $afterVersion, + $row->target_version, + ); + } + } elseif (! $ok && ! isset($result['reason_code'])) { + // Companion already reported the failure itself (ok=false, + // its own error text kept as-is). Still record the cause + // when the upgrader output says what it was, so a failure + // reported correctly is grouped the same way as one caught + // by the stalled check above. + $errorLines = UpgraderOutput::errorLines($result['messages'] ?? null); + if ($errorLines !== []) { + $result['reason_code'] = UpgraderOutput::classify($errorLines); + } } // 'upgrade_completed' (Companion >= 1.30.2) tells us the file @@ -228,10 +273,14 @@ public function handle(ActionLogger $logger): void 'messages' => $result['messages'] ?? null, 'repairs' => $result['repairs'] ?? null, 'error' => $ok ? null : (string) ($result['error'] ?? 'unknown error'), + 'reason_code' => $ok ? null : ($result['reason_code'] ?? null), 'completed_at' => now(), ])->save(); - $summary = $this->actionLogSummary($row, $result); + // The summary follows the job's own verdict, not Companion's raw + // `ok`: a stalled no-op or an upgrader error must not be logged + // (and mirrored to wp-admin) as "Plugin updated: 1.0.0 → 1.0.0". + $summary = $this->actionLogSummary($row, ['ok' => $ok] + $result); $repairs = $result['repairs'] ?? []; if (is_array($repairs) && $repairs !== []) { $repairCount = count($repairs); @@ -258,19 +307,22 @@ public function handle(ActionLogger $logger): void ); } catch (Throwable $e) { $caughtException = $e; + // A transport exception can quote the response body, which may + // hold upgrader output — redact it like any other result text. + $exceptionMessage = UpgraderOutput::redactText($e->getMessage()); $row->forceFill([ 'status' => PluginUpdateJob::STATUS_FAILED, - 'error' => $e->getMessage(), + 'error' => $exceptionMessage, 'completed_at' => now(), ])->save(); $logger->record( actionType: $this->actionLogType(), - summary: "Update threw: {$e->getMessage()}", + summary: "Update threw: {$exceptionMessage}", site: $site, target: $this->actionLogTarget($row), ok: false, - error: $e->getMessage(), + error: $exceptionMessage, actor: $row->requested_by_user_id !== null ? "user:{$row->requested_by_user_id}" : 'auto', ); diff --git a/app/Models/ActionLog.php b/app/Models/ActionLog.php index 23c7759f..f79ebce3 100644 --- a/app/Models/ActionLog.php +++ b/app/Models/ActionLog.php @@ -45,6 +45,24 @@ class ActionLog extends Model public const TYPE_TRANSLATIONS_UPDATE = 'translations_update'; + /** + * The nightly loop held back a theme update because the installed + * theme doesn't look like the theme on offer (ThemeIdentityGuard). + */ + public const TYPE_THEME_UPDATE_REFUSED = 'theme_update_refused'; + + /** + * A version change between two Companion snapshots that Control did not + * make (WordPress auto-updates, another updater, someone in wp-admin). + * Written by ObservedUpdateRecorder; details are {slug, name, + * from_version, to_version, observed_at, previous_snapshot_at}. + */ + public const TYPE_PLUGIN_UPDATE_OBSERVED = 'plugin_update_observed'; + + public const TYPE_THEME_UPDATE_OBSERVED = 'theme_update_observed'; + + public const TYPE_CORE_UPDATE_OBSERVED = 'core_update_observed'; + public const TYPE_SSO_LOGIN = 'sso_login'; public const TYPE_COMPANION_INSTALL = 'companion_install'; @@ -158,6 +176,31 @@ class ActionLog extends Model self::TYPE_TRANSLATIONS_UPDATE, ]; + /** + * Updates seen in the snapshots but made by something other than + * Control. Informational only, and deliberately not in UPDATE_TYPES: + * that list means "updates Control ran". + * + * @var array + */ + public const OBSERVED_UPDATE_TYPES = [ + self::TYPE_PLUGIN_UPDATE_OBSERVED, + self::TYPE_THEME_UPDATE_OBSERVED, + self::TYPE_CORE_UPDATE_OBSERVED, + ]; + + /** + * Rows kept in Control only: ActionLogger and the backfill command never + * mirror them to the client's wp-admin activity log, which lists what + * Control did on the site. + * + * @var array + */ + public const LOCAL_ONLY_TYPES = [ + ...self::OBSERVED_UPDATE_TYPES, + self::TYPE_THEME_UPDATE_REFUSED, + ]; + protected $fillable = [ 'site_id', 'server_id', diff --git a/app/Models/PluginUpdateFailureStreak.php b/app/Models/PluginUpdateFailureStreak.php index 1fe4446b..f8288a8f 100644 --- a/app/Models/PluginUpdateFailureStreak.php +++ b/app/Models/PluginUpdateFailureStreak.php @@ -23,7 +23,11 @@ class PluginUpdateFailureStreak extends Model 'last_target_version', 'last_from_version', 'last_error', + 'reason_code', + 'consecutive_fs_failures', + 'fs_streak_started_job_id', 'last_failed_at', + 'last_retry_queued_at', 'last_job_id', 'ignored_at', 'ignore_id', @@ -32,7 +36,10 @@ class PluginUpdateFailureStreak extends Model protected $casts = [ 'consecutive_failures' => 'integer', + 'consecutive_fs_failures' => 'integer', + 'fs_streak_started_job_id' => 'integer', 'last_failed_at' => 'datetime', + 'last_retry_queued_at' => 'datetime', 'ignored_at' => 'datetime', 'companion_pushed_at' => 'datetime', ]; diff --git a/app/Models/PluginUpdateIgnore.php b/app/Models/PluginUpdateIgnore.php index 631aa6ef..8eb20808 100644 --- a/app/Models/PluginUpdateIgnore.php +++ b/app/Models/PluginUpdateIgnore.php @@ -10,9 +10,11 @@ * "This (site, target) pair should never appear as a pending update." * Exact analogue of ManageWP Orion's "Ignored" tab. * - * No expiry semantics — a future migration can add `expires_at` if "ignore - * this version only" becomes a use case. Today: ignore is forever until - * removed. + * No expiry column. A manual ignore holds until an operator removes it. An + * auto_failure pause is softer: the nightly loop still tries a newly + * released version or a version that fixes a known vulnerability + * (RunNightlyPluginUpdates::filterIgnored), and a successful update to the + * target version deletes the row (UpdateFailureStreakRecorder::record). */ class PluginUpdateIgnore extends Model { @@ -22,6 +24,14 @@ class PluginUpdateIgnore extends Model public const SOURCE_AUTO_FAILURE = 'auto_failure'; + /** + * Held by the nightly loop because the installed theme doesn't look like + * the theme on offer (a custom theme sharing its folder name with an + * unrelated wordpress.org theme). Holds like a manual ignore: only an + * operator lifts it. Never shown in wp-admin (client_visible=false). + */ + public const SOURCE_IDENTITY_MISMATCH = 'identity_mismatch'; + protected static function booted(): void { static::creating(function ($ignore) { diff --git a/app/Models/PluginUpdateJob.php b/app/Models/PluginUpdateJob.php index bace91ad..afed4998 100644 --- a/app/Models/PluginUpdateJob.php +++ b/app/Models/PluginUpdateJob.php @@ -65,6 +65,7 @@ class PluginUpdateJob extends Model 'reactivated', 'elapsed_ms', 'error', + 'reason_code', 'messages', 'state_before', 'repairs', diff --git a/app/Models/Site.php b/app/Models/Site.php index f4b09dfb..14482257 100644 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -4,6 +4,7 @@ use App\Jobs\CaptureSiteScreenshotJob; use App\Services\HostingProvider\HostingProviderRegistry; +use App\Services\Updates\ObservedUpdateRecorder; use App\Services\Uptime\UptimeStatsCalculator; use App\Support\Monitoring\DomainIgnoreList; use App\Support\Settings; @@ -93,6 +94,9 @@ * @property ?int $backup_relay_last_size_bytes * @property ?string $backup_relay_last_sha256 * @property ?array $backup_relay_last_manifest + * @property ?Carbon $backup_relay_last_attempt_at last archive attempt (success or failure) + * @property ?string $backup_relay_last_error last failed attempt's error; null once an archive succeeds + * @property ?Carbon $backup_relay_failing_since first failure in the current unbroken failure streak * @property ?string $bill_com_customer_id * @property ?string $bill_com_customer_name * @property ?string $bill_com_linked_via_invoice @@ -102,13 +106,16 @@ * @property ?string $auto_updates_paused_reason short note shown as a tooltip in the fleet listing * @property ?Carbon $auto_updates_last_run_at last time the nightly loop touched this site * @property bool $uptime_monitoring_enabled - * @property string $uptime_state up|down|maintenance|unknown + * @property string $uptime_state up|down|maintenance|probe_blocked|unknown * @property ?Carbon $uptime_last_checked_at * @property ?Carbon $uptime_last_up_at * @property ?int $uptime_last_status_code * @property int $uptime_consecutive_failures * @property ?Carbon $uptime_down_since * @property ?Carbon $uptime_maintenance_since + * @property ?Carbon $uptime_probe_blocked_since start of the current unbroken run of HTTP 429 probe answers + * @property int $uptime_consecutive_blocked HTTP 429 answers in a row (the run probe_blocked waits for) + * @property ?Carbon $uptime_probe_blocked_alerted_at when ops was told about that run (one notice per run) * @property ?Carbon $uptime_ignored_at * @property ?string $uptime_ignore_reason * @property bool $uptime_sla_exempt @@ -255,6 +262,12 @@ class Site extends Model public const UPTIME_STATE_UNKNOWN = 'unknown'; + /** + * The site rate-limits the probe (HTTP 429), so we can't tell whether it + * is up. Not an outage: it never counts as downtime. + */ + public const UPTIME_STATE_PROBE_BLOCKED = 'probe_blocked'; + protected $fillable = [ 'spinupwp_id', 'pressable_site_id', @@ -328,6 +341,9 @@ class Site extends Model 'backup_relay_last_size_bytes', 'backup_relay_last_sha256', 'backup_relay_last_manifest', + 'backup_relay_last_attempt_at', + 'backup_relay_last_error', + 'backup_relay_failing_since', 'bill_com_customer_id', 'bill_com_customer_name', 'bill_com_linked_via_invoice', @@ -344,6 +360,9 @@ class Site extends Model 'uptime_consecutive_failures', 'uptime_down_since', 'uptime_maintenance_since', + 'uptime_probe_blocked_since', + 'uptime_probe_blocked_alerted_at', + 'uptime_consecutive_blocked', 'uptime_ignored_at', 'uptime_ignore_reason', 'uptime_sla_exempt', @@ -432,6 +451,8 @@ protected function casts(): array 'backup_relay_last_archived_at' => 'datetime', 'backup_relay_last_size_bytes' => 'integer', 'backup_relay_last_manifest' => 'array', + 'backup_relay_last_attempt_at' => 'datetime', + 'backup_relay_failing_since' => 'datetime', 'bill_com_linked_at' => 'datetime', 'care_plan_override' => 'boolean', 'auto_updates_paused' => 'boolean', @@ -443,6 +464,9 @@ protected function casts(): array 'uptime_consecutive_failures' => 'integer', 'uptime_down_since' => 'datetime', 'uptime_maintenance_since' => 'datetime', + 'uptime_probe_blocked_since' => 'datetime', + 'uptime_consecutive_blocked' => 'integer', + 'uptime_probe_blocked_alerted_at' => 'datetime', 'uptime_ignored_at' => 'datetime', 'uptime_sla_exempt' => 'boolean', 'uptime_skip_body_check' => 'boolean', @@ -523,6 +547,28 @@ protected static function booted(): void CaptureSiteScreenshotJob::dispatch($site->id); } }); + + // A Companion snapshot that reports a WordPress core proves the site + // runs WordPress. Heal is_wordpress here rather than in each place + // that writes a snapshot, so a site enrolled without the flag doesn't + // stay out of the WordPress-only jobs (core checksums, lockout and + // firewall pulls) forever. Never clears the flag. + static::saving(function (Site $site) { + if (! $site->is_wordpress && isset($site->companion_snapshot['wp_core'])) { + $site->is_wordpress = true; + } + }); + + // Updates applied by something other than Control (WordPress + // auto-updates, another updater, wp-admin) only show up as version + // changes between two snapshots. Compared here, where the previous + // snapshot is still the original, rather than in each place that + // writes one. Informational rows only; never throws. + static::updated(function (Site $site) { + if ($site->wasChanged('companion_snapshot')) { + app(ObservedUpdateRecorder::class)->recordSnapshotChange($site); + } + }); } public function scopeNotArchived(Builder $query): void @@ -1404,6 +1450,11 @@ public function isMaintenance(): bool return $this->uptime_state === self::UPTIME_STATE_MAINTENANCE; } + public function isUptimeProbeBlocked(): bool + { + return $this->uptime_state === self::UPTIME_STATE_PROBE_BLOCKED; + } + /** * Return a consolidated status color for visual cards ('green', 'yellow', 'red'). */ @@ -1417,7 +1468,7 @@ public function healthColor(): string return 'red'; } - if ($this->uptime_state === 'unknown' || $this->uptime_state === self::UPTIME_STATE_MAINTENANCE || $this->sslState() === self::SSL_STATE_YELLOW) { + if ($this->uptime_state === 'unknown' || $this->uptime_state === self::UPTIME_STATE_MAINTENANCE || $this->uptime_state === self::UPTIME_STATE_PROBE_BLOCKED || $this->sslState() === self::SSL_STATE_YELLOW) { return 'yellow'; } diff --git a/app/Models/SiteUptimeEvent.php b/app/Models/SiteUptimeEvent.php index 332a160b..d12ec3de 100644 --- a/app/Models/SiteUptimeEvent.php +++ b/app/Models/SiteUptimeEvent.php @@ -8,11 +8,12 @@ use Illuminate\Support\Carbon; /** - * One row per uptime state transition (up→down or down→up). NOT one per probe. + * One row per uptime state transition (up→down, down→up, into maintenance, + * into probe_blocked). NOT one per probe. * * @property int $id * @property int $site_id - * @property string $event_type 'down' or 'up' + * @property string $event_type 'down', 'up', 'maintenance' or 'probe_blocked' * @property ?int $status_code * @property ?string $error * @property ?int $response_time_ms @@ -36,6 +37,12 @@ class SiteUptimeEvent extends Model public const TYPE_MAINTENANCE = 'maintenance'; + /** + * The site started rate-limiting the probe (HTTP 429). Opens a stretch of + * "can't tell" — never downtime. Closed by the next up/down/maintenance. + */ + public const TYPE_PROBE_BLOCKED = 'probe_blocked'; + public const REASON_CLIENT_DNS = 'client_dns'; public const REASON_DOMAIN_EXPIRED = 'domain_expired'; diff --git a/app/Services/ActionLog/ActionLogger.php b/app/Services/ActionLog/ActionLogger.php index 771a5a87..094ae3d1 100644 --- a/app/Services/ActionLog/ActionLogger.php +++ b/app/Services/ActionLog/ActionLogger.php @@ -7,6 +7,7 @@ use App\Models\Site; use App\Services\Companion\ClockworkCompanionClient; use App\Services\Companion\CompanionInstaller; +use App\Support\UpgraderOutput; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; use Throwable; @@ -95,8 +96,13 @@ public function record( // warning in pushToCompanion). Either way we don't break the action. // // Server-only actions (no site) don't push — the Companion mirror is a - // per-site client-facing log, not a fleet log. - if ($site !== null && $site->companion_installed && ! empty($site->companion_secret)) { + // per-site client-facing log, not a fleet log. Neither do local-only + // types (updates Control merely observed, refused updates): the + // mirror lists what Control did on the site. + if ( + $site !== null && $site->companion_installed && ! empty($site->companion_secret) + && ! in_array($actionType, ActionLog::LOCAL_ONLY_TYPES, true) + ) { $this->pushToCompanion($site, $row, $actionType, $target, $summary, $details, $ok, $error, $elapsedMs, $actor, $ranAt); } @@ -161,6 +167,26 @@ public function recordCompanionInstall(Site $site, array $result): ?ActionLog /** * @param array|null $details */ + /** + * An action-log row as it may be shown in the client's wp-admin. The + * mirror is the client's own site, so licence keys, tokens and signed + * download URLs in the summary, details or error are redacted on the + * way out, whatever wrote the row (rows from before upgrader output was + * redacted at write time still hold them). redact() keeps the shape of + * details: only string values change. + * + * @param array $payload + * @return array + */ + public static function forCompanionMirror(array $payload): array + { + $payload['summary'] = UpgraderOutput::redactText((string) ($payload['summary'] ?? '')); + $payload['details'] = UpgraderOutput::redact($payload['details'] ?? null); + $payload['error'] = isset($payload['error']) ? UpgraderOutput::redactText((string) $payload['error']) : null; + + return $payload; + } + private function pushToCompanion( Site $site, ActionLog $row, @@ -175,7 +201,7 @@ private function pushToCompanion( Carbon $ranAt, ): void { try { - (new ClockworkCompanionClient($site))->appendActionLog([ + (new ClockworkCompanionClient($site))->appendActionLog(self::forCompanionMirror([ 'action_type' => $actionType, 'target' => $target, 'summary' => $summary, @@ -186,7 +212,7 @@ private function pushToCompanion( 'actor' => $actor, 'care_plan_enabled' => (bool) $site->care_plan_enabled, 'ran_at' => $ranAt->toIso8601String(), - ]); + ])); // Mark the row pushed so the backfill command knows to skip it // on future re-syncs. forceFill avoids re-triggering observers. $row->forceFill(['companion_pushed_at' => now()])->save(); diff --git a/app/Services/Chat/ChatNotifier.php b/app/Services/Chat/ChatNotifier.php index c44aa654..04712530 100644 --- a/app/Services/Chat/ChatNotifier.php +++ b/app/Services/Chat/ChatNotifier.php @@ -41,6 +41,11 @@ interface ChatNotifier 'description' => 'Uptime probe detected the site finished scheduled maintenance and is back up.', 'default' => true, ], + 'uptime_probe_blocked' => [ + 'label' => 'Uptime probe blocked', + 'description' => 'A site has rate-limited the uptime probe (HTTP 429) for longer than the hours set on Monitoring → Settings, so its real status cannot be confirmed. Sent once per blocked stretch.', + 'default' => true, + ], 'ssl_state_changed' => [ 'label' => 'SSL state changed', 'description' => 'Certificate state moved between green / yellow / red.', @@ -86,6 +91,11 @@ interface ChatNotifier 'description' => 'A security scan (Sucuri SiteCheck or the Companion in-WP probe) found malware on a site that was clean on its last scan.', 'default' => true, ], + 'php_in_uploads_detected' => [ + 'label' => 'New PHP file in uploads', + 'description' => 'The Companion in-WP probe found a PHP file under wp-content/uploads that was not there on the site\'s previous scan. Each new path alerts once; files already present on the last scan stay quiet.', + 'default' => true, + ], 'backup_relay_stale' => [ 'label' => 'Backup relay silent', 'description' => 'The offsite (S3 Glacier) backup relay droplet hasn\'t reported a new run in longer than its Sun+Wed cadence allows.', @@ -186,12 +196,21 @@ public function siteEnteredMaintenance(Site $site, ?int $statusCode, ?string $re public function siteExitedMaintenance(Site $site, ?int $maintenanceSec): bool; + public function uptimeProbeBlocked(Site $site, int $blockedSec, ?int $statusCode, ?string $retryAfter = null): bool; + public function pluginUpdateFailed(Site $site, PluginUpdateJob $job): bool; public function pluginUpdateAutoIgnored(Site $site, PluginUpdateJob $job, int $failures): bool; public function malwareFindingDetected(Site $site, SiteSecurityScan $scan): bool; + /** + * @param list $paths PHP files under wp-content/uploads that the + * previous scan did not report, relative to + * the WordPress root + */ + public function phpInUploadsDetected(Site $site, SiteSecurityScan $scan, array $paths): bool; + public function backupRelayStale(int $daysSinceLastRun, ?Carbon $lastRunAt = null): bool; public function backupRelayRecovered(): bool; diff --git a/app/Services/Chat/ChatNotifierDispatcher.php b/app/Services/Chat/ChatNotifierDispatcher.php index fa56ce48..9b2dc2cc 100644 --- a/app/Services/Chat/ChatNotifierDispatcher.php +++ b/app/Services/Chat/ChatNotifierDispatcher.php @@ -125,6 +125,15 @@ public function siteExitedMaintenance(Site $site, ?int $maintenanceSec): bool return $this->dispatch(fn (ChatNotifier $n) => $n->siteExitedMaintenance($site, $maintenanceSec)); } + /** + * A blocked probe is a monitoring gap, not an outage, so it is routed like + * the other routine per-site notices and stays quiet for inactive sites. + */ + public function uptimeProbeBlocked(Site $site, int $blockedSec, ?int $statusCode, ?string $retryAfter = null): bool + { + return $this->dispatchForSite($site, fn (ChatNotifier $n) => $n->uptimeProbeBlocked($site, $blockedSec, $statusCode, $retryAfter)); + } + public function pluginUpdateFailed(Site $site, PluginUpdateJob $job): bool { return $this->dispatchForSite($site, fn (ChatNotifier $n) => $n->pluginUpdateFailed($site, $job)); @@ -173,6 +182,14 @@ public function malwareFindingDetected(Site $site, SiteSecurityScan $scan): bool return $this->dispatch(fn (ChatNotifier $n) => $n->malwareFindingDetected($site, $scan)); } + // Same reasoning as malwareFindingDetected: a PHP file newly dropped into + // uploads is a possible compromise, not a maintenance nag, so it is not + // gated on is_inactive either. + public function phpInUploadsDetected(Site $site, SiteSecurityScan $scan, array $paths): bool + { + return $this->dispatch(fn (ChatNotifier $n) => $n->phpInUploadsDetected($site, $scan, $paths)); + } + /** * Same as dispatch(), but short-circuits entirely for a site marked * is_inactive — used only by the "routine maintenance" event methods diff --git a/app/Services/Security/CompanionMalwareScanner.php b/app/Services/Security/CompanionMalwareScanner.php index ddf20347..135cf002 100644 --- a/app/Services/Security/CompanionMalwareScanner.php +++ b/app/Services/Security/CompanionMalwareScanner.php @@ -6,6 +6,8 @@ use App\Models\SiteSecurityScan; use App\Services\Companion\ClockworkCompanionClient; use App\Services\Ssh\SshClient; +use App\Services\Ssh\SshCommandTimedOut; +use RuntimeException; /** * In-WP malware probe runner. Two transports, picked per-site: @@ -27,6 +29,53 @@ */ class CompanionMalwareScanner { + /** Most php_in_uploads paths the SSH transport keeps per scan. */ + public const SSH_UPLOADS_LIMIT = 100; + + /** Most files the SSH transport keeps per obfuscation signature. */ + public const SSH_SIGNATURE_LIMIT = 50; + + /** details.abort_reason for an SSH scan that hit one of the limits above. */ + public const SSH_ABORT_REASON = 'ssh_result_limit'; + + /** details.abort_reason for an SSH scan where a check didn't finish (timed out, or cut off). */ + public const SSH_INCOMPLETE_REASON = 'ssh_check_incomplete'; + + /** + * Printed after each SSH check's pipeline finishes, outside it. Output + * without it was cut off (the command timed out or the connection + * dropped), so the check can't vouch for anything it didn't list. + */ + public const SSH_DONE_SENTINEL = '__CW_DONE__'; + + /** Printed by the pre-check when the docroot exists and holds a wp-config.php. */ + public const SSH_DOCROOT_OK = '__CW_OK__'; + + /** + * Docroot prefixes stripped from finding paths on every site, on top of + * the site's own wp_path and any ABSPATH Companion reports. Some managed + * hosts report absolute paths from these roots instead of paths relative + * to the WordPress install. + */ + private const KNOWN_DOCROOT_PREFIXES = [ + '/srv/htdocs', + '/wordpress/core', + ]; + + /** + * Finding kinds an operator may suppress per path with + * `clockwork:suppress-malware-path --kind=`. Review-tier kinds only: + * php_in_uploads (legitimate plugins drop PHP into uploads) and the + * preg_replace /e modifier (dead on PHP 8, only ever matches legacy + * vendor code). The positive signatures (eval_*, shell_*) are never + * suppressible, and wp_config_recently_modified already alerts only + * once per mtime change. + */ + public const SUPPRESSIBLE_KINDS = [ + 'php_in_uploads', + 'preg_replace_e_modifier', + ]; + public function __construct( private readonly SshClient $ssh, ) {} @@ -91,14 +140,38 @@ public function scanSite(Site $site): SecurityScanResult * 3. `stat -c %Y wp-config.php` compared to now() - 30d. * * Output is parsed and shaped exactly like the Companion JSON response so - * `buildResult()` doesn't care which transport produced it. + * `buildResult()` doesn't care which transport produced it. Each check's + * output is capped (SSH_UPLOADS_LIMIT, SSH_SIGNATURE_LIMIT); a scan that + * hits a cap is reported as scan_aborted with abort_reason + * SSH_ABORT_REASON and the capped checks in truncated_checks. + * + * Every check must also prove it finished: each command prints + * SSH_DONE_SENTINEL after its pipeline, and runs with a timeout that + * throws instead of returning partial output (SshClient::execChecked). + * A check that timed out or lost its sentinel is listed in + * incomplete_checks and the scan is scan_aborted with abort_reason + * SSH_INCOMPLETE_REASON. ssh_checks_verified is true only when every + * check finished, which is what a client report needs before it can call + * a clean SSH scan complete. A docroot that doesn't exist (or holds no + * wp-config.php) fails the scan rather than reading as clean. * - * @return array{ok: true, findings: list, scanned_files_count: int, scanned_at: string, wall_seconds: float, scan_aborted: bool, abort_reason: ?string} + * @return array{ok: true, findings: list, scanned_files_count: int, scanned_at: string, wall_seconds: float, scan_aborted: bool, abort_reason: ?string, truncated_checks: list, incomplete_checks: list, ssh_checks_verified: bool} */ private function runViaSsh(Site $site): array { $docRoot = $site->wp_path ?: '/sites/'.$site->domain.'/files'; + // Every check sends its errors to /dev/null, so a wrong or guessed + // docroot would read as "nothing found". Prove it's a WordPress root. + $preflight = sprintf( + 'test -d %1$s && { test -r %1$s/wp-config.php || test -r %1$s/../wp-config.php; } && echo %2$s', + escapeshellarg($docRoot), + self::SSH_DOCROOT_OK, + ); + if (! str_contains($this->ssh->execChecked($site->server, $preflight, 10), self::SSH_DOCROOT_OK)) { + throw new RuntimeException("docroot {$docRoot} not found or holds no wp-config.php"); + } + $needles = [ 'eval_base64' => 'eval(base64_decode(', 'eval_gzinflate' => 'eval(gzinflate(', @@ -110,6 +183,13 @@ private function runViaSsh(Site $site): array ]; $findings = []; + // Checks whose output hit their head -n cap. Each command asks for one + // line more than it keeps, so "more lines than the cap" proves the + // check was cut short instead of guessing from an exact count. + $truncated = []; + // Checks that didn't finish: timed out, or their output lost the + // completion sentinel. + $incomplete = []; // 1. PHP in uploads. // Size threshold of 30 bytes excludes the noise dropped by legit @@ -118,19 +198,12 @@ private function runViaSsh(Site $site): array // golden." stub. Real PHP backdoors are virtually always >30 bytes // because they need eval/system + input handling + at least minimal // obfuscation. Defense-in-depth: also grep -L for the silence stub - // text, in case a slightly larger variant exists. - $uploadsCmd = sprintf( - "find %s/wp-content/uploads -type f \\( -name '*.php' -o -name '*.phtml' \\) -size +30c 2>/dev/null" - ." | xargs -I{} sh -c 'grep -qiE \"^<\\?php[[:space:]]*(//|/\\*)[[:space:]]*[Ss]ilence is golden\" \"\$1\" || echo \"\$1\"' _ {} 2>/dev/null" - .' | head -100', - escapeshellarg($docRoot), - ); - $uploadsOut = $this->ssh->exec($site->server, $uploadsCmd, 30); - foreach (preg_split('/\r?\n/', trim((string) $uploadsOut)) ?: [] as $line) { - $line = trim($line); - if ($line === '') { - continue; - } + // text, in case a slightly larger variant exists. find hands the + // paths to sh as arguments (-exec … {} +), never through xargs, + // which stops at the first file name with a quote in it. + $uploadsCmd = self::sshUploadsCommand($docRoot); + $uploadsOut = $this->runSshCheck($site, 'php_in_uploads', $uploadsCmd, 30, $incomplete); + foreach ($this->capLines($uploadsOut, self::SSH_UPLOADS_LIMIT, 'php_in_uploads', $truncated) as $line) { $findings[] = [ 'kind' => 'php_in_uploads', 'path' => $this->relativePath($docRoot, $line), @@ -150,19 +223,18 @@ private function runViaSsh(Site $site): array // findings (that was every "via ssh" row on /issues, 2026-07-15). // Matched by directory name, so stray copies outside mu-plugins // are excluded too; PHP under uploads is still caught by check 1, - // which excludes nothing. + // which excludes nothing. clockwork-renegade ships the same scanner + // source, so it is excluded for the same reason. foreach ($needles as $kind => $needle) { $grepCmd = sprintf( - "grep -rlF --include='*.php' --include='*.phtml' --exclude-dir=vendor --exclude-dir=node_modules --exclude-dir=cache --exclude-dir=clockwork-companion %s %s 2>/dev/null | head -50", + "grep -rlF --include='*.php' --include='*.phtml' --exclude-dir=vendor --exclude-dir=node_modules --exclude-dir=cache --exclude-dir=clockwork-companion --exclude-dir=clockwork-renegade %s %s 2>/dev/null | head -n %d; echo %s", escapeshellarg($needle), escapeshellarg($docRoot), + self::SSH_SIGNATURE_LIMIT + 1, + self::SSH_DONE_SENTINEL, ); - $grepOut = $this->ssh->exec($site->server, $grepCmd, 60); - foreach (preg_split('/\r?\n/', trim((string) $grepOut)) ?: [] as $line) { - $line = trim($line); - if ($line === '') { - continue; - } + $grepOut = $this->runSshCheck($site, $kind, $grepCmd, 60, $incomplete); + foreach ($this->capLines($grepOut, self::SSH_SIGNATURE_LIMIT, $kind, $truncated) as $line) { $findings[] = [ 'kind' => $kind, 'path' => $this->relativePath($docRoot, $line), @@ -175,11 +247,12 @@ private function runViaSsh(Site $site): array // Companion-side MalwareScanner; the once-per-mtime dedup happens // centrally in buildResult() so both transports get it. $statCmd = sprintf( - 'stat -c %%Y %s/wp-config.php 2>/dev/null || stat -c %%Y %s/../wp-config.php 2>/dev/null || true', + 'stat -c %%Y %s/wp-config.php 2>/dev/null || stat -c %%Y %s/../wp-config.php 2>/dev/null || true; echo %s', escapeshellarg($docRoot), escapeshellarg($docRoot), + self::SSH_DONE_SENTINEL, ); - $statOut = trim((string) $this->ssh->exec($site->server, $statCmd, 10)); + $statOut = trim($this->runSshCheck($site, 'wp_config_mtime', $statCmd, 10, $incomplete)); if (ctype_digit($statOut) && ((int) $statOut) > (time() - 30 * 86400)) { $findings[] = [ 'kind' => 'wp_config_recently_modified', @@ -188,17 +261,110 @@ private function runViaSsh(Site $site): array ]; } + // A capped or unfinished check may have hidden further hits, so the + // scan is not a complete picture of the docroot. Report it the way + // Companion reports a scan that stopped at its file limit, so nothing + // downstream reads it as a full one. return [ 'ok' => true, 'findings' => $findings, 'scanned_files_count' => 0, // SSH path doesn't expose a precise count; UI tolerates 0. 'scanned_at' => gmdate('c'), 'wall_seconds' => 0, - 'scan_aborted' => false, - 'abort_reason' => null, + 'scan_aborted' => $truncated !== [] || $incomplete !== [], + 'abort_reason' => match (true) { + $incomplete !== [] => self::SSH_INCOMPLETE_REASON, + $truncated !== [] => self::SSH_ABORT_REASON, + default => null, + }, + 'truncated_checks' => $truncated, + 'incomplete_checks' => $incomplete, + 'ssh_checks_verified' => $incomplete === [], ]; } + /** + * The php_in_uploads check: PHP files over 30 bytes under uploads that + * aren't the "Silence is golden" stub, at most SSH_UPLOADS_LIMIT + 1 of + * them, then the completion sentinel. Paths reach sh as arguments, so a + * quote (or any other character) in a file name can't stop the check. + */ + public static function sshUploadsCommand(string $docRoot): string + { + return sprintf( + "find %s/wp-content/uploads -type f \\( -name '*.php' -o -name '*.phtml' \\) -size +30c" + ." -exec sh -c 'for f do grep -qiE \"^<\\?php[[:space:]]*(//|/\\*)[[:space:]]*[Ss]ilence is golden\" \"\$f\" || printf \"%%s\\n\" \"\$f\"; done' _ {} + 2>/dev/null" + .' | head -n %d; echo %s', + escapeshellarg($docRoot), + self::SSH_UPLOADS_LIMIT + 1, + self::SSH_DONE_SENTINEL, + ); + } + + /** + * Run one SSH check and return its output without the completion + * sentinel. A check that timed out, or whose output has no sentinel, is + * added to $incomplete and returns what it got (a partial list still + * names real files; it just can't vouch for the rest). + * + * @param list $incomplete + */ + private function runSshCheck(Site $site, string $check, string $command, int $timeoutSeconds, array &$incomplete): string + { + try { + $output = $this->ssh->execChecked($site->server, $command, $timeoutSeconds); + } catch (SshCommandTimedOut) { + $incomplete[] = $check; + + return ''; + } + + $lines = preg_split('/\r?\n/', $output) ?: []; + $done = false; + $kept = []; + foreach ($lines as $line) { + if (trim($line) === self::SSH_DONE_SENTINEL) { + $done = true; + + continue; + } + $kept[] = $line; + } + + if (! $done) { + $incomplete[] = $check; + } + + return implode("\n", $kept); + } + + /** + * Split SSH output into trimmed, non-empty lines and keep at most $limit + * of them. Callers ask `head` for $limit + 1 lines, so getting more than + * $limit back means the check was cut short; its name is then appended + * to $truncated. + * + * @param list $truncated + * @return list + */ + private function capLines(string $output, int $limit, string $check, array &$truncated): array + { + $lines = []; + foreach (preg_split('/\r?\n/', trim($output)) ?: [] as $line) { + $line = trim($line); + if ($line !== '') { + $lines[] = $line; + } + } + + if (count($lines) > $limit) { + $truncated[] = $check; + $lines = array_slice($lines, 0, $limit); + } + + return $lines; + } + /** * Finding kinds whose presence is informational (review-and-confirm), * not a positive malware indicator. Findings of these kinds, in the @@ -221,38 +387,169 @@ private function runViaSsh(Site $site): array 'preg_replace_e_modifier', ]; + /** + * The companion_finding_state key holding the operator's per-path + * suppression list for one finding kind. php_in_uploads keeps its + * original key (php_in_uploads_suppressed), so existing lists still apply. + */ + public static function suppressionStateKey(string $kind): string + { + return $kind.'_suppressed'; + } + + /** + * Turn a finding path into a path relative to the WordPress root, so the + * same file reads the same whichever transport or host layout reported it: + * + * - backslashes become '/' (a Windows-built plugin can report + * 'src\SecurityScans\X.php'); + * - repeated slashes collapse, and a leading './' is dropped; + * - a known docroot prefix is stripped: the site's wp_path, any ABSPATH + * Companion reports in its snapshot, and the managed-host roots in + * KNOWN_DOCROOT_PREFIXES. + * + * Only whole known prefixes are stripped (never "everything before the + * first wp-content/"), so a root-level file such as '/x.php' + * becomes 'x.php' and a path outside every known root is left absolute. + */ + public static function normalizeFindingPath(string $path, ?Site $site = null): string + { + return self::stripDocroot($path, self::docrootPrefixes($site)); + } + + /** + * normalizeFindingPath() bound to one site, with the docroot prefixes + * resolved once. Use it when normalizing many paths for the same site. + * + * @return \Closure(string): string + */ + public static function findingPathNormalizer(?Site $site = null): \Closure + { + $prefixes = self::docrootPrefixes($site); + + return fn (string $path): string => self::stripDocroot($path, $prefixes); + } + + /** + * @param list $prefixes each ending in '/', longest first + */ + private static function stripDocroot(string $path, array $prefixes): string + { + $p = self::cleanSlashes($path); + + foreach ($prefixes as $prefix) { + if (str_starts_with($p, $prefix)) { + $p = substr($p, strlen($prefix)); + break; + } + } + + while (str_starts_with($p, './')) { + $p = substr($p, 2); + } + + return $p; + } + + private static function cleanSlashes(string $path): string + { + $p = str_replace('\\', '/', trim($path)); + + return preg_replace('#/{2,}#', '/', $p) ?? $p; + } + + /** + * @return list each ending in '/', longest first so a nested + * docroot wins over its parent + */ + private static function docrootPrefixes(?Site $site): array + { + $snapshot = $site?->companion_snapshot; + $abspath = is_array($snapshot) + ? ($snapshot['environment']['abspath'] ?? $snapshot['abspath'] ?? null) + : null; + + $prefixes = []; + foreach ([$site?->wp_path, $abspath, ...self::KNOWN_DOCROOT_PREFIXES] as $candidate) { + if (! is_string($candidate)) { + continue; + } + $candidate = rtrim(self::cleanSlashes($candidate), '/'); + // Never strip a bare '/' (or an empty root): that would make every + // absolute path look relative. + if ($candidate === '') { + continue; + } + $prefixes[] = $candidate.'/'; + } + + $prefixes = array_values(array_unique($prefixes)); + usort($prefixes, fn (string $a, string $b) => strlen($b) <=> strlen($a)); + + return $prefixes; + } + + /** + * The site's suppression lists, normalized at compare time so entries + * stored before normalization existed (relative or absolute) keep + * matching. + * + * @param array $state + * @param \Closure(string): string $normalize + * @return array> kind => set of normalized paths + */ + private static function suppressedPathsByKind(array $state, \Closure $normalize): array + { + $byKind = []; + foreach (self::SUPPRESSIBLE_KINDS as $kind) { + foreach ((array) ($state[self::suppressionStateKey($kind)] ?? []) as $entry) { + if (is_string($entry) && trim($entry) !== '') { + $byKind[$kind][$normalize($entry)] = true; + } + } + } + + return $byKind; + } + /** * Once-per-modification-event suppression for "transient informational" - * findings, plus permanent per-path suppression for known-safe - * `php_in_uploads` files. + * findings, plus permanent per-path suppression for known-safe files. * * wp_config_recently_modified: the probe raises whenever the file's mtime * is within the last 30 days. Without dedup, a single legitimate touch * would re-flag the site every day for 30 days. Operators want one yellow * blip per actual mtime change, then quiet until the file is touched again. * - * php_in_uploads suppression: legitimate plugins (e.g. Ajax Load More, - * Mailchimp for WP) drop PHP files in uploads that are not backdoors. - * After an operator reviews and confirms a path is safe, they add it to - * `companion_finding_state.php_in_uploads_suppressed` via the - * `clockwork:suppress-malware-path` command. Suppressed paths are silently - * dropped so the scan goes clean; they appear nowhere in scan output. - * If the file is later replaced with something malicious, the path stays - * suppressed — operators should only suppress paths they actively manage. + * Per-path suppression: legitimate plugins (e.g. Ajax Load More, + * Mailchimp for WP) drop PHP files in uploads that are not backdoors, and + * some legacy vendor code still carries a preg_replace /e line. After an + * operator reviews and confirms a path is safe, they add it to the site's + * list for that kind (`companion_finding_state._suppressed`, see + * SUPPRESSIBLE_KINDS) via the `clockwork:suppress-malware-path` command. + * A suppression matches on kind AND normalized path, so suppressing a + * vendor file's preg_replace /e finding never hides another kind found + * in the same file. Suppressed findings are silently dropped so the scan + * goes clean; they appear nowhere in scan output. If the file is later + * replaced with something malicious, the path stays suppressed for that + * kind — operators should only suppress paths they actively manage. * * Other finding kinds (eval_*, shell_*) intentionally keep firing every * scan — they're positive malware indicators and deserve to keep * screaming until cleared. * + * Expects finding paths already normalized (see buildResult()). + * * @param list $findings + * @param \Closure(string): string $normalize * @return list */ - private function dedupTransientFindings(Site $site, array $findings): array + private function dedupTransientFindings(Site $site, array $findings, \Closure $normalize): array { $state = is_array($site->companion_finding_state) ? $site->companion_finding_state : []; $kept = []; $stateChanged = false; - $suppressed = array_values((array) ($state['php_in_uploads_suppressed'] ?? [])); + $suppressed = self::suppressedPathsByKind($state, $normalize); foreach ($findings as $f) { if (! is_array($f)) { @@ -261,8 +558,8 @@ private function dedupTransientFindings(Site $site, array $findings): array continue; } - // Drop php_in_uploads findings for operator-confirmed safe paths. - if (($f['kind'] ?? '') === 'php_in_uploads' && in_array($f['path'] ?? '', $suppressed, true)) { + // Drop findings an operator confirmed safe for this kind + path. + if (isset($suppressed[(string) ($f['kind'] ?? '')][(string) ($f['path'] ?? '')])) { continue; } @@ -322,11 +619,15 @@ private function extractMtimeFromEvidence(string $evidence): ?int * The scanner's own source lists every signature it looks for, so when * Companion is installed as a regular plugin (inside the scanned tree, * rather than mu-plugins) it would flag itself as a webshell. Only the - * SecurityScans directory of our own plugins is excluded. + * SecurityScans directory of our own plugins is excluded. Backslashes are + * read as '/', so a path reported with Windows separators still matches. */ public static function isOwnScannerSource(string $path): bool { - return (bool) preg_match('#(^|/)wp-content/(mu-)?plugins/clockwork-(companion|renegade)/src/SecurityScans/[^/]+\.php$#', $path); + return (bool) preg_match( + '#(^|/)wp-content/(mu-)?plugins/clockwork-(companion|renegade)/src/SecurityScans/[^/]+\.php$#', + self::cleanSlashes($path), + ); } /** @@ -336,15 +637,34 @@ public static function isOwnScannerSource(string $path): bool * - only warning kinds → warning * - any other kind → issues_found * - * @param array{ok: true, findings: list, scanned_files_count: int, scanned_at: string, wall_seconds: float, scan_aborted: bool, abort_reason: ?string} $payload + * has_malware_hit is set when any finding is a positive malware kind + * (outside WARNING_KINDS), so SecurityScanRecorder's clean → hit alert + * fires for this scanner too; review-only findings never set it. + * + * @param array{ok: true, findings: list, scanned_files_count: int, scanned_at: string, wall_seconds: float, scan_aborted: bool, abort_reason: ?string, truncated_checks?: list} $payload */ private function buildResult(Site $site, array $payload, string $transport, float $started): SecurityScanResult { + // Normalize every path first so suppression, the own-scanner filter + // and the stored findings all see one path shape, whichever + // transport or host layout reported it. + $normalize = self::findingPathNormalizer($site); + $payload['findings'] = array_map( + function ($f) use ($normalize) { + if (is_array($f) && isset($f['path']) && is_string($f['path'])) { + $f['path'] = $normalize($f['path']); + } + + return $f; + }, + $payload['findings'], + ); + // Apply once-per-mtime dedup for wp_config_recently_modified before // counting findings. Both transports (Companion HTTP + SSH wp-cli) // emit the same evidence-string shape, so we can central-process // here regardless of which path produced the payload. - $payload['findings'] = $this->dedupTransientFindings($site, $payload['findings']); + $payload['findings'] = $this->dedupTransientFindings($site, $payload['findings'], $normalize); $payload['findings'] = array_values(array_filter( $payload['findings'], fn (array $f) => ! self::isOwnScannerSource((string) ($f['path'] ?? '')), @@ -357,29 +677,65 @@ private function buildResult(Site $site, array $payload, string $transport, floa ? "Clean. {$payload['scanned_files_count']} files scanned via {$transport}." : "{$count} finding(s) via {$transport}: ".collect($kinds)->map(fn ($n, $k) => "{$k}={$n}")->implode(', '); - $status = match (true) { - $count === 0 => SiteSecurityScan::STATUS_CLEAN, - collect($payload['findings'])->every(fn ($f) => in_array($f['kind'], self::WARNING_KINDS, true)) => SiteSecurityScan::STATUS_WARNING, - default => SiteSecurityScan::STATUS_ISSUES_FOUND, - }; + $hasMalwareHit = self::hasMalwareHit($payload['findings']); + $status = self::statusForFindings($payload['findings']); + + $details = [ + 'transport' => $transport, + 'findings' => $payload['findings'], + 'scanned_files_count' => $payload['scanned_files_count'], + 'scan_aborted' => $payload['scan_aborted'], + 'abort_reason' => $payload['abort_reason'], + ]; + // Always kept for SSH scans, even when empty: its presence says the + // scan checked its result caps. + if ($transport === 'ssh' || ! empty($payload['truncated_checks'])) { + $details['truncated_checks'] = array_values((array) ($payload['truncated_checks'] ?? [])); + } + // Whether every SSH check proved it finished (and which didn't). + // Client reports call a clean SSH scan complete only with this set. + if ($transport === 'ssh') { + $details['incomplete_checks'] = array_values((array) ($payload['incomplete_checks'] ?? [])); + $details['ssh_checks_verified'] = ($payload['ssh_checks_verified'] ?? false) === true; + } return new SecurityScanResult( site: $site, scanType: SiteSecurityScan::TYPE_COMPANION_MALWARE, status: $status, + hasMalwareHit: $hasMalwareHit, modifiedFilesCount: $count, summary: $summary, - details: [ - 'transport' => $transport, - 'findings' => $payload['findings'], - 'scanned_files_count' => $payload['scanned_files_count'], - 'scan_aborted' => $payload['scan_aborted'], - 'abort_reason' => $payload['abort_reason'], - ], + details: $details, elapsedMs: (int) ((microtime(true) - $started) * 1000), ); } + /** + * A scan's status from its findings: clean for none, warning when every + * finding is a review-only kind (WARNING_KINDS), issues_found otherwise. + * + * @param array $findings + */ + public static function statusForFindings(array $findings): string + { + return match (true) { + $findings === [] => SiteSecurityScan::STATUS_CLEAN, + ! self::hasMalwareHit($findings) => SiteSecurityScan::STATUS_WARNING, + default => SiteSecurityScan::STATUS_ISSUES_FOUND, + }; + } + + /** + * Whether any finding is a positive malware kind (outside WARNING_KINDS). + * + * @param array $findings + */ + private static function hasMalwareHit(array $findings): bool + { + return collect($findings)->contains(fn ($f) => ! in_array(is_array($f) ? ($f['kind'] ?? null) : null, self::WARNING_KINDS, true)); + } + private function failure(Site $site, float $started, string $message): SecurityScanResult { return new SecurityScanResult( diff --git a/app/Services/Security/SecurityScanRecorder.php b/app/Services/Security/SecurityScanRecorder.php index 513eb465..45c89663 100644 --- a/app/Services/Security/SecurityScanRecorder.php +++ b/app/Services/Security/SecurityScanRecorder.php @@ -23,7 +23,8 @@ * Also the chokepoint for the clean → malware-hit alert: every scan type * that can set has_malware_hit (SiteCheck, Companion in-WP probe) flows * through record(), so the transition check lives here once rather than - * being duplicated in each artisan command. + * being duplicated in each artisan command. The Companion probe's + * new-PHP-in-uploads notice lives here for the same reason. */ class SecurityScanRecorder { @@ -66,12 +67,18 @@ public function record(SecurityScanResult $r): SiteSecurityScan // Captured before the new row exists so it's genuinely the PREVIOUS // scan of this type for this site — used below to alert only on the // clean → malware-hit transition, not on every re-scan of a site - // that's still infected. - $previouslyHadMalwareHit = (bool) SiteSecurityScan::query() + // that's still infected. Failed scans are skipped: they carry no + // verdict (has_malware_hit is always false on them), so counting one + // as "clean" would re-alert on the next hit after every transport + // outage. + $previous = SiteSecurityScan::query() ->where('site_id', $r->site->id) ->where('scan_type', $r->scanType) + ->where('status', '!=', SiteSecurityScan::STATUS_FAILED) ->orderByDesc('scanned_at') - ->value('has_malware_hit'); + ->orderByDesc('id') + ->first(); + $previouslyHadMalwareHit = (bool) $previous?->has_malware_hit; $row = SiteSecurityScan::create([ 'site_id' => $r->site->id, @@ -99,6 +106,10 @@ public function record(SecurityScanResult $r): SiteSecurityScan } } + if ($r->scanType === SiteSecurityScan::TYPE_COMPANION_MALWARE && ! $r->isFailed()) { + $this->notifyNewPhpInUploads($r, $previous, $row); + } + // Forward the operator-actionable subset of the full scan details // into the action_log payload so that Companion's Security page can // expand each row to show kind/path/evidence per finding (mirrors the @@ -144,4 +155,70 @@ public function record(SecurityScanResult $r): SiteSecurityScan return $row; } + + /** + * php_in_uploads is a review-tier finding, so it never sets + * has_malware_hit and the clean → hit alert above never covers it. A + * PHP file newly dropped into uploads is still the most common sign of + * a compromise, so post a notice for each path the previous non-failed + * scan of this site did not report. Paths are compared normalized + * (CompanionMalwareScanner::normalizeFindingPath), so older rows that + * stored absolute paths don't make known files look new, and each file + * alerts once — on the scan where it first appears — while long-standing + * files stay quiet. With no earlier scan to compare against, every path + * counts as new: nobody has reviewed them yet. + */ + private function notifyNewPhpInUploads(SecurityScanResult $r, ?SiteSecurityScan $previous, SiteSecurityScan $row): void + { + $normalize = CompanionMalwareScanner::findingPathNormalizer($r->site); + + $current = $this->uploadsPhpPaths($r->details['findings'] ?? null, $normalize); + if ($current === []) { + return; + } + + $before = $previous !== null + ? $this->uploadsPhpPaths($previous->details['findings'] ?? null, $normalize) + : []; + + $new = array_values(array_diff($current, $before)); + if ($new === []) { + return; + } + + try { + $this->chat->phpInUploadsDetected($r->site, $row, $new); + } catch (Throwable $e) { + Log::warning('security_scan.php_in_uploads_alert_failed', [ + 'site_id' => $r->site->id, + 'scan_id' => $row->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * @param \Closure(string): string $normalize + * @return list unique normalized php_in_uploads paths + */ + private function uploadsPhpPaths(mixed $findings, \Closure $normalize): array + { + if (! is_array($findings)) { + return []; + } + + $paths = []; + foreach ($findings as $f) { + if (! is_array($f) || ($f['kind'] ?? null) !== 'php_in_uploads') { + continue; + } + $path = is_string($f['path'] ?? null) ? $normalize($f['path']) : ''; + if ($path !== '') { + $paths[$path] = true; + } + } + + // strval: PHP turns numeric-string array keys into ints. + return array_map('strval', array_keys($paths)); + } } diff --git a/app/Services/Security/WpCoreChecksumVerifier.php b/app/Services/Security/WpCoreChecksumVerifier.php index 6ea62e3c..19d50b97 100644 --- a/app/Services/Security/WpCoreChecksumVerifier.php +++ b/app/Services/Security/WpCoreChecksumVerifier.php @@ -84,6 +84,22 @@ public function __construct( private readonly PressableCommandRunner $pressableRunner, ) {} + /** + * Whether verify() has any way to run wp-cli on this site: root SSH to a + * tracked server, Pressable's command API, or the host's own command + * runner. Mirrors verify()'s dispatch. A site with none of these (a + * standalone site on a host we have no shell access to) can never be + * checked, so the nightly run skips it instead of recording a failure. + */ + public function hasTransport(Site $site): bool + { + $host = $site->host(); + + return $host->supports(HostingProvider::CAP_SSH) + || $site->isPressable() + || $host->commandRunner() !== null; + } + public function verify(Site $site): SecurityScanResult { $started = (int) (microtime(true) * 1000); diff --git a/app/Services/Security/WpVulnerabilityClient.php b/app/Services/Security/WpVulnerabilityClient.php index 583eb7fb..6210dde6 100644 --- a/app/Services/Security/WpVulnerabilityClient.php +++ b/app/Services/Security/WpVulnerabilityClient.php @@ -225,15 +225,17 @@ private function buildRows(string $slug, array $vulns): array if (! is_array($op)) { $op = []; } - [$fromVersion, $fromInclusive] = $this->resolveBound($op['min_version'] ?? null, $op['min_operator'] ?? null); - [$toVersion, $toInclusive] = $this->resolveBound($op['max_version'] ?? null, $op['max_operator'] ?? null); + [$fromVersion, $fromInclusive, $toVersion, $toInclusive] = self::resolveRange($op); $patchedIn = null; - if (((string) ($op['unfixed'] ?? '0')) === '0' && $toVersion !== null) { + $isExact = $fromVersion !== null && $fromVersion === $toVersion; + if (! self::isUnfixed($op['unfixed'] ?? null) && $toVersion !== null && ! $isExact) { // If max_operator is 'lt', then to_version IS the first // patched version. If 'lte', the actual fixed version is // higher than to_version — we don't know exactly, so leave // it null and rely on the operator to update past to_version. + // An exact-version advisory ('eq') has no single fixed + // version either: every other release is unaffected. $patchedIn = ! $toInclusive ? $toVersion : null; } @@ -273,25 +275,106 @@ private function buildRows(string $slug, array $vulns): array } /** - * Map (version, operator) to (version-or-null, inclusive-bool). - * Operators: gt, gte, lt, lte, eq. Null/empty version means unbounded. + * Turn the feed's operator block into the affected range: [from, + * from_inclusive, to, to_inclusive], where a null version means the + * range is open on that side. * - * @return array{0: ?string, 1: bool} + * The feed gives up to two (version, operator) pairs, min_* and max_*. + * The operator, not the slot it arrives in, decides which side of the + * range a version bounds: + * - lt / lte: upper bound, exclusive / inclusive + * - gt / gte: lower bound, exclusive / inclusive + * - eq: exactly that version (from = to, both inclusive). An exact + * advisory used to be stored as an inclusive upper bound with no + * lower bound, so "== 3.0.123" matched every older release too. + * An operator the feed has never sent keeps the side of its slot and + * is treated as inclusive, so an unknown shape errs towards reporting. + * + * @param array $op + * @return array{0: ?string, 1: bool, 2: ?string, 3: bool} + */ + public static function resolveRange(array $op): array + { + $from = [null, true]; + $to = [null, true]; + + foreach (['min' => 'from', 'max' => 'to'] as $prefix => $side) { + $version = self::cleanVersion($op["{$prefix}_version"] ?? null); + if ($version === null) { + continue; + } + + $operator = self::normalizeOperator($op["{$prefix}_operator"] ?? null); + if ($operator === 'eq') { + // An exact version wins over anything else in the block. + return [$version, true, $version, true]; + } + + $bounds = match ($operator) { + 'gt', 'gte' => 'from', + 'lt', 'lte' => 'to', + default => $side, + }; + $inclusive = ! in_array($operator, ['gt', 'lt'], true); + + if ($bounds === 'from') { + $from = [$version, $inclusive]; + } else { + $to = [$version, $inclusive]; + } + } + + return [$from[0], $from[1], $to[0], $to[1]]; + } + + /** + * The feed's operator names, plus the symbol spellings other feeds use. */ - private function resolveBound(mixed $version, mixed $operator): array + private static function normalizeOperator(mixed $operator): ?string { + $op = is_string($operator) ? strtolower(trim($operator)) : ''; + + return match ($op) { + 'lt', '<' => 'lt', + 'lte', 'le', '<=' => 'lte', + 'gt', '>' => 'gt', + 'gte', 'ge', '>=' => 'gte', + 'eq', '=', '==', '===' => 'eq', + default => null, + }; + } + + /** + * A usable version string, or null for "no bound". A number in the JSON + * (2 or 2.1) is a version too: dropping it would open the range on that + * side and match every release. + */ + private static function cleanVersion(mixed $version): ?string + { + if (is_int($version) || is_float($version)) { + $version = (string) $version; + } $v = is_string($version) ? trim($version) : ''; if ($v === '' || $v === '*') { - return [null, true]; + return null; + } + + return mb_substr($v, 0, 64); + } + + /** + * The feed sends unfixed as "0" or "1"; booleans and integers are read + * the same way. A missing flag means fixed (the old behaviour); a value + * that can't be read counts as unfixed, so no fixed version is claimed + * without evidence. + */ + private static function isUnfixed(mixed $unfixed): bool + { + if ($unfixed === null) { + return false; } - $op = strtolower((string) $operator); - $inclusive = match ($op) { - 'gte', 'lte', 'eq' => true, - 'gt', 'lt' => false, - default => true, - }; - return [mb_substr($v, 0, 64), $inclusive]; + return filter_var($unfixed, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? true; } /** diff --git a/app/Services/Ssh/SshClient.php b/app/Services/Ssh/SshClient.php index 9fb35198..f30ae275 100644 --- a/app/Services/Ssh/SshClient.php +++ b/app/Services/Ssh/SshClient.php @@ -141,6 +141,40 @@ public function exec(Server $server, string $command, ?int $timeoutSeconds = nul return is_string($output) ? $output : ''; } + /** + * Like exec(), but a command that runs out of time throws + * SshCommandTimedOut instead of returning the output so far as if it had + * finished, and a command phpseclib couldn't run at all throws too. For + * callers that read "no output" as "nothing found". + * + * @throws SshCommandTimedOut + * @throws RuntimeException + */ + public function execChecked(Server $server, string $command, ?int $timeoutSeconds = null): string + { + $ssh = $this->connect($server); + + if ($timeoutSeconds !== null && $timeoutSeconds > 0) { + $ssh->setTimeout($timeoutSeconds); + } + + try { + $output = $ssh->exec($command); + $timedOut = $ssh->isTimeout(); + } finally { + $ssh->disconnect(); + } + + if ($timedOut) { + throw new SshCommandTimedOut('SSH command timed out after '.($timeoutSeconds ?? 'the default').'s.'); + } + if (! is_string($output)) { + throw new RuntimeException('SSH command could not be run.'); + } + + return $output; + } + /** * Lightweight connection test. Returns the username the server reports back * along with which auth method actually succeeded. diff --git a/app/Services/Ssh/SshCommandTimedOut.php b/app/Services/Ssh/SshCommandTimedOut.php new file mode 100644 index 00000000..e1b6c351 --- /dev/null +++ b/app/Services/Ssh/SshCommandTimedOut.php @@ -0,0 +1,13 @@ +getOriginal('companion_snapshot'); + $current = $site->companion_snapshot; + if (! is_array($previous) || ! is_array($current)) { + return 0; + } + + $previousAt = $site->getOriginal('companion_snapshot_at'); + $observedAt = $site->wasChanged('companion_snapshot_at') && $site->companion_snapshot_at !== null + ? $site->companion_snapshot_at + : Carbon::now(); + + return $this->record( + $site, + $previous, + $current, + $previousAt instanceof \DateTimeInterface ? Carbon::instance($previousAt) : null, + Carbon::instance($observedAt), + ); + } catch (Throwable $e) { + Log::warning('updates.observed_update_record_failed', [ + 'site_id' => $site->id, + 'error' => $e->getMessage(), + ]); + + return 0; + } + } + + /** + * @param array $previous + * @param array $current + * @return int rows written + */ + public function record(Site $site, array $previous, array $current, ?Carbon $previousAt, Carbon $observedAt): int + { + $changes = []; + foreach ($this->kinds() as $kind => [$actionType, $extract]) { + $before = $extract($previous); + $after = $extract($current); + foreach ($after as $slug => $item) { + $from = $before[$slug]['version'] ?? ''; + $to = $item['version']; + if ($from === '' || $to === '' || version_compare($to, $from, '<=')) { + continue; + } + $changes[] = [ + 'kind' => $kind, + 'action_type' => $actionType, + 'slug' => (string) $slug, + 'name' => $item['name'] !== '' ? $item['name'] : ($before[$slug]['name'] ?? (string) $slug), + 'from' => $from, + 'to' => $to, + ]; + } + } + + if ($changes === []) { + return 0; + } + + $windowStart = $observedAt->copy()->subHours(self::CONTROL_WINDOW_HOURS); + if ($previousAt !== null && $previousAt->lessThan($windowStart)) { + $windowStart = $previousAt->copy(); + } + + $controlJobs = PluginUpdateJob::query() + ->where('site_id', $site->id) + ->where('created_at', '>=', $windowStart) + ->get(['target_kind', 'target_slug', 'status', 'after_version']); + + $alreadyLogged = ActionLog::query() + ->where('site_id', $site->id) + ->whereIn('action_type', ActionLog::OBSERVED_UPDATE_TYPES) + ->get(['action_type', 'target', 'details']) + ->map(fn (ActionLog $row) => $row->action_type.'|'.$row->target.'|'.($row->details['to_version'] ?? '')) + ->flip(); + + $written = 0; + foreach ($changes as $c) { + if ($this->controlMadeIt($controlJobs, $c['kind'], $c['slug'], $c['to'])) { + continue; + } + if ($alreadyLogged->has($c['action_type'].'|'.$c['slug'].'|'.$c['to'])) { + continue; + } + + $label = match ($c['kind']) { + PluginUpdateJob::KIND_THEME => "Theme updated outside Control: {$c['name']}", + PluginUpdateJob::KIND_CORE => 'WordPress core updated outside Control:', + default => "Plugin updated outside Control: {$c['name']}", + }; + + $row = app(ActionLogger::class)->record( + actionType: $c['action_type'], + summary: "{$label} {$c['from']} → {$c['to']}", + site: $site, + target: $c['slug'], + details: [ + 'slug' => $c['slug'], + 'name' => $c['name'], + 'from_version' => $c['from'], + 'to_version' => $c['to'], + 'observed_at' => $observedAt->toIso8601String(), + 'previous_snapshot_at' => $previousAt?->toIso8601String(), + ], + ok: true, + actor: self::ACTOR, + ranAt: $observedAt, + ); + + if ($row !== null) { + $written++; + } + } + + return $written; + } + + /** + * @param Collection $jobs + */ + private function controlMadeIt(Collection $jobs, string $kind, string $slug, string $toVersion): bool + { + return $jobs->contains(function (PluginUpdateJob $job) use ($kind, $slug, $toVersion) { + if ($job->target_kind !== $kind) { + return false; + } + if ($kind !== PluginUpdateJob::KIND_CORE && $job->target_slug !== $slug) { + return false; + } + + return in_array($job->status, [PluginUpdateJob::STATUS_COMPLETE, ...PluginUpdateJob::LIVE_STATUSES], true) + || ($job->after_version !== null && (string) $job->after_version === $toVersion); + }); + } + + /** + * Per kind: the action type and how to read slug => {name, version} + * from a snapshot. + * + * @return array): array}> + */ + private function kinds(): array + { + $list = fn (string $group, string $key) => function (array $snapshot) use ($group, $key): array { + $items = $snapshot[$group][$key] ?? null; + if (! is_array($items)) { + return []; + } + $out = []; + foreach ($items as $item) { + if (! is_array($item) || ! isset($item['slug']) || ! is_scalar($item['slug']) || (string) $item['slug'] === '') { + continue; + } + $out[(string) $item['slug']] = [ + 'name' => is_scalar($item['name'] ?? null) ? trim((string) $item['name']) : '', + 'version' => is_scalar($item['version'] ?? null) ? trim((string) $item['version']) : '', + ]; + } + + return $out; + }; + + $core = function (array $snapshot): array { + $version = $snapshot['wp_core']['current_version'] ?? null; + + return is_scalar($version) && trim((string) $version) !== '' + ? [self::CORE_SLUG => ['name' => 'WordPress', 'version' => trim((string) $version)]] + : []; + }; + + return [ + PluginUpdateJob::KIND_PLUGIN => [ActionLog::TYPE_PLUGIN_UPDATE_OBSERVED, $list('plugins', 'plugins')], + PluginUpdateJob::KIND_THEME => [ActionLog::TYPE_THEME_UPDATE_OBSERVED, $list('themes', 'items')], + PluginUpdateJob::KIND_CORE => [ActionLog::TYPE_CORE_UPDATE_OBSERVED, $core], + ]; + } +} diff --git a/app/Services/Updates/ThemeIdentityGuard.php b/app/Services/Updates/ThemeIdentityGuard.php new file mode 100644 index 00000000..c45a85d0 --- /dev/null +++ b/app/Services/Updates/ThemeIdentityGuard.php @@ -0,0 +1,192 @@ + */ + private array $listings = []; + + /** + * Why updating this theme would put a different theme in its place, or + * null when it looks like the theme on offer. + * + * @param array $theme one companion_snapshot themes.items entry + */ + public function mismatchReason(array $theme): ?string + { + $slug = trim((string) ($theme['slug'] ?? '')); + if ($slug === '') { + return null; + } + + $name = self::displayName($theme['name'] ?? ''); + $installed = $name !== '' ? "\"{$name}\"" : 'with no name'; + $offered = trim((string) ($theme['new_version'] ?? '')); + $offeredLabel = $offered !== '' ? $offered : 'the offered'; + + $listing = $this->listing($slug); + if ($listing['status'] === self::LISTED) { + $listedName = self::displayName($listing['name'] ?? ''); + if (self::sameName($name, $listedName)) { + return null; + } + + return sprintf( + 'The installed theme is %s, but the wordpress.org theme in the folder "%s" is "%s"%s. Updating to %s would replace the installed theme with that unrelated theme.', + $installed, + $slug, + $listedName, + $listing['version'] ? " (latest {$listing['version']})" : '', + $offeredLabel, + ); + } + + $parent = trim((string) ($theme['template'] ?? $theme['parent'] ?? '')); + if ($parent !== '' && $parent !== $slug) { + return sprintf( + 'The installed theme %s is a child theme of "%s". A child theme gets no updates of its own, so the %s package is a different theme that shares the folder name "%s".', + $installed, + $parent, + $offeredLabel, + $slug, + ); + } + + if (preg_match('/\bchild\b/i', $name) === 1) { + return sprintf( + 'The installed theme %s is named as a child theme. A child theme gets no updates of its own, so the %s package is most likely a different theme that shares the folder name "%s".', + $installed, + $offeredLabel, + $slug, + ); + } + + return null; + } + + /** + * Names compared the way a directory slug is derived from a theme name: + * entities decoded, lower case, a trailing word "theme" and everything + * but letters and digits dropped. "Twenty Twenty-Four" and + * "twenty twenty four" match; "Studio Child Theme" and "Studio" don't. + */ + public static function sameName(string $a, string $b): bool + { + $normalize = function (string $name): string { + $n = mb_strtolower(self::displayName($name)); + $n = (string) preg_replace('/\s+theme\s*$/u', '', $n); + + return (string) preg_replace('/[^\p{L}\p{N}]+/u', '', $n); + }; + + $na = $normalize($a); + + return $na !== '' && $na === $normalize($b); + } + + private static function displayName(mixed $name): string + { + return trim(html_entity_decode((string) $name, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + + /** + * @return array{status: string, name: ?string, version: ?string} + */ + private function listing(string $slug): array + { + return $this->listings[$slug] ??= $this->fetchListing($slug); + } + + /** + * wordpress.org answers an unknown slug with an error body (usually with + * HTTP 404), so the body decides, not the status code alone. + * + * @return array{status: string, name: ?string, version: ?string} + */ + private function fetchListing(string $slug): array + { + $unknown = ['status' => self::UNKNOWN, 'name' => null, 'version' => null]; + + try { + $response = Http::timeout(self::TIMEOUT_SECONDS) + ->retry(2, 500, function (Throwable $exception): bool { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); + }, throw: false) + ->withHeaders(['User-Agent' => 'Clockwork-Monitoring/1.0 (+theme-identity-check)']) + ->get(self::DIRECTORY_URL, [ + 'action' => 'theme_information', + 'request' => ['slug' => $slug], + ]); + } catch (Throwable) { + return $unknown; + } + + if ($response->serverError()) { + return $unknown; + } + + $body = $response->json(); + if (is_array($body) && isset($body['error'])) { + return ['status' => self::NOT_LISTED, 'name' => null, 'version' => null]; + } + + if ($response->successful() && is_array($body) && is_string($body['name'] ?? null) && trim($body['name']) !== '') { + return [ + 'status' => self::LISTED, + 'name' => $body['name'], + 'version' => is_scalar($body['version'] ?? null) ? (string) $body['version'] : null, + ]; + } + + if ($response->status() === 404) { + return ['status' => self::NOT_LISTED, 'name' => null, 'version' => null]; + } + + return $unknown; + } +} diff --git a/app/Services/Updates/UpdateFailureStreakRecorder.php b/app/Services/Updates/UpdateFailureStreakRecorder.php index c66c2512..5a9532e0 100644 --- a/app/Services/Updates/UpdateFailureStreakRecorder.php +++ b/app/Services/Updates/UpdateFailureStreakRecorder.php @@ -11,6 +11,7 @@ use App\Services\Chat\ChatNotifier; use App\Services\Companion\ClockworkCompanionClient; use App\Support\Settings; +use App\Support\UpgraderOutput; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; use Throwable; @@ -24,12 +25,19 @@ * client_visible=true). * * Other plugins on the site continue their regular care-plan updates. - * Any successful update (nightly or manual) resets the streak. + * Any successful update (nightly or manual) resets the streak. A successful + * update that reaches its target version also lifts an auto-failure pause + * for that slug and re-pushes the exceptions list, so the wp-admin notice + * clears. (The nightly loop retries auto-paused slugs when a new release or + * a security fix is offered; see RunNightlyPluginUpdates::filterIgnored.) */ class UpdateFailureStreakRecorder { public const DEFAULT_THRESHOLD = 5; + /** Clears the run of file-permission failures along with a streak. */ + public const FS_RUN_RESET = ['consecutive_fs_failures' => 0, 'fs_streak_started_job_id' => null]; + public const MIN_THRESHOLD = 3; public const MAX_THRESHOLD = 20; @@ -49,6 +57,10 @@ public function record(PluginUpdateJob $job, ?array $result = null, ?Throwable $ if ($job->status === PluginUpdateJob::STATUS_COMPLETE) { $this->resetStreak($job->site_id, $job->target_kind, $job->target_slug); + if ($this->reachedTargetVersion($job)) { + $this->liftAutoPause($job); + } + return; } @@ -74,7 +86,7 @@ public function record(PluginUpdateJob $job, ?array $result = null, ?Throwable $ return; } - $this->incrementStreak($job); + $this->incrementStreak($job, $result); } /** @@ -99,10 +111,78 @@ public function resetStreak(int $siteId, string $targetKind, string $targetSlug) ->where('site_id', $siteId) ->where('target_kind', $targetKind) ->where('target_slug', $targetSlug) - ->update(['consecutive_failures' => 0]); + ->update(['consecutive_failures' => 0, ...self::FS_RUN_RESET]); + } + + /** + * Did the job land on (or past) the version it was queued for? Needs + * both versions: a completed job without them proves nothing about + * whether the version that kept failing is now installed. + */ + private function reachedTargetVersion(PluginUpdateJob $job): bool + { + $target = trim((string) ($job->target_version ?? '')); + $after = trim((string) ($job->after_version ?? '')); + + return $target !== '' && $after !== '' && version_compare($after, $target, '>='); + } + + /** + * Remove this slug's auto-failure pause after a successful update: + * delete the ignore row (manual ignores are left alone), clear the + * streak's pause markers, and push the shorter exceptions list so the + * client's wp-admin stops showing the plugin as paused. + */ + private function liftAutoPause(PluginUpdateJob $job): void + { + $deleted = PluginUpdateIgnore::query() + ->where('site_id', $job->site_id) + ->where('target_kind', $job->target_kind) + ->where('target_slug', $job->target_slug) + ->where('source', PluginUpdateIgnore::SOURCE_AUTO_FAILURE) + ->delete(); + + if ($deleted === 0) { + return; + } + + PluginUpdateFailureStreak::query() + ->where('site_id', $job->site_id) + ->where('target_kind', $job->target_kind) + ->where('target_slug', $job->target_slug) + ->update([ + 'consecutive_failures' => 0, + ...self::FS_RUN_RESET, + 'ignored_at' => null, + 'ignore_id' => null, + ]); + + Log::info('updates.auto_pause_lifted', [ + 'site_id' => $job->site_id, + 'kind' => $job->target_kind, + 'slug' => $job->target_slug, + 'version' => $job->after_version, + 'job_id' => $job->id, + ]); + + $site = $job->site; + if ($site instanceof Site) { + $this->maybePushExceptions($site); + } } - private function incrementStreak(PluginUpdateJob $job): void + /** + * Count one more plugin-level nightly failure and, at the threshold, + * pause it. A run of file-permission failures (reason_code + * fs_permission) as long as the threshold that affects the whole site + * pauses the site once instead of auto-ignoring the plugin — see + * pauseSiteForFilesystemFailure(). Failures for any other reason break + * that run, so a plugin paused for, say, licence failures doesn't pause + * the whole site on its first file-permission failure. + * + * @param array|null $result + */ + private function incrementStreak(PluginUpdateJob $job, ?array $result = null): void { $streak = PluginUpdateFailureStreak::firstOrNew([ 'site_id' => $job->site_id, @@ -113,7 +193,20 @@ private function incrementStreak(PluginUpdateJob $job): void $streak->consecutive_failures = (int) $streak->consecutive_failures + 1; $streak->last_target_version = $job->target_version; $streak->last_from_version = $job->before_version; - $streak->last_error = mb_strimwidth((string) ($job->error ?? 'unknown error'), 0, 500, '…'); + // last_error is copied into the ignore row, the chat alerts and the + // site pause log, so it gets the same redaction as the job's own + // columns even if the job error was written by another path. + $streak->last_error = mb_strimwidth(UpgraderOutput::redactText((string) ($job->error ?? 'unknown error')), 0, 500, '…'); + $streak->reason_code = $job->reason_code ?? ($result['reason_code'] ?? null); + if ($streak->reason_code === UpgraderOutput::REASON_FS_PERMISSION) { + if ((int) $streak->consecutive_fs_failures === 0) { + $streak->fs_streak_started_job_id = $job->id; + } + $streak->consecutive_fs_failures = (int) $streak->consecutive_fs_failures + 1; + } else { + $streak->consecutive_fs_failures = 0; + $streak->fs_streak_started_job_id = null; + } $streak->last_failed_at = Carbon::now(); $streak->last_job_id = $job->id; $streak->save(); @@ -123,10 +216,151 @@ private function incrementStreak(PluginUpdateJob $job): void $enabled = (bool) $settings->get('updates.auto_ignore_enabled', true); if ($enabled && $streak->consecutive_failures >= $threshold) { + if ( + $streak->reason_code === UpgraderOutput::REASON_FS_PERMISSION + && (int) $streak->consecutive_fs_failures >= $threshold + && $this->isSiteWideFilesystemFailure($job, $streak) + ) { + $this->pauseSiteForFilesystemFailure($job, $streak); + + return; + } + $this->applyAutoIgnore($job, $streak); } } + /** + * A file-permission failure is a site problem when no plugin or theme + * update on the site has succeeded since this run of file-permission + * failures began (the job recorded when it did; uncounted transport or + * reaped failures in between don't move it). If one has, the site's + * files are writable in general and only this plugin's own files are + * off, so the plugin keeps the normal per-plugin auto-ignore rather than + * stopping updates for every other plugin on the site. A run with no + * recorded start (from before it was kept) gets the per-plugin ignore. + */ + private function isSiteWideFilesystemFailure(PluginUpdateJob $job, PluginUpdateFailureStreak $streak): bool + { + $runStartJobId = $streak->fs_streak_started_job_id; + if ($runStartJobId === null) { + return false; + } + + return ! PluginUpdateJob::query() + ->where('site_id', $job->site_id) + ->whereIn('target_kind', [PluginUpdateJob::KIND_PLUGIN, PluginUpdateJob::KIND_THEME]) + ->where('status', PluginUpdateJob::STATUS_COMPLETE) + ->where('id', '>', $runStartJobId) + ->exists(); + } + + /** + * WordPress cannot write the site's files ("Could not copy file", + * "Could not create directory …/wp-content/upgrade/…"), so every plugin + * on the site fails the same way, night after night. Pausing each plugin + * as it crosses the threshold turns one site problem into a pile of + * per-plugin pauses (each with its own chat alert and a client-visible + * "paused" notice in wp-admin) and hides the real cause. Instead, pause + * nightly updates for the whole site once, through the same + * auto_updates_paused flag operators use, with one action log and one + * ops alert. No per-plugin ignore is written and nothing is pushed to + * wp-admin. Ops fix the file ownership/permissions and resume the site + * with the existing per-site toggle. + */ + private function pauseSiteForFilesystemFailure(PluginUpdateJob $job, PluginUpdateFailureStreak $streak): void + { + $site = $job->site; + if (! $site instanceof Site) { + return; + } + + $reason = mb_strimwidth(sprintf( + 'Filesystem not writable: %d nightly updates in a row failed with a file-permission error. Fix file ownership/permissions, then resume.', + $streak->consecutive_fs_failures, + ), 0, 255, '…'); + + // Conditional update: the recorder runs after the per-site update + // lock is released, so two of the site's jobs can reach this point + // together; only the one that flips the flag logs and alerts. A site + // that is already paused (by an operator, or an earlier failure) + // keeps its existing reason and gets no second alert. + $flipped = Site::query() + ->whereKey($site->id) + ->where('auto_updates_paused', false) + ->update([ + 'auto_updates_paused' => true, + 'auto_updates_paused_reason' => $reason, + ]); + + if ($flipped === 0) { + return; + } + + $site->refresh(); + + $name = $job->target_name ?: $job->target_slug; + $kindLabel = $job->target_kind === PluginUpdateJob::KIND_THEME ? 'theme' : 'plugin'; + + try { + app(ActionLogger::class)->record( + actionType: ActionLog::TYPE_AUTO_UPDATES_TOGGLED, + summary: "Paused nightly auto-updates on {$site->domain}: filesystem not writable ({$streak->consecutive_fs_failures} failed nightly updates in a row)", + site: $site, + target: 'paused', + details: [ + 'previous' => false, + 'now' => true, + 'reason' => $reason, + 'reason_code' => UpgraderOutput::REASON_FS_PERMISSION, + 'target_kind' => $job->target_kind, + 'target_slug' => $job->target_slug, + 'streak' => $streak->consecutive_fs_failures, + 'from_version' => $streak->last_from_version, + 'target_version' => $streak->last_target_version, + 'last_error' => $streak->last_error, + 'job_id' => $job->id, + ], + ok: true, + actor: 'auto' + ); + } catch (Throwable $e) { + Log::warning('updates.site_pause_action_log_failed', [ + 'site_id' => $site->id, + 'error' => $e->getMessage(), + ]); + } + + // Same gate as the per-plugin alerts (ChatNotifierDispatcher:: + // dispatchForSite): inactive sites stay quiet. + if ($site->is_inactive) { + return; + } + + try { + $title = sprintf(':pause_button: Paused nightly auto-updates on %s: filesystem not writable', $site->domain); + app(ChatNotifier::class)->send($title, [[ + 'fallback' => $title, + 'color' => '#f59e0b', + 'title' => $title, + 'text' => sprintf( + '%d nightly updates in a row failed because WordPress could not write the files, so nightly updates are paused for the whole site instead of plugin by plugin. Make wp-content/plugins and wp-content/upgrade writable by the PHP user, then resume auto-updates for the site.', + $streak->consecutive_fs_failures, + ), + 'fields' => [ + ['title' => 'Site', 'value' => $site->domain, 'short' => true], + ['title' => 'Last failure', 'value' => sprintf('%s %s (%s → %s)', $kindLabel, $name, $streak->last_from_version ?: '?', $streak->last_target_version ?: '?'), 'short' => true], + ['title' => 'Last error', 'value' => mb_strimwidth((string) $streak->last_error, 0, 280, '…'), 'short' => false], + ], + ]]); + } catch (Throwable $e) { + Log::warning('chat_notifier.site_auto_updates_paused_failed', [ + 'site_id' => $site->id, + 'error' => $e->getMessage(), + ]); + } + } + private function applyAutoIgnore(PluginUpdateJob $job, PluginUpdateFailureStreak $streak): void { $site = $job->site; @@ -140,9 +374,10 @@ private function applyAutoIgnore(PluginUpdateJob $job, PluginUpdateFailureStreak ->where('target_slug', $job->target_slug) ->first(); - // Operator already paused this slug by hand — do not rewrite it as - // auto_failure or push it into wp-admin. - if ($existingIgnore && $existingIgnore->isManual()) { + // Operator already paused this slug by hand (or the nightly loop held + // it as a different theme) — do not rewrite it as auto_failure or + // push it into wp-admin. + if ($existingIgnore && ! $existingIgnore->isAutoFailure()) { return; } diff --git a/app/Services/Uptime/UptimeProbeResult.php b/app/Services/Uptime/UptimeProbeResult.php index 3e50950d..80e68f81 100644 --- a/app/Services/Uptime/UptimeProbeResult.php +++ b/app/Services/Uptime/UptimeProbeResult.php @@ -12,6 +12,11 @@ * - succeeded=false → status_code is set if we got a response (4xx/5xx), * or null if the request never completed (timeout / DNS / refused / TLS fail). * In either failure case, `error` carries a short human description. + * - isProbeBlocked=true (always with succeeded=false) → the site answered + * HTTP 429: a rate limiter or anti-bot challenge refused the probe. That + * says nothing either way about whether real visitors can reach the site, + * so UptimeStateUpdater neither counts it as a failure nor as proof of + * life (see blocked()). */ final class UptimeProbeResult { @@ -24,6 +29,7 @@ public function __construct( public readonly ?string $xRobotsTagHeader = null, public readonly bool $isMaintenance = false, public readonly ?string $retryAfter = null, + public readonly bool $isProbeBlocked = false, ) {} public static function success(int $statusCode, int $responseTimeMs, ?string $body = null, ?string $xRobotsTagHeader = null): self @@ -61,6 +67,28 @@ public static function authProtected(int $statusCode, int $responseTimeMs, strin return new self(true, $statusCode, $responseTimeMs, $error, $body, $xRobotsTagHeader); } + /** + * The site rate-limited the probe (HTTP 429). Deliberately NOT a success: + * a host can throttle real visitors too, so this must not set last-up or + * clear a failure streak. Deliberately NOT a plain failure either: a rate + * limiter or anti-bot challenge that singles out automated clients would + * otherwise read as a multi-day outage. The state updater treats it as + * "no signal" and tracks it separately. + */ + public static function blocked(int $statusCode, int $responseTimeMs, string $reason, ?string $retryAfter = null, ?string $body = null, ?string $xRobotsTagHeader = null): self + { + return new self( + succeeded: false, + statusCode: $statusCode, + responseTimeMs: $responseTimeMs, + error: $reason, + body: $body, + xRobotsTagHeader: $xRobotsTagHeader, + retryAfter: $retryAfter, + isProbeBlocked: true, + ); + } + public static function badStatus(int $statusCode, int $responseTimeMs, string $error, ?string $body = null, ?string $xRobotsTagHeader = null): self { return new self(false, $statusCode, $responseTimeMs, $error, $body, $xRobotsTagHeader); diff --git a/app/Services/Uptime/UptimeProber.php b/app/Services/Uptime/UptimeProber.php index 64b7b417..593612dd 100644 --- a/app/Services/Uptime/UptimeProber.php +++ b/app/Services/Uptime/UptimeProber.php @@ -29,7 +29,8 @@ * job; uptime is reachability only. * * Treats 2xx, 3xx, and auth-protected 4xx (401 with WWW-Authenticate, 403) - * as success; everything else as failure. + * as success; 429 as "probe blocked" (neither success nor failure — see + * UptimeProbeResult::blocked()); everything else as failure. */ class UptimeProber { @@ -100,6 +101,20 @@ public function probe(string $url, int $timeoutSec = 10, ?string $requireKeyword return UptimeProbeResult::authProtected($status, $elapsed, 'HTTP 403 (forbidden — likely WAF or allowlist)', $body, $xRobotsTag); } + // Rate-limited (RFC 6585 §4). A host rate limiter or a security + // plugin's anti-bot challenge is refusing the probe itself, which + // tells us nothing about whether real visitors get through. Report it + // as blocked so the state machine neither opens an outage on it nor + // treats it as proof the site is up. + if ($status === 429) { + // Capped: the header is remote-controlled and the reason lands in + // a 500-char column. + $retryAfter = $response->header('Retry-After') !== '' ? mb_substr($response->header('Retry-After'), 0, 64) : null; + $reason = 'HTTP 429 (rate-limited — probe blocked'.($retryAfter !== null ? " · Retry-After: {$retryAfter}" : '').')'; + + return UptimeProbeResult::blocked($status, $elapsed, $reason, $retryAfter, $body, $xRobotsTag); + } + // Scheduled maintenance mode (RFC 7231 §6.6.4): WordPress core and // reputable maintenance plugins (SeedProd, Kadence, WP Maintenance Mode) // emit 503 + Retry-After (or recognizable maintenance copy) for SEO diff --git a/app/Services/Uptime/UptimeStateUpdater.php b/app/Services/Uptime/UptimeStateUpdater.php index af5f3ce6..89143d26 100644 --- a/app/Services/Uptime/UptimeStateUpdater.php +++ b/app/Services/Uptime/UptimeStateUpdater.php @@ -27,6 +27,24 @@ * - unknown → up: instant, no event/alert (initial state, not a transition) * - unknown → down: 2 failures, but emits a "first detected down" event * (we still want to know about a site we've never seen up) + * - up/unknown → probe_blocked: the site answered HTTP 429 (a rate + * limiter or anti-bot challenge refused the probe) as many times in a + * row as it takes failures to go down. That is neither a failure nor + * proof of life: the failure streak is not incremented, last-up is not + * set, and the site never goes down on it. A shorter run of 429s leaves + * no trace. Entering the state records a probe_blocked event dated at + * the run's first 429. Staying blocked for the configured number of + * hours sends one ops chat notice. A sub-threshold failure while + * blocked leaves the blocked stretch open; a success, maintenance, or a + * real down transition ends it. + * - down/maintenance → probe_blocked: only after 429s for the configured + * number of hours. Until then the outage (or maintenance) stays open + * and nothing is recorded or sent: a 429 during a real outage is often + * the overloaded origin's rate limiter, so a later failure belongs to + * the same outage and a success is its normal recovery (with the + * recovery alerts). A run that long closes the outage at the first 429, + * so a site that only rate-limits the probe doesn't show days down. + * Any answer other than a 429 ends a run of 429s. */ class UptimeStateUpdater { @@ -38,6 +56,15 @@ class UptimeStateUpdater */ public const FAILURE_THRESHOLD_FOR_DOWN = 2; + /** + * Hours a site may keep rate-limiting the probe before ops gets a chat + * notice. Runtime value comes from the Settings key below (Monitoring → + * Settings); this is the default. + */ + public const PROBE_BLOCKED_ALERT_HOURS = 6; + + public const SETTING_PROBE_BLOCKED_ALERT_HOURS = 'monitoring.uptime_probe_blocked_alert_hours'; + public function __construct( protected ChatNotifier $mattermost, protected ActionLogger $actionLogger, @@ -50,7 +77,9 @@ public function update(Site $site, UptimeProbeResult $probe): void $now = Carbon::now(); $previousState = $site->uptime_state ?? 'unknown'; - if ($probe->isMaintenance) { + if ($probe->isProbeBlocked) { + $this->handleBlocked($site, $probe, $previousState, $now); + } elseif ($probe->isMaintenance) { $this->handleMaintenance($site, $probe, $previousState, $now); } elseif ($probe->succeeded) { $this->handleSuccess($site, $probe, $previousState, $now); @@ -58,10 +87,164 @@ public function update(Site $site, UptimeProbeResult $probe): void $this->handleFailure($site, $probe, $previousState, $now); } - $site->forceFill([ + $fields = [ 'uptime_last_checked_at' => $now, 'uptime_last_status_code' => $probe->statusCode, - ])->save(); + ]; + + // Any real answer ends a run of 429s, and the blocked stretch ends + // once the site has left the probe_blocked state on one (up, + // maintenance, or a confirmed outage). + if (! $probe->isProbeBlocked) { + $fields['uptime_consecutive_blocked'] = 0; + if ($site->uptime_state !== Site::UPTIME_STATE_PROBE_BLOCKED) { + $fields['uptime_probe_blocked_since'] = null; + $fields['uptime_probe_blocked_alerted_at'] = null; + } + } + + $site->forceFill($fields)->save(); + } + + /** + * HTTP 429: the site is refusing the probe, so we can't tell whether it is + * up. Never counts toward the failure streak, never sets last-up, never + * opens an outage. See the class docblock for when a run of 429s becomes + * the probe_blocked state. + */ + private function handleBlocked(Site $site, UptimeProbeResult $probe, string $previousState, Carbon $now): void + { + $run = (int) $site->uptime_consecutive_blocked + 1; + // The first 429 of the run: anything else in between resets it. + $blockedSince = $site->uptime_probe_blocked_since ?? $now; + + if ($previousState !== Site::UPTIME_STATE_PROBE_BLOCKED) { + $confirmedState = in_array($previousState, [Site::UPTIME_STATE_DOWN, 'maintenance'], true); + $proven = $confirmedState + ? abs($blockedSince->diffInSeconds($now)) >= $this->probeBlockedAlertHours() * 3600 + : $run >= $this->failureThreshold(); + + if (! $proven) { + // Remember the run; change nothing else. A down site stays + // down and keeps its failure streak, so a failure after this + // is the same outage and a success its recovery. + $site->forceFill([ + 'uptime_consecutive_blocked' => $run, + 'uptime_probe_blocked_since' => $blockedSince, + ])->save(); + + return; + } + } + + $isTransition = $previousState !== Site::UPTIME_STATE_PROBE_BLOCKED; + $wasDown = $previousState === Site::UPTIME_STATE_DOWN; + $downSince = $site->uptime_down_since; + + $fields = [ + 'uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED, + 'uptime_probe_blocked_since' => $blockedSince, + 'uptime_consecutive_blocked' => $run, + 'uptime_down_since' => null, + 'uptime_maintenance_since' => null, + ]; + + // Closing an outage resets the streak the way a success would; + // otherwise a single blip after this would count as the second + // failure of an outage that has already ended. + if ($wasDown) { + $fields['uptime_consecutive_failures'] = 0; + } + + $site->forceFill($fields)->save(); + + if ($isTransition) { + // Dated at the run's first 429: only 429s came after it, so the + // event can't overlap another, and the outage it closes ends there. + $this->recordEvent($site, SiteUptimeEvent::TYPE_PROBE_BLOCKED, $probe, $blockedSince); + $this->logProbeBlocked( + $site, + $probe, + $wasDown && $downSince !== null ? (int) abs($downSince->diffInSeconds($blockedSince)) : null, + $blockedSince, + ); + } + + $this->maybeNotifyProbeBlocked($site, $probe, $blockedSince, $now); + } + + /** Consecutive failures (and, before probe_blocked, 429s) it takes to change state. */ + private function failureThreshold(): int + { + return max(1, (int) app(Settings::class)->get('monitoring.uptime_failure_threshold', self::FAILURE_THRESHOLD_FOR_DOWN)); + } + + private function logProbeBlocked(Site $site, UptimeProbeResult $probe, ?int $closedOutageSec, Carbon $at): void + { + $summary = $closedOutageSec !== null + ? sprintf('%s answers again but is blocking the uptime probe (HTTP %d) — outage closed after %d min; recovery not confirmed', $site->domain, (int) $probe->statusCode, (int) round($closedOutageSec / 60)) + : sprintf('%s is blocking the uptime probe (HTTP %d) — not counted as downtime', $site->domain, (int) $probe->statusCode); + + $this->actionLogger->record( + actionType: ActionLog::TYPE_UPTIME_TRANSITION, + summary: $summary, + site: $site, + target: Site::UPTIME_STATE_PROBE_BLOCKED, + details: array_filter([ + 'status_code' => $probe->statusCode, + 'retry_after' => $probe->retryAfter, + 'closed_outage_sec' => $closedOutageSec, + ], fn ($v) => $v !== null), + ok: false, + error: $probe->error, + elapsedMs: $probe->responseTimeMs, + actor: 'scheduled', + ranAt: $at, + ); + } + + /** + * One chat notice per blocked stretch, once it has lasted the configured + * number of hours. Ignored sites stay silent (same rule as down alerts); + * the dispatcher also silences inactive sites. + */ + private function maybeNotifyProbeBlocked(Site $site, UptimeProbeResult $probe, Carbon $blockedSince, Carbon $now): void + { + if ($site->uptime_probe_blocked_alerted_at !== null || $site->isUptimeIgnored()) { + return; + } + + $blockedSec = (int) abs($blockedSince->diffInSeconds($now)); + if ($blockedSec < $this->probeBlockedAlertHours() * 3600) { + return; + } + + $site->forceFill(['uptime_probe_blocked_alerted_at' => $now])->save(); + + try { + $this->mattermost->uptimeProbeBlocked($site, $blockedSec, $probe->statusCode, $probe->retryAfter); + } catch (\Throwable $e) { + Log::warning('uptime.mattermost_probe_blocked_failed', ['site_id' => $site->id, 'error' => $e->getMessage()]); + } + + $this->actionLogger->record( + actionType: ActionLog::TYPE_UPTIME_TRANSITION, + summary: sprintf('%s has blocked the uptime probe for %d h (HTTP %d) — ops notified', $site->domain, intdiv($blockedSec, 3600), (int) $probe->statusCode), + site: $site, + target: Site::UPTIME_STATE_PROBE_BLOCKED, + details: ['status_code' => $probe->statusCode, 'blocked_sec' => $blockedSec, 'notified' => true], + ok: false, + error: $probe->error, + elapsedMs: $probe->responseTimeMs, + actor: 'scheduled', + ); + } + + private function probeBlockedAlertHours(): int + { + $hours = (int) app(Settings::class)->get(self::SETTING_PROBE_BLOCKED_ALERT_HOURS, self::PROBE_BLOCKED_ALERT_HOURS); + + return max(1, $hours); } private function handleMaintenance(Site $site, UptimeProbeResult $probe, string $previousState, Carbon $now): void @@ -88,8 +271,10 @@ private function handleSuccess(Site $site, UptimeProbeResult $probe, string $pre { $isRecoveryFromDown = $previousState === 'down'; $isRecoveryFromMaint = $previousState === 'maintenance'; + $isRecoveryFromBlocked = $previousState === Site::UPTIME_STATE_PROBE_BLOCKED; $downSince = $site->uptime_down_since; $maintSince = $site->uptime_maintenance_since; + $blockedSince = $site->uptime_probe_blocked_since; $site->forceFill([ 'uptime_state' => 'up', @@ -131,6 +316,22 @@ private function handleSuccess(Site $site, UptimeProbeResult $probe, string $pre if (! $site->isUptimeIgnored()) { $this->fireMaintenanceRecoveryNotification($site, $maintSec, $probe); } + } elseif ($isRecoveryFromBlocked) { + // Closes the blocked stretch in the event log. No chat: nothing + // was down, and the stretch was never reported as an outage. + $blockedSec = $blockedSince !== null ? (int) abs($blockedSince->diffInSeconds($now)) : null; + $this->recordEvent($site, SiteUptimeEvent::TYPE_UP, $probe, $now); + + $this->actionLogger->record( + actionType: ActionLog::TYPE_UPTIME_TRANSITION, + summary: "{$site->domain} uptime probe gets through again (was blocked for ".($blockedSec !== null ? (int) round($blockedSec / 60) : 0).' min)', + site: $site, + target: 'up', + details: ['status_code' => $probe->statusCode, 'probe_blocked_sec' => $blockedSec], + ok: true, + elapsedMs: $probe->responseTimeMs, + actor: 'scheduled', + ); } } @@ -157,11 +358,7 @@ private function handleFailure(Site $site, UptimeProbeResult $probe, string $pre // Transition to 'down' once threshold is crossed (and not already down). // Threshold comes from Settings (Monitoring → Settings page) and falls // back to the constant default for fresh installs. - $threshold = (int) app(Settings::class)->get( - 'monitoring.uptime_failure_threshold', - self::FAILURE_THRESHOLD_FOR_DOWN, - ); - if ($previousState !== 'down' && $newFailures >= $threshold) { + if ($previousState !== 'down' && $newFailures >= $this->failureThreshold()) { // Run the SSH diagnostic ONCE per outage at the transition point. // If the probe was a bare 503 without headers, but the SSH diagnostician // finds maintenance mode active (.maintenance file or nginx maintenance.conf), diff --git a/app/Support/DomainNormalizer.php b/app/Support/DomainNormalizer.php new file mode 100644 index 00000000..13524653 --- /dev/null +++ b/app/Support/DomainNormalizer.php @@ -0,0 +1,55 @@ +…". For premium plugins that URL carries the licence key, a + * vendor authorisation token or JWT, or a signed, expiring download link — + * in the query string, and sometimes in the path. Everything Control keeps + * or sends from an update result (plugin_update_jobs.messages and .error, + * action_logs details/summary/error, failure streaks, ignores, chat alerts + * and the Companion activity mirror) goes through redact()/redactText() + * first: + * - a download URL is cut down to scheme://host/[redacted]; + * - any other URL loses its credentials, query string and fragment, and + * token-like path segments are masked; + * - JWTs, bearer tokens and secret-named key=value pairs are masked + * wherever they appear. + * + * Classification. Companion/Renegade versions in the field report ok=true + * even when the upgrader logged "Error: …" and nothing changed on the site. + * errorLines() pulls out the real error — only the last "Error:" line plus + * the line before it, which carries the detail (e.g. the file that could + * not be copied) — never a download line. classify() maps those lines to a + * reason code so failures can be grouped by cause. + */ +final class UpgraderOutput +{ + /** The site's files or wp-content/upgrade are not writable by PHP. A site problem, not a plugin one. */ + public const REASON_FS_PERMISSION = 'fs_permission'; + + /** The vendor refused the download (licence inactive or expired, 401/403). */ + public const REASON_DOWNLOAD_UNAUTHORIZED = 'download_unauthorized'; + + /** The download was not a usable zip (vendor returned an error page, truncated file). */ + public const REASON_BAD_PACKAGE = 'bad_package'; + + /** WordPress had no download URL for the update (typical of an unlicensed premium plugin). */ + public const REASON_NO_PACKAGE = 'no_package'; + + /** No upgrader error at all: WordPress simply no longer offered the update when the job ran. */ + public const REASON_NOT_OFFERED = 'not_offered'; + + /** An upgrader error that matches none of the known causes. */ + public const REASON_UPGRADER_ERROR = 'upgrader_error'; + + public const REDACTED = '[redacted]'; + + private const MAX_LINE_LENGTH = 300; + + /** + * Parameter names whose values are secrets wherever a name=value pair + * shows up (query strings that escaped URL parsing, HTML-escaped + * `&key=` runs, error text). + */ + private const SECRET_PARAM_NAMES = 'license|licence|license_key|licence_key|license_hash|license_id|key|api_key|apikey|site_key|token|access_token|auth|authorization|password|passwd|pwd|secret|signature|sig|purchase_code|purchase_key|code|x-amz-signature|x-amz-credential|x-amz-security-token|awsaccesskeyid|key-pair-id'; + + /** + * A URL, optionally preceded by the "Downloading … from " phrase the + * upgrader logs before fetching a package. No /u modifier on purpose: + * an invalid UTF-8 byte must not make preg_* bail out and hand back + * the unredacted text. + */ + private const URL_PATTERN = '~(Downloading\b[^\r\n]{0,80}?\bfrom\s+)?((?:https?|ftp)://[^\s<>"\'`]+)~i'; + + /** + * Recursively redact every string in a value (an update result, a + * messages list, action_log details). Keys and non-string scalars are + * left alone. + */ + public static function redact(mixed $value): mixed + { + if (is_string($value)) { + return self::redactText($value); + } + + if (is_array($value)) { + foreach ($value as $key => $item) { + $value[$key] = self::redact($item); + } + } + + return $value; + } + + public static function redactText(string $text): string + { + if ($text === '') { + return $text; + } + + $redacted = preg_replace_callback(self::URL_PATTERN, function (array $m): string { + [$url, $suffix] = self::splitTrailing($m[2]); + $isDownload = ($m[1] ?? '') !== ''; + + return $m[1].($isDownload ? self::redactDownloadUrl($url) : self::redactUrl($url)).$suffix; + }, $text); + + if ($redacted === null) { + // Regex engine gave up (backtrack limit, bad input). Fail closed. + $redacted = preg_replace('~(?:https?|ftp)://\S+~i', '[url '.self::REDACTED.']', $text); + if ($redacted === null) { + return self::REDACTED; + } + } + + // A JWT (or any base64url-encoded JSON object) starts with "eyJ". + $redacted = preg_replace('/eyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]*){0,2}/', self::REDACTED, $redacted) ?? self::REDACTED; + $redacted = preg_replace('/\b(Bearer|Basic)\s+[A-Za-z0-9._~+\/=-]{8,}/i', '$1 '.self::REDACTED, $redacted) ?? self::REDACTED; + + return preg_replace( + '/(?]+/i', + '$1='.self::REDACTED, + $redacted, + ) ?? self::REDACTED; + } + + /** + * Strip credentials, query string and fragment from a URL and mask + * token-like path segments. Keeps scheme, host, port and the rest of + * the path so the URL stays useful for diagnosis. + */ + public static function redactUrl(string $url): string + { + $parts = parse_url($url); + if ($parts === false || ! isset($parts['scheme'], $parts['host'])) { + $url = preg_replace('~^([a-z][a-z0-9+.-]*://)[^/?#]*@~i', '$1', $url) ?? ''; + + return preg_replace('~[?#].*$~s', '', $url) ?? self::REDACTED; + } + + $segments = explode('/', (string) ($parts['path'] ?? '')); + foreach ($segments as $i => $segment) { + if ($segment !== '' && self::looksLikeSecret($segment)) { + $segments[$i] = self::REDACTED; + } + } + + return self::origin($parts).implode('/', $segments); + } + + public static function isErrorLine(string $line): bool + { + return preg_match('/^\s*Error:/i', $line) === 1; + } + + public static function isDownloadLine(string $line): bool + { + return preg_match('/^\s*Downloading\b/i', $line) === 1; + } + + /** + * The upgrader's real error: the last line starting with "Error:" plus + * the line just before it (unless that one is a download line), cleaned + * of HTML and redacted. Empty when the output holds no "Error:" line. + * + * @return list + */ + public static function errorLines(mixed $messages): array + { + if (is_string($messages)) { + $messages = preg_split('/\R/', $messages) ?: []; + } + if (! is_array($messages)) { + return []; + } + + $lines = []; + foreach ($messages as $message) { + if (! is_scalar($message)) { + continue; + } + $line = self::cleanLine((string) $message); + if ($line !== '') { + $lines[] = $line; + } + } + + for ($i = count($lines) - 1; $i >= 0; $i--) { + if (! self::isErrorLine($lines[$i])) { + continue; + } + + $out = []; + if ($i > 0 && ! self::isDownloadLine($lines[$i - 1])) { + $out[] = $lines[$i - 1]; + } + $out[] = $lines[$i]; + + return $out; + } + + return []; + } + + /** + * Map upgrader error lines (see errorLines()) to a reason code. + * + * @param list $lines + */ + public static function classify(array $lines): string + { + $text = strtolower(implode("\n", $lines)); + + return match (true) { + (bool) preg_match('/could not (copy|create|remove|delete|move|write)|could not access filesystem|filesystem error|unable to locate (wordpress|needed folder)|not writable|permission denied|could not be copied|inconsistent file permissions/', $text) => self::REASON_FS_PERMISSION, + (bool) preg_match('/unauthori[sz]ed|forbidden|not authori[sz]ed|\b40[13]\b|licen[cs]e|purchase code/', $text) => self::REASON_DOWNLOAD_UNAUTHORIZED, + (bool) preg_match('/pclzip|incompatible archive|central dir|not a valid zip|package could not be installed|contains no files|could not extract|unable to unzip/', $text) => self::REASON_BAD_PACKAGE, + (bool) preg_match('/valid url was not provided|package not available|no update package/', $text) => self::REASON_NO_PACKAGE, + default => self::REASON_UPGRADER_ERROR, + }; + } + + private static function cleanLine(string $line): string + { + $line = html_entity_decode(strip_tags($line), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $line = self::redactText($line); + $line = trim(preg_replace('/\s+/', ' ', $line) ?? ''); + + return mb_strimwidth($line, 0, self::MAX_LINE_LENGTH, '…'); + } + + /** + * Download URLs are where licence keys, vendor tokens and signatures + * live — in the query string and, for some vendors, in the path — so + * only the origin survives. + */ + private static function redactDownloadUrl(string $url): string + { + $parts = parse_url($url); + if ($parts === false || ! isset($parts['scheme'], $parts['host'])) { + return '[url '.self::REDACTED.']'; + } + + return self::origin($parts).'/'.self::REDACTED; + } + + /** + * @param array $parts + */ + private static function origin(array $parts): string + { + return strtolower((string) $parts['scheme']).'://'.$parts['host'] + .(isset($parts['port']) ? ':'.$parts['port'] : ''); + } + + /** + * Peel trailing punctuation and the upgrader's ellipsis off a matched + * URL, so "…/pkg.zip…" keeps its "…" after redaction. + * + * @return array{0: string, 1: string} + */ + private static function splitTrailing(string $url): array + { + $suffix = ''; + while (preg_match('/(…|…|\xE2\x80\xA6|\.\.\.|[.,;:!?)\]}])$/', $url, $m) === 1) { + $suffix = $m[1].$suffix; + $url = substr($url, 0, -strlen($m[1])); + } + + return [$url, $suffix]; + } + + /** + * A path segment that is (or contains) a JWT, or a long run of mixed + * letters and digits — the shape of licence keys, hashes and download + * tokens. Ordinary slugs and versioned zip names stay readable. + */ + private static function looksLikeSecret(string $segment): bool + { + $segment = rawurldecode($segment); + + if (preg_match('/eyJ[A-Za-z0-9_-]{8,}/', $segment) === 1) { + return true; + } + + foreach (preg_split('/[^A-Za-z0-9_-]+/', $segment) ?: [] as $run) { + $length = strlen($run); + if ($length < 12) { + continue; + } + $hasDigit = preg_match('/\d/', $run) === 1; + if (! $hasDigit) { + continue; + } + if ($length >= 16 && preg_match('/[A-Za-z]/', $run) === 1) { + return true; + } + if (preg_match('/[a-z]/', $run) === 1 && preg_match('/[A-Z]/', $run) === 1) { + return true; + } + } + + return false; + } +} diff --git a/config/clockwork.php b/config/clockwork.php index d2b3cd98..b928b0ea 100644 --- a/config/clockwork.php +++ b/config/clockwork.php @@ -429,6 +429,12 @@ // this are usually pages Lighthouse can't measure stably anyway. 'poll_interval_seconds' => (int) env('CLOCKWORK_GTMETRIX_POLL_INTERVAL', 5), 'poll_max_attempts' => (int) env('CLOCKWORK_GTMETRIX_POLL_MAX_ATTEMPTS', 36), + // API credits the plan refills each day (at ~04:27 UTC; unused + // credits don't carry over). One test costs one credit. The nightly + // rotation's month-end catch-up never schedules more scans than this. + // The default is the smallest (Core) plan's 10, a deliberately + // conservative floor: set it to the account's plan. + 'daily_credits' => (int) env('CLOCKWORK_GTMETRIX_DAILY_CREDITS', 10), ], 'security_scans' => [ @@ -531,6 +537,13 @@ 'enabled' => (bool) env('CLOCKWORK_CARE_PLANS_ENABLED', true), ], + // Client reports (modules/ClientReports). Times are stored in UTC, which + // reads as the wrong time of day to clients; the report emails show + // outage start times in this zone, with its abbreviation (e.g. "EDT"). + 'reports' => [ + 'timezone' => env('CLOCKWORK_REPORTS_TIMEZONE', 'America/New_York'), + ], + // Anonymous usage telemetry — enabled by default, easily disabled anytime. // Sends only a bucketed site count and list of enabled module IDs. // Never domains, IPs, emails, database contents, or any identifying data. diff --git a/database/migrations/2026_11_03_120000_add_backup_relay_attempt_state_to_sites_table.php b/database/migrations/2026_11_03_120000_add_backup_relay_attempt_state_to_sites_table.php new file mode 100644 index 00000000..a475f648 --- /dev/null +++ b/database/migrations/2026_11_03_120000_add_backup_relay_attempt_state_to_sites_table.php @@ -0,0 +1,40 @@ +timestamp('backup_relay_last_attempt_at') + ->nullable() + ->after('backup_relay_last_manifest'); + $table->text('backup_relay_last_error') + ->nullable() + ->after('backup_relay_last_attempt_at'); + $table->timestamp('backup_relay_failing_since') + ->nullable() + ->after('backup_relay_last_error'); + }); + } + + public function down(): void + { + Schema::table('sites', function (Blueprint $table) { + $table->dropColumn([ + 'backup_relay_last_attempt_at', + 'backup_relay_last_error', + 'backup_relay_failing_since', + ]); + }); + } +}; diff --git a/database/migrations/2026_11_03_120100_add_reason_code_to_plugin_update_jobs_and_failure_streaks.php b/database/migrations/2026_11_03_120100_add_reason_code_to_plugin_update_jobs_and_failure_streaks.php new file mode 100644 index 00000000..61263f32 --- /dev/null +++ b/database/migrations/2026_11_03_120100_add_reason_code_to_plugin_update_jobs_and_failure_streaks.php @@ -0,0 +1,43 @@ +string('reason_code', 32)->nullable()->after('error'); + }); + + Schema::table('plugin_update_failure_streaks', function (Blueprint $table) { + $table->string('reason_code', 32)->nullable()->after('last_error'); + }); + } + + public function down(): void + { + Schema::table('plugin_update_failure_streaks', function (Blueprint $table) { + $table->dropColumn('reason_code'); + }); + + Schema::table('plugin_update_jobs', function (Blueprint $table) { + $table->dropColumn('reason_code'); + }); + } +}; diff --git a/database/migrations/2026_11_03_120200_add_uptime_probe_blocked_to_sites.php b/database/migrations/2026_11_03_120200_add_uptime_probe_blocked_to_sites.php new file mode 100644 index 00000000..19f89c27 --- /dev/null +++ b/database/migrations/2026_11_03_120200_add_uptime_probe_blocked_to_sites.php @@ -0,0 +1,53 @@ + ! Schema::hasColumn('sites', $c))); + if ($missing === []) { + return; + } + + if (DB::getDriverName() === 'mysql') { + // INSTANT add at end of table (sites is hit continuously by the + // uptime probe and the dashboard). + DB::statement('SET SESSION lock_wait_timeout = 15'); + $adds = implode(', ', array_map(fn (string $c) => "ADD COLUMN {$c} TIMESTAMP NULL", $missing)); + DB::statement("ALTER TABLE sites {$adds}, ALGORITHM=INSTANT"); + + return; + } + + Schema::table('sites', function (Blueprint $table) use ($missing) { + foreach ($missing as $column) { + $table->timestamp($column)->nullable(); + } + }); + } + + public function down(): void + { + Schema::table('sites', function (Blueprint $table) { + $table->dropColumn(['uptime_probe_blocked_since', 'uptime_probe_blocked_alerted_at']); + }); + } +}; diff --git a/database/migrations/2026_11_03_120300_backfill_is_wordpress_on_custom_companion_sites.php b/database/migrations/2026_11_03_120300_backfill_is_wordpress_on_custom_companion_sites.php new file mode 100644 index 00000000..794a2033 --- /dev/null +++ b/database/migrations/2026_11_03_120300_backfill_is_wordpress_on_custom_companion_sites.php @@ -0,0 +1,30 @@ +where('hosting_provider', 'custom') + ->where('companion_installed', true) + ->where('is_wordpress', false) + ->whereNotNull('companion_snapshot->wp_core') + ->update(['is_wordpress' => true]); + } + + public function down(): void + { + // One-time data repair; the rows were wrong before, so nothing to undo. + } +}; diff --git a/database/migrations/2026_11_03_120400_add_retry_and_fs_streak_state_to_plugin_update_failure_streaks.php b/database/migrations/2026_11_03_120400_add_retry_and_fs_streak_state_to_plugin_update_failure_streaks.php new file mode 100644 index 00000000..07ca52e4 --- /dev/null +++ b/database/migrations/2026_11_03_120400_add_retry_and_fs_streak_state_to_plugin_update_failure_streaks.php @@ -0,0 +1,40 @@ +timestamp('last_retry_queued_at')->nullable()->after('last_failed_at'); + $table->unsignedInteger('consecutive_fs_failures')->default(0)->after('reason_code'); + $table->unsignedBigInteger('fs_streak_started_job_id')->nullable()->after('consecutive_fs_failures'); + }); + } + + public function down(): void + { + Schema::table('plugin_update_failure_streaks', function (Blueprint $table) { + $table->dropColumn(['last_retry_queued_at', 'consecutive_fs_failures', 'fs_streak_started_job_id']); + }); + } +}; diff --git a/database/migrations/2026_11_03_120500_add_uptime_consecutive_blocked_to_sites.php b/database/migrations/2026_11_03_120500_add_uptime_consecutive_blocked_to_sites.php new file mode 100644 index 00000000..feee9519 --- /dev/null +++ b/database/migrations/2026_11_03_120500_add_uptime_consecutive_blocked_to_sites.php @@ -0,0 +1,44 @@ +unsignedSmallInteger('uptime_consecutive_blocked')->default(0); + }); + } + + public function down(): void + { + Schema::table('sites', function (Blueprint $table) { + $table->dropColumn('uptime_consecutive_blocked'); + }); + } +}; diff --git a/database/migrations/2026_11_05_120000_repair_exact_version_plugin_vulnerabilities.php b/database/migrations/2026_11_05_120000_repair_exact_version_plugin_vulnerabilities.php new file mode 100644 index 00000000..1b805058 --- /dev/null +++ b/database/migrations/2026_11_05_120000_repair_exact_version_plugin_vulnerabilities.php @@ -0,0 +1,70 @@ +whereNull('from_version') + ->whereNotNull('to_version') + ->where('to_inclusive', true) + ->where('title', 'like', '%==%') + ->orderBy('id') + ->get(['id', 'wordfence_id', 'slug', 'to_version', 'title']); + + foreach ($rows as $row) { + if (! preg_match('/==\s*(\S+)\s*(?:\([^)]*\)\s*)?$/u', (string) $row->title, $m)) { + continue; + } + $version = (string) $row->to_version; + if ($m[1] !== $version) { + continue; + } + + // The unique key covers (wordfence_id, slug, from, to): when the + // exact row already exists, the broken copy is simply removed. + $exactExists = DB::table('plugin_vulnerabilities') + ->where('wordfence_id', $row->wordfence_id) + ->where('slug', $row->slug) + ->where('from_version', $version) + ->where('to_version', $version) + ->exists(); + + if ($exactExists) { + DB::table('plugin_vulnerabilities')->where('id', $row->id)->delete(); + + continue; + } + + DB::table('plugin_vulnerabilities')->where('id', $row->id)->update([ + 'from_version' => $version, + 'from_inclusive' => true, + 'to_inclusive' => true, + 'patched_in' => null, + ]); + } + } + + public function down(): void + { + // One-time data repair; the rows were wrong before, so nothing to undo. + } +}; diff --git a/modules/BackupRelay/src/Console/RunBackupRelayNow.php b/modules/BackupRelay/src/Console/RunBackupRelayNow.php index 5a822b26..606cdaeb 100644 --- a/modules/BackupRelay/src/Console/RunBackupRelayNow.php +++ b/modules/BackupRelay/src/Console/RunBackupRelayNow.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Log; use Modules\BackupRelay\Jobs\ArchiveSiteBackupJob; use Modules\BackupRelay\Services\GlacierUploader; +use Modules\BackupRelay\Services\RelayAttemptRecorder; use Modules\Core\Contracts\DirectS3BackupRelayAdapter; use Modules\Core\Contracts\HostingProvider; use Throwable; @@ -120,6 +121,10 @@ public function handle(GlacierUploader $uploader, Settings $settings): int ]; $this->error(" [fail] {$site->domain} — {$e->getMessage()}"); Log::error('backup_relay.site_failed', ['site_id' => $site->id, 'direct_only' => $directOnly, 'error' => $e->getMessage()]); + // The job records its own failures; this also covers anything + // that failed around it. Neither the log nor this command's + // output is kept anywhere an operator can see it. + RelayAttemptRecorder::recordFailure($site, $e->getMessage()); } finally { if ($locked) { Cache::forget($lockKey); diff --git a/modules/BackupRelay/src/Jobs/ArchiveSiteBackupJob.php b/modules/BackupRelay/src/Jobs/ArchiveSiteBackupJob.php index 1a840648..ba8a6d5c 100644 --- a/modules/BackupRelay/src/Jobs/ArchiveSiteBackupJob.php +++ b/modules/BackupRelay/src/Jobs/ArchiveSiteBackupJob.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Modules\BackupRelay\Services\GlacierUploader; +use Modules\BackupRelay\Services\RelayAttemptRecorder; use Modules\Core\Contracts\BackupRef; use Modules\Core\Contracts\BackupRelayAdapter; use Modules\Core\Contracts\DirectS3BackupRelayAdapter; @@ -42,6 +43,22 @@ public function handle(GlacierUploader $uploader): string { $site = $this->site instanceof Site ? $this->site : Site::query()->findOrFail($this->site); + try { + return $this->archive($site, $uploader); + } catch (Throwable $e) { + // Keep the failure on the site row so it is visible without log + // access. Queued runs never reach RunBackupRelayNow's own catch. + RelayAttemptRecorder::recordFailure($site, $e->getMessage()); + + throw $e; + } + } + + /** + * @return string 'archived' | 'skipped' + */ + private function archive(Site $site, GlacierUploader $uploader): string + { $host = $site->host(); if (! $host->supports(HostingProvider::CAP_BACKUP_RELAY)) { Log::info("BackupRelay: Provider {$host->id()} does not support CAP_BACKUP_RELAY for {$site->domain}."); @@ -216,16 +233,18 @@ private function markArchived(Site $site, array $meta = [], mixed $disk = null, { $updates = [ 'backup_relay_last_archived_at' => now(), + ...RelayAttemptRecorder::successAttributes(), ]; if (array_key_exists('size_bytes', $meta) && $meta['size_bytes'] !== null) { $updates['backup_relay_last_size_bytes'] = (int) $meta['size_bytes']; } + // The stored hash describes the newest archive only. When this archive + // came without one, clear it: keeping the previous archive's hash would + // pair it with the new key, and restore would fail the checksum. $sha = isset($meta['sha256']) ? trim((string) $meta['sha256']) : ''; - if ($sha !== '') { - $updates['backup_relay_last_sha256'] = $sha; - } + $updates['backup_relay_last_sha256'] = $sha !== '' ? $sha : null; $manifest = isset($meta['manifest']) && is_array($meta['manifest']) ? $meta['manifest'] : null; if ($manifest !== null) { diff --git a/modules/BackupRelay/src/Services/CompanionBackupRelayAdapter.php b/modules/BackupRelay/src/Services/CompanionBackupRelayAdapter.php index 41d1a518..1c728417 100644 --- a/modules/BackupRelay/src/Services/CompanionBackupRelayAdapter.php +++ b/modules/BackupRelay/src/Services/CompanionBackupRelayAdapter.php @@ -22,6 +22,53 @@ */ class CompanionBackupRelayAdapter implements BackupRelayAdapter, DirectS3BackupRelayAdapter { + /** Plugin capability that exposes POST /backup/create (the direct-to-S3 dump). */ + public const CAPABILITY_BACKUP_CREATE = 'backup-create'; + + /** + * @param array|null $capabilities as reported by the plugin's /health + */ + public static function supportsBackupCreate(?array $capabilities): bool + { + return in_array(self::CAPABILITY_BACKUP_CREATE, $capabilities ?? [], true); + } + + /** + * Operator-facing reason a site's plugin cannot take a direct backup. + */ + public static function missingBackupCreateMessage(Site $site): string + { + $plugin = $site->isRenegade() ? 'Renegade' : 'Companion'; + $version = trim((string) $site->companion_version); + $label = $version !== '' ? "{$plugin} {$version}" : $plugin; + + return $site->isRenegade() + ? "{$label} lacks the backup-create capability; update Renegade." + : "{$label} lacks the backup-create capability; update Companion or switch to Renegade."; + } + + /** + * Why backup relay must not be turned on for this site, or null when it + * can be. Only sites this adapter archives (the plugin uploads straight to + * S3) need backup-create; host-streamed providers are never blocked here. + */ + public static function enableBlockedReason(Site $site): ?string + { + try { + $adapter = $site->host()->backupRelayAdapter(); + } catch (Throwable) { + return null; + } + + if (! $adapter instanceof self) { + return null; + } + + return self::supportsBackupCreate($site->companion_capabilities) + ? null + : self::missingBackupCreateMessage($site); + } + public function latestBackupRef(Site $site): ?BackupRef { if (! $site->companion_installed || ! $site->companion_secret) { @@ -47,6 +94,13 @@ public function latestBackupRef(Site $site): ?BackupRef */ public function uploadDirectToS3(Site $site, string $destinationKey, array $presigned): array { + // Fail loudly rather than skip: relay is on, so a backup was promised, + // and the run must report this site as failed with a fix the operator + // can act on (an older plugin has no /backup/create endpoint). + if (! self::supportsBackupCreate($site->companion_capabilities)) { + throw new RuntimeException("BackupRelay: {$site->domain}: ".self::missingBackupCreateMessage($site)); + } + $client = new ClockworkCompanionClient($site, timeout: 300); try { diff --git a/modules/BackupRelay/src/Services/RelayAttemptRecorder.php b/modules/BackupRelay/src/Services/RelayAttemptRecorder.php new file mode 100644 index 00000000..301e5c69 --- /dev/null +++ b/modules/BackupRelay/src/Services/RelayAttemptRecorder.php @@ -0,0 +1,53 @@ + + */ + public static function successAttributes(): array + { + return [ + 'backup_relay_last_attempt_at' => now(), + 'backup_relay_last_error' => null, + 'backup_relay_failing_since' => null, + ]; + } + + /** + * Record a failed attempt. failing_since keeps the start of the current + * streak. Never throws: losing this bookkeeping must not mask the + * original failure. + */ + public static function recordFailure(Site $site, string $error): void + { + try { + $error = trim($error) !== '' ? trim($error) : 'Backup relay attempt failed.'; + + $site->forceFill([ + 'backup_relay_last_attempt_at' => now(), + 'backup_relay_last_error' => mb_substr($error, 0, self::MAX_ERROR_LENGTH), + 'backup_relay_failing_since' => $site->backup_relay_failing_since ?? now(), + ])->save(); + } catch (Throwable $e) { + Log::warning("BackupRelay: Could not record failed attempt for {$site->domain}: {$e->getMessage()}"); + } + } +} diff --git a/modules/BillCom/src/CarePlanSyncService.php b/modules/BillCom/src/CarePlanSyncService.php index fb28c90e..644c2dab 100644 --- a/modules/BillCom/src/CarePlanSyncService.php +++ b/modules/BillCom/src/CarePlanSyncService.php @@ -26,6 +26,10 @@ * - Otherwise → set care_plan_enabled = false. * 4. Sites with care_plan_override IS NOT NULL are skipped. * + * Domains are compared normalized (SiteDomainIndex / DomainNormalizer), so a + * site stored as "www.example.com" is hit by a line naming "example.com", + * and the extractor recognises every TLD a non-archived site uses. + * * Why line-item-level not customer-level: a single customer can have multiple * sites, where some are care-plan'd and some aren't. One real customer, for * example, hosts client-l.example AND client-m.example — only the latter @@ -52,11 +56,19 @@ public function __construct( * active_care_plan_domains: int, * active_care_plan_customers: int, * unmatched_care_plan_domains: array, + * archived_care_plan_domains: array, + * domain_collisions: array, * care_plan_lineitems_without_domain: int, * sites_set_true: int, * sites_set_false: int, * sites_skipped_due_to_override: int, * } + * + * archived_care_plan_domains: active care-plan domains that only an + * archived Site has — kept out of unmatched_care_plan_domains so they + * don't read as "not in Clockwork yet". + * domain_collisions: normalized domain => number of non-archived sites + * sharing it, where the sync had to pick one (exact match wins). */ public function sync(int $windowDays, string $itemRegex, bool $dryRun = false): array { @@ -67,12 +79,17 @@ public function sync(int $windowDays, string $itemRegex, bool $dryRun = false): 'active_care_plan_domains' => 0, 'active_care_plan_customers' => 0, 'unmatched_care_plan_domains' => [], + 'archived_care_plan_domains' => [], + 'domain_collisions' => [], 'care_plan_lineitems_without_domain' => 0, 'sites_set_true' => 0, 'sites_set_false' => 0, 'sites_skipped_due_to_override' => 0, ]; + $siteIndex = SiteDomainIndex::build(); + $extraTlds = $siteIndex->tlds(); + // 1. Sync + classify Items. $carePlanItemIds = []; foreach ($this->client->items() as $row) { @@ -139,7 +156,7 @@ public function sync(int $windowDays, string $itemRegex, bool $dryRun = false): } $desc = (string) ($li['description'] ?? ''); - $domains = InvoiceDomainExtractor::extractAll($desc); + $domains = InvoiceDomainExtractor::extractAll($desc, $extraTlds); if ($domains === []) { $stats['care_plan_lineitems_without_domain']++; if ($customerId !== '') { @@ -156,6 +173,17 @@ public function sync(int $windowDays, string $itemRegex, bool $dryRun = false): $stats['active_care_plan_domains'] = count($activeDomains); $stats['active_care_plan_customers'] = count($activeCustomers); + // Resolve each active (normalized) domain to the one non-archived + // site it names. When two sites normalize to the same domain, the + // index picks the exact match and records the collision. + $activeDomainBySiteId = []; // site id => active domain + foreach ($activeDomains as $domain => $_) { + $site = $siteIndex->find($domain); + if ($site !== null) { + $activeDomainBySiteId[$site->id] = $domain; + } + } + // 3. Apply to sites — only those without a manual override. $matchedDomains = []; foreach (Site::query()->whereNotNull('bill_com_customer_id')->get() as $site) { @@ -165,11 +193,11 @@ public function sync(int $windowDays, string $itemRegex, bool $dryRun = false): continue; } - $domainHit = isset($activeDomains[$site->domain]); + $domainHit = isset($activeDomainBySiteId[$site->id]); $customerHit = isset($activeCustomers[$site->bill_com_customer_id]); $shouldBeOnPlan = $domainHit || $customerHit; if ($domainHit) { - $matchedDomains[$site->domain] = true; + $matchedDomains[$activeDomainBySiteId[$site->id]] = true; } if ($site->care_plan_enabled === $shouldBeOnPlan) { @@ -192,13 +220,18 @@ public function sync(int $windowDays, string $itemRegex, bool $dryRun = false): // Active care-plan domains that didn't match any Site row — surface // these so the operator can see "Bill.com says X is on a care plan but X - // isn't in Clockwork yet." + // isn't in Clockwork yet." A domain only an archived site has is listed + // separately: it IS in Clockwork, just archived. foreach ($activeDomains as $domain => $_) { - if (! isset($matchedDomains[$domain])) { - $stats['unmatched_care_plan_domains'][$domain] = 1; + if (isset($matchedDomains[$domain])) { + continue; } + $bucket = $siteIndex->belongsToArchivedSite($domain) ? 'archived_care_plan_domains' : 'unmatched_care_plan_domains'; + $stats[$bucket][$domain] = 1; } + $stats['domain_collisions'] = $siteIndex->collisions(); + return $stats; } } diff --git a/modules/BillCom/src/Console/SyncBillCarePlans.php b/modules/BillCom/src/Console/SyncBillCarePlans.php index 704b0e24..f634c25d 100644 --- a/modules/BillCom/src/Console/SyncBillCarePlans.php +++ b/modules/BillCom/src/Console/SyncBillCarePlans.php @@ -74,6 +74,20 @@ public function handle(BillComClient $client, ActionLogger $logger): int } } + if (! empty($stats['archived_care_plan_domains'])) { + $this->line('Care-plan domains that only belong to archived sites:'); + foreach ($stats['archived_care_plan_domains'] as $domain => $_) { + $this->line(" {$domain} (archived)"); + } + } + + if (! empty($stats['domain_collisions'])) { + $this->warn('Domains shared by more than one active site (used the exact match):'); + foreach ($stats['domain_collisions'] as $domain => $count) { + $this->line(" {$domain} ({$count} sites)"); + } + } + $logger->record( actionType: ActionLog::TYPE_BILL_COM_SYNC, summary: sprintf( diff --git a/modules/BillCom/src/Console/SyncBillCustomers.php b/modules/BillCom/src/Console/SyncBillCustomers.php index 826c65d5..413eaf2f 100644 --- a/modules/BillCom/src/Console/SyncBillCustomers.php +++ b/modules/BillCom/src/Console/SyncBillCustomers.php @@ -67,6 +67,20 @@ public function handle(BillComClient $client, ActionLogger $logger): int } } + if (! empty($stats['archived_site_domains'])) { + $this->line('Domains that only belong to archived sites (not linked):'); + foreach ($stats['archived_site_domains'] as $domain => $_) { + $this->line(" {$domain} (archived)"); + } + } + + if (! empty($stats['domain_collisions'])) { + $this->warn('Domains shared by more than one active site (linked the exact match):'); + foreach ($stats['domain_collisions'] as $domain => $count) { + $this->line(" {$domain} ({$count} sites)"); + } + } + $logger->record( actionType: ActionLog::TYPE_BILL_COM_SYNC, summary: sprintf( diff --git a/modules/BillCom/src/CustomerSyncService.php b/modules/BillCom/src/CustomerSyncService.php index 4aeeb34a..594c7a6c 100644 --- a/modules/BillCom/src/CustomerSyncService.php +++ b/modules/BillCom/src/CustomerSyncService.php @@ -2,7 +2,6 @@ namespace Modules\BillCom; -use App\Models\Site; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; @@ -18,6 +17,10 @@ * touching `synced_at`). Re-linking the same site is fine — last-write-wins * and we always pick the most recent invoice as the source. * + * Domains are matched through SiteDomainIndex, so a site stored as + * "www.example.com" links from an invoice that says "example.com" (and vice + * versa), and the extractor also recognises any TLD a non-archived site uses. + * * Read-only on the Bill.com side; writes only to the local cache + sites. */ class CustomerSyncService @@ -33,7 +36,15 @@ public function __construct( * sites_linked: int, * sites_relinked: int, * unmatched_domains: array, + * archived_site_domains: array, + * domain_collisions: array, * } + * + * unmatched_domains: extracted from invoices, no Site row at all. + * archived_site_domains: extracted from invoices, only an archived Site + * has it (never linked; listed apart so it doesn't read as missing). + * domain_collisions: normalized domain => number of non-archived sites + * sharing it, where the sync had to pick one (exact match wins). */ public function sync(int $windowDays, bool $dryRun = false): array { @@ -43,8 +54,13 @@ public function sync(int $windowDays, bool $dryRun = false): array 'sites_linked' => 0, 'sites_relinked' => 0, 'unmatched_domains' => [], + 'archived_site_domains' => [], + 'domain_collisions' => [], ]; + $siteIndex = SiteDomainIndex::build(); + $extraTlds = $siteIndex->tlds(); + // 1. Cache customer list locally so per-site UI dropdowns are instant. $customerIndex = []; // id => row foreach ($this->client->customers() as $row) { @@ -86,7 +102,7 @@ public function sync(int $windowDays, bool $dryRun = false): array $desc = (string) ($li['description'] ?? ''); // Extract ALL domains — some operators' "HOSTING WEBSITES:" lines often // name two or three sites that all belong to the same customer. - foreach (InvoiceDomainExtractor::extractAll($desc) as $domain) { + foreach (InvoiceDomainExtractor::extractAll($desc, $extraTlds) as $domain) { // Pick most recent invoice if we've seen this domain before. $existing = $domainToBest[$domain] ?? null; if ($existing === null @@ -103,11 +119,12 @@ public function sync(int $windowDays, bool $dryRun = false): array } } - // 3. Apply links to matching sites. + // 3. Apply links to matching sites (normalized: www./case/scheme-insensitive). foreach ($domainToBest as $domain => $best) { - $site = Site::query()->where('domain', $domain)->first(); + $site = $siteIndex->find($domain); if ($site === null) { - $stats['unmatched_domains'][$domain] = ($stats['unmatched_domains'][$domain] ?? 0) + 1; + $bucket = $siteIndex->belongsToArchivedSite($domain) ? 'archived_site_domains' : 'unmatched_domains'; + $stats[$bucket][$domain] = ($stats[$bucket][$domain] ?? 0) + 1; continue; } @@ -134,6 +151,8 @@ public function sync(int $windowDays, bool $dryRun = false): array $isRelink ? $stats['sites_relinked']++ : $stats['sites_linked']++; } + $stats['domain_collisions'] = $siteIndex->collisions(); + return $stats; } } diff --git a/modules/BillCom/src/InvoiceDomainExtractor.php b/modules/BillCom/src/InvoiceDomainExtractor.php index 51f6feef..ca276c90 100644 --- a/modules/BillCom/src/InvoiceDomainExtractor.php +++ b/modules/BillCom/src/InvoiceDomainExtractor.php @@ -2,6 +2,8 @@ namespace Modules\BillCom; +use App\Support\DomainNormalizer; + /** * Pure-function helper. Given a Bill.com line item description, returns the * site domain embedded in it (or null if none found). @@ -12,8 +14,9 @@ * "http://www.example.com - - Plugin updates, backups, security scans, etc." * "Monthly maintenance — example.com" * - * Strategy: extract the first plausible domain via regex; lowercase + strip - * `www.` so we match a `Site::domain` row. + * Strategy: extract every plausible domain via regex, then run each through + * DomainNormalizer (lowercase, no scheme, no leading `www.`) so it compares + * equal to a normalized `Site::domain` — see SiteDomainIndex. * * Out of scope: validating the domain is reachable, distinguishing the * customer's domain from incidental ones in the description (e.g. if a @@ -24,17 +27,25 @@ class InvoiceDomainExtractor { /** - * TLDs we recognise. Intentionally focused on standard fleet TLDs (.com / .org / - * .net / .io / .co / .us / .gov / .edu). Adding TLDs is one line; better to be - * precise than catch random words ending in `.tv`. + * TLDs we always recognise. Intentionally focused on standard fleet TLDs + * (.com / .org / .net / .io / .co / .us / .gov / .edu) — better to be + * precise than catch random words that happen to end in a short suffix. + * + * Fleet sites on any other TLD (.tv, .law, .media, ...) are covered by the + * `$extraTlds` argument: the sync services pass the TLDs of every + * non-archived Site (SiteDomainIndex::tlds()), so a domain on a TLD the + * fleet actually uses is recognised without accepting every dotted word. * * @var array */ - private const TLDS = ['com', 'org', 'net', 'io', 'co', 'us', 'gov', 'edu']; + public const TLDS = ['com', 'org', 'net', 'io', 'co', 'us', 'gov', 'edu']; - public static function extract(string $description): ?string + /** + * @param iterable $extraTlds TLDs to accept on top of TLDS + */ + public static function extract(string $description, iterable $extraTlds = []): ?string { - $all = self::extractAll($description); + $all = self::extractAll($description, $extraTlds); return $all === [] ? null : $all[0]; } @@ -44,16 +55,20 @@ public static function extract(string $description): ?string * line item description lists multiple sites — some invoices sometimes * have a "HOSTING WEBSITES:" line that names two or three domains. * - * @return array deduped, lowercase, www-stripped + * @param iterable $extraTlds TLDs to accept on top of TLDS + * @return array deduped, normalized (lowercase, www-stripped) */ - public static function extractAll(string $description): array + public static function extractAll(string $description, iterable $extraTlds = []): array { $description = trim($description); if ($description === '') { return []; } - $tldGroup = implode('|', self::TLDS); + $tldGroup = implode('|', array_map( + fn (string $tld) => preg_quote($tld, '/'), + self::allowedTlds($extraTlds), + )); $pattern = '/(?:https?:\/\/)?(?:www\.)?([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.(?:'.$tldGroup.'))\b/i'; if (preg_match_all($pattern, $description, $matches) === false) { @@ -62,12 +77,33 @@ public static function extractAll(string $description): array $domains = []; foreach ($matches[1] as $match) { - $domain = strtolower((string) $match); - if (! in_array($domain, $domains, true)) { + $domain = DomainNormalizer::normalize((string) $match); + if ($domain !== '' && ! in_array($domain, $domains, true)) { $domains[] = $domain; } } return $domains; } + + /** + * TLDS plus every well-formed extra TLD (alphabetic, 2-24 letters), + * lowercased and deduped. Anything else in `$extraTlds` is ignored, so a + * stray value can never inject regex syntax or widen the match. + * + * @param iterable $extraTlds + * @return array + */ + private static function allowedTlds(iterable $extraTlds): array + { + $tlds = array_fill_keys(self::TLDS, true); + foreach ($extraTlds as $tld) { + $tld = strtolower(trim((string) $tld, " \t\n\r\0\x0B.")); + if (preg_match('/^[a-z]{2,24}$/', $tld) === 1) { + $tlds[$tld] = true; + } + } + + return array_keys($tlds); + } } diff --git a/modules/BillCom/src/SiteDomainIndex.php b/modules/BillCom/src/SiteDomainIndex.php new file mode 100644 index 00000000..7b57c1df --- /dev/null +++ b/modules/BillCom/src/SiteDomainIndex.php @@ -0,0 +1,134 @@ +> normalized key => non-archived sites, lowest id first */ + private array $sitesByKey = []; + + /** @var array normalized keys of archived sites */ + private array $archivedKeys = []; + + /** @var array */ + private array $tlds = []; + + /** @var array normalized key => number of non-archived sites sharing it */ + private array $collisions = []; + + /** + * @param iterable $sites non-archived sites + * @param iterable $archivedDomains domains of archived sites + */ + public function __construct(iterable $sites, iterable $archivedDomains = []) + { + foreach ($sites as $site) { + $key = DomainNormalizer::normalize((string) $site->domain); + if ($key === '') { + continue; + } + $this->sitesByKey[$key][] = $site; + + $tld = DomainNormalizer::tld($key); + if ($tld !== null) { + $this->tlds[$tld] = true; + } + } + + foreach ($archivedDomains as $domain) { + $key = DomainNormalizer::normalize((string) $domain); + if ($key !== '') { + $this->archivedKeys[$key] = true; + } + } + } + + public static function build(): self + { + return new self( + Site::query()->orderBy('id')->get(), + Site::query() + ->withoutGlobalScope('notArchived') + ->whereNotNull('archived_at') + ->pluck('domain') + ->map(fn ($domain) => (string) $domain), + ); + } + + /** + * The non-archived site a domain refers to, or null when there is none. + */ + public function find(string $domain): ?Site + { + $key = DomainNormalizer::normalize($domain); + $candidates = $this->sitesByKey[$key] ?? []; + + if (count($candidates) <= 1) { + return $candidates[0] ?? null; + } + + $this->collisions[$key] = count($candidates); + + foreach (array_unique([strtolower(trim($domain)), $key]) as $wanted) { + foreach ($candidates as $site) { + if (strtolower(trim((string) $site->domain)) === $wanted) { + return $site; + } + } + } + + return $candidates[0]; + } + + /** + * True when no non-archived site has this domain but an archived one does. + */ + public function belongsToArchivedSite(string $domain): bool + { + $key = DomainNormalizer::normalize($domain); + + return ! isset($this->sitesByKey[$key]) && isset($this->archivedKeys[$key]); + } + + /** + * Distinct TLDs of the non-archived sites, for InvoiceDomainExtractor's + * `$extraTlds` argument. + * + * @return array + */ + public function tlds(): array + { + return array_keys($this->tlds); + } + + /** + * Keys find() had to choose between more than one non-archived site for, + * with the number of sites sharing each key. + * + * @return array + */ + public function collisions(): array + { + return $this->collisions; + } +} diff --git a/modules/ClientReports/resources/views/_nav.blade.php b/modules/ClientReports/resources/views/_nav.blade.php index ad17691c..10962834 100644 --- a/modules/ClientReports/resources/views/_nav.blade.php +++ b/modules/ClientReports/resources/views/_nav.blade.php @@ -1,38 +1,55 @@ @php + $navCounts = \Modules\ClientReports\Support\ModuleNavCounts::get(); $navTabs = [ [ - 'key' => 'reports', - 'label' => 'Reports', - 'icon' => 'fa-file-lines', - 'route' => 'client-reports.index', - 'active' => request()->routeIs('client-reports.index'), + 'label' => 'Clients', + 'icon' => 'fa-users', + 'url' => route('client-reports.index'), + 'active' => request()->routeIs('client-reports.index', 'client-reports.clients.*'), + 'count' => null, + 'count_title' => null, ], [ - 'key' => 'templates', - 'label' => 'Templates', - 'icon' => 'fa-layer-group', - 'route' => 'client-reports.templates.index', - 'active' => request()->routeIs('client-reports.templates.*'), + 'label' => 'Reports', + 'icon' => 'fa-file-lines', + 'url' => route('client-reports.reports'), + 'active' => request()->routeIs('client-reports.reports', 'client-reports.email'), + 'count' => $navCounts['waiting'] ?: null, + 'count_title' => $navCounts['waiting'].' '.($navCounts['period_label'] ?? '').' report(s) waiting for review or sending', ], [ - 'key' => 'schedules', 'label' => 'Scheduling', 'icon' => 'fa-clock', - 'route' => 'client-reports.schedules.index', + 'url' => route('client-reports.schedules.index'), 'active' => request()->routeIs('client-reports.schedules.*'), + 'count' => $navCounts['missing'] ?: null, + 'count_title' => $navCounts['missing'].' care-plan site(s) without a report schedule', + ], + [ + 'label' => 'Templates', + 'icon' => 'fa-layer-group', + 'url' => route('client-reports.templates.index'), + 'active' => request()->routeIs('client-reports.templates.*'), + 'count' => null, + 'count_title' => null, ], ]; @endphp -
+@include('client-reports::partials.styles') + +
+ diff --git a/modules/ClientReports/resources/views/clients/_row.blade.php b/modules/ClientReports/resources/views/clients/_row.blade.php new file mode 100644 index 00000000..2f42ac88 --- /dev/null +++ b/modules/ClientReports/resources/views/clients/_row.blade.php @@ -0,0 +1,33 @@ +{{-- One client on the Clients list. $row: ClientDirectory row; $query: the + list's filters, kept on the client page link for its previous/next. --}} +@php($clientUrl = route('client-reports.clients.show', [$row['key'], ...$query])) +
  • +
    + + @if ($row['client'] === null)@endif{{ $row['name'] }} + + @if ($row['contact']) +
    {{ $row['contact'] }}
    + @endif +
    + {{ $row['counts']['sites'] }} {{ \Illuminate\Support\Str::plural('site', $row['counts']['sites']) }} + @if ($row['counts']['waiting'] > 0) + {{ $row['counts']['waiting'] }} waiting + @endif + @if ($row['counts']['missing'] > 0) + {{ $row['counts']['missing'] }} no schedule + @endif +
    + + Reports & schedules + +
    + +
    + @forelse ($row['sites'] as $siteRow) + @include('client-reports::clients._site-line', ['siteRow' => $siteRow, 'context' => []]) + @empty +
    No sites assigned to this client.
    + @endforelse +
    +
  • diff --git a/modules/ClientReports/resources/views/clients/_site-line.blade.php b/modules/ClientReports/resources/views/clients/_site-line.blade.php new file mode 100644 index 00000000..0a8112fc --- /dev/null +++ b/modules/ClientReports/resources/views/clients/_site-line.blade.php @@ -0,0 +1,47 @@ +{{-- One site on the Clients list: care plan, schedule, recipients and the + last months of reports. $siteRow: ClientDirectory site row. --}} +@php + $site = $siteRow['site']; + $schedule = $siteRow['schedule']; + $carePlansOn = \App\Models\Site::areCarePlansEnabled(); + $recipients = array_values(array_filter((array) ($schedule?->recipients ?? []))); +@endphp +
    +
    +
    + {{ $site->domain }} + @if ($site->is_inactive) + Inactive + @endif + @if ($carePlansOn) + @if ($siteRow['eligible']) + Care plan + @else + No care plan — reports won't send + @endif + @endif +
    +
    + @if ($siteRow['schedule_state'] === 'scheduled') + Scheduled · {{ $schedule->delivery_mode === \Modules\ClientReports\Models\ClientReportSchedule::MODE_DRAFT ? 'draft' : 'auto' }} + @elseif ($siteRow['schedule_state'] === 'disabled') + Schedule disabled + @elseif ($siteRow['missing']) + No schedule +
    + @csrf + +
    + @else + No schedule + @endif + @if ($schedule) + + + {{ $recipients !== [] ? implode(', ', $recipients) : 'No recipients (drafts only)' }} + + @endif +
    +
    + @include('client-reports::partials.history-strip', ['months' => $siteRow['months'], 'context' => $context ?? []]) +
    diff --git a/modules/ClientReports/resources/views/clients/index.blade.php b/modules/ClientReports/resources/views/clients/index.blade.php new file mode 100644 index 00000000..da3d9ff3 --- /dev/null +++ b/modules/ClientReports/resources/views/clients/index.blade.php @@ -0,0 +1,138 @@ +@extends('layouts.app') + +@section('title', 'Clients · Client Reports · Clockwork') + +@section('content') + + + + Review waiting reports + + + + +@include('client-reports::_nav') +@include('client-reports::partials.flash') + +@php + $directoryClass = \Modules\ClientReports\Services\ClientDirectory::class; + $carePlansOn = \App\Models\Site::areCarePlansEnabled(); + $care = $filters['care']; + $sort = $filters['sort']; + // Links that change one setting and keep the rest (paging restarts). + $with = fn (array $changes) => route('client-reports.index', $directoryClass::queryFor(array_merge($filters, $changes))); + $selectionLabel = ['yes' => 'care-plan clients', 'no' => 'clients without a care plan', 'all' => 'clients'][$care]; + $narrowed = isset($filters['q']) || isset($filters['waiting']) || isset($filters['missing']); + $sectionTitles = [ + 'care' => ['Care plan clients — reported monthly', 'fa-shield-heart', 'text-[var(--color-status-green)]'], + 'other' => ['No care plan — reports aren\'t sent', 'fa-ban', 'text-[var(--color-ink-soft)]'], + ]; +@endphp + +{{-- Care plan / No care plan / All, then the tiles for that selection --}} +
    +
    + @foreach (['yes' => 'Care plan', 'no' => 'No care plan', 'all' => 'All'] as $value => $label) + {{ $label }} + @endforeach +
    +
    + Order: + Needs attention + A–Z +
    +
    + + + +
    + {{-- Search and filters; the care-plan selection and order ride along --}} +
    + @if ($care !== $directoryClass::CARE_DEFAULT)@endif + @if ($sort !== $directoryClass::SORT_DEFAULT)@endif +
    + + + +
    + + +
    + + @if ($narrowed) + Clear + @endif +
    +
    + {{ $rows->total() }} {{ $selectionLabel }}{{ $narrowed ? ' match' : '' }} + @include('client-reports::partials.strip-legend') +
    +
    + + @if ($rows->isEmpty()) +
    + + No {{ $selectionLabel }} match these filters. +
    + @else + @foreach ($rows->groupBy('section') as $section => $sectionRows) +
    +

    + {{ $sectionTitles[$section][0] }} +

    +
      + @foreach ($sectionRows as $row) + @include('client-reports::clients._row', ['row' => $row, 'query' => $query]) + @endforeach +
    +
    + @endforeach + @if ($rows->hasPages()) +
    {{ $rows->links() }}
    + @endif + @endif + + {{-- With "Care plan" selected, everyone else is folded away here. --}} + @if ($folded->isNotEmpty()) +
    + + No care plan — reports aren't sent + {{ $folded->count() }} + Show + +
      + @foreach ($folded as $row) + @include('client-reports::clients._row', ['row' => $row, 'query' => $query]) + @endforeach +
    +
    + @endif +
    +@endsection diff --git a/modules/ClientReports/resources/views/clients/show.blade.php b/modules/ClientReports/resources/views/clients/show.blade.php new file mode 100644 index 00000000..00e072db --- /dev/null +++ b/modules/ClientReports/resources/views/clients/show.blade.php @@ -0,0 +1,270 @@ +@extends('layouts.app') + +@section('title', $row['name'].' · Client Reports · Clockwork') + +@php + $carePlansOn = \App\Models\Site::areCarePlansEnabled(); + $stateClass = \Modules\ClientReports\Support\ReportState::class; + $modeDraft = \Modules\ClientReports\Models\ClientReportSchedule::MODE_DRAFT; + $listUrl = route('client-reports.index', $filters); + $reportContext = array_filter(['period' => 'all', 'client' => $row['key'], 'from' => 'client', 'superseded' => $showSuperseded ? '1' : null]); + // The switcher's labels must be unique to map back to a client. + $switchOptions = []; + foreach ($switcher as $option) { + $label = $option['name']; + $n = 2; + while (isset($switchOptions[$label])) { + $label = $option['name'].' ('.$n++.')'; + } + $switchOptions[$label] = route('client-reports.clients.show', [$option['key'], ...$filters]); + } + $currentLabel = array_search(route('client-reports.clients.show', [$row['key'], ...$filters]), $switchOptions, true) ?: $row['name']; +@endphp + +@section('content') +@include('client-reports::_nav') +@include('client-reports::partials.flash') + +{{-- Client navigation: back to the (filtered) list, previous/next, switcher --}} +
    + All clients +
    + @if ($previousKey !== null) + + @else + Previous + @endif + @if ($position !== null) + {{ $position }} of {{ $listCount }} + @endif + @if ($nextKey !== null) + + @else + Next + @endif + +
    +
    + +{{-- Client header --}} +
    +
    +
    +

    {{ $row['name'] }}

    + @if ($row['contact']) +
    {{ $row['contact'] }}
    + @endif + @if ($row['client'] === null) +

    Sites with no client. Reports still go out for those on a care plan with a schedule; assign a client to keep contacts and history together.

    + @endif + @if ($row['contacts'] !== []) +
    + @foreach ($row['contacts'] as $contact) + {{ $contact }} + @endforeach +
    + @endif +
    +
    + + + @if (! $carePlansOn) + Care plans off: every site gets reports + @else + {{ $row['counts']['care'] }} of {{ $row['counts']['sites'] }} {{ \Illuminate\Support\Str::plural('site', $row['counts']['sites']) }} on a care plan + @endif + + @if ($row['counts']['missing'] > 0) + {{ $row['counts']['missing'] }} without a schedule + @endif + @if ($row['counts']['waiting'] > 0) + {{ $row['counts']['waiting'] }} waiting + @endif + @if ($row['client'] && \Illuminate\Support\Facades\Route::has('clients.show')) + Client record + @endif +
    +
    +
    + +@forelse ($row['sites'] as $siteRow) + @php + $site = $siteRow['site']; + $schedule = $siteRow['schedule']; + $recipients = array_values(array_filter((array) ($schedule?->recipients ?? []))); + $reports = $siteRow['reports']->filter(fn ($r) => $showSuperseded || $r->status !== \Modules\ClientReports\Models\ClientReport::STATUS_SUPERSEDED)->values(); + $hiddenCopies = $siteRow['reports']->count() - $siteRow['reports']->filter(fn ($r) => $r->status !== \Modules\ClientReports\Models\ClientReport::STATUS_SUPERSEDED)->count(); + @endphp +
    +
    +
    +

    + {{ $site->domain }} +

    +
    + @if ($site->is_inactive) + Inactive + @endif + @if ($carePlansOn) + @if ($siteRow['eligible']) + Care plan + @else + No care plan — reports won't send + @endif + @endif + Site dashboard +
    +
    + @include('client-reports::partials.history-strip', ['months' => $siteRow['months'], 'context' => $reportContext]) +
    +
    + + {{-- Schedule --}} +
    +
    + Report schedule + @if ($schedule) +
    + Edit +
    + @csrf + @method('PATCH') + + +
    +
    + @elseif ($siteRow['missing']) +
    + @csrf + +
    + @endif +
    + @if ($schedule) +
    +
    Status
    +
    + @if ($schedule->is_enabled) + Enabled + @else + Disabled + @endif + · {{ ucfirst($schedule->frequency) }}, {{ $schedule->delivery_mode === $modeDraft ? 'drafts for review' : 'sends automatically' }} +
    +
    Recipients
    +
    {{ $recipients !== [] ? implode(', ', $recipients) : 'None (drafts only)' }}
    +
    Next run
    +
    {{ $schedule->next_run_at ? $schedule->next_run_at->format('M j, Y') : '—' }}
    +
    Last sent
    +
    + @if ($schedule->last_sent_at) + {{ $schedule->last_sent_at->format('M j, Y') }} + @else + Never — its first report waits for someone to send it + @endif +
    +
    + @elseif ($siteRow['missing']) +

    On a care plan but no schedule, so no report is sent. Create one with the default template, monthly{{ $row['contacts'] !== [] ? ', to the client\'s contacts' : ', as drafts for review (no contacts on file)' }}.

    + @else +

    No schedule{{ $carePlansOn && ! $siteRow['eligible'] ? ': reports are only sent for care-plan sites' : '' }}.

    + @endif +
    +
    + + {{-- Full report history --}} + @if ($reports->isEmpty()) +
    No reports yet.
    + @else +
    + + + + + + + + + + + @foreach ($reports as $report) + @php($state = $stateClass::of($report)) + + + + + + + @endforeach + +
    PeriodStatusSent toActions
    +
    {{ $report->period_start->format('F Y') }}
    +
    {{ $report->period_start->format('M j') }} – {{ $report->period_end->format('M j') }}
    +
    + @include('client-reports::partials.state-pill', ['state' => $state]) + @include('client-reports::partials.reason-chips', ['reasons' => $stateClass::reasons($report)]) + + @if (! empty($report->sent_to)) +
    {{ implode(', ', $report->sent_to) }}
    + @if ($report->sent_at) +
    {{ $report->sent_at->format('M j, Y') }}
    + @endif + @elseif ($report->recipients() !== []) +
    Not sent · for {{ implode(', ', $report->recipients()) }}
    + @else + Not sent · no recipient + @endif +
    + @include('client-reports::partials.report-actions', ['report' => $report, 'context' => $reportContext]) +
    +
    + @endif + @if ($hiddenCopies > 0 || $showSuperseded) + + @endif +
    +@empty +
    + No sites are assigned to this client yet. + @if ($row['client'] && \Illuminate\Support\Facades\Route::has('clients.show')) + Assign sites on the client record. + @endif +
    +@endforelse + +

    Keyboard: ← previous client · → next client

    + + +@endsection diff --git a/modules/ClientReports/resources/views/emails/report-text.blade.php b/modules/ClientReports/resources/views/emails/report-text.blade.php index 12073d3d..8c6e8b8d 100644 --- a/modules/ClientReports/resources/views/emails/report-text.blade.php +++ b/modules/ClientReports/resources/views/emails/report-text.blade.php @@ -9,41 +9,49 @@ $forms = $data['forms'] ?? []; $workLog = $data['work_log'] ?? []; $period = $report->period_start->format('F Y'); + // Which sections the client sees: one rule for this, the HTML part and the preview. + $sections = \Modules\ClientReports\Support\ReportSections::visibility($data); + // A site enrolled part-way through the month: say when monitoring began. + $coverageNote = \Modules\ClientReports\Support\ReportPhrases::coverageNote($data); + $monitoredSince = \Modules\ClientReports\Support\ReportPhrases::monitoringBeganOn($uptime['monitored_since'] ?? null); @endphp WEBSITE CARE REPORT — {!! $domain !!} {!! $period !!} ({!! $report->period_start->format('M j') !!} – {!! $report->period_end->format('M j, Y') !!}) -Hi{!! $contactName ? ' '.$contactName : '' !!}, +Hi {!! $greetingName ?: 'there' !!}, Here's your website care summary for {!! $domain !!} for {!! $period !!}. +@if ($coverageNote) +{!! $coverageNote !!} +@endif @if (! empty($data['meta']['custom_notes'])) A NOTE FROM YOUR SUPPORT TEAM {!! $data['meta']['custom_notes'] !!} @endif -@if (isset($data['updates'])) +@if ($sections['updates']) UPDATES APPLIED ({!! count($updateRows) !!}) @forelse ($updateRows as $row) - [{!! $row['type'] !!}] {!! $row['label'] !!}{!! $row['date'] ? ' ('.\Illuminate\Support\Carbon::parse($row['date'])->format('M j').')' : '' !!} @empty -{!! \Modules\ClientReports\Services\ClientReportCompiler::updatesWereWaiting($updates) ? 'No updates were applied this month.' : 'Everything was already up to date this month.' !!} +{!! \Modules\ClientReports\Support\ReportPhrases::noUpdatesLine($updates) !!} @endforelse @endif -@if (isset($data['uptime']) && ($uptime['monitored'] ?? true)) +@if ($sections['uptime']) UPTIME -Availability: {!! $uptime['uptime_percentage'] ?? '—' !!}% +Availability: {!! $uptime['uptime_percentage'] ?? '—' !!}%{!! $monitoredSince ? ' (monitoring began '.$monitoredSince.')' : '' !!} Outages: {!! (int) ($uptime['outages_count'] ?? 0) !!} Total downtime: {!! (int) ($uptime['downtime_minutes'] ?? 0) !!} min @foreach ($uptime['outages'] ?? [] as $outage) -- {!! ! empty($outage['started_before_period']) ? 'Down since before' : 'Down' !!} {!! \Illuminate\Support\Carbon::parse($outage['started_at'])->format('M j, g:i A') !!} UTC for {!! (int) $outage['minutes'] < 1 ? 'under a minute' : $outage['minutes'].' min' !!}{!! ! empty($outage['ongoing']) ? ' (still down at period end)' : '' !!} +- {!! \Modules\ClientReports\Support\ReportPhrases::outageStart($outage) !!} for {!! (int) $outage['minutes'] < 1 ? 'under a minute' : $outage['minutes'].' min' !!}{!! ! empty($outage['ongoing']) ? ' (still down at period end)' : '' !!} @endforeach @if ((int) ($uptime['outages_count'] ?? 0) > count($uptime['outages'] ?? []) && ! empty($uptime['outages'])) (Showing the {!! count($uptime['outages']) !!} most recent of {!! (int) $uptime['outages_count'] !!} outages.) @endif @endif -@if (isset($data['security'])) +@if ($sections['security']) SECURITY Security scans run: {!! number_format((int) ($security['total_scans'] ?? 0)) !!} @@ -51,38 +59,41 @@ Spam & malware blocklists: {!! match ($security['blacklist_status']) { 'clean' => 'Not listed', 'issues_found', 'warning' => 'Listed — under review', null => 'Not checked this month', default => ucfirst((string) $security['blacklist_status']) } !!} @endif @if (array_key_exists('malware_status', $security)) -Malware scan: {!! match ($security['malware_status']) { 'clean' => 'No malware found', 'warning', 'issues_found' => 'Issues flagged', null => 'Not checked this month', default => ucfirst((string) $security['malware_status']) } !!} +Malware scan: {!! \Modules\ClientReports\Support\ReportPhrases::malwareLabel($security)[0] !!} @endif -@if ((bool) ($data['site']['is_wordpress'] ?? true)) +@if ((bool) ($data['site']['is_wordpress'] ?? true) && (bool) ($security['checksum_supported'] ?? true)) WordPress core files: {!! match ($security['checksum_status'] ?? null) { 'clean' => 'Verified clean', 'issues_found', 'warning' => 'Unexpected changes found', null => 'Not checked this month', default => ucfirst((string) $security['checksum_status']) } !!} @endif @php - $vulns = (int) ($security['active_vulnerabilities'] ?? 0); - $vulnPlugins = count($security['vulnerable_plugins'] ?? []); - $inactiveVulns = (int) ($security['inactive_plugin_vulnerabilities'] ?? 0); + $vulnsCheckedOn = \Modules\ClientReports\Support\ReportPhrases::vulnerabilitiesCheckedOn($security); + $vulnPluginNames = \Modules\ClientReports\Support\ReportPhrases::vulnerablePlugins($security); @endphp -Known plugin vulnerabilities{!! ! empty($security['vulnerabilities_as_of']) ? ' (as of '.\Illuminate\Support\Carbon::parse($security['vulnerabilities_as_of'])->format('M j').')' : '' !!}: {!! ! ($security['vulnerabilities_checked'] ?? false) ? 'Not checked' : ($vulns === 0 ? 'None found' : $vulns.' found'.($vulnPlugins > 0 ? ' in '.$vulnPlugins.' '.\Illuminate\Support\Str::plural('plugin', $vulnPlugins) : '').($inactiveVulns > 0 ? ' ('.$inactiveVulns.' in inactive plugins)' : '')) !!} +Known plugin vulnerabilities{!! $vulnsCheckedOn ? ' (checked '.$vulnsCheckedOn.')' : '' !!}: {!! \Modules\ClientReports\Support\ReportPhrases::vulnerabilities($security) !!} +@if ($vulnPluginNames !== []) +{!! \Illuminate\Support\Str::plural('Plugin', count($vulnPluginNames)) !!} affected: {!! implode(', ', $vulnPluginNames) !!} +@endif @if (($security['blocked_threats_count'] ?? null) !== null) Malicious visitors blocked: {!! number_format((int) $security['blocked_threats_count']) !!} @endif @endif -@if (! empty($data['backups']['verified'])) +@if ($sections['backups']) +@php($backupDestination = \Modules\ClientReports\Support\ReportPhrases::backupDestination($data['backups'])) BACKUPS @if (isset($data['backups']['status'])) @if ($data['backups']['covers_period'] ?? true) -Backed up on {!! (int) $data['backups']['days_with_backups'] !!} {!! \Illuminate\Support\Str::plural('day', (int) $data['backups']['days_with_backups']) !!} this month{!! ! empty($data['backups']['destination']) ? ' by '.$data['backups']['destination'] : '' !!}. Most recent: +Backed up on {!! (int) $data['backups']['days_with_backups'] !!} {!! \Illuminate\Support\Str::plural('day', (int) $data['backups']['days_with_backups']) !!} this month{!! $backupDestination ? ' '.$backupDestination : '' !!}. Most recent: @else -Backed up{!! ! empty($data['backups']['destination']) ? ' by '.$data['backups']['destination'] : '' !!}. Most recent: +Backed up{!! $backupDestination ? ' '.$backupDestination : '' !!}. Most recent: @endif @foreach ($data['backups']['recent'] as $day) -- {!! \Illuminate\Support\Carbon::parse($day['date'])->format('D, M j') !!}{!! ! empty($day['manual']) ? ' (manual)' : '' !!} +- {!! \Illuminate\Support\Carbon::parse($day['date'])->format('D, M j') !!}: {!! \Modules\ClientReports\Support\ReportPhrases::backupKinds((array) ($day['kinds'] ?? [])) !!}{!! ! empty($day['manual']) ? ' (manual)' : '' !!}{!! isset($day['bytes']) ? ', '.\Modules\ClientReports\Support\ReportPhrases::bytes((int) $day['bytes']) : '' !!} @endforeach @else Your site's files and database were backed up to independent off-site storage this month.{!! ! empty($data['backups']['last_backup_at']) ? ' Most recent backup: '.\Illuminate\Support\Carbon::parse($data['backups']['last_backup_at'])->format('M j, Y').'.' : '' !!} @endif @endif -@if (! empty($perf['has_performance']) && (! empty($perf['mobile']) || ! empty($perf['desktop']))) +@if ($sections['performance']) SPEED & PERFORMANCE (Google Lighthouse, out of 100) @foreach (['mobile' => 'Mobile', 'desktop' => 'Desktop'] as $key => $label) @@ -91,29 +102,36 @@ @endif @endforeach @endif -@if (! empty($traffic['has_traffic'])) +@if ($sections['traffic']) TRAFFIC Visits: {!! number_format((int) ($traffic['total_visits'] ?? 0)) !!} Requests served: {!! number_format((int) ($traffic['total_requests'] ?? 0)) !!} +@if (\Modules\ClientReports\Support\ReportPhrases::bandwidth($traffic['bandwidth_mb'] ?? null) !== null) +Data transferred: {!! \Modules\ClientReports\Support\ReportPhrases::bandwidth($traffic['bandwidth_mb']) !!} +@endif @endif -@if ((int) ($data['meta']['schema_version'] ?? 1) >= 2 && ((int) ($forms['live_tests'] ?? 0) > 0 || (int) ($forms['lab_tests'] ?? 0) > 0)) +@if ($sections['forms']) CONTACT FORMS @if ((int) ($forms['live_tests'] ?? 0) > 0) -{!! (int) $forms['live_tests'] !!} test submissions, {!! $forms['pass_rate'] ?? 0 !!}% delivered +{!! (int) $forms['live_tests'] !!} live test submissions, notification email reported sent for {!! $forms['pass_rate'] ?? 0 !!}% (inbox delivery not checked) @endif @if ((int) ($forms['lab_tests'] ?? 0) > 0) -Forms accepted {!! (int) ($forms['lab_accepted'] ?? 0) !!} of {!! (int) $forms['lab_tests'] !!} automated check submissions (no email sent, so delivery wasn't tested) +Forms accepted {!! (int) ($forms['lab_accepted'] ?? 0) !!} of {!! (int) $forms['lab_tests'] !!} automated check submissions (no email sent, so the email wasn't tested) @endif @endif -@if (! empty($workLog['entries'])) +@if ($sections['work_log']) ADDITIONAL WORK THIS MONTH @foreach ($workLog['entries'] as $entry) - {!! \Illuminate\Support\Carbon::parse($entry['worked_on'])->format('M j') !!}: {!! $entry['description'] !!} ({!! $entry['hours'] !!}h) @endforeach @endif +@if ($branding['footer_text'] !== '') + +{!! $branding['footer_text'] !!} +@endif Questions about anything in this report? Just reply to this email{!! $branding['support_email'] !== '' ? ' or write to '.$branding['support_email'] : '' !!}. diff --git a/modules/ClientReports/resources/views/emails/report.blade.php b/modules/ClientReports/resources/views/emails/report.blade.php index 15696347..2a00f100 100644 --- a/modules/ClientReports/resources/views/emails/report.blade.php +++ b/modules/ClientReports/resources/views/emails/report.blade.php @@ -13,6 +13,7 @@ $soft = '#f8fafc'; $good = '#047857'; $warn = '#b45309'; + $bad = '#b91c1c'; $meta = $data['meta'] ?? []; $updates = $data['updates'] ?? []; @@ -32,22 +33,32 @@ $period = $report->period_start->format('F Y'); $range = $report->period_start->format('M j').' – '.$report->period_end->format('M j, Y'); - $showUptime = isset($data['uptime']) && ($uptime['monitored'] ?? true); - // Reports compiled before schema 2 counted form passes wrongly (always 0%), so their forms data isn't shown. - $isCurrentSchema = (int) ($meta['schema_version'] ?? 1) >= 2; + // Which sections the client sees: one rule for this, the text part and the preview. + $sections = \Modules\ClientReports\Support\ReportSections::visibility($data); + $showUptime = $sections['uptime']; $liveTests = (int) ($forms['live_tests'] ?? 0); $labTests = (int) ($forms['lab_tests'] ?? 0); - $showForms = $isCurrentSchema && isset($data['forms']) && ($liveTests > 0 || $labTests > 0); - $showPerf = isset($data['performance']) && ! empty($perf['has_performance']) && (! empty($perf['mobile']) || ! empty($perf['desktop'])); - $showTraffic = isset($data['traffic']) && ! empty($traffic['has_traffic']); - $showWork = isset($data['work_log']) && ! empty($workLog['entries']); + $showForms = $sections['forms']; + $showPerf = $sections['performance']; + $showTraffic = $sections['traffic']; + $showWork = $sections['work_log']; $updateCount = count($updateRows); $uptimePct = $uptime['uptime_percentage'] ?? null; $uptimeLabel = $uptimePct === null ? '—' : rtrim(rtrim(number_format((float) $uptimePct, 2), '0'), '.').'%'; + // Green only for a month that really was (99.9%+), amber from 99%, red below. + $uptimeColor = match (\Modules\ClientReports\Support\ReportPhrases::uptimeTone($uptimePct)) { + 'good' => $good, + 'warn' => $warn, + 'bad' => $bad, + default => $muted, + }; $downtime = (int) ($uptime['downtime_minutes'] ?? 0); $downtimeLabel = $downtime >= 60 ? floor($downtime / 60).'h '.($downtime % 60).'m' : $downtime.' min'; $isWordPress = (bool) ($data['site']['is_wordpress'] ?? true); + // No transport can run the core-file check on some hosts, so "Not checked" + // would be true every month: the row is left out. Older reports had the row. + $showChecksum = $isWordPress && (bool) ($security['checksum_supported'] ?? true); $checksum = $security['checksum_status'] ?? null; [$checksumLabel, $checksumColor] = match ($checksum) { 'clean' => ['Verified clean', $good], @@ -56,15 +67,13 @@ default => [ucfirst((string) $checksum), $warn], }; // Older reports never actually checked vulnerabilities, so a missing flag means "not checked". - $vulnsChecked = (bool) ($security['vulnerabilities_checked'] ?? false); - $inactiveVulns = (int) ($security['inactive_plugin_vulnerabilities'] ?? 0); - $vulns = (int) ($security['active_vulnerabilities'] ?? 0); - $vulnPlugins = count($security['vulnerable_plugins'] ?? []); - [$vulnLabel, $vulnColor] = match (true) { - ! $vulnsChecked => ['Not checked', $muted], - $vulns === 0 => ['None found', $good], - default => [$vulns.' found'.($vulnPlugins > 0 ? ' in '.$vulnPlugins.' '.\Illuminate\Support\Str::plural('plugin', $vulnPlugins) : '').($inactiveVulns > 0 ? ' ('.$inactiveVulns.' in inactive plugins)' : ''), $warn], + $vulnLabel = \Modules\ClientReports\Support\ReportPhrases::vulnerabilities($security); + $vulnColor = match (true) { + ! ($security['vulnerabilities_checked'] ?? false) => $muted, + (int) ($security['active_vulnerabilities'] ?? 0) === 0 => $good, + default => $warn, }; + $vulnPluginNames = \Modules\ClientReports\Support\ReportPhrases::vulnerablePlugins($security); $showMalware = array_key_exists('malware_status', $security); $showBlacklist = array_key_exists('blacklist_status', $security); [$blacklistLabel, $blacklistColor] = match ($security['blacklist_status'] ?? null) { @@ -73,40 +82,39 @@ null => ['Not checked this month', $muted], default => [ucfirst((string) $security['blacklist_status']), $warn], }; - [$malwareLabel, $malwareColor] = match ($security['malware_status'] ?? null) { - 'clean' => ['No malware found', $good], - 'warning', 'issues_found' => ['Issues flagged', $warn], - null => ['Not checked this month', $muted], - default => [ucfirst((string) $security['malware_status']), $warn], - }; - $vulnsAsOf = ! empty($security['vulnerabilities_as_of']) - ? \Illuminate\Support\Carbon::parse($security['vulnerabilities_as_of'])->format('M j') - : null; - $showBackups = isset($data['backups']) && ! empty($backups['verified']); - $backupKindLabel = fn (array $kinds) => match (true) { - in_array('full', $kinds, true), in_array('fs', $kinds, true) && in_array('db', $kinds, true) => 'Files & database', - in_array('db', $kinds, true) => 'Database', - default => 'Files', + // Shared with the text part: "Reviewed — no threat found" once a person + // has checked review-tier findings, "Partial scan" for a scan cut short. + [$malwareLabel, $malwareTone] = \Modules\ClientReports\Support\ReportPhrases::malwareLabel($security); + $malwareColor = match ($malwareTone) { + 'good' => $good, + 'muted' => $muted, + default => $warn, }; - $outageWhen = fn (array $o) => (! empty($o['started_before_period']) ? 'Down since before ' : 'Down ').\Illuminate\Support\Carbon::parse($o['started_at'])->format('M j, g:i A').' UTC'.(! empty($o['ongoing']) ? ' (still down at period end)' : ''); + // The Companion snapshot date: when we looked, often just after the period. + $vulnsCheckedOn = \Modules\ClientReports\Support\ReportPhrases::vulnerabilitiesCheckedOn($security); + $showBackups = $sections['backups']; + $backupDestination = \Modules\ClientReports\Support\ReportPhrases::backupDestination($backups); + // A site enrolled part-way through the month: say when monitoring began. + $coverageNote = \Modules\ClientReports\Support\ReportPhrases::coverageNote($data); + $monitoredSince = \Modules\ClientReports\Support\ReportPhrases::monitoringBeganOn($uptime['monitored_since'] ?? null); + $outageWhen = fn (array $o) => \Modules\ClientReports\Support\ReportPhrases::outageStart($o).(! empty($o['ongoing']) ? ' (still down at period end)' : ''); $outageLength = fn (int $m) => $m < 1 ? '<1 min' : ($m >= 60 ? floor($m / 60).'h '.($m % 60).'m' : $m.' min'); - $formatBytes = fn (int $b) => $b >= 1024 ** 3 ? number_format($b / 1024 ** 3, 1).' GB' : ($b >= 1024 ** 2 ? number_format($b / 1024 ** 2, 0).' MB' : number_format(max(1, $b / 1024), 0).' KB'); - $scoreColor = fn ($s) => $s >= 90 ? $good : ($s >= 50 ? $warn : '#b91c1c'); - $bandwidth = (float) ($traffic['bandwidth_mb'] ?? 0); - $showBandwidth = $bandwidth > 0; - $bandwidthLabel = $bandwidth >= 1024 ? number_format($bandwidth / 1024, 1).' GB' : number_format($bandwidth, 0).' MB'; + $scoreColor = fn ($s) => $s >= 90 ? $good : ($s >= 50 ? $warn : $bad); + // Shared with the text part, so both say the same. + $bandwidthLabel = \Modules\ClientReports\Support\ReportPhrases::bandwidth($traffic['bandwidth_mb'] ?? null); + $showBandwidth = $bandwidthLabel !== null; // Tiles sit four across even on a phone, so long numbers are abbreviated there // (the section below still shows the exact figure). $compact = fn ($n) => $n >= 1000000 ? rtrim(rtrim(number_format($n / 1000000, 1), '0'), '.').'M' : ($n >= 10000 ? rtrim(rtrim(number_format($n / 1000, 1), '0'), '.').'K' : number_format($n)); $tiles = []; - if (isset($data['updates'])) $tiles[] = [$updateCount, 'Updates applied', $ink]; - if ($showUptime) $tiles[] = [$uptimeLabel, 'Uptime', $good]; - if (isset($data['security'])) $tiles[] = [$compact((int) ($security['total_scans'] ?? 0)), 'Security scans', $ink]; + if ($sections['updates']) $tiles[] = [$updateCount, 'Updates applied', $ink]; + if ($showUptime) $tiles[] = [$uptimeLabel, 'Uptime', $uptimeColor]; + if ($sections['security']) $tiles[] = [$compact((int) ($security['total_scans'] ?? 0)), 'Security scans', $ink]; if ($showTraffic) $tiles[] = [$compact((int) ($traffic['total_visits'] ?? 0)), 'Visits', $ink]; - elseif (isset($data['security']) && ($security['blocked_threats_count'] ?? null) !== null) $tiles[] = [$compact((int) $security['blocked_threats_count']), 'Threats blocked', $ink]; + elseif ($sections['security'] && ($security['blocked_threats_count'] ?? null) !== null) $tiles[] = [$compact((int) $security['blocked_threats_count']), 'Threats blocked', $ink]; $tiles = array_slice($tiles, 0, 4); @endphp @@ -145,8 +153,11 @@ {{-- Intro --}} -

    Hi{{ $contactName ? ' '.$contactName : '' }},

    +

    Hi {{ $greetingName ?: 'there' }},

    Here's your website care summary for {!! $plainDomain !!} for {{ $period }} — what we updated, how the site performed, and how we kept it secure.

    + @if ($coverageNote) +

    {{ $coverageNote }}

    + @endif @if (! empty($meta['custom_notes'])) @@ -179,7 +190,7 @@ @endif {{-- Updates --}} - @if (isset($data['updates'])) + @if ($sections['updates'])
    Updates applied

    Keeping WordPress, themes and plugins current closes known security holes and keeps everything compatible.

    @@ -195,11 +206,7 @@ @endforeach @else - @if (\Modules\ClientReports\Services\ClientReportCompiler::updatesWereWaiting($updates)) -

    No updates were applied this month.

    - @else -

    Everything was already up to date this month — no updates were needed.

    - @endif +

    {{ \Modules\ClientReports\Support\ReportPhrases::noUpdatesLine($updates) }}

    @endif @endif @@ -210,7 +217,7 @@
    Uptime & availability

    We check your site around the clock and are alerted the moment it stops responding.

    - +
    Availability{{ $uptimeLabel }}
    Availability @if ($monitoredSince)(monitoring began {{ $monitoredSince }})@endif{{ $uptimeLabel }}
    Outages{{ (int) ($uptime['outages_count'] ?? 0) }}
    Total downtime{{ $downtimeLabel }}
    @@ -231,12 +238,20 @@ @endif {{-- Security --}} - @if (isset($data['security'])) + @if ($sections['security'])
    Security
    - {{-- Only claim blocking where something on the site reports its blocks to us. --}} - @php $showBlocked = ($security['blocked_threats_count'] ?? null) !== null; @endphp -

    {{ $showBlocked ? 'We scan for malware and tampered files, watch for known plugin vulnerabilities, and block attackers at the server.' : 'We scan for malware and tampered files and watch for known plugin vulnerabilities.' }}

    + {{-- Only claim blocking where we know the site's lockouts reach us, and + "at the server" only where a fail2ban jail bans there. --}} + @php + $showBlocked = ($security['blocked_threats_count'] ?? null) !== null; + $securityIntro = match (true) { + $showBlocked && (bool) ($security['blocks_at_server'] ?? true) => 'We scan for malware and tampered files, watch for known plugin vulnerabilities, and block attackers at the server.', + $showBlocked => 'We scan for malware and tampered files, watch for known plugin vulnerabilities, and block malicious visitors.', + default => 'We scan for malware and tampered files and watch for known plugin vulnerabilities.', + }; + @endphp +

    {{ $securityIntro }}

    @if ($showBlacklist) @@ -245,10 +260,14 @@ @if ($showMalware) @endif - @if ($isWordPress) + @if ($showChecksum) @endif - + @if ($showBlocked) @endif @@ -304,10 +323,10 @@ @endif @@ -323,17 +342,17 @@ @php $backupDays = (int) ($backups['days_with_backups'] ?? 0); @endphp

    @if ($backups['covers_period'] ?? true) - Your site was backed up on {{ $backupDays }} {{ \Illuminate\Support\Str::plural('day', $backupDays) }} this month{{ ! empty($backups['destination']) ? ' by '.$backups['destination'] : '' }}, so it can be restored if anything goes wrong. + Your site was backed up on {{ $backupDays }} {{ \Illuminate\Support\Str::plural('day', $backupDays) }} this month{{ $backupDestination ? ' '.$backupDestination : '' }}, so it can be restored if anything goes wrong. @else - Your site is backed up{{ ! empty($backups['destination']) ? ' by '.$backups['destination'] : '' }}, so it can be restored if anything goes wrong. + Your site is backed up{{ $backupDestination ? ' '.$backupDestination : '' }}, so it can be restored if anything goes wrong. @endif

    Security scans run{{ number_format((int) ($security['total_scans'] ?? 0)) }}
    Malware scan{{ $malwareLabel }}
    WordPress core files{{ $checksumLabel }}
    Known plugin vulnerabilities @if ($vulnsAsOf)(as of {{ $vulnsAsOf }})@endif{{ $vulnLabel }}
    Known plugin vulnerabilities @if ($vulnsCheckedOn)(checked {{ $vulnsCheckedOn }})@endif + @if ($vulnPluginNames !== []) +
    {{ \Illuminate\Support\Str::plural('Plugin', count($vulnPluginNames)) }} affected: {{ implode(', ', $vulnPluginNames) }}
    + @endif +
    {{ $vulnLabel }}
    Malicious visitors blocked{{ number_format((int) $security['blocked_threats_count']) }}
    Contact forms
    @if ($liveTests > 0) -

    We sent {{ $liveTests }} test {{ \Illuminate\Support\Str::plural('submission', $liveTests) }} through your forms to confirm messages reach you — {{ $forms['pass_rate'] ?? 0 }}% delivered.

    +

    We sent {{ $liveTests }} test {{ \Illuminate\Support\Str::plural('submission', $liveTests) }} through your forms — your site reported sending the notification email for {{ $forms['pass_rate'] ?? 0 }}% of them. We don't check the inbox, so this doesn't confirm the emails arrived.

    @endif @if ($labTests > 0) -

    Your forms accepted {{ (int) ($forms['lab_accepted'] ?? 0) }} of {{ $labTests }} automated check {{ \Illuminate\Support\Str::plural('submission', $labTests) }}. These checks confirm the form works without sending an email, so they don't test delivery.

    +

    Your forms accepted {{ (int) ($forms['lab_accepted'] ?? 0) }} of {{ $labTests }} automated check {{ \Illuminate\Support\Str::plural('submission', $labTests) }}. These checks confirm the form works without sending an email, so they don't test the email.

    @endif
    @foreach ($backups['recent'] as $day) - - + + @endforeach
    {{ \Illuminate\Support\Carbon::parse($day['date'])->format('D, M j') }}{{ $backupKindLabel($day['kinds']) }}{{ ! empty($day['manual']) ? ' (manual)' : '' }}{{ $day['bytes'] !== null ? $formatBytes((int) $day['bytes']) : '—' }}{{ \Modules\ClientReports\Support\ReportPhrases::backupKinds((array) $day['kinds']) }}{{ ! empty($day['manual']) ? ' (manual)' : '' }}{{ $day['bytes'] !== null ? \Modules\ClientReports\Support\ReportPhrases::bytes((int) $day['bytes']) : '—' }}
    diff --git a/modules/ClientReports/resources/views/index.blade.php b/modules/ClientReports/resources/views/index.blade.php index eeccd0f3..cf843874 100644 --- a/modules/ClientReports/resources/views/index.blade.php +++ b/modules/ClientReports/resources/views/index.blade.php @@ -1,195 +1,282 @@ @extends('layouts.app') -@section('title', 'Client Reports · Clockwork') +@section('title', 'Reports · Client Reports · Clockwork') + +@php + $stateClass = \Modules\ClientReports\Support\ReportState::class; + $categoryClass = \Modules\ClientReports\Support\ReviewReasonCategories::class; + $filtersClass = \Modules\ClientReports\Support\ReportListFilters::class; + $context = $filters->toQuery(); + $periodLabel = $filters->period === $filtersClass::ALL || $filters->period === null + ? 'all periods' + : \Illuminate\Support\Carbon::createFromFormat('!Y-m', $filters->period)->format('F Y'); + // Summary tiles: each one a status filter for the selected period. + $tiles = [ + [$filtersClass::WAITING, 'Waiting', array_sum(array_map(fn ($s) => $summary[$s] ?? 0, $stateClass::WAITING)), 'Anything not sent yet'], + [$stateClass::NEEDS_REVIEW, $stateClass::label($stateClass::NEEDS_REVIEW), $summary[$stateClass::NEEDS_REVIEW] ?? 0, 'Held: something to check or fix'], + [$stateClass::OUTDATED, $stateClass::label($stateClass::OUTDATED), $summary[$stateClass::OUTDATED] ?? 0, 'Recompile before sending'], + [$stateClass::READY, $stateClass::label($stateClass::READY), $summary[$stateClass::READY] ?? 0, 'Nothing to fix, waiting to be sent'], + [$stateClass::SENT, $stateClass::label($stateClass::SENT), ($summary[$stateClass::SENT] ?? 0) + ($summary[$stateClass::PARTLY_SENT] ?? 0), ($summary[$stateClass::PARTLY_SENT] ?? 0) > 0 ? ($summary[$stateClass::PARTLY_SENT]).' only partly' : 'With every recipient'], + [$filtersClass::ALL, 'All', $summary['total'] ?? 0, 'Every report this period'], + ]; + $activeStatus = $filters->status ?? $filtersClass::ALL; +@endphp @section('content') + subtitle="Check what's held, fix and recompile, then send. Filters stay in the address, so they survive opening a report and coming back."> - - Generate New Report + + One-off report @include('client-reports::_nav') +@include('client-reports::partials.flash') -
    - {{-- Main Reports List --}} -
    -
    -
    -

    - - Generated Reports History -

    - {{ $reports->total() }} reports +{{-- Summary of the selected period: counts that are also filter links. --}} +
    + @foreach ($tiles as [$tileStatus, $tileLabel, $tileCount, $tileHint]) + +
    + {{ $tileLabel }} + @if ($stateClass::isKnown($tileStatus)) + + @endif
    +
    {{ number_format($tileCount) }}
    +
    {{ $tileHint }}
    +
    + @endforeach +
    - @if ($reports->isEmpty()) -
    - - No reports generated yet. Use the generator on the right to compile your first client report! -
    - @else -
    - - - - - - - - - - - - @foreach ($reports as $r) - - - - - - - - @endforeach - -
    Site & TitlePeriodClientStatusActions
    -
    - {{ $r->title }} - @if ($r->template) - - {{ $r->template->name }} - - @endif -
    -
    {{ $r->site->domain }}
    -
    - {{ $r->period_start->format('M j') }} – {{ $r->period_end->format('M j, Y') }} - - {{ $r->client_name ?: '—' }} - @if ($r->client_email) -
    {{ $r->client_email }}
    - @endif -
    - @if ($r->status === 'generated' && empty($r->sent_to) && ! empty($r->sections_data['meta']['review_reasons'])) - - Needs review - - @elseif ($r->status !== 'sent' && ! empty($r->sent_to)) - - Partly sent - - @else - - {{ ucfirst($r->status) }} - - @endif - -
    - - - - {{-- No saved recipient: ask for one instead of failing server-side. --}} -
    recipients() === []) - onsubmit="const to = prompt('Send this report to which email address(es)?'); if (! to) return false; this.email.value = to; return true;" - @else - onsubmit="return confirm(@js('Email report to '.implode(', ', $r->recipientsToSend() ?: $r->recipients()).'?'));" - @endif - > - @csrf - +
    + {{-- Filters (GET, so they live in the query string) --}} + + @if ($filters->client) + + @if ($filters->from)@endif + @endif +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + @if ($filters->isFiltered() || $filters->showSuperseded) + Clear + @endif +
    + - - -
    -
    -
    -
    - {{ $reports->links() }} -
    + {{-- Bulk actions for the period. Never send. --}} +
    + @csrf + @foreach ($context as $contextKey => $contextValue) + + @endforeach +
    + {{ $reports->total() }} {{ \Illuminate\Support\Str::plural('report', $reports->total()) }} + · {{ $periodLabel }} + @if ($filters->client) + · one client + × client @endif
    -
    +
    + + @if ($outdatedCount > 0) + + @endif +
    + - {{-- Generate Report Form (full width below the history table) --}} -
    - {{-- Generate Report Panel --}} -
    -

    - - Generate Client Report -

    -

    - Compile data from updates, uptime, security scans, and performance benchmarks. -

    + @if ($reports->isEmpty()) +
    + + @if ($filters->isFiltered()) + No reports match these filters. Clear filters + @else + No reports for {{ $periodLabel }} yet. + @endif +
    + @else +
    + + + + + + + + + + + + + @foreach ($reports as $r) + @php($state = $stateClass::of($r)) + + + + + + + + + @endforeach + +
    + + Site & clientPeriodStatus & whyRecipientsActions
    + canRecompile()) + aria-label="Select {{ $r->site?->domain }} {{ $r->period_start->format('M Y') }}" + title="{{ $r->canRecompile() ? 'Select to recompile' : 'Only unsent scheduled reports can be recompiled' }}"> + + {{ $r->site?->domain ?? $r->client_name ?? $r->title }} +
    + @if ($r->site?->client) + {{ $r->site->client->company_name ?: $r->site->client->name }} + @elseif ($r->site) + No client + @endif + @if ($r->schedule_id === null) + One-off + @endif +
    +
    {{ $r->title }}
    +
    +
    {{ $r->period_start->format('M Y') }}
    +
    {{ $r->period_start->format('M j') }} – {{ $r->period_end->format('M j') }}
    +
    + @include('client-reports::partials.state-pill', ['state' => $state]) + @include('client-reports::partials.reason-chips', ['reasons' => $stateClass::reasons($r)]) + + @if ($r->recipients() !== []) +
    {{ implode(', ', $r->recipients()) }}
    + @else + No recipient + @endif + @if (! empty($r->sent_to) && $state !== $stateClass::SENT) +
    Has it: {{ implode(', ', $r->sent_to) }}
    + @elseif ($r->sent_at) +
    Sent {{ $r->sent_at->format('M j, Y') }}
    + @endif +
    + @include('client-reports::partials.report-actions', ['report' => $r, 'context' => $context]) +
    +
    + @if ($reports->hasPages()) +
    {{ $reports->links() }}
    + @endif + @endif +
    -
    - @csrf -
    - - -
    +{{-- One-off report, outside any schedule --}} +
    + + + Generate a one-off report + — for any site and dates, outside its schedule + + + @csrf +
    + + +
    - @if (!empty($templates) && $templates->isNotEmpty()) -
    - - -
    - @endif + @if (!empty($templates) && $templates->isNotEmpty()) +
    + + +
    + @endif -
    -
    - - -
    -
    - - -
    -
    +
    +
    + + +
    +
    + + +
    +
    -
    - - -
    +
    + + +
    -
    - - -
    +
    + + +
    -
    - - -
    +
    + + +
    -
    - -
    - +
    +
    -
    -
    + + @endsection diff --git a/modules/ClientReports/resources/views/partials/flash.blade.php b/modules/ClientReports/resources/views/partials/flash.blade.php new file mode 100644 index 00000000..a27179d0 --- /dev/null +++ b/modules/ClientReports/resources/views/partials/flash.blade.php @@ -0,0 +1,13 @@ +@if (session('status')) +
    + + {{ session('status') }} +
    +@endif + +@if (session('status_error')) + +@endif diff --git a/modules/ClientReports/resources/views/partials/history-strip.blade.php b/modules/ClientReports/resources/views/partials/history-strip.blade.php new file mode 100644 index 00000000..5e28b426 --- /dev/null +++ b/modules/ClientReports/resources/views/partials/history-strip.blade.php @@ -0,0 +1,25 @@ +{{-- One site's last report periods as small month cells, each linking to + that month's report. $months: ClientDirectory site row 'months'; + $context: query parameters for the report links. --}} +@php + $stateClass = \Modules\ClientReports\Support\ReportState::class; + $directoryClass = \Modules\ClientReports\Services\ClientDirectory::class; +@endphp +
    + @foreach ($months as $ym => $cell) + @php + $monthLabel = $cell['month']->format('M Y'); + $title = match ($cell['state']) { + $directoryClass::CELL_NONE => "{$monthLabel}: no report", + $directoryClass::CELL_SKIPPED => "{$monthLabel}: skipped (no care plan)", + default => "{$monthLabel}: ".$stateClass::label($cell['state']), + }; + @endphp + @if ($cell['report']) + {{ $cell['month']->format('M') }} + @else + {{ $cell['month']->format('M') }} + @endif + @endforeach +
    diff --git a/modules/ClientReports/resources/views/partials/reason-chips.blade.php b/modules/ClientReports/resources/views/partials/reason-chips.blade.php new file mode 100644 index 00000000..8371d609 --- /dev/null +++ b/modules/ClientReports/resources/views/partials/reason-chips.blade.php @@ -0,0 +1,12 @@ +{{-- Short chips for why a report is held, one per category; the full + sentences are in each chip's tooltip. $reasons: list of review reasons. --}} +@php($groups = \Modules\ClientReports\Support\ReviewReasonCategories::group($reasons ?? [])) +@if ($groups !== []) +
    + @foreach ($groups as $category => $texts) + + {{ \Modules\ClientReports\Support\ReviewReasonCategories::short($category) }}@if (count($texts) > 1) ×{{ count($texts) }}@endif + + @endforeach +
    +@endif diff --git a/modules/ClientReports/resources/views/partials/report-actions.blade.php b/modules/ClientReports/resources/views/partials/report-actions.blade.php new file mode 100644 index 00000000..6dffd209 --- /dev/null +++ b/modules/ClientReports/resources/views/partials/report-actions.blade.php @@ -0,0 +1,65 @@ +{{-- A report's actions on the app-styled pages (list, client page, email + version): View, Email version, Recompile, Send, Send test to me. + $report; $context: query parameters that keep the list the operator came + from (filters, client page); $hide: actions to leave out (e.g. 'view'). --}} +@php + $stateClass = \Modules\ClientReports\Support\ReportState::class; + $context = $context ?? []; + $hide = $hide ?? []; + $blocked = $stateClass::sendBlockedReason($report); + $hasRecipients = $report->recipients() !== []; + $operatorEmail = auth()->user()?->email; +@endphp +
    + @unless (in_array('view', $hide, true)) + + View + + @endunless + @unless (in_array('email', $hide, true)) + + Email + + @endunless + {{-- Unsent scheduled reports only: refresh from current data. Never sends. --}} + @if ($report->canRecompile()) +
    + @csrf + {{-- The new copy opens in the same list context (filters, client page). --}} + @foreach ($context as $contextKey => $contextValue) + + @endforeach + +
    + @endif + @if ($blocked !== null) + + @else +
    + @csrf + + +
    + @endif +
    + @csrf + +
    +
    diff --git a/modules/ClientReports/resources/views/partials/state-pill.blade.php b/modules/ClientReports/resources/views/partials/state-pill.blade.php new file mode 100644 index 00000000..0b6f6c86 --- /dev/null +++ b/modules/ClientReports/resources/views/partials/state-pill.blade.php @@ -0,0 +1,5 @@ +{{-- A report's state as a pill. $state: a ReportState constant. --}} +@php($stateClass = \Modules\ClientReports\Support\ReportState::class) + + {{ $stateClass::label($state) }} + diff --git a/modules/ClientReports/resources/views/partials/strip-legend.blade.php b/modules/ClientReports/resources/views/partials/strip-legend.blade.php new file mode 100644 index 00000000..664812f5 --- /dev/null +++ b/modules/ClientReports/resources/views/partials/strip-legend.blade.php @@ -0,0 +1,8 @@ +@php($stateClass = \Modules\ClientReports\Support\ReportState::class) +
    + @foreach ([$stateClass::SENT, $stateClass::NEEDS_REVIEW, $stateClass::OUTDATED, $stateClass::READY, $stateClass::PARTLY_SENT] as $legendState) + {{ $stateClass::label($legendState) }} + @endforeach + Skipped (no care plan) + No report +
    diff --git a/modules/ClientReports/resources/views/partials/styles.blade.php b/modules/ClientReports/resources/views/partials/styles.blade.php new file mode 100644 index 00000000..00b1ba70 --- /dev/null +++ b/modules/ClientReports/resources/views/partials/styles.blade.php @@ -0,0 +1,60 @@ +{{-- A few module-only styles on top of the app's palette (status-* pills, + CSS variables), so they follow light/dark like everything else. In the + components layer, so Tailwind utilities and the status-* classes still + win over them. --}} + diff --git a/modules/ClientReports/resources/views/reports/_hidden-note.blade.php b/modules/ClientReports/resources/views/reports/_hidden-note.blade.php new file mode 100644 index 00000000..aa3b7844 --- /dev/null +++ b/modules/ClientReports/resources/views/reports/_hidden-note.blade.php @@ -0,0 +1,17 @@ +{{-- Operator preview only: why a section is left out of what the client + sees. no-print, so it never reaches the PDF; the caller only includes it + on the operator preview, so it never reaches the public view either. + Optional: $badge (a short status pill, e.g. "Failing") and $detail (an + error line). --}} +
    + +
    +
    {{ $note }}
    + @if (! empty($badge)) + {{ $badge }} + @endif + @if (! empty($detail)) +
    {{ $detail }}
    + @endif +
    +
    diff --git a/modules/ClientReports/resources/views/reports/_malware-reviewed-form.blade.php b/modules/ClientReports/resources/views/reports/_malware-reviewed-form.blade.php new file mode 100644 index 00000000..dabcf1be --- /dev/null +++ b/modules/ClientReports/resources/views/reports/_malware-reviewed-form.blade.php @@ -0,0 +1,9 @@ +{{-- Review-tier malware findings a person has checked: clears that reason + and the client's "Issues flagged". Never sends. Operator preview only. --}} +
    + @csrf + + +
    diff --git a/modules/ClientReports/resources/views/reports/_operator-toolbar.blade.php b/modules/ClientReports/resources/views/reports/_operator-toolbar.blade.php new file mode 100644 index 00000000..1b9263a4 --- /dev/null +++ b/modules/ClientReports/resources/views/reports/_operator-toolbar.blade.php @@ -0,0 +1,108 @@ +{{-- Operator preview only (never the public /reports/view page, never + printed): a sticky bar to step through the list the report was opened + from and act on it. $report, $nav (ClientReportsController::navigation). --}} +@php + $stateClass = \Modules\ClientReports\Support\ReportState::class; + $state = $stateClass::of($report); + $query = $nav['query'] ?? []; + $blocked = $stateClass::sendBlockedReason($report); + $operatorEmail = auth()->user()?->email; + $btn = 'px-3 py-1.5 rounded-lg border border-slate-300 bg-white text-slate-700 text-xs font-semibold hover:bg-slate-50 transition-colors inline-flex items-center gap-1.5 whitespace-nowrap'; + $btnOff = 'px-3 py-1.5 rounded-lg border border-slate-200 bg-slate-50 text-slate-400 text-xs font-semibold inline-flex items-center gap-1.5 whitespace-nowrap cursor-not-allowed'; + $pillColours = [ + $stateClass::NEEDS_REVIEW => 'bg-orange-100 text-orange-800', + $stateClass::OUTDATED => 'bg-violet-100 text-violet-800', + $stateClass::READY => 'bg-blue-100 text-blue-800', + $stateClass::PARTLY_SENT => 'bg-amber-100 text-amber-800', + $stateClass::SENT => 'bg-emerald-100 text-emerald-800', + $stateClass::SUPERSEDED => 'bg-slate-200 text-slate-700', + ]; +@endphp +
    +
    +
    +
    + {{ $nav['back_label'] }} + @if ($nav['previous']) + + @else + + @endif + @if ($nav['position']) + {{ $nav['position'] }} of {{ $nav['count'] }} + @endif + @if ($nav['next']) + + @else + + @endif +
    +
    + Email version + {{-- Unsent scheduled reports only: refresh from current data. Never sends. --}} + @if ($report->canRecompile()) +
    + @csrf + @foreach ($query as $key => $value) + + @endforeach + +
    + @endif + +
    + @csrf + +
    + @if ($blocked !== null) + Send + @else +
    recipients() === []) + onsubmit="const to = prompt('Send this report to which email address(es)?'); if (! to) return false; this.email.value = to; return true;" + @else + onsubmit="return confirm(@js($stateClass::sendConfirmation($report, "\n\n")));" + @endif + > + @csrf + + +
    + @endif +
    +
    +
    +
    + {{ $stateClass::label($state) }} + {{ $report->site?->domain }} + {{ $report->period_start->format('M j') }} – {{ $report->period_end->format('M j, Y') }} + @if ($blocked !== null) + Send unavailable: {{ $blocked }} + @elseif ($report->recipientsToSend() !== []) + Send goes to {{ implode(', ', $report->recipientsToSend()) }} + @endif +
    + +
    +
    +
    + diff --git a/modules/ClientReports/resources/views/reports/_review-panel.blade.php b/modules/ClientReports/resources/views/reports/_review-panel.blade.php new file mode 100644 index 00000000..0d4d4e64 --- /dev/null +++ b/modules/ClientReports/resources/views/reports/_review-panel.blade.php @@ -0,0 +1,48 @@ +{{-- Operator preview only: why the report is held, grouped by category, + each with a link to where it can be looked at. $report, $reasons. --}} +@php + $categoryClass = \Modules\ClientReports\Support\ReviewReasonCategories::class; + $groups = $categoryClass::group($reasons); + $tabLabels = ['security' => 'Security tab', 'updates' => 'Updates tab', 'traffic' => 'Traffic tab', 'forms' => 'Forms tab', 'overview' => 'site overview']; + // Review-tier malware findings a person can mark checked (held, unsent reports only). + $canMarkReviewed = $report->status === \Modules\ClientReports\Models\ClientReport::STATUS_GENERATED + && empty($report->sent_to) + && \Modules\ClientReports\Services\ReportReviewFlags::canMarkMalwareReviewed($data ?? (array) $report->sections_data); +@endphp +
    +
    {{ $report->schedule?->delivery_mode === \Modules\ClientReports\Models\ClientReportSchedule::MODE_AUTO ? 'Held for review instead of being emailed automatically' : 'Check these before sending' }}
    +
    + @foreach ($groups as $category => $texts) + @php($tab = $categoryClass::siteTab($category)) +
    +
    + {{ $categoryClass::label($category) }} + @if ($category === $categoryClass::FIRST_REPORT && $report->schedule) + Check the schedule's recipients → + @elseif ($tab !== null && $report->site) + Open the {{ $tabLabels[$tab] ?? $tab }} → + @endif +
    +
      + @foreach ($texts as $reason) +
    • {{ $reason }}
    • + @endforeach +
    + @if ($category === $categoryClass::MALWARE && $canMarkReviewed) + @include('client-reports::reports._malware-reviewed-form', ['report' => $report]) + @php($canMarkReviewed = false) + @endif +
    + @endforeach +
    + @if ($canMarkReviewed) + @include('client-reports::reports._malware-reviewed-form', ['report' => $report]) + @endif +
    + @if ($report->canRecompile()) + Fix or confirm these, then press Recompile to refresh this report from current data (it never sends), or Send it as it is. + @else + Fix or confirm these, then Send it (or generate a fresh one). + @endif +
    +
    diff --git a/modules/ClientReports/resources/views/reports/email.blade.php b/modules/ClientReports/resources/views/reports/email.blade.php index e4cc0e39..12aeb1df 100644 --- a/modules/ClientReports/resources/views/reports/email.blade.php +++ b/modules/ClientReports/resources/views/reports/email.blade.php @@ -1,14 +1,12 @@ - - - - - - Email · {{ $report->title }} - - - - +@extends('layouts.app') + +@section('title', 'Email · '.($report->site?->domain ?? $report->title).' · Client Reports') + @php + $stateClass = \Modules\ClientReports\Support\ReportState::class; + $categoryClass = \Modules\ClientReports\Support\ReviewReasonCategories::class; + $state = $stateClass::of($report); + $query = $nav['query'] ?? []; // What pressing Send would do right now, so the reviewer sees the real "To". $to = $report->recipientsToSend(); $toLabel = match (true) { @@ -16,44 +14,138 @@ $report->recipients() !== [] => 'Everyone on this report already has it', default => 'No recipient saved — Send will ask for one', }; - $reasons = $report->status === \Modules\ClientReports\Models\ClientReport::STATUS_SENT ? [] : ($report->sections_data['meta']['review_reasons'] ?? []); + $reasons = $stateClass::reasons($report); + $tabLabels = ['security' => 'Security tab', 'updates' => 'Updates tab', 'traffic' => 'Traffic tab', 'forms' => 'Forms tab', 'overview' => 'site overview']; @endphp -
    -
    - Back to the report - This is the email exactly as the client gets it. -
    -
    -
    To{{ $toLabel }}
    - @if (! empty($report->sent_to)) -
    Sent to{{ implode(', ', $report->sent_to) }}
    +@section('content') +@include('client-reports::_nav') +@include('client-reports::partials.flash') + +{{-- Same stepping and actions as the preview --}} +
    +
    + {{ $nav['back_label'] }} + @if ($nav['previous']) + Previous + @else + Previous @endif -
    Subject{{ $subject }}
    + @if ($nav['position']) + {{ $nav['position'] }} of {{ $nav['count'] }} + @endif + @if ($nav['next']) + Next + @else + Next + @endif +
    +
    + Report page + @include('client-reports::partials.report-actions', ['report' => $report, 'context' => $query, 'hide' => ['view', 'email']])
    +
    - @if ($report->status === \Modules\ClientReports\Models\ClientReport::STATUS_SUPERSEDED) -
    A newer report for this schedule and period replaced this one. It can't be sent.
    - @elseif ($reasons !== []) -
    -
    Check these before sending
    -
      - @foreach ($reasons as $reason) -
    • {{ $reason }}
    • - @endforeach -
    +
    +
    + {{-- Envelope --}} +
    +
    +
    +
    {{ $report->site?->domain }}
    +
    {{ $report->period_start->format('M j') }} – {{ $report->period_end->format('M j, Y') }} · This is the email exactly as the client gets it.
    +
    + @include('client-reports::partials.state-pill', ['state' => $state]) +
    +
    +
    From
    +
    {{ $from ?? '' }}
    +
    To
    +
    {{ $toLabel }}
    + @if (! empty($report->sent_to)) +
    Sent to
    +
    {{ implode(', ', $report->sent_to) }}@if ($report->sent_at) · {{ $report->sent_at->format('M j, Y') }}@endif
    + @endif +
    Subject
    +
    {{ $subject }}
    +
    - @endif - {{-- No scripts inside; allow-same-origin only so the frame can be sized to its content. --}} - + {{-- HTML and plain-text parts --}} +
    +
    +
    + + +
    + What mail apps that don't show HTML display instead. +
    +
    + {{-- No scripts inside; allow-same-origin only so the frame can be sized to its content. --}} + +
    +
    +
    {{ $text }}
    +
    +
    +
    -
    - Plain-text version -

    What mail apps that don't show HTML display instead.

    -
    {{ $text }}
    -
    + {{-- Why it's held --}} +
    - - + + +@endsection diff --git a/modules/ClientReports/resources/views/reports/show.blade.php b/modules/ClientReports/resources/views/reports/show.blade.php index 95dffc57..8cf7af79 100644 --- a/modules/ClientReports/resources/views/reports/show.blade.php +++ b/modules/ClientReports/resources/views/reports/show.blade.php @@ -23,14 +23,36 @@ @php - $data = $report->sections_data; + $data = (array) ($report->sections_data ?? []); $meta = $data['meta'] ?? []; $branding = $data['branding'] ?? []; $site = $data['site'] ?? []; $updates = $data['updates'] ?? []; $uptime = $data['uptime'] ?? []; - // Same rule as the email: a site the uptime probe doesn't check has no uptime to show. - $showUptime = isset($data['uptime']) && ($uptime['monitored'] ?? true); + $isOperatorPreview = (bool) ($isOperatorPreview ?? false); + // Which sections the client sees: the same rule as both email parts. This + // page prints as the PDF and is also the public report view, so a hidden + // section only shows up here as a no-print note on the operator preview. + $sections = \Modules\ClientReports\Support\ReportSections::visibility($data); + $hiddenNotes = $isOperatorPreview ? \Modules\ClientReports\Support\ReportSections::hiddenNotes($data) : []; + $showUptime = $sections['uptime']; + // The email's figure and colour: green only for a month that really was + // (99.9%+), amber from 99%, red below. + $uptimePct = $uptime['uptime_percentage'] ?? null; + $uptimeLabel = $uptimePct === null ? '—' : rtrim(rtrim(number_format((float) $uptimePct, 2), '0'), '.').'%'; + $uptimeTone = match (\Modules\ClientReports\Support\ReportPhrases::uptimeTone($uptimePct)) { + 'good' => ['text' => 'text-emerald-600', 'badge' => 'text-emerald-700 bg-emerald-50'], + 'warn' => ['text' => 'text-amber-600', 'badge' => 'text-amber-700 bg-amber-50'], + 'bad' => ['text' => 'text-red-600', 'badge' => 'text-red-700 bg-red-50'], + default => ['text' => 'text-slate-400', 'badge' => 'text-slate-600 bg-slate-100'], + }; + // A site enrolled part-way through the month: say when monitoring began. + $coverageNote = \Modules\ClientReports\Support\ReportPhrases::coverageNote($data); + $monitoredSince = \Modules\ClientReports\Support\ReportPhrases::monitoringBeganOn($uptime['monitored_since'] ?? null); + $probeBlockedMinutes = (int) ($uptime['probe_blocked_minutes'] ?? 0); + // Core, theme and plugin updates, cleaned up exactly as the email lists them. + $updateRows = (new \Modules\ClientReports\Mail\ClientReportMail($report))->updateRows(); + $updateCount = count($updateRows); $security = $data['security'] ?? []; $perf = $data['performance'] ?? []; $forms = $data['forms'] ?? []; @@ -44,41 +66,50 @@ $accentColor = !empty($branding['accent_color']) ? $branding['accent_color'] : '#7EFF83'; @endphp +@if ($isOperatorPreview && isset($nav)) + @include('client-reports::reports._operator-toolbar', ['report' => $report, 'nav' => $nav]) +@endif +
    - {{-- Floating Action Bar for Print / Share --}} -
    -
    - - Executive Maintenance Report - - Period: {{ $report->period_start->format('M j, Y') }} – {{ $report->period_end->format('M j, Y') }} + @unless ($isOperatorPreview && isset($nav)) + {{-- Floating Action Bar for Print / Share --}} +
    +
    + + Executive Maintenance Report + + Period: {{ $report->period_start->format('M j, Y') }} – {{ $report->period_end->format('M j, Y') }} +
    +
    + +
    -
    - @if ($isOperatorPreview ?? false) - - Email version - - @endif - + @endunless + + @if ($isOperatorPreview && (session('status') || session('status_error'))) +
    + {{ session('status_error') ?: session('status') }}
    -
    + @endif + + @if ($isOperatorPreview && $report->status !== \Modules\ClientReports\Models\ClientReport::STATUS_SUPERSEDED && empty($report->sent_to) && $report->isStale()) +
    +
    Outdated: compiled before the latest report fixes
    +
    + Its figures were worked out under older rules, so it can't be sent as it is. + {{ $report->canRecompile() ? 'Press Recompile to refresh it from current data (it never sends), check the new copy, then send that one.' : 'Generate a new report for this period instead.' }} +
    +
    + @endif - @if (($isOperatorPreview ?? false) && $report->status === \Modules\ClientReports\Models\ClientReport::STATUS_SUPERSEDED) + @if ($isOperatorPreview && $report->status === \Modules\ClientReports\Models\ClientReport::STATUS_SUPERSEDED)
    A newer report for this schedule and period replaced this one. It can't be sent.
    - @elseif (($isOperatorPreview ?? false) && ! empty($data['meta']['review_reasons']) && $report->status !== 'sent') -
    -
    {{ $report->schedule?->delivery_mode === \Modules\ClientReports\Models\ClientReportSchedule::MODE_AUTO ? 'Held for review instead of being emailed automatically' : 'Check these before sending' }}
    -
      - @foreach ($data['meta']['review_reasons'] as $reason) -
    • {{ $reason }}
    • - @endforeach -
    -
    Fix or confirm these, then send it from the history table (or generate a fresh one).
    -
    + @elseif ($isOperatorPreview && \Modules\ClientReports\Support\ReportState::reasons($report) !== []) + @include('client-reports::reports._review-panel', ['report' => $report, 'reasons' => \Modules\ClientReports\Support\ReportState::reasons($report), 'data' => $data]) @endif {{-- Report Document Container --}} @@ -116,6 +147,10 @@
    + @if ($coverageNote) +

    {{ $coverageNote }}

    + @endif + {{-- Custom Operator Notes if Provided --}} @if (!empty($meta['custom_notes']))
    @@ -126,11 +161,13 @@ {{-- Key Metric Highlights Grid --}} @php - // Pre-schema-2 reports counted form passes wrongly (always 0%); don't show them. - $showFormsTile = (int) ($data['meta']['schema_version'] ?? 1) >= 2 && (int) ($forms['live_tests'] ?? 0) > 0; + // Same rule as the email's forms section (which also hides pre-schema-2 numbers). + $liveTests = (int) ($forms['live_tests'] ?? 0); + $labTests = (int) ($forms['lab_tests'] ?? 0); + $showFormsTile = $sections['forms']; // Null means nothing on the site reports blocks, so there's no number to show. - $showBlockedTile = isset($data['security']) && ($security['blocked_threats_count'] ?? null) !== null; - $metricCount = (isset($data['updates']) ? 1 : 0) + $showBlockedTile = $sections['security'] && ($security['blocked_threats_count'] ?? null) !== null; + $metricCount = ($sections['updates'] ? 1 : 0) + ($showUptime ? 1 : 0) + ($showBlockedTile ? 1 : 0) + ($showFormsTile ? 1 : 0); @@ -148,15 +185,15 @@ @endphp @if ($metricCount > 0)
    - @if (isset($data['updates'])) + @if ($sections['updates'])
    -
    {{ $updates['total'] ?? 0 }}
    +
    {{ $updateCount }}
    Updates Completed
    @endif @if ($showUptime)
    -
    {{ $uptime['uptime_percentage'] ?? 100 }}%
    +
    {{ $uptimeLabel }}
    Uptime Rate
    @endif @@ -168,52 +205,57 @@ @endif @if ($showFormsTile)
    -
    {{ $forms['pass_rate'] ?? 0 }}%
    -
    Form Deliverability
    + @if ($liveTests > 0) + {{-- Only live runs send an email, and all we learn is that the site + reported it sent: nothing watches the inbox, so no "deliverability". --}} +
    {{ $forms['pass_rate'] ?? 0 }}%
    +
    Form Emails Sent
    + @else +
    {{ (int) ($forms['lab_accepted'] ?? 0) }}/{{ $labTests }}
    +
    Form Checks Passed
    + @endif
    @endif
    @endif {{-- Section: Updates Applied --}} - @if (isset($data['updates'])) + @if ($sections['updates'])

    Updates & Upgrades

    - {{ $updates['total'] ?? 0 }} updates performed + {{ $updateCount }} {{ \Illuminate\Support\Str::plural('update', $updateCount) }} performed

    Regular updates ensure your site remains secure against known vulnerabilities and compatible with current WordPress releases.

    - @if (!empty($updates['items']['plugins'])) + @if ($updateCount > 0)
    - + - @foreach ($updates['items']['plugins'] as $item) + @foreach ($updateRows as $row) - - - + + + @endforeach
    ComponentDetailsUpdate Date Applied
    {{ $item['target'] ?: 'WordPress Plugin' }}{{ $item['summary'] }}{{ $item['date'] }}{{ $row['type'] }}{{ $row['label'] }}{{ $row['date'] }}
    - @elseif (\Modules\ClientReports\Services\ClientReportCompiler::updatesWereWaiting($updates)) - {{-- Same rule as the email: never "up to date" while updates wait or weren't checked. --}} -
    No updates were applied this period.
    @else -
    All software components were up-to-date throughout this period.
    + {{-- The email's sentence: never "up to date" while updates wait or weren't checked. --}} +
    {{ \Modules\ClientReports\Support\ReportPhrases::noUpdatesLine($updates) }}
    @endif
    @endif @@ -225,12 +267,12 @@

    Uptime & Availability

    - - {{ $uptime['uptime_percentage'] ?? 100 }}% Available + + {{ $uptimeLabel }} Available

    - Our global uptime monitors ping your website 24/7 at frequent intervals to verify round-the-clock availability. + We check your site around the clock and are alerted the moment it stops responding.

    @@ -242,15 +284,25 @@
    Total Downtime
    -
    24 / 7 / 365
    -
    Proactive Monitoring
    + @if ($monitoredSince) +
    {{ $monitoredSince }}
    +
    Monitoring began
    + @else +
    24 / 7 / 365
    +
    Proactive Monitoring
    + @endif
    + @if ($isOperatorPreview && $probeBlockedMinutes > 0) +

    The uptime monitor was blocked (HTTP 429) for {{ $probeBlockedMinutes }} min this period. That time isn't counted as downtime, and the client doesn't see this note.

    + @endif
    + @elseif (isset($hiddenNotes['uptime'])) + @include('client-reports::reports._hidden-note', ['note' => $hiddenNotes['uptime']]) @endif {{-- Section: Security & Threat Prevention --}} - @if (isset($data['security'])) + @if ($sections['security'])

    @@ -267,12 +319,17 @@ default => [ucfirst((string) $checksum), 'text-amber-600'], }; $vulnsChecked = (bool) ($security['vulnerabilities_checked'] ?? false); - [$malwareLabel, $malwareClass] = match ($security['malware_status'] ?? null) { - 'clean' => ['No malware found', 'text-emerald-600'], - 'warning', 'issues_found' => ['Issues flagged', 'text-amber-600'], - null => ['Not checked this period', 'text-slate-400'], - default => [ucfirst((string) $security['malware_status']), 'text-amber-600'], + $vulnPluginNames = \Modules\ClientReports\Support\ReportPhrases::vulnerablePlugins($security); + $vulnsCheckedOn = \Modules\ClientReports\Support\ReportPhrases::vulnerabilitiesCheckedOn($security); + // The email's label: "Reviewed — no threat found" once a person has + // checked review-tier findings, "Partial scan" for a scan cut short. + [$malwareLabel, $malwareTone] = \Modules\ClientReports\Support\ReportPhrases::malwareLabel($security); + $malwareClass = match ($malwareTone) { + 'good' => 'text-emerald-600', + 'muted' => 'text-slate-500', + default => 'text-amber-600', }; + $blockedNote = \Modules\ClientReports\Support\ReportPhrases::blockedVisitorsNote($security); $vulns = (int) ($security['active_vulnerabilities'] ?? 0); @endphp
    @@ -285,95 +342,118 @@ @endif
    @endif -
    -
    WordPress Core Files
    - @if ((bool) ($data['site']['is_wordpress'] ?? true)) + {{-- Like the email: no core-files row for a non-WordPress site or a host + with no way to run the check, and no blocked count unless we know + the site's lockouts reach us. --}} + @if ((bool) ($data['site']['is_wordpress'] ?? true) && (bool) ($security['checksum_supported'] ?? true)) +
    +
    WordPress Core Files
    {{ $checksumLabel }}
    @if (! empty($security['checksum_checked_at']))
    Checked {{ $security['checksum_checked_at'] }}
    @endif - @else -
    Not checked (no WordPress detected)
    - @endif -
    -
    -
    Malicious Requests Deflected
    -
    - {{ ($security['blocked_threats_count'] ?? null) !== null ? $security['blocked_threats_count'].' IP bans' : 'Not measured (no blocker reports to Control)' }}
    -
    + @elseif ($isOperatorPreview) +
    +
    WordPress Core Files
    +
    {{ (bool) ($data['site']['is_wordpress'] ?? true) ? 'Not checked (no checksum path for this host)' : 'Not checked (no WordPress detected)' }} — hidden from the client
    +
    + @endif + @if (($security['blocked_threats_count'] ?? null) !== null) +
    +
    Malicious Visitors Blocked
    +
    {{ number_format((int) $security['blocked_threats_count']) }}
    +
    + @elseif ($isOperatorPreview) +
    +
    Malicious Visitors Blocked
    +
    {{ $blockedNote ?? 'Malicious visitors blocked hidden from the client: no blocker reports to Control for this site this period.' }}
    +
    + @endif
    Known Plugin Vulnerabilities
    - @if (! $vulnsChecked) -
    Not checked
    - @else -
    - {{ $vulns === 0 ? 'None found' : $vulns.' found' }} -
    - @if (! empty($security['vulnerable_plugins'])) -
    {{ implode(', ', $security['vulnerable_plugins']) }}
    - @endif - @if (! empty($security['vulnerabilities_as_of'])) -
    As of {{ $security['vulnerabilities_as_of'] }}
    - @endif +
    + {{ \Modules\ClientReports\Support\ReportPhrases::vulnerabilities($security) }} +
    + @if ($vulnPluginNames !== []) +
    {{ \Illuminate\Support\Str::plural('Plugin', count($vulnPluginNames)) }} affected: {{ implode(', ', $vulnPluginNames) }}
    + @endif + @if ($vulnsCheckedOn) +
    Checked {{ $vulnsCheckedOn }}
    @endif

    @endif - {{-- Section: Backups & Disaster Recovery --}} - @if (isset($data['backups'])) + {{-- Section: Backups & Disaster Recovery. Only backups we actually saw + recently, like the email; anything else is an operator note. --}} + @if ($sections['backups']) @php - $backupStatus = $backups['status'] ?? (! empty($backups['verified']) ? 'verified' : 'not_checked'); - [$backupBadge, $backupBadgeClass] = match ($backupStatus) { - 'verified' => ['Verified this period', 'text-emerald-700 bg-emerald-50'], - 'none_found' => ['No backups found this period — hidden from the client email', 'text-amber-700 bg-amber-50'], - 'stale' => ['No backups in the last 10 days of the period — hidden from the client email', 'text-amber-700 bg-amber-50'], - 'no_source' => ['No backup source for this host — hidden from the client email', 'text-slate-600 bg-slate-100'], - default => ['Could not check backups — hidden from the client email', 'text-red-700 bg-red-50'], - }; + $backupDays = (int) ($backups['days_with_backups'] ?? 0); + $backupDestination = \Modules\ClientReports\Support\ReportPhrases::backupDestination($backups); @endphp

    Backups & Recovery

    - {{ $backupBadge }} + Verified this period
    - @if (in_array($backupStatus, ['verified', 'stale'], true)) + @if (isset($backups['status']))

    @if ($backups['covers_period'] ?? true) - Backed up on {{ (int) ($backups['days_with_backups'] ?? 0) }} day(s) this period{{ ! empty($backups['destination']) ? ' by '.$backups['destination'] : '' }}.{{ $backupStatus === 'stale' && ! empty($backups['last_backup_at']) ? ' Last backup: '.$backups['last_backup_at'].'.' : '' }} + Backed up on {{ $backupDays }} {{ \Illuminate\Support\Str::plural('day', $backupDays) }} this period{{ $backupDestination ? ' '.$backupDestination : '' }}. @else - Backed up{{ ! empty($backups['destination']) ? ' by '.$backups['destination'] : '' }}.{{ $backupStatus === 'stale' && ! empty($backups['last_backup_at']) ? ' Last backup: '.$backups['last_backup_at'].'.' : '' }} + Backed up{{ $backupDestination ? ' '.$backupDestination : '' }}. @endif

    - @if (! ($backups['covers_period'] ?? true) && ($isOperatorPreview ?? false)) -

    Our records for this site start {{ $backups['records_from'] }}, so the email leaves out the day count.

    + @if (! ($backups['covers_period'] ?? true) && $isOperatorPreview) + @if (! empty($backups['archives_began'])) +

    Off-site backups began on {{ $backups['archives_began'] }}; none were made before that this period, although the site was enrolled by {{ $backups['expected_from'] ?? $backups['records_from'] }}. The email leaves out the day count.

    + @else +

    Our records for this site start {{ $backups['records_from'] }}, so the email leaves out the day count.

    + @endif @endif + @else + {{-- Older reports, compiled before backups were recorded: the email's wording. --}} +

    + Your site's files and database were backed up to independent off-site storage this period.{{ ! empty($backups['last_backup_at']) ? ' Most recent backup: '.$backups['last_backup_at'].'.' : '' }} +

    @endif @if (! empty($backups['recent'])) @foreach ($backups['recent'] as $day) - - - + + + @endforeach
    {{ $day['date'] }}{{ implode(' + ', $day['kinds']) }}{{ ! empty($day['manual']) ? ' (manual)' : '' }}{{ $day['bytes'] !== null ? number_format($day['bytes'] / 1024 ** 2, 1).' MB' : '—' }}{{ \Illuminate\Support\Carbon::parse($day['date'])->format('D, M j') }}{{ \Modules\ClientReports\Support\ReportPhrases::backupKinds((array) ($day['kinds'] ?? [])) }}{{ ! empty($day['manual']) ? ' (manual)' : '' }}{{ ($day['bytes'] ?? null) !== null ? \Modules\ClientReports\Support\ReportPhrases::bytes((int) $day['bytes']) : '—' }}
    @endif - @if (! empty($backups['check_error'])) + @if (! empty($backups['check_error']) && $isOperatorPreview)
    Last check failed: {{ $backups['check_error'] }}
    @endif
    + @elseif (isset($hiddenNotes['backups'])) + @php + // Our relay's own failure (internal: never on the public view). + $relayError = ! empty($backups['relay_failing_since']) && ! empty($backups['relay_error']) + ? 'Last relay error: '.$backups['relay_error'] + : null; + @endphp + @include('client-reports::reports._hidden-note', [ + 'note' => $hiddenNotes['backups'], + 'badge' => ($backups['status'] ?? null) === 'failing' ? 'Failing' : null, + 'detail' => $relayError ?? (! empty($backups['check_error']) ? 'Last check failed: '.$backups['check_error'] : null), + ]) @endif {{-- Section: Performance Benchmarks --}} - @if (isset($data['performance']) && ! empty($perf['has_performance']) && (! empty($perf['mobile']) || ! empty($perf['desktop']))) + @if ($sections['performance'])

    @@ -398,10 +478,12 @@ @endif

    + @elseif (isset($hiddenNotes['performance'])) + @include('client-reports::reports._hidden-note', ['note' => $hiddenNotes['performance']]) @endif {{-- Section: Traffic Analytics --}} - @if (isset($data['traffic']) && !empty($traffic['has_traffic'])) + @if ($sections['traffic'])

    @@ -410,9 +492,14 @@ {{ number_format($traffic['total_visits'] ?? 0) }} visits this period

    - Overall visitor traffic and bandwidth usage recorded by the fleet's edge/CDN layer during the reporting period. + Visits, requests and data served for your site during the reporting period.

    -
    + @if ($isOperatorPreview && isset($traffic['days_covered'])) +

    Recorded on {{ (int) $traffic['days_covered'] }} of {{ (int) ($traffic['period_days'] ?? 0) }} days, from {{ ($traffic['source'] ?? null) === 'pressable' ? "Pressable's traffic feed" : "the server's access logs" }}.

    + @endif + {{-- Like the email: no "Data transferred" when the host reported no byte counts. --}} + @php($bandwidthLabel = \Modules\ClientReports\Support\ReportPhrases::bandwidth($traffic['bandwidth_mb'] ?? null)) +
    {{ number_format($traffic['total_visits'] ?? 0) }}
    Total Visits
    @@ -421,15 +508,39 @@
    {{ number_format($traffic['total_requests'] ?? 0) }}
    Total Requests
    -
    -
    {{ ($traffic['bandwidth_mb'] ?? 0) > 0 ? number_format($traffic['bandwidth_mb'], 1).' MB' : 'Not measured' }}
    -
    Bandwidth Served
    -
    + @if ($bandwidthLabel !== null) +
    +
    {{ $bandwidthLabel }}
    +
    Data Transferred
    +
    + @endif
    + @elseif (isset($hiddenNotes['traffic'])) + @include('client-reports::reports._hidden-note', ['note' => $hiddenNotes['traffic']]) @endif - @if (isset($data['work_log'])) + {{-- Section: Contact forms. Same rule and wording as the email. --}} + @if ($sections['forms']) +
    +
    +

    + Contact Forms +

    +
    + @if ($liveTests > 0) +

    We sent {{ $liveTests }} test {{ \Illuminate\Support\Str::plural('submission', $liveTests) }} through your forms — your site reported sending the notification email for {{ $forms['pass_rate'] ?? 0 }}% of them. We don't check the inbox, so this doesn't confirm the emails arrived.

    + @endif + @if ($labTests > 0) +

    Your forms accepted {{ (int) ($forms['lab_accepted'] ?? 0) }} of {{ $labTests }} automated check {{ \Illuminate\Support\Str::plural('submission', $labTests) }}. These checks confirm the form works without sending an email, so they don't test the email.

    + @endif +
    + @elseif (isset($hiddenNotes['forms'])) + @include('client-reports::reports._hidden-note', ['note' => $hiddenNotes['forms']]) + @endif + + {{-- Section: Work log. Hidden when no hours were logged, like the email. --}} + @if ($sections['work_log'])

    @@ -437,34 +548,33 @@

    {{ number_format($workLog['total_hours'] ?? 0, 2) }} hours
    - @if (empty($workLog['entries'])) -

    No manual work was logged during this period.

    - @else - - +
    + + + + + + + + + @foreach ($workLog['entries'] as $entry) - - - + + + - - - @foreach ($workLog['entries'] as $entry) - - - - - - @endforeach - -
    DateHoursWork
    DateHoursWork{{ $entry['worked_on'] }}{{ number_format($entry['hours'], 2) }} + {{ $entry['description'] }} + {{-- Who logged it is for us: never printed or on the public view. --}} + @if ($isOperatorPreview && ! empty($entry['user'])) + {{ $entry['user'] }} + @endif +
    {{ $entry['worked_on'] }}{{ number_format($entry['hours'], 2) }} - {{ $entry['description'] }} - @if (!empty($entry['user'])) - {{ $entry['user'] }} - @endif -
    - @endif + @endforeach + +
    + @elseif (isset($hiddenNotes['work_log'])) + @include('client-reports::reports._hidden-note', ['note' => $hiddenNotes['work_log']]) @endif {{-- Custom Footer Note if present --}} diff --git a/modules/ClientReports/resources/views/schedules/_row.blade.php b/modules/ClientReports/resources/views/schedules/_row.blade.php new file mode 100644 index 00000000..dc4b88f5 --- /dev/null +++ b/modules/ClientReports/resources/views/schedules/_row.blade.php @@ -0,0 +1,95 @@ + @php + $recips = array_values(array_filter((array) $s->recipients)); + $eligible = $runnerClass::siteIsEligible($s->site); + $latest = $latestReports->get($s->id); + @endphp + + + {{ $s->site->domain }} +
    + @if (! $eligible) + + No care plan — won't send + + @elseif (\App\Models\Site::areCarePlansEnabled()) + Care plan + @endif + @if ($s->site->client) + {{ $s->site->client->company_name ?: $s->site->client->name }} + @endif +
    + + + @if ($s->delivery_mode === $modeDraft) + Drafts for review + @else + Auto-Send + @endif +
    + {{ ucfirst($s->frequency) }} · {{ $s->template?->name ?? 'Default (all sections)' }} +
    + + + @if ($recips !== []) +
    {{ implode(', ', $recips) }}
    + @else + No recipients + @endif + + + @if ($s->next_run_at) +
    {{ $s->next_run_at->format('M j, Y') }}
    +
    {{ $s->last_sent_at ? 'Last sent '.$s->last_sent_at->format('M j') : 'Never sent yet' }}
    + @else + — + @endif + + + @if ($latest) + @php($latestState = $stateClass::of($latest)) + +
    {{ $latest->period_start->format('M Y') }}
    + @include('client-reports::partials.state-pill', ['state' => $latestState]) +
    + @else + None yet + @endif + + +
    + @csrf + @method('PATCH') + +
    + + +
    +
    + @csrf + +
    + + Edit + +
    + @csrf + @method('DELETE') + +
    +
    + + diff --git a/modules/ClientReports/resources/views/schedules/index.blade.php b/modules/ClientReports/resources/views/schedules/index.blade.php index 453314ad..1a37494a 100644 --- a/modules/ClientReports/resources/views/schedules/index.blade.php +++ b/modules/ClientReports/resources/views/schedules/index.blade.php @@ -3,216 +3,155 @@ @section('title', 'Scheduled Reports · Clockwork') @section('content') -
    -
    -

    - - Client Reports -

    -

    - Automated, executive-ready white-labeled maintenance and performance reports for your agency clients. -

    -
    - -
    + + @include('client-reports::_nav') +@include('client-reports::partials.flash') -@if (session('status')) -
    - - {{ session('status') }} -
    -@endif - -@if (session('status_error')) -
    - - {{ session('status_error') }} -
    -@endif +@php + $stateClass = \Modules\ClientReports\Support\ReportState::class; + $runnerClass = \Modules\ClientReports\Services\ScheduledReportRunner::class; + $modeDraft = \Modules\ClientReports\Models\ClientReportSchedule::MODE_DRAFT; + $filterLabels = \Modules\ClientReports\Http\Controllers\SchedulesController::FILTERS; +@endphp -
    - {{-- Active Schedules Table --}} -
    -
    +{{-- The coverage gap: care-plan sites that get no report at all. --}} +@if ($missingSites->isNotEmpty()) +
    +

    - - Automated Reporting Schedules + + Care-plan sites without a report schedule ({{ $missingSites->count() }})

    -

    - Cron automated dispatches aggregating monthly and weekly site maintenance telemetry. +

    + These sites are on a care plan but get no monthly report. "Create schedule" sets one up with the default template, monthly, + to the client's contacts (or as drafts for review when there are none). Nothing is sent: each first report waits for you to check and send it.

    - {{ $schedules->count() }} schedule(s) configured +
    + @csrf + +
    +
    + + @if ($missingSites->count() > 9) + + @endif +
    +
    +@endif -
    - - - - - - - - - - - - - - - @forelse ($schedules as $s) - - - - - - - - - ' + .'' + .''; + @endphp - - @csrf - @method('DELETE') - - - - - + @if ($careSchedules->isNotEmpty() || $noCareSchedules->isEmpty()) +

    + Care plan — reported monthly + {{ $careSchedules->count() }} +

    +
    +
    SiteTemplateFrequencyDelivery ModeRecipientsNext DispatchStateActions
    - - {{ $s->site->domain }} - - @if ($s->site->care_plan_enabled) - - Care Plan - - @elseif (! \Modules\ClientReports\Services\ScheduledReportRunner::siteIsEligible($s->site)) - - No care plan — won't send - - @endif - - @if ($s->template) - - - {{ $s->template->name }} - -
    - {{ count((array) $s->template->sections) }} sections -
    - @else - Default (All Sections) - @endif -
    - {{ $s->frequency }} - - @if ($s->delivery_mode === \Modules\ClientReports\Models\ClientReportSchedule::MODE_DRAFT) - - Draft Review - - @else - - Auto-Send - - @endif - - @php $recips = (array) $s->recipients; @endphp - @if (!empty($recips)) -
    - {{ implode(', ', $recips) }} -
    - @else - None - @endif -
    - @if ($s->next_run_at) -
    {{ $s->next_run_at->format('M j, Y') }}
    -
    {{ $s->next_run_at->diffForHumans() }}
    - @else - — - @endif -
    -
    - @csrf - @method('PATCH') - -
    -
    -
    - {{-- Run Now Action --}} -
    - @csrf - -
    +
    +
    +
    +

    + + Report schedules +

    +
    + +
    + + + +
    + +
    +
    +
    + @foreach ($filterLabels as $key => $label) + + {{ $label }} {{ $counts[$key] }} + + @endforeach +
    +
    - - - + @php + // Care-plan schedules first; the ones that won't send (no care plan) folded below. + [$careSchedules, $noCareSchedules] = $schedules->partition(fn ($s) => $runnerClass::siteIsEligible($s->site)); + $tableHead = '
    SiteDeliveryRecipientsNext runLatest reportStateActions
    + {!! $tableHead !!} + + @forelse ($careSchedules as $s) + @include('client-reports::schedules._row', ['s' => $s]) @empty - @endforelse
    + - No automated schedules created yet. Configure your first client site below! + {{ $show === 'all' && $q === '' ? 'No report schedules yet.' : 'No schedules match.' }}
    -
    + @endif - {{-- Unscheduled Care Plan Sites --}} - @if ($unscheduledSites->isNotEmpty()) -
    -
    -

    - - Unscheduled Managed Sites ({{ $unscheduledSites->count() }}) -

    - Sites ready for automated reporting + @if ($noCareSchedules->isNotEmpty()) +
    isEmpty()) open @endif> + + No care plan — won't send + {{ $noCareSchedules->count() }} + Each month without a care plan is skipped. + +
    + + {!! $tableHead !!} + + @foreach ($noCareSchedules as $s) + @include('client-reports::schedules._row', ['s' => $s]) + @endforeach + +
    -
    - @foreach ($unscheduledSites as $us) -
    -
    -
    - {{ $us->domain }} -
    - @if ($us->care_plan_enabled) - Care Plan Active - @else - Standard Monitored - @endif -
    - - Schedule - -
    - @endforeach -
    -
    + @endif
    @endsection diff --git a/modules/ClientReports/routes/web.php b/modules/ClientReports/routes/web.php index 5fb72d58..5911713c 100644 --- a/modules/ClientReports/routes/web.php +++ b/modules/ClientReports/routes/web.php @@ -2,6 +2,8 @@ use Illuminate\Support\Facades\Route; use Modules\ClientReports\Http\Controllers\ClientReportsController; +use Modules\ClientReports\Http\Controllers\ClientsOverviewController; +use Modules\ClientReports\Http\Controllers\ReportReviewController; use Modules\ClientReports\Http\Controllers\SchedulesController; use Modules\ClientReports\Http\Controllers\TemplatesController; @@ -12,7 +14,15 @@ // Authenticated operator routes Route::middleware(['web', 'auth', 'active'])->group(function () { - Route::get('/client-reports', [ClientReportsController::class, 'index'])->name('client-reports.index'); + // The module's landing page: clients with their care plans, schedules and report history. + Route::get('/client-reports', [ClientsOverviewController::class, 'index'])->name('client-reports.index'); + Route::get('/client-reports/clients/{client}', [ClientsOverviewController::class, 'show']) + ->where('client', 'unassigned|[0-9]+') + ->name('client-reports.clients.show'); + + 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'); Route::post('/client-reports/generate', [ClientReportsController::class, 'generate'])->name('client-reports.generate'); // Templates CRUD Route::resource('/client-reports/templates', TemplatesController::class) @@ -20,6 +30,11 @@ ->names('client-reports.templates'); // Schedules CRUD & Actions + // One-click default schedules for care-plan sites that have none. Never send. + Route::post('/client-reports/schedules/quick/{site}', [SchedulesController::class, 'quickStore']) + ->name('client-reports.schedules.quick'); + Route::post('/client-reports/schedules/quick-all', [SchedulesController::class, 'quickStoreAll']) + ->name('client-reports.schedules.quick-all'); Route::resource('/client-reports/schedules', SchedulesController::class) ->except(['show']) ->names('client-reports.schedules'); @@ -28,7 +43,13 @@ Route::post('/client-reports/schedules/{schedule}/send-now', [SchedulesController::class, 'sendNow']) ->name('client-reports.schedules.send-now'); - Route::get('/client-reports/{report}', [ClientReportsController::class, 'show'])->name('client-reports.show'); - Route::get('/client-reports/{report}/email', [ClientReportsController::class, 'email'])->name('client-reports.email'); - Route::post('/client-reports/{report}/send', [ClientReportsController::class, 'send'])->name('client-reports.send'); + Route::get('/client-reports/{report}', [ClientReportsController::class, 'show'])->whereNumber('report')->name('client-reports.show'); + Route::get('/client-reports/{report}/email', [ClientReportsController::class, 'email'])->whereNumber('report')->name('client-reports.email'); + Route::post('/client-reports/{report}/send', [ClientReportsController::class, 'send'])->whereNumber('report')->name('client-reports.send'); + // Emails the client's exact email to the signed-in operator only. Changes nothing. + Route::post('/client-reports/{report}/send-test', [ClientReportsController::class, 'sendTest'])->whereNumber('report')->name('client-reports.send-test'); + // Refreshes an unsent scheduled report from current data. Never sends. + Route::post('/client-reports/{report}/recompile', [ClientReportsController::class, 'recompile'])->whereNumber('report')->name('client-reports.recompile'); + // Records that a held report's review-tier malware findings were checked. Never sends. + Route::post('/client-reports/{report}/malware-reviewed', [ReportReviewController::class, 'markMalwareReviewed'])->whereNumber('report')->name('client-reports.malware-reviewed'); }); diff --git a/modules/ClientReports/src/ClientReportsServiceProvider.php b/modules/ClientReports/src/ClientReportsServiceProvider.php index 02fb971b..c5b964e2 100644 --- a/modules/ClientReports/src/ClientReportsServiceProvider.php +++ b/modules/ClientReports/src/ClientReportsServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Console\Scheduling\Schedule; use Modules\ClientReports\Console\Commands\ImportReportRecipients; +use Modules\ClientReports\Console\Commands\RecompileHeldClientReports; use Modules\ClientReports\Console\Commands\SendScheduledClientReports; use Modules\Core\ModuleManifest; use Modules\Core\ModuleServiceProvider; @@ -23,6 +24,7 @@ public function boot(): void $this->commands([ SendScheduledClientReports::class, ImportReportRecipients::class, + RecompileHeldClientReports::class, ]); } diff --git a/modules/ClientReports/src/Console/Commands/ImportReportRecipients.php b/modules/ClientReports/src/Console/Commands/ImportReportRecipients.php index 198e9130..60127f99 100644 --- a/modules/ClientReports/src/Console/Commands/ImportReportRecipients.php +++ b/modules/ClientReports/src/Console/Commands/ImportReportRecipients.php @@ -22,7 +22,8 @@ class ImportReportRecipients extends Command {--site-column= : Explicit header name of the column containing site domains} {--apply : Write the changes. Without it the command only shows what it would do} {--replace : Replace a schedule\'s existing recipients instead of adding to them} - {--no-push : Don\'t push the updated recipients to the sites\' WordPress plugin}'; + {--no-push : Don\'t push the updated recipients to the sites\' WordPress plugin} + {--include-no-care-plan : Create enabled schedules for sites without an active care plan too (by default they\'re created disabled)}'; protected $description = 'Import client report recipients from a CSV (site domain + email columns). Dry run unless --apply.'; @@ -34,6 +35,7 @@ public function handle(ReportRecipientsSyncService $sync): int $path = (string) $this->argument('path'); $apply = (bool) $this->option('apply'); $replace = (bool) $this->option('replace'); + $includeNoCarePlan = (bool) $this->option('include-no-care-plan'); if (! is_file($path) || ! is_readable($path)) { $this->error("Can't read {$path}."); @@ -131,7 +133,7 @@ public function handle(ReportRecipientsSyncService $sync): int $schedules = ClientReportSchedule::query()->get()->keyBy('site_id'); $defaultTemplate = ClientReportTemplate::query()->orderByDesc('is_default')->orderBy('name')->first(); - $stats = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'no_site' => 0, 'no_emails' => 0]; + $stats = ['created' => 0, 'created_disabled' => 0, 'updated' => 0, 'unchanged' => 0, 'no_site' => 0, 'no_emails' => 0]; $tableRows = []; /** @var list $toPush */ $toPush = []; @@ -159,8 +161,19 @@ public function handle(ReportRecipientsSyncService $sync): int $schedule = $schedules->get($site->id); if ($schedule === null) { + // A CSV row is no proof the client pays for reports, so a site + // without a care plan gets its schedule switched off until + // someone turns it on, unless asked otherwise. + $enabled = $site->isCarePlanActive() || $includeNoCarePlan; $stats['created']++; - $tableRows[] = [$domain, 'Create schedule'.$carePlanNote, implode(', ', $emails)]; + if (! $enabled) { + $stats['created_disabled']++; + } + $tableRows[] = [ + $domain, + 'Create schedule'.($enabled ? $carePlanNote : ' (no care plan: created disabled)'), + implode(', ', $emails), + ]; if ($apply) { $schedule = ClientReportSchedule::create([ @@ -169,7 +182,7 @@ public function handle(ReportRecipientsSyncService $sync): int 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => $emails, - 'is_enabled' => true, + 'is_enabled' => $enabled, 'next_run_at' => (new ClientReportSchedule(['frequency' => ClientReportSchedule::FREQUENCY_MONTHLY]))->computeNextRun(), ]); $toPush[] = [$site, $schedule]; @@ -203,9 +216,10 @@ public function handle(ReportRecipientsSyncService $sync): int $this->table(['Site', 'Action', 'Recipients'], $tableRows); $this->line(sprintf( - '%s: %d created, %d updated, %d unchanged, %d not found in Control, %d without a valid email%s.', + '%s: %d created%s, %d updated, %d unchanged, %d not found in Control, %d without a valid email%s.', $apply ? 'Done' : 'Dry run', $stats['created'], + $stats['created_disabled'] > 0 ? " ({$stats['created_disabled']} disabled: no care plan)" : '', $stats['updated'], $stats['unchanged'], $stats['no_site'], diff --git a/modules/ClientReports/src/Console/Commands/RecompileHeldClientReports.php b/modules/ClientReports/src/Console/Commands/RecompileHeldClientReports.php new file mode 100644 index 00000000..b80590d7 --- /dev/null +++ b/modules/ClientReports/src/Console/Commands/RecompileHeldClientReports.php @@ -0,0 +1,88 @@ +whereNotNull('schedule_id') + ->where('status', ClientReport::STATUS_GENERATED) + ->with('site') + ->orderBy('id'); + + if ($target = $this->option('site')) { + $query->whereHas('site', fn ($q) => is_numeric($target) ? $q->where('id', (int) $target) : $q->where('domain', $target)); + } + + $reports = $query->get() + ->filter(fn (ClientReport $r) => $r->canRecompile() && (! $this->option('outdated') || $r->isStale())) + ->values(); + + if ($reports->isEmpty()) { + $this->info('Nothing to recompile.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $recompiled = 0; + $held = 0; + $failed = 0; + + foreach ($reports as $report) { + $label = sprintf('#%d %s (%s – %s)', $report->id, $report->site?->domain ?? 'unknown site', $report->period_start->format('M j'), $report->period_end->format('M j, Y')); + + if ($dryRun) { + $this->line("{$label}: would recompile".($report->isStale() ? ' (outdated)' : '')); + + continue; + } + + try { + $run = $runner->recompile($report); + $recompiled++; + if ($run->held()) { + $held++; + } + $this->line("{$label}: recompiled as #{$run->report->id}".($run->held() ? ', held: '.implode(' ', $run->heldReasons) : ', nothing to review')); + } catch (Throwable $e) { + $failed++; + Log::error("Recompiling client report #{$report->id} failed: {$e->getMessage()}", ['exception' => $e]); + $this->error("{$label}: failed: {$e->getMessage()}"); + } + } + + if ($dryRun) { + $this->info($reports->count().' report(s) would be recompiled. Nothing was changed or sent.'); + + return self::SUCCESS; + } + + $this->info("Recompiled {$recompiled} report(s), {$held} held for review, {$failed} failed. Nothing was sent."); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/modules/ClientReports/src/Console/Commands/SendScheduledClientReports.php b/modules/ClientReports/src/Console/Commands/SendScheduledClientReports.php index 3dcc7349..08932183 100644 --- a/modules/ClientReports/src/Console/Commands/SendScheduledClientReports.php +++ b/modules/ClientReports/src/Console/Commands/SendScheduledClientReports.php @@ -2,8 +2,10 @@ namespace Modules\ClientReports\Console\Commands; +use App\Services\ActionLog\ActionLogger; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use Modules\ClientReports\Models\ClientReportSchedule; use Modules\ClientReports\Services\ScheduledReportRunner; use Throwable; @@ -16,7 +18,12 @@ class SendScheduledClientReports extends Command protected $description = 'Generate and email scheduled client reports for care plan websites.'; - public function handle(ScheduledReportRunner $runner): int + /** The action log entry listing the schedules a run skipped. */ + public const ACTION_SKIPPED = 'client_report_skipped'; + + public const SKIPPED_NO_CARE_PLAN = 'no_care_plan'; + + public function handle(ScheduledReportRunner $runner, ActionLogger $actions): int { $isForce = (bool) $this->option('force'); @@ -44,6 +51,8 @@ public function handle(ScheduledReportRunner $runner): int $processed = 0; $crashed = 0; $undelivered = 0; + /** @var list $noCarePlan */ + $noCarePlan = []; foreach ($schedules as $schedule) { $site = $schedule->site; @@ -64,6 +73,7 @@ public function handle(ScheduledReportRunner $runner): int // a report for a month the site had none. if (! ScheduledReportRunner::siteIsEligible($site)) { $schedule->advanceNextRun(); + $noCarePlan[] = (string) $site->domain; $this->line(" - {$site->domain}: skipped this month (care plan is off; reports are for care-plan sites)"); continue; @@ -110,6 +120,23 @@ public function handle(ScheduledReportRunner $runner): int $this->info("Scheduled client reports dispatch completed ({$processed} processed)."); + // A scheduled run's output isn't kept, and the skipped month is gone + // once the schedule moves on, so say which sites missed it where an + // operator will see it. + if ($noCarePlan !== []) { + $actions->record( + actionType: self::ACTION_SKIPPED, + summary: Str::limit(sprintf( + 'Skipped %d scheduled client %s (no care plan): %s', + count($noCarePlan), + Str::plural('report', count($noCarePlan)), + implode(', ', $noCarePlan), + ), 500), + details: ['domains' => $noCarePlan, 'reason' => self::SKIPPED_NO_CARE_PLAN], + actor: 'scheduled', + ); + } + // A site whose report crashed, or whose email didn't reach everyone // (a mail outage is the likely one), stays due and is retried // tomorrow; exit as failed so Scheduled Jobs shows it instead of a diff --git a/modules/ClientReports/src/Http/Controllers/ClientReportsController.php b/modules/ClientReports/src/Http/Controllers/ClientReportsController.php index 57015720..8b48e5d3 100644 --- a/modules/ClientReports/src/Http/Controllers/ClientReportsController.php +++ b/modules/ClientReports/src/Http/Controllers/ClientReportsController.php @@ -6,25 +6,56 @@ use App\Models\Site; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Mail\Mailables\Address; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; use Illuminate\View\View; +use InvalidArgumentException; use Modules\ClientReports\Mail\ClientReportMail; +use Modules\ClientReports\Mail\ClientReportTestMail; use Modules\ClientReports\Models\ClientReport; use Modules\ClientReports\Models\ClientReportTemplate; use Modules\ClientReports\Services\ClientReportCompiler; use Modules\ClientReports\Services\ClientReportSender; +use Modules\ClientReports\Services\ReportBrowser; +use Modules\ClientReports\Services\ScheduledReportRunner; +use Modules\ClientReports\Support\ReportListFilters; +use Modules\ClientReports\Support\ReportState; +use Throwable; class ClientReportsController extends Controller { + /** Most reports one "Recompile" press works through, so the request finishes. */ + public const RECOMPILE_BATCH = 100; + /** - * Display reports dashboard. + * The reports list: one period at a time (the latest by default), + * filtered by state, hold reason, client or search, with a summary of + * the period that doubles as filter links. Filters live in the query + * string so they survive paging and a trip into a report and back. */ - public function index(): View + public function index(Request $request, ReportBrowser $browser): View { - $reports = ClientReport::query() - ->with(['site', 'template']) - ->orderByDesc('created_at') - ->paginate(20); + $periods = $browser->periods(); + $filters = ReportListFilters::fromRequest($request, array_key_first($periods) ?? ReportListFilters::ALL); + $reports = $browser->list($filters); + + $page = max(1, (int) $request->query('page', 1)); + $paginated = new LengthAwarePaginator( + $reports->forPage($page, ReportBrowser::PER_PAGE)->values(), + $reports->count(), + ReportBrowser::PER_PAGE, + $page, + ['path' => route('client-reports.reports'), 'query' => $filters->toQuery()], + ); + + // The summary strip counts the whole period, whatever else is filtered. + $periodReports = $browser->list(new ReportListFilters(period: $filters->period)); + $outdatedCount = $periodReports + ->filter(fn (ClientReport $r) => $r->canRecompile() && ReportState::of($r) === ReportState::OUTDATED) + ->count(); $sites = Site::query() ->where('is_inactive', false) @@ -36,7 +67,15 @@ public function index(): View ->orderBy('name') ->get(); - return view('client-reports::index', compact('reports', 'sites', 'templates')); + return view('client-reports::index', [ + 'reports' => $paginated, + 'filters' => $filters, + 'periods' => $periods, + 'summary' => $browser->summarize($periodReports), + 'outdatedCount' => $outdatedCount, + 'sites' => $sites, + 'templates' => $templates, + ]); } /** @@ -91,29 +130,38 @@ public function generate(Request $request, ClientReportCompiler $compiler): Redi /** * Preview report for operator. */ - public function show(ClientReport $report): View + public function show(ClientReport $report, Request $request, ReportBrowser $browser): View { - $report->load('site', 'schedule'); + $report->load('site.client', 'schedule'); - return view('client-reports::reports.show', ['report' => $report, 'isOperatorPreview' => true]); + return view('client-reports::reports.show', [ + 'report' => $report, + 'isOperatorPreview' => true, + 'nav' => $this->navigation($report, $request, $browser), + ]); } /** * The email exactly as the client gets it. The preview page lays some * sections out differently, so this is what to check before sending. */ - public function email(ClientReport $report): View + public function email(ClientReport $report, Request $request, ReportBrowser $browser): View { $report->load('site.client', 'schedule'); $mail = new ClientReportMail($report); $content = $mail->content(); + $envelope = $mail->envelope(); + // With no From on the envelope, Laravel sends from the global address. + $from = $envelope->from ?? new Address((string) config('mail.from.address'), (string) config('mail.from.name')); return view('client-reports::reports.email', [ 'report' => $report, - 'subject' => $mail->envelope()->subject, + 'from' => $from->name !== null && $from->name !== '' ? "{$from->name} <{$from->address}>" : $from->address, + 'subject' => $envelope->subject, 'html' => $mail->render(), // Plain-text clients get this part instead. 'text' => view($content->text, $content->with)->render(), + 'nav' => $this->navigation($report, $request, $browser), ]); } @@ -130,6 +178,47 @@ public function publicShow(string $token): View return view('client-reports::reports.show', ['report' => $report, 'isOperatorPreview' => false]); } + /** + * Recompile an unsent scheduled report for its own period from current + * data, replacing (superseding) the old copy. It never sends anything: + * the new copy is checked for review like a scheduled run's, and goes + * out only when someone sends it from the history table. + */ + public function recompile(ClientReport $report, ScheduledReportRunner $runner): RedirectResponse + { + if (! $report->canRecompile()) { + return back()->with('status_error', match (true) { + $report->schedule_id === null => 'Only scheduled reports can be recompiled. Generate a new report for this period instead.', + $report->status === ClientReport::STATUS_SUPERSEDED => 'A newer report for this period replaced this one. Recompile that one instead.', + default => 'This report has already been sent to someone, so it can\'t be recompiled.', + }); + } + + try { + $run = $runner->recompile($report); + } catch (InvalidArgumentException $e) { + return back()->with('status_error', $e->getMessage()); + } catch (Throwable $e) { + Log::error("Recompiling client report #{$report->id} failed: {$e->getMessage()}", ['exception' => $e]); + + return back()->with('status_error', "Recompiling failed, so the report was left as it was: {$e->getMessage()}"); + } + + $fresh = $run->report; + $message = 'Recompiled from current data for '.$fresh->period_start->format('M j').' – '.$fresh->period_end->format('M j, Y').'. Nothing was sent.'; + $message .= $run->held() + ? ' It is held for review: '.implode(' ', $run->heldReasons) + : ' Nothing needs review; send it when ready.'; + + // Keep the list context the operator came from (filters, client page). + $context = array_filter( + (array) request()->only(['period', 'status', 'category', 'q', 'client', 'from', 'superseded']), + fn ($value) => is_string($value) && $value !== '', + ); + + return redirect()->route('client-reports.show', [$fresh, ...$context])->with('status', $message); + } + /** * Email report to client. */ @@ -139,6 +228,16 @@ public function send(ClientReport $report, Request $request, ClientReportSender return back()->with('status_error', 'A newer report for this period replaced this one. Send the newer one instead.'); } + // Compiled under older report rules and not yet with anyone: its + // figures were worked out the old way. A partly sent or sent report + // can still finish delivering (or be resent) the copy its other + // recipients already have. + if (empty($report->sent_to) && $report->isStale()) { + return back()->with('status_error', $report->canRecompile() + ? 'This report was compiled before the latest report fixes. Press Recompile, check the new copy, then send that one.' + : 'This report was compiled before the latest report fixes. Generate a new report for this period instead.'); + } + $recipients = $request->filled('email') ? ClientReport::parseRecipients((string) $request->input('email')) : $report->recipientsToSend(); @@ -178,4 +277,140 @@ public function send(ClientReport $report, Request $request, ClientReportSender return back()->with('status', 'Report emailed to '.implode(', ', $delivered).'.'); } + + /** + * Email the client's exact email to the signed-in operator, and only to + * them, with "[TEST]" in the subject. Nothing about the report or its + * schedule changes: it isn't marked sent, nobody is added to sent_to and + * the schedule's last send stays as it was. + */ + public function sendTest(ClientReport $report, Request $request): RedirectResponse + { + $to = (string) ($request->user()?->email ?? ''); + if (! filter_var($to, FILTER_VALIDATE_EMAIL)) { + return back()->with('status_error', 'Your account has no valid email address to send a test copy to.'); + } + + try { + Mail::to($to)->send(new ClientReportTestMail($report)); + } catch (Throwable $e) { + Log::error("Test copy of client report #{$report->id} failed to send: {$e->getMessage()}", ['exception' => $e]); + + return back()->with('status_error', "The test copy to {$to} failed: {$e->getMessage()}"); + } + + return back()->with('status', "Test copy sent to {$to} only, with [TEST] in the subject. The client got nothing and the report is unchanged."); + } + + /** + * Recompile a batch of unsent scheduled reports (every outdated one of a + * period, or the ones ticked on the list) through the same + * ScheduledReportRunner::recompile() as the single Recompile button. It + * never sends anything and never moves a schedule on. + */ + public function recompileMany(Request $request, ReportBrowser $browser, ScheduledReportRunner $runner): RedirectResponse + { + $filters = ReportListFilters::fromRequest($request, ReportListFilters::ALL); + $back = redirect()->route('client-reports.reports', $filters->toQuery()); + + if ($request->input('scope') === 'selected') { + $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 recompile first.'); + } + } else { + $ids = $browser->list(new ReportListFilters(period: $filters->period, status: ReportState::OUTDATED)) + ->filter(fn (ClientReport $r) => $r->canRecompile()) + ->pluck('id') + ->values(); + if ($ids->isEmpty()) { + return $back->with('status', 'No outdated reports to recompile in this period.'); + } + } + + @set_time_limit(600); + $remaining = max(0, $ids->count() - self::RECOMPILE_BATCH); + $recompiled = $held = $skipped = 0; + $failed = []; + + foreach ($ids->take(self::RECOMPILE_BATCH) as $id) { + $report = ClientReport::query()->find($id); + if ($report === null || ! $report->canRecompile()) { + $skipped++; + + continue; + } + + try { + $run = $runner->recompile($report); + $recompiled++; + $held += $run->held() ? 1 : 0; + } catch (Throwable $e) { + $failed[] = $report->site?->domain ?? "#{$report->id}"; + Log::error("Recompiling client report #{$report->id} failed: {$e->getMessage()}", ['exception' => $e]); + } + } + + $message = "Recompiled {$recompiled} report(s) from current data, {$held} of them held for review."; + if ($skipped > 0) { + $message .= " Skipped {$skipped} that can't be recompiled (sent, replaced or not scheduled)."; + } + if ($remaining > 0) { + $message .= " {$remaining} more are left: press Recompile again to continue."; + } + $message .= ' Nothing was sent.'; + + return $failed === [] + ? $back->with('status', $message) + : $back->with('status_error', $message.' Failed: '.implode(', ', $failed).' (see the log).'); + } + + /** + * 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 + * next report of that same filtered list. Opened on its own, a report + * steps through its own period. + * + * @return array{query: array, previous: ?int, next: ?int, position: ?int, count: int, back_url: string, back_label: string} + */ + protected function navigation(ClientReport $report, Request $request, ReportBrowser $browser): array + { + $hasContext = collect(['period', 'status', 'category', 'q', 'client', 'superseded']) + ->contains(fn (string $key) => $request->query->has($key)); + $filters = $hasContext + ? ReportListFilters::fromRequest($request, ReportListFilters::ALL) + : new ReportListFilters( + period: $report->period_start->format('Y-m'), + showSuperseded: $report->status === ClientReport::STATUS_SUPERSEDED, + ); + + $ids = $browser->list($filters)->pluck('id')->values(); + $position = $ids->search($report->id, true); + + if ($filters->from === 'client' && $filters->client !== null) { + $backUrl = route('client-reports.clients.show', $filters->client); + $backLabel = 'Back to client'; + } else { + $query = $filters->toQuery(); + if ($position !== false && $position >= ReportBrowser::PER_PAGE) { + $query['page'] = (string) (intdiv($position, ReportBrowser::PER_PAGE) + 1); + } + $backUrl = route('client-reports.reports', $query); + $backLabel = 'Back to list'; + } + + return [ + 'query' => $filters->toQuery(), + 'previous' => $position !== false && $position > 0 ? $ids[$position - 1] : null, + 'next' => $position !== false && $position < $ids->count() - 1 ? $ids[$position + 1] : null, + 'position' => $position === false ? null : $position + 1, + 'count' => $ids->count(), + 'back_url' => $backUrl, + 'back_label' => $backLabel, + ]; + } } diff --git a/modules/ClientReports/src/Http/Controllers/ClientsOverviewController.php b/modules/ClientReports/src/Http/Controllers/ClientsOverviewController.php new file mode 100644 index 00000000..0b23c872 --- /dev/null +++ b/modules/ClientReports/src/Http/Controllers/ClientsOverviewController.php @@ -0,0 +1,84 @@ +query()); + ['listed' => $rows, 'folded' => $folded] = $directory->arrange($directory->rows(), $filters); + + $page = max(1, (int) $request->query('page', 1)); + $paginator = new LengthAwarePaginator( + $rows->forPage($page, self::PER_PAGE)->values(), + $rows->count(), + self::PER_PAGE, + $page, + ['path' => route('client-reports.index'), 'query' => ClientDirectory::queryFor($filters)], + ); + + // The tiles count what the care-plan selection shows (search and the + // other filters aside), so they read as the list's own totals. + $selection = $directory->arrange($directory->rows(), ['care' => $filters['care']])['listed']; + $totals = [ + 'clients' => $selection->filter(fn (array $row) => $row['client'] !== null)->count(), + 'care' => $selection->sum(fn (array $row) => $row['counts']['care']), + 'missing' => $selection->sum(fn (array $row) => $row['counts']['missing']), + 'waiting' => $selection->sum(fn (array $row) => $row['counts']['waiting']), + 'unassigned' => $selection->filter(fn (array $row) => $row['client'] === null)->sum(fn (array $row) => $row['counts']['sites']), + ]; + + return view('client-reports::clients.index', [ + 'rows' => $paginator, + 'folded' => $folded, + 'filters' => $filters, + 'query' => ClientDirectory::queryFor($filters), + 'months' => $directory->months(), + 'totals' => $totals, + ]); + } + + public function show(Request $request, string $client, ClientDirectory $directory): View + { + abort_unless($client === ReportListFilters::UNASSIGNED || ctype_digit($client), 404); + + $row = $directory->find($client); + abort_if($row === null, 404); + + // Previous/next follow the list the operator came from (same filters, + // same order, the folded "No care plan" clients after the others); + // the switcher offers every client. + $filters = ClientDirectory::filtersFrom($request->query()); + $all = $directory->rows(); + ['listed' => $listed, 'folded' => $folded] = $directory->arrange($all, $filters); + $list = $listed->concat($folded)->pluck('key')->unique()->values(); + $position = $list->search($row['key'], true); + + return view('client-reports::clients.show', [ + 'row' => $row, + 'filters' => ClientDirectory::queryFor($filters), + 'months' => $directory->months(), + 'previousKey' => $position !== false && $position > 0 ? $list[$position - 1] : null, + 'nextKey' => $position !== false && $position < $list->count() - 1 ? $list[$position + 1] : null, + 'position' => $position === false ? null : $position + 1, + 'listCount' => $list->count(), + 'switcher' => $all->map(fn (array $r) => ['key' => $r['key'], 'name' => $r['name'], 'sites' => $r['counts']['sites']])->values(), + 'showSuperseded' => $request->boolean('superseded'), + ]); + } +} diff --git a/modules/ClientReports/src/Http/Controllers/ReportReviewController.php b/modules/ClientReports/src/Http/Controllers/ReportReviewController.php new file mode 100644 index 00000000..c8b99db6 --- /dev/null +++ b/modules/ClientReports/src/Http/Controllers/ReportReviewController.php @@ -0,0 +1,84 @@ +validate([ + 'note' => ['required', 'string', 'max:500'], + ]); + $user = $request->user(); + + try { + $remaining = DB::transaction(function () use ($report, $validated, $user) { + // Re-read under a lock, so a Recompile or a send can't slip in between. + $current = ClientReport::query()->lockForUpdate()->find($report->id); + + if ($current === null || $current->status !== ClientReport::STATUS_GENERATED || ! empty($current->sent_to)) { + throw new InvalidArgumentException('Only a report that has not been sent to anyone, and has no newer copy, can have its findings marked reviewed.'); + } + + $data = is_array($current->sections_data) ? $current->sections_data : []; + if (empty($data['meta']['review_reasons'])) { + throw new InvalidArgumentException('This report is not held for review, so there is nothing to mark.'); + } + if (! ReportReviewFlags::canMarkMalwareReviewed($data)) { + throw new InvalidArgumentException('Only review-tier malware findings (a scan graded "warning", with no malware signature) can be marked reviewed, and this report has none waiting.'); + } + + $data = ReportReviewFlags::withMalwareReviewed($data, [ + 'reviewed_by' => (string) ($user?->name ?: $user?->email ?: 'Unknown'), + 'reviewed_by_id' => $user?->id, + 'reviewed_at' => now()->toIso8601String(), + 'note' => trim((string) $validated['note']), + ]); + $current->update(['sections_data' => $data]); + + Log::info('client_reports.malware_reviewed', [ + 'report_id' => $current->id, + 'site_id' => $current->site_id, + 'scan_id' => $data['security']['malware_reviewed']['scan_id'], + 'user_id' => $user?->id, + ]); + + return $data['meta']['review_reasons']; + }); + } catch (InvalidArgumentException $e) { + return back()->with('status_error', $e->getMessage()); + } + + $message = 'Marked the malware findings reviewed; the client now reads "Reviewed — no threat found". Nothing was sent.'; + $message .= $remaining === [] + ? ' Nothing else holds this report; send it from the history table when ready.' + : ' Still held for: '.implode(' ', $remaining); + + return redirect()->route('client-reports.show', $report)->with('status', $message); + } +} diff --git a/modules/ClientReports/src/Http/Controllers/SchedulesController.php b/modules/ClientReports/src/Http/Controllers/SchedulesController.php index df87a44d..af30cf7c 100644 --- a/modules/ClientReports/src/Http/Controllers/SchedulesController.php +++ b/modules/ClientReports/src/Http/Controllers/SchedulesController.php @@ -7,32 +7,83 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use Illuminate\View\View; +use Modules\ClientReports\Models\ClientReport; use Modules\ClientReports\Models\ClientReportSchedule; use Modules\ClientReports\Models\ClientReportTemplate; +use Modules\ClientReports\Services\ReportBrowser; use Modules\ClientReports\Services\ReportRecipientsSyncService; use Modules\ClientReports\Services\ScheduledReportRunner; use Throwable; class SchedulesController extends Controller { - public function index(): View + /** Schedule list filters: who won't get a report, and why. */ + public const FILTERS = [ + 'all' => 'All', + 'wont_send' => 'Won\'t send: no care plan', + 'no_recipients' => 'No recipients', + 'disabled' => 'Disabled', + 'draft' => 'Drafts for review', + ]; + + public function index(Request $request, ReportBrowser $browser): View { - $schedules = ClientReportSchedule::query() - ->with(['site', 'template']) + // A schedule outlives its site's archiving; those sites are left out. + $all = ClientReportSchedule::query() + ->with(['site.client:id,name,company_name', 'template']) ->get() - ->sortBy(fn ($s) => $s->site->domain); - - $scheduledSiteIds = $schedules->pluck('site_id')->all(); - - $unscheduledSites = Site::query() + ->filter(fn (ClientReportSchedule $s) => $s->site !== null) + ->sortBy(fn (ClientReportSchedule $s) => Str::lower($s->site->domain)) + ->values(); + + $matches = [ + 'all' => fn (ClientReportSchedule $s) => true, + 'wont_send' => fn (ClientReportSchedule $s) => ! ScheduledReportRunner::siteIsEligible($s->site), + 'no_recipients' => fn (ClientReportSchedule $s) => array_filter((array) $s->recipients) === [], + 'disabled' => fn (ClientReportSchedule $s) => ! $s->is_enabled, + 'draft' => fn (ClientReportSchedule $s) => $s->delivery_mode === ClientReportSchedule::MODE_DRAFT, + ]; + $counts = array_map(fn (callable $match) => $all->filter($match)->count(), $matches); + + $show = is_string($request->query('show')) && array_key_exists($request->query('show'), self::FILTERS) ? $request->query('show') : 'all'; + $q = is_string($request->query('q')) ? trim(mb_substr($request->query('q'), 0, 100)) : ''; + $needle = Str::lower($q); + $schedules = $all->filter($matches[$show]) + ->filter(fn (ClientReportSchedule $s) => $needle === '' || Str::contains(Str::lower(implode(' ', [ + $s->site->domain, $s->site->client?->name, $s->site->client?->company_name, ...array_filter((array) $s->recipients), + ])), $needle)) + ->values(); + + // Each schedule's latest report (newest period, newest copy), so its + // state shows next to it. + $latestReports = $browser->get($browser->query() + ->whereIn('schedule_id', $all->pluck('id')) + ->where('status', '!=', ClientReport::STATUS_SUPERSEDED)) + ->sortByDesc(fn (ClientReport $r) => $r->period_start->format('Y-m-d').'-'.str_pad((string) $r->id, 12, '0', STR_PAD_LEFT)) + ->unique('schedule_id') + ->keyBy('schedule_id'); + + // Care-plan sites that get no report at all: the coverage gap. + $sync = app(ReportRecipientsSyncService::class); + $missingSites = Site::query() ->where('is_inactive', false) - ->whereNotIn('id', $scheduledSiteIds) - ->orderByDesc('care_plan_enabled') + ->carePlanEligible() + ->whereNotIn('id', ClientReportSchedule::query()->select('site_id')) + ->with('client') ->orderBy('domain') - ->get(['id', 'domain', 'care_plan_enabled']); - - return view('client-reports::schedules.index', compact('schedules', 'unscheduledSites')); + ->get() + ->map(fn (Site $site) => ['site' => $site, 'recipients' => $sync->resolvePayloadForSite($site)['report_recipients']]); + + return view('client-reports::schedules.index', [ + 'schedules' => $schedules, + 'counts' => $counts, + 'show' => $show, + 'q' => $q, + 'latestReports' => $latestReports, + 'missingSites' => $missingSites, + ]); } public function create(Request $request): View|RedirectResponse @@ -203,7 +254,7 @@ public function destroy(ClientReportSchedule $schedule): RedirectResponse ->with('status', "Reporting schedule for {$domain} deleted."); } - public function toggle(ClientReportSchedule $schedule): RedirectResponse + public function toggle(Request $request, ClientReportSchedule $schedule): RedirectResponse { $schedule->update([ 'is_enabled' => ! $schedule->is_enabled, @@ -215,8 +266,90 @@ public function toggle(ClientReportSchedule $schedule): RedirectResponse $state = $schedule->is_enabled ? 'enabled' : 'paused'; - return redirect()->route('client-reports.schedules.index') - ->with('status', "Schedule for {$schedule->site->domain} has been {$state}."); + // The client page toggles in place and stays where it was. + $redirect = $request->input('return') === 'back' + ? back() + : redirect()->route('client-reports.schedules.index'); + + return $redirect->with('status', "Schedule for {$schedule->site->domain} has been {$state}."); + } + + /** + * One-click schedule for a care-plan site that has none: the default + * template, monthly, sent to the client's contacts (the same fallback + * WordPress is shown) or, with no contacts on file, as drafts for + * review. Its first report always waits for someone to send it. + */ + public function quickStore(Site $site): RedirectResponse + { + if ($site->is_inactive) { + return back()->with('status_error', "{$site->domain} is inactive, so it gets no reports."); + } + + if (! ScheduledReportRunner::siteIsEligible($site)) { + return back()->with('status_error', "{$site->domain} has no care plan. Client reports are only sent for care-plan sites."); + } + + if (ClientReportSchedule::query()->where('site_id', $site->id)->exists()) { + return back()->with('status', "{$site->domain} already has a report schedule."); + } + + $schedule = $this->createDefaultSchedule($site); + $recipients = (array) $schedule->recipients; + + $message = $schedule->delivery_mode === ClientReportSchedule::MODE_AUTO + ? "Created a monthly report schedule for {$site->domain}, to ".implode(', ', $recipients).'. Its first report ('.$schedule->next_run_at->format('M j').') waits for you to check and send it.' + : "Created a monthly report schedule for {$site->domain} as drafts for review: the client has no contacts on file. Add recipients to send it."; + + return back()->with('status', $message); + } + + /** + * The same one-click schedule for every active care-plan site that has + * none. Nothing is sent: each schedule's first report waits for review. + */ + public function quickStoreAll(): RedirectResponse + { + $sites = Site::query() + ->where('is_inactive', false) + ->carePlanEligible() + ->whereNotIn('id', ClientReportSchedule::query()->select('site_id')) + ->orderBy('domain') + ->get(); + + if ($sites->isEmpty()) { + return back()->with('status', 'Every care-plan site already has a report schedule.'); + } + + $drafts = $sites->map(fn (Site $site) => $this->createDefaultSchedule($site)) + ->filter(fn (ClientReportSchedule $s) => $s->delivery_mode === ClientReportSchedule::MODE_DRAFT) + ->count(); + + return back()->with('status', "Created {$sites->count()} monthly report schedule(s)" + .($drafts > 0 ? ", {$drafts} of them as drafts for review because the client has no contacts on file" : '') + .'. Each first report waits for you to check and send it. Nothing was sent.'); + } + + /** Default template, monthly, client contacts (auto) or none (drafts). */ + protected function createDefaultSchedule(Site $site): ClientReportSchedule + { + $sync = app(ReportRecipientsSyncService::class); + $recipients = $sync->resolvePayloadForSite($site)['report_recipients']; + $template = ClientReportTemplate::query()->orderByDesc('is_default')->orderBy('name')->first(); + + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, + 'template_id' => $template?->id, + 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => $recipients !== [] ? ClientReportSchedule::MODE_AUTO : ClientReportSchedule::MODE_DRAFT, + 'recipients' => $recipients, + 'is_enabled' => true, + 'next_run_at' => (new ClientReportSchedule(['frequency' => ClientReportSchedule::FREQUENCY_MONTHLY]))->computeNextRun(), + ]); + + $sync->pushToSite($site, $schedule); + + return $schedule; } public function sendNow(ClientReportSchedule $schedule, ScheduledReportRunner $runner): RedirectResponse diff --git a/modules/ClientReports/src/Mail/ClientReportMail.php b/modules/ClientReports/src/Mail/ClientReportMail.php index 423fa37a..1d24e4d3 100644 --- a/modules/ClientReports/src/Mail/ClientReportMail.php +++ b/modules/ClientReports/src/Mail/ClientReportMail.php @@ -10,7 +10,10 @@ use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Str; +use Modules\ClientManagement\Models\Client; use Modules\ClientReports\Models\ClientReport; +use Modules\ClientReports\Support\ReportPhrases; +use Throwable; /** * The monthly client report, delivered as a complete self-contained email. @@ -36,11 +39,27 @@ public function envelope(): Envelope : []; return new Envelope( + from: $this->fromAddress(), subject: sprintf('Your %s website report — %s', $this->report->period_start->format('F Y'), $this->domain()), replyTo: $replyTo, ); } + /** + * The configured sending address under the report brand's company name, + * so the inbox shows the agency rather than whatever MAIL_FROM_NAME says + * (by default the internal product name). Null leaves Laravel's global + * From in place when no sending address is configured. + */ + public function fromAddress(): ?Address + { + $address = (string) config('mail.from.address'); + + return filter_var($address, FILTER_VALIDATE_EMAIL) + ? new Address($address, $this->branding()['company_name']) + : null; + } + public function content(): Content { return new Content( @@ -51,13 +70,53 @@ public function content(): Content 'data' => $this->report->sections_data ?? [], 'branding' => $this->branding(), 'logoBytes' => $this->logoBytes(), - 'contactName' => $this->report->site?->client?->name, + // "Hi Jane," or, when we can't be sure who reads it, "Hi there,". + 'greetingName' => $this->greetingName(), 'domain' => $this->domain(), 'updateRows' => $this->updateRows(), ], ); } + /** + * The first name to greet the reader by, or null for "Hi there,". Only + * when the report goes to one address, and that address is a known + * client contact whose stored name is plainly a person's + * (ReportPhrases::firstName): the site's client's name is often an + * organisation, and a report sent to several people, or to someone + * other than the site's contact, would greet the wrong person. + */ + public function greetingName(): ?string + { + $recipients = $this->report->recipients(); + if ($recipients === []) { + $recipients = array_values(array_filter(array_map( + fn ($r) => trim((string) $r), + (array) ($this->report->schedule?->recipients ?? []), + ))); + } + if (count($recipients) !== 1) { + return null; + } + + $address = mb_strtolower(trim($recipients[0])); + + try { + $own = $this->report->site?->client; + $contacts = $own instanceof Client && mb_strtolower(trim((string) $own->email)) === $address + ? collect([$own]) + : Client::query()->whereRaw('LOWER(TRIM(email)) = ?', [$address])->get(); + } catch (Throwable) { + // No client records to read (the module's table isn't there). + return null; + } + + // Two contacts sharing the address under different names: don't guess. + $names = $contacts->map(fn (Client $c) => ReportPhrases::firstName($c->name))->unique()->values(); + + return $names->count() === 1 ? $names->first() : null; + } + /** * Branding captured at compile time, with the Companion defaults as fallback. * @@ -104,6 +163,9 @@ public function updateRows(): array public static function cleanUpdateSummary(string $summary, ?string $target = null): string { + // 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'); $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/Mail/ClientReportTestMail.php b/modules/ClientReports/src/Mail/ClientReportTestMail.php new file mode 100644 index 00000000..70e7e9a5 --- /dev/null +++ b/modules/ClientReports/src/Mail/ClientReportTestMail.php @@ -0,0 +1,23 @@ +subject(self::SUBJECT_PREFIX.$envelope->subject); + } +} diff --git a/modules/ClientReports/src/Models/ClientReport.php b/modules/ClientReports/src/Models/ClientReport.php index 87590a6f..a8347e7b 100644 --- a/modules/ClientReports/src/Models/ClientReport.php +++ b/modules/ClientReports/src/Models/ClientReport.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Carbon; use Illuminate\Support\Str; +use Modules\ClientReports\Services\ClientReportCompiler; /** * @property int $id @@ -104,6 +105,33 @@ public function recipientsToSend(): array return array_values(array_diff($this->recipients(), $this->sent_to ?? [])); } + /** + * Whether this report was compiled before the report rules it would be + * read under now (meta.schema_version below the compiler's). Its stored + * figures were worked out the old way, so it mustn't reach anyone who + * hasn't already got it: recompile it (or generate a new one) first. + */ + public function isStale(): bool + { + $data = is_array($this->sections_data) ? $this->sections_data : []; + + return (int) ($data['meta']['schema_version'] ?? 0) < ClientReportCompiler::SCHEMA_VERSION; + } + + /** + * Whether the Recompile action may refresh this report from current + * data: only a scheduled report that hasn't reached anyone (still + * generated, nobody in sent_to). A sent or partly sent report is a + * record of what the client got, a superseded one has a newer copy, and + * a hand-generated one has no schedule to recompile it from. + */ + public function canRecompile(): bool + { + return $this->schedule_id !== null + && $this->status === self::STATUS_GENERATED + && empty($this->sent_to); + } + protected static function booted(): void { static::creating(function (ClientReport $report) { diff --git a/modules/ClientReports/src/Models/ClientReportTemplate.php b/modules/ClientReports/src/Models/ClientReportTemplate.php index d9d67aa3..cfbc9cf2 100644 --- a/modules/ClientReports/src/Models/ClientReportTemplate.php +++ b/modules/ClientReports/src/Models/ClientReportTemplate.php @@ -41,42 +41,42 @@ protected function casts(): array public const SECTIONS = [ 'updates' => [ 'label' => 'Updates & Upgrades', - 'description' => 'Plugins, themes, core updates and translation upgrades applied.', + 'description' => 'Plugin, theme and WordPress core updates applied during the period, and any still waiting at its end.', 'icon' => 'fa-circle-arrow-up', ], 'uptime' => [ 'label' => 'Uptime & Availability', - 'description' => 'Availability percentage, outage counts, and total downtime.', + 'description' => 'Uptime percentage, outage count, total downtime and the most recent outages, for sites the uptime monitor checks.', 'icon' => 'fa-heart-pulse', ], 'security' => [ 'label' => 'Security & Firewall', - 'description' => 'Checksum malware scans, file integrity, and blocked malicious requests.', + 'description' => 'Latest malware scan, WordPress core file check, blocklist check and known plugin vulnerabilities, plus attackers blocked when the site reports its blocks.', 'icon' => 'fa-shield-halved', ], 'performance' => [ 'label' => 'Performance Benchmarks', - 'description' => 'PageSpeed scores, time to first byte (TTFB), and page load speed.', + 'description' => 'Latest mobile and desktop performance score and page load time (LCP) from tests run during the period.', 'icon' => 'fa-gauge-high', ], 'forms' => [ 'label' => 'Contact Form Testing', - 'description' => 'Automated synthetic form submission success and deliverability rates.', + 'description' => 'Automated test submissions and whether each form accepted them. Scheduled tests suppress the email, so delivery is only reported for live tests.', 'icon' => 'fa-envelope-circle-check', ], 'traffic' => [ 'label' => 'Traffic Analytics', - 'description' => 'Monthly unique visits, top referrers, and page view statistics.', + 'description' => 'Visits, page and file requests served, and data transferred (when the host reports it).', 'icon' => 'fa-chart-line', ], 'backups' => [ 'label' => 'Backups & Snapshots', - 'description' => 'Completed backup runs, cloud storage locations, and restore readiness.', + 'description' => 'Backups recorded from the site\'s own backup source: where they are stored and the last 10 days of the period.', 'icon' => 'fa-box-archive', ], 'work_log' => [ 'label' => 'Work log', - 'description' => 'Manual hours logged by the team during the report period.', + 'description' => 'Hours logged in the site\'s work log during the period; left out when there are none.', 'icon' => 'fa-clock', ], ]; diff --git a/modules/ClientReports/src/Services/ClientDirectory.php b/modules/ClientReports/src/Services/ClientDirectory.php new file mode 100644 index 00000000..63949b07 --- /dev/null +++ b/modules/ClientReports/src/Services/ClientDirectory.php @@ -0,0 +1,369 @@ + cell, last six + * report periods: report, state). + */ +class ClientDirectory +{ + public const MONTHS = 6; + + /** A history-strip month with no report and no reason to expect one. */ + public const CELL_NONE = 'none'; + + /** A month the schedule was set up for but the site had no care plan, so nothing was sent. */ + public const CELL_SKIPPED = 'skipped'; + + public function __construct(protected ReportBrowser $browser) {} + + /** + * The last report periods, oldest first, ending with last month. + * + * @return list + */ + public function months(int $count = self::MONTHS): array + { + $last = now()->subMonthNoOverflow()->startOfMonth(); + + return array_map(fn (int $i) => $last->copy()->subMonthsNoOverflow($i), range($count - 1, 0)); + } + + /** + * Every client (and the Unassigned group, when it has sites), sorted by name. + * + * @param bool $withHistory load every report of every site (the client page) instead of only the last months + * @return Collection> + */ + public function rows(bool $withHistory = false, ?string $only = null): Collection + { + $clients = Client::query() + ->when($only !== null && $only !== ReportListFilters::UNASSIGNED, fn ($q) => $q->whereKey((int) $only)) + ->when($only === ReportListFilters::UNASSIGNED, fn ($q) => $q->whereRaw('1 = 0')) + ->get(['id', 'name', 'company_name', 'email', 'additional_emails']); + + $sites = Site::query() + ->when($only !== null, fn ($q) => $only === ReportListFilters::UNASSIGNED + ? $q->whereNull('client_id') + : $q->where('client_id', (int) $only)) + ->orderBy('domain') + ->get(['id', 'domain', 'client_id', 'care_plan_enabled', 'is_inactive', 'created_at']); + + $schedules = ClientReportSchedule::query() + ->whereIn('site_id', $sites->pluck('id')) + ->get() + ->keyBy('site_id'); + + $months = $this->months(); + $reportQuery = $this->browser->query()->whereIn('site_id', $sites->pluck('id')); + if (! $withHistory) { + $reportQuery->where('period_start', '>=', $months[0]->toDateString()) + ->where('status', '!=', ClientReport::STATUS_SUPERSEDED); + } + $reportsBySite = $this->browser->get($reportQuery) + ->sortByDesc(fn (ClientReport $r) => $r->period_start->format('Y-m-d').'-'.str_pad((string) $r->id, 12, '0', STR_PAD_LEFT)) + ->groupBy('site_id'); + + $siteRows = $sites->map(fn (Site $site) => $this->siteRow($site, $schedules->get($site->id), $reportsBySite->get($site->id, collect()), $months)) + ->groupBy(fn (array $row) => $row['site']->client_id === null ? ReportListFilters::UNASSIGNED : (string) $row['site']->client_id); + + $rows = $clients->map(fn (Client $client) => $this->row( + (string) $client->id, + $client, + $siteRows->get((string) $client->id, collect())->values()->all(), + )); + + $unassigned = $siteRows->get(ReportListFilters::UNASSIGNED); + if ($unassigned !== null && $unassigned->isNotEmpty()) { + $rows->push($this->row(ReportListFilters::UNASSIGNED, null, $unassigned->values()->all())); + } + + // Alphabetical, the Unassigned group last. + return $rows + ->sortBy(fn (array $row) => ($row['client'] === null ? '1' : '0').Str::lower($row['name'])) + ->values(); + } + + /** @return array|null */ + public function find(string $key, bool $withHistory = true): ?array + { + return $this->rows($withHistory, $key)->first(); + } + + /** + * Narrow the rows to the list's filters: q (client, contact, site or + * recipient), care (yes: any site on a care plan, no: none), waiting + * (a report waiting for review or sending) and missing (a care-plan site + * with no schedule). + * + * @param Collection> $rows + * @param array $filters + * @return Collection> + */ + public function filter(Collection $rows, array $filters): Collection + { + $q = Str::lower(trim((string) ($filters['q'] ?? ''))); + $care = $filters['care'] ?? null; + + return $rows->filter(function (array $row) use ($q, $care, $filters) { + if ($q !== '' && ! Str::contains($this->haystack($row), $q)) { + return false; + } + if ($care === 'yes' && $row['counts']['care'] === 0) { + return false; + } + if ($care === 'no' && $row['counts']['care'] > 0) { + return false; + } + if (! empty($filters['waiting']) && $row['counts']['waiting'] === 0) { + return false; + } + + return empty($filters['missing']) || $row['counts']['missing'] > 0; + })->values(); + } + + /** The list's default care-plan selection: care-plan clients, the rest folded away below. */ + public const CARE_DEFAULT = 'yes'; + + /** The list's default order: clients needing attention first. */ + public const SORT_DEFAULT = 'attention'; + + /** + * The list filters a request asks for, defaults filled in: q, care + * (yes | no | all), sort (attention | az), waiting, missing. queryFor() + * turns them back into a query string without the defaults. + * + * @param array $input + * @return array + */ + public static function filtersFrom(array $input): array + { + return array_filter([ + 'q' => is_string($input['q'] ?? null) ? trim(mb_substr($input['q'], 0, 100)) : null, + 'care' => in_array($input['care'] ?? null, ['yes', 'no', 'all'], true) ? $input['care'] : self::CARE_DEFAULT, + 'sort' => in_array($input['sort'] ?? null, ['attention', 'az'], true) ? $input['sort'] : self::SORT_DEFAULT, + 'waiting' => ! empty($input['waiting']) ? '1' : null, + 'missing' => ! empty($input['missing']) ? '1' : null, + ], fn ($value) => $value !== null && $value !== ''); + } + + /** + * The filters as query parameters for links, leaving out the defaults. + * + * @param array $filters + * @return array + */ + public static function queryFor(array $filters): array + { + return array_filter($filters, fn ($value, string $key) => $value !== null && $value !== '' + && ! ($key === 'care' && $value === self::CARE_DEFAULT) + && ! ($key === 'sort' && $value === self::SORT_DEFAULT), ARRAY_FILTER_USE_BOTH); + } + + /** + * Split the rows into care-plan clients (any active site on a care plan) + * and the rest, each row tagged with its 'section' (care | other). The + * Unassigned group is split by site the same way, so care-plan sites + * without a client sit with the care-plan clients. + * + * @param Collection> $rows + * @return array{0: Collection>, 1: Collection>} + */ + public function split(Collection $rows): array + { + $care = collect(); + $other = collect(); + + foreach ($rows as $row) { + if ($row['client'] === null) { + [$careSites, $otherSites] = collect($row['sites']) + ->partition(fn (array $s) => $s['eligible'] && ! $s['site']->is_inactive) + ->map(fn (Collection $sites) => $sites->values()->all()) + ->all(); + if ($careSites !== []) { + $care->push(['section' => 'care'] + $this->row($row['key'], null, $careSites)); + } + if ($otherSites !== []) { + $other->push(['section' => 'other'] + $this->row($row['key'], null, $otherSites)); + } + + continue; + } + + $row['counts']['care'] > 0 + ? $care->push(['section' => 'care'] + $row) + : $other->push(['section' => 'other'] + $row); + } + + return [$care, $other]; + } + + /** + * Order rows: 'attention' puts clients with a report waiting or a + * care-plan site missing a schedule first, then A–Z; 'az' is A–Z. The + * Unassigned group comes after the clients of its kind either way. + * + * @param Collection> $rows + * @return Collection> + */ + public function sort(Collection $rows, string $sort = self::SORT_DEFAULT): Collection + { + return $rows->sortBy(fn (array $row) => sprintf( + '%d%d%s', + $sort === 'attention' && ($row['counts']['waiting'] > 0 || $row['counts']['missing'] > 0) ? 0 : 1, + $row['client'] === null ? 1 : 0, + Str::lower($row['name']), + ))->values(); + } + + /** + * The rows the list shows for its filters, in order, and the rows it + * folds away below them: with "Care plan" selected, the care-plan + * clients are listed and the others are folded into a collapsed "No care + * plan" section; "No care plan" lists only those; "All" lists both, + * care-plan clients first. + * + * @param Collection> $rows rows() + * @param array $filters filtersFrom() + * @return array{listed: Collection>, folded: Collection>} + */ + public function arrange(Collection $rows, array $filters): array + { + [$care, $other] = $this->split($rows); + $narrow = ['care' => 'all'] + $filters; + $sort = $filters['sort'] ?? self::SORT_DEFAULT; + $care = $this->sort($this->filter($care, $narrow), $sort); + $other = $this->sort($this->filter($other, $narrow), $sort); + + return match ($filters['care'] ?? self::CARE_DEFAULT) { + 'no' => ['listed' => $other, 'folded' => collect()], + 'all' => ['listed' => $care->concat($other)->values(), 'folded' => collect()], + default => ['listed' => $care, 'folded' => $other], + }; + } + + /** + * @param list> $sites + * @return array + */ + protected function row(string $key, ?Client $client, array $sites): array + { + $company = trim((string) $client?->company_name); + $person = trim((string) $client?->name); + $name = $client === null ? 'Unassigned sites' : ($company !== '' ? $company : $person); + + $active = array_filter($sites, fn (array $s) => ! $s['site']->is_inactive); + + return [ + 'key' => $key, + 'client' => $client, + 'name' => $name, + 'contact' => $client !== null && $person !== '' && $person !== $name ? $person : null, + 'contacts' => $client?->allRecipients() ?? [], + 'sites' => $sites, + 'counts' => [ + 'sites' => count($sites), + 'care' => count(array_filter($active, fn (array $s) => $s['eligible'])), + 'scheduled' => count(array_filter($sites, fn (array $s) => $s['schedule_state'] === 'scheduled')), + 'missing' => count(array_filter($sites, fn (array $s) => $s['missing'])), + 'waiting' => array_sum(array_map(fn (array $s) => $s['waiting'], $sites)), + 'review' => array_sum(array_map(fn (array $s) => $s['review'], $sites)), + ], + ]; + } + + /** + * @param Collection $reports newest period first + * @param list $months + * @return array + */ + protected function siteRow(Site $site, ?ClientReportSchedule $schedule, Collection $reports, array $months): array + { + $eligible = ScheduledReportRunner::siteIsEligible($site); + $live = $reports->filter(fn (ClientReport $r) => $r->status !== ClientReport::STATUS_SUPERSEDED); + + $cells = []; + foreach ($months as $month) { + $key = $month->format('Y-m'); + $report = $live->first(fn (ClientReport $r) => $r->period_start->format('Y-m') === $key); + $state = match (true) { + $report !== null => ReportState::of($report), + // The schedule existed by the run on the 1st of the next month + // but the site has no care plan, so that run skipped it. + $schedule !== null && ! $eligible && $schedule->created_at !== null + && $schedule->created_at->lte($month->copy()->addMonthNoOverflow()->endOfDay()) => self::CELL_SKIPPED, + default => self::CELL_NONE, + }; + $cells[$key] = ['month' => $month, 'report' => $report, 'state' => $state]; + } + + // Waiting/review count each site's newest live report only: an older + // unsent period that a newer one followed isn't something to chase. + $latestState = ($latest = $live->first()) !== null ? ReportState::of($latest) : null; + + return [ + 'site' => $site, + 'eligible' => $eligible, + 'schedule' => $schedule, + 'schedule_state' => match (true) { + $schedule === null => 'none', + $schedule->is_enabled => 'scheduled', + default => 'disabled', + }, + 'missing' => $schedule === null && $eligible && ! $site->is_inactive, + 'latest' => $latest, + 'latest_state' => $latestState, + 'waiting' => in_array($latestState, ReportState::WAITING, true) ? 1 : 0, + 'review' => in_array($latestState, [ReportState::NEEDS_REVIEW, ReportState::OUTDATED], true) ? 1 : 0, + 'reports' => $reports->values(), + 'months' => $cells, + ]; + } + + /** @param array $row */ + protected function haystack(array $row): string + { + $parts = [$row['name'], $row['contact'], ...$row['contacts']]; + foreach ($row['sites'] as $site) { + $parts[] = $site['site']->domain; + foreach ((array) ($site['schedule']?->recipients ?? []) as $recipient) { + $parts[] = $recipient; + } + } + + return Str::lower(implode(' ', array_filter($parts))); + } +} diff --git a/modules/ClientReports/src/Services/ClientReportCompiler.php b/modules/ClientReports/src/Services/ClientReportCompiler.php index d8dd7f80..6a91d99f 100644 --- a/modules/ClientReports/src/Services/ClientReportCompiler.php +++ b/modules/ClientReports/src/Services/ClientReportCompiler.php @@ -5,7 +5,9 @@ use App\Models\ActionLog; use App\Models\BlockedIp; use App\Models\ContactFormTestRun; +use App\Models\PluginUpdateIgnore; use App\Models\PluginVulnerability; +use App\Models\ReviewQueueEntry; use App\Models\Site; use App\Models\SiteBackup; use App\Models\SiteBackupCheck; @@ -17,32 +19,129 @@ use App\Models\SiteWorkLog; use App\Services\Backups\SiteBackupRecorder; use App\Services\Companion\CompanionBrandingManager; +use App\Services\Security\CompanionMalwareScanner; use App\Services\Security\PluginVulnerabilityMatcher; -use App\Support\Settings; +use App\Support\UpgraderOutput; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use Modules\ClientReports\Mail\ClientReportMail; +use Modules\ClientReports\Support\ReportPhrases; +use Modules\Core\Contracts\HostingProvider; +use Throwable; class ClientReportCompiler { - public const SCHEMA_VERSION = 3; + public const SCHEMA_VERSION = 7; + + /** + * security.malware_status for a clean scan that didn't cover the whole + * site (stopped at a limit, or an SSH scan cut short or recorded before + * that could be told). Shown as "Partial scan". + */ + public const MALWARE_INCOMPLETE = 'incomplete'; /** How many of the period's final days the backups list shows. */ public const BACKUP_LIST_DAYS = 10; public const UPTIME_OUTAGE_LIST_MAX = 5; + /** "Too Many Requests": the host rate-limited the uptime probe, so it couldn't see the site. */ + public const PROBE_BLOCKED_STATUS = 429; + /** A clean read older than this can't vouch for "no backups found". */ public const BACKUP_CHECK_MAX_AGE_HOURS = 48; + /** + * A database backup smaller than this share of the site's database, or + * under BACKUP_MIN_DB_BYTES of one over BACKUP_SIZEABLE_DB_BYTES, is an + * empty or failed dump, however many days it ran. + */ + public const BACKUP_MIN_DB_RATIO = 0.01; + + public const BACKUP_MIN_DB_BYTES = 100 * 1024; + + public const BACKUP_SIZEABLE_DB_BYTES = 10 * 1024 * 1024; + + /** How much of the backup relay's last error a report keeps. */ + public const RELAY_ERROR_MAX_LENGTH = 200; + + /** + * The period's last day of traffic counts as complete only once its row + * was written this long after the day ended: a host's feed (Pressable) + * fills a day in after it's over. + */ + public const TRAFFIC_LAST_DAY_SETTLE_HOURS = 2; + + /** Review-queue states of a lockout that hasn't become a ban (and may never). */ + public const UNBANNED_LOCKOUT_STATUSES = [ + ReviewQueueEntry::STATUS_PENDING, + ReviewQueueEntry::STATUS_QUEUED_FOR_BAN, + ReviewQueueEntry::STATUS_FAILED, + ]; + + /** Above this share of requests from two IPs, access logs see a proxy or gateway, not visitors. */ + public const TRAFFIC_TOP_IP_SHARE_MAX = 0.9; + + /** On a site this busy, fewer visits per request than this means visits weren't counted from real visitors. */ + public const TRAFFIC_BUSY_REQUESTS = 100000; + + public const TRAFFIC_MIN_VISITS_PER_REQUEST = 0.001; + /** The feed refreshes daily; allow one missed run before calling it stale. */ public const VULNERABILITY_FEED_MAX_AGE_HOURS = 48; /** A Companion snapshot older than this before the period end can't say what was waiting then. */ public const SNAPSHOT_MAX_AGE_HOURS = 48; + /** + * The agency's own connector plugins, by directory. Updating them is + * our tooling, not the client's software, so they're never listed or + * counted as the client's updates. + */ + public const AGENCY_PLUGIN_DIRS = ['clockwork-companion', 'clockwork-renegade']; + + /** + * Version changes something other than Control made (WordPress's own + * auto-updates, the host, someone in wp-admin), recorded when Control + * sees them between snapshots, and the update kind each one is listed + * as. Their details: slug, name, from_version, to_version, observed_at. + */ + public const OBSERVED_UPDATE_TYPES = [ + 'plugin_update_observed' => ActionLog::TYPE_PLUGIN_UPDATE, + 'theme_update_observed' => ActionLog::TYPE_THEME_UPDATE, + 'core_update_observed' => ActionLog::TYPE_CORE_UPDATE, + ]; + + /** + * Logged after an update to say it was undone or stopped: the nightly + * run pausing the plugin or theme, or a backup restored over the site. + */ + protected const UNDO_LOG_TYPES = [ + ActionLog::TYPE_PLUGIN_UPDATE_AUTO_IGNORED, + ActionLog::TYPE_THEME_UPDATE_AUTO_IGNORED, + ActionLog::TYPE_BACKUP_RESTORE_APPLIED, + ]; + + /** A ban lifted this soon after it was made was a mistake, not a malicious visitor stopped. */ + public const BAN_FALSE_POSITIVE_MINUTES = 60; + + /** Where lockouts reach Control from (the pull of Gatekeeper / LLAR / Wordfence lockouts). */ + public const LOCKOUT_SOURCES = [BlockedIp::SOURCE_LLAR, BlockedIp::SOURCE_GATEKEEPER, BlockedIp::SOURCE_WORDFENCE]; + + /** + * Lockouts reach Control from across the fleet every day; none for this + * long before the period end means the feed stopped, so a count can't + * be the month's total. + */ + public const LOCKOUT_INGEST_MAX_GAP_HOURS = 72; + protected ?bool $vulnerabilityFeedFresh = null; + /** @var array lastIngestedLockout() by period end */ + protected array $lastIngestedLockout = []; + public function __construct( - protected Settings $settings, protected PluginVulnerabilityMatcher $vulnerabilities, protected SiteBackupRecorder $backups, ) {} @@ -63,19 +162,46 @@ public function compile( $start = $periodStart->copy()->startOfDay(); $end = $periodEnd->copy()->endOfDay(); + // The same calendar days in the report timezone, as UTC instants. The + // uptime and updates sections and the coverage dates show times and + // dates in that zone, so they're measured over it: an outage at 01:00 + // UTC on the 1st reads "Sep 30, 9:00 PM EDT" and belongs to the month + // before, and so does an update run then. The other sections list + // whole days (UTC, as their sources record them). + $zone = ReportPhrases::timezone(); + $localStart = Carbon::parse($periodStart->toDateString(), $zone)->startOfDay()->utc(); + $localEnd = Carbon::parse($periodEnd->toDateString(), $zone)->endOfDay()->utc(); + $shouldInclude = fn (string $key) => $sections === null || in_array($key, $sections, true); // Some sites are flagged non-WordPress but report a WordPress core via // Companion; trust the snapshot so their core-file row isn't dropped. $isWordPress = (bool) $site->is_wordpress || isset($site->companion_snapshot['wp_core']); + // A site added part-way through the period, or whose care plan was + // switched on part-way through it, was only looked after from then, + // which the whole report says; null when it was looked after for the + // whole period. Uptime monitoring can begin later still (switched on + // mid-month for a site added long before), which only the uptime + // section measures from. + [$joinedAt, $coverageSource] = $this->coverageStart($site, $localStart, $localEnd); + $uptimeFrom = $this->uptimeMonitoringStart($site, $localStart, $localEnd); + $data = [ 'meta' => [ 'compiled_at' => now()->toIso8601String(), - 'period_start' => $start->toDateString(), - 'period_end' => $end->toDateString(), - 'period_days' => $start->diffInDays($end) + 1, + 'period_start' => $periodStart->toDateString(), + 'period_end' => $periodEnd->toDateString(), + // Carbon 3's diffInDays is a float, and $end is the last + // microsecond of the day, so round to whole days (31 for October). + 'period_days' => (int) round($start->diffInDays($end)), 'custom_notes' => $customNotes, + // When the site was added or its care plan switched on, if that + // was after the period's first day in the report timezone (ISO + // 8601), and which it was ('site_added' or 'care_plan'). The + // views say so and the report is held for review. + 'coverage_start' => $joinedAt?->toIso8601String(), + 'coverage_source' => $coverageSource, // 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, @@ -92,10 +218,10 @@ public function compile( ]; if ($shouldInclude('updates')) { - $data['updates'] = $this->compileUpdates($site, $start, $end); + $data['updates'] = $this->compileUpdates($site, $localStart, $localEnd); } if ($shouldInclude('uptime')) { - $data['uptime'] = $this->compileUptime($site, $start, $end); + $data['uptime'] = $this->compileUptime($site, $localStart, $localEnd, $uptimeFrom); } if ($shouldInclude('security')) { $data['security'] = $this->compileSecurity($site, $start, $end, $isWordPress); @@ -110,7 +236,7 @@ public function compile( $data['traffic'] = $this->compileTraffic($site, $start, $end); } if ($shouldInclude('backups')) { - $data['backups'] = $this->compileBackups($site, $start, $end); + $data['backups'] = $this->compileBackups($site, $start, $end, $joinedAt); } if ($shouldInclude('work_log')) { $data['work_log'] = $this->compileWorkLog($site, $start, $end); @@ -119,67 +245,201 @@ public function compile( return $data; } - protected function compileBranding(): array + /** + * When the site was added, if that was inside the period but after its + * first day, so a site enrolled late in the month isn't reported as + * looked after for all of it. $start and $end bound the period's days in + * the report timezone, so "after the first day" is the day the client + * reads. Being added on the first day counts as the whole period, as it + * does for backups (covers_period). + */ + protected function joinedAt(Site $site, Carbon $start, Carbon $end): ?Carbon { - if (class_exists(CompanionBrandingManager::class)) { - $reportsBranding = app(CompanionBrandingManager::class)->getReportsBranding(); - - return [ - 'company_name' => $reportsBranding['company_name'] ?: config('app.name', 'Clockwork Control'), - 'support_url' => $reportsBranding['support_url'] ?: config('app.url'), - 'support_email' => $reportsBranding['support_email'] ?: config('mail.from.address'), - 'logo_url' => $reportsBranding['logo_url'], - 'primary_color' => $reportsBranding['primary_color'], - 'accent_color' => $reportsBranding['accent_color'], - 'footer_text' => $reportsBranding['footer_text'], - ]; + $created = $site->created_at; + if ($created === null || ! $created->betweenIncluded($start, $end) || $created->lessThan($start->copy()->addDay())) { + return null; } + return $created->copy(); + } + + /** + * Where the report's coverage begins, if after the period's first day: + * when the site was added (joinedAt) or when its care plan was switched + * on (carePlanStartedAt), whichever is later, with which it was. + * + * @return array{0: ?Carbon, 1: ?string} + */ + protected function coverageStart(Site $site, Carbon $start, Carbon $end): array + { + $added = $this->joinedAt($site, $start, $end); + $carePlan = $this->carePlanStartedAt($site, $start, $end); + + return match (true) { + $carePlan !== null && ($added === null || $carePlan->greaterThan($added)) => [$carePlan, 'care_plan'], + $added !== null => [$added, 'site_added'], + default => [null, null], + }; + } + + /** + * When the site's care plan was last switched on inside the period, if + * that was after its first day (in the report timezone): the period + * before wasn't a care-plan period, so the report covers only the rest. + * A switch-on is a care_plan_toggled row that turned the plan on from + * off: details.now (the site page's toggle) or details.enabled (a + * one-off command-line run) true, with details.previous false. Rows + * that re-sent the flag, cleared an override or found the plan already + * on aren't. With care plans switched off fleet-wide every site counts + * as on, so toggles say nothing. + */ + protected function carePlanStartedAt(Site $site, Carbon $start, Carbon $end): ?Carbon + { + if (! Site::areCarePlansEnabled()) { + return null; + } + + $switchedOn = ActionLog::query() + ->where('site_id', $site->id) + ->where('action_type', ActionLog::TYPE_CARE_PLAN_TOGGLED) + ->whereBetween('ran_at', [$start, $end]) + ->orderByDesc('ran_at') + ->orderByDesc('id') + ->get(['ran_at', 'details']) + ->first(function (ActionLog $log) { + $details = is_array($log->details) ? $log->details : []; + + return ($details['now'] ?? $details['enabled'] ?? null) === true + && array_key_exists('previous', $details) && $details['previous'] === false; + }); + + $at = $switchedOn?->ran_at; + if ($at === null || $at->lessThan($start->copy()->addDay())) { + return null; + } + + return $at->copy(); + } + + /** + * When uptime monitoring of this site began, if that was after the + * period's first day (in the report timezone), so the uptime figures are + * measured from then. Monitoring begins with the first successful uptime + * probe, which leaves a seed transition in the action log; that can come + * long after the site was added, when monitoring is switched on + * mid-month. A backfilled seed is dated when the backfill ran, not when + * monitoring began, and a site whose first probes failed gets no seed at + * all, so those fall back to when the site was added, if that was inside + * the period. A seed after the period is kept: the uptime section is then + * left out, since monitoring had nothing to say about the period. + */ + protected function uptimeMonitoringStart(Site $site, Carbon $start, Carbon $end): ?Carbon + { + $seed = ActionLog::query() + ->where('site_id', $site->id) + ->where('action_type', ActionLog::TYPE_UPTIME_TRANSITION) + ->where('details->seed', true) + ->orderBy('ran_at') + ->orderBy('id') + ->first(['ran_at', 'details']); + + $began = match (true) { + $seed !== null && $seed->ran_at !== null && empty($seed->details['backfilled']) => $seed->ran_at, + $site->created_at !== null && $site->created_at->betweenIncluded($start, $end) => $site->created_at, + default => null, + }; + + if ($began === null || $began->lessThan($start->copy()->addDay())) { + return null; + } + + return $began->copy(); + } + + /** + * The report's branding, captured at compile time. The support URL falls + * back to the agency website and never to Control's own URL: Control runs + * on a private host (often localhost) that clients can't reach. A blank + * Companion support URL is meaningful there (it hides the support + * button), so the fallback lives here rather than in the branding manager. + */ + protected function compileBranding(): array + { + $reportsBranding = app(CompanionBrandingManager::class)->getReportsBranding(); + + $supportUrl = trim($reportsBranding['support_url']); + $companyUrl = trim($reportsBranding['company_url']); + return [ - 'company_name' => $this->settings->get('companion.branding.company_name') ?: ($this->settings->get('companion.company_name') ?: config('app.name', 'Clockwork Control')), - 'support_url' => $this->settings->get('companion.branding.support_url') ?: ($this->settings->get('companion.support_url') ?: config('app.url')), - 'support_email' => $this->settings->get('companion.branding.support_email') ?: ($this->settings->get('companion.support_email') ?: config('mail.from.address')), - 'logo_url' => $this->settings->get('companion.branding.logo_url') ?: $this->settings->get('companion.logo_url'), - 'primary_color' => '#2D2062', - 'accent_color' => '#7EFF83', - 'footer_text' => '', + 'company_name' => $reportsBranding['company_name'] ?: config('app.name', 'Clockwork Control'), + 'support_url' => $supportUrl !== '' ? $supportUrl : ($companyUrl !== '' ? $companyUrl : null), + 'support_email' => $reportsBranding['support_email'] ?: config('mail.from.address'), + 'logo_url' => $reportsBranding['logo_url'], + 'primary_color' => $reportsBranding['primary_color'], + 'accent_color' => $reportsBranding['accent_color'], + 'footer_text' => $reportsBranding['footer_text'], ]; } + /** + * The updates applied in the period: Control's own update runs, and + * version changes something else made that Control saw between + * snapshots (OBSERVED_UPDATE_TYPES), each listed once. $start and $end + * bound the period's days in the report timezone, and each update is + * dated by its day there, so an update run at 9:30 PM on the 28th + * (01:30 UTC on the 29th) reads "Oct 28", as an outage then would. + * + * Left out: runs that changed nothing, the agency's own connector + * plugins (AGENCY_PLUGIN_DIRS), and updates that were undone after they + * ran (see undoneUpdates()), which are kept apart as not_kept for the + * review flags. + */ protected function compileUpdates(Site $site, Carbon $start, Carbon $end): array { + $zone = ReportPhrases::timezone(); $logs = ActionLog::query() ->where('site_id', $site->id) ->whereIn('action_type', ActionLog::UPDATE_TYPES) ->where('ok', true) ->whereBetween('ran_at', [$start, $end]) ->orderByDesc('ran_at') + ->orderByDesc('id') ->get() - ->reject(fn (ActionLog $log) => self::isNoOpUpdate((string) $log->summary)); + ->reject(fn (ActionLog $log) => self::isNoOpUpdate((string) $log->summary) || self::isAgencyPluginUpdate($log)) + ->values(); - $pluginUpdates = []; - $themeUpdates = []; - $coreUpdates = []; - $translationUpdates = []; + $undone = $this->undoneUpdates($site, $logs); + $logs = $logs->reject(fn (ActionLog $log) => isset($undone[$log->id]))->values(); - foreach ($logs as $log) { - $item = [ + $items = $logs->map(fn (ActionLog $log) => [ + 'type' => $log->action_type, + 'at' => $log->ran_at, + 'item' => [ 'target' => $log->target, 'summary' => $log->summary, - 'date' => $log->ran_at?->toDateString(), - ]; + 'date' => $log->ran_at?->copy()->setTimezone($zone)->toDateString(), + 'observed' => false, + ], + ])->all(); - match ($log->action_type) { - ActionLog::TYPE_PLUGIN_UPDATE => $pluginUpdates[] = $item, - ActionLog::TYPE_THEME_UPDATE => $themeUpdates[] = $item, - ActionLog::TYPE_CORE_UPDATE => $coreUpdates[] = $item, - ActionLog::TYPE_TRANSLATIONS_UPDATE => $translationUpdates[] = $item, - default => null, - }; + foreach ($this->observedUpdates($site, $start, $end) as $observed) { + $items[] = $observed; } + // Newest first, as the rows were read. + usort($items, fn (array $a, array $b) => ($b['at']?->getTimestamp() ?? 0) <=> ($a['at']?->getTimestamp() ?? 0)); + + $byType = fn (string $type) => array_values(array_map( + fn (array $i) => $i['item'], + array_filter($items, fn (array $i) => $i['type'] === $type), + )); + $pluginUpdates = $byType(ActionLog::TYPE_PLUGIN_UPDATE); + $themeUpdates = $byType(ActionLog::TYPE_THEME_UPDATE); + $coreUpdates = $byType(ActionLog::TYPE_CORE_UPDATE); + $translationUpdates = $byType(ActionLog::TYPE_TRANSLATIONS_UPDATE); + return [ - 'total' => $logs->count(), + 'total' => count($items), 'plugins_count' => count($pluginUpdates), 'themes_count' => count($themeUpdates), 'core_count' => count($coreUpdates), @@ -189,13 +449,234 @@ protected function compileUpdates(Site $site, Carbon $start, Carbon $end): array 'themes' => $themeUpdates, 'core' => $coreUpdates, ], + // Internal: updates that ran in the period but were undone after + // (the site is on an older version now, and the update was paused + // or a backup restored), so they aren't listed. For the review flags. + 'not_kept' => array_values($undone), // What was still waiting at the end of the period, so the email - // never says "already up to date" while something was. null means + // never says "up to date" while something was. null means // unknown (no snapshot, or one too old to say). See updatesWereWaiting(). ...$this->pendingUpdates($site, $end), ]; } + /** Whether an update log is for one of the agency's own connector plugins. */ + protected static function isAgencyPluginUpdate(ActionLog $log): bool + { + $type = self::OBSERVED_UPDATE_TYPES[$log->action_type] ?? $log->action_type; + if ($type !== ActionLog::TYPE_PLUGIN_UPDATE) { + return false; + } + + $details = is_array($log->details) ? $log->details : []; + + return self::isAgencyPlugin((string) ($details['slug'] ?? $log->target ?? '')); + } + + /** Whether a plugin slug ("clockwork-companion/clockwork-companion.php") is one of the agency's own. */ + public static function isAgencyPlugin(string $slug): bool + { + return in_array(strtolower(explode('/', trim($slug), 2)[0]), self::AGENCY_PLUGIN_DIRS, true); + } + + /** + * Version changes something other than Control made, seen in the period, + * as update items. Each is dated by when it was seen (ran_at, the + * snapshot that showed the new version) in the report timezone. One that Control's own + * update run made (the same plugin, theme or core going to the same + * version) is already listed, so it's left out; so are the agency's own + * plugins and changes that ended on the version they started from. + * + * @return list + */ + protected function observedUpdates(Site $site, Carbon $start, Carbon $end): array + { + $zone = ReportPhrases::timezone(); + $rows = $this->observedUpdateRows($site, $start) + ->filter(fn (array $row) => $row['at']->betweenIncluded($start, $end)) + ->reject(fn (array $row) => $row['to'] === '' || $row['to'] === $row['from']) + ->values(); + if ($rows->isEmpty()) { + return []; + } + + // Control's own runs to the same version, a little either side of + // the period: a snapshot can see a change a day after Control made it. + $applied = []; + ActionLog::query() + ->where('site_id', $site->id) + ->whereIn('action_type', [ActionLog::TYPE_PLUGIN_UPDATE, ActionLog::TYPE_THEME_UPDATE, ActionLog::TYPE_CORE_UPDATE]) + ->where('ok', true) + ->whereBetween('ran_at', [$start->copy()->subDays(31), $end->copy()->addDays(31)]) + ->get(['action_type', 'target', 'summary', 'details']) + ->each(function (ActionLog $log) use (&$applied) { + $details = is_array($log->details) ? $log->details : []; + $to = trim((string) ($details['after_version'] ?? '')); + if ($to === '' && preg_match('/→\s*(\S+)\s*(?:\[|$)/u', (string) $log->summary, $m)) { + $to = $m[1]; + } + $applied[self::updateKey($log->action_type, (string) ($details['slug'] ?? $log->target ?? ''), $to)] = true; + }); + + $prefixes = [ + ActionLog::TYPE_PLUGIN_UPDATE => 'Plugin updated: ', + ActionLog::TYPE_THEME_UPDATE => 'Theme updated: ', + ActionLog::TYPE_CORE_UPDATE => 'WordPress core updated: ', + ]; + + $items = []; + foreach ($rows as $row) { + if (isset($applied[self::updateKey($row['type'], $row['slug'], $row['to'])])) { + continue; + } + + $name = $row['type'] === ActionLog::TYPE_CORE_UPDATE ? '' : $row['name']; + $versions = ($row['from'] !== '' ? $row['from'].' → ' : '→ ').$row['to']; + $items[] = [ + 'type' => $row['type'], + 'at' => $row['at'], + 'item' => [ + 'target' => $row['slug'] !== '' ? $row['slug'] : null, + 'summary' => $prefixes[$row['type']].trim($name.' '.$versions), + 'date' => $row['at']->copy()->setTimezone($zone)->toDateString(), + 'observed' => true, + ], + ]; + } + + return $items; + } + + /** + * The site's observed version changes seen at or after $since, read + * into one shape, without the agency's own plugins. A row's ran_at is + * the snapshot that showed the new version. Names come from the + * snapshot as WordPress gives them, HTML entities and all ("Example + * & Co"), so they're decoded for display. + * + * @return Collection + */ + protected function observedUpdateRows(Site $site, Carbon $since): Collection + { + $decode = fn (mixed $text) => trim(html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + + return ActionLog::query() + ->where('site_id', $site->id) + ->whereIn('action_type', array_keys(self::OBSERVED_UPDATE_TYPES)) + ->where('ok', true) + ->where('ran_at', '>=', $since) + ->get() + ->reject(fn (ActionLog $log) => self::isAgencyPluginUpdate($log)) + ->map(function (ActionLog $log) use ($decode) { + $details = is_array($log->details) ? $log->details : []; + $slug = trim((string) ($details['slug'] ?? $log->target ?? '')); + + return [ + 'type' => self::OBSERVED_UPDATE_TYPES[$log->action_type], + 'slug' => $slug, + 'name' => $decode($details['name'] ?? '') ?: ($slug !== '' ? Str::headline(explode('/', $slug, 2)[0]) : 'WordPress'), + 'from' => trim((string) ($details['from_version'] ?? '')), + 'to' => trim((string) ($details['to_version'] ?? '')), + 'at' => $log->ran_at?->copy()->utc(), + ]; + }) + ->filter(fn (array $row) => $row['at'] !== null) + ->values(); + } + + /** "plugin_update|example-forms|2.1": an update by kind, plugin or theme directory and the version it reached. */ + protected static function updateKey(string $type, string $slug, string $version): string + { + $dir = $type === ActionLog::TYPE_CORE_UPDATE ? '' : strtolower(explode('/', trim($slug), 2)[0]); + + return $type.'|'.$dir.'|'.trim($version); + } + + /** + * Plugin and theme updates that ran but didn't stick: the site's + * snapshot, taken after the update, shows a lower version than the + * update reached, and since the update the plugin or theme was paused + * (an update ignore, or the nightly run's auto-pause) or a backup was + * restored over the site. A lower version alone isn't enough (the + * snapshot may list the plugin under another slug, or a person may have + * rolled it back for a reason the client was told about), so without + * one of those the update is still listed. + * + * @param Collection $logs + * @return array keyed by log id + */ + protected function undoneUpdates(Site $site, Collection $logs): array + { + $snapshotAt = $site->companion_snapshot_at; + $snapshot = (array) $site->companion_snapshot; + if ($snapshotAt === null || $snapshot === []) { + return []; + } + + $installed = []; + foreach (['plugin' => $snapshot['plugins']['plugins'] ?? null, 'theme' => $snapshot['themes']['items'] ?? null] as $kind => $entries) { + foreach ((array) $entries as $entry) { + $slug = is_array($entry) ? trim((string) ($entry['slug'] ?? '')) : ''; + $version = is_array($entry) ? trim((string) ($entry['version'] ?? '')) : ''; + if ($slug !== '' && $version !== '') { + $installed[$kind][$slug] = $version; + $installed[$kind][strtolower(explode('/', $slug, 2)[0])] ??= $version; + } + } + } + + $candidates = []; + foreach ($logs as $log) { + $kind = match ($log->action_type) { + ActionLog::TYPE_PLUGIN_UPDATE => 'plugin', + ActionLog::TYPE_THEME_UPDATE => 'theme', + default => null, + }; + $details = is_array($log->details) ? $log->details : []; + $slug = trim((string) ($details['slug'] ?? $log->target ?? '')); + $after = trim((string) ($details['after_version'] ?? '')); + $now = $kind !== null ? ($installed[$kind][$slug] ?? $installed[$kind][strtolower(explode('/', $slug, 2)[0])] ?? null) : null; + + if ($now !== null && $after !== '' && $log->ran_at !== null && $snapshotAt->greaterThan($log->ran_at) + && version_compare($now, $after, '<')) { + $candidates[$log->id] = [$log, $kind, $slug, $now]; + } + } + if ($candidates === []) { + return []; + } + + $earliest = collect($candidates)->min(fn (array $c) => $c[0]->ran_at); + $pauses = PluginUpdateIgnore::query() + ->where('site_id', $site->id) + ->whereIn('target_slug', array_unique(array_column($candidates, 2))) + ->get(['target_kind', 'target_slug', 'ignored_at']); + $undoLogs = ActionLog::query() + ->where('site_id', $site->id) + ->whereIn('action_type', self::UNDO_LOG_TYPES) + ->where('ran_at', '>=', $earliest) + ->get(['action_type', 'target', 'ran_at']); + + $undone = []; + foreach ($candidates as $id => [$log, $kind, $slug, $now]) { + $paused = $pauses->contains(fn (PluginUpdateIgnore $p) => $p->target_kind === $kind && $p->target_slug === $slug + && $p->ignored_at !== null && $p->ignored_at->greaterThanOrEqualTo($log->ran_at)); + $rolledBack = $undoLogs->contains(fn (ActionLog $u) => $u->ran_at !== null && $u->ran_at->greaterThanOrEqualTo($log->ran_at) + && ($u->action_type === ActionLog::TYPE_BACKUP_RESTORE_APPLIED || $u->target === $slug)); + + if ($paused || $rolledBack) { + $undone[$id] = [ + 'type' => $kind, + 'label' => ClientReportMail::cleanUpdateSummary((string) $log->summary, $slug), + 'date' => $log->ran_at->copy()->setTimezone(ReportPhrases::timezone())->toDateString(), + 'installed_version' => $now, + ]; + } + } + + return $undone; + } + /** * Updates waiting at the end of the period, from the Companion snapshot. * The 06:00 UTC run comes before that night's refresh (01:30 ET), so it @@ -207,7 +688,12 @@ protected function compileUpdates(Site $site, Carbon $start, Carbon $end): array * waiting then and has since been applied, so those are counted * separately. * - * @return array{pending_plugins: ?list, pending_themes: ?list, pending_core: ?bool, updated_since_period: int, snapshot_at: ?string, snapshot_stale: bool} + * pending_plugin_items is the same list with each plugin's file slug and + * the version offered, for the review flags to join on (names repeat). + * The agency's own connector plugins are left out, as they are from the + * updates list. + * + * @return array{pending_plugins: ?list, pending_plugin_items: ?list, pending_themes: ?list, pending_core: ?bool, updated_since_period: int, snapshot_at: ?string, snapshot_stale: bool} */ protected function pendingUpdates(Site $site, Carbon $end): array { @@ -216,30 +702,45 @@ protected function pendingUpdates(Site $site, Carbon $end): array $stale = $site->companion_snapshot !== null && ($snapshotAt === null || $snapshotAt->lessThan($end->copy()->subHours(self::SNAPSHOT_MAX_AGE_HOURS))); - $waitingNames = function (mixed $items): ?array { + $waiting = function (mixed $items, callable $shape): ?array { if (! is_array($items)) { return null; } return array_values(array_map( - fn (array $i) => (string) ($i['name'] ?? $i['slug'] ?? ''), + $shape, array_filter($items, fn ($i) => is_array($i) && ! empty($i['update_available'])), )); }; + $name = fn (array $i) => (string) ($i['name'] ?? $i['slug'] ?? ''); + $plugins = $snapshot['plugins']['plugins'] ?? null; + if (is_array($plugins)) { + $plugins = array_values(array_filter($plugins, fn ($p) => ! (is_array($p) && self::isAgencyPlugin((string) ($p['slug'] ?? ''))))); + } $core = $snapshot['wp_core'] ?? null; + // Updates applied after the period (by Control, or seen changing + // since) were waiting at its end. $updatedSince = ActionLog::query() ->where('site_id', $site->id) ->whereIn('action_type', [ActionLog::TYPE_PLUGIN_UPDATE, ActionLog::TYPE_THEME_UPDATE, ActionLog::TYPE_CORE_UPDATE]) ->where('ok', true) ->where('ran_at', '>', $end) - ->get(['summary']) - ->reject(fn (ActionLog $log) => self::isNoOpUpdate((string) $log->summary)) - ->count(); + ->get(['action_type', 'target', 'summary', 'details']) + ->reject(fn (ActionLog $log) => self::isNoOpUpdate((string) $log->summary) || self::isAgencyPluginUpdate($log)) + ->count() + + $this->observedUpdateRows($site, $end) + ->filter(fn (array $row) => $row['at']->greaterThan($end) && $row['to'] !== '' && $row['to'] !== $row['from']) + ->count(); return [ - 'pending_plugins' => $stale ? null : $waitingNames($snapshot['plugins']['plugins'] ?? null), - 'pending_themes' => $stale ? null : $waitingNames($snapshot['themes']['items'] ?? null), + 'pending_plugins' => $stale ? null : $waiting($plugins, $name), + 'pending_plugin_items' => $stale ? null : $waiting($plugins, fn (array $i) => [ + 'slug' => (string) ($i['slug'] ?? ''), + 'name' => $name($i), + 'new_version' => trim((string) ($i['new_version'] ?? '')) !== '' ? trim((string) $i['new_version']) : null, + ]), + 'pending_themes' => $stale ? null : $waiting($snapshot['themes']['items'] ?? null, $name), 'pending_core' => $stale || ! is_array($core) ? null : ! empty($core['update_available']), 'updated_since_period' => $updatedSince, 'snapshot_at' => $snapshotAt?->toDateString(), @@ -292,51 +793,112 @@ public static function isNoOpUpdate(string $summary): bool * `maintenance`) event, clamped to the period. Events only record state * changes, so the last event before the period tells us whether the site * was already down when it began. SLA-exempt outages don't count. + * + * $start and $end bound the period's days in the report timezone, the + * zone the outage times are shown in. A site whose uptime monitoring + * began part-way through the period is measured from then + * ($monitoringFrom): the days before weren't watched, so they can't + * count as up time. + * + * A host that rate-limits the monitor answers HTTP 429. That says the + * probe was blocked, not that visitors couldn't reach the site, so the + * time it was blocked is kept out of the downtime and the outages and + * counted separately (probe_blocked_minutes, for the operator). The + * monitor records that as a `probe_blocked` event, which runs until the + * next event of any other kind; before it had that state it recorded a + * `down` with status 429, which is read the same way. A `probe_blocked` + * event ends an open outage there (the site answers again; the monitor + * just can't see past the block), and a real `down` while blocked starts + * an outage. In those older rows a real outage that began while the + * probe was already blocked left no event, so it can't be told apart. */ - protected function compileUptime(Site $site, Carbon $start, Carbon $end): array + protected function compileUptime(Site $site, Carbon $start, Carbon $end, ?Carbon $monitoringFrom = null): array { - $totalMinutes = max(1, (int) $start->diffInMinutes($end)); + $from = $monitoringFrom !== null ? $monitoringFrom->copy()->max($start) : $start->copy(); + // $end is the last microsecond of the day: round, or a 31-day month is 44,639 minutes. + $totalMinutes = max(1, (int) round($from->diffInMinutes($end))); $periodEnd = $end->copy()->min(now()); $priorEvent = SiteUptimeEvent::query() ->where('site_id', $site->id) - ->where('event_at', '<', $start) + ->where('event_at', '<', $from) ->orderByDesc('event_at') ->orderByDesc('id') ->first(); $events = SiteUptimeEvent::query() ->where('site_id', $site->id) - ->whereBetween('event_at', [$start, $end]) + ->whereBetween('event_at', [$from, $end]) ->orderBy('event_at') ->orderBy('id') ->get(); $isCountedDown = fn (SiteUptimeEvent $e) => $e->event_type === SiteUptimeEvent::TYPE_DOWN && ! $e->is_sla_exempt; + // What an event opens: 'blocked' for a probe_blocked event, or a down + // the monitor recorded for a 429 before it had that state; 'down' + // for a real outage; null for one that ends whatever was open (up, + // maintenance, an SLA-exempt outage). + $opens = fn (SiteUptimeEvent $e): ?string => match (true) { + $e->event_type === SiteUptimeEvent::TYPE_PROBE_BLOCKED => 'blocked', + $isCountedDown($e) => (int) $e->status_code === self::PROBE_BLOCKED_STATUS ? 'blocked' : 'down', + default => null, + }; + // The interval currently open: where it's counted from, when it really + // began (before the period, for one carried in), and why. + $priorOpens = $priorEvent !== null ? $opens($priorEvent) : null; + $open = $priorOpens !== null ? [ + 'from' => $from->copy(), + 'began' => $priorEvent->event_at->copy(), + 'carried_in' => true, + 'blocked' => $priorOpens === 'blocked', + ] : null; $intervals = []; - $downSince = $priorEvent !== null && $isCountedDown($priorEvent) ? $start->copy() : null; foreach ($events as $event) { - if ($isCountedDown($event)) { - $downSince ??= $event->event_at->copy(); + $kind = $opens($event); + // More of the same (a 429 down carried into a probe_blocked + // event, say) keeps the interval and when it began. + if ($kind !== null && $open !== null && $open['blocked'] === ($kind === 'blocked')) { continue; } - if ($downSince !== null) { - $intervals[] = [$downSince, $event->event_at->copy()]; - $downSince = null; + // Anything else ends what was open where it happened: an outage + // ends when the site answers again, even if only with a 429, and + // a blocked stretch ends at a real outage. + if ($open !== null) { + $intervals[] = [...$open, 'to' => $event->event_at->copy(), 'ongoing' => false]; + $open = null; + } + + if ($kind !== null) { + $open = [ + 'from' => $event->event_at->copy(), + 'began' => $event->event_at->copy(), + 'carried_in' => false, + 'blocked' => $kind === 'blocked', + ]; } } - $stillDown = $downSince !== null; - if ($stillDown && $periodEnd->greaterThan($downSince)) { - $intervals[] = [$downSince, $periodEnd->copy()]; + if ($open !== null && $periodEnd->greaterThan($open['from'])) { + $intervals[] = [...$open, 'to' => $periodEnd->copy(), 'ongoing' => true]; } - $outages = count($intervals) + ($stillDown && ! $periodEnd->greaterThan($downSince) ? 1 : 0); - $downtimeSeconds = array_sum(array_map(fn (array $i) => max(0, (int) $i[0]->diffInSeconds($i[1])), $intervals)); + $seconds = fn (array $i) => max(0, (int) $i['from']->diffInSeconds($i['to'])); + $outageIntervals = array_values(array_filter($intervals, fn (array $i) => ! $i['blocked'])); + $outageRow = fn (array $i) => [ + 'started_at' => $i['from']->toIso8601String(), + 'minutes' => (int) round($seconds($i) / 60), + 'started_before_period' => $i['carried_in'], + 'ongoing' => $i['ongoing'], + ]; + $blockedSeconds = array_sum(array_map($seconds, array_filter($intervals, fn (array $i) => $i['blocked']))); + + $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))); @@ -344,36 +906,50 @@ protected function compileUptime(Site $site, Carbon $start, Carbon $end): array // No events reads as 100%, so the flag alone isn't enough: the probe // skips ignored and staging servers and ignore-listed domains (the // same query as clockwork:check-site-uptime), and one that stopped - // checking the site leaves no events either. + // checking the site leaves no events either. Monitoring that only + // began after the period has nothing to say about it. $inProbe = $site->uptime_monitoring_enabled && Site::query()->whereKey($site->id)->hostMonitored()->notDomainIgnored()->exists(); $lastChecked = $site->uptime_last_checked_at; $checkedRecently = $lastChecked !== null && $lastChecked->greaterThanOrEqualTo($periodEnd->copy()->subDay()); return [ - 'monitored' => $inProbe && $checkedRecently, + 'monitored' => $inProbe && $checkedRecently && ($monitoringFrom === null || $monitoringFrom->lessThan($periodEnd)), // On, in the probe, but not checked lately: a person should look. 'probe_stopped' => $inProbe && ! $checkedRecently, 'last_checked_at' => $lastChecked?->toDateString(), + // When uptime monitoring began, if that was after the period's first day. + 'monitored_since' => $monitoringFrom?->toIso8601String(), 'uptime_percentage' => $uptimePercentage, 'outages_count' => $outages, 'downtime_minutes' => $downtimeMinutes, 'total_period_minutes' => $totalMinutes, + // Internal: time the monitor was blocked (HTTP 429), not counted as + // 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), + '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 // start, so it's flagged rather than shown as starting then. - 'outages' => array_slice(array_reverse(array_map(fn (array $i) => [ - 'started_at' => $i[0]->toIso8601String(), - 'minutes' => (int) round($i[0]->diffInSeconds($i[1]) / 60), - 'started_before_period' => $i[0]->equalTo($start) && $priorEvent !== null && $isCountedDown($priorEvent), - 'ongoing' => $stillDown && $i[1]->equalTo($periodEnd), - ], $intervals)), 0, self::UPTIME_OUTAGE_LIST_MAX), + 'outages' => array_slice(array_reverse(array_map($outageRow, $outageIntervals)), 0, self::UPTIME_OUTAGE_LIST_MAX), + // Internal: every outage of ReportReviewFlags::LONG_OUTAGE_MINUTES + // or more, oldest first and uncapped, so one that dropped off the + // client's list still holds the report for a person to explain. + 'long_outages' => array_values(array_filter( + array_map($outageRow, $outageIntervals), + fn (array $o) => $o['minutes'] >= ReportReviewFlags::LONG_OUTAGE_MINUTES, + )), ]; } /** * Anything we didn't actually check is reported as null / not-checked — * never as a passing default — because this goes to clients verbatim. + * + * The malware_* keys after malware_checked_at and vulnerable_plugin_items + * are internal, for the review flags. Nothing here holds a file path: + * stored data can reach the preview page, which is also the public view. */ protected function compileSecurity(Site $site, Carbon $start, Carbon $end, bool $isWordPress): array { @@ -384,6 +960,7 @@ protected function compileSecurity(Site $site, Carbon $start, Carbon $end, bool ->whereBetween('scanned_at', [$start, $end]) ->where('status', '!=', SiteSecurityScan::STATUS_FAILED) ->orderByDesc('scanned_at') + ->orderByDesc('id') ->get(); $checksumScan = $isWordPress @@ -391,9 +968,18 @@ protected function compileSecurity(Site $site, Carbon $start, Carbon $end, bool : null; // Companion's in-WordPress malware probe is the deeper check; SiteCheck - // (remote, front-end only) stands in when it didn't run. - $malwareScan = $scans->firstWhere('scan_type', SiteSecurityScan::TYPE_COMPANION_MALWARE) - ?? $scans->firstWhere('scan_type', SiteSecurityScan::TYPE_SITECHECK); + // (remote, front-end only) stands in when it didn't run. The client + // sees the latest scan, but one earlier in the month that found + // malware is still something a person should look at. + $companionScans = $scans->where('scan_type', SiteSecurityScan::TYPE_COMPANION_MALWARE)->values(); + $malwareScan = $companionScans->first() ?? $scans->firstWhere('scan_type', SiteSecurityScan::TYPE_SITECHECK); + // Each Companion scan read with the scanner's own-source exemption + // applied again, so a scan stored before the scanner learned to skip + // its own signature list doesn't count as having found malware. + $normalize = CompanionMalwareScanner::findingPathNormalizer($site); + $effectiveStatus = fn (SiteSecurityScan $scan): ?string => self::statusWithoutOwnScanner($scan, $normalize)['status']; + $malwareDetails = self::malwareDetails($malwareScan, $normalize); + $issueScans = $companionScans->filter(fn (SiteSecurityScan $scan) => $effectiveStatus($scan) === SiteSecurityScan::STATUS_ISSUES_FOUND); $blacklistScan = $scans->firstWhere('scan_type', SiteSecurityScan::TYPE_BLACKLIST); // The plugin list is the site's current Companion snapshot, so this is @@ -405,10 +991,35 @@ protected function compileSecurity(Site $site, Carbon $start, Carbon $end, bool $vulnerabilitiesChecked = is_array($snapshotPlugins) && $this->vulnerabilityFeedIsFresh(); $vulnerabilities = $vulnerabilitiesChecked ? $this->vulnerabilities->forSite($site) : []; + // Bans lifted as false positives (a rule that misfired, or a ban + // undone within the hour) blocked nobody malicious. $blockedIpsCount = BlockedIp::query() ->where('site_id', $site->id) ->whereBetween('banned_at', [$start, $end]) + ->get(['banned_at', 'unbanned_at', 'decided_by']) + ->reject(fn (BlockedIp $ban) => self::liftedAsFalsePositive($ban)) + ->count(); + // Lockouts from the period that never became a ban: waiting for + // review, or failed (no server to ban at, fail2ban not provisioned). + // The ban count leaves those visitors out, so it can't be shown. + $unbannedLockouts = ReviewQueueEntry::query() + ->where('site_id', $site->id) + ->whereBetween('created_at', [$start, $end]) + ->whereIn('status', self::UNBANNED_LOCKOUT_STATUSES) ->count(); + // A count is a monthly total only when lockouts reached us all + // month: bans in the period prove some did, not that all did. The + // site must report its lockouts now (a Companion that stopped + // advertising its blocker mid-month doesn't), and lockouts must + // still have been arriving near the period end. + $lastIngested = $this->lastIngestedLockout($end); + $blocksUnmeasured = match (true) { + $unbannedLockouts > 0 => 'unbanned_lockouts', + ! $this->lockoutsIngested($site, $start, $end) => 'no_lockout_feed', + $lastIngested !== null && $lastIngested->lessThan($end->copy()->subHours(self::LOCKOUT_INGEST_MAX_GAP_HOURS)) => 'ingestion_stopped', + default => null, + }; + $blocksMeasured = $blocksUnmeasured === null; return [ 'total_scans' => $scans->count(), @@ -418,19 +1029,372 @@ protected function compileSecurity(Site $site, Carbon $start, Carbon $end, bool '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: 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. + 'vulnerable_plugin_items' => $this->vulnerablePluginItems($site, $vulnerabilities), 'inactive_plugin_vulnerabilities' => count(array_filter($vulnerabilities, fn (array $v) => ! $v['active'])), - // Only a number when something on the site reports its blocks to us; - // with no blocker reporting, 0 would read as "no attacks". - 'blocked_threats_count' => $blockedIpsCount > 0 || $this->reportsBlocks($site) ? $blockedIpsCount : null, + // Only a number when we know lockouts from this site actually reach + // us and were all turned into bans; otherwise 0 would read as "no + // attacks" (or a low count as all of them). + 'blocked_threats_count' => $blocksMeasured ? $blockedIpsCount : null, + // Internal: lockouts in the period that never became a ban. + 'unbanned_lockouts' => $unbannedLockouts, + // Internal: why the count is hidden (unbanned_lockouts, + // no_lockout_feed, ingestion_stopped), or null when it's shown, + // and when any site's lockout last reached us by the period end. + // For the operator note (ReportPhrases::blockedVisitorsNote). + 'blocks_unmeasured' => $blocksUnmeasured, + 'lockouts_last_ingested_at' => $lastIngested?->toIso8601String(), + // "At the server" only where a fail2ban jail can ban at the server. + 'blocks_at_server' => $blocksMeasured && $this->hasProvisionedJail($site), + // Whether any transport can run the core-file check on this host. + // When none can, "Not checked this month" would be true every + // month, so the row is left out rather than shown. + 'checksum_supported' => $isWordPress && $this->hasChecksumTransport($site), 'checksum_status' => $checksumScan?->status, 'checksum_checked_at' => $checksumScan?->scanned_at?->toDateString(), - 'malware_status' => $malwareScan?->status, + 'malware_status' => $this->malwareStatus($malwareScan, $malwareDetails, $malwareScan !== null ? $effectiveStatus($malwareScan) : null), 'malware_checked_at' => $malwareScan?->scanned_at?->toDateString(), + 'malware_scan_id' => $malwareScan?->id, + // kind => count, from Companion's findings; null for SiteCheck. + 'malware_finding_kinds' => self::findingKinds($malwareDetails), + 'malware_transport' => $malwareDetails !== null ? ($malwareDetails['transport'] ?? null) : null, + 'malware_scan_aborted' => $malwareDetails !== null && ! empty($malwareDetails['scan_aborted']), + 'malware_abort_reason' => $malwareDetails !== null ? ($malwareDetails['abort_reason'] ?? null) : null, + 'malware_scanned_files' => $malwareDetails !== null && isset($malwareDetails['scanned_files_count']) ? (int) $malwareDetails['scanned_files_count'] : null, + // The SSH checks that hit their result cap; null when the scan + // didn't record them (Companion, or an SSH scan from before). + 'malware_truncated_checks' => $malwareDetails !== null && is_array($malwareDetails['truncated_checks'] ?? null) + ? array_values(array_filter($malwareDetails['truncated_checks'], 'is_string')) + : null, + // The SSH checks that didn't finish (timed out or cut off), and + // whether every check proved it finished; null when not recorded. + 'malware_incomplete_checks' => $malwareDetails !== null && is_array($malwareDetails['incomplete_checks'] ?? null) + ? array_values(array_filter($malwareDetails['incomplete_checks'], 'is_string')) + : null, + 'malware_ssh_checks_verified' => $malwareDetails !== null && array_key_exists('ssh_checks_verified', $malwareDetails) + ? (bool) $malwareDetails['ssh_checks_verified'] + : null, + // The worst any Companion scan in the period found, and when one + // last found malware, so a month that started infected doesn't + // go out as clean on the strength of its last scan. + 'malware_worst_in_period' => match (true) { + $issueScans->isNotEmpty() => SiteSecurityScan::STATUS_ISSUES_FOUND, + $companionScans->contains(fn (SiteSecurityScan $scan) => $effectiveStatus($scan) === SiteSecurityScan::STATUS_WARNING) => SiteSecurityScan::STATUS_WARNING, + $companionScans->isNotEmpty() => SiteSecurityScan::STATUS_CLEAN, + default => null, + }, + 'malware_last_issue_at' => $issueScans->first()?->scanned_at?->toDateString(), + '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(), ]; } + /** + * A Companion malware scan's recorded details, or null for SiteCheck and + * for rows that never recorded any (whose stored status is all there is). + * Findings in the scanner's own source are left out, as the scanner now + * does when it records a scan. + * + * @param ?\Closure(string): string $normalize + * @return ?array + */ + protected static function malwareDetails(?SiteSecurityScan $scan, ?\Closure $normalize = null): ?array + { + if ($scan === null || $scan->scan_type !== SiteSecurityScan::TYPE_COMPANION_MALWARE) { + return null; + } + + $details = $scan->details; + if (! is_array($details) || ! is_array($details['findings'] ?? null)) { + return null; + } + + $details['findings'] = self::statusWithoutOwnScanner($scan, $normalize ?? CompanionMalwareScanner::findingPathNormalizer())['findings']; + + return $details; + } + + /** + * A Companion scan's status and findings with the scanner's own source + * left out (CompanionMalwareScanner::isOwnScannerSource). Scans recorded + * before that exemption worked flagged the scanner's signature list as + * malware; this only re-applies the exemption the scanner makes today. + * A scan whose stored findings are all kept (or that has none recorded, + * like factory or SiteCheck rows) keeps its stored status. + * + * @param \Closure(string): string $normalize + * @return array{status: ?string, findings: ?array} + */ + protected static function statusWithoutOwnScanner(SiteSecurityScan $scan, \Closure $normalize): array + { + $findings = is_array($scan->details) && is_array($scan->details['findings'] ?? null) ? $scan->details['findings'] : null; + if ($scan->scan_type !== SiteSecurityScan::TYPE_COMPANION_MALWARE || $findings === null || $findings === []) { + return ['status' => $scan->status, 'findings' => $findings]; + } + + $kept = array_values(array_filter($findings, fn ($f) => ! (is_array($f) && is_string($f['path'] ?? null) + && CompanionMalwareScanner::isOwnScannerSource($normalize($f['path']))))); + + return count($kept) === count($findings) + ? ['status' => $scan->status, 'findings' => $findings] + : ['status' => CompanionMalwareScanner::statusForFindings($kept), 'findings' => $kept]; + } + + /** + * The scan's status, except that a clean result from a scan that didn't + * cover the whole site is "incomplete", never "clean": Companion stopped + * at its file or time limit, or an SSH check hit its result cap + * (scan_aborted, either way). A partial scan that found something still + * counts as flagged. + * + * An SSH scan never knows how many files it read (scanned_files_count is + * always 0), so that count says nothing. Its checks can also run out of + * time, or stop at an odd file name, and still come back looking clean. + * Since the scanner makes every check prove it finished, it stores + * ssh_checks_verified on SSH scans; only a clean SSH scan with that set + * is complete. Older SSH rows (with or without truncated_checks) can't + * vouch for the whole site: those stay "incomplete". That's the safe + * reading for a report, and it only touches scans recorded before the + * change. + * + * @param ?array $details + */ + protected function malwareStatus(?SiteSecurityScan $scan, ?array $details, ?string $status = null): ?string + { + $status ??= $scan?->status; + if ($status === SiteSecurityScan::STATUS_CLEAN && $details !== null) { + $partial = ! empty($details['scan_aborted']) + || (($details['transport'] ?? null) === 'ssh' && empty($details['ssh_checks_verified'])); + + if ($partial) { + return self::MALWARE_INCOMPLETE; + } + } + + return $status; + } + + /** + * How many findings of each kind the scan made, most first. Kinds only: + * the paths stay on the scan record. + * + * @param ?array $details + * @return ?array + */ + protected static function findingKinds(?array $details): ?array + { + if ($details === null) { + return null; + } + + $kinds = []; + foreach ($details['findings'] as $finding) { + $kind = is_array($finding) ? trim((string) ($finding['kind'] ?? '')) : ''; + $kind = $kind !== '' ? $kind : 'unknown'; + $kinds[$kind] = ($kinds[$kind] ?? 0) + 1; + } + arsort($kinds); + + return $kinds; + } + + /** + * One entry per installed plugin with a known vulnerability, keyed by its + * file slug as the snapshot, the updater and update pauses know it: + * fixed_in is the highest version any of its advisories is patched in, + * or null when one has no fix yet; update_offered is the version + * WordPress offered at the snapshot; paused says why updates to it are + * on hold, read now because a later unpause can't change this report. + * + * @param list $vulnerabilities + * @return list + */ + protected function vulnerablePluginItems(Site $site, array $vulnerabilities): array + { + if ($vulnerabilities === []) { + return []; + } + + // The matcher names a plugin by its directory; key its advisories the + // same way, with the version, to find the snapshot entry they came from. + $advisories = []; + foreach ($vulnerabilities as $v) { + $advisories[$v['plugin_slug']."\0".$v['current_version']][] = $v['vulnerability']; + } + + $plugins = []; + foreach ((array) ($site->companion_snapshot['plugins']['plugins'] ?? []) as $plugin) { + $slug = is_array($plugin) ? (string) ($plugin['slug'] ?? '') : ''; + $key = explode('/', $slug, 2)[0]."\0".(string) ($plugin['version'] ?? ''); + if ($slug !== '' && isset($advisories[$key])) { + $plugins[$slug] = [$plugin, $advisories[$key]]; + } + } + + $pauses = $plugins === [] ? collect() : PluginUpdateIgnore::query() + ->where('site_id', $site->id) + ->where('target_kind', 'plugin') + ->whereIn('target_slug', array_keys($plugins)) + ->get() + ->keyBy('target_slug'); + + $items = []; + foreach ($plugins as $slug => [$plugin, $vulns]) { + [$fixedIn, $fixedAfter] = self::fixedVersion($vulns); + + $offered = trim((string) ($plugin['new_version'] ?? '')); + $pause = $pauses->get($slug); + + $items[] = [ + 'slug' => (string) $slug, + 'name' => (string) ($plugin['name'] ?? $slug), + 'version' => (string) ($plugin['version'] ?? ''), + 'fixed_in' => $fixedIn, + 'fixed_after' => $fixedAfter, + 'update_offered' => ! empty($plugin['update_available']) && $offered !== '' ? $offered : null, + 'paused' => $pause instanceof PluginUpdateIgnore ? [ + 'source' => (string) $pause->source, + 'since' => $pause->ignored_at?->toDateString(), + 'failure_count' => $pause->failure_count, + 'note' => $pause->note !== null && trim($pause->note) !== '' ? Str::limit(trim($pause->note), 120) : null, + ] : null, + ]; + } + + return $items; + } + + /** + * The lowest version clear of every advisory, as [fixed_in, null] (that + * version fixes them) or [null, fixed_after] (only versions after that + * one do), or [null, null] when one has no fix. An advisory names its + * patched version, or failing that its range does: "below 3.0" is fixed + * in 3.0, "up to and including 4.2.0" (or exactly 4.2.0) by whatever + * comes after 4.2.0. + * One open-ended ("all versions") with no patched version has no fix yet. + * + * @param list $vulns + * @return array{0: ?string, 1: ?string} + */ + protected static function fixedVersion(array $vulns): array + { + $best = null; + foreach ($vulns as $vuln) { + $patched = trim((string) $vuln->patched_in); + $bound = trim((string) $vuln->to_version); + // An advisory for exactly one version ("== X", stored from = to = X). + $exact = $bound !== '' && $bound !== '*' && trim((string) $vuln->from_version) === $bound; + $threshold = match (true) { + $patched !== '' => [$patched, false], + $bound !== '' && $bound !== '*' => [$bound, $exact || (bool) $vuln->to_inclusive], + default => null, + }; + if ($threshold === null) { + return [null, null]; + } + + $cmp = $best === null ? 1 : version_compare($threshold[0], $best[0]); + if ($cmp > 0 || ($cmp === 0 && $threshold[1] && ! $best[1])) { + $best = $threshold; + } + } + + return $best === null ? [null, null] : ($best[1] ? [null, $best[0]] : [$best[0], null]); + } + + /** + * Whether some transport can run `wp core verify-checksums` for this + * site, mirroring how WpCoreChecksumVerifier::verify() picks one: a host + * with server SSH needs a (monitored) server; one without uses Pressable's + * command API or the host's own command runner. Custom hosts have none. + */ + protected function hasChecksumTransport(Site $site): bool + { + try { + $host = $site->host(); + + if ($host->supports(HostingProvider::CAP_SSH)) { + return $site->server !== null && ! $site->server->is_ignored; + } + + return $site->isPressable() || $host->commandRunner() !== null; + } catch (Throwable) { + // A host whose module isn't enabled has no transport either. + return false; + } + } + + /** + * Evidence that lockouts from this site were turned into bans through the + * whole period, so a count of 0 bans is a measurement: it's in the + * lockout pull (a blocker reports its lockouts), sits on a server with a + * provisioned fail2ban jail, and that server's lockouts were pulled in + * the period's last day or after, so a pull that stopped mid-month can't + * vouch for the rest of it. A capability string alone proves nothing: + * the blocker can be switched off, or the site left out of every pull. + * Review-queue rows are no evidence either: they are the lockouts that + * did not become bans (see UNBANNED_LOCKOUT_STATUSES). + */ + protected function lockoutsIngested(Site $site, Carbon $start, Carbon $end): bool + { + $pulledAt = $site->server?->last_llar_pull_at; + + return (bool) $site->is_wordpress + && $this->reportsBlocks($site) + && $this->hasProvisionedJail($site) + && $pulledAt !== null + && $pulledAt->greaterThanOrEqualTo($end->copy()->subDay()); + } + + /** + * A ban that blocked nobody malicious: reversed as a false positive + * (decided_by false_positive_*), or lifted within + * BAN_FALSE_POSITIVE_MINUTES of being made. + */ + protected static function liftedAsFalsePositive(BlockedIp $ban): bool + { + if (str_starts_with((string) $ban->decided_by, 'false_positive')) { + return true; + } + + return $ban->banned_at !== null && $ban->unbanned_at !== null + && $ban->unbanned_at->lessThanOrEqualTo($ban->banned_at->copy()->addMinutes(self::BAN_FALSE_POSITIVE_MINUTES)); + } + + /** + * When a lockout from any site last reached Control (a ban from a + * lockout source) by $end, or null when none ever has. Lockouts arrive + * from across the fleet every day, so none for days means the feed + * stopped, even while the pull itself keeps running and stamping + * last_llar_pull_at. + */ + protected function lastIngestedLockout(Carbon $end): ?Carbon + { + $key = $end->toIso8601String(); + if (! array_key_exists($key, $this->lastIngestedLockout)) { + $this->lastIngestedLockout[$key] = BlockedIp::query() + ->whereIn('source', self::LOCKOUT_SOURCES) + ->where('banned_at', '<=', $end) + ->orderByDesc('banned_at') + ->first(['banned_at']) + ?->banned_at + ?->copy(); + } + + return $this->lastIngestedLockout[$key]; + } + + /** The site's server has Clockwork's fail2ban jail, so bans happen at the server. */ + protected function hasProvisionedJail(Site $site): bool + { + $server = $site->server; + + return $server !== null && ! $server->is_ignored && $server->clockwork_jail_provisioned_at !== null; + } + /** Gatekeeper (Companion) or Limit Login Attempts Reloaded sends this site's lockouts to Control. */ protected function reportsBlocks(Site $site): bool { @@ -524,11 +1488,12 @@ protected function compileForms(Site $site, Carbon $start, Carbon $end): array ->get(); // Only live runs actually send email, so only they say anything about - // delivery. Lab runs suppress the email and just prove the form accepts - // a submission. + // it, and even then only that the site reported the email as sent: + // nothing watches the inbox. Lab runs suppress the email and just + // prove the form accepts a submission. $live = $runs->where('mode', ContactFormTestRun::MODE_LIVE); $lab = $runs->where('mode', ContactFormTestRun::MODE_LAB); - $delivered = $live + $reportedSent = $live ->where('status', ContactFormTestRun::STATUS_SUCCESS) ->where('mail_outcome', ContactFormTestRun::MAIL_SENT) ->count(); @@ -536,24 +1501,61 @@ protected function compileForms(Site $site, Carbon $start, Carbon $end): array return [ 'total_synthetic_tests' => $runs->count(), 'live_tests' => $live->count(), - 'successful_tests' => $delivered, - 'pass_rate' => $live->isNotEmpty() ? round(($delivered / $live->count()) * 100, 1) : null, + 'successful_tests' => $reportedSent, + 'pass_rate' => $live->isNotEmpty() ? round(($reportedSent / $live->count()) * 100, 1) : null, 'lab_tests' => $lab->count(), - 'lab_accepted' => $lab->where('status', ContactFormTestRun::STATUS_SUCCESS)->count(), + // Whether the form plugin took the submission. A run's status is + // Companion answering at all, which it can do while the form refuses. + 'lab_accepted' => $lab->where('accepted', true)->count(), ]; } + /** + * The period's traffic, with a status saying whether the figures can go + * to the client: 'ok', 'partial' (days missing, or the last day not yet + * complete), 'suspect' (the logs see a proxy, or visits can't be real), + * 'no_data' (a host we collect traffic from, but nothing for the period) + * or 'unsupported' (a host we don't collect traffic from). Only 'ok' is + * shown; the others are hidden and, except 'unsupported', held for review. + */ protected function compileTraffic(Site $site, Carbon $start, Carbon $end): array { + // Half-open, so the last day matches however the date was written + // ("2026-10-31" or "2026-10-31 00:00:00"). $traffic = SiteTrafficDaily::query() ->where('site_id', $site->id) - ->whereBetween('date', [$start->toDateString(), $end->toDateString()]) + ->where('date', '>=', $start->toDateString()) + ->where('date', '<', $end->copy()->addDay()->toDateString()) ->get(); $visits = (int) $traffic->sum('visits'); $requests = (int) $traffic->sum('requests'); $bytes = (int) $traffic->sum('bytes_sent'); + // A day with no requests and no visits is a placeholder for a day the + // feed had nothing for, not a measurement. + $measured = $traffic->filter(fn (SiteTrafficDaily $day) => $day->requests > 0 || $day->visits > 0); + $periodDays = max(1, (int) round($start->diffInDays($end))); + $daysCovered = $measured->map(fn (SiteTrafficDaily $day) => $day->date->toDateString())->unique()->count(); + + $lastDay = $measured->first(fn (SiteTrafficDaily $day) => $day->date->toDateString() === $end->toDateString()); + $settledAt = $end->copy()->addDay()->startOfDay()->addHours(self::TRAFFIC_LAST_DAY_SETTLE_HOURS); + $lastDayComplete = $lastDay?->updated_at !== null && $lastDay->updated_at->greaterThanOrEqualTo($settledAt); + + $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); + + $status = match (true) { + // supportsTrafficReport() is the host's state now, so it only + // names an empty period; rows that exist are always reported on. + $measured->isEmpty() => $this->collectsTraffic($site) ? 'no_data' : 'unsupported', + $suspect => 'suspect', + $daysCovered < $periodDays - 1 || ! $lastDayComplete => 'partial', + default => 'ok', + }; + return [ 'has_traffic' => $traffic->isNotEmpty(), 'total_visits' => $visits, @@ -561,17 +1563,77 @@ protected function compileTraffic(Site $site, Carbon $start, Carbon $end): array // Some hosts' traffic feeds (Pressable) carry no byte counts; 0 bytes // for real requests means "not measured", not "nothing sent". 'bandwidth_mb' => $bytes > 0 ? round($bytes / (1024 * 1024), 1) : null, + 'status' => $status, + 'source' => $source, + 'days_covered' => $daysCovered, + 'period_days' => $periodDays, + 'last_day_complete' => $lastDayComplete, + // Internal: the share of requests from the two busiest IPs over the + // period, where the rows name them (access logs do, Pressable doesn't), + // and how many IPs that is (1 when every listed request came from one). + 'top_ip_share' => $topIpShare, + 'top_ip_count' => $topIpCount, ]; } + /** Whether this site's host is one we collect traffic from (its state now). */ + protected function collectsTraffic(Site $site): bool + { + try { + return $site->supportsTrafficReport(); + } catch (Throwable) { + // A host whose module isn't enabled isn't read either. + return false; + } + } + + /** + * The two busiest IPs' share of the requests on the days that list their + * busiest IPs, and how many IPs that share is from (one or two), or + * nulls when no day lists them. + * + * @param iterable $days + * @return array{0: ?float, 1: ?int} + */ + protected static function topIps(iterable $days): array + { + $hitsByIp = []; + $requests = 0; + + foreach ($days as $day) { + $ips = is_array($day->top_ips) ? array_filter($day->top_ips, fn ($row) => is_array($row) && isset($row['ip'])) : []; + if ($ips === []) { + continue; + } + + $requests += $day->requests; + foreach ($ips as $row) { + $hitsByIp[(string) $row['ip']] = ($hitsByIp[(string) $row['ip']] ?? 0) + (int) ($row['hits'] ?? 0); + } + } + + if ($requests <= 0) { + return [null, null]; + } + + rsort($hitsByIp); + $top = array_slice($hitsByIp, 0, 2); + + return [round(min(1.0, array_sum($top) / $requests), 3), count($top)]; + } + /** * Backups come only from site_backups — what each site's real source * (Pressable's API, SpinupWP's Spaces bucket, our S3 archives) reported, * recorded daily by clockwork:record-site-backups. A backup is only shown * if we saw it; "none found" and "couldn't check" are kept apart so the - * preview can flag them, and neither is shown to the client. + * preview can flag them, and neither is shown to the client. Nor are + * database backups far smaller than the database ('suspect'): a source + * can list an empty or failed dump every day. A relay site with no + * archive in the period whose relay was already failing by its end is + * 'failing', with the relay's error kept for the operator. */ - protected function compileBackups(Site $site, Carbon $start, Carbon $end): array + protected function compileBackups(Site $site, Carbon $start, Carbon $end, ?Carbon $joinedAt = null): array { $source = $this->backups->sourceFor($site); $windowStart = $end->copy()->subDays(self::BACKUP_LIST_DAYS - 1)->startOfDay()->max($start); @@ -619,6 +1681,24 @@ protected function compileBackups(Site $site, Carbon $start, Carbon $end): array ->min('backup_at'); $coversPeriod = $recordsFrom !== null && Carbon::parse($recordsFrom)->lessThan($start->copy()->addDay()); + // Our relay's S3 listing is the whole history (90-day retention), so + // its oldest archive is when archiving actually began, not where our + // records start. A relay site that was there from the period start + // (or from when it joined) had no off-site backup on the days before + // that: a gap for a person to see, not "records start here". + // Enrolment turns the relay on, so when the site joined stands in for + // when protection was expected; if the relay was switched on later, + // the report is held for nothing worse than a look. + $archivesBegan = null; + $expectedFrom = null; + if ($source === SiteBackup::SOURCE_S3_ARCHIVE && $site->backup_relay_enabled && $recordsFrom !== null) { + $expectedFrom = ($joinedAt ?? $start)->copy()->startOfDay()->max($start); + $first = Carbon::parse($recordsFrom); + if ($first->greaterThanOrEqualTo($expectedFrom->copy()->addDays(2)) && $first->lessThanOrEqualTo($end)) { + $archivesBegan = $first->toDateString(); + } + } + $check = $source === null ? null : SiteBackupCheck::query() ->where('site_id', $site->id) ->where('source', $source) @@ -626,38 +1706,141 @@ protected function compileBackups(Site $site, Carbon $start, Carbon $end): array $checkIsCurrent = $check !== null && $check->ok && $check->checked_at->greaterThanOrEqualTo(now()->subHours(self::BACKUP_CHECK_MAX_AGE_HOURS)); + // A source can report a backup every day that holds next to nothing: + // compare the newest database backup with the size Companion reports + // for the site's database. + $dbSize = $site->companion_snapshot['environment']['db_size_bytes'] ?? null; + $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); + + // 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 + // nothing (or nothing recent) to show. + $relayFailure = $source === SiteBackup::SOURCE_S3_ARCHIVE ? self::relayFailure($site, $end) : null; + $status = match (true) { $source === null => 'no_source', // Backups that stopped before the last 10 days are a problem to // look at, not something to show the client as fine. - $recent->isNotEmpty() => 'verified', + $recent->isNotEmpty() => $dbBackupTooSmall ? 'suspect' : 'verified', $days->isNotEmpty() => 'stale', + // No archive at all in the period, and the relay says why. + $relayFailure !== null => 'failing', $checkIsCurrent => 'none_found', default => 'not_checked', }; + // Pressable and our S3 relay back up every day. SpinupWP's schedule is + // set per site (often weekly), so it counts as daily only when the + // period has backups on consecutive days. The days the period has had + // so far, for a report on a month still running. + $daily = $source !== null && ($source !== SiteBackup::SOURCE_SPINUPWP_SPACES || self::hasConsecutiveDays($days->keys()->all())); + $periodDays = max(1, (int) round($start->diffInDays($end->copy()->min(now()->endOfDay())))); + // The days backups were expected: from when the site's coverage began + // (it was added, or its care plan switched on, part-way through). + $expectedDays = max(1, (int) round(($joinedAt ?? $start)->copy()->startOfDay()->max($start) + ->diffInDays($end->copy()->min(now()->endOfDay())))); + return [ 'enabled' => $source !== null, 'verified' => $status === 'verified', 'status' => $status, 'source' => $source, - 'destination' => match ($source) { - SiteBackup::SOURCE_PRESSABLE => 'your host, Pressable', - SiteBackup::SOURCE_SPINUPWP_SPACES => 'SpinupWP, stored in DigitalOcean Spaces', - SiteBackup::SOURCE_S3_ARCHIVE => 'independent off-site storage (Amazon S3)', - default => null, - }, + // The whole phrase, preposition included ("by your host, Pressable"). + 'destination' => $source !== null ? (ReportPhrases::BACKUP_DESTINATIONS[$source] ?? null) : null, 'days_with_backups' => $days->count(), 'covers_period' => $coversPeriod, + // Internal, for the review flags: whether the source backs up every + // day, and how many days of the period have passed. + 'daily' => $daily, + 'period_days' => $periodDays, + 'expected_days' => $expectedDays, 'records_from' => $recordsFrom !== null ? Carbon::parse($recordsFrom)->toDateString() : null, + // Internal: when a relay site's off-site archives first began, + // inside the period, and when they should have (the period start, + // or when the site joined); null when there's no such gap. + 'archives_began' => $archivesBegan, + 'expected_from' => $archivesBegan !== null ? $expectedFrom?->toDateString() : null, 'last_backup_at' => $inPeriod->first()?->backup_at->toDateString(), 'list_from' => $windowStart->toDateString(), 'recent' => $recent->all(), '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_size_bytes' => $dbSize, + // 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. + 'relay_failing_since' => $relayFailure['since'] ?? null, + 'relay_error' => $relayFailure['error'] ?? null, ]; } + /** + * The backup relay's current failure streak, when it began on or before + * the period end (a streak that began later says nothing about the + * period). A successful archive clears it, so this is the state now. + * + * @return ?array{since: string, error: ?string} + */ + protected static function relayFailure(Site $site, Carbon $end): ?array + { + $since = $site->backup_relay_failing_since; + if ($since === null || $since->greaterThan($end)) { + return null; + } + + return [ + 'since' => $since->toIso8601String(), + 'error' => self::relayErrorSummary($site->backup_relay_last_error), + ]; + } + + /** + * A relay error short enough for a review reason. The recorder stores + * what the archive attempt threw; a signed storage URL can carry + * credentials in its query string, so query strings and fragments are + * stripped from every URL (and secrets masked) before it's kept here. + */ + public static function relayErrorSummary(?string $error): ?string + { + $error = trim((string) $error); + if ($error === '') { + return null; + } + + $error = UpgraderOutput::redactText($error); + // redactText only knows http(s)/ftp URLs; any scheme loses its query. + $error = preg_replace('~\b([a-z][a-z0-9+.\-]*://[^\s?#]*)[?#]\S*~i', '$1', $error) ?? ''; + $error = trim(preg_replace('/\s+/u', ' ', $error) ?? ''); + + return $error === '' ? null : Str::limit($error, self::RELAY_ERROR_MAX_LENGTH); + } + + /** A database backup this much smaller than the database is empty or broken. */ + public static function backupTooSmall(int $backupBytes, int $dbBytes): bool + { + return $backupBytes < $dbBytes * self::BACKUP_MIN_DB_RATIO + || ($backupBytes < self::BACKUP_MIN_DB_BYTES && $dbBytes > self::BACKUP_SIZEABLE_DB_BYTES); + } + + /** @param list $dates Y-m-d */ + protected static function hasConsecutiveDays(array $dates): bool + { + sort($dates); + + for ($i = 1; $i < count($dates); $i++) { + if (Carbon::parse($dates[$i - 1])->addDay()->toDateString() === $dates[$i]) { + return true; + } + } + + return false; + } + protected function compileWorkLog(Site $site, Carbon $start, Carbon $end): array { $entries = SiteWorkLog::query() diff --git a/modules/ClientReports/src/Services/ReportBrowser.php b/modules/ClientReports/src/Services/ReportBrowser.php new file mode 100644 index 00000000..77acbe18 --- /dev/null +++ b/modules/ClientReports/src/Services/ReportBrowser.php @@ -0,0 +1,160 @@ + */ + public function query(): Builder + { + return ClientReport::query() + ->select(self::LIGHT_COLUMNS) + ->addSelect('sections_data->meta as report_meta') + ->with([ + // A report outlives its site's archiving; keep the domain readable. + 'site' => fn ($q) => $q->withoutGlobalScopes()->select(['id', 'domain', 'client_id', 'care_plan_enabled', 'is_inactive', 'archived_at']), + 'site.client:id,name,company_name', + 'schedule:id,site_id,delivery_mode,last_sent_at,is_enabled,recipients', + ]); + } + + /** + * @param Builder $query + * @return EloquentCollection + */ + public function get(Builder $query): EloquentCollection + { + $reports = $query->get(); + foreach ($reports as $report) { + $meta = json_decode((string) $report->getAttribute('report_meta'), true); + $attributes = Arr::except($report->getAttributes(), 'report_meta'); + $attributes['sections_data'] = json_encode(['meta' => is_array($meta) ? $meta : []]); + $report->setRawAttributes($attributes, true); + } + + return $reports; + } + + /** + * Months that have reports, newest first ("2026-10" => count of live reports). + * + * @return array + */ + public function periods(): array + { + $counts = []; + ClientReport::query() + ->where('status', '!=', ClientReport::STATUS_SUPERSEDED) + ->pluck('period_start') + ->each(function ($start) use (&$counts) { + $key = Carbon::parse($start)->format('Y-m'); + $counts[$key] = ($counts[$key] ?? 0) + 1; + }); + krsort($counts); + + return $counts; + } + + public function latestPeriod(): ?string + { + return array_key_first($this->periods()); + } + + /** + * Every report the filters match, in list order: newest period first, + * then by site. + * + * @return Collection + */ + public function list(ReportListFilters $filters): Collection + { + $query = $this->query(); + + if ($filters->period !== null && $filters->period !== ReportListFilters::ALL) { + $month = Carbon::createFromFormat('!Y-m', $filters->period); + $query->where('period_start', '>=', $month->toDateString()) + ->where('period_start', '<', $month->copy()->addMonthNoOverflow()->toDateString()); + } + + if (! $filters->showSuperseded && $filters->status !== ReportState::SUPERSEDED) { + $query->where('status', '!=', ClientReport::STATUS_SUPERSEDED); + } + + if ($filters->client === ReportListFilters::UNASSIGNED) { + $query->whereHas('site', fn ($q) => $q->withoutGlobalScopes()->whereNull('client_id')); + } elseif ($filters->client !== null) { + $query->whereHas('site', fn ($q) => $q->withoutGlobalScopes()->where('client_id', (int) $filters->client)); + } + + $reports = $this->get($query)->toBase(); + + if ($filters->q !== '') { + $needle = Str::lower($filters->q); + $reports = $reports->filter(fn (ClientReport $r) => Str::contains(Str::lower(implode(' ', array_filter([ + $r->site?->domain, $r->title, $r->client_name, $r->client_email, + $r->site?->client?->name, $r->site?->client?->company_name, + ]))), $needle)); + } + + if ($filters->status !== null && $filters->status !== ReportListFilters::ALL) { + $reports = $reports->filter(fn (ClientReport $r) => $filters->status === ReportListFilters::WAITING + ? in_array(ReportState::of($r), ReportState::WAITING, true) + : ReportState::of($r) === $filters->status); + } + + if ($filters->category !== null) { + $reports = $reports->filter(fn (ClientReport $r) => in_array($filters->category, ReviewReasonCategories::forReasons(ReportState::reasons($r)), true)); + } + + return $reports + ->sort(fn (ClientReport $a, ClientReport $b) => [$b->period_start->format('Y-m-d'), Str::lower((string) $a->site?->domain), $b->id] + <=> [$a->period_start->format('Y-m-d'), Str::lower((string) $b->site?->domain), $a->id]) + ->values(); + } + + /** + * How many reports are in each state, for the summary strip. + * + * @param Collection $reports + * @return array + */ + public function summarize(Collection $reports): array + { + $counts = array_fill_keys(array_keys(ReportState::options()), 0); + foreach ($reports as $report) { + $state = ReportState::of($report); + $counts[$state] = ($counts[$state] ?? 0) + 1; + } + $counts['total'] = $reports->count(); + + return $counts; + } +} diff --git a/modules/ClientReports/src/Services/ReportRecipientsSyncService.php b/modules/ClientReports/src/Services/ReportRecipientsSyncService.php index ad12fe01..658aa835 100644 --- a/modules/ClientReports/src/Services/ReportRecipientsSyncService.php +++ b/modules/ClientReports/src/Services/ReportRecipientsSyncService.php @@ -58,10 +58,13 @@ public function resolvePayloadForSite(Site $site, ?ClientReportSchedule $schedul $fallbackRecipients = $this->sanitizeEmails([$site->client_email]); } + // No schedule means no report goes out, whatever the care plan says, + // so WordPress must not show reports as active. The client's + // addresses are still offered as the recipients to start from. return [ 'report_recipients' => $fallbackRecipients, 'report_frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, - 'report_enabled' => (bool) $site->isCarePlanActive(), + 'report_enabled' => false, 'report_last_sent_at' => null, 'report_recipients_updated_at' => now()->toIso8601String(), ]; @@ -232,12 +235,22 @@ public function reconcileFromSnapshot(Site $site, array $snapshotPayload): void } // Case 2: WordPress has never been initialized with recipients yet, - // and Control has a schedule (or client recipients on an active care plan). + // and Control has a schedule (or client recipients to offer, pushed + // as not active, since without a schedule nothing sends). if ($updatedAtRaw === null && $wpRecipients === []) { $resolved = $this->resolvePayloadForSite($site, $schedule); if ($schedule !== null || ! empty($resolved['report_recipients'])) { $this->pushToSite($site, $schedule); } + + return; + } + + // Case 3: WordPress shows reports as active, set by Control (an + // earlier push that took a care plan for a schedule), but Control + // has no schedule, so nothing sends: tell WordPress so. + if ($schedule === null && ($cn['report_enabled'] ?? false) === true) { + $this->pushToSite($site); } } diff --git a/modules/ClientReports/src/Services/ReportReviewFlags.php b/modules/ClientReports/src/Services/ReportReviewFlags.php index 0879374b..862deb79 100644 --- a/modules/ClientReports/src/Services/ReportReviewFlags.php +++ b/modules/ClientReports/src/Services/ReportReviewFlags.php @@ -2,19 +2,33 @@ namespace Modules\ClientReports\Services; +use App\Models\SiteBackup; +use App\Models\SiteSecurityScan; +use App\Services\Security\CompanionMalwareScanner; +use Illuminate\Support\Carbon; +use Modules\ClientReports\Support\ReportPhrases; + /** * Things in a compiled report that a person should see before the client * does. An auto schedule whose report has any of these is held as a draft * instead of being emailed; a clean report still sends itself. * - * Plugin vulnerabilities are deliberately not here: about a third of sites - * have one at any time, and holding every one of those reports would turn - * "review the exceptions" back into "review everything". + * A plugin vulnerability on its own doesn't hold a report: about a third of + * sites have one at any time, and holding every one of those reports would + * turn "review the exceptions" back into "review everything". One that an + * update waiting at the end of the month would have fixed, or whose updates + * are paused, means the updates aren't going through, so that does. + * + * These are plain functions of the stored sections_data, so anything they + * need (a plugin's pause, a scan's finding kinds) is captured at compile time. */ class ReportReviewFlags { private const BAD_SCAN_STATUSES = ['warning', 'issues_found']; + /** An outage at least this long holds the report so a person can add context. */ + public const LONG_OUTAGE_MINUTES = 60; + /** * @param array $data a report's sections_data * @return list @@ -24,32 +38,74 @@ public static function reasons(array $data): array $reasons = []; $security = (array) ($data['security'] ?? []); - if (in_array($security['malware_status'] ?? null, self::BAD_SCAN_STATUSES, true)) { - $reasons[] = 'The malware scan flagged something.'; + // A site enrolled part-way through the month, or whose care plan was + // switched on then, was only looked after from then. + $coverageStart = ReportPhrases::monitoringBeganOn($data['meta']['coverage_start'] ?? null); + if ($coverageStart !== null) { + $began = ($data['meta']['coverage_source'] ?? null) === 'care_plan' + ? "The care plan was switched on {$coverageStart}" + : "Monitoring began {$coverageStart}"; + $reasons[] = ReportPhrases::beganAfterPeriod($data['meta']['coverage_start'], $data) + ? "{$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."; } + // 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; + if (ReportPhrases::laterDay($uptimeSince, $data['meta']['coverage_start'] ?? null)) { + $began = ReportPhrases::monitoringBeganOn($uptimeSince); + $reasons[] = ReportPhrases::monitoringBeganAfterPeriod($data) + ? "Uptime monitoring began {$began}, after this period, so the Uptime section was left out." + : "Uptime monitoring began {$began}, so uptime covers only part of the month."; + } + + array_push($reasons, ...self::malwareReasons($security)); if (in_array($security['checksum_status'] ?? null, self::BAD_SCAN_STATUSES, true)) { $reasons[] = 'WordPress core files differ from the official release.'; } + // A host we can check, but no check ran: the client would read "Not + // checked this month". Hosts with no way to check leave the row out. + if (($security['checksum_supported'] ?? false) === true && array_key_exists('checksum_status', $security) && $security['checksum_status'] === null) { + $reasons[] = 'No WordPress core-file check ran this month, although this host supports one, so the client would read "Not checked this month".'; + } if (in_array($security['blacklist_status'] ?? null, self::BAD_SCAN_STATUSES, true)) { $reasons[] = 'The site is on a spam or malware blocklist.'; } + // Lockouts that never became a ban (security.unbanned_lockouts) hide + // the blocked-visitors count, and the operator preview says why. They + // don't hold the report: with the count hidden the email is accurate, + // and a ban backlog is an ops problem that would otherwise hold the + // same sites every month. + // A known hole with a fix waiting means an update isn't going through. - $vulnerableAndWaiting = array_values(array_intersect( - (array) ($security['vulnerable_plugins'] ?? []), - (array) ($data['updates']['pending_plugins'] ?? []), - )); - if ($vulnerableAndWaiting !== []) { - $reasons[] = 'A plugin with a known vulnerability has an update waiting: '.implode(', ', array_unique($vulnerableAndWaiting)).'.'; + array_push($reasons, ...self::vulnerablePluginReasons($security, (array) ($data['updates'] ?? []))); + + if (isset($data['backups'])) { + array_push($reasons, ...self::backupReasons((array) $data['backups'])); } - $backupStatus = $data['backups']['status'] ?? null; - if (isset($data['backups']) && in_array($backupStatus, ['none_found', 'stale', 'not_checked'], true)) { - $reasons[] = match ($backupStatus) { - 'none_found' => 'No backups were found this month.', - 'stale' => 'Backups stopped before the last 10 days of the month.', - default => 'Backups could not be checked.', - }; + // Missing days, a proxy's view or a feed with nothing in it: hidden + // from the client, so say why. A host we don't collect traffic from + // has nothing to check. + $trafficProblem = isset($data['traffic']) && in_array($data['traffic']['status'] ?? null, ['partial', 'suspect', 'no_data'], true) + ? ReportPhrases::trafficProblem((array) $data['traffic']) + : null; + if ($trafficProblem !== null) { + $reasons[] = ucfirst($trafficProblem).', so the Traffic section was left out.'; + } + + // The client would read "accepted 18 of 19" or "reported sending ... 75%" with no + // explanation, so a person should look at the failed tests first. + $forms = (array) ($data['forms'] ?? []); + $labTests = (int) ($forms['lab_tests'] ?? 0); + $labAccepted = (int) ($forms['lab_accepted'] ?? 0); + if ($labTests > $labAccepted) { + $reasons[] = "Contact-form checks failed: the forms accepted {$labAccepted} of {$labTests} automated check submissions this month."; + } + $liveTests = (int) ($forms['live_tests'] ?? 0); + if ($liveTests > 0 && (float) ($forms['pass_rate'] ?? 0) < 100) { + $reasons[] = 'Not every live contact-form test reported its email as sent: '.(float) ($forms['pass_rate'] ?? 0)."% of {$liveTests} test submissions did."; } if (! empty($data['uptime']['probe_stopped'])) { @@ -62,13 +118,519 @@ public static function reasons(array $data): array $reasons[] = 'Companion '.($since ? "hasn't reported since {$since}" : 'has no snapshot date').', so waiting updates and plugin vulnerabilities are out of date.'; } + // The client would read "No updates were applied this month" with no + // explanation; something was waiting, so a person should see why + // (failing, paused, or a site-wide pause) first. + $noUpdatesReason = self::noUpdatesAppliedReason((array) ($data['updates'] ?? [])); + if ($noUpdatesReason !== null) { + $reasons[] = $noUpdatesReason; + } + + // An update that ran and was then undone (the site is back on the + // older version) isn't listed; a person should know why, and whether + // the client saw anything of it. + $notKeptReason = self::notKeptReason((array) ($data['updates'] ?? [])); + if ($notKeptReason !== null) { + $reasons[] = $notKeptReason; + } + + $stillDown = false; foreach ((array) ($data['uptime']['outages'] ?? []) as $outage) { if (! empty($outage['ongoing'])) { $reasons[] = 'The site was still down at the end of the month.'; + $stillDown = true; break; } } + // A long outage that ended reads to the client as a bare figure; a + // person should say what happened first (one still open has its + // own reason above). + $longOutages = self::longOutagesReason((array) ($data['uptime'] ?? [])); + if ($longOutages !== null) { + $reasons[] = $longOutages; + } + + // 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; + if (! $stillDown && is_string($blockedSince) && $blockedSince !== '') { + $reasons[] = 'The uptime monitor was blocked (HTTP 429) from '.ReportPhrases::time($blockedSince).'; the site was not necessarily down.'; + } + return $reasons; } + + /** + * Backups the client won't see (none, stopped, unchecked, implausibly + * small, or our relay failing, with its error), and a daily source that + * backed up on far fewer days than the period had, which the client + * would read as "backed up on 12 days". + * + * @param array $backups + * @return list + */ + private static function backupReasons(array $backups): array + { + $status = $backups['status'] ?? null; + $relayFailing = self::relayFailing($backups); + + $reason = match ($status) { + 'none_found' => 'No backups were found this month.', + 'stale' => 'Backups stopped before the last 10 days of the month.'.($relayFailing !== null ? ' '.$relayFailing : ''), + 'failing' => $relayFailing ?? 'Off-site backups were failing at the end of the month.', + 'not_checked' => 'Backups could not be checked.', + 'suspect' => is_numeric($backups['db_backup_bytes'] ?? null) && is_numeric($backups['db_size_bytes'] ?? null) + ? 'Database backups are '.ReportPhrases::bytes((int) $backups['db_backup_bytes']) + .' but the database is '.ReportPhrases::bytes((int) $backups['db_size_bytes']) + .', so they look empty or broken and the Backups section was left out.' + : 'Database backups are far smaller than the database, so they look empty or broken and the Backups section was left out.', + default => null, + }; + if ($reason !== null) { + return [$reason]; + } + + $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. + $periodDays = (int) ($backups['expected_days'] ?? $backups['period_days'] ?? 0); + + // Our relay's archives began part-way through the month for a site + // that was there before: the days before had no off-site backup. + $began = is_string($backups['archives_began'] ?? null) ? $backups['archives_began'] : null; + 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)."]; + } + + 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 []; + } + + /** + * Why nothing was applied although updates were waiting, or null. Counted + * from the rows the email lists (plugins, themes, core): translations + * count toward the total but aren't listed, so the email can say "No + * updates were applied" while the total is above 0. Only updates known + * to be waiting count (named in the snapshot, or applied after the + * period): a site with no snapshot says nothing either way, a stale + * snapshot has its own reason, and an updates section without items + * (older or partial data) says nothing. + * + * @param array $updates + */ + private static function noUpdatesAppliedReason(array $updates): ?string + { + if (! array_key_exists('items', $updates) || ! empty($updates['snapshot_stale'])) { + return null; + } + + $applied = (int) ($updates['plugins_count'] ?? 0) + (int) ($updates['themes_count'] ?? 0) + (int) ($updates['core_count'] ?? 0); + $waiting = array_values(array_filter(array_merge( + (array) ($updates['pending_plugins'] ?? []), + (array) ($updates['pending_themes'] ?? []), + ($updates['pending_core'] ?? false) === true ? ['WordPress core'] : [], + ), fn ($name) => is_string($name) && trim($name) !== '')); + $knownWaiting = $waiting !== [] || (int) ($updates['updated_since_period'] ?? 0) > 0; + + if ($applied > 0 || ! $knownWaiting || ! ClientReportCompiler::updatesWereWaiting($updates)) { + return null; + } + + $named = array_slice($waiting, 0, 5); + $list = $waiting === [] ? '' : ': '.implode(', ', $named).(count($waiting) > 5 ? ' and '.(count($waiting) - 5).' more' : ''); + + return 'No updates were applied this month although updates were waiting'.$list + .', so the client would read "No updates were applied this month". Check why they are failing or paused.'; + } + + /** + * The completed outages of LONG_OUTAGE_MINUTES or more, with when each + * began and how long it lasted, or null when there were none. Reports + * compiled before long_outages was kept are read from their (capped) + * outages list. + * + * @param array $uptime + */ + private static function longOutagesReason(array $uptime): ?string + { + $outages = array_key_exists('long_outages', $uptime) ? (array) $uptime['long_outages'] : array_reverse((array) ($uptime['outages'] ?? [])); + + $items = []; + foreach ($outages as $outage) { + $minutes = is_array($outage) ? (int) ($outage['minutes'] ?? 0) : 0; + if ($minutes < self::LONG_OUTAGE_MINUTES || ! empty($outage['ongoing']) || ! is_string($outage['started_at'] ?? null)) { + continue; + } + + $items[] = preg_replace('/^Down /', '', ReportPhrases::outageStart($outage)).' for '.ReportPhrases::duration($minutes); + } + + return $items === [] + ? null + : 'The site was down for an hour or more: '.implode('; ', $items).'. Add context for the client before this goes out.'; + } + + /** + * The updates left out of the list because they were undone after they + * ran (updates.not_kept), or null when there were none. + * + * @param array $updates + */ + private static function notKeptReason(array $updates): ?string + { + $items = []; + foreach ((array) ($updates['not_kept'] ?? []) as $item) { + if (! is_array($item) || trim((string) ($item['label'] ?? '')) === '') { + continue; + } + + $when = array_filter([ + is_string($item['date'] ?? null) && $item['date'] !== '' ? Carbon::parse($item['date'])->format('M j') : null, + trim((string) ($item['installed_version'] ?? '')) !== '' ? 'the site is on '.trim((string) $item['installed_version']).' now' : null, + ]); + $items[] = trim((string) $item['label']).($when !== [] ? ' ('.implode('; ', $when).')' : ''); + } + + if ($items === []) { + return null; + } + + return (count($items) === 1 + ? 'An update was undone after it ran, so it is left out of the updates list: ' + : 'Updates were undone after they ran, so they are left out of the updates list: ') + .implode('; ', $items).'. Add context for the client if they noticed.'; + } + + /** + * "Off-site backups have been failing since Oct 3, 2026: ", from + * the relay's failure streak the compiler kept, or null when there was + * none by the end of the period. The error was already trimmed and + * stripped of URL query strings at compile time; it's re-checked here + * because this text is shown wherever the review reasons are. + * + * @param array $backups + */ + private static function relayFailing(array $backups): ?string + { + $since = ReportPhrases::date($backups['relay_failing_since'] ?? null); + if ($since === null) { + return null; + } + + $error = ClientReportCompiler::relayErrorSummary(is_string($backups['relay_error'] ?? null) ? $backups['relay_error'] : null); + if ($error === null) { + return "Off-site backups have been failing since {$since}."; + } + + return "Off-site backups have been failing since {$since}: {$error}".(preg_match('/[.!?…]$/u', $error) ? '' : '.'); + } + + /** + * Whether finding kinds (kind => count) are all review-tier: ones the + * scanner grades as "warning" (PHP in uploads, a recently changed + * wp-config, the dead /e modifier), never a malware signature. The + * scanner's own grading decides, so the two can't disagree. + */ + public static function reviewTierKinds(mixed $kinds): bool + { + if (! is_array($kinds) || $kinds === []) { + return false; + } + + $findings = array_map(fn ($kind) => ['kind' => (string) $kind], array_keys($kinds)); + + return CompanionMalwareScanner::statusForFindings($findings) === SiteSecurityScan::STATUS_WARNING; + } + + /** + * Whether a person has reviewed the malware findings the report shows: + * the latest scan is a "warning" with review-tier findings only, and + * security.malware_reviewed was recorded for that same scan and the + * same findings. A newer scan, or different findings, needs a new look. + * + * @param array $security sections_data['security'] + */ + public static function malwareReviewed(array $security): bool + { + $mark = $security['malware_reviewed'] ?? null; + $kinds = $security['malware_finding_kinds'] ?? null; + + return is_array($mark) + && ($security['malware_status'] ?? null) === SiteSecurityScan::STATUS_WARNING + && isset($security['malware_scan_id'], $mark['scan_id']) + && (int) $mark['scan_id'] === (int) $security['malware_scan_id'] + && self::reviewTierKinds($kinds) + && self::sameKinds($mark['finding_kinds'] ?? null, $kinds); + } + + /** + * Whether a report's malware findings can be marked reviewed: the latest + * scan is a "warning" with review-tier findings only (never suspected + * malware), not already marked. + * + * @param array $data a report's sections_data + */ + public static function canMarkMalwareReviewed(array $data): bool + { + $security = (array) ($data['security'] ?? []); + + return ($security['malware_status'] ?? null) === SiteSecurityScan::STATUS_WARNING + && isset($security['malware_scan_id']) + && self::reviewTierKinds($security['malware_finding_kinds'] ?? null) + && ! self::malwareReviewed($security); + } + + /** + * The report's sections_data with its malware findings marked reviewed + * ($mark: reviewed_by, reviewed_by_id, reviewed_at, note), tied to the + * scan and findings it shows, and the review reasons that clears taken + * out of meta.review_reasons; the others (and the first-report reason) + * stay. + * + * @param array $data + * @param array $mark + * @return array + */ + public static function withMalwareReviewed(array $data, array $mark): array + { + $before = self::reasons($data); + $data['security']['malware_reviewed'] = [ + 'scan_id' => (int) $data['security']['malware_scan_id'], + 'finding_kinds' => (array) $data['security']['malware_finding_kinds'], + ...$mark, + ]; + $cleared = array_diff($before, self::reasons($data)); + + // A reason stored under older wording still names the findings to review. + $data['meta']['review_reasons'] = array_values(array_filter( + (array) ($data['meta']['review_reasons'] ?? []), + fn ($reason) => ! in_array($reason, $cleared, true) && ! str_starts_with((string) $reason, 'The malware scan flagged files to review'), + )); + + return $data; + } + + /** Whether two kind => count lists are the same, in any order. */ + private static function sameKinds(mixed $a, mixed $b): bool + { + if (! is_array($a) || ! is_array($b)) { + return false; + } + + $normalize = function (array $kinds): array { + $kinds = array_map('intval', $kinds); + ksort($kinds); + + return $kinds; + }; + + return $normalize($a) === $normalize($b); + } + + /** + * The latest scan's verdict, with its finding kinds and scan id so the + * reviewer knows what to open, and any malware an earlier scan in the + * period found that the latest one no longer does. Review-tier findings + * a person has marked reviewed (malwareReviewed) no longer hold it. + * + * @param array $security + * @return list + */ + private static function malwareReasons(array $security): array + { + $reasons = []; + $status = $security['malware_status'] ?? null; + $scan = isset($security['malware_scan_id']) ? ' (scan #'.(int) $security['malware_scan_id'].')' : ''; + $kinds = self::findingKinds($security['malware_finding_kinds'] ?? null); + $stoppedEarly = ! empty($security['malware_scan_aborted']) ? ' The scan also stopped before it finished.' : ''; + + if ($status === 'warning' && self::malwareReviewed($security)) { + // A person checked these findings: nothing more to hold for. + } elseif ($status === 'warning' && $kinds !== '') { + $reasons[] = "The malware scan flagged files to review: {$kinds}{$scan}.{$stoppedEarly}"; + } elseif ($status === 'issues_found' && $kinds !== '') { + $reasons[] = "The malware scan found suspected malware: {$kinds}{$scan}.{$stoppedEarly}"; + } elseif (in_array($status, self::BAD_SCAN_STATUSES, true)) { + $reasons[] = "The malware scan flagged something{$scan}."; + } elseif ($status === ClientReportCompiler::MALWARE_INCOMPLETE) { + $reasons[] = self::partialScanReason($security, $scan); + } + + if (($security['malware_worst_in_period'] ?? null) === 'issues_found' && $status !== 'issues_found') { + $days = (int) ($security['malware_issue_days'] ?? 0); + $last = $security['malware_last_issue_at'] ?? null; + $when = array_filter([ + $days > 0 ? 'on '.$days.' '.($days === 1 ? 'day' : 'days') : null, + is_string($last) && $last !== '' ? "last on {$last}" : null, + ]); + $reasons[] = 'An earlier malware scan this month flagged malware'.($when !== [] ? ' ('.implode(', ', $when).')' : '') + .', but the latest scan did not: check it was cleaned up.'; + } + + return $reasons; + } + + /** @param array $security */ + private static function partialScanReason(array $security, string $scan): string + { + $outcome = ', so the client sees "Partial scan" instead of an all-clear.'; + + if (($security['malware_transport'] ?? null) === 'ssh') { + $names = fn (string $key) => array_values(array_filter((array) ($security[$key] ?? []), fn ($c) => is_string($c) && $c !== '')); + $unfinished = $names('malware_incomplete_checks'); + if ($unfinished !== []) { + return "The latest malware scan{$scan} ran over SSH and some checks didn't finish (".implode(', ', $unfinished).'); they timed out or were cut off, so they can\'t vouch for the files they didn\'t list'.$outcome; + } + + // A complete SSH scan isn't partial, so one that didn't stop early + // was recorded before SSH scans proved each check finished. + if (empty($security['malware_scan_aborted'])) { + return "The latest malware scan{$scan} ran over SSH before scans recorded whether every check finished; it can't vouch for the whole site{$outcome}"; + } + + $checks = $names('malware_truncated_checks'); + + return "The latest malware scan{$scan} ran over SSH and hit its result limit".($checks !== [] ? ' ('.implode(', ', $checks).')' : '') + .'; files past the limit were not checked'.$outcome; + } + + $files = (int) ($security['malware_scanned_files'] ?? 0); + $detail = array_filter([ + is_string($security['malware_abort_reason'] ?? null) && $security['malware_abort_reason'] !== '' ? 'limit: '.$security['malware_abort_reason'] : null, + $files > 0 ? number_format($files).' files checked' : null, + ]); + + return "The latest malware scan{$scan} stopped before it finished".($detail !== [] ? ' ('.implode(', ', $detail).')' : '').$outcome; + } + + /** "php_in_uploads ×4, wp_config_recently_modified ×1", or '' when there are none. */ + private static function findingKinds(mixed $kinds): string + { + if (! is_array($kinds)) { + return ''; + } + + $parts = []; + foreach ($kinds as $kind => $count) { + $parts[] = $kind.' ×'.(int) $count; + } + + return implode(', ', $parts); + } + + /** + * Reports compiled with vulnerable_plugin_items join them to the waiting + * updates by file slug: hold when the update waiting reaches the version + * that fixes the plugin, or when its updates are paused. Older reports + * only kept names, so they're matched by name. + * + * @param array $security + * @param array $updates + * @return list + */ + private static function vulnerablePluginReasons(array $security, array $updates): array + { + if (! array_key_exists('vulnerable_plugin_items', $security)) { + $vulnerableAndWaiting = array_values(array_unique(array_intersect( + (array) ($security['vulnerable_plugins'] ?? []), + (array) ($updates['pending_plugins'] ?? []), + ))); + + return $vulnerableAndWaiting !== [] + ? ['A plugin with a known vulnerability has an update waiting: '.implode(', ', $vulnerableAndWaiting).'.'] + : []; + } + + // What was waiting at the end of the period, by slug. Without that + // list (a stale snapshot, or no updates section), use what the + // plugin's own entry recorded as offered. + $pending = $updates['pending_plugin_items'] ?? null; + $offeredBySlug = null; + if (is_array($pending)) { + $offeredBySlug = []; + foreach ($pending as $p) { + if (is_array($p) && (string) ($p['slug'] ?? '') !== '') { + $offeredBySlug[(string) $p['slug']] = $p['new_version'] ?? null; + } + } + } + + $waiting = []; + $paused = []; + foreach ((array) $security['vulnerable_plugin_items'] as $item) { + if (! is_array($item)) { + continue; + } + + $slug = (string) ($item['slug'] ?? ''); + $label = trim(trim((string) ($item['name'] ?? '')) ?: $slug).(($item['version'] ?? '') !== '' ? ' '.$item['version'] : ''); + $fixedIn = is_string($item['fixed_in'] ?? null) && $item['fixed_in'] !== '' ? $item['fixed_in'] : null; + // No patched version named, but the affected range is closed: + // any version after its upper bound is clear. + $fixedAfter = $fixedIn === null && is_string($item['fixed_after'] ?? null) && $item['fixed_after'] !== '' ? $item['fixed_after'] : null; + $offered = match (true) { + $offeredBySlug === null => $item['update_offered'] ?? null, + array_key_exists($slug, $offeredBySlug) => $offeredBySlug[$slug] ?? $item['update_offered'] ?? null, + default => null, + }; + $fix = match (true) { + $fixedIn !== null => "fixed in {$fixedIn}", + $fixedAfter !== null => "fixed in the next version after {$fixedAfter}", + default => 'no fixed version yet', + }; + $offeredFixes = $offered && ( + ($fixedIn !== null && version_compare((string) $offered, $fixedIn, '>=')) + || ($fixedAfter !== null && version_compare((string) $offered, $fixedAfter, '>')) + ); + + if (is_array($item['paused'] ?? null)) { + $paused[] = "{$label} ({$fix}; ".self::pause($item['paused']).($offered ? "; {$offered} is waiting" : '').')'; + } elseif ($offeredFixes) { + $waiting[] = "{$label} → {$offered} ({$fix})"; + } + } + + $reasons = []; + if ($waiting !== []) { + $reasons[] = "A plugin with a known vulnerability has an update waiting that fixes it, but it wasn't applied: ".implode('; ', $waiting).'.'; + } + if ($paused !== []) { + $reasons[] = 'Updates are paused for a plugin with a known vulnerability: '.implode('; ', $paused).'.'; + } + + return $reasons; + } + + /** + * "auto-paused on 2026-10-30 after 3 failed attempts" or "paused by hand since 2026-09-01". + * + * @param array $pause + */ + private static function pause(array $pause): string + { + $since = is_string($pause['since'] ?? null) && $pause['since'] !== '' ? $pause['since'] : null; + + // The update offered is a different package that shares the slug. + if (($pause['source'] ?? null) === 'identity_mismatch') { + return 'update held back'.($since ? " since {$since}" : '').': the offered package appears to be a different theme'; + } + + if (($pause['source'] ?? null) === 'auto_failure') { + $failures = (int) ($pause['failure_count'] ?? 0); + + return 'auto-paused'.($since ? " on {$since}" : '') + .($failures > 0 ? " after {$failures} failed ".($failures === 1 ? 'attempt' : 'attempts') : ''); + } + + $note = is_string($pause['note'] ?? null) && trim($pause['note']) !== '' ? ': '.trim($pause['note']) : ''; + + return 'paused by hand'.($since ? " since {$since}" : '').$note; + } } diff --git a/modules/ClientReports/src/Services/ScheduledReportRunner.php b/modules/ClientReports/src/Services/ScheduledReportRunner.php index e1524ff9..8ca7bb29 100644 --- a/modules/ClientReports/src/Services/ScheduledReportRunner.php +++ b/modules/ClientReports/src/Services/ScheduledReportRunner.php @@ -5,12 +5,16 @@ use App\Models\Site; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; +use InvalidArgumentException; use Modules\ClientReports\Models\ClientReport; use Modules\ClientReports\Models\ClientReportSchedule; /** * One scheduled report cycle, shared by `clockwork:send-client-reports` and the - * schedule's "Send now" button so the two can't drift apart. + * schedule's "Send now" button so the two can't drift apart. recompile() is the + * reports' Recompile action: the same compile and review check for an unsent + * report's own period, without ever sending. * * Callers are responsible for the guards (enabled, due, active site, auto mode * having recipients) — this only does the work. @@ -52,7 +56,8 @@ public function run(ClientReportSchedule $schedule): ScheduledReportRun // outage doesn't pile up a new report every day. That includes one // already sent (say, finished by hand after a failed run). A held // report is the exception: it's recompiled, since someone may have - // fixed the problem. + // fixed the problem. So is an unsent one compiled under older report + // rules (ClientReport::isStale()). $report = $this->resumableReport($schedule, $start, $end); $resumed = $report !== null; $report ??= $this->compileFresh($schedule, $start, $end, $recipients); @@ -95,6 +100,51 @@ public function run(ClientReportSchedule $schedule): ScheduledReportRun ); } + /** + * Recompile a scheduled report that hasn't reached anyone, from current + * data, for the report's own period (not "last month", which is what + * run() compiles), so a held draft picks up whatever was fixed or added + * since: hours logged, a scan rerun, a backup recorded. The older copy is + * superseded and the new one is checked for review the way run() checks + * one: an auto schedule that has never sent holds it as its first + * report, a draft schedule doesn't add that reason. + * + * It never sends anything, even when the new copy is clean and the + * schedule has sent before, and it doesn't move the schedule on: it is + * not a scheduled cycle. A clean copy is sent by hand from the history + * table like any other. + * + * @throws InvalidArgumentException when the report isn't a scheduled one + * that is still unsent + */ + public function recompile(ClientReport $report): ScheduledReportRun + { + return DB::transaction(function () use ($report) { + // Re-read under a lock, so a second click can't recompile a copy + // the first one has just superseded. + $current = ClientReport::query()->lockForUpdate()->find($report->id); + $schedule = $current?->schedule; + + if ($current === null || ! $current->canRecompile() || $schedule === null + || $schedule->site === null || $schedule->site_id !== $current->site_id) { + throw new InvalidArgumentException('Only a scheduled report that has not been sent to anyone can be recompiled.'); + } + + $start = $current->period_start->copy()->startOfDay(); + $end = $current->period_end->copy()->startOfDay(); + $recipients = array_values(array_filter((array) $schedule->recipients)); + $notes = $current->sections_data['meta']['custom_notes'] ?? null; + + $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, + ); + + return new ScheduledReportRun($fresh, isDraft: true, resumed: false, heldReasons: $reasons); + }); + } + protected function resumableReport(ClientReportSchedule $schedule, Carbon $start, Carbon $end): ?ClientReport { $report = $this->samePeriod($schedule, $start, $end) @@ -102,9 +152,13 @@ protected function resumableReport(ClientReportSchedule $schedule, Carbon $start ->latest('id') ->first(); - $held = $report !== null && empty($report->sent_to) && ! empty($report->sections_data['meta']['review_reasons']); + // Held, or compiled under older report rules: either way nobody has + // it yet, so recompile rather than send it as it is. + $unsent = $report !== null && empty($report->sent_to); + $held = $unsent && ! empty($report->sections_data['meta']['review_reasons']); + $outdated = $unsent && $report->isStale(); - return $held ? null : $report; + return $held || $outdated ? null : $report; } /** @@ -114,15 +168,50 @@ protected function resumableReport(ClientReportSchedule $schedule, Carbon $start * * @param list $recipients */ - protected function compileFresh(ClientReportSchedule $schedule, Carbon $start, Carbon $end, array $recipients): ClientReport + protected function compileFresh(ClientReportSchedule $schedule, Carbon $start, Carbon $end, array $recipients, ?string $customNotes = null): ClientReport { - $this->samePeriod($schedule, $start, $end) + $earlier = $this->samePeriod($schedule, $start, $end) ->where('status', ClientReport::STATUS_GENERATED) ->get() - ->filter(fn (ClientReport $r) => empty($r->sent_to)) - ->each(fn (ClientReport $r) => $r->update(['status' => ClientReport::STATUS_SUPERSEDED])); + ->filter(fn (ClientReport $r) => empty($r->sent_to)); + + // A person's review of the malware findings, from the newest copy + // that has one: it still holds if the new copy shows the same scan + // and findings. + $malwareReview = $earlier->sortByDesc('id') + ->map(fn (ClientReport $r) => $r->sections_data['security']['malware_reviewed'] ?? null) + ->first(fn ($mark) => is_array($mark)); + + $earlier->each(fn (ClientReport $r) => $r->update(['status' => ClientReport::STATUS_SUPERSEDED])); - return $this->compile($schedule, $start, $end, $recipients); + $report = $this->compile($schedule, $start, $end, $recipients, $customNotes); + + if (is_array($malwareReview)) { + $this->reapplyMalwareReview($report, $malwareReview); + } + + return $report; + } + + /** + * Carry an operator's "findings reviewed" mark onto a freshly compiled + * copy when it shows the same scan with the same findings + * (ReportReviewFlags::malwareReviewed). A newer scan or different + * findings need a person to look again, so the mark is dropped. + * + * @param array $mark an earlier copy's security.malware_reviewed + */ + protected function reapplyMalwareReview(ClientReport $report, array $mark): void + { + $data = is_array($report->sections_data) ? $report->sections_data : []; + if (! isset($data['security'])) { + return; + } + + $data['security']['malware_reviewed'] = $mark; + if (ReportReviewFlags::malwareReviewed((array) $data['security'])) { + $report->update(['sections_data' => $data]); + } } /** @@ -157,7 +246,7 @@ protected function samePeriod(ClientReportSchedule $schedule, Carbon $start, Car /** * @param list $recipients */ - protected function compile(ClientReportSchedule $schedule, Carbon $start, Carbon $end, array $recipients): ClientReport + protected function compile(ClientReportSchedule $schedule, Carbon $start, Carbon $end, array $recipients, ?string $customNotes = null): ClientReport { $site = $schedule->site; @@ -172,6 +261,7 @@ protected function compile(ClientReportSchedule $schedule, Carbon $start, Carbon site: $site, periodStart: $start, periodEnd: $end, + customNotes: $customNotes, sections: $schedule->template?->sections, ), 'client_name' => $site->domain, diff --git a/modules/ClientReports/src/Support/ModuleNavCounts.php b/modules/ClientReports/src/Support/ModuleNavCounts.php new file mode 100644 index 00000000..a3865775 --- /dev/null +++ b/modules/ClientReports/src/Support/ModuleNavCounts.php @@ -0,0 +1,59 @@ +latestPeriod(); + $waiting = $review = 0; + if ($period !== null) { + foreach ($browser->list(new ReportListFilters(period: $period)) as $report) { + $state = ReportState::of($report); + $waiting += in_array($state, ReportState::WAITING, true) ? 1 : 0; + $review += in_array($state, [ReportState::NEEDS_REVIEW, ReportState::OUTDATED], true) ? 1 : 0; + } + } + + return [ + 'period' => $period, + 'period_label' => $period !== null ? Carbon::createFromFormat('!Y-m', $period)->format('M Y') : null, + 'waiting' => $waiting, + 'review' => $review, + 'missing' => self::sitesMissingSchedule(), + ]; + } catch (Throwable $e) { + Log::warning('client_reports.nav_counts_failed', ['error' => $e->getMessage()]); + + return ['period' => null, 'period_label' => null, 'waiting' => 0, 'review' => 0, 'missing' => 0]; + } + }); + } + + /** Active care-plan sites (all active sites when care plans are off fleet-wide) with no report schedule. */ + public static function sitesMissingSchedule(): int + { + return Site::query() + ->where('is_inactive', false) + ->carePlanEligible() + ->whereNotIn('id', ClientReportSchedule::query()->select('site_id')) + ->count(); + } +} diff --git a/modules/ClientReports/src/Support/ReportListFilters.php b/modules/ClientReports/src/Support/ReportListFilters.php new file mode 100644 index 00000000..52367a05 --- /dev/null +++ b/modules/ClientReports/src/Support/ReportListFilters.php @@ -0,0 +1,96 @@ +boolean('superseded'), + client: $client, + from: $client !== null && self::text($request, 'from') === 'client' ? 'client' : null, + ); + } + + /** A filter from the query string or form; anything that isn't a plain string counts as unset. */ + private static function text(Request $request, string $key): string + { + $value = $request->input($key); + + return is_string($value) ? $value : ''; + } + + /** + * The non-empty filters as query parameters. + * + * @return array + */ + public function toQuery(): array + { + return array_filter([ + 'period' => $this->period, + 'status' => $this->status, + 'category' => $this->category, + 'q' => $this->q, + 'superseded' => $this->showSuperseded ? '1' : null, + 'client' => $this->client, + 'from' => $this->from, + ], fn ($value) => $value !== null && $value !== ''); + } + + /** @param array $changes */ + public function with(array $changes): array + { + return array_filter(array_merge($this->toQuery(), $changes), fn ($value) => $value !== null && $value !== ''); + } + + public function isFiltered(): bool + { + return $this->status !== null || $this->category !== null || $this->q !== '' || $this->client !== null; + } +} diff --git a/modules/ClientReports/src/Support/ReportPhrases.php b/modules/ClientReports/src/Support/ReportPhrases.php new file mode 100644 index 00000000..f79e9dcb --- /dev/null +++ b/modules/ClientReports/src/Support/ReportPhrases.php @@ -0,0 +1,504 @@ + 'by your host, Pressable', + SiteBackup::SOURCE_SPINUPWP_SPACES => 'by SpinupWP to DigitalOcean Spaces', + SiteBackup::SOURCE_S3_ARCHIVE => 'to independent off-site storage (Amazon S3)', + ]; + + /** + * Words that make a contact's stored name an organisation's rather than + * a person's ("St James Church", "Example Design Group"), lower case. + */ + private const ORGANISATION_WORDS = [ + 'academy', 'agency', 'and', 'association', 'bank', 'board', 'cafe', 'capital', 'center', 'centre', + 'church', 'cid', 'clinic', 'club', 'co', 'coalition', 'college', 'committee', 'company', 'consulting', + 'corp', 'corporation', 'council', 'department', 'dept', 'design', 'designs', 'enterprises', 'firm', + 'for', 'foundation', 'fund', 'gallery', 'global', 'group', 'health', 'holdings', 'hospital', 'hotel', + 'inc', 'industries', 'info', 'inn', 'institute', 'interiors', 'international', 'law', 'library', 'llc', + 'llp', 'ltd', 'marketing', 'media', 'ministries', 'ministry', 'museum', 'national', 'network', 'of', + 'office', 'partners', 'pllc', 'program', 'programme', 'project', 'properties', 'realty', 'resort', + 'restaurant', 'sales', 'salon', 'school', 'services', 'shop', 'society', 'solutions', 'spa', 'store', + 'studio', 'studios', 'support', 'team', 'the', 'trust', 'university', 'web', 'website', + ]; + + /** + * The first name to greet a contact by ("Jane" for "Jane Example"), or + * null when the stored name isn't plainly one person's: an organisation + * ("Example Institute", an acronym, a name with an organisation word or + * more than three words), an initial ("J. Example"), a name with a note + * in brackets or with digits or symbols ("Jane (Example Co)"), or one + * not written as a name (all lower case, or run together with capitals + * inside, "ExampleSupply"). Null greets "Hi there,", which is never + * wrong; a guessed name can be. + */ + public static function firstName(?string $name): ?string + { + $name = trim(preg_replace('/\s+/u', ' ', (string) $name) ?? ''); + if ($name === '' || preg_match('/[()\[\]{}\/\\\\&@,+#:;!?"_\d]/u', $name)) { + return null; + } + + $words = explode(' ', $name); + if (count($words) > 3) { + return null; + } + + foreach ($words as $word) { + if (in_array(mb_strtolower(rtrim($word, '.')), self::ORGANISATION_WORDS, true) + || (mb_strlen($word) > 1 && preg_match('/^\p{Lu}+$/u', $word))) { + return null; + } + } + + $first = $words[0]; + + return mb_strlen($first) >= 2 && preg_match("/^\p{Lu}\p{Ll}*(?:['’\-]\p{Lu}?\p{Ll}+)*$/u", $first) + ? $first + : null; + } + + /** + * The timezone times in a report are shown in (clockwork.reports.timezone). + * Stored times are UTC, which reads as the wrong time of day to clients. + * An invalid setting falls back to UTC rather than breaking the email, as + * does having no app config at all (the review flags are plain functions). + */ + public static function timezone(): string + { + try { + $zone = trim((string) config('clockwork.reports.timezone', 'America/New_York')); + + return (new DateTimeZone($zone !== '' ? $zone : 'UTC'))->getName(); + } catch (Throwable) { + return 'UTC'; + } + } + + /** + * "Oct 30, 2:05 PM EDT": a stored time in the report timezone. + */ + public static function time(string $at): string + { + return Carbon::parse($at)->setTimezone(self::timezone())->format('M j, g:i A T'); + } + + /** + * "Oct 3, 2026": a stored time's date in the report timezone, or null + * when there's no (readable) time. + */ + public static function date(mixed $at): ?string + { + if (! is_string($at) || trim($at) === '') { + return null; + } + + try { + return Carbon::parse($at)->setTimezone(self::timezone())->format('M j, Y'); + } catch (Throwable) { + return null; + } + } + + /** + * "Oct 19": the day monitoring of the site began, in the report timezone, + * from meta.coverage_start or uptime.monitored_since. Null when it + * covered the whole period, and for reports compiled before it was kept. + */ + public static function monitoringBeganOn(mixed $at): ?string + { + if (! is_string($at) || trim($at) === '') { + return null; + } + + try { + return Carbon::parse($at)->setTimezone(self::timezone())->format('M j'); + } catch (Throwable) { + return null; + } + } + + /** + * Whether uptime monitoring began only after the report's period had + * ended, so the uptime section has nothing to say about it (a report + * compiled late for a past month). Reports compiled before uptime kept + * its own start fall back to meta.coverage_start. + * + * @param array $data a report's sections_data + */ + public static function monitoringBeganAfterPeriod(array $data): bool + { + return self::beganAfterPeriod($data['uptime']['monitored_since'] ?? $data['meta']['coverage_start'] ?? null, $data); + } + + /** + * Whether a stored time falls after the period's last day, as the report + * timezone reads it (the zone its dates are shown in). + * + * @param array $data a report's sections_data + */ + public static function beganAfterPeriod(mixed $began, array $data): bool + { + $periodEnd = $data['meta']['period_end'] ?? null; + if (self::monitoringBeganOn($began) === null || ! is_string($periodEnd) || $periodEnd === '') { + return false; + } + + try { + return Carbon::parse($began)->greaterThan(Carbon::parse($periodEnd, self::timezone())->endOfDay()); + } catch (Throwable) { + return false; + } + } + + /** + * Whether two stored times fall on different days as the report + * timezone reads them, the first one later. + */ + public static function laterDay(mixed $at, mixed $than): bool + { + if (self::monitoringBeganOn($at) === null) { + return false; + } + if (self::monitoringBeganOn($than) === null) { + return true; + } + + $zone = self::timezone(); + + return Carbon::parse($at)->setTimezone($zone)->toDateString() > Carbon::parse($than)->setTimezone($zone)->toDateString(); + } + + /** + * "Monitoring began Oct 19, so this report covers Oct 19 – Oct 31." for a + * site added after the period started, so a late enrolment doesn't read + * as a whole month of care. Null otherwise. Uptime monitoring switched on + * later than that isn't this: the uptime line says so on its own. + * + * @param array $data a report's sections_data + */ + public static function coverageNote(array $data): ?string + { + $began = self::monitoringBeganOn($data['meta']['coverage_start'] ?? null); + if ($began === null) { + return null; + } + + // A care plan switched on mid-month: the site may have been watched + // before, but it wasn't being looked after. + $subject = ($data['meta']['coverage_source'] ?? null) === 'care_plan' ? 'Your care plan' : 'Monitoring'; + + if (self::beganAfterPeriod($data['meta']['coverage_start'], $data)) { + return "{$subject} began {$began}, after the end of this report's period."; + } + + $periodEnd = $data['meta']['period_end'] ?? null; + + return is_string($periodEnd) && $periodEnd !== '' + ? "{$subject} began {$began}, so this report covers {$began} – ".Carbon::parse($periodEnd)->format('M j').'.' + : "{$subject} began {$began}."; + } + + /** + * "Down Oct 5, 3:12 PM EDT", in the report timezone. An outage already + * open when the period began has its start clamped to the period start, + * which isn't when it began, so it reads "Down since before Oct 1": the + * period's first day in the report timezone. Reports compiled before the + * period was measured in that zone clamped to midnight UTC, which is + * read as the UTC date it is. + * + * @param array $outage one of sections_data['uptime']['outages'] + */ + public static function outageStart(array $outage): string + { + $at = Carbon::parse((string) $outage['started_at']); + + if (! empty($outage['started_before_period'])) { + $utc = $at->copy()->utc(); + $legacyUtcStart = $utc->format('H:i:s') === '00:00:00'; + + return 'Down since before '.($legacyUtcStart ? $utc : $at->setTimezone(self::timezone()))->format('M j'); + } + + return 'Down '.$at->setTimezone(self::timezone())->format('M j, g:i A T'); + } + + /** + * What the updates section says when no update is listed. Only what's + * known: nothing was waiting at the end of the month (the snapshot said + * so), never that no updates were needed all month, since something + * other than Control can apply one. When something was waiting, or we + * can't tell (including reports compiled before waiting updates were + * recorded), just that none were applied. + * + * @param array $updates sections_data['updates'] + */ + public static function noUpdatesLine(array $updates): string + { + return array_key_exists('pending_plugins', $updates) && ! ClientReportCompiler::updatesWereWaiting($updates) + ? 'Everything was up to date at the end of the month.' + : 'No updates were applied this month.'; + } + + /** At or above this availability the figure reads as good. */ + public const UPTIME_GOOD = 99.9; + + /** At or above this (and below UPTIME_GOOD) it reads as a warning; below it, as bad. */ + public const UPTIME_FAIR = 99.0; + + /** + * 'good', 'warn' or 'bad' for an availability percentage, so a month + * at 97% isn't shown in the colour of a perfect one; null when there's + * no figure. + */ + public static function uptimeTone(mixed $percentage): ?string + { + if (! is_numeric($percentage)) { + return null; + } + + return match (true) { + (float) $percentage >= self::UPTIME_GOOD => 'good', + (float) $percentage >= self::UPTIME_FAIR => 'warn', + default => 'bad', + }; + } + + /** + * "40.7 GB", "512 MB": data transferred, from traffic.bandwidth_mb, or + * null when the host reported no byte counts. + */ + public static function bandwidth(mixed $megabytes): ?string + { + $mb = is_numeric($megabytes) ? (float) $megabytes : 0.0; + if ($mb <= 0) { + return null; + } + + return $mb >= 1024 ? number_format($mb / 1024, 1).' GB' : number_format($mb, 0).' MB'; + } + + /** + * "Files & database", "Database" or "Files": what a day's backups held, + * from one of backups.recent's kinds lists. + * + * @param array $kinds + */ + public static function backupKinds(array $kinds): string + { + return match (true) { + in_array(SiteBackup::KIND_FULL, $kinds, true), + in_array(SiteBackup::KIND_FILES, $kinds, true) && in_array(SiteBackup::KIND_DATABASE, $kinds, true) => 'Files & database', + in_array(SiteBackup::KIND_DATABASE, $kinds, true) => 'Database', + default => 'Files', + }; + } + + /** "45 min", "6h 19m": a length of time the way the email's outage list shows it. */ + public static function duration(int $minutes): string + { + return $minutes >= 60 ? intdiv($minutes, 60).'h '.($minutes % 60).'m' : max(0, $minutes).' min'; + } + + /** + * The end of "Your site was backed up …" as a whole phrase, preposition + * included ("by your host, Pressable"), or null when the source is unknown. + * + * @param array $backups sections_data['backups'] + */ + public static function backupDestination(array $backups): ?string + { + $source = $backups['source'] ?? null; + if (is_string($source) && isset(self::BACKUP_DESTINATIONS[$source])) { + return self::BACKUP_DESTINATIONS[$source]; + } + + $destination = trim((string) ($backups['destination'] ?? '')); + if ($destination === '') { + return null; + } + + // Older reports stored only the place ("Host Automated Snapshots"), + // and the templates put "by" in front of it. + return preg_match('/^(by|to|in|on)\s/i', $destination) ? $destination : 'by '.$destination; + } + + /** + * "9 KB", "60 MB", "3.0 GB": a size the way the email's backups list shows it. + */ + public static function bytes(int $bytes): string + { + return match (true) { + $bytes >= 1024 ** 3 => number_format($bytes / 1024 ** 3, 1).' GB', + $bytes >= 1024 ** 2 => number_format($bytes / 1024 ** 2, 0).' MB', + default => number_format(max(1, $bytes / 1024), 0).' KB', + }; + } + + /** + * What's wrong with a report's traffic figures, as a clause for the + * operator ("traffic was recorded on only 14 of 31 days"), or null when + * nothing is: the figures are complete and believable, or the report was + * compiled before traffic had a status. + * + * @param array $traffic sections_data['traffic'] + */ + public static function trafficProblem(array $traffic): ?string + { + $status = $traffic['status'] ?? null; + $days = (int) ($traffic['days_covered'] ?? 0); + $periodDays = (int) ($traffic['period_days'] ?? 0); + $coverage = "traffic was recorded on only {$days} of {$periodDays} days"; + $fewDays = $periodDays > 0 && $days < $periodDays - 1; + + return match ($status) { + 'unsupported' => "traffic isn't collected for this host", + 'no_data' => "no traffic was recorded for this period, although this host's traffic is collected", + 'partial' => $fewDays ? $coverage : "the period's last day of traffic wasn't complete when the report was compiled", + 'suspect' => self::suspectTraffic($traffic).($fewDays ? "; also, {$coverage}" : ''), + default => null, + }; + } + + /** @param array $traffic */ + private static function suspectTraffic(array $traffic): string + { + $share = $traffic['top_ip_share'] ?? null; + if (($traffic['source'] ?? null) === 'nginx' && is_numeric($share) && (float) $share > ClientReportCompiler::TRAFFIC_TOP_IP_SHARE_MAX) { + // Older reports didn't keep how many IPs the share is from. + $ips = (int) ($traffic['top_ip_count'] ?? 2) === 1 ? 'one IP address' : 'two IP addresses'; + + return (int) round((float) $share * 100)."% of requests came from {$ips}, which looks like a proxy or gateway in front of the site rather than visitors"; + } + + return number_format((int) ($traffic['total_visits'] ?? 0)).' visits for '.number_format((int) ($traffic['total_requests'] ?? 0)) + ." requests doesn't look like real visitors"; + } + + /** + * "None found", "2 found in 2 plugins (1 in inactive plugins)", or "Not + * checked" when there was no plugin list or the feed was stale. Older + * reports never actually checked, so a missing flag means not checked. + * + * @param array $security sections_data['security'] + */ + public static function vulnerabilities(array $security): string + { + if (! ($security['vulnerabilities_checked'] ?? false)) { + return 'Not checked'; + } + + $vulns = (int) ($security['active_vulnerabilities'] ?? 0); + if ($vulns === 0) { + return 'None found'; + } + + $plugins = count((array) ($security['vulnerable_plugins'] ?? [])); + $inactive = (int) ($security['inactive_plugin_vulnerabilities'] ?? 0); + + return $vulns.' found' + .($plugins > 0 ? ' in '.$plugins.' '.Str::plural('plugin', $plugins) : '') + .($inactive > 0 ? ' ('.$inactive.' in inactive plugins)' : ''); + } + + /** + * The client's malware-scan line and its tone ('good', 'warn' or + * 'muted'), shared by both email parts. Review-tier findings a person + * has checked (ReportReviewFlags::malwareReviewed) read "Reviewed — no + * threat found" instead of "Issues flagged"; suspected malware never does. + * + * @param array $security sections_data['security'] + * @return array{0: string, 1: string} + */ + public static function malwareLabel(array $security): array + { + $status = $security['malware_status'] ?? null; + + return match (true) { + $status === 'clean' => ['No malware found', 'good'], + $status === 'warning' && ReportReviewFlags::malwareReviewed($security) => ['Reviewed — no threat found', 'good'], + in_array($status, ['warning', 'issues_found'], true) => ['Issues flagged', 'warn'], + // A clean result from a scan that didn't cover the whole site. + $status === ClientReportCompiler::MALWARE_INCOMPLETE => ['Partial scan', 'muted'], + $status === null => ['Not checked this month', 'muted'], + default => [ucfirst((string) $status), 'warn'], + }; + } + + /** + * Why the "Malicious visitors blocked" count is hidden, as a note for + * the operator, or null when it's shown (or the report predates the + * reason being kept and has no unbanned lockouts to explain it). + * + * @param array $security sections_data['security'] + */ + public static function blockedVisitorsNote(array $security): ?string + { + if (($security['blocked_threats_count'] ?? null) !== null) { + return null; + } + + $lockouts = (int) ($security['unbanned_lockouts'] ?? 0); + $reason = match ($security['blocks_unmeasured'] ?? ($lockouts > 0 ? 'unbanned_lockouts' : null)) { + 'unbanned_lockouts' => $lockouts.' '.Str::plural('lockout', $lockouts).' this period never became a ban, so a count would leave those visitors out', + 'ingestion_stopped' => 'no lockout from any site has reached Control since ' + .(self::date($security['lockouts_last_ingested_at'] ?? null) ?? 'well before the period end') + .', so a count would cover only part of the period', + 'no_lockout_feed' => "nothing shows this site's lockouts reached Control all period (no blocker reporting to Control now, " + ."no provisioned fail2ban jail, or the server's lockouts weren't pulled at the end of the period)", + default => null, + }; + + return $reason !== null ? 'Malicious visitors blocked hidden from the client: '.$reason.'.' : null; + } + + /** + * The names of the plugins with a known vulnerability, when any was found. + * + * @param array $security sections_data['security'] + * @return list + */ + public static function vulnerablePlugins(array $security): array + { + if (! ($security['vulnerabilities_checked'] ?? false) || (int) ($security['active_vulnerabilities'] ?? 0) === 0) { + return []; + } + + $names = array_map(fn ($name) => trim((string) $name), (array) ($security['vulnerable_plugins'] ?? [])); + + return array_values(array_unique(array_filter($names, fn (string $name) => $name !== ''))); + } + + /** + * "Nov 1": the date the plugin list was checked. It's the Companion + * snapshot date, so it's often the day after the period ends; it says + * when we looked, not the state over the whole period. + * + * @param array $security sections_data['security'] + */ + public static function vulnerabilitiesCheckedOn(array $security): ?string + { + return ! empty($security['vulnerabilities_as_of']) + ? Carbon::parse((string) $security['vulnerabilities_as_of'])->format('M j') + : null; + } +} diff --git a/modules/ClientReports/src/Support/ReportSections.php b/modules/ClientReports/src/Support/ReportSections.php new file mode 100644 index 00000000..f24456b8 --- /dev/null +++ b/modules/ClientReports/src/Support/ReportSections.php @@ -0,0 +1,125 @@ + 'Uptime', + 'performance' => 'Speed & performance', + 'traffic' => 'Traffic', + 'forms' => 'Contact forms', + 'backups' => 'Backups', + 'work_log' => 'Work log', + ]; + + /** + * @param array $data a report's sections_data + * @return array section key => shown to the client + */ + public static function visibility(array $data): array + { + $uptime = (array) ($data['uptime'] ?? []); + $perf = (array) ($data['performance'] ?? []); + $forms = (array) ($data['forms'] ?? []); + + return [ + 'updates' => isset($data['updates']), + // A site the uptime probe doesn't check has no uptime to show. + 'uptime' => isset($data['uptime']) && ($uptime['monitored'] ?? true), + 'security' => isset($data['security']), + 'performance' => isset($data['performance']) && ! empty($perf['has_performance']) + && (! empty($perf['mobile']) || ! empty($perf['desktop'])), + // Only complete, believable figures; reports compiled before + // traffic had a status show as they always did. + 'traffic' => isset($data['traffic']) && ! empty($data['traffic']['has_traffic']) + && ($data['traffic']['status'] ?? 'ok') === 'ok', + // Reports compiled before schema 2 counted form passes wrongly + // (always 0%), so their forms numbers are never shown. + 'forms' => self::hasCurrentFormsData($data) && isset($data['forms']) + && ((int) ($forms['live_tests'] ?? 0) > 0 || (int) ($forms['lab_tests'] ?? 0) > 0), + // Only backups we actually saw recently; "none found", "stopped", + // "couldn't check", implausibly small database backups and a + // failing relay are for the operator, never the client. + 'backups' => isset($data['backups']) && ! empty($data['backups']['verified']), + 'work_log' => isset($data['work_log']) && ! empty($data['work_log']['entries']), + ]; + } + + /** + * Why each compiled section is hidden from the client, as a short note + * for the operator preview. Sections that are shown, and sections the + * report's template didn't include, aren't listed. + * + * @param array $data a report's sections_data + * @return array section key => note + */ + public static function hiddenNotes(array $data): array + { + $notes = []; + + foreach (self::visibility($data) as $key => $shown) { + if ($shown || ! isset($data[$key])) { + continue; + } + + $reason = self::hiddenReason($key, (array) $data[$key], $data); + if ($reason !== null) { + $notes[$key] = self::LABELS[$key].' hidden from the client: '.$reason.'.'; + } + } + + return $notes; + } + + /** + * @param array $section + * @param array $data + */ + private static function hiddenReason(string $key, array $section, array $data): ?string + { + return match ($key) { + 'uptime' => match (true) { + ! empty($section['probe_stopped']) => 'the uptime probe '.(! empty($section['last_checked_at']) ? "hasn't checked the site since {$section['last_checked_at']}" : 'has never checked the site'), + ReportPhrases::monitoringBeganAfterPeriod($data) => 'uptime monitoring began '.ReportPhrases::monitoringBeganOn($section['monitored_since'] ?? $data['meta']['coverage_start'] ?? null).', after this period', + default => "the uptime probe doesn't check this site", + }, + 'performance' => 'no speed test ran in this period', + 'traffic' => ReportPhrases::trafficProblem($section) ?? 'no traffic was recorded for this period', + 'forms' => self::hasCurrentFormsData($data) + ? 'no form tests ran in this period' + : 'compiled before form results were counted correctly', + 'backups' => match ($section['status'] ?? null) { + 'none_found' => 'no backups were found this period', + 'stale' => 'no backups in the last 10 days of the period' + .(! empty($section['last_backup_at']) ? " (last one {$section['last_backup_at']})" : ''), + 'no_source' => 'there is no backup source for this host', + 'failing' => 'no off-site archive this period; the backup relay has been failing' + .(ReportPhrases::date($section['relay_failing_since'] ?? null) !== null ? ' since '.ReportPhrases::date($section['relay_failing_since']) : ''), + 'suspect' => 'the newest database backup is far smaller than the database' + .(is_numeric($section['db_backup_bytes'] ?? null) && is_numeric($section['db_size_bytes'] ?? null) + ? ' ('.ReportPhrases::bytes((int) $section['db_backup_bytes']).' for '.ReportPhrases::bytes((int) $section['db_size_bytes']).')' + : ''), + default => 'backups could not be checked', + }, + 'work_log' => 'no hours were logged this period', + default => null, + }; + } + + /** @param array $data */ + private static function hasCurrentFormsData(array $data): bool + { + return (int) ($data['meta']['schema_version'] ?? 1) >= 2; + } +} diff --git a/modules/ClientReports/src/Support/ReportState.php b/modules/ClientReports/src/Support/ReportState.php new file mode 100644 index 00000000..53a5fdb1 --- /dev/null +++ b/modules/ClientReports/src/Support/ReportState.php @@ -0,0 +1,169 @@ + */ + 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::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.'], + ]; + + public static function of(ClientReport $report): string + { + return self::classify( + (string) $report->status, + (array) ($report->sent_to ?? []), + $report->isStale(), + self::reasons($report), + ); + } + + /** + * @param list $sentTo + * @param iterable $reasons + */ + public static function classify(string $status, array $sentTo, bool $stale, iterable $reasons): string + { + return match (true) { + $status === ClientReport::STATUS_SUPERSEDED => self::SUPERSEDED, + $status === ClientReport::STATUS_SENT => self::SENT, + $sentTo !== [] => self::PARTLY_SENT, + $stale => self::OUTDATED, + ReviewReasonCategories::forReasons($reasons) !== [] && ! ReviewReasonCategories::onlyFirstReport($reasons) => self::NEEDS_REVIEW, + default => self::READY, + }; + } + + /** + * What the operator should check before this report goes out: the stored + * review reasons, or nothing once it has been sent. + * + * @return list + */ + public static function reasons(ClientReport $report): array + { + if ($report->status === ClientReport::STATUS_SENT) { + return []; + } + + $data = is_array($report->sections_data) ? $report->sections_data : []; + + return array_values(array_filter( + (array) ($data['meta']['review_reasons'] ?? []), + fn ($reason) => is_string($reason) && trim($reason) !== '', + )); + } + + /** + * Why the Send button can't be used right now, or null when it can. + */ + public static function sendBlockedReason(ClientReport $report): ?string + { + return match (true) { + $report->status === ClientReport::STATUS_SUPERSEDED => 'A newer copy for this period replaced this one. Send that one instead.', + empty($report->sent_to) && $report->isStale() => $report->canRecompile() + ? 'Compiled before the latest report fixes: recompile it, check the new copy, then send that one.' + : 'Compiled before the latest report fixes: generate a new report for this period instead.', + $report->recipients() !== [] && $report->recipientsToSend() === [] => 'Everyone on this report already has it.', + default => null, + }; + } + + /** + * The confirmation shown before a real send: who gets it, and what the + * send changes. Sending a schedule's first report by hand is what lets + * that schedule email its later clean reports by itself. + */ + public static function sendConfirmation(ClientReport $report, string $glue = ' '): string + { + $to = $report->recipientsToSend() ?: $report->recipients(); + $lines = [$to === [] + ? 'Email this report? You will be asked for the address to send it to.' + : 'Email report to '.implode(', ', $to).'?']; + $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) { + $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.'; + } + + return implode($glue, $lines); + } + + /** The confirmation shown before a test copy goes to the operator. */ + public static function testConfirmation(?string $operatorEmail): string + { + return 'Send a test copy to '.($operatorEmail ?: 'your own address').'? Only you get it, with [TEST] in the subject. The client gets nothing, and the report and its schedule don\'t change.'; + } + + /** @return array state => label, in the order the screens list them */ + public static function options(): array + { + return array_map(fn (array $meta) => $meta['label'], self::META); + } + + public static function isKnown(?string $state): bool + { + return $state !== null && isset(self::META[$state]); + } + + public static function label(string $state): string + { + return self::META[$state]['label'] ?? ucfirst(str_replace('_', ' ', $state)); + } + + /** CSS class of the status pill (the app's status-* palette, dark-mode aware). */ + public static function pill(string $state): string + { + return self::META[$state]['pill'] ?? 'status-unknown'; + } + + public static function icon(string $state): string + { + return self::META[$state]['icon'] ?? 'fa-circle'; + } + + public static function hint(string $state): string + { + return self::META[$state]['hint'] ?? ''; + } +} diff --git a/modules/ClientReports/src/Support/ReviewReasonCategories.php b/modules/ClientReports/src/Support/ReviewReasonCategories.php new file mode 100644 index 00000000..893572fd --- /dev/null +++ b/modules/ClientReports/src/Support/ReviewReasonCategories.php @@ -0,0 +1,169 @@ + */ + private const META = [ + self::FIRST_REPORT => ['label' => 'First report', 'short' => 'First report', 'icon' => 'fa-flag', 'tab' => null], + self::MALWARE => ['label' => 'Malware & core files', 'short' => 'Malware', 'icon' => 'fa-bug', 'tab' => 'security'], + self::VULNERABILITIES => ['label' => 'Vulnerable plugins', 'short' => 'Vulnerability', 'icon' => 'fa-shield-halved', 'tab' => 'updates'], + self::UPDATES => ['label' => 'Updates', 'short' => 'Updates', 'icon' => 'fa-arrows-rotate', 'tab' => 'updates'], + self::BACKUPS => ['label' => 'Backups', 'short' => 'Backups', 'icon' => 'fa-database', 'tab' => 'overview'], + self::UPTIME => ['label' => 'Uptime', 'short' => 'Uptime', 'icon' => 'fa-heart-pulse', 'tab' => 'overview'], + self::TRAFFIC => ['label' => 'Traffic', 'short' => 'Traffic', 'icon' => 'fa-chart-line', 'tab' => 'traffic'], + self::FORMS => ['label' => 'Contact forms', 'short' => 'Forms', 'icon' => 'fa-envelope-open-text', 'tab' => 'forms'], + self::PARTIAL_MONTH => ['label' => 'Partial month', 'short' => 'Partial month', 'icon' => 'fa-calendar-minus', 'tab' => 'overview'], + self::OTHER => ['label' => 'Other', 'short' => 'Other', 'icon' => 'fa-circle-info', 'tab' => 'overview'], + ]; + + /** + * Keyword rules, most specific first: the first match wins. A reason + * about the Companion plugin's stale snapshot mentions vulnerabilities + * in passing, so it is matched before them; "Uptime monitoring began" + * is an uptime gap, so uptime comes before the partial-month rule. + * + * @var list + */ + private const RULES = [ + ['/first report from this schedule/i', self::FIRST_REPORT], + ['/companion .*(reported|snapshot)/i', self::UPDATES], + ['/vulnerab/i', self::VULNERABILITIES], + ['/malware|blocklist|core[- ]file/i', self::MALWARE], + ['/backup|backed up/i', self::BACKUPS], + ['/uptime|still down|monitor was blocked|http 429/i', self::UPTIME], + ['/traffic|requests came from/i', self::TRAFFIC], + ['/contact[- ]form|\bforms?\b/i', self::FORMS], + ['/monitoring began|part of the month|covers none/i', self::PARTIAL_MONTH], + ['/update/i', self::UPDATES], + ]; + + public static function categorize(string $reason): string + { + foreach (self::RULES as [$pattern, $category]) { + if (preg_match($pattern, $reason) === 1) { + return $category; + } + } + + return self::OTHER; + } + + /** + * The categories a report's reasons fall into, in the order they first appear. + * + * @param iterable $reasons + * @return list + */ + public static function forReasons(iterable $reasons): array + { + return array_keys(self::group($reasons)); + } + + /** + * The reasons grouped by category, each group in its stored order. + * + * @param iterable $reasons + * @return array> + */ + public static function group(iterable $reasons): array + { + $groups = []; + foreach ($reasons as $reason) { + if (! is_string($reason) || trim($reason) === '') { + continue; + } + $groups[self::categorize($reason)][] = $reason; + } + + return $groups; + } + + /** + * Whether the only thing holding a report is that it is its schedule's + * first: nothing in it needs fixing, it just waits for a person to send it. + * + * @param iterable $reasons + */ + public static function onlyFirstReport(iterable $reasons): bool + { + $categories = self::forReasons($reasons); + + return $categories === [self::FIRST_REPORT]; + } + + /** + * The categories an operator can filter the reports list by. + * + * @return array + */ + public static function filterOptions(): array + { + $options = []; + foreach (self::META as $key => $meta) { + if ($key !== self::FIRST_REPORT) { + $options[$key] = $meta['label']; + } + } + + return $options; + } + + public static function isKnown(?string $category): bool + { + return $category !== null && isset(self::META[$category]); + } + + public static function label(string $category): string + { + return self::META[$category]['label'] ?? self::META[self::OTHER]['label']; + } + + public static function short(string $category): string + { + return self::META[$category]['short'] ?? self::META[self::OTHER]['short']; + } + + public static function icon(string $category): string + { + return self::META[$category]['icon'] ?? self::META[self::OTHER]['icon']; + } + + /** The site dashboard tab where the problem can be looked at, if there is one. */ + public static function siteTab(string $category): ?string + { + return self::META[$category]['tab'] ?? null; + } +} diff --git a/modules/ClientSlack/src/ClientSlackNotifier.php b/modules/ClientSlack/src/ClientSlackNotifier.php index 68140920..c2f9bf84 100644 --- a/modules/ClientSlack/src/ClientSlackNotifier.php +++ b/modules/ClientSlack/src/ClientSlackNotifier.php @@ -112,6 +112,11 @@ public function malwareFindingDetected(Site $site, SiteSecurityScan $scan): bool return false; } + public function phpInUploadsDetected(Site $site, SiteSecurityScan $scan, array $paths): bool + { + return false; + } + public function siteEnteredMaintenance(Site $site, ?int $statusCode, ?string $reason, ?string $retryAfter = null): bool { return false; @@ -122,6 +127,11 @@ public function siteExitedMaintenance(Site $site, ?int $maintenanceSec): bool return false; } + public function uptimeProbeBlocked(Site $site, int $blockedSec, ?int $statusCode, ?string $retryAfter = null): bool + { + return false; + } + public function contactFormTestFailed(Site $site, string $reason, int $streak, ?string $formId = null): bool { $webhook = $this->webhookUrl($site); diff --git a/modules/ContactForms/src/Commands/TestContactForms.php b/modules/ContactForms/src/Commands/TestContactForms.php index c9e2e32d..801b7193 100644 --- a/modules/ContactForms/src/Commands/TestContactForms.php +++ b/modules/ContactForms/src/Commands/TestContactForms.php @@ -106,14 +106,23 @@ private function targetTests() /** * "Is this row due?" — never-tested rows always qualify; otherwise the - * row must be older than its frequency window. + * row must be older than its frequency window, less a grace margin. + * + * The scheduled run starts at a fixed time each day, but last_test_at + * is stamped when each test finishes — a few minutes later, and at a + * slightly different minute every day. A strict 24h / 7d cutoff skips + * a row whenever today's run reaches this filter earlier in the day + * than the previous test finished, so daily rows ran roughly every + * other day and weekly rows every eighth day. The margins absorb that + * drift while still keeping a row from running twice in one window: + * daily is due after 20 hours, weekly after 7 days minus 4 hours. * * @param Builder $q */ private function applyDueFilter($q): void { - $dailyCutoff = now()->subDay(); - $weeklyCutoff = now()->subDays(7); + $dailyCutoff = now()->subHours(20); + $weeklyCutoff = now()->subDays(7)->addHours(4); $q->where(function ($q) use ($dailyCutoff, $weeklyCutoff) { $q->whereNull('last_test_at') diff --git a/modules/ContactForms/src/ContactFormsServiceProvider.php b/modules/ContactForms/src/ContactFormsServiceProvider.php index 379846bb..7952e659 100644 --- a/modules/ContactForms/src/ContactFormsServiceProvider.php +++ b/modules/ContactForms/src/ContactFormsServiceProvider.php @@ -111,10 +111,10 @@ public function manifest(): ModuleManifest return new ModuleManifest( id: 'contact-forms', name: 'Contact Form Testing', - description: 'Automated synthetic end-to-end testing and delivery verification for WordPress contact forms via Clockwork Companion.', + description: 'Automated synthetic submissions that check WordPress contact forms still accept messages, via Clockwork Companion. Scheduled tests suppress the outgoing email, so they don\'t verify delivery.', credentialFields: [], status: ModuleManifest::STATUS_VERIFIED, - statusNote: 'Verified synthetic form testing and email round-trip delivery verification.', + statusNote: 'Verified synthetic form testing: checks that each form accepts a submission. Inbox delivery is not verified.', ); } } diff --git a/modules/Core/src/Support/WebhookChatNotifier.php b/modules/Core/src/Support/WebhookChatNotifier.php index 25a0def0..d554c092 100644 --- a/modules/Core/src/Support/WebhookChatNotifier.php +++ b/modules/Core/src/Support/WebhookChatNotifier.php @@ -8,6 +8,7 @@ use App\Models\Site; use App\Models\SiteSecurityScan; use App\Services\Chat\ChatNotifier; +use App\Services\Uptime\UptimeProber; use App\Support\Settings; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; @@ -588,6 +589,43 @@ public function siteExitedMaintenance(Site $site, ?int $maintenanceSec): bool return $this->send($title, [$attachment]); } + /** + * The uptime probe has been rate-limited (HTTP 429) for a long stretch. + * Not an outage alert: the state machine does not count this as + * downtime, but it can't confirm the site is up either, so a person + * should look. Fires once per blocked stretch. + */ + public function uptimeProbeBlocked(Site $site, int $blockedSec, ?int $statusCode, ?string $retryAfter = null): bool + { + if (! $this->isEventEnabled('uptime_probe_blocked')) { + return false; + } + + $duration = $this->formatDowntime($blockedSec); + $detail = $statusCode !== null ? "HTTP {$statusCode}" : 'rate-limited'; + $title = sprintf(':no_entry: %s is blocking the uptime probe (%s for %s)', $site->domain, $detail, $duration); + + $body = 'The site has kept answering the uptime probe with "Too Many Requests" for this long, so Clockwork cannot tell whether it is up. ' + .'This is not counted as downtime. A host rate limiter or a security plugin\'s anti-bot challenge is the usual cause: ' + .'allowlist the uptime monitor\'s User-Agent ('.UptimeProber::userAgent().') there. ' + .'If real visitors are being throttled too, treat it as an outage.'; + + $attachment = [ + 'fallback' => $title, + 'color' => '#e0a800', + 'title' => $title, + 'text' => $body, + 'fields' => array_values(array_filter([ + ['title' => 'Site', 'value' => $site->domain, 'short' => true], + ['title' => 'Server', 'value' => $site->server !== null ? $site->server->name : 'n/a', 'short' => true], + ['title' => 'Blocked for', 'value' => $duration, 'short' => true], + $retryAfter !== null ? ['title' => 'Retry-After', 'value' => mb_strimwidth($retryAfter, 0, 64, '…'), 'short' => true] : null, + ])), + ]; + + return $this->send($title, [$attachment]); + } + /** * Per-failure ping for the nightly auto-update loop. Fires only when * the queued job is part of a `nightly-*` batch — manual bulk-update @@ -691,6 +729,64 @@ public function malwareFindingDetected(Site $site, SiteSecurityScan $scan): bool return $this->send($title, [$attachment]); } + /** + * PHP files under wp-content/uploads that the previous scan of the site + * did not report (see SecurityScanRecorder). A review-tier signal, not a + * confirmed compromise, so it is amber rather than red. Paths come from + * the site's filesystem, so they are defanged before being posted: + * backticks and angle brackets could otherwise break the code span or + * form chat control sequences such as mentions. + * + * @param list $paths + */ + public function phpInUploadsDetected(Site $site, SiteSecurityScan $scan, array $paths): bool + { + if (! $this->isEventEnabled('php_in_uploads_detected')) { + return false; + } + + $paths = array_values(array_filter( + array_map(fn ($p) => is_string($p) ? trim($p) : '', $paths), + fn (string $p) => $p !== '', + )); + if ($paths === []) { + return false; + } + + $domain = $site->domain; + $count = count($paths); + $shownLimit = 10; + + $lines = array_map( + fn (string $p) => '• `'.mb_strimwidth( + str_replace(['`', '<', '>'], ["'", '‹', '›'], preg_replace('/[\x00-\x1F\x7F]/u', '', $p) ?? $p), + 0, + 200, + '…', + ).'`', + array_slice($paths, 0, $shownLimit), + ); + if ($count > $shownLimit) { + $lines[] = sprintf('…and %d more', $count - $shownLimit); + } + + $title = sprintf(':warning: %d new PHP file%s in uploads on %s', $count, $count === 1 ? '' : 's', $domain); + $attachment = [ + 'fallback' => $title, + 'color' => '#f59e0b', + 'title' => $title, + 'text' => implode("\n", $lines)."\n\nNot present on the previous scan. Review each file before suppressing it.", + 'fields' => [ + ['title' => 'Site', 'value' => $domain, 'short' => true], + ['title' => 'Server', 'value' => $site->server !== null ? $site->server->name : 'n/a', 'short' => true], + ['title' => 'Scan status', 'value' => (string) $scan->status, 'short' => true], + ['title' => 'Scan', 'value' => '#'.$scan->id, 'short' => true], + ], + ]; + + return $this->send($title, [$attachment]); + } + /** * Fleet-level, not site-scoped — the backup relay droplet has no * connection back to this app (see PushBackupRelayTargets's docblock), diff --git a/modules/Pressable/src/PressableServiceProvider.php b/modules/Pressable/src/PressableServiceProvider.php index bf169093..ac7cb8bb 100644 --- a/modules/Pressable/src/PressableServiceProvider.php +++ b/modules/Pressable/src/PressableServiceProvider.php @@ -68,8 +68,16 @@ public function scheduledTasks(Schedule $schedule): void ->withoutOverlapping(60) ->onOneServer(); + // Also writes site_traffic_daily, which the 06:00 client-report run + // reads, so it must finish well before then: a run after the + // reports leaves the last day of the month with only the hours + // recorded the morning before. Times here are UTC; Pressable's + // buckets are UTC days, so by 04:45 yesterday is complete with + // margin for Pressable's own lag, and the ~15-20 min run ends long + // before 06:00. The capability check reads the previous day's 06:25 + // refresh, which only matters the day a site first upgrades. $schedule->command('clockwork:pressable-traffic-report') - ->dailyAt('06:37') + ->dailyAt('04:45') ->withoutOverlapping(60) ->onOneServer(); diff --git a/resources/docs/architecture/data-model.md b/resources/docs/architecture/data-model.md index a885a60e..13c602dc 100644 --- a/resources/docs/architecture/data-model.md +++ b/resources/docs/architecture/data-model.md @@ -39,10 +39,10 @@ One row per WordPress (or non-WP) site. Natural key: `domain` (unique). Notable - Care plan: `care_plan_enabled`, `care_plan_override` (NULL = Bill.com may write; bool = manual override sync respects). - SEO indexability: `seo_indexable` (default `true`), `seo_blocked_reason` + `seo_blocked_snippet` (the single current top-priority reason/evidence), `seo_checked_at`, `seo_monitoring_enabled` (default `true`), `seo_state_changed_at`, plus per-vector last-known snapshots `seo_meta_snippet` / `seo_header_snippet` / `seo_robots_snippet` — kept independently because the meta-tag, `X-Robots-Tag` header, and `robots.txt` vectors are checked by different code paths at different cadences, so fixing the higher-priority one doesn't silently "forget" a lower-priority one is still blocking. Suppressible per-site via `ignored_issues` (see below). - Site Command Center: `notes` (free-text, operator-editable), `dashboard_layout` (nullable JSON array of widget keys ordering the per-site Overview dashboard's draggable cards — `Site::resolvedDashboardLayout()` falls back to `Site::DEFAULT_DASHBOARD_LAYOUT` and appends any new default widgets missing from an older saved layout), `screenshot_path` + `screenshot_captured_at` (locally cached fleet-grid-view thumbnail, captured async via `CaptureSiteScreenshotJob` on site creation and by the `clockwork:capture-site-screenshots` command; `Site::screenshotUrl()` falls back to Automattic's free mShots renderer when no local capture exists yet). -- Backup relay: `backup_relay_enabled` (boolean, default `false`) marks whether the site is enrolled in scheduled off-host backup archiving to S3 Glacier Instant Retrieval. `backup_relay_frequency` (`daily` / `twice_weekly` / `weekly`, nullable) is a per-site cadence for unhosted/custom sites; `null` inherits the fleet setting on `/settings/backup-relay`. `backup_relay_last_archived_at` (nullable timestamp) tracks when an archive run was most recently completed. Companion/direct-to-S3 runs also store `backup_relay_last_size_bytes` and `backup_relay_last_sha256`. +- Backup relay: `backup_relay_enabled` (boolean, default `false`) marks whether the site is enrolled in scheduled off-host backup archiving to S3 Glacier Instant Retrieval. `backup_relay_frequency` (`daily` / `twice_weekly` / `weekly`, nullable) is a per-site cadence for unhosted/custom sites; `null` inherits the fleet setting on `/settings/backup-relay`. `backup_relay_last_archived_at` (nullable timestamp) tracks when an archive run was most recently completed. Companion/direct-to-S3 runs also store `backup_relay_last_size_bytes` and `backup_relay_last_sha256`. Each attempt records `backup_relay_last_attempt_at` and, on failure, `backup_relay_last_error` (the error as thrown; client reports strip URL query strings and secrets before showing it); `backup_relay_failing_since` marks when the current failure streak began and is cleared by the next successful archive. - Bill.com: `bill_com_customer_id`, `bill_com_customer_name`, `bill_com_linked_via_invoice`, `bill_com_linked_at`. - Companion: `companion_installed`, `companion_version`, `companion_capabilities`, `companion_secret` (encrypted), `companion_last_seen_at`, `companion_snapshot` (JSON), `companion_snapshot_at`, `companion_stuck_since` + `companion_stuck_reason` (both nullable). Set by `clockwork:detect-stuck-companion-state` (daily sweep) when a site transitions into a stuck state — a failed install never retried past 24h, or an installed Companion gone silent past the snapshot-staleness threshold (3 days). Cleared back to null on recovery. The pair exists purely to make that transition detectable for alerting. Companion traffic visibility can also be conditionally gated via `canViewCompanionTraffic()`. -- Uptime: `uptime_monitoring_enabled`, `uptime_state`, `uptime_last_checked_at`, `uptime_last_up_at`, `uptime_last_status_code`, `uptime_consecutive_failures`, `uptime_down_since`, `uptime_require_keyword` (optional homepage string the 5-minute probe must find), `uptime_skip_body_check` (skip the white-screen length heuristic; fatals and required keyword still apply). +- Uptime: `uptime_monitoring_enabled`, `uptime_state`, `uptime_last_checked_at`, `uptime_last_up_at`, `uptime_last_status_code`, `uptime_consecutive_failures`, `uptime_down_since`, `uptime_probe_blocked_since` / `uptime_probe_blocked_alerted_at` / `uptime_consecutive_blocked` (the current run of HTTP 429 answers, its one ops notice, and how many 429s in a row it has been; see [Features → Uptime monitoring](/docs/features/uptime-monitoring)), `uptime_require_keyword` (optional homepage string the 5-minute probe must find), `uptime_skip_body_check` (skip the white-screen length heuristic; fatals and required keyword still apply). - Work logs: `site_work_logs` (`worked_on`, `hours`, `description`, `user_id`) feed the Client Reports `work_log` section. - Flagged WP admins: `ignored_wp_admins` acknowledges a specific snapshot admin (`site_id` + `subject`). Site-level `/issues` mute uses `ignored_issues.issue_type = wp_admin_flagged`. - Performance: `performance_scan_region` (GTmetrix location ID override; NULL = global default), `psi_unavailable_at` + `psi_unavailable_reason` (circuit-breaker after 3 consecutive failed GTmetrix/PSI scans; the name is historical. It protects the paid quota, so failed reads of Pressable's free host report never set it. Auto-cleared on the next successful scan or host-report read, even an unchanged one). diff --git a/resources/docs/features/backup-relay.md b/resources/docs/features/backup-relay.md index 905c036d..86ac0c39 100644 --- a/resources/docs/features/backup-relay.md +++ b/resources/docs/features/backup-relay.md @@ -181,7 +181,9 @@ SpinupWP and Pressable already take their own backups. Glacier relay on those ho For WordPress sites we **do not host** (WP Engine, Kinsta, a client box, any `custom` enroll), Companion dumps files + database and PUTs a zip straight to agency S3 Glacier Instant Retrieval. That is the backup. -- Connecting a site at `/sites/create` turns relay **on** and sets `backup_relay_frequency = daily`. +- Connecting a site at `/sites/create` turns relay **on** and sets `backup_relay_frequency = daily`, but only when the plugin's `/health` lists the `backup-create` capability. Older Companion builds have no `/backup/create` endpoint, so the site is enrolled with relay off and the flash message says why (update Companion or switch to Renegade). +- Relay cannot be switched **on** for a direct-to-S3 site without `backup-create` — from the Backups card, the single toggle or the bulk form on `/settings/backup-relay` (the bulk form enables the rest and lists the refused sites). A site that is already on stays on, so its failures keep showing. If a run reaches such a site anyway, it fails with `Companion lacks the backup-create capability; update Companion or switch to Renegade.` without calling the plugin. +- Every failed attempt is stored on the site (`backup_relay_last_attempt_at`, `backup_relay_last_error`, `backup_relay_failing_since`), whether it came from the nightly pass, Backup Now or a queued job; the next successful archive clears them. The Backups card shows the error and the start of the failure streak, so nobody needs the application log to see why a site has no fresh archive. - The site Overview **Backups** card is the per-site control: on/off, Daily / Twice weekly / Weekly, last/next, a month calendar of archives, and **Backup Now**. - Nightly `clockwork:backup-relay-run` (04:58 in `in_repo` mode; the 04:30 `--direct-only` pass in `external_agent` mode) respects that per-site cadence. Daily uses calendar day so a 15:00 Backup Now does not skip tomorrow morning. - **Backup Now** runs `clockwork:backup-relay-run --site={id} --force` in the background and writes `archives/{domain}/{Y-m-d_H-i-s}.zip` so it never collides with the scheduled daily key. @@ -192,7 +194,7 @@ For WordPress sites we **do not host** (WP Engine, Kinsta, a client box, any `cu For custom unhosted sites, Clockwork provides a safe two-step restore flow directly from the site's Backups widget (`/sites/{id}`): 1. **Two-Step Architecture**: - - **Step 1 (Stage)**: The operator selects an archive from the historical archives table and confirms the exact domain name. Control first checks `BackupArchiveEnumerator::belongsToSite()` — the object key must sit under `{archive_prefix}/{domain}/` or `{domain}/` (no `..`, no sibling domains). Then it verifies the SHA-256 integrity hash for the archive (from the `{key}.sha256.json` sidecar created during backup, or from `sites.backup_relay_last_sha256` for the newest archive). If no hash is on record, restore is strictly refused. Control generates a 24-hour presigned S3 GET URL (`BackupArchiveEnumerator::getDownloadUrl()`, `DOWNLOAD_URL_TTL_HOURS = 24`) and triggers `POST /wp-json/clockwork/v1/backup/restore/stage` on Companion. Companion refuses the download unless the URL is public HTTPS (no private/reserved IPs, no redirects), streams the zip into `wp-content/clockwork-backups/restore-staging/`, validates the SHA-256 checksum, and unpacks the archive via `ZipArchive` (or one-shot `PclZip` fallback on hosts lacking `ext-zip`). Unsafe zip entries (`../`, absolute paths) abort extract and are skipped again on file apply. Control polls `GET /wp-json/clockwork/v1/backup/restore/status` (a GET request that does not burn the HMAC replay window) until staging finishes. + - **Step 1 (Stage)**: The operator selects an archive from the historical archives table and confirms the exact domain name. Control first checks `BackupArchiveEnumerator::belongsToSite()` — the object key must sit under `{archive_prefix}/{domain}/` or `{domain}/` (no `..`, no sibling domains). Then it verifies the SHA-256 integrity hash for the archive (from the `{key}.sha256.json` sidecar created during backup, or from `sites.backup_relay_last_sha256` for the newest archive). `backup_relay_last_sha256` is reset to null whenever an archive is recorded without a hash (an object adopted after a timeout or gateway error), so an older archive's hash is never paired with a newer key. If no hash is on record, restore is strictly refused. Control generates a 24-hour presigned S3 GET URL (`BackupArchiveEnumerator::getDownloadUrl()`, `DOWNLOAD_URL_TTL_HOURS = 24`) and triggers `POST /wp-json/clockwork/v1/backup/restore/stage` on Companion. Companion refuses the download unless the URL is public HTTPS (no private/reserved IPs, no redirects), streams the zip into `wp-content/clockwork-backups/restore-staging/`, validates the SHA-256 checksum, and unpacks the archive via `ZipArchive` (or one-shot `PclZip` fallback on hosts lacking `ext-zip`). Unsafe zip entries (`../`, absolute paths) abort extract and are skipped again on file apply. Control polls `GET /wp-json/clockwork/v1/backup/restore/status` (a GET request that does not burn the HMAC replay window) until staging finishes. - **Step 2 (Apply)**: Once staged, the operator reviews the detected staging details (database dump presence, table prefix, files archive) in the modal and clicks **Apply Restore**. Companion places WordPress into maintenance mode (`.maintenance`), imports the SQL dump using gzip streaming scoped strictly to `$wpdb->prefix` (failing closed with `prefix_mismatch` if 0 tables match), and copies the restored `wp-content/` files over the active filesystem. 2. **Fail-Closed Maintenance Mode**: diff --git a/resources/docs/features/client-reports.md b/resources/docs/features/client-reports.md index 947c5cb6..acdab73b 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-10-22 +updated: 2026-11-05 author: Aaron Reimann tags: [client-reports, reporting, email, care-plans, scheduling, templates] tracks: [modules/ClientReports/**] @@ -12,15 +12,37 @@ Client reports are the monthly "here's what we did for your site" email. Everyth Open issues, decisions and next steps are tracked separately in [Internal → Client reports tracker](/docs/internal/client-reports-tracker). -## The three tabs +## The tabs -**Reports** (`/client-reports`) holds the history table and the manual **Generate** form. Pick a site, a template, a date range, and optionally a client name, email and a custom note. You land on a preview page with a **Print / Download PDF** button. +The module has four tabs: **Clients** (the landing page), **Reports**, **Scheduling** and **Templates**. The Reports tab shows how many of the latest period's reports are waiting for someone, and the Scheduling tab how many care-plan sites have no report schedule. -The preview's **Email version** button (`/client-reports/{report}/email`) shows the email exactly as the client gets it, with the To and Subject lines, any review reasons, and the plain-text version that text-only mail apps show. The preview page lays some sections out differently (updates as a table, for one), so check the email version before sending. +**Clients** (`/client-reports`, route `client-reports.index`) lists every client (the Client Management module's clients; sites belong to a client through `sites.client_id`) with, per site: the care plan (**Care plan**, or **No care plan — reports won't send**), the schedule (**Scheduled · auto/draft**, **Schedule disabled**, or **No schedule** with a one-click **Create schedule** for a care-plan site), its recipients, and a strip of the last six report periods. Each month's cell links to that month's report and is coloured by its state: sent, needs review, outdated, ready to send, partly sent, **skipped** (the schedule existed but the site had no care plan) or no report. The list is **organised by care plan**. A client counts as a care-plan client when any of its active sites has a care plan (its other sites stay listed with it, marked **No care plan — reports won't send**). By default the page lists the care-plan clients under **Care plan clients — reported monthly**, and folds everyone else into a collapsed **No care plan — reports aren't sent (N)** section below. The **Care plan / No care plan / All** switch changes that: **No care plan** lists only those, **All** lists both, care-plan clients first. Sites without a client follow the same split: an **Unassigned sites** entry after the care-plan clients holds the active care-plan ones, and another in the No care plan section holds the rest. **Order: Needs attention** (the default) puts clients with a report waiting or a care-plan site missing a schedule first, then A–Z; **A–Z** is alphabetical (the Unassigned entry after the clients either way). Archived sites are left out; inactive ones are dimmed and never count as missing a schedule. Filters: search (client, contact, site or recipient), **Reports waiting** (a site's newest report is waiting for review or sending) and **Missing a schedule**, applied to both sections. The switch, the order and the filters all live in the query string, so paging and the client pages keep them, and the four tiles count the current care-plan selection (the waiting and no-schedule tiles are those filters). -The paper-plane button in the history table emails a report to every address in its `client_email`, which scheduled reports fill with all their recipients, comma-separated. On a **Partly sent** report it only emails the addresses that don't have it yet; on a **Sent** one it sends to everyone again. If a report has no address saved, the button asks for one (you can enter several, comma-separated) and saves it on the report. A **superseded** report can't be sent. Sending a scheduled report by hand stamps its schedule's `last_sent_at`, the same as an automatic send. +Click a client for its **reports page** (`/client-reports/clients/{id}`, or `/client-reports/clients/unassigned`): the client's contacts and care-plan summary, then each site with its schedule (status, mode, recipients, next run, last sent, **Edit**, **Enable/Disable**) and the full history of its reports, each with its state, short hold-reason chips and **View**, **Email**, **Recompile**, **Send** and **Test to me**. Replaced copies are hidden behind a "Show replaced copies" link. **Previous**/**Next** (or ← / →) step through the client list in the order it was shown, with the care-plan switch, order and filters it was opened with (the folded No care plan clients come after the care-plan ones), and **Go to client…** jumps to any client by name. -History statuses: **Generated** (compiled, not sent), **Needs review** (held, see below), **Partly sent** (some recipients got it), **Sent**, and **Superseded** (a newer report for the same schedule and period replaced it before it reached anyone). +**Reports** (`/client-reports/reports`, route `client-reports.reports`) lists one period at a time, the latest by default, and hides replaced (superseded) copies unless **Show replaced copies** is ticked. Filter by period, status (**Waiting**, i.e. anything unsent, **Needs review**, **Outdated**, **Ready to send**, **Partly sent**, **Sent**, **Replaced**), hold reason and a search over site, client and recipient. The summary tiles count the period's reports per status and are filter links too. Every filter lives in the query string, so paging, opening a report and coming back keep it. Each row shows short chips for why a report is held (hover for the full sentences) and labelled actions. + +Hold-reason categories are worked out from the stored `meta.review_reasons` sentences by `Modules\ClientReports\Support\ReviewReasonCategories` (keyword rules, most specific first; anything unrecognised is **Other**): First report, Malware & core files (including blocklists and core-file checks), Vulnerable plugins, Updates (including a silent Companion), Backups, Uptime, Traffic, Contact forms and Partial month. If `ReportReviewFlags` rewords a reason, check that `tests/Unit/Modules/ClientReportReviewCategoriesTest.php` still puts it in the right category. Report states come from `Modules\ClientReports\Support\ReportState`: **Ready to send** means nothing to fix, either a clean draft or a report held only because it's its schedule's first. + +**Recompile all outdated (N)** recompiles every outdated, unsent scheduled report of the selected period; **Recompile selected** does the ticked ones. Both go through the same `ScheduledReportRunner::recompile()` as the Recompile button, at most 100 per press, never send, and say how many were recompiled, held, skipped or failed. + +The one-off **Generate a one-off report** form at the bottom still compiles any site and date range by hand. Pick a site, a template, a date range, and optionally a client name, email and a custom note. You land on the preview. A period is always whole days: the compiler runs from the start of the first day to the end of the last, and `meta.period_days` is a whole number (31 for October). + +**The operator preview** (`/client-reports/{report}`) has a sticky toolbar, on the operator preview only: **Back to list** (or **Back to client**) with the filters and page it was opened from, **Previous**/**Next** through that same filtered list, **Email version**, **Recompile**, **Print / PDF**, **Test to me** and **Send**. Send is disabled with the reason shown when the report is outdated, replaced or already with everyone. Keys: ← / → previous and next, **e** the email version. The review reasons are grouped by category, each with a link to the matching site dashboard tab (Security, Updates, Traffic, Forms or the overview), or to the schedule for a first report. + +The **Email version** (`/client-reports/{report}/email`) shows the email exactly as the client gets it: From, To (who pressing Send would email now), Sent to, Subject, the grouped review reasons, and tabs for the HTML email and the plain-text version that text-only mail apps show, with the same previous/next and actions as the preview (← / → and **v** for the report page). The preview page lays sections out differently (updates as a table, for one), so check the email version before sending. + +The preview, the HTML email and the plain-text email show the **same sections**: one rule, `Modules\ClientReports\Support\ReportSections::visibility()`, decides from the stored report which sections the client sees, and all three views use it. A section that was compiled but has nothing true to show (no hours logged, no form tests, backups not verified, the probe doesn't check the site, no speed test, traffic missing or not believable) is left out of all three. On the operator preview it shows instead as a grey, dashed note that doesn't print, saying why, e.g. "Work log hidden from the client: no hours were logged this period." The preview's updates table and its counts come from the same cleaned-up rows as the email (`ClientReportMail::updateRows()`: WordPress core, themes and plugins), and it has a Contact forms section with the email's wording. + +The preview page is also what prints as the PDF and what the public `/reports/view/{token}` route serves. So operator-only things (the hidden-section notes and their badges, review reasons, the backup check or relay error, "no blocker reports to Control", the core-files "no checksum path" note, 429 blocked time, traffic coverage, the Recompile button and action messages, who logged each work-log entry) appear only on the operator preview, never print, and never appear on the public page. + +An unsent scheduled report has a **Recompile** button (on the reports list, the client page and the operator preview). It compiles the same period again from current data and never sends anything; see [Refreshing held drafts](#refreshing-held-drafts-recompile). + +**Send** emails a report to every address in its `client_email`, which scheduled reports fill with all their recipients, comma-separated. Before it sends, it asks for confirmation naming who gets it, and, for an `auto` schedule that has never sent, says that once this first report is sent the schedule emails its clean reports automatically. On a **Partly sent** report it only emails the addresses that don't have it yet; on a **Sent** one it sends to everyone again. If a report has no address saved, the button asks for one (you can enter several, comma-separated) and saves it on the report. A **superseded** report can't be sent. Sending a scheduled report by hand stamps its schedule's `last_sent_at`, the same as an automatic send. + +**Test to me** (`POST /client-reports/{report}/send-test`) emails the exact client email to the signed-in operator's own address and nobody else, with `[TEST]` at the start of the subject (`ClientReportTestMail`). It works on any report and changes nothing: the report isn't marked sent, nobody is added to `sent_to`, and the schedule's `last_sent_at` (which switches a schedule to sending by itself) stays as it was. + +States: **Needs review** (held, see below), **Outdated** (unsent and compiled under older report rules; it can't be sent until it's recompiled), **Ready to send** (nothing to fix: a clean draft, or held only as a schedule's first report), **Partly sent** (some recipients got it), **Sent**, and **Replaced** (`superseded`: a newer report for the same schedule and period replaced it before it reached anyone, after a recompile or a scheduled rerun). **Templates** (`/client-reports/templates`) control which sections go into a report. A template is just a name plus a list of section keys. Exactly one template is the default. You can't delete the default or the last remaining template, and un-ticking "default" on the only default is silently ignored. @@ -29,15 +51,17 @@ History statuses: **Generated** (compiled, not sent), **Needs review** (held, se - **`auto`** compiles and emails every recipient, unless something needs a person first, in which case it's held as a draft (see **Hold for review**). A schedule's **first** report is always held. It needs at least one recipient. - **`draft`** only compiles, so someone can review the report before sending it by hand. Drafts are flagged with the same review reasons. -Schedules only send for **care-plan sites** (see below); the list marks the others **No care plan — won't send**. +Schedules only send for **care-plan sites** (see below); the list marks the others **No care plan — won't send**. The list shows care-plan schedules first (**Care plan — reported monthly**) and folds the **No care plan — won't send** ones into a collapsed section below, which opens when you filter for them. It filters by why a schedule won't send (**No care plan**, **No recipients**, **Disabled**) or by **Drafts for review**, with counts, searches by site, client or recipient, and shows each schedule's latest report and its state. + +**Care-plan sites without a report schedule** (active, not archived) are listed at the top: that's the coverage gap to close. **Create schedule** sets one up in one click (`POST /client-reports/schedules/quick/{site}`): the default template, monthly, next run on the 1st, `auto` to the client's contacts (the same fallback recipients WordPress is shown: the client's emails, else the site's `client_email`), or `draft` with no recipients when there are none. **Create all N** does it for every one of them. Nothing is sent: an `auto` schedule's first report is always held for review. -Each row also has **Pause/Enable** and **Send now** buttons. +Each row also has **Pause/Enable** and **Send now** buttons. **Send now** runs the schedule for **last month**, exactly like the 06:00 run, so on a schedule that has sent before it emails a clean report straight away. To refresh a held report, use **Recompile** on the report instead. ## A report is a snapshot This is the key thing to understand. `ClientReportCompiler::compile()` queries the monitoring tables once and writes everything into `client_reports.sections_data` as JSON. That includes the branding (colours, logo, company name), captured at generation time. The preview page and the email both render from that JSON and never from live data. -So if you change the branding or fix a data bug, existing reports won't change. Generate a new one. +So if you change the branding or fix a data bug, existing reports won't change. Recompile an unsent scheduled report (see [Refreshing held drafts](#refreshing-held-drafts-recompile)), or generate a new one. ## Where each section's numbers come from @@ -45,21 +69,21 @@ The rule for every section: **if we didn't check it during the period, the repor | Key | Source | Period | When there's no data | |---|---|---|---| -| `updates` | `action_logs` rows in `ActionLog::UPDATE_TYPES` with `ok = true`, minus runs that changed nothing. What was still **waiting at the period end**: plugins, themes and WordPress core from the Companion snapshot (`pending_plugins`, `pending_themes`, `pending_core`), plus any plugin/theme/core update applied **after** the period (`updated_since_period`, for a held report recompiled days later, or a manual update). The 06:00 UTC run comes before that night's snapshot refresh (01:30 ET) and nightly updates, so "at the period end" normally means the previous day's snapshot, about 17h before the end. A snapshot taken more than 48h before the period end (Companion stopped answering) can't say, so all three are unknown (`snapshot_stale`) and the report is held. | In period | "Everything was already up to date" only when we know nothing was waiting (`ClientReportCompiler::updatesWereWaiting()`, shared by both emails and the preview); otherwise "No updates were applied this month" | -| `uptime` | `site_uptime_events`: each `down` runs until the next `up`/`maintenance`, clamped to the period (an outage already open at the start counts from the start). SLA-exempt outages are skipped. The 5 most recent outages are listed with start time and length, marked when one began before the period ("Down since before …") or was still open at its end. "Monitored" means the probe really checks the site: monitoring on, the same server/ignore-list rules as `clockwork:check-site-uptime`, and checked within a day of the period end (`uptime_last_checked_at`). No events would otherwise read as 100%. | In period | Hidden if the site isn't monitored. When it should be but hasn't been checked lately (`probe_stopped`), the report is held. | +| `updates` | `action_logs` rows in `ActionLog::UPDATE_TYPES` with `ok = true`, minus runs that changed nothing. What was still **waiting at the period end**: plugins, themes and WordPress core from the Companion snapshot (`pending_plugins`, `pending_themes`, `pending_core`), plus any plugin/theme/core update applied **after** the period (`updated_since_period`, for a held report recompiled days later, or a manual update). The 06:00 UTC run comes before that night's snapshot refresh (01:30 ET) and nightly updates, so "at the period end" normally means the previous day's snapshot, about 17h before the end. A snapshot taken more than 48h before the period end (Companion stopped answering) can't say, so all three are unknown (`snapshot_stale`) and the report is held. Updates are dated by the **report timezone's** calendar day (below), and the period is measured over those days, so an update run late in the evening isn't listed a day late. **Version changes made outside Control** (`plugin_update_observed`, `theme_update_observed`, `core_update_observed` rows, dated by the snapshot that showed the new version) are listed like any other update, once each: a change Control itself made to the same version isn't listed twice, and one seen after the period counts as waiting at its end. The agency's own connector plugins (`ClientReportCompiler::AGENCY_PLUGIN_DIRS`: Companion and Renegade) are left out of the list, the counts and the waiting updates. An update the site's later snapshot shows was **undone** (a lower version now, and a pause, auto-pause or backup restore since) is left out, kept as `updates.not_kept`, and the report is held to say so. Names are HTML-decoded, so "&" never reaches the client. | In period | `ReportPhrases::noUpdatesLine()`, shared by both emails and the report page: "Everything was up to date at the end of the month." only when we know nothing was waiting; otherwise "No updates were applied this month." It never claims no updates were needed, since something other than Control can apply one. | +| `uptime` | `site_uptime_events`: each `down` runs until the next event of another kind, clamped to the period (an outage already open at the start counts from the start). The period here is its calendar days **in the report timezone** (below), the zone outage times are shown in, so an outage at 01:00 UTC on the 1st belongs to the month before, where it reads "Sep 30, 9:00 PM EDT". The other sections list whole UTC days, as their sources record them. SLA-exempt outages are skipped. **Uptime monitoring that began part-way through the period** is measured from when it began: the first successful probe's seed transition in `action_logs`, or the site's `created_at` when that falls inside the period (a backfilled seed doesn't count). Starting on the period's first day (local) counts as the whole period. The uptime row and preview tile then say "(monitoring began Oct 17)", and the report is held with "Uptime monitoring began Oct 17, so uptime covers only part of the month." (or, when it began after the period, the section is left out and the reason says so). **A site added part-way through the period** (`created_at` after the period's first local day) is reported as such for the whole report: `meta.coverage_start`, "Monitoring began Oct 19, so this report covers Oct 19 – Oct 31." in the intro, and held. Monitoring switched on later for a site added long before is an uptime matter only, so the intro doesn't claim the whole report covers part of the month while the other sections list the whole month's work. **A care plan switched on part-way through the period** starts coverage the same way: the last off-to-on switch of the care plan inside the period (the site page's toggle or the command-line form, after the period's first local day) is the coverage start when it's later than the site's own, with `meta.coverage_source = care_plan`. The client reads "Your care plan began Oct 28, so this report covers Oct 28 – Oct 31." (`ReportPhrases::coverageNote()`, in both emails and the report page) and the report is held with "The care plan was switched on Oct 28, so this report covers only part of the month." **The availability figure is coloured by value** in the email and on the report page (`ReportPhrases::uptimeTone()`): green from 99.9%, amber from 99%, red below. **Every outage of an hour or more** (`uptime.long_outages`, uncapped, so one that fell off the five listed still counts) holds the report with its date and length, so a person adds context first; one still open at the end keeps its own "still down" reason. **HTTP 429 is never downtime**: a host rate-limiting the monitor leaves a `probe_blocked` event (or, before the monitor had that state, a `down` with status 429), and the time until the next event of another kind is counted apart as `probe_blocked_minutes`, shown only on the operator preview. A `probe_blocked` event ends an open outage (the site answers again), and a real `down` while blocked starts one. A block still open at the period end is held with "The uptime monitor was blocked (HTTP 429) from …; the site was not necessarily down." instead of "still down". The 5 most recent outages are listed with start time and length, marked when one began before the period ("Down since before Oct 1", date only, since its stored start is the period start) or was still open at its end. Start times are stored in UTC and shown in the report timezone, `clockwork.reports.timezone` (`CLOCKWORK_REPORTS_TIMEZONE`, default `America/New_York`), with the zone's abbreviation ("Down Oct 5, 3:12 PM EDT"). An invalid zone falls back to UTC. "Monitored" means the probe really checks the site: monitoring on, the same server/ignore-list rules as `clockwork:check-site-uptime`, and checked within a day of the period end (`uptime_last_checked_at`). No events would otherwise read as 100%. | In period (from when monitoring began) | Hidden if the site isn't monitored, or monitoring began after the period. When it should be monitored but hasn't been checked lately (`probe_stopped`), the report is held. | | `security` scans | `site_security_scans`, excluding `failed` scans (they never completed); warnings = `warning` + `issues_found` | In period | Count of 0 | -| `security` threats blocked | `blocked_ips` rows banned in the period (lockouts pulled from Gatekeeper or Limit Login Attempts Reloaded) | In period | Left out (tile and row) when nothing on the site reports blocks to Control. A 0 is shown only when a blocker is reporting. | +| `security` threats blocked | `blocked_ips` rows banned in the period (lockouts pulled from Gatekeeper, Limit Login Attempts Reloaded or Wordfence), **leaving out bans that were mistakes**: decided as a false positive, or lifted within 60 minutes (`BAN_FALSE_POSITIVE_MINUTES`) | In period | "Malicious visitors blocked" shows a number only when it was **measured all month**: the site reports its lockouts to Control now, and lockouts from the fleet were still arriving within 72 hours of the period end (`LOCKOUT_INGEST_MAX_GAP_HOURS`; the pull stamps `last_llar_pull_at` even when nothing arrives, so that alone proves nothing). A ban in the period is no longer taken as proof the whole month was measured. Review-queue rows are not evidence: they are the lockouts that **didn't** become bans. When any lockout created in the period is still `pending`/`queued_for_ban` or `failed` (`unbanned_lockouts`), the count is hidden even beside real bans. Otherwise `blocked_threats_count` is null and the tile and row are left out, so an unmeasured 0 never reads as "no attacks"; `security.blocks_unmeasured` (`unbanned_lockouts`, `ingestion_stopped`, `no_lockout_feed`) and `lockouts_last_ingested_at` say why, and the operator preview words it with `ReportPhrases::blockedVisitorsNote()` ("Malicious visitors blocked hidden from the client: …"). A hidden count doesn't hold the report: with it left out the email is accurate. "Block attackers at the server" appears only where that jail exists (`blocks_at_server`); elsewhere the copy says "block malicious visitors". | | `security` blocklists | Latest completed `blacklist` scan in the period | In period | "Not checked this month" | -| `security` malware | Latest completed `companion_malware` scan in the period, falling back to `sitecheck` | In period | "Not checked this month" | -| `security` core files | Latest *completed* `core_checksums` scan in the period. A site counts as WordPress if `is_wordpress` is set **or** Companion reports a `wp_core`. | In period | "Not checked this month"; row left out for non-WordPress sites | -| `security` vulnerabilities | `PluginVulnerabilityMatcher` (installed plugins from the Companion snapshot vs. the vulnerability feed). Matches in inactive plugins are noted separately. | **Snapshot date**, shown as "as of …" | "Not checked" when there's no plugin list, **or** the feed hasn't refreshed in 48h (an empty or stale feed would otherwise read as "None found"). A plugin whose lookup failed in the nightly refresh keeps its previous entries rather than dropping out. | +| `security` malware | Latest completed `companion_malware` scan in the period, falling back to `sitecheck`. A clean scan that didn't cover the whole site shows **"Partial scan"**, never "No malware found" (`malware_status = incomplete`): Companion stopped at its file or time limit, or an SSH check hit its result cap (`scan_aborted`). SSH scans never count files, and a check can time out or be cut off and still look clean, so a clean SSH scan counts as complete only when every check proved it finished (`ssh_checks_verified`); one whose checks didn't is partial and the reason names them, and SSH rows recorded before that marker existed always said `scan_aborted = false`, so a clean one stays partial. Finding kinds, the scan id and the worst result of any scan in the period are kept for the review reasons; file paths never are. Every Companion scan in the period is read with the scanner's own-source exemption applied again (`CompanionMalwareScanner::isOwnScannerSource`, after path normalization), so rows stored before the scanner skipped its own signature list don't count as "an earlier scan found malware", and a scan with nothing else is re-graded from the findings that remain. **Mark findings reviewed:** a held report that hasn't reached anyone, whose latest scan was graded `warning` with review-tier findings only (PHP in uploads, a recently changed wp-config, the dead `/e` modifier), has a **Mark findings reviewed** form in the operator preview's review panel (`POST /client-reports/{report}/malware-reviewed`, a note saying what was checked is required). It records who, when and the note in `security.malware_reviewed`, tied to the scan id and finding kinds the report shows, removes that review reason (the others stay), and every view then reads **"Reviewed — no threat found"** instead of "Issues flagged" (`ReportPhrases::malwareLabel()`). Suspected malware (`issues_found`) can never be marked. Recompile and a scheduled run's fresh compile carry the mark over only while the scan and its findings are unchanged. Nothing is sent. | In period | "Not checked this month" | +| `security` core files | Latest *completed* `core_checksums` scan in the period. A site counts as WordPress if `is_wordpress` is set **or** Companion reports a `wp_core`. `checksum_supported` says whether any transport can run the check on this host (server SSH on a non-ignored server, Pressable's API, or the host's command runner). | In period | "Not checked this month", and held, where the host supports a check. The row is **left out** of both emails and the public view for non-WordPress sites and on hosts with no checksum path (custom hosts); the operator preview says why. | +| `security` vulnerabilities | `PluginVulnerabilityMatcher` (installed plugins from the Companion snapshot vs. the vulnerability feed). Matches in inactive plugins are noted separately. When any are found, the affected plugins are named ("Plugins affected: …", from `vulnerable_plugins`). Each one is also kept by file slug (`vulnerable_plugin_items`) with the fixed version, the update offered and any update pause, for the review flags. | **Snapshot date**, shown as "(checked Nov 1)": it's when we looked, often the day after the period | "Not checked" when there's no plugin list, **or** the feed hasn't refreshed in 48h (an empty or stale feed would otherwise read as "None found"). A plugin whose lookup failed in the nightly refresh keeps its previous entries rather than dropping out. | | `performance` | Latest OK mobile/desktop `site_performance_scans` + phone `site_field_metrics`. **GTmetrix scans count as desktop** whatever strategy they were filed under: our GTmetrix plan has no phone emulation, so its "mobile" rows are desktop Chrome. Only Pressable's and Google's mobile results show as Mobile. Pressable's own report (read nightly, mobile and desktop) is **dated by when Pressable ran the test** (`source_generated_at`), not when we read it, and so is the "Tested …" label. | In period (by test date) | Section hidden | -| `forms` | `contact_form_test_runs`. Only **live** runs test delivery: delivered = `status = 'success'` **and** `mail_outcome = 'sent'`. **Lab** runs suppress the email, so they're reported as "accepted N of M" and never as delivered. | In period | Section hidden (`pass_rate` is null) | -| `traffic` | `site_traffic_daily` (visits, requests, bandwidth) | In period | Section hidden. "Data transferred" is left out when the host reports no byte counts (Pressable). | -| `backups` | `site_backups`: what the site's **real** backup source reported, recorded daily by `clockwork:record-site-backups` (05:30). Pressable → Pressable's backup API (files daily, plus one database backup per day); SpinupWP → the DigitalOcean Spaces bucket SpinupWP writes to; unhosted sites → our own S3 archives (Companion uploads). The email lists the **last 10 days of the period**, one row per day, plus how many days that month had a backup. That count is left out when our records for the site start after the period's first day (`covers_period` false): Pressable keeps only ~8 days, so the month we started recording would otherwise read as "backed up on 9 days". A day's size counts one backup of each kind (the newest scheduled one), so extra on-demand or hourly backups don't inflate it. | In period | Section hidden from the client. The preview shows **No backups found** (checked, none), **No backups in the last 10 days** (backups stopped mid-period) or **Could not check** (read failed, or no clean check in 48h), with the error. | -| `work_log` | `site_work_logs` (hours entered on the site page) | In period | Section hidden | +| `forms` | `contact_form_test_runs`. Only **live** runs (run by hand) send an email: "reported sent" = `status = 'success'` **and** `mail_outcome = 'sent'`, which means the site reported the email as sent; nothing watches the inbox, and the client wording says so. **Lab** runs (the schedule) suppress the email, so they're reported as "accepted N of M", counted from the run's `accepted` column (whether the form plugin took the submission), never as sent. The preview's tile shows the live send rate as "Form Emails Sent", or "N/M Form Checks Passed" for lab runs. | In period | Section hidden (`pass_rate` is null); the operator preview notes it | +| `traffic` | `site_traffic_daily` (visits, requests, bandwidth), with a `status`: `ok`; `partial` (more than one day missing, or the last day's row missing or written less than 2 hours after that day ended); `suspect` (on access logs, two IPs carry over 90% of requests, i.e. a proxy or gateway; or over 100k requests with visits under 0.1% of them); `no_data` (a host we collect traffic from, nothing recorded); `unsupported` (a host we don't collect from). Days with no requests and no visits don't count as recorded. | In period | Only `ok` is shown. `partial`, `suspect` and `no_data` are hidden and held; `unsupported` is hidden. The operator preview says why, and for a shown section how many days were recorded and from which source. "Data transferred" is left out when the host reports no byte counts (Pressable). Reports compiled before traffic had a status show as they always did. | +| `backups` | `site_backups`: what the site's **real** backup source reported, recorded daily by `clockwork:record-site-backups` (05:30). Pressable → Pressable's backup API (files daily, plus one database backup per day); SpinupWP → the DigitalOcean Spaces bucket SpinupWP writes to; unhosted sites → our own S3 archives (the backup relay). The email lists the **last 10 days of the period**, one row per day, plus how many days that month had a backup. That count is left out when our records for the site start after the period's first day (`covers_period` false): Pressable keeps only ~8 days, so the month we started recording would otherwise read as "backed up on 9 days". A day's size counts one backup of each kind (the newest scheduled one), so extra on-demand or hourly backups don't inflate it. | In period | Shown only when `verified` (backups in the last 10 days of the period). Otherwise hidden from the client and held, and the operator preview says why: no backups found (checked, none); no backups in the last 10 days (with the last date); no backup source for the host (not held); could not check (read failed, or no clean check in 48h, with the error); **`suspect`**, the newest database backup is under 1% of the database Companion reports (or under 100 KB of one over 10 MB), with both sizes; **`failing`**, a relay site with no archive in the period whose relay had been failing since on or before the period end (`sites.backup_relay_failing_since`): the preview shows a red **Failing** badge and the relay's last error, and the reason reads "Off-site backups have been failing since Oct 3, 2026: …". The error is cut to about 200 characters and URLs lose their query strings. A relay whose archives stopped part-way gets the same sentence after "Backups stopped …". A verified month from a daily source (Pressable, the relay, or SpinupWP with back-to-back days) with more than 3 days missing is held as "Backed up on only N of M days this month"; the client still reads the plain count. `destination` is stored as a whole phrase ("by your host, Pressable"); older reports that stored only the place still read correctly. | +| `work_log` | `site_work_logs` (hours entered on the site page). Who logged each entry is stored, but shown only on the operator preview. | In period | Section hidden (no entries); the operator preview notes it | -Every compiled report carries `meta.schema_version` (currently **3**: 2 = honesty fixes, 3 = recorded backups list). Reports compiled before the honesty fixes have no version. The views treat their vulnerability line as "Not checked" and hide their forms numbers, because those were computed wrongly. +Every compiled report carries `meta.schema_version` (currently **7**: 2 = honesty fixes, 3 = recorded backups list, 4 = whole-number period lengths and the support URL fix below, 5 = partial malware scans, the core-file and threats-blocked rules and slug-joined vulnerability holds, 6 = uptime measured over the period's days in the report timezone, the coverage start split from the uptime monitoring start, and SSH scans complete only when every check proved it finished, 7 = updates dated by report-timezone day with updates observed outside Control and without the agency's connector plugins or undone updates, blocked-visitor counts only when measured all month, the care-plan coverage start, long-outage holds, and the traffic, backup-days and vulnerability-range wording). After deploying a schema bump, every unsent report shows **Outdated** until it's recompiled (see [Refreshing held drafts](#refreshing-held-drafts-recompile)). Keys added since (coverage start, 429 blocked time, traffic status, backup `suspect`/`failing`, `malware_truncated_checks`) are read with defaults, so older reports render as they always did. `meta.period_days` is the period's length in whole days (31 for October). Reports compiled before the honesty fixes have no version. The views treat their vulnerability line as "Not checked" and hide their forms numbers, because those were computed wrongly. ### Backup recording @@ -67,29 +91,33 @@ Every compiled report carries `meta.schema_version` (currently **3**: 2 = honest Pressable's database backups are hourly, so one row per UTC day is kept, keyed `day:YYYY-MM-DD`; a later run's newer pick replaces it. A SpinupWP run is labelled by what's actually in the bucket (files, database, or both), and a run first seen mid-upload is replaced once the rest lands. Empty (0-byte) objects don't count as backups. -Section keys and labels are defined once in `ClientReportTemplate::SECTIONS`. Adding a section means adding it there, adding a `compileX()` method in the compiler, and adding a block to both views and the email. +Section keys and labels are defined once in `ClientReportTemplate::SECTIONS`. Adding a section means adding it there, adding a `compileX()` method in the compiler, adding its show/hide rule (and hidden note) to `ReportSections`, and adding a block to the preview and both email parts. Wording shared by the three views (outage times, backup destination and kinds, sizes, data transferred, vulnerabilities, the malware label, the "no updates" sentence, the uptime colour, the coverage note and the operator's blocked-visitors note) lives in `Modules\ClientReports\Support\ReportPhrases`, so the report page says what both email parts say. ## Scheduled sending `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. +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. 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. Held reports are never sent automatically: running the schedule again (**Send now**, `--force`) recompiles 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: - - malware scan, core files or blocklist came back `warning` / `issues_found` - - backups: none found, stopped before the last 10 days, or couldn't be checked - - the site was still down at the end of the month - - uptime monitoring is on but the probe hasn't checked the site lately, or Companion hasn't reported in the 48h before the period end - - a plugin with a known vulnerability has an update waiting (the update isn't going through, often an unlicensed premium plugin) - - it's the schedule's **first** report (`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. - - Known plugin vulnerabilities don't hold a report (about a third of sites have one at any time). +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`). + - **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. + - **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`. 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. -The command and the **Send now** button share the same code: `ScheduledReportRunner`, with delivery in `ClientReportSender`. +The command and the **Send now** button share the same code: `ScheduledReportRunner`, with delivery in `ClientReportSender`. The **Recompile** action uses the same runner (`ScheduledReportRunner::recompile()`) but never reaches the sender. + +**Recipient imports.** `clockwork:import-report-recipients ` (dry run unless `--apply`) adds recipients to existing schedules and creates missing ones as monthly `auto` schedules. A CSV row is no proof the client pays for reports, so a schedule it creates for a site **without an active care plan** is created **disabled** (the dry run says "no care plan: created disabled"), unless `--include-no-care-plan` is passed. Schedules created by earlier imports are left as they are. ### When a send fails part-way @@ -103,22 +131,39 @@ php artisan clockwork:send-client-reports --force # ignore next_run_at php artisan clockwork:send-client-reports --site=example.com # one site (id or domain) ``` -Careful: `--force` and **Send now** send for real on an `auto` schedule. On a dev copy, check `MAIL_MAILER=log` first. +Careful: `--force` and **Send now** send for real on an `auto` schedule. A held report isn't emailed only because its fresh copy is checked again, and a schedule that has never sent always holds its report as the first; once a schedule has sent (`last_sent_at` set), a clean fresh copy goes out straight away. They also always compile **last month**, so from the 1st they no longer reach the previous month's drafts. To refresh a draft, use **Recompile**. On a dev copy, check `MAIL_MAILER=log` first. + +## Refreshing held drafts (Recompile) + +**Recompile** (`POST /client-reports/{report}/recompile`, route `client-reports.recompile`) refreshes an unsent scheduled report from current data. It's the safe way to pick up whatever changed since the report was compiled: hours logged, a scan rerun, a backup recorded, a fix in Control itself after a deploy. + +- **Who gets the button:** scheduled reports that haven't reached anyone, i.e. status `generated` with an empty `sent_to` (`ClientReport::canRecompile()`). It's on the reports list, the client page and the operator preview, never on the public view; the reports list can also recompile every outdated report of a period, or the ticked ones, at once. Sent, partly sent, superseded and hand-generated reports are refused with a reason. +- **What it does:** `ScheduledReportRunner::recompile()` compiles the report's **own** `period_start`–`period_end` (not "last month"), with the schedule's current template and recipients, keeps any custom note, marks the old copy **superseded**, and runs the same review check as a scheduled run. An `auto` schedule that has never sent holds the new copy as its first report; a `draft` schedule doesn't add that reason. It runs in a transaction, so if compiling fails the old copy stays live. +- **What it never does:** send email, stamp `last_sent_at`, or move `next_run_at`. A recompiled copy with nothing to review still waits for someone to send it from the history table. +- **Outdated reports can't be sent as they are.** A report whose `meta.schema_version` is below `ClientReportCompiler::SCHEMA_VERSION` was compiled under older rules (`ClientReport::isStale()`). While nobody has it, the reports list shows **Outdated** with Send disabled, the operator preview says so, and `send()` refuses it ("Press Recompile …", or "Generate a new report …" for a hand-made one) without stamping `last_sent_at`. A scheduled run doesn't reuse such a copy either: it recompiles. A **Partly sent** or **Sent** report can still finish delivering or be resent, since its recipients already have that copy. + +To refresh the drafts after a deploy: + +1. Deploy and run the migrations, so the new data the compiler reads exists. +2. Run `php artisan clockwork:recompile-held-client-reports` (add `--dry-run` first to list them, `--outdated` for only those compiled under older rules, `--site=` for one site). It recompiles every unsent scheduled report the same way the button does and prints each new copy's review reasons; it never sends. Or open each held report (history → **Needs review** / **Outdated**) and press **Recompile**. You land on the new copy, with a message saying it was recompiled for its period and that nothing was sent, plus any reasons it's held for. +3. Check the new copy's review box and its **Email version**. Fix what you can and recompile again; send it by hand from the history table only once it's right. + +Don't use **Send now** or `clockwork:send-client-reports --force` to refresh drafts: they compile last month only, and on a schedule that has sent before they email a clean report immediately. ## The email -`ClientReportMail` puts the whole report in the email body: an HTML view plus a plain-text one (`emails/report*.blade.php`). The logo is embedded inline via `BrandLogo` so it still shows when remote images are blocked. Update rows are cleaned up into client-friendly lines ("TranslatePress 3.3.6 → 3.3.7"). Reply-To is the report brand's support email. +`ClientReportMail` puts the whole report in the email body: an HTML view plus a plain-text one (`emails/report*.blade.php`). The logo is embedded inline via `BrandLogo` so it still shows when remote images are blocked. Update rows are cleaned up into client-friendly lines ("Example Plugin 3.3.6 → 3.3.7"). The greeting uses a first name only when the report goes to one address that is a known client contact whose stored name is plainly a person's; otherwise it says "Hi there,", in both parts. The From line is the configured sending address (`MAIL_FROM_ADDRESS`) under the report brand's company name, so the inbox shows the agency whatever `MAIL_FROM_NAME` says; the **Email version** page shows it. Reply-To is the report brand's support email. The footer text, when set, appears in both the HTML and plain-text parts. There's deliberately **no "view online" link**: Control lives on a private network clients can't reach. That's also why the public `/reports/view/{token}` route exists but isn't linked anywhere useful. ## Branding -Colours, logo, company name and support details come from **Settings → Companion & White Label → Client Reports** (`/settings/companion?tab=reports`, stored as `reports.branding.*`). Blank fields fall back to the shared Companion identity. See [Companion & White Label](/docs/features/companion-branding). +Colours, logo, company name and support details come from **Settings → Companion & White Label → Client Reports** (`/settings/companion?tab=reports`, stored as `reports.branding.*`). Blank fields fall back to the shared Companion identity. The support URL falls back from the reports value to the shared Companion support URL, then to the Agency Website; it never falls back to Control's own URL (`APP_URL`), which is private. The settings form only accepts public http(s) addresses there, not localhost or private-network hosts. The email doesn't link to it today; it's stored with each report. See [Companion & White Label](/docs/features/companion-branding). The **primary colour** fills the header banner of both the email and the operator preview (and the section underlines); the logo sits on it, so use a logo that reads on that colour. The preview keeps the colours when printed or saved as a PDF. Because branding is captured in the snapshot, regenerate a report to see a colour change. ## Tests -- `tests/Feature/Modules/`: `ClientReportsControllerTest`, `ClientReportTemplatesTest`, `ClientReportSchedulesTest` (scheduling, care-plan gate, hold for review, partial sends), `ClientReportMailTest`, `ClientReportAccuracyTest` (every section's numbers and wording against the "never claim what wasn't checked" rule) and `BackupRelayDirectOnlyTest`. +- `tests/Feature/Modules/`: `ClientReportsControllerTest` (including Recompile, which must never send), `ClientReportTemplatesTest`, `ClientReportSchedulesTest` (scheduling, care-plan gate, hold for review, partial sends), `ClientReportMailTest`, `ClientReportAccuracyTest` (every section's numbers and wording against the "never claim what wasn't checked" rule), `ClientReportViewsTest` (one show/hide rule across both emails and the preview; operator-only notes) and `BackupRelayDirectOnlyTest`. - `tests/Feature/Console/RecordSiteBackupsTest.php`: the backup recorder. - `tests/Unit/ReportReviewFlagsTest.php`: the hold-for-review rules. diff --git a/resources/docs/features/clockwork-renegade.md b/resources/docs/features/clockwork-renegade.md index c5f4737f..a09d6677 100644 --- a/resources/docs/features/clockwork-renegade.md +++ b/resources/docs/features/clockwork-renegade.md @@ -103,7 +103,7 @@ When an operator enrolls a new site under **Sites → + Add Site** (`SitesContro - If Classic Companion returns 200 OK, `companion_variant = 'companion'`. - If neither endpoint responds, the operator receives an informative HTTP 404 message indicating neither variant was found. -Enrollment matches SpinupWP/Pressable imports for nightly plugin updates (`auto_updates_paused = false`) and kicks a one-shot Sucuri SiteCheck + Companion malware scan in the background so the Security tab is not empty until the next 02:00 UTC cron. WP core checksums stay SSH/Pressable-only. +Enrollment matches SpinupWP/Pressable imports for nightly plugin updates (`auto_updates_paused = false`) and kicks a one-shot Sucuri SiteCheck + Companion malware scan in the background so the Security tab is not empty until the next 02:00 UTC cron. It also starts one performance scan in the background (see [Performance scans](/docs/features/performance-scans)). WP core checksums stay SSH/Pressable-only; the nightly checksum run skips standalone sites rather than recording a failure for them. Enrolment sets `is_wordpress`, so the site joins the WordPress-only jobs (lockout and firewall pulls, plugin detection). ### Download Hub diff --git a/resources/docs/features/contact-form-testing.md b/resources/docs/features/contact-form-testing.md index 20f01925..707851d4 100644 --- a/resources/docs/features/contact-form-testing.md +++ b/resources/docs/features/contact-form-testing.md @@ -2,13 +2,13 @@ title: Contact form testing section: Features order: 70 -updated: 2026-10-04 +updated: 2026-11-03 author: Aaron Reimann tags: [contact-forms, companion, testing, care-plan, slack, modules] tracks: [modules/ContactForms/src/Commands/{TestContactForms,DetectContactForms,SyncCompanionFormSubscriptions}.php, modules/ContactForms/src/ContactFormsServiceProvider.php, modules/ContactForms/src/ContactFormTester.php, modules/ContactForms/src/FormsController.php, app/Http/Controllers/FormsController.php, app/Models/ContactFormTest.php, resources/views/dashboard/forms/index.blade.php] --- -A care-plan benefit that confirms a site's contact forms are still firing. Companion injects a marker and triggers the form; we verify the receiving email lands. Failures and recoveries fire a chat alert. Care-plan sites can configure up to **3 forms per site**, each on its own schedule. Packaged as the `clockwork/contact-forms` module (`modules/ContactForms`) in the **Maintenance & QA** category. +A care-plan benefit that confirms a site's contact forms still accept submissions. Companion submits the form with a marker and reports whether the form plugin accepted it. Scheduled tests suppress the outgoing email, so they don't test delivery (see [Lab vs live](#lab-vs-live)). Failures and recoveries fire a chat alert. Care-plan sites can configure up to **3 forms per site**, each on its own schedule. Packaged as the `clockwork/contact-forms` module (`modules/ContactForms`) in the **Maintenance & QA** category. ## Module architecture & enablement @@ -23,11 +23,18 @@ Contact Form Testing is decoupled into a standalone module (`modules/ContactForm 1. The site is on a **care plan** (gate enforced at the service, command, and controller). 2. Companion is installed on the site with the `'contact-form-test'` capability advertised. 3. Operator opens the per-site **Forms** tab and adds up to 3 form-tests (form ID, frequency: weekly default, optional URL). -4. Daily at 06:00 UTC, `clockwork:test-contact-forms` walks every `contact_form_tests` row whose frequency makes it due (weekly ≥ 7 days since last run; daily ≥ 1 day) and POSTs to Companion's `/test-contact-form`. -5. Companion submits the form with a marker string in the message body. We watch the destination inbox for the marker. If the marker arrives → pass. If not within the timeout → fail. +4. Daily at 06:00 UTC, `clockwork:test-contact-forms` walks every `contact_form_tests` row whose frequency makes it due and POSTs to Companion's `/test-contact-form`. Never-tested rows are always due; daily rows are due once the last run is more than 20 hours old, weekly rows once it is more than 7 days minus 4 hours old. The grace window absorbs the few minutes each run takes, so a test that finished at 06:12 yesterday is still due when today's run starts. +5. Companion submits the form with a marker string in the message body, in **lab** mode (the scheduled default): the outgoing email is suppressed. The run passes when the form plugin accepts the submission and fails when it rejects it or Companion can't be reached. Each run writes a row to `contact_form_test_runs` keyed by `contact_form_test_id` so per-form history is preserved. Per-form state (pass/fail/streak/last_test_at) lives on the `contact_form_tests` row. +## Lab vs live + +- **Lab** (`--mode=lab`, the default for the daily schedule and the Forms tab's **Test now**): Companion submits the form with the outgoing email suppressed (`mail_outcome = 'suppressed'`). This proves the form accepts a submission, not that its email is delivered. +- **Live** (`clockwork:test-contact-forms --mode=live`, run by hand): the email goes out and the run records whether the site reported it as sent (`mail_outcome = 'sent'` or `'failed'`). Nothing watches the destination inbox, so even a live pass doesn't prove the email arrived. + +Client reports follow the same split: lab runs are shown as "accepted N of M" and never as delivered; only live runs produce a delivery rate. See [Features → Client reports](/docs/features/client-reports). + ## Where to look - **`/forms`** — fleet-wide table of every configured form-test across every care-plan site (top-nav). Its search box filters instantly as you type, no need to hit Enter — same client-side pattern as [Features → Sites (fleet view)](/docs/features/sites-fleet-view#what-you-see); see that page for how it works and its "current page only" limitation. @@ -43,7 +50,7 @@ Per-site Forms tab: 2. Click **Install Companion** on the Settings tab if not already. 3. **Detect contact forms** runs daily — Companion scans for active form plugins and caches what it finds. 4. On the Forms tab, **+ Add form**: pick a form ID from the dropdown (or paste one), URL optional, frequency = Weekly. -5. **Test now** to verify end-to-end before relying on the schedule. +5. **Test now** to confirm the form accepts a submission before relying on the schedule (a lab run, so no email is sent). Repeat for up to 3 forms. If a client needs more than 3 or a daily cadence, they contact support — daily is exposed via the `?admin=1` query string on the Forms tab. @@ -70,11 +77,11 @@ The HMAC secret can be rotated from the Settings tab too — useful when offboar ## Why this is care-plan-only -Form testing is non-trivial to run reliably (Companion install per site, HMAC rotation, marker round-trip via real SMTP). Bundling it with the care plan keeps the operations surface area aligned with the customers who already opted into managed maintenance, rather than spinning up a separate billing line. +Form testing is non-trivial to run reliably (Companion install per site, HMAC rotation, a real submission through each form plugin). Bundling it with the care plan keeps the operations surface area aligned with the customers who already opted into managed maintenance, rather than spinning up a separate billing line. ## What off-plan clients see (Companion 1.21.5+) -Off-plan clients still see the **Forms** admin page in their `Tools → Clockwork` menu — it's a deliberate sales surface. Detection runs for everyone, so they can see Clockwork already inventoried their contact forms. The Monitor toggles are visually present but locked with a "Care plan" pill; the upsell banner at the top names the specific features they'd get (weekly tests, email-delivery verification, the Mattermost ping when a form silently breaks). Re-detect still works so they can demo it. If the site previously had a care plan and has subscriptions, those render as "Forms you were monitoring" with a "Care plan paused" pill — Stop-monitoring still works for cleanup, Test-now is gated. +Off-plan clients still see the **Forms** admin page in their `Tools → Clockwork` menu — it's a deliberate sales surface. Detection runs for everyone, so they can see Clockwork already inventoried their contact forms. The Monitor toggles are visually present but locked with a "Care plan" pill; the upsell banner at the top names the specific features they'd get (weekly test submissions, the Mattermost ping when a form stops accepting them). The banner text lives in Companion; it must not promise email-delivery verification, which the scheduled tests don't do. Re-detect still works so they can demo it. If the site previously had a care plan and has subscriptions, those render as "Forms you were monitoring" with a "Care plan paused" pill — Stop-monitoring still works for cleanup, Test-now is gated. This is the same upsell pattern Performance / Security / Backups use — see [decision #27 in the architecture notes](/docs/architecture/clockwork-decisions) for the underlying principle ("show what they're missing, don't hide the feature"). Defense-in-depth: the `wp_ajax_clockwork_companion_subscribe_form` and `wp_ajax_clockwork_companion_test_form_now` endpoints refuse with `403 + error: care_plan_required` when off-plan, so a clever admin can't bypass the disabled UI. @@ -83,5 +90,5 @@ This is the same upsell pattern Performance / Security / Backups use — see [de - **Care-plan flag is the master gate.** Removing the care plan on a site stops its form tests immediately (next scheduler pass skips the rows). The rows themselves stay — re-enabling the care plan resumes testing without re-configuration. - **Companion install is intentionally manual.** Each site gets the plugin via the per-site button. - **Detect + test commands are no-ops** on sites without Companion. Safe to leave running fleet-wide. -- **The marker must round-trip to a real inbox.** If the form's destination email is bounced or the box is unreachable, we'll see the form as failing even though the form plugin itself is fine. +- **A pass doesn't mean the email arrived.** Scheduled (lab) runs suppress the email, and live runs only record whether the site reported it as sent. A form whose notification email bounces or goes to spam can still pass. - **Schema migration safety net:** the seven `sites.contact_form_test_*` columns are still in the table for one release cycle as a backup; they're no longer read. A follow-up migration drops them. diff --git a/resources/docs/features/performance-scans.md b/resources/docs/features/performance-scans.md index fb330d1c..56cb402d 100644 --- a/resources/docs/features/performance-scans.md +++ b/resources/docs/features/performance-scans.md @@ -2,7 +2,7 @@ title: Performance scans section: Features order: 50 -updated: 2026-10-15 +updated: 2026-11-03 author: Aaron Reimann tags: [performance, lighthouse, gtmetrix, psi, care-plan, pressable] tracks: [app/Services/Performance/**, app/Console/Commands/RunPerformanceScans.php, modules/Pressable/src/PressableLighthouseClient.php, modules/Pressable/src/PressableClient.php] @@ -42,9 +42,13 @@ CLS is stored as `cls_x1000` (CLS × 1000 as integer) to avoid float drift in My **Three scheduled runs per night:** 04:45 UTC scans 1/7th of the GTmetrix/PSI fleet (`--weekly-rotation`), so each of those sites gets one primary-engine scan per week; 05:05 (mobile) and 05:15 (desktop) read Pressable's own report for every Pressable care-plan site (`--host-reports`). Host-report reads are free, need no GTmetrix/PSI key, and never trip the "unavailable" flag (that flag protects the paid quota): a failed read is just retried the next night. -Why the rotation: GTmetrix refills API credits daily at ~04:27 UTC and **credits don't accumulate**. The full-fleet nightly run would burn credits quickly and fall back to PSI. The rotation slices sites (ordered by domain) into 7 near-equal buckets by `index % 7 == UTC weekday`, ~8 sites per night now that Pressable sites are left out, which fits comfortably within the daily budget. A site's scan weekday can shift when sites are added/removed — fine, since scores are compared week-over-week either way. +Why the rotation: GTmetrix refills API credits daily at ~04:27 UTC and **credits don't accumulate**. The full-fleet nightly run would burn credits quickly and fall back to PSI. The rotation gives each site a fixed weekday, `crc32(domain) % 7 == UTC weekday`, so roughly 1/7th of the GTmetrix/PSI fleet is scanned each night. The weekday depends on the site's own domain only: enrolling or removing other sites never moves it. (Until 2026-11 the bucket was the site's position in the domain-sorted list, so every enrolment shifted every site's weekday, and some sites went more than a week without a scan.) -The run time is scheduled at 04:45 UTC to land just *after* the credit refill. The Advanced plan provides 50 credits/day, and the weekly rotation keeps generous headroom for on-demand manual scans. +**Month-end catch-up.** A client report only shows a speed test run inside its month, and a site can miss its weekday (the scan failed, or it came under care after its slot had passed). On each of the last 7 nights of a month the rotation also takes care-plan sites with no OK scan since the 1st, within the daily credit budget (`CLOCKWORK_GTMETRIX_DAILY_CREDITS`, default 10, the smallest (Core) plan's allowance; set it to your plan's daily credits): free credits go to sites whose own weekday won't come round again that month first; if that's not enough, tonight's sites that already have a scan this month give up their slot and are scanned again next week. Anything that still doesn't fit waits for the next night rather than falling back to PSI. The run prints a `Month-end catch-up:` line with the counts. + +**Baseline scan.** Standalone enrolment (`SitesController::store`) and switching a site's care plan on (`SitesController::toggleCarePlan`) each start one detached `clockwork:run-performance-scans --site= --strategy=mobile`, so a new site doesn't wait up to a week for its first score. + +The run time is scheduled at 04:45 UTC to land just *after* the credit refill. Credits beyond tonight's rotation bucket are headroom for on-demand manual scans and the month-end catch-up, so `CLOCKWORK_GTMETRIX_DAILY_CREDITS` should match the plan's real daily allowance. It's one scan per site per run, not two. GTmetrix's tier doesn't expose device emulation (`/devices` + `/connections` return empty; `/browsers` only lists desktop Chrome and Firefox), so "mobile" and "desktop" strategies would submit identical desktop-Chrome scans. Running twice would double quota usage for identical data. @@ -103,7 +107,7 @@ Recovery is automatic: any successful scan (via `--site=X` smoke-test, or an `-- | `--site=X` | Bypasses both the care-plan filter and the unavailable gate. Use for manual retry. | | `--include-unavailable` | Runs against flagged sites for "is the engine back?" probes. | | `--engine=gtmetrix\|psi` | Forces one engine, bypassing the fallback chain. Ignored for Pressable sites (always read from Pressable's report). | -| `--weekly-rotation` | Scan only tonight's 1/7th slice of the fleet (GTmetrix/PSI sites; Pressable sites are left out). What the scheduler passes. Ignored when `--site` is given. | +| `--weekly-rotation` | Scan only tonight's 1/7th slice of the fleet (GTmetrix/PSI sites; Pressable sites are left out), plus the month-end catch-up in a month's last 7 nights. What the scheduler passes. Ignored when `--site` is given. | | `--host-reports` | Only sites whose host runs its own report (Pressable), read nightly. Ignored when `--site` is given. | The site's Performance tab surfaces `psi_unavailable_reason` as a banner so you know why scans stopped. diff --git a/resources/docs/features/security-scans.md b/resources/docs/features/security-scans.md index 285cdfe2..19ed0353 100644 --- a/resources/docs/features/security-scans.md +++ b/resources/docs/features/security-scans.md @@ -2,7 +2,7 @@ title: Security scans section: Features order: 40 -updated: 2026-10-15 +updated: 2026-11-04 author: Aaron Reimann tags: [security, scans, sucuri, blacklist, checksums, allowlist, care-plan, wordpress-7, pressable, modules, admins, closed-plugins, cisa-kev] tracks: [app/Services/Security/**, modules/Sucuri/src/**, app/Console/Commands/{ScanSiteCheck,CheckBlacklists,VerifyWpCoreChecksums,PressableSecuritySummaryReport,RefreshClosedPlugins,RefreshCisaKev}.php, app/Models/SiteCoreChecksumAllowlist.php, app/Models/IgnoredWpAdmin.php, app/Models/PluginDirectoryStatus.php, app/Models/CisaKevEntry.php, modules/Pressable/src/**, app/Http/Controllers/SecurityScansController.php, app/Http/Controllers/SecurityScansSettingsController.php, app/Http/Controllers/SecurityAdminsController.php, app/Http/Controllers/SitesController.php] @@ -88,7 +88,11 @@ Toggle panel for the three commands that respect a `security_scans.{key}_enabled ## Alerting on new findings -`SecurityScanRecorder` (the single chokepoint every scan type's result flows through — see `app/Services/Security/SecurityScanRecorder.php`) fires `ChatNotifier::malwareFindingDetected()` on the **clean → malware-hit transition only**: it checks the immediately-preceding scan of the same type for the same site and only alerts if that one was clean and this one isn't, so a site that remains in an issue state doesn't repeatedly spam alerts on every run. Same transition-based suppression shape as SSL-state and uptime-transition alerts. See [Integrations → Mattermost](/docs/integrations/mattermost) / [Slack](/docs/integrations/slack) for the toggle. +`SecurityScanRecorder` (the single chokepoint every scan type's result flows through — see `app/Services/Security/SecurityScanRecorder.php`) fires `ChatNotifier::malwareFindingDetected()` on the **clean → malware-hit transition only**: it checks the latest earlier **non-failed** scan of the same type for the same site and only alerts if that one had no malware hit and this one does, so a site that remains in an issue state doesn't repeatedly spam alerts on every run. Failed scans are skipped because they carry no verdict; otherwise every transport outage in between would re-alert. Same transition-based suppression shape as SSL-state and uptime-transition alerts. The Companion malware probe sets `has_malware_hit` when any finding kind is outside `WARNING_KINDS` (i.e. the scan is `issues_found`), so it alerts through the same path as Sucuri. + +`php_in_uploads` is a review-tier finding, so it never sets `has_malware_hit`. Instead the recorder fires `ChatNotifier::phpInUploadsDetected()` for each **new** PHP file under uploads: paths in this Companion scan that the previous non-failed scan of the site did not report, compared after path normalization. Each file alerts once, on the scan where it first appears; long-standing files (plugin logs and the like) stay quiet. A site's first scan reports every path, since nobody has reviewed them yet. Suppressed paths never alert. It has its own toggle (`php_in_uploads_detected`). + +See [Integrations → Mattermost](/docs/integrations/mattermost) / [Slack](/docs/integrations/slack) for the toggles. ## Manual run @@ -114,7 +118,11 @@ php artisan clockwork:verify-wp-core-checksums --site=42 - `wp_config_recently_modified` — wp-config.php mtime within 30 days; operator should confirm the change was authorized. - `php_in_uploads` — PHP file found under `wp-content/uploads/`. Legitimately placed by plugins like Smush (log files), so yellow rather than red. Still worth a glance to confirm the path is from a known plugin. - `preg_replace_e_modifier` — the `/e` modifier was a historic backdoor technique, but was **removed in PHP 7.0**, so on modern PHP fleets a match is dead version-guarded code. In practice it only flags legacy vendor code. A genuine backdoor also trips the `eval_*`/`shell_*` signatures, which stay `issues_found`. -- **Our own scanner is never a finding.** `MalwareScanner.php` lists every signature it searches for, so when Companion (or Renegade) is installed as a regular plugin, inside the scanned tree, it used to flag itself as a webshell. `CompanionMalwareScanner::isOwnScannerSource()` drops findings in `wp-content/(mu-)plugins/clockwork-{companion,renegade}/src/SecurityScans/` only; anything else in our plugin folder is still flagged. +- **Our own scanner is never a finding.** `MalwareScanner.php` lists every signature it searches for, so when Companion (or Renegade) is installed as a regular plugin, inside the scanned tree, it used to flag itself as a webshell. `CompanionMalwareScanner::isOwnScannerSource()` drops findings in `wp-content/(mu-)plugins/clockwork-{companion,renegade}/src/SecurityScans/` only (backslash separators are read as `/`); anything else in our plugin folder is still flagged. +- **Finding paths are relative to the WordPress root.** Hosts report paths in different shapes: relative, absolute from a managed host's docroot (`/srv/htdocs/…`, `/wordpress/core/…`), or with Windows separators. `CompanionMalwareScanner::normalizeFindingPath()` converts backslashes to `/` and strips a known docroot prefix (the site's `wp_path`, an ABSPATH reported in the Companion snapshot, and those managed-host roots) before anything compares or stores a path. Only whole known prefixes are stripped, so a root-level file becomes `file.php` and a path outside every known root stays absolute. Rows stored before this change may still hold absolute paths; everything that compares paths normalizes both sides. +- **Per-path suppression is per kind.** `clockwork:suppress-malware-path` stores a normalized path in `companion_finding_state._suppressed` for one of `CompanionMalwareScanner::SUPPRESSIBLE_KINDS`: `php_in_uploads` (the default, and the original `php_in_uploads_suppressed` list) or `preg_replace_e_modifier` (for a known legacy vendor file). A suppression drops only that kind at that path, so any other kind found in the same file is still reported. The positive signatures (`eval_*`, `shell_*`) cannot be suppressed. Suppression only affects scans run after it is added. +- **SSH scans can be partial.** The SSH transport caps output per check (`SSH_UPLOADS_LIMIT` = 100 uploads paths, `SSH_SIGNATURE_LIMIT` = 50 files per signature). When a check hits its cap the scan is stored with `scan_aborted = true`, `abort_reason = ssh_result_limit` and the capped checks in `details.truncated_checks`, the same way a Companion scan that stops at its file limit is marked. Every SSH scan stores `truncated_checks` (an empty list when no check hit its cap), so a complete SSH scan can be told apart from one recorded before cut-short scans were detected: those rows always said `scan_aborted = false` and have no `truncated_checks`. SSH scans always store `scanned_files_count = 0`, so that count says nothing about coverage. +- **Every SSH check must prove it finished.** Before the checks, a pre-check confirms the docroot exists and holds a `wp-config.php` (`__CW_OK__`); without it the scan fails rather than reading a wrong or guessed path as clean. Each check then runs through `SshClient::execChecked()`, which throws `SshCommandTimedOut` instead of handing back the output that arrived before the timeout, and prints a completion sentinel (`__CW_DONE__`) after its pipeline. A check that timed out or lost its sentinel is listed in `details.incomplete_checks`, and the scan is stored `scan_aborted = true` with `abort_reason = ssh_check_incomplete` (findings it did list are kept). `details.ssh_checks_verified` is true only when every check finished. The uploads check hands file paths to `sh` as arguments (`find … -exec sh -c '…' _ {} +`), so a quote in a file name can no longer silently end it the way `xargs` did. Client reports count a clean SSH scan as complete only when `ssh_checks_verified` is set; clean SSH rows recorded before it (with or without `truncated_checks`) read as a partial scan. - **`issues_found`** (red) — at least one real-malware finding (PHP backdoor in uploads, obfuscation signature, etc.). The `details` JSON has the structured verdict. Drives the Issues page. - **`failed`** (yellow, "Failed") — couldn't run. Common cause for Sucuri: CF WAF 403'd the scanner. Companion's Security page recognises this shape and renders "Blocked by site firewall (likely Cloudflare)" instead of a red alarm. The blacklist scan covers the gap. @@ -133,6 +141,8 @@ Output parsing (the modified / missing / should-not-exist regex logic + allowlis `clockwork:verify-wp-core-checksums`'s site-selection query uses `Site::hostMonitored()` (not the old SpinupWP-only server scope), so the nightly fleet-wide run reaches care-plan Pressable sites too. +A site with **no transport at all** (no `CAP_SSH`, not Pressable, and no host `commandRunner()`, e.g. a standalone `custom` site) can never be checked. `WpCoreChecksumVerifier::hasTransport()` mirrors the dispatch above, and the command skips those sites, counting them as `skipped=` in its summary line and logging their ids, instead of writing a `failed` / `no_command_runner` row every night and exiting non-zero. + ## Pressable-only: vulnerability alerts + Defensive Mode Two Pressable-native signals with no SpinupWP equivalent, pushed daily (06:39) by `clockwork:pressable-security-summary-report` to each Companion-equipped Pressable site's `/security-summary-report` endpoint, and rendered on Companion's Security admin page (v1.31.6+): @@ -253,7 +263,7 @@ The suppressed count is still recorded in the scan's `details.ignored_unexpected ## Gotchas -- **The malware scanner must exclude itself.** The signature needles (`eval(base64_decode(`, `c99shell`, …) exist as literal strings inside Companion's own `MalwareScanner.php`, so both scan paths exclude the `clockwork-companion` directory — the Companion-side scanner natively, and the SSH-transport grep via `--exclude-dir=clockwork-companion`. Without it every Companion-equipped docroot would self-flag findings. Because the Companion-side exclusion missed copies installed as a regular plugin, Control also drops any finding under `wp-content/(mu-)plugins/clockwork-{companion,renegade}/src/SecurityScans/` for both transports (`CompanionMalwareScanner::isOwnScannerSource()`). PHP-in-uploads (check 1) keeps no exclusion. +- **The malware scanner must exclude itself.** The signature needles (`eval(base64_decode(`, `c99shell`, …) exist as literal strings inside Companion's own `MalwareScanner.php`, so both scan paths exclude the `clockwork-companion` directory — the Companion-side scanner natively, and the SSH-transport grep via `--exclude-dir=clockwork-companion` (plus `--exclude-dir=clockwork-renegade`, which ships the same scanner source). Without it every Companion-equipped docroot would self-flag findings. Because the Companion-side exclusion missed copies installed as a regular plugin, Control also drops any finding under `wp-content/(mu-)plugins/clockwork-{companion,renegade}/src/SecurityScans/` for both transports (`CompanionMalwareScanner::isOwnScannerSource()`). PHP-in-uploads (check 1) keeps no exclusion. - **`echo` not `printf %s`** when feeding sudo's stdin password during checksum runs. `printf %s` (no trailing newline) makes sudo wait on EOF and `wp` exits silently with empty output. The plugin detector accidentally hides this via a trailing `| grep ... || true`; the security verifier doesn't, so it must use `echo`. - **Sucuri rate-limited around 30 req/min.** The artisan loop sleeps 250 ms between sites. A 60-site care-plan run takes about half a minute on top of the per-site scan time. - **Care-plan toggle drives Sucuri + checksums automatically.** When Bill.com flips a site off care plan, those scans stop running for it on the next cycle. Blacklist keeps going (hosting tier). diff --git a/resources/docs/features/traffic-and-capacity.md b/resources/docs/features/traffic-and-capacity.md index bf60985d..9f5cf506 100644 --- a/resources/docs/features/traffic-and-capacity.md +++ b/resources/docs/features/traffic-and-capacity.md @@ -2,7 +2,7 @@ title: Traffic + capacity section: Features order: 120 -updated: 2026-09-28 +updated: 2026-11-03 author: Aaron Reimann tags: [traffic, capacity, visits, analytics, pressable, eol, php] tracks: [app/Console/Commands/RollupTraffic.php, app/Http/Controllers/CapacityController.php, app/Services/Runtime/**, app/Console/Commands/RefreshRuntimeEol.php, app/Console/Commands/PressableTrafficReport.php, modules/Pressable/src/PressableClient.php] @@ -90,11 +90,11 @@ So if you need to backfill rollups for a date older than 30 days, the data isn't ## Pressable traffic reporting — pushed to Companion, not this page -`clockwork:pressable-traffic-report` (daily 06:37) builds a 30-day **daily** rollup for each Companion-equipped Pressable site — sourced from Pressable's time-series metrics API (`POST /sites/{id}/metrics`, via `PressableClient::siteMetrics()`). Pressable's `/metrics` endpoint provides per-day requests (by HTTP status), daily unique visitors, and per-path request counts, enabling the command to build the standard `{totals: {today, month_30d}, daily: [...]}` payload shape expected by `PushCompanionTraffic`. Companion's `TrafficPage` renders the normal chart display seamlessly. +`clockwork:pressable-traffic-report` (daily 04:45) builds a 30-day **daily** rollup for each Companion-equipped Pressable site — sourced from Pressable's time-series metrics API (`POST /sites/{id}/metrics`, via `PressableClient::siteMetrics()`). Pressable's `/metrics` endpoint provides per-day requests (by HTTP status), daily unique visitors, and per-path request counts, enabling the command to build the standard `{totals: {today, month_30d}, daily: [...]}` payload shape expected by `PushCompanionTraffic`. Companion's `TrafficPage` renders the normal chart display seamlessly. Two real Pressable API quirks the command has to work around: metrics/dimensions must come from the same "family" to combine (mixing `views` with `http_status` silently returns empty rather than erroring), and the auto-selected time resolution differs by family for the same date range (daily buckets for Uniques & Views, 8-hour buckets for Edge Logs — aggregated into calendar days here). -This command also upserts the same daily rows into Clockwork's `site_traffic_daily` table, which is what `/capacity` reads for Pressable MTD / rolling / trending numbers. The dashboard itself makes **one** Pressable `GET /account` per hour (5-minute negative cache on failure) and otherwise stays on local SQL. +Before any push, the command also upserts daily rows into Clockwork's `site_traffic_daily` table for **every** Pressable site with a Pressable site id, Companion or not. This is what `/capacity` reads for Pressable MTD / rolling / trending numbers, and what monthly client reports read, which is why it runs before the 06:00 report run. Only the days and metrics Pressable actually returned are written, over the last 28 days; the zero-filled days of the Companion chart are never stored, so a day Pressable has nothing for (for example before a site moved to Pressable) keeps its earlier row. `bytes_sent` and `top_paths` are never overwritten. The dashboard itself makes **one** Pressable `GET /account` per hour (5-minute negative cache on failure) and otherwise stays on local SQL. The per-site `/sites/{id}/traffic` tab still stays hidden for Pressable sites (no nginx access log to source it from). Clients see the chart in Companion wp-admin (`/traffic-report`) via this push; operators see Pressable quota on `/capacity` from the same `site_traffic_daily` rows. diff --git a/resources/docs/features/updates.md b/resources/docs/features/updates.md index e60b170c..2ff9ed4a 100644 --- a/resources/docs/features/updates.md +++ b/resources/docs/features/updates.md @@ -2,10 +2,10 @@ title: Updates section: Features order: 35 -updated: 2026-09-28 +updated: 2026-11-05 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/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*] +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*] --- Fleet-wide page for plugin / theme / WP core / translation updates. Replaces the per-site click-through workflow with a single grouped-by-name view modeled after ManageWP Orion. Lives at **`/updates`** (top-nav between Issues and Security). @@ -92,6 +92,7 @@ Bill.com sync drives `care_plan_enabled` automatically based on invoice line ite - **Manual ignore**: Triggered by an operator clicking *Ignore* on `/updates`. Sets `source = manual` and `client_visible = false`. It is kept private to Control by default (an agency decision like "hold off updating WooCommerce this week" is internal). - **Auto-ignore**: Triggered automatically when a plugin or theme repeatedly fails nightly automated updates and crosses the streak threshold. Sets `source = auto_failure` and `client_visible = true`. +- **Held as a different theme**: Written by the nightly loop when the installed theme doesn't look like the theme on offer (see *Theme slug collisions* below). Sets `source = identity_mismatch` and `client_visible = false`, and holds like a manual ignore (shown as one on the site's Updates tab, with the reason underneath). ### Auto-ignore after repeated nightly failures @@ -107,11 +108,19 @@ When a stubborn plugin or theme fails the nightly update queue night after night #### Threshold and Kill Switch - **Threshold**: Default 5 consecutive nightly failures (min 3, max 20), configurable via the `updates.auto_ignore_after_failures` setting. -- **Kill switch**: `updates.auto_ignore_enabled` (default `true`). When set to `false`, consecutive failure streaks are still tracked for operator visibility, but auto-ignore rows are never created. +- **Kill switch**: `updates.auto_ignore_enabled` (default `true`). When set to `false`, consecutive failure streaks are still tracked for operator visibility, but auto-ignore rows are never created (and the site-level pause below never fires). + +#### File-permission failures pause the site, not each plugin +Each streak stores the `reason_code` of its latest failure (see *Stalled no-op detection*), and how many counted failures in a row were `fs_permission` ("Could not copy file", "Could not create directory …/wp-content/upgrade/…") in `consecutive_fs_failures`, with the job that began that run in `fs_streak_started_job_id`. A counted failure for any other reason ends the run. When the run of `fs_permission` failures itself reaches the threshold and no plugin or theme update on that site has succeeded since the run began (uncounted transport or reaped failures in between don't move its start), the problem is the site's file ownership, not the plugin. A plugin that failed for other reasons first and then once with a file-permission error is not enough. Instead of auto-ignoring each plugin in turn, Control: +- sets `sites.auto_updates_paused = true` with a "Filesystem not writable…" reason (the same flag as the manual per-site pause, so the nightly loop skips the whole site); +- writes one `auto_updates_toggled` action log (`actor = auto`, `details.reason_code = fs_permission`); +- sends one ops chat alert. + +No per-plugin ignore is created and nothing is pushed to wp-admin. Fix the file ownership/permissions for the PHP user, then resume the site with the per-site auto-update toggle. If the site is already paused (by an operator or an earlier failure), nothing changes and no second alert is sent. If another update on the site did succeed during the streak, the plugin gets the normal per-plugin auto-ignore. #### Reset and Resume -- Any **successful update** (nightly or manual) resets `consecutive_failures` back to 0. -- When an auto-ignored plugin ships a new version, it remains paused until resumed (badged as "New version available — still paused") to prevent endless retries on broken plugins. +- Any **successful update** (nightly or manual) resets `consecutive_failures` back to 0. When it also lands on its target version, an auto-ignore for that slug is removed, the streak's pause markers are cleared, and the shorter exceptions list is pushed so the wp-admin notice clears. Manual ignores are never removed this way. +- An auto-ignore does not block forever. The nightly loop still tries an auto-ignored plugin or theme when the snapshot offers a **different version** from the one last tried (`plugin_update_failure_streaks.last_target_version`), once per new version: queueing the retry records that version as the last target (and `last_retry_queued_at`), so a retry that times out, is reaped or hits a transport error still counts as its one attempt. It also tries an auto-ignored plugin whose installed version is **vulnerable** when the offered version is at or above the highest `patched_in` of the matching advisories: at once for a fix version not tried yet, and otherwise at most once every 7 days (`RunNightlyPluginUpdates::SECURITY_RETRY_BACKOFF_DAYS`, counted from the later of the last failure and the last queued retry). A fix version that last failed with `download_unauthorized`, `no_package` or `not_offered` is not retried at all until a newer version is offered, because a licence or offer problem needs an operator. Otherwise the slug stays paused until resumed or until one of those conditions is met. - Clicking **Resume** (or unignore) in Control deletes the ignore record, resets the consecutive failure streak to 0, and immediately pushes an updated exceptions payload to the WordPress site. - Converting an auto-ignore into a **manual ignore** keeps nightly retries paused but sets `client_visible=false` and pushes an updated exceptions list so the slug drops out of wp-admin. - Manual ignores are never rewritten as auto-failure, even if the slug later fails nightly. @@ -155,6 +164,14 @@ stdout_logfile=/var/log/clockwork/plugin-updates.log Every completed update writes to `action_logs` with `action_type='plugin_update'` (or `theme_update` / `core_update` / `translations_update`). The `ActionLogger` mirrors to the per-site Companion endpoint, so the client's wp-admin "Activity" page shows what we've done. +### Updates made outside Control + +WordPress's own auto-updates, another management tool or someone in wp-admin can update a plugin before Control does. Those updates leave no job, so `App\Services\Updates\ObservedUpdateRecorder` finds them in the snapshots instead: whenever a new Companion snapshot replaces the previous one (the `Site` model's `updated` hook, so every place that writes a snapshot is covered), each installed plugin, theme or WordPress core version that went **up** gets one `action_logs` row — `plugin_update_observed`, `theme_update_observed` or `core_update_observed`, `actor = observed`, `ran_at` = the new snapshot's time, details `{slug, name, from_version, to_version, observed_at, previous_snapshot_at}` (core uses the slug `wordpress`). The update happened somewhere between `previous_snapshot_at` and `observed_at`. + +A change is not logged when Control made it — a `plugin_update_jobs` row for the same site and slug (any core job for core) created in the last 48 hours, or since the previous snapshot if that is older, that completed, is still live, or ended on exactly the new version — nor for downgrades (rollbacks, restores), items added or removed, or a change already logged with the same new version (a snapshot that flips back and forth logs once). + +These rows are informational. They are in `ActionLog::OBSERVED_UPDATE_TYPES`, not `UPDATE_TYPES` (which means "updates Control ran"), and in `ActionLog::LOCAL_ONLY_TYPES`, so neither `ActionLogger` nor `clockwork:backfill-companion-action-log` mirrors them to the client's wp-admin. They never touch update jobs, failure streaks, auto-pauses or chat alerts. + The `plugin_update_jobs` table is the **live** progress substrate — once a batch finishes, those rows are useful for "what just ran" forensics for a few days, then can be safely archived. Action logs are the immutable record. **"History"** in the page header (top-right, next to Care-plan auto-updates) links into `MaintenanceHistoryController` (`/maintenance-history`, pre-filtered to `action_type=_updates` — a synthetic filter value that expands to `plugin_update`/`theme_update`/`core_update`/`translations_update` together) — the full cross-site `action_logs` log, filterable by site/type/care-plan/outcome/actor, month-scoped with a pill-bar month picker. Originally built as a billing-reconciliation view ("what to bill, what's covered" — hence the covered/billable stats strip), it reads the exact same table this page's audit trail writes to, so it doubles as the fleet-wide update history without any separate data path. Each site's Overview tab also links here directly (`?site_id={id}`) from its Recent Activity card, which is itself capped at 25 rows — useful when you need the full, unpaginated per-site history rather than just the last couple dozen actions. @@ -165,11 +182,20 @@ Plugins-only, opt-in per site, runs every night between 02:00 and 06:00 ET. The ### How it works -1. `clockwork:run-nightly-plugin-updates` (02:00 ET) discovers every care-plan site with `auto_updates_paused = false` and `companion_installed = true`, queries its Companion snapshot for pending plugin updates, dedupes against existing `plugin_update_jobs` rows and `plugin_update_ignores`, and queues one `PluginUpdateJob` per plugin with `batch_id = "nightly-YYYY-MM-DD-..."`. The run reuses the same `plugin-updates` Supervisor queue as manual batches. At the end of every run — whether or not any jobs were queued — the loop stamps `auto_updates_last_run_at` on **all eligible** care-plan sites. This gives a daily heartbeat: "3 hours ago" means the loop ran and found nothing to do; a stale or null timestamp means the loop stopped firing, not that the site was up to date. -2. Vulnerable plugins (matched by `PluginVulnerabilityMatcher`) are sorted first so security patches land before cosmetic updates. +1. `clockwork:run-nightly-plugin-updates` (02:00 ET) discovers every care-plan site with `auto_updates_paused = false` and `companion_installed = true`, queries its Companion snapshot for pending plugin updates, dedupes against existing `plugin_update_jobs` rows and `plugin_update_ignores`, and queues one `PluginUpdateJob` per plugin with `batch_id = "nightly-YYYY-MM-DD-..."` (the America/New_York date; a `--site` run uses `nightly-YYYY-MM-DD-site-...`). The fleet-wide run happens at most once per local date: if a fleet-wide nightly batch was already queued today (as on the DST fall-back night, when 01:30 happens twice), it logs and skips. `--force` overrides that, and `--site` runs are neither blocked nor counted. The run reuses the same `plugin-updates` Supervisor queue as manual batches. At the end of every run — whether or not any jobs were queued — the loop stamps `auto_updates_last_run_at` on **all eligible** care-plan sites. This gives a daily heartbeat: "3 hours ago" means the loop ran and found nothing to do; a stale or null timestamp means the loop stopped firing, not that the site was up to date. +2. Vulnerable plugins (matched by `PluginVulnerabilityMatcher` on the plugin's directory, the part of the snapshot's `dir/file.php` slug before the slash) are sorted first so security patches land before cosmetic updates. 3. `clockwork:nightly-update-summary` (06:15 ET) reads every job whose `batch_id` starts with `nightly-` since the last summary run and emails the operator a success/failure breakdown. No jobs → no email. 4. If any job ends in `failed` and its `batch_id` is a nightly batch, `AbstractRunUpdate` fires a Mattermost `pluginUpdateFailed()` ping immediately — manual bulk failures stay quiet since the operator is watching the page. +### Theme slug collisions + +WordPress asks wordpress.org about every installed theme by folder name. A custom theme whose folder shares its name with an unrelated directory theme is offered that theme's releases as updates, and applying one replaces the custom theme wholesale — the custom code is gone and the site can fatal-error. Before queueing a theme, the nightly loop asks `App\Services\Updates\ThemeIdentityGuard`: + +1. If wordpress.org lists a theme for that folder (`api.wordpress.org/themes/info/1.2/`, one request per folder per run), the installed theme's name must match the listed name (case, punctuation, HTML entities and a trailing "Theme" ignored). A different name means a different theme. +2. If the folder isn't listed (a premium theme's own updater makes the offer) or the directory can't be reached, a child theme is refused: a snapshot `template`/`parent` field naming another theme, or — since today's snapshots carry no such field — the word "child" in the theme's name. Anything else is queued as before. + +A held-back theme is recorded once: a `skipped` `plugin_update_jobs` row with `reason_code = identity_mismatch` in that night's batch (so the 06:15 summary email lists it with the reason), an ignore with `source = identity_mismatch` (so later nights and the Updates page's bulk actions leave it alone), a local-only `theme_update_refused` action log, and one chat message. It never counts toward a failure streak or an auto-pause. If the theme really is the one on offer, unignore it on the site's Updates tab and update it by hand; the nightly loop holds it again the next time an update is offered. Plugins are not checked: the snapshot has nothing comparable for them, and wordpress.org plugin names carry taglines and edition names, so a name comparison would hold back legitimate updates. Manual updates from `/updates` are not checked either, but they skip any ignored theme. + ### Opt-in toggle `sites.auto_updates_paused` — newly-imported sites via `clockwork:import-spinupwp` and `clockwork:import-pressable`, and newly enrolled standalone / Renegade sites via Sites → Add Site, start with auto-updates enabled (`auto_updates_paused = false`), while never overriding an existing manual pause setting. Flip from: @@ -231,6 +257,7 @@ WordPress's `validate_current_theme()` can fire during the filesystem replacemen |---|---|---| | `state_before` | JSON? | Active plugins + stylesheet/template captured from local snapshot before the update runs. | | `repairs` | JSON? | Array of `{type, slug, detail}` objects from the post-update verify call. `null` if no repairs were needed or the site doesn't support post-update-verify yet. | +| `reason_code` | string? | Cause of a failed job: `fs_permission`, `download_unauthorized`, `bad_package`, `no_package`, `not_offered` or `upgrader_error`. `null` for successes and for failures with no upgrader evidence (transport, reactivation). Also stored on `plugin_update_failure_streaks` for the latest counted failure. | ### Stalled no-op detection @@ -242,6 +269,21 @@ Root cause: for licensed plugins where update feeds may require an active licens This is a Laravel-side reinterpretation of Companion's response — no Companion plugin change or fleet redeploy involved. The underlying flakiness (a premium plugin's own update source going stale) isn't something this app can fix directly; it usually means the plugin's license/purchase code needs attention in that site's wp-admin. +**Upgrader errors are not "no longer offered".** Companion/Renegade builds in the field also return `ok: true` (and `upgrade_completed: true`) when the WordPress upgrader itself failed and logged an `Error: …` line. Inside the stalled-no-op branch, Control looks for that line in `messages`. When there is one, the job's `error` is the last `Error:` line plus the line before it (the detail, e.g. which file could not be copied), `upgrade_completed` is set to `false` so post-update verify/repair is skipped, and `reason_code` records the cause: + +| `reason_code` | Upgrader output | Usually means | +|---|---|---| +| `fs_permission` | "Could not copy file", "Could not create directory", "Could not remove the old …" | PHP cannot write `wp-content/plugins` or `wp-content/upgrade` (file ownership). A site problem: see the site-level pause above. | +| `download_unauthorized` | "Download failed. Unauthorized / Forbidden", licence wording | Premium licence inactive or expired on that site. | +| `bad_package` | PCLZIP errors, "The package could not be installed" | The vendor returned something that is not a zip (often a licence error page). | +| `no_package` | "A valid URL was not provided", "Update package not available" | WordPress had no download URL (unlicensed premium plugin). | +| `upgrader_error` | Any other `Error:` line | Read the error text. | +| `not_offered` | No `Error:` line at all | The original stalled no-op described above. | + +When Companion reports the failure itself (`ok: false`), its own error text is kept and `reason_code` is still derived from the upgrader output. The action-log summary follows the job's verdict, so a stalled no-op is logged as "Plugin update failed", not "Plugin updated: 1.0 → 1.0". + +**Download URLs are redacted.** The upgrader's "Downloading update from …" lines carry licence keys, vendor tokens/JWTs and signed download URLs. `App\Support\UpgraderOutput::redact()` runs on every update result before anything is stored or sent (`plugin_update_jobs.messages`/`error`, action-log details/summary/error and their Companion activity mirror, failure streaks, ignores, chat alerts): download URLs are cut to `scheme://host/[redacted]`, other URLs lose credentials, query string and fragment (token-like path segments are masked), and JWTs, bearer tokens and secret-named `key=value` pairs are masked anywhere. Download lines are never copied into error text. Rows written before this change still hold the unredacted lines in Control's database, but every push to the Companion activity mirror (`ActionLogger` and `clockwork:backfill-companion-action-log`, including `--reset-since` re-pushes) redacts the summary, details and error on the way out, so those rows can't write licence keys back into a client's wp-admin. + ## What's NOT here - **Safe Update / backup-before-update / rollback**. Not building this. If an update breaks a site, recover from the host's normal backup (BlogVault, SpinupWP). diff --git a/resources/docs/features/uptime-monitoring.md b/resources/docs/features/uptime-monitoring.md index 612536f4..c5574462 100644 --- a/resources/docs/features/uptime-monitoring.md +++ b/resources/docs/features/uptime-monitoring.md @@ -2,7 +2,7 @@ title: Uptime monitoring section: Features order: 30 -updated: 2026-10-02 +updated: 2026-11-03 author: Aaron Reimann tags: [monitoring, uptime, alerts, hosting, pressable, slack, twilio, subscribers] tracks: [app/Services/Uptime/**, app/Support/Monitoring/**, app/Http/Controllers/MonitoringController.php, app/Http/Controllers/SitesController.php, app/Console/Commands/CheckSiteUptime.php, app/Services/Twilio/OnCallResolver.php, app/Mail/SiteDownClientMail.php, app/Mail/SiteUpClientMail.php] @@ -26,14 +26,14 @@ Monitoring queries use `Site::hostMonitored()` across the fleet, ensuring both s A site is **up** if it responds with HTTP 2xx or 3xx within 10 seconds — or if it returns 401/403 indicating it's alive but auth-protected (see below). Anything else is **down**: - HTTP 5xx -- HTTP 4xx other than 401 (with `WWW-Authenticate`) and 403 +- HTTP 4xx other than 401 (with `WWW-Authenticate`), 403, and 429 (see the rate-limit carve-out below) - Connection refused, DNS failure, TLS handshake failure - Request timeout - HTTP 200/3xx whose body is a white screen (under 50 visible characters after tag-stripping) or contains WordPress/PHP fatal signatures (`There has been a critical error`, `Fatal error`, etc.). Optional per-site `uptime_require_keyword` on the site Settings tab also fails a 200 that does not contain that string. Sites that are *legitimately* near-empty on a logged-out GET (parked static HTML, a JS-rendered SPA shell, a gated/private homepage) can skip only the length check via `uptime_skip_body_check` on the same Settings tab — signatures and the required keyword still apply. The probe does not issue a second HTTP request — it inspects the body already fetched. A site has to fail **two probes in a row** (about 10 minutes at the default cadence) before it transitions to `down` and fires the Mattermost alert. Recovery is instant — the moment the next probe succeeds, we transition back to `up` and fire the recovery alert. -The 2-failure threshold is the de-jitter logic. A single bad probe (CF blip, momentary network flicker, a brief WAF rate-limit) won't fire an alert. +The 2-failure threshold is the de-jitter logic. A single bad probe (CF blip, momentary network flicker) won't fire an alert. ## Outage fault attribution & SLA exemption ("Not Our Fault" / Client DNS) @@ -67,6 +67,19 @@ The per-site Overview status pill shows **"Up · auth required"** with a lock ic If you want to exempt our probe from a CF WAF rule, the User-Agent is `Clockwork-Uptime/1.0` plus `(+CLOCKWORK_OPERATOR_CONTACT_EMAIL)` if that's set (see `reference/env-vars`) — `UptimeProber::userAgent()` builds it dynamically as of the modularization roadmap's Phase 8, replacing what used to be a hardcoded email address. Whitelist whatever your instance's actual User-Agent string is in the WAF and the probe will receive a 200 instead. +## The 429 rate-limit carve-out ("probe blocked") + +Some hosts and security plugins rate-limit or challenge automated clients and answer the probe with **HTTP 429 Too Many Requests** while real visitors load the site fine. Counting that as down turned a host's anti-bot rule into a multi-day "outage" in uptime numbers and client reports, so a 429 is now its own result: + +- `UptimeProber` returns `UptimeProbeResult::blocked()` (`isProbeBlocked = true`, `succeeded = false`, `Retry-After` kept when sent). +- `UptimeStateUpdater` moves an `up` (or `unknown`) site to the **`probe_blocked`** state once it has answered 429 as many times **in a row** as it takes failures to go down (`monitoring.uptime_failure_threshold`, default 2; counted in `sites.uptime_consecutive_blocked`). Any other answer resets the run, so a single rate-limit blip leaves no event, no action log (nothing reaches the client's wp-admin activity log) and no month-end report hold. Entering the state records a `probe_blocked` row in `site_uptime_events` dated at the run's **first** 429. A 429 does **not** add to `uptime_consecutive_failures`, does **not** set `uptime_last_up_at`, and never transitions the site to `down`. Blocked time never counts as downtime. +- A site that is `down` (or in `maintenance`) and answers 429 **stays** `down` (or in maintenance): a 429 during a real outage is often the overloaded origin's rate limiter. A failure after it belongs to the same outage (no new down event, alert, SMS or diagnosis), and a success is the outage's normal recovery, with the usual "back up" chat, SMS and client messages. Only when the site has answered nothing but 429 for the probe-blocked notice hours (below, default 6) does it move to `probe_blocked`: the outage is closed at the first 429 (and the failure streak reset), so a site that only rate-limits the probe after an outage doesn't show days of downtime, but recovery is not confirmed, so no "back up" alert goes out. +- `sites.uptime_probe_blocked_since` marks the start of the run of 429s, and of the blocked stretch. A single failed probe while blocked does not end it; a success, maintenance, or a real down transition does. A real outage while blocked still goes `down` after the usual failure threshold. +- If a site keeps blocking for **6 hours** (Monitoring → Settings, "Notify when a site blocks the probe for"), one `uptime_probe_blocked` chat notice goes to the ops channels (Mattermost / Slack, toggleable per channel like every other event). It respects the per-site rules: ignored sites stay silent and inactive sites are skipped. One notice per blocked stretch (`sites.uptime_probe_blocked_alerted_at`); it never pages on-call SMS or client contacts. +- On `/monitoring` the site shows a yellow **BLOCKED** pill and a "probe blocked" filter; the per-site widget shows **Probe blocked**. + +The fix is usually on the host: allowlist the `Clockwork-Uptime/1.0` User-Agent (and the Control server's IP) in the host's rate limiter or the security plugin's anti-bot settings. If real visitors are throttled too, treat it as an outage. + ## The 503 maintenance mode carve-out WordPress core automatic updates (`.maintenance`), popular maintenance plugins (SeedProd, Kadence, WP Maintenance Mode), and hosting panel maintenance modes legitimately return **HTTP 503 Service Unavailable** for SEO protection (RFC 7231 §6.6.4). @@ -111,11 +124,12 @@ How: per-site **Settings** tab → "Ignore uptime alerts" section. There's a rea ## Tuning the global settings -`/monitoring/settings` exposes three knobs. Change them carefully — they affect every site. +`/monitoring/settings` exposes four knobs. Change them carefully — they affect every site. - **Probe interval** — how often we hit each site. Default 5 minutes. The minimum is 1 minute (gives ManageWP-tier sensitivity), max is 15 minutes (gentlest, lowest log noise). Changes only apply on the next `schedule:work` restart. - **Failure threshold** — how many failures in a row trigger the down transition. Default 2. Lower values = more sensitive (1 = alert on first miss, you'll get noise). Higher values = fewer alerts (6 = ~30 minutes at default cadence before you hear about it). Threshold changes apply on the next probe — no restart needed. - **Ignored domains** — wildcard patterns that remove matching sites from uptime monitoring entirely (see the section above). Applies on the next probe — no restart needed. +- **Probe-blocked notice** — hours a site may keep answering HTTP 429 before the one-off ops chat notice (1–168, default 6), and before a `down` or `maintenance` site that answers only 429 moves to `probe_blocked`. Applies on the next probe. ## Alert channels & dispatch diff --git a/resources/docs/integrations/bill-com.md b/resources/docs/integrations/bill-com.md index 8c17e949..12bb30de 100644 --- a/resources/docs/integrations/bill-com.md +++ b/resources/docs/integrations/bill-com.md @@ -59,7 +59,9 @@ We never write to Bill.com. ## Files - `app/Services/BillCom/BillComClient.php` — HMAC + session client; generators for `customers()`, `items()`, `invoicesSince()` so callers don't materialise the full result set. -- `app/Services/BillCom/InvoiceDomainExtractor.php` — pure-function regex extractor. Recognises a fixed TLD list (com / org / net / io / co / us / gov / edu). +- `app/Services/BillCom/InvoiceDomainExtractor.php` — pure-function regex extractor. Recognises a fixed TLD list (com / org / net / io / co / us / gov / edu) plus every TLD a non-archived site uses (passed in per sync). +- `SiteDomainIndex` — normalized lookup of sites by domain, built once per sync (see "Domain matching" below). +- `app/Support/DomainNormalizer.php` — shared domain normalizer (lowercase; strips scheme, path, query, port, trailing dot and a leading `www.`). - `app/Services/BillCom/CustomerSyncService.php` — pulls customers + invoices, links sites by domain. - `app/Services/BillCom/CarePlanSyncService.php` — classifies items via regex, walks invoices, sets `care_plan_enabled`. - `app/Console/Commands/{BillComTest,SyncBillCustomers,SyncBillCarePlans}.php` @@ -86,6 +88,16 @@ What works is two specific operator-side conventions: If either convention breaks (the operator stops including domains, or starts using inconsistent Item names), the relevant half silently degrades. The `bill_com_linked_via_invoice` audit column shows which invoice proved each link. +### Domain matching + +Both syncs build a `SiteDomainIndex` once per run and compare domains normalized on both sides, so a site stored as `www.example.com` matches a line that says `example.com` or `http://www.example.com/`, and the other way round. The extractor also accepts every TLD a non-archived site uses, so a fleet domain on `.tv`, `.law` or similar is recognised without opening the pattern to every dotted word. + +What the run report (console output and the `bill_com_sync` action-log details) separates out: + +- `unmatched_domains` / `unmatched_care_plan_domains` — named in invoices, no matching site (care-plan sync: no linked, non-overridden site hit). +- `archived_site_domains` / `archived_care_plan_domains` — named in invoices, but only an archived site has that domain. Never linked or flagged; listed apart so they don't read as missing from Clockwork. +- `domain_collisions` — normalized domain → number of non-archived sites sharing it (e.g. an apex site and its `www.` twin both live). The sync uses the site whose stored domain matches exactly (falling back to the lowest id) and reports the key so the duplicate can be archived or consolidated. + ## `/settings/bill-com` — control panel (`BillComSettingsController`) The fleet-wide status page for this integration. Everything on it is read from either `config('clockwork.bill_com')` or already-synced local tables — the page never calls the Bill.com API itself except when you click a run button. diff --git a/resources/docs/integrations/gtmetrix.md b/resources/docs/integrations/gtmetrix.md index 2c841850..fab44d14 100644 --- a/resources/docs/integrations/gtmetrix.md +++ b/resources/docs/integrations/gtmetrix.md @@ -2,7 +2,7 @@ title: GTmetrix section: Integrations order: 79 -updated: 2026-10-15 +updated: 2026-11-03 author: Aaron Reimann tags: [integrations, performance, lighthouse, gtmetrix, care-plan, pressable] tracks: [modules/GTmetrix/src/GtmetrixClient.php, modules/GTmetrix/src/GTmetrixServiceProvider.php, app/Console/Commands/RunPerformanceScans.php] @@ -83,7 +83,7 @@ Gated on `Settings('performance_scans.enabled')`. ## Credit budget (why the weekly rotation exists) -API credits refill daily at ~04:27 UTC and **don't accumulate**. To ensure coverage within credit limits, the scheduler passes `--weekly-rotation`: sites ordered by domain, sliced into 7 buckets by `index % 7 == UTC weekday`. Pressable sites aren't in the slices (as of 2026-10-15 they use Pressable's own report, read nightly), so the rotation is about 54 sites, ~8 a night. The Advanced plan provides 50 credits/day, and the weekly rotation keeps generous headroom for manual runs and retries. +API credits refill daily at ~04:27 UTC and **don't accumulate**. The scheduler passes `--weekly-rotation`: each site has a fixed UTC weekday, `crc32(lowercased domain) % 7` (0 = Sunday), so enrolling or removing other sites never moves it. Pressable sites aren't in the rotation (they're read from Pressable's own report nightly), which leaves roughly 1/7th of the GTmetrix/PSI fleet per night. On each of a month's last 7 nights the rotation also adds care-plan sites with no OK scan since the 1st, within `CLOCKWORK_GTMETRIX_DAILY_CREDITS`: sites whose own weekday won't come round again that month go first, and if that isn't enough, tonight's already-scanned sites give up their slot. Anything left waits for the next night rather than falling back to PSI. The default budget (10) is the smallest (Core) plan's daily allowance; set the variable to your plan's daily credits so the catch-up can use them. See [Features → Performance scans](/docs/features/performance-scans). ## Storage diff --git a/resources/docs/integrations/mattermost.md b/resources/docs/integrations/mattermost.md index db3604e2..17191ec0 100644 --- a/resources/docs/integrations/mattermost.md +++ b/resources/docs/integrations/mattermost.md @@ -2,7 +2,7 @@ title: Mattermost section: Integrations order: 60 -updated: 2026-10-04 +updated: 2026-11-03 author: Aaron Reimann tags: [integrations, mattermost, notifications, alerts, slack] tracks: [modules/Mattermost/src/**, app/Console/Commands/MattermostTest.php, app/Services/Chat/**] @@ -43,7 +43,7 @@ $this->app->singleton(ChatNotifier::class, fn ($app) => new ChatNotifierDispatch (The tag array was a single hardcoded literal in `AppServiceProvider` before Phase 7 of the modularization roadmap — now each module's own service provider contributes its channel by tagging itself the same way it contributes a `CloudProvider` or `DiagnosticCheck`. An agency that doesn't want Mattermost at all can simply not require `clockwork/mattermost` in `composer.json` — no code to comment out, no dead settings page.) -`ChatNotifier::EVENTS` is the canonical registry of notification types (site down/up, SSL state changed, plugin update failed, IP blocked, contact form failed/recovered, Companion unreachable/recovered, malware finding detected, backup relay silent/recovered, server update failed, queue worker restart failed, scheduler stale/recovered) — both `MattermostNotifier` and `SlackNotifier` implement the full set and gate each method on its own per-event setting. `companion_unreachable`/`companion_reachable` have two independent callers: `ContactFormTester` (form testing enabled but no response) and the daily `clockwork:detect-stuck-companion-state` sweep (a failed install never retried, or an installed Companion gone silent for days) — see [Reference → Scheduled jobs](/docs/reference/scheduled-jobs). Fleet-level events with no `Site` involved: `backup_relay_stale`/`backup_relay_recovered`, `queue_worker_restart_failed`, `scheduler_stale`/`scheduler_recovered`. `ClientSlackNotifier` implements the interface too but only actually sends for a handful of client-relevant events (see [Integrations → Slack](/docs/integrations/slack)); everything else is a silent no-op for that channel. Each channel independently no-ops when unconfigured, so having all three registered is always safe — a channel with no webhook URL simply never sends. +`ChatNotifier::EVENTS` is the canonical registry of notification types (site down/up, uptime probe blocked (HTTP 429), SSL state changed, plugin update failed, IP blocked, contact form failed/recovered, Companion unreachable/recovered, malware finding detected, new PHP file in uploads, backup relay silent/recovered, server update failed, queue worker restart failed, scheduler stale/recovered) — both `MattermostNotifier` and `SlackNotifier` implement the full set and gate each method on its own per-event setting. `companion_unreachable`/`companion_reachable` have two independent callers: `ContactFormTester` (form testing enabled but no response) and the daily `clockwork:detect-stuck-companion-state` sweep (a failed install never retried, or an installed Companion gone silent for days) — see [Reference → Scheduled jobs](/docs/reference/scheduled-jobs). Fleet-level events with no `Site` involved: `backup_relay_stale`/`backup_relay_recovered`, `queue_worker_restart_failed`, `scheduler_stale`/`scheduler_recovered`. `ClientSlackNotifier` implements the interface too but only actually sends for a handful of client-relevant events (see [Integrations → Slack](/docs/integrations/slack)); everything else is a silent no-op for that channel. Each channel independently no-ops when unconfigured, so having all three registered is always safe — a channel with no webhook URL simply never sends. Adding a fourth channel means: implement `ChatNotifier`, gate each method with an `isEventEnabled()`-style check, and tag the class `clockwork.notifiers` (in `AppServiceProvider`, or in a module's own service provider). Callers never change. @@ -93,6 +93,7 @@ Standard Mattermost incoming-webhook payload: - `send($text, $attachments = [])` — generic. - `siteWentDown(Site, status, detail)` — formatted down alert. - `siteWentUp(Site, downtimeMinutes)` — formatted recovery. +- `uptimeProbeBlocked(Site, blockedSec, statusCode, retryAfter)` — the uptime monitor has been rate-limited (HTTP 429) for longer than the hours set on Monitoring → Settings (default 6). This is not an outage and the time is never counted as downtime. One notice per blocked stretch. Ignored sites stay silent, inactive sites are skipped, and it never pages SMS or client Slack. See [Features → Uptime monitoring](/docs/features/uptime-monitoring). - `serverWentRed(Server, reason, cpuPct)` — formatted server alert when a node spikes over threshold into critical status. - `serverRecovered(Server)` — formatted notification when a previously red server recovers to normal green telemetry. - `aiRemedyTriaged(AiRemedyRun)` — delivers root cause and proposed remediation commands upon completing incident triage. @@ -102,7 +103,8 @@ Standard Mattermost incoming-webhook payload: - `contactFormFailed(ContactFormTest)` / `contactFormRecovered(ContactFormTest)` — form test alert/recovery. - `companionUnreachable(Site, reason)` / `companionReachable(Site, stuckForSeconds)` — Companion gone silent (a failed install never retried, or an installed site not heard from in days — see [Reference → Scheduled jobs](/docs/reference/scheduled-jobs)'s `clockwork:detect-stuck-companion-state` entry) and its recovery counterpart. - `pluginUpdateFailed(Site, PluginUpdateJob)` — nightly auto-update failure (manual bulk-update runs stay silent; only `nightly-*` batches page). Also fires from `clockwork:reap-stale-update-jobs` for a `nightly-*` job that got stuck and reaped, not just a live failure. -- `malwareFindingDetected(Site, SiteSecurityScan)` — fires on the clean → malware-hit transition (Sucuri SiteCheck or the Companion in-WP probe), not on every re-scan of a site that's still infected. +- `malwareFindingDetected(Site, SiteSecurityScan)` — fires on the clean → malware-hit transition (Sucuri SiteCheck or the Companion in-WP probe), not on every re-scan of a site that's still infected. Failed scans in between don't count as "clean". +- `phpInUploadsDetected(Site, SiteSecurityScan, paths)` — the Companion in-WP probe found PHP files under `wp-content/uploads` that the site's previous scan did not report. Each new path alerts once; files already present stay quiet. Like `malwareFindingDetected`, not gated on `is_inactive`. - `serverUpdateFailed(Server, reason)` — a live `apt-get update/upgrade` failure, or a stuck update reaped by `clockwork:reap-stale-server-updates`. No `Site` involved — server-level. - `backupRelayStale(daysSinceLastRun, lastRunAt)` / `backupRelayRecovered()` — the offsite backup relay droplet has gone quiet (6+ days with no new run recorded) and its recovery. No `Site` involved — see [Features → Backup relay](/docs/features/backup-relay). - `queueWorkerRestartFailed(reason)` — the `com.clockwork.queue` launchd watchdog (`clockwork:ensure-queue-worker`, every 5 min) found the worker crashed AND its own restart attempt also failed. The worst case in this whole list: every queued job in the app is stuck until someone intervenes by hand. diff --git a/resources/docs/integrations/pressable.md b/resources/docs/integrations/pressable.md index 97a214d7..cb19c3b9 100644 --- a/resources/docs/integrations/pressable.md +++ b/resources/docs/integrations/pressable.md @@ -2,7 +2,7 @@ title: Pressable section: Integrations order: 21 -updated: 2026-10-04 +updated: 2026-11-03 author: Aaron Reimann tags: [integrations, pressable, hosting, wordpress] tracks: [modules/Pressable/src/**, app/Console/Commands/{ImportPressable,PressableTest,InstallCompanionPressable,PressableBackupsReport,PressableTrafficReport,PressableSecuritySummaryReport}.php] @@ -110,7 +110,7 @@ Handles Pressable's 30-second no-output kill. Live-tested both the success path | Cadence | Command | |---|---| | daily 06:32 | `clockwork:pressable-backups-report` — pushes real backup run history to Companion's Backups page. | -| daily 06:37 | `clockwork:pressable-traffic-report` — pushes period-total traffic stats to Companion's Traffic page. | +| daily 04:45 | `clockwork:pressable-traffic-report` — records each Pressable site's daily traffic into `site_traffic_daily` (read by client reports, so it runs before the 06:00 report run), then pushes the 30-day chart to Companion's Traffic page where supported. | | daily 06:39 | `clockwork:pressable-security-summary-report` — pushes plugin/theme vulnerability alerts + Defensive Mode status to Companion's Security page. Pressable-only capability, no SpinupWP parity intent. | `clockwork:import-pressable` and `clockwork:install-companion-pressable` are **not** scheduled — the site inventory doesn't churn the way it needs a daily re-import yet, and Companion rollout to new Pressable sites is still a deliberate per-site/per-batch step. Run them manually as needed. See [Reference → Scheduled jobs](/docs/reference/scheduled-jobs) and [Reference → Artisan commands](/docs/reference/artisan-commands). diff --git a/resources/docs/integrations/slack.md b/resources/docs/integrations/slack.md index 70f10fc6..c8a3b4b0 100644 --- a/resources/docs/integrations/slack.md +++ b/resources/docs/integrations/slack.md @@ -2,7 +2,7 @@ title: Slack section: Integrations order: 61 -updated: 2026-10-04 +updated: 2026-11-03 author: Aaron Reimann tags: [integrations, slack, notifications, alerts, pressable] tracks: [modules/Slack/src/**, app/Services/Chat/**, resources/views/settings/slack.blade.php] @@ -53,7 +53,7 @@ The webhook URL is the credential, same trust model as Mattermost's — don't co ### What we POST -Slack's incoming-webhook payload shape (`text` + `attachments`), same semantic helpers as `MattermostNotifier` (`send`, `siteWentDown`, `siteWentUp`, `serverWentRed`, `serverRecovered`, `aiRemedyTriaged`, `aiRemedyExecuted`, `ipBlocked`, `sslStateChanged`, `contactFormTestFailed`, `contactFormTestRecovered`, `companionUnreachable`/`companionReachable`, `pluginUpdateFailed`, `malwareFindingDetected`, `serverUpdateFailed`, `backupRelayStale`/`backupRelayRecovered`, `queueWorkerRestartFailed`, `schedulerStale`/`schedulerRecovered`) since both implement the same `ChatNotifier` contract — see [Mattermost](/docs/integrations/mattermost) for what each one covers. +Slack's incoming-webhook payload shape (`text` + `attachments`), same semantic helpers as `MattermostNotifier` (`send`, `siteWentDown`, `siteWentUp`, `uptimeProbeBlocked`, `serverWentRed`, `serverRecovered`, `aiRemedyTriaged`, `aiRemedyExecuted`, `ipBlocked`, `sslStateChanged`, `contactFormTestFailed`, `contactFormTestRecovered`, `companionUnreachable`/`companionReachable`, `pluginUpdateFailed`, `malwareFindingDetected`, `phpInUploadsDetected`, `serverUpdateFailed`, `backupRelayStale`/`backupRelayRecovered`, `queueWorkerRestartFailed`, `schedulerStale`/`schedulerRecovered`) since both implement the same `ChatNotifier` contract — see [Mattermost](/docs/integrations/mattermost) for what each one covers. ### Files @@ -77,7 +77,7 @@ Clients don't need to know an IP got blocked or a plugin auto-update failed — - `siteWentDown` / `siteWentUp` — "Your website example.com is currently unreachable" / "...is back online," with a link to your configured support URL (`CLOCKWORK_OPERATOR_SUPPORT_URL`) on the down message. No status codes, no server names. - `contactFormTestFailed` / `contactFormTestRecovered` — same tone, for the contact-form smoke test. -Every other `ChatNotifier` method (`ipBlocked`, `sslStateChanged`, `pluginUpdateFailed`, `companionUnreachable`, `malwareFindingDetected`, generic `send`) is implemented as a hard `return false` — not gated by a setting, just a no-op by design. This is the one channel in the fan-out where "doesn't send most events" is intentional, not a missing feature. +Every other `ChatNotifier` method (for example `ipBlocked`, `sslStateChanged`, `pluginUpdateFailed`, `companionUnreachable`, `malwareFindingDetected`, `phpInUploadsDetected`, `uptimeProbeBlocked`, generic `send`) is implemented as a hard `return false` — not gated by a setting, just a no-op by design. This is the one channel in the fan-out where "doesn't send most events" is intentional, not a missing feature. ### Why a site-level webhook instead of a Clockwork setting diff --git a/resources/docs/internal/client-reports-tracker.md b/resources/docs/internal/client-reports-tracker.md index 388b1c32..b21d9301 100644 --- a/resources/docs/internal/client-reports-tracker.md +++ b/resources/docs/internal/client-reports-tracker.md @@ -2,7 +2,7 @@ title: Client reports tracker section: Internal order: 30 -updated: 2026-10-29 +updated: 2026-11-03 author: Aaron Reimann tags: [client-reports, tracker, roadmap, known-issues] --- @@ -28,7 +28,7 @@ None of them has ever sent, so the 06:00 UTC run holds **every** report for revi 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 **Send now** on the schedule to recompile from current data. That 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 "as of" 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". +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. ## Open issues diff --git a/resources/docs/reference/api-endpoints-we-call.md b/resources/docs/reference/api-endpoints-we-call.md index 41526a8f..91067932 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-09-28 +updated: 2026-11-05 author: Aaron Reimann tags: [reference, api, integrations] tracks: [app/Services/*/*Client.php, modules/*/src/*Client.php, app/Services/Companion/ClockworkCompanionClient.php] @@ -108,7 +108,7 @@ Daily import at 03:30 (`clockwork:import-spinupwp`). | POST | `/sites/{id}/wordpress/commands`, `/sites/{id}/wordpress/wpcli` | Fire-and-forget shell / wp-cli commands — see `PressableCommandRunner` for how this becomes synchronous (result read back from the activity log, not the response body). | | GET | `/sites/{id}/backups`, `/backups/fs`, `/backups/db` | Backup run history — `clockwork:pressable-backups-report` (06:32 daily). | | GET | `/sites/{id}/reports/performance/latest` | Pressable's own Lighthouse report — see [Integrations → Pressable](/docs/integrations/pressable). | -| GET | `/sites/{id}/statistics` | Page-view stats — `clockwork:pressable-traffic-report` (06:37 daily). | +| GET | `/sites/{id}/statistics` | Page-view stats — `clockwork:pressable-traffic-report` (04:45 daily). | | GET | `/sites/{id}/security-alerts/{plugins,themes}` | Known-vulnerability + Defensive Mode status — `clockwork:pressable-security-summary-report` (06:39 daily), Pressable-only, no SpinupWP equivalent. | | POST | `/sites/{id}/metrics`, `/sites/{id}/logs/activity` | CPU/MySQL resource metrics and the activity-log poll `PressableCommandRunner` uses for command results. | @@ -154,12 +154,16 @@ 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. +`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. ## WordPress.org Plugin Information — `https://api.wordpress.org/plugins/info/1.2/` `app/Services/Security/PluginDirectoryClient.php` · No auth, keyless. `GET ?action=plugin_information&request[slug]={slug}` with `User-Agent: Clockwork-Monitoring/1.0 (+plugin-directory-check)`. 100ms delay between requests. Queries unique plugin slugs across the fleet to detect closed/abandoned zombieware plugins (`plugin_directory_statuses`). Tolerates partial failures; non-closed/not_found responses for premium plugins never alert. Weekly on Mondays at 03:30 UTC (`clockwork:refresh-closed-plugins`). +## WordPress.org Theme Information — `https://api.wordpress.org/themes/info/1.2/` + +`app/Services/Updates/ThemeIdentityGuard.php` · No auth, keyless. `GET ?action=theme_information&request[slug]={slug}` with `User-Agent: Clockwork-Monitoring/1.0 (+theme-identity-check)`, 8s timeout, retried twice on connection errors and 5xx. Called by the nightly update loop (`clockwork:run-nightly-plugin-updates`) once per theme folder that has an update pending, to check the installed theme is the directory theme on offer (see Features → Updates, *Theme slug collisions*). An error body (`{"error": "Theme not found"}`, usually HTTP 404) means the folder isn't listed; a failed lookup falls back to the snapshot checks and never stops the night. + ## CISA Known Exploited Vulnerabilities (KEV) — `https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json` `app/Services/Security/CisaKevClient.php` · No auth, keyless official JSON feed (~1.7k active in-the-wild exploitation entries). Single daily download at 03:20 UTC (`clockwork:refresh-cisa-kev`), atomic swap (`cisa_kev_entries`) via database transaction and chunked insert. Used at query-time to badge CVEs detected in installed plugins as `Actively exploited (CISA KEV)`. diff --git a/resources/docs/reference/artisan-commands.md b/resources/docs/reference/artisan-commands.md index 84b95937..75bac3fd 100644 --- a/resources/docs/reference/artisan-commands.md +++ b/resources/docs/reference/artisan-commands.md @@ -2,7 +2,7 @@ title: Artisan commands section: Reference order: 50 -updated: 2026-10-29 +updated: 2026-11-04 author: Aaron Reimann tags: [reference, artisan, cli, modules] tracks: [app/Console/Commands/**, modules/*/src/Commands/**, modules/*/src/Console/*.php, modules/*/src/Console/Commands/**] @@ -103,15 +103,16 @@ Every `clockwork:*` command, alphabetical, with a one-line summary and an exampl | `clockwork:ensure-companion-trust-proxy` | Idempotently define `CLOCKWORK_COMPANION_TRUST_PROXY` in wp-config.php on care-plan auto-update sites. | `php artisan clockwork:ensure-companion-trust-proxy` | | `clockwork:sync-companion-form-subscriptions` | Reconcile client-chosen form-test subscriptions from Companion into `contact_form_tests` (`created_by=client`). | `php artisan clockwork:sync-companion-form-subscriptions` | | `clockwork:cleanup-spam-comments` | Bulk-purge spam and trash comments older than N days (default 30) from every Companion-equipped site advertising the `comments-moderation` capability. Runs weekly. | `php artisan clockwork:cleanup-spam-comments --days=30` | -| `clockwork:send-client-reports` | Generate and email scheduled white-labeled client reports for care-plan sites. `--site=` limits to one; `--force` bypasses the due-date check. Runs daily at 06:00 UTC; each report covers the previous calendar month. Care-plan sites only: a due schedule on a site without one is skipped and moved to its next run. An `auto` schedule's report is held as a draft instead of emailed when something needs a look first. That means a malware, core-file or blocklist scan flagged something, a vulnerable plugin has an update waiting, backups are missing, stale or unchecked, or an outage was still ongoing at month end. A re-run for the same period reuses the unsent report and emails only recipients not yet in `sent_to`. Compiling a fresh report marks any unsent earlier copy `superseded`. The email contains the **entire report inline** (no "view online" link — Control isn't publicly reachable): brand-colour header with the logo embedded as an inline image, at-a-glance tiles, updates, uptime, security, performance, traffic, forms (only when tests ran), backups and work log. Header colour = White Labeling → Client Reports → Primary Brand Color (default `#2D2062`). After a send, a monthly schedule's `next_run_at` snaps to 00:00 on the 1st of next month (weekly: midnight seven days out) so a long batch never pushes the next run past the following 06:00 tick. | `php artisan clockwork:send-client-reports --site=example.com` | -| `clockwork:import-report-recipients` | Load client report recipients from a CSV (e.g. the ManageWP export). Needs a site column (`domain`, `website`, `site`, `site url`, `url` or `managewp`) and at least one column with "email" or "recipient" in its name. Sites are matched by domain (scheme, `www.` and path are ignored). A matched site with no schedule gets a monthly auto schedule; one that already has a schedule gets the emails added to its recipients (`--replace` replaces them instead). Unmatched sites and rows without a valid email are listed, not imported. Dry run unless `--apply`; with `--apply` each changed schedule is pushed to the site's Companion/Renegade plugin unless `--no-push`. The CSV holds client emails, so it stays on the server and is never committed. | `php artisan clockwork:import-report-recipients ~/report-recipients.csv --apply` | +| `clockwork:send-client-reports` | Generate and email scheduled white-labeled client reports for care-plan sites. `--site=` limits to one; `--force` bypasses the due-date check. Runs daily at 06:00 UTC; each report covers the previous calendar month. Care-plan sites only: a due schedule on a site without one is skipped and moved to its next run. An `auto` schedule's report is held as a draft instead of emailed when something needs a look first. For example: monitoring covers only part of the month, a malware scan found something or stopped early, core files or a blocklist were flagged, a vulnerable plugin has a fixing update waiting or its updates are paused, no updates were applied while some were waiting, backups are missing, stale, unchecked, suspect or failing, traffic or form-test data is incomplete or failing, lockouts were never turned into bans, or the site was down or the probe was blocked at month end. A schedule's first report is always held. The full list is in [Features → Client reports](/docs/features/client-reports#scheduled-sending) (Hold for review). A re-run for the same period reuses the unsent report and emails only recipients not yet in `sent_to`. Compiling a fresh report marks any unsent earlier copy `superseded`. The email contains the **entire report inline** (no "view online" link — Control isn't publicly reachable): brand-colour header with the logo embedded as an inline image, at-a-glance tiles, updates, uptime, security, performance, traffic, forms (only when tests ran), backups and work log. Header colour = White Labeling → Client Reports → Primary Brand Color (default `#2D2062`). After a send, a monthly schedule's `next_run_at` snaps to 00:00 on the 1st of next month (weekly: midnight seven days out) so a long batch never pushes the next run past the following 06:00 tick. | `php artisan clockwork:send-client-reports --site=example.com` | +| `clockwork:recompile-held-client-reports` | Recompile every unsent scheduled client report (status `generated`, nobody in `sent_to`) for its own period from current data, as the **Recompile** button does: the old copy is superseded and the new one checked for review. Never sends, never stamps `last_sent_at`, never moves `next_run_at`. `--outdated` limits it to reports compiled under older report rules; `--site=` to one site; `--dry-run` lists them and changes nothing. Use it after a deploy that changes report rules, before anyone sends a held draft. See [Features → Client reports](/docs/features/client-reports#refreshing-held-drafts-recompile). | `php artisan clockwork:recompile-held-client-reports --outdated --dry-run` | +| `clockwork:import-report-recipients` | Load client report recipients from a CSV (e.g. the ManageWP export). Needs a site column (`domain`, `website`, `site`, `site url`, `url` or `managewp`) and at least one column with "email" or "recipient" in its name. Sites are matched by domain (scheme, `www.` and path are ignored). A matched site with no schedule gets a monthly `auto` schedule. If the site has no active care plan, that schedule is created **disabled** (the dry run says "no care plan: created disabled") unless `--include-no-care-plan` is passed. Enable it on the Schedules tab when the site gets a care plan, because nothing turns it on automatically. A site that already has a schedule gets the emails added to its recipients (`--replace` replaces them instead). Unmatched sites and rows without a valid email are listed, not imported. Dry run unless `--apply`; with `--apply` each changed schedule is pushed to the site's Companion/Renegade plugin unless `--no-push`. The CSV holds client emails, so it stays on the server and is never committed. | `php artisan clockwork:import-report-recipients ~/report-recipients.csv --apply` | | `clockwork:build-companion-tarball` | Produce signed `.tgz` + `.sha256` for `dist_url` mode. | `php artisan clockwork:build-companion-tarball --out=~/Downloads` | | `clockwork:refresh-companion-snapshot` | Pull `/snapshot` per site → `companion_snapshot` JSON column. Purges Pressable's edge cache first (best-effort) — otherwise a stale cached response can mask a real update. | `php artisan clockwork:refresh-companion-snapshot` | | `clockwork:refresh-companion-capabilities` | Refresh `companion_capabilities` per site. Same Pressable edge-cache purge as the snapshot refresh above. | `php artisan clockwork:refresh-companion-capabilities` | | `clockwork:push-companion-branding` | Push white-label branding (logo, company name, colours, support email) to Companion/Renegade sites. `--site=` limits to one; `--verify` reads live branding first and re-pushes only drifted sites (scheduled weekly, Mon 06:50). | `php artisan clockwork:push-companion-branding --verify` | | `clockwork:push-companion-backups` | Push SpinupWP config + DO Spaces history per site. | `php artisan clockwork:push-companion-backups` | | `clockwork:pressable-backups-report` | Pressable counterpart — pushes real backup run history from Pressable's own `/backups` endpoint. No 30/90-day retention filter applied (Pressable's API has no pagination to request more than it hands back). | `php artisan clockwork:pressable-backups-report` | -| `clockwork:pressable-traffic-report` | Pushes Pressable's period-total traffic stats (today/yesterday/current+last month/last 12 months) to Companion's Traffic page. No daily breakdown — structurally different from the nginx-log rollup the SpinupWP command sources from. | `php artisan clockwork:pressable-traffic-report` | +| `clockwork:pressable-traffic-report` | Reads daily requests (by status) and unique visitors from Pressable's metrics API. Writes the days Pressable returned into `site_traffic_daily` for every Pressable site, Companion or not, then pushes the 30-day chart to Companion's Traffic page on sites that support it. `--site=` limits to one site (id or domain). Runs daily at 04:45. | `php artisan clockwork:pressable-traffic-report --site=42` | | `clockwork:pressable-security-summary-report` | Pushes Pressable's own plugin/theme CVE feed + Defensive Mode status to Companion's Security page. Pressable-only capability, no SpinupWP equivalent. | `php artisan clockwork:pressable-security-summary-report` | | `clockwork:record-site-backups` | Record each active site's backups from its real source into `site_backups`: Pressable's backup API, SpinupWP's DigitalOcean Spaces bucket, or our S3 archives for unhosted relay sites. Also writes a `site_backup_checks` row for each site that has a backup source, saying whether the read worked. Sites with no source are skipped. Rows are kept after the source prunes them. Exits as failed if any site couldn't be read. `--site=` limits to one site (id or domain). Runs daily at 05:30. | `php artisan clockwork:record-site-backups --site=42` | | `clockwork:push-backup-relay-targets` | Writes the backup-relay-enabled site list to S3. Direct-to-S3 (Companion) sites are left out because the droplet can't reach them; Control backs those up with `clockwork:backup-relay-run --direct-only`. The file goes to (`{S3_BUCKET}/{prefix}/targets.json`) for the standalone backup-relay droplet to read — no direct connection to that droplet. See [Features → Backup relay](/docs/features/backup-relay). | `php artisan clockwork:push-backup-relay-targets` | @@ -121,7 +122,7 @@ Every `clockwork:*` command, alphabetical, with a one-line summary and an exampl | `clockwork:rotate-companion-secret` | Rotate per-site HMAC secret. | `php artisan clockwork:rotate-companion-secret --all` | | `clockwork:detect-contact-forms` | Companion-aware contact-form detection. | `php artisan clockwork:detect-contact-forms` | | `clockwork:test-contact-forms` | Run the due contact-form tests across the fleet (care-plan only). `--site=X` bypasses the care-plan filter; `--form=ID` targets a single contact_form_tests row; `--force` bypasses the frequency-due check. | `php artisan clockwork:test-contact-forms --force --site=example.com` | -| `clockwork:run-nightly-plugin-updates` | Nightly care-plan auto-update path — runs `wp plugin update` per opted-in site. Has no fixed time: it's chained via `->then()` and starts as soon as the 01:30 ET `clockwork:refresh-companion-snapshot` run exits. Per-site opt-out toggle on the site Updates tab. | `php artisan clockwork:run-nightly-plugin-updates` | +| `clockwork:run-nightly-plugin-updates` | Nightly care-plan auto-update path — runs `wp plugin update` per opted-in site. Has no fixed time: it's chained via `->then()` and starts as soon as the 01:30 ET `clockwork:refresh-companion-snapshot` run exits. Per-site opt-out toggle on the site Updates tab. The fleet-wide run happens at most once per America/New_York date; `--force` re-runs anyway, `--site=` limits to one site (not guarded), `--dry-run` only lists candidates. | `php artisan clockwork:run-nightly-plugin-updates` | | `clockwork:nightly-update-summary` | Email summary of the previous night's auto-update run. Runs 06:15 ET. | `php artisan clockwork:nightly-update-summary` | | `clockwork:push-companion-traffic` | Push the previous day's traffic rollup to each Companion-equipped site for display in the WP admin. | `php artisan clockwork:push-companion-traffic` | | `clockwork:ensure-queue-worker` | Watchdog for the `com.clockwork.queue` launchd service — checks `launchctl list` for a live PID and kickstarts the worker if it has crashed or been throttled into a backoff loop. Scheduled every 5 min so recovery gap ≤ 5 min. A successful revival is log-only; if the kickstart itself fails, fires a `queue_worker_restart_failed` Mattermost/Slack alert (every queued job in the app is stuck at that point). Crontab itself is watched by `clockwork:scheduler-heartbeat` (Health + metrics). | `php artisan clockwork:ensure-queue-worker` | @@ -134,7 +135,7 @@ Every `clockwork:*` command, alphabetical, with a one-line summary and an exampl | `clockwork:scan-sitecheck` | Sucuri SiteCheck remote scan per site. | `php artisan clockwork:scan-sitecheck` | | `clockwork:verify-wp-core-checksums` | `wp core verify-checksums` per site — SSH for SpinupWP, Pressable's async command API for Pressable. | `php artisan clockwork:verify-wp-core-checksums` | | `clockwork:run-companion-malware-scans` | In-WP malware probe — Companion plugin endpoint preferred, SSH wp-cli fallback. Bypasses Cloudflare so CF-fronted sites get real signal instead of the 403 wall Sucuri's external scanner hits. | `php artisan clockwork:run-companion-malware-scans` | -| `clockwork:suppress-malware-path` | Add or remove a path from the per-site malware scan suppression list (`php_in_uploads` findings). Requires `--site=` and `--path=`; `--unsuppress` removes instead of adds. Use for known-safe upload-resident PHP files (e.g. MC4WP debug logs that start with `then()`: `clockwork:run-nightly-plugin-updates` fires immediately after this command's process actually exits, not at a fixed clock offset — see the next row. | | 01:45 | `clockwork:ensure-companion-trust-proxy` | Idempotently inject `CLOCKWORK_COMPANION_TRUST_PROXY` into wp-config.php on care-plan auto-update sites so Companion's rate limiter sees real IPs behind CF. Independently scheduled at a fixed time, not chained — unlike the update loop below, a stale snapshot here just means the constant gets injected a day late, not a silently dropped update. | | 01:50 | `clockwork:detect-stuck-companion-state` | Fleet-wide sweep for a failed install never retried (>24h) or an installed Companion gone silent (>3 days) — alerts on the transition into and out of "stuck" so unmonitored or failed installs are caught automatically. | -| chained | `clockwork:run-nightly-plugin-updates` | Care-plan auto-update path — runs `wp plugin update` for each opted-in site. Per-site opt-out toggle on the site Updates tab. **Not its own `Schedule::command()` entry** — chained via `->then()` off `clockwork:refresh-companion-snapshot` above (`routes/console.php`), ensuring the full snapshot refresh completes across the entire fleet before update queries evaluate candidates. | +| chained | `clockwork:run-nightly-plugin-updates` | Care-plan auto-update path — runs `wp plugin update` for each opted-in site. Per-site opt-out toggle on the site Updates tab. **Not its own `Schedule::command()` entry** — chained via `->then()` off `clockwork:refresh-companion-snapshot` above (`routes/console.php`), ensuring the full snapshot refresh completes across the entire fleet before update queries evaluate candidates. On the DST fall-back night 01:30 ET happens twice and the chain fires twice; the command skips the second run because a fleet-wide nightly batch already exists for that local date (`--force` overrides). | ## Daily — security scan window (02:00 UTC) @@ -114,6 +114,7 @@ The scheduler itself is watched by `clockwork:scheduler-heartbeat` (every minute | 04:45 | `clockwork:run-performance-scans --strategy=mobile --weekly-rotation` | Lighthouse run for tonight's 1/7th of the care-plan fleet, leaving out Pressable sites (read nightly at 05:05/05:15 instead). GTmetrix primary, PSI fallback. Each site gets one scan per week. Scheduled at 04:45 to land just after GTmetrix's daily credit refill (credits don't bank). `--strategy` is display-only on GTmetrix's tier; kept for row continuity. | | 04:45 | `clockwork:capture-site-screenshots` | Refresh each site's homepage screenshot via Automattic's mShots service, feeding the visual fleet grid view. `withoutOverlapping(30)`, `runInBackground()`. | | 04:45 | `clockwork:detect-wp-plugins` | SSH wp-cli probe for active LLAR/Wordfence per WP site. | +| 04:45 | `clockwork:pressable-traffic-report` | Pressable's daily traffic from its metrics API, via `PressableServiceProvider::scheduledTasks()`. Writes the days Pressable returned into `site_traffic_daily` for every Pressable site, then pushes the 30-day chart to sites whose Companion supports it. Runs before the 06:00 client reports, which read those rows, so the last day of the month is complete. | | 04:50 | `clockwork:detect-contact-forms` | Companion-aware contact-form detection. | | 04:55 | `clockwork:sync-companion-form-subscriptions` | Reconcile client-picked form-test subscriptions (Companion wp-admin Forms tab) into `contact_form_tests`. Slots between detect (04:50) and test (06:00). | | 04:58 | `clockwork:backup-relay-run` (or `clockwork:push-backup-relay-targets`) | Off-host archival to S3 Glacier IR. Per-site cadence (`sites.backup_relay_frequency`) is honoured in in-repo mode; custom/unhosted sites default to daily. `--force` / Backup Now skips the interval. External-agent mode writes `targets.json` instead, leaving out direct-to-S3 (Companion) sites, which the 04:30 `--direct-only` pass covers. See [Features → Backup relay](/docs/features/backup-relay). | @@ -134,7 +135,6 @@ The scheduler itself is watched by `clockwork:scheduler-heartbeat` (every minute | 06:30 | `clockwork:push-companion-backups` | Fetch SpinupWP backup config + DO Spaces history → POST to each Companion site. | | 06:32 | `clockwork:pressable-backups-report` | Pressable counterpart — pulls real backup run history straight from Pressable's `/backups` API (no separate inventory-import dependency, unlike the SpinupWP version). | | 06:35 | `clockwork:push-companion-traffic` | Push yesterday's traffic rollup to each Companion-equipped site. | -| 06:37 | `clockwork:pressable-traffic-report` | Pressable counterpart — pulls page-view period totals straight from Pressable's stats API (no nginx access-log rollup dependency). | | 06:39 | `clockwork:pressable-security-summary-report` | Pressable-only: known plugin/theme vulnerabilities + Defensive Mode status. No SpinupWP equivalent — pure additional capability, not a parity fix. | | 06:40 | `clockwork:push-gatekeeper-settings` | Daily catch-up sync for Gatekeeper login lockout settings. Pushes policy defaults and site overrides to Companion and Renegade across the fleet. | | 06:40 | `clockwork:pull-backup-relay-report` | External-agent mode only: reads the backup-relay droplet's last run summary back from S3 and records it into `backup_relay_runs`. Also alerts if no run has completed within 6 days. See [Features → Backup relay](/docs/features/backup-relay). | diff --git a/resources/views/dashboard/site/header.blade.php b/resources/views/dashboard/site/header.blade.php index dcf57b35..338c9cc3 100644 --- a/resources/views/dashboard/site/header.blade.php +++ b/resources/views/dashboard/site/header.blade.php @@ -98,6 +98,12 @@ class="hover:text-[var(--color-brand)] transition-colors" @endif @endif + {{-- Client Reports module: this site's schedule and report history. --}} + @if (\Illuminate\Support\Facades\Route::has('client-reports.clients.show')) + + Client reports + + @endif @if ($ssoCapable && count($ssoAdmins) > 0)
    diff --git a/resources/views/dashboard/site/widgets/_widget-backups.blade.php b/resources/views/dashboard/site/widgets/_widget-backups.blade.php index 4d965395..a83c7dfb 100644 --- a/resources/views/dashboard/site/widgets/_widget-backups.blade.php +++ b/resources/views/dashboard/site/widgets/_widget-backups.blade.php @@ -10,6 +10,12 @@ $hasRelayOrPressable = $isPressable || $relayEnabled || $lastArchived !== null; $spacesEligible = $isSpinupWp && $spacesConfigured; $showSnapshots = $hasRelayOrPressable || $spacesEligible || $isCustom; + $relayLastError = $site->backup_relay_last_error ?? null; + $relayFailingSince = $site->backup_relay_failing_since ?? null; + $relayLastAttemptAt = $site->backup_relay_last_attempt_at ?? null; + $relayBlockedReason = ($isCustom && ! $relayEnabled && class_exists(\Modules\BackupRelay\Services\CompanionBackupRelayAdapter::class)) + ? \Modules\BackupRelay\Services\CompanionBackupRelayAdapter::enableBlockedReason($site) + : null; @endphp
    toIso8601String()), nextAt: @js($nextScheduled?->toIso8601String()), + lastError: @js($relayLastError), + failingSince: @js($relayFailingSince?->toIso8601String()), + lastAttemptAt: @js($relayLastAttemptAt?->toIso8601String()), + blockedReason: @js($relayBlockedReason), saving: false, runningNow: false, runMessage: null, @@ -223,6 +233,11 @@ if (json.schedule.frequency) this.frequency = json.schedule.frequency; this.lastAt = json.schedule.last_archived_at || this.lastAt; this.nextAt = json.schedule.next_scheduled_at || this.nextAt; + if ('last_error' in json.schedule) { + this.lastError = json.schedule.last_error; + this.failingSince = json.schedule.failing_since; + this.lastAttemptAt = json.schedule.last_attempt_at; + } } }, fetchHistory() { @@ -262,6 +277,10 @@ const data = await res.json(); if (data.ok) { this.nextAt = data.next_scheduled_at; + } else { + // Refused (e.g. the plugin lacks backup-create): show the server's state. + if ('enabled' in data) this.enabled = !!data.enabled; + this.runMessage = data.message || 'Could not save the backup schedule.'; } } catch (e) { /* keep previous UI */ } finally { this.saving = false; } @@ -271,6 +290,7 @@ this.runningNow = true; this.runMessage = null; const started = this.lastAt; + const startedAttempt = this.lastAttemptAt; try { const res = await fetch('{{ route('sites.backup-relay.run-now', $site) }}', { method: 'POST', @@ -293,6 +313,11 @@ this.runningNow = false; return; } + if (this.lastError && this.lastAttemptAt && this.lastAttemptAt !== startedAttempt) { + this.runMessage = 'Backup failed: ' + this.lastError; + this.runningNow = false; + return; + } } this.runMessage = 'Backup is still running. Refresh in a few minutes.'; } catch (e) { @@ -315,9 +340,9 @@ Backups @if ($isCustom) - + - {{ $relayEnabled ? 'Protected' : 'Disabled' }} + {{ $relayEnabled ? ($relayLastError ? 'Failing' : 'Protected') : 'Disabled' }} @elseif ($hasRelayOrPressable) @@ -355,9 +380,10 @@
    - - - {{ $relayEnabled && $lastArchived ? 'Backups are successful' : ($relayEnabled ? 'Glacier relay active' : 'Backups disabled') }} + + + + {{ $relayEnabled && $relayLastError ? 'Last backup attempt failed' : ($relayEnabled && $lastArchived ? 'Backups are successful' : ($relayEnabled ? 'Glacier relay active' : 'Backups disabled')) }}
    @@ -376,6 +402,22 @@
    + {{-- Last failed attempt (cleared by the next successful archive) --}} +
    +
    + + + {{ $relayFailingSince ? 'Failing since '.$relayFailingSince->format('Y-m-d, H:i') : 'Last backup attempt failed' }} + +
    +

    {{ $relayLastError }}

    +

    + {{ $relayLastAttemptAt ? 'Last attempt: '.$relayLastAttemptAt->format('Y-m-d, H:i') : '' }} +

    +
    + {{-- Configuration / Cadence metadata box --}}
    @@ -392,7 +434,8 @@ :aria-checked="enabled ? 'true' : 'false'" aria-label="Toggle Glacier backups for {{ $site->domain }}" @click="enabled = !enabled; saveSchedule()" - :disabled="saving" + :disabled="saving || (!enabled && blockedReason)" + :title="!enabled && blockedReason ? blockedReason : null" class="cw-switch" :class="{ 'cw-switch--on': enabled, 'cw-switch--busy': saving }"> @@ -404,6 +447,7 @@ class="cw-switch"
    +

    {{ $relayBlockedReason }}

    Cadence: + hours +
    + @error('probe_blocked_alert_hours') +

    {{ $message }}

    + @enderror +

    + A site that answers the probe with HTTP 429 (Too Many Requests) is rate-limiting the monitor, not down. + It shows as "blocked", never counts as downtime, and never fires a down alert. If it keeps blocking + for this many hours in a row, one chat notice is sent so someone can allowlist the monitor or check + whether real visitors are being throttled too. Default: 6. +

    +
    + {{-- Domain ignore list --}}
    diff --git a/resources/views/settings/backup-relay.blade.php b/resources/views/settings/backup-relay.blade.php index 06451ff9..5f39bf6a 100644 --- a/resources/views/settings/backup-relay.blade.php +++ b/resources/views/settings/backup-relay.blade.php @@ -1007,7 +1007,16 @@ function backupRelaySettings() { .then(data => { if (data.success) { this.showToast(data.message); + return; } + // Refused (e.g. the site's plugin cannot take direct backups): undo. + if (willEnable) { + const i = this.enabledSites.indexOf(id); + if (i > -1) this.enabledSites.splice(i, 1); + } else if (!this.enabledSites.includes(id)) { + this.enabledSites.push(id); + } + this.showToast(data.message || 'Could not update backup relay for this site.', 'error'); }) .catch(() => { // Revert on network failure diff --git a/resources/views/settings/companion.blade.php b/resources/views/settings/companion.blade.php index 46e72e60..5e60f9fe 100644 --- a/resources/views/settings/companion.blade.php +++ b/resources/views/settings/companion.blade.php @@ -1067,6 +1067,7 @@ class="w-full px-3 py-2 text-sm border border-[var(--color-border)] rounded-md f +

    Leave empty to use the Companion Support Portal URL, then your Agency Website (now: ). Must be a public http(s) address clients can reach, not localhost or a private address.

    diff --git a/routes/console.php b/routes/console.php index 673f0340..a607176b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -258,9 +258,13 @@ // stands at read time). ->then() guarantees the update loop only starts once // refresh has actually exited, however long that takes, instead of racing a // fixed offset. The chained call intentionally skips withoutOverlapping()/ -// onOneServer() of its own — it inherits both from this parent event, since -// the whole refresh+then-update sequence only ever fires once per this -// event's single daily occurrence. +// onOneServer() of its own — it inherits both from this parent event. +// +// 01:30 local happens twice on the DST fall-back day, so this event fires +// twice that night. The update command guards itself: it skips when a +// fleet-wide nightly batch already exists for the local date (see +// RunNightlyPluginUpdates::fleetBatchForLocalDate()), so failures are not +// counted twice toward the auto-pause threshold. Schedule::command('clockwork:refresh-companion-snapshot') ->dailyAt('01:30') ->timezone('America/New_York') @@ -381,8 +385,10 @@ ->withoutOverlapping() ->onOneServer(); -// Pressable counterpart (06:37) moved to Modules\Pressable\PressableServiceProvider::scheduledTasks() (Phase 7) -// — pulls page-view stats straight from Pressable's API (no nginx +// Pressable counterpart (clockwork:pressable-traffic-report) moved to +// Modules\Pressable\PressableServiceProvider::scheduledTasks() (Phase 7) and +// now runs at 04:45, ahead of the 06:00 client reports that read its rows +// — pulls daily traffic straight from Pressable's API (no nginx // access-log rollup dependency, unlike the SpinupWP version). // clockwork:pressable-security-summary-report (06:39, Pressable-only: known plugin/theme diff --git a/tests/Feature/BackupRelay/BackupRelayAttemptStateTest.php b/tests/Feature/BackupRelay/BackupRelayAttemptStateTest.php new file mode 100644 index 00000000..85560af0 --- /dev/null +++ b/tests/Feature/BackupRelay/BackupRelayAttemptStateTest.php @@ -0,0 +1,192 @@ +custom()->withCompanionInstalled()->create(array_merge([ + 'domain' => 'relay-'.uniqid().'.example.com', + 'companion_capabilities' => ['backup-create'], + 'backup_relay_enabled' => true, + 'backup_relay_frequency' => 'daily', + ], $attributes)); +} + +describe('backup relay attempt state', function () { + beforeEach(function () { + Storage::fake('s3-backup-relay'); + Carbon::setTestNow(Carbon::parse('2026-10-28 04:30:00')); + }); + + afterEach(function () { + Carbon::setTestNow(); + }); + + it('records each failure, keeps the start of the streak, and clears it all on the next archive', function () { + $site = directRelaySite(['domain' => 'streak.example.com']); + + Http::fake([ + 'https://streak.example.com/wp-json/clockwork/v1/backup/create*' => Http::sequence() + ->push(['ok' => false, 'error' => 'Database dump failed on host'], 500) + ->push(['ok' => false, 'error' => 'Upload to storage was refused'], 500) + ->push(['ok' => true, 'size_bytes' => 4096, 'sha256' => str_repeat('c', 64)], 200), + ]); + + $uploader = new GlacierUploader('s3-backup-relay'); + + expect(fn () => (new ArchiveSiteBackupJob($site))->handle($uploader)) + ->toThrow(RuntimeException::class, 'Database dump failed on host'); + + $site->refresh(); + expect($site->backup_relay_last_error)->toContain('Database dump failed on host') + ->and($site->backup_relay_failing_since?->toDateTimeString())->toBe('2026-10-28 04:30:00') + ->and($site->backup_relay_last_attempt_at?->toDateTimeString())->toBe('2026-10-28 04:30:00') + ->and($site->backup_relay_last_archived_at)->toBeNull(); + + Carbon::setTestNow(Carbon::parse('2026-10-29 04:30:00')); + + expect(fn () => (new ArchiveSiteBackupJob($site->fresh()))->handle($uploader)) + ->toThrow(RuntimeException::class, 'Upload to storage was refused'); + + $site->refresh(); + expect($site->backup_relay_last_error)->toContain('Upload to storage was refused') + ->and($site->backup_relay_failing_since?->toDateTimeString())->toBe('2026-10-28 04:30:00') + ->and($site->backup_relay_last_attempt_at?->toDateTimeString())->toBe('2026-10-29 04:30:00'); + + Carbon::setTestNow(Carbon::parse('2026-10-30 04:30:00')); + + expect((new ArchiveSiteBackupJob($site->fresh()))->handle($uploader))->toBe('archived'); + + $site->refresh(); + expect($site->backup_relay_last_error)->toBeNull() + ->and($site->backup_relay_failing_since)->toBeNull() + ->and($site->backup_relay_last_attempt_at?->toDateTimeString())->toBe('2026-10-30 04:30:00') + ->and($site->backup_relay_last_archived_at?->toDateTimeString())->toBe('2026-10-30 04:30:00') + ->and($site->backup_relay_last_sha256)->toBe(str_repeat('c', 64)); + }); + + it('leaves attempt state alone when the run skips the site', function () { + $site = directRelaySite([ + 'backup_relay_last_archived_at' => now()->subHour(), + 'backup_relay_last_attempt_at' => now()->subHour(), + ]); + + Http::fake(); + + expect((new ArchiveSiteBackupJob($site))->handle(new GlacierUploader('s3-backup-relay')))->toBe('skipped'); + + expect($site->fresh()->backup_relay_last_attempt_at?->toDateTimeString())->toBe('2026-10-28 03:30:00'); + Http::assertNothingSent(); + }); + + it('persists the failure from a scheduled direct-only run so it is visible without log access', function () { + directRelaySite([ + 'domain' => 'old-plugin.example.com', + 'companion_version' => '1.30.3', + 'companion_capabilities' => ['snapshot', 'updates'], + ]); + + Http::fake(); + $this->mock(ChatNotifier::class, fn ($mock) => $mock->shouldReceive('send') + ->once() + ->withArgs(fn (string $message) => str_contains($message, 'old-plugin.example.com'))); + + $this->artisan('clockwork:backup-relay-run --direct-only') + ->expectsOutputToContain('lacks the backup-create capability') + ->assertExitCode(1); + + $site = Site::query()->where('domain', 'old-plugin.example.com')->firstOrFail(); + expect($site->backup_relay_last_error) + ->toContain('Companion 1.30.3 lacks the backup-create capability; update Companion or switch to Renegade.') + ->and($site->backup_relay_failing_since)->not->toBeNull() + ->and($site->backup_relay_last_attempt_at)->not->toBeNull(); + + Http::assertNothingSent(); + }); +}); + +describe('backup relay stored archive hash', function () { + beforeEach(function () { + Storage::fake('s3-backup-relay'); + Carbon::setTestNow(Carbon::parse('2026-10-31 04:45:00')); + }); + + afterEach(function () { + Carbon::setTestNow(); + }); + + it('clears the previous hash when an archive already in storage is adopted without one', function () { + $site = directRelaySite([ + 'domain' => 'adopted.example.com', + 'backup_relay_last_archived_at' => Carbon::parse('2026-10-30 04:45:00'), + 'backup_relay_last_sha256' => str_repeat('a', 64), + ]); + + $previousKey = 'archives/adopted.example.com/2026-10-30.zip'; + $newKey = 'archives/adopted.example.com/2026-10-31.zip'; + Storage::disk('s3-backup-relay')->put($previousKey, str_repeat('p', 128)); + Storage::disk('s3-backup-relay')->put($newKey, str_repeat('n', 256)); + + Http::fake(); + + expect((new ArchiveSiteBackupJob($site))->handle(new GlacierUploader('s3-backup-relay')))->toBe('archived'); + + $site->refresh(); + expect($site->backup_relay_last_sha256)->toBeNull() + ->and($site->backup_relay_last_size_bytes)->toBe(256) + ->and(app(BackupArchiveEnumerator::class)->resolveArchiveSha256($site, $newKey))->toBeNull(); + + Http::assertNothingSent(); + }); + + it('clears the previous hash when the plugin reports a failure but the archive landed anyway', function () { + $site = directRelaySite([ + 'domain' => 'landed.example.com', + 'backup_relay_last_archived_at' => Carbon::parse('2026-10-30 04:45:00'), + 'backup_relay_last_sha256' => str_repeat('a', 64), + 'backup_relay_last_error' => 'HTTP 502 error code: 502', + 'backup_relay_failing_since' => Carbon::parse('2026-10-29 04:45:00'), + ]); + + $newKey = 'archives/landed.example.com/2026-10-31.zip'; + + // The upload completes on the host while the gateway answers 502. + Http::fake(function (Request $request) use ($newKey) { + if (str_contains($request->url(), '/backup/create')) { + Storage::disk('s3-backup-relay')->put($newKey, str_repeat('n', 512)); + + return Http::response('error code: 502', 502); + } + + return Http::response([], 404); + }); + + expect((new ArchiveSiteBackupJob($site))->handle(new GlacierUploader('s3-backup-relay')))->toBe('archived'); + + $site->refresh(); + expect($site->backup_relay_last_sha256)->toBeNull() + ->and($site->backup_relay_last_archived_at?->toDateTimeString())->toBe('2026-10-31 04:45:00') + ->and($site->backup_relay_last_error)->toBeNull() + ->and($site->backup_relay_failing_since)->toBeNull() + ->and(app(BackupArchiveEnumerator::class)->resolveArchiveSha256($site, $newKey))->toBeNull(); + + Storage::disk('s3-backup-relay')->assertMissing("{$newKey}.sha256.json"); + }); +}); diff --git a/tests/Feature/BackupRelay/CompanionBackupRelayTest.php b/tests/Feature/BackupRelay/CompanionBackupRelayTest.php index 364c3416..0bfc2994 100644 --- a/tests/Feature/BackupRelay/CompanionBackupRelayTest.php +++ b/tests/Feature/BackupRelay/CompanionBackupRelayTest.php @@ -59,6 +59,7 @@ it('successfully executes direct S3 Glacier backup via Companion and persists size and sha256', function () { $site = Site::factory()->custom()->withCompanionInstalled()->create([ 'domain' => 'wpengine-client.com', + 'companion_capabilities' => ['backup-create'], 'backup_relay_enabled' => true, 'backup_relay_last_archived_at' => null, ]); @@ -104,6 +105,7 @@ it('skips a second scheduled daily run but Backup Now writes a timestamped zip', function () { $site = Site::factory()->custom()->withCompanionInstalled()->create([ 'domain' => 'daily-custom.com', + 'companion_capabilities' => ['backup-create'], 'backup_relay_enabled' => true, 'backup_relay_frequency' => 'daily', 'backup_relay_last_archived_at' => null, @@ -162,6 +164,7 @@ it('handles companion backup generation failures gracefully', function () { $site = Site::factory()->custom()->withCompanionInstalled()->create([ 'domain' => 'wpengine-failed.com', + 'companion_capabilities' => ['backup-create'], 'backup_relay_enabled' => true, ]); @@ -177,4 +180,75 @@ expect(fn () => $job->handle($uploader))->toThrow(\RuntimeException::class, 'Disk space insufficient'); }); + + it('fails with an actionable error and never calls a plugin that lacks backup-create', function () { + $site = Site::factory()->custom()->withCompanionInstalled()->create([ + 'domain' => 'old-companion.example.com', + 'companion_variant' => 'companion', + 'companion_version' => '1.30.3', + 'companion_capabilities' => ['snapshot', 'updates', 'malware-scan'], + 'backup_relay_enabled' => true, + ]); + + Http::fake(); + + $job = new ArchiveSiteBackupJob($site); + + expect(fn () => $job->handle(new GlacierUploader('s3-backup-relay')))->toThrow( + \RuntimeException::class, + 'old-companion.example.com: Companion 1.30.3 lacks the backup-create capability; update Companion or switch to Renegade.', + ); + + Http::assertNothingSent(); + expect($site->fresh()->backup_relay_last_archived_at)->toBeNull(); + }); + + it('still archives a site without backup-create when its object is already in storage', function () { + $site = Site::factory()->custom()->withCompanionInstalled()->create([ + 'domain' => 'old-companion-landed.example.com', + 'companion_capabilities' => [], + 'backup_relay_enabled' => true, + ]); + Storage::disk('s3-backup-relay')->put('archives/old-companion-landed.example.com/2026-09-11.zip', 'zip'); + + Http::fake(); + + expect((new ArchiveSiteBackupJob($site))->handle(new GlacierUploader('s3-backup-relay')))->toBe('archived'); + Http::assertNothingSent(); + }); + + it('names the plugin the site actually runs in the missing-capability message', function () { + $companion = Site::factory()->custom()->make(['companion_variant' => 'companion', 'companion_version' => '1.30.3']); + $renegade = Site::factory()->custom()->make(['companion_variant' => 'renegade', 'companion_version' => '1.0.0']); + $unknown = Site::factory()->custom()->make(['companion_variant' => 'companion', 'companion_version' => null]); + + expect(CompanionBackupRelayAdapter::missingBackupCreateMessage($companion)) + ->toBe('Companion 1.30.3 lacks the backup-create capability; update Companion or switch to Renegade.') + ->and(CompanionBackupRelayAdapter::missingBackupCreateMessage($renegade)) + ->toBe('Renegade 1.0.0 lacks the backup-create capability; update Renegade.') + ->and(CompanionBackupRelayAdapter::missingBackupCreateMessage($unknown)) + ->toBe('Companion lacks the backup-create capability; update Companion or switch to Renegade.'); + }); + + it('blocks enabling relay only for direct-upload sites whose plugin lacks backup-create', function () { + $withoutCapability = Site::factory()->custom()->withCompanionInstalled()->create([ + 'companion_version' => '1.30.3', + 'companion_capabilities' => ['snapshot'], + ]); + $neverProbed = Site::factory()->custom()->withCompanionInstalled()->create([ + 'companion_capabilities' => null, + ]); + $withCapability = Site::factory()->custom()->withCompanionInstalled()->create([ + 'companion_capabilities' => ['snapshot', 'backup-create'], + ]); + $hostStreamed = Site::factory()->pressable()->create([ + 'companion_capabilities' => null, + ]); + + expect(CompanionBackupRelayAdapter::enableBlockedReason($withoutCapability)) + ->toBe('Companion 1.30.3 lacks the backup-create capability; update Companion or switch to Renegade.') + ->and(CompanionBackupRelayAdapter::enableBlockedReason($neverProbed))->toContain('lacks the backup-create capability') + ->and(CompanionBackupRelayAdapter::enableBlockedReason($withCapability))->toBeNull() + ->and(CompanionBackupRelayAdapter::enableBlockedReason($hostStreamed))->toBeNull(); + }); }); diff --git a/tests/Feature/BackupRelay/SiteBackupRelayScheduleTest.php b/tests/Feature/BackupRelay/SiteBackupRelayScheduleTest.php index d35c16fb..b02d53de 100644 --- a/tests/Feature/BackupRelay/SiteBackupRelayScheduleTest.php +++ b/tests/Feature/BackupRelay/SiteBackupRelayScheduleTest.php @@ -108,4 +108,80 @@ ->assertStatus(422) ->assertJsonPath('ok', false); }); + + it('refuses to turn relay on when the site plugin lacks backup-create', function () { + $site = Site::factory()->custom()->withCompanionInstalled()->create([ + 'companion_version' => '1.30.3', + 'companion_capabilities' => ['snapshot', 'updates'], + 'backup_relay_enabled' => false, + 'backup_relay_frequency' => 'daily', + ]); + + $this->patchJson(route('sites.backup-relay.update', $site), [ + 'enabled' => true, + 'frequency' => 'weekly', + ])->assertStatus(422) + ->assertJsonPath('ok', false) + ->assertJsonPath('enabled', false) + ->assertJsonPath('message', "Glacier backups for {$site->domain} were not turned on: Companion 1.30.3 lacks the backup-create capability; update Companion or switch to Renegade."); + + $site->refresh(); + expect($site->backup_relay_enabled)->toBeFalse() + ->and($site->backup_relay_frequency)->toBe('daily') + ->and(ActionLog::query()->where('site_id', $site->id)->where('action_type', ActionLog::TYPE_BACKUP_RELAY_TOGGLED)->exists())->toBeFalse(); + }); + + it('redirects back with a warning when a form post tries to enable relay without backup-create', function () { + $site = Site::factory()->custom()->withCompanionInstalled()->create([ + 'companion_capabilities' => [], + 'backup_relay_enabled' => false, + ]); + + $this->from(route('sites.show', $site)) + ->patch(route('sites.backup-relay.update', $site), [ + 'enabled' => '1', + 'frequency' => 'daily', + ])->assertRedirect(route('sites.show', $site)) + ->assertSessionHas('warning', fn (string $msg) => str_contains($msg, 'lacks the backup-create capability')); + + expect($site->fresh()->backup_relay_enabled)->toBeFalse(); + }); + + it('turns relay on when the site plugin has backup-create', function () { + $site = Site::factory()->custom()->withCompanionInstalled()->create([ + 'companion_capabilities' => ['snapshot', 'backup-create'], + 'backup_relay_enabled' => false, + ]); + + $this->patchJson(route('sites.backup-relay.update', $site), [ + 'enabled' => true, + 'frequency' => 'daily', + ])->assertOk() + ->assertJsonPath('ok', true) + ->assertJsonPath('enabled', true); + + expect($site->fresh()->backup_relay_enabled)->toBeTrue(); + }); + + it('lets a site already on relay without backup-create change cadence or turn off', function () { + $site = Site::factory()->custom()->withCompanionInstalled()->create([ + 'companion_capabilities' => ['snapshot'], + 'backup_relay_enabled' => true, + 'backup_relay_frequency' => 'daily', + ]); + + $this->patchJson(route('sites.backup-relay.update', $site), [ + 'enabled' => true, + 'frequency' => 'weekly', + ])->assertOk()->assertJsonPath('enabled', true); + + expect($site->fresh()->backup_relay_frequency)->toBe('weekly'); + + $this->patchJson(route('sites.backup-relay.update', $site), [ + 'enabled' => false, + 'frequency' => 'weekly', + ])->assertOk()->assertJsonPath('enabled', false); + + expect($site->fresh()->backup_relay_enabled)->toBeFalse(); + }); }); diff --git a/tests/Feature/BillCom/CarePlanAndCustomerSyncTest.php b/tests/Feature/BillCom/CarePlanAndCustomerSyncTest.php index fd3efc8d..dcfebdea 100644 --- a/tests/Feature/BillCom/CarePlanAndCustomerSyncTest.php +++ b/tests/Feature/BillCom/CarePlanAndCustomerSyncTest.php @@ -242,3 +242,197 @@ function billComClientMock(array $customers = [], array $items = [], array $invo ->and($stats['sites_skipped_due_to_override'])->toBe(0); }); }); + +/** + * One invoice with one line item — enough for the domain-matching cases below. + */ +function billComSingleLineInvoice(string $customerId, string $description, string $itemId = 'item-1', string $invoiceNumber = 'INV-9001'): array +{ + return [ + 'customerId' => $customerId, + 'invoiceNumber' => $invoiceNumber, + 'invoiceDate' => '2026-08-01', + 'invoiceLineItems' => [ + ['itemId' => $itemId, 'description' => $description], + ], + ]; +} + +describe('CustomerSyncService::sync() domain normalization', function () { + it('links a site stored as www.example.com from a line that writes it with a scheme and www.', function () { + $site = Site::factory()->create(['domain' => 'www.example.com', 'bill_com_customer_id' => null]); + + $client = billComClientMock( + customers: [['id' => '0cuWWW1', 'name' => 'Example Client', 'archived' => false]], + invoices: [billComSingleLineInvoice('0cuWWW1', 'http://www.example.com - WordPress Care Plan')], + ); + + $stats = (new CustomerSyncService($client))->sync(windowDays: 365); + + $site->refresh(); + expect($site->bill_com_customer_id)->toBe('0cuWWW1') + ->and($site->bill_com_linked_via_invoice)->toBe('INV-9001') + ->and($stats['sites_linked'])->toBe(1) + ->and($stats['unmatched_domains'])->toBe([]) + ->and($stats['domain_collisions'])->toBe([]); + }); + + it('links a site stored as www.example.org from a line that names the bare domain', function () { + $site = Site::factory()->create(['domain' => 'www.example.org', 'bill_com_customer_id' => null]); + + $client = billComClientMock( + customers: [['id' => '0cuWWW2', 'name' => 'Example Org', 'archived' => false]], + invoices: [billComSingleLineInvoice('0cuWWW2', 'Website hosting for example.org')], + ); + + $stats = (new CustomerSyncService($client))->sync(windowDays: 365); + + expect($site->refresh()->bill_com_customer_id)->toBe('0cuWWW2') + ->and($stats['sites_linked'])->toBe(1) + ->and($stats['unmatched_domains'])->toBe([]); + }); + + it('links a site on a TLD outside the static allow-list because a non-archived site uses that TLD', function () { + $site = Site::factory()->create(['domain' => 'example.tv', 'bill_com_customer_id' => null]); + + $client = billComClientMock( + customers: [['id' => '0cuTV1', 'name' => 'Example TV', 'archived' => false]], + invoices: [billComSingleLineInvoice('0cuTV1', 'Website hosting for example.tv')], + ); + + $stats = (new CustomerSyncService($client))->sync(windowDays: 365); + + expect($site->refresh()->bill_com_customer_id)->toBe('0cuTV1') + ->and($stats['sites_linked'])->toBe(1); + }); + + it('lists a domain only an archived site has under archived_site_domains, not unmatched, and never links it', function () { + $archived = Site::factory()->archived()->create(['domain' => 'www.example.net', 'bill_com_customer_id' => null]); + + $client = billComClientMock( + customers: [['id' => '0cuARCH1', 'name' => 'Former Client', 'archived' => false]], + invoices: [billComSingleLineInvoice('0cuARCH1', 'Hosting for example.net and missing-example.com')], + ); + + $stats = (new CustomerSyncService($client))->sync(windowDays: 365); + + $archived = Site::withoutGlobalScope('notArchived')->find($archived->id); + expect($archived->bill_com_customer_id)->toBeNull() + ->and($stats['archived_site_domains'])->toBe(['example.net' => 1]) + ->and($stats['unmatched_domains'])->toBe(['missing-example.com' => 1]) + ->and($stats['sites_linked'])->toBe(0); + }); + + it('prefers the exact domain when two active sites normalize to the same key, and counts the collision', function () { + $apex = Site::factory()->create(['domain' => 'example.com', 'bill_com_customer_id' => null]); + $www = Site::factory()->create(['domain' => 'www.example.com', 'bill_com_customer_id' => null]); + + $client = billComClientMock( + customers: [['id' => '0cuDUP1', 'name' => 'Duplicate Client', 'archived' => false]], + invoices: [billComSingleLineInvoice('0cuDUP1', 'Website hosting for example.com')], + ); + + $stats = (new CustomerSyncService($client))->sync(windowDays: 365); + + expect($apex->refresh()->bill_com_customer_id)->toBe('0cuDUP1') + ->and($www->refresh()->bill_com_customer_id)->toBeNull() + ->and($stats['sites_linked'])->toBe(1) + ->and($stats['domain_collisions'])->toBe(['example.com' => 2]); + }); +}); + +describe('CarePlanSyncService::sync() domain normalization', function () { + it('flips care_plan_enabled on for a site stored as www.example.com when the care-plan line writes it with a scheme and www.', function () { + $customer = BillComCustomer::create(['id' => '0cuCPWWW1', 'name' => 'Example Client', 'archived' => false]); + $site = Site::factory()->create([ + 'domain' => 'www.example.com', + 'bill_com_customer_id' => $customer->id, + 'care_plan_enabled' => false, + 'care_plan_override' => null, + ]); + + $client = billComClientMock( + items: [['id' => 'item-care-1', 'name' => 'WordPress Care Plan']], + invoices: [billComSingleLineInvoice($customer->id, 'http://www.example.com - WordPress Care Plan', 'item-care-1')], + ); + + $stats = (new CarePlanSyncService($client))->sync(windowDays: 60, itemRegex: '/care plan/i'); + + expect($site->refresh()->care_plan_enabled)->toBeTrue() + ->and($stats['sites_set_true'])->toBe(1) + ->and($stats['active_care_plan_domains'])->toBe(1) + ->and($stats['unmatched_care_plan_domains'])->toBe([]) + ->and($stats['domain_collisions'])->toBe([]); + }); + + it('flips care_plan_enabled on for a .law site, a TLD only recognised because a non-archived site uses it', function () { + $customer = BillComCustomer::create(['id' => '0cuCPLAW1', 'name' => 'Example Law', 'archived' => false]); + $site = Site::factory()->create([ + 'domain' => 'example.law', + 'bill_com_customer_id' => $customer->id, + 'care_plan_enabled' => false, + 'care_plan_override' => null, + ]); + + $client = billComClientMock( + items: [['id' => 'item-care-1', 'name' => 'Care Plan - Monthly']], + invoices: [billComSingleLineInvoice($customer->id, 'Care plan services for example.law', 'item-care-1')], + ); + + $stats = (new CarePlanSyncService($client))->sync(windowDays: 60, itemRegex: '/care plan/i'); + + expect($site->refresh()->care_plan_enabled)->toBeTrue() + ->and($stats['care_plan_lineitems_without_domain'])->toBe(0) + ->and($stats['unmatched_care_plan_domains'])->toBe([]); + }); + + it('lists a care-plan domain only an archived site has under archived_care_plan_domains, keeping truly unknown ones unmatched', function () { + $customer = BillComCustomer::create(['id' => '0cuCPARCH1', 'name' => 'Former Client', 'archived' => false]); + Site::factory()->archived()->create([ + 'domain' => 'example.net', + 'bill_com_customer_id' => $customer->id, + 'care_plan_enabled' => false, + 'care_plan_override' => null, + ]); + + $client = billComClientMock( + items: [['id' => 'item-care-1', 'name' => 'Care Plan - Monthly']], + invoices: [billComSingleLineInvoice($customer->id, 'Care plan for www.example.net and missing-example.org', 'item-care-1')], + ); + + $stats = (new CarePlanSyncService($client))->sync(windowDays: 60, itemRegex: '/care plan/i'); + + expect($stats['archived_care_plan_domains'])->toBe(['example.net' => 1]) + ->and($stats['unmatched_care_plan_domains'])->toBe(['missing-example.org' => 1]) + ->and($stats['sites_set_true'])->toBe(0); + }); + + it('flags only the exact-match site when two active sites normalize to the same domain, and counts the collision', function () { + $customer = BillComCustomer::create(['id' => '0cuCPDUP1', 'name' => 'Duplicate Client', 'archived' => false]); + $apex = Site::factory()->create([ + 'domain' => 'example.org', + 'bill_com_customer_id' => $customer->id, + 'care_plan_enabled' => false, + 'care_plan_override' => null, + ]); + $www = Site::factory()->create([ + 'domain' => 'www.example.org', + 'bill_com_customer_id' => $customer->id, + 'care_plan_enabled' => false, + 'care_plan_override' => null, + ]); + + $client = billComClientMock( + items: [['id' => 'item-care-1', 'name' => 'Care Plan - Monthly']], + invoices: [billComSingleLineInvoice($customer->id, 'Care plan for example.org', 'item-care-1')], + ); + + $stats = (new CarePlanSyncService($client))->sync(windowDays: 60, itemRegex: '/care plan/i'); + + expect($apex->refresh()->care_plan_enabled)->toBeTrue() + ->and($www->refresh()->care_plan_enabled)->toBeFalse() + ->and($stats['sites_set_true'])->toBe(1) + ->and($stats['domain_collisions'])->toBe(['example.org' => 2]) + ->and($stats['unmatched_care_plan_domains'])->toBe([]); + }); +}); diff --git a/tests/Feature/BillCom/SiteDomainIndexTest.php b/tests/Feature/BillCom/SiteDomainIndexTest.php new file mode 100644 index 00000000..0c599e8b --- /dev/null +++ b/tests/Feature/BillCom/SiteDomainIndexTest.php @@ -0,0 +1,89 @@ +create(['domain' => 'www.example.com']); + $bare = Site::factory()->create(['domain' => 'example.org']); + + $index = SiteDomainIndex::build(); + + expect($index->find('example.com')?->id)->toBe($www->id) + ->and($index->find('HTTP://WWW.EXAMPLE.COM/'))->not->toBeNull() + ->and($index->find('www.example.org')?->id)->toBe($bare->id) + ->and($index->find('missing-example.net'))->toBeNull() + ->and($index->collisions())->toBe([]); + }); + + it('never returns an archived site, but remembers its domain', function () { + Site::factory()->archived()->create(['domain' => 'www.example.net']); + + $index = SiteDomainIndex::build(); + + expect($index->find('example.net'))->toBeNull() + ->and($index->belongsToArchivedSite('example.net'))->toBeTrue() + ->and($index->belongsToArchivedSite('missing-example.net'))->toBeFalse(); + }); + + it('does not call a domain archived-only when a live site also has it', function () { + Site::factory()->archived()->create(['domain' => 'www.example.com']); + $live = Site::factory()->create(['domain' => 'example.com']); + + $index = SiteDomainIndex::build(); + + expect($index->find('www.example.com')?->id)->toBe($live->id) + ->and($index->belongsToArchivedSite('example.com'))->toBeFalse() + ->and($index->collisions())->toBe([]); + }); + + it('collects the TLDs of non-archived sites only', function () { + Site::factory()->create(['domain' => 'www.example.tv']); + Site::factory()->create(['domain' => 'example.law']); + Site::factory()->archived()->create(['domain' => 'example.museum']); + + $tlds = SiteDomainIndex::build()->tlds(); + + expect($tlds)->toContain('tv', 'law') + ->and($tlds)->not->toContain('museum'); + }); + + it('prefers the exact domain on a collision and records it', function () { + $apex = Site::factory()->create(['domain' => 'example.com']); + $www = Site::factory()->create(['domain' => 'www.example.com']); + + $index = SiteDomainIndex::build(); + + expect($index->find('example.com')?->id)->toBe($apex->id) + ->and($index->find('www.example.com')?->id)->toBe($www->id) + ->and($index->collisions())->toBe(['example.com' => 2]); + }); + + it('falls back to the lowest id when no colliding site matches exactly', function () { + $first = Site::factory()->create(['domain' => 'www.example.org']); + Site::factory()->create(['domain' => 'WWW.Example.org.']); + + $index = SiteDomainIndex::build(); + + expect($index->find('https://example.org/')?->id)->toBe($first->id) + ->and($index->collisions())->toBe(['example.org' => 2]); + }); + + it('records nothing for a collision no lookup touched', function () { + Site::factory()->create(['domain' => 'example.com']); + Site::factory()->create(['domain' => 'www.example.com']); + + $index = SiteDomainIndex::build(); + $index->find('missing-example.com'); + + expect($index->collisions())->toBe([]); + }); +}); diff --git a/tests/Feature/Chat/ChatNotifierGatingTest.php b/tests/Feature/Chat/ChatNotifierGatingTest.php index 64c59359..b227c0cc 100644 --- a/tests/Feature/Chat/ChatNotifierGatingTest.php +++ b/tests/Feature/Chat/ChatNotifierGatingTest.php @@ -30,14 +30,15 @@ | an inactive site: | sslStateChanged, contactFormTestFailed, | contactFormTestRecovered, companionUnreachable, companionReachable, -| pluginUpdateFailed +| pluginUpdateFailed, uptimeProbeBlocked (a monitoring gap, not an outage) | | NEVER gated — routed straight through dispatch() even for an inactive | site, because the class's own comment block says these are active-incident | signals (a real compromise, a real outage, a real attacker at the | firewall) on infrastructure Clockwork still serves, not routine | maintenance nags like an upcoming SSL renewal: -| siteWentDown, siteWentUp, malwareFindingDetected, ipBlocked +| siteWentDown, siteWentUp, malwareFindingDetected, phpInUploadsDetected, +| ipBlocked | | Not site-scoped at all — no Site parameter, so is_inactive can't apply: | send, backupRelayStale, backupRelayRecovered, serverUpdateFailed, @@ -163,6 +164,13 @@ public function siteExitedMaintenance(Site $site, ?int $maintenanceSec): bool return $this->returns; } + public function uptimeProbeBlocked(Site $site, int $blockedSec, ?int $statusCode, ?string $retryAfter = null): bool + { + $this->calls[] = __FUNCTION__; + + return $this->returns; + } + public function pluginUpdateFailed(Site $site, PluginUpdateJob $job): bool { $this->calls[] = __FUNCTION__; @@ -184,6 +192,13 @@ public function malwareFindingDetected(Site $site, SiteSecurityScan $scan): bool return $this->returns; } + public function phpInUploadsDetected(Site $site, SiteSecurityScan $scan, array $paths): bool + { + $this->calls[] = __FUNCTION__; + + return $this->returns; + } + public function backupRelayStale(int $daysSinceLastRun, ?Carbon $lastRunAt = null): bool { $this->calls[] = __FUNCTION__; @@ -285,10 +300,11 @@ public function emailAuthDegraded(string $domain, array $findings, ?string $prev PluginUpdateJob::factory()->create(['site_id' => $s->id]), 5 )], + 'uptimeProbeBlocked' => [fn (ChatNotifierDispatcher $d, Site $s) => $d->uptimeProbeBlocked($s, 6 * 3600, 429, '120')], ]); /** - * The four dispatch()-routed "active incident" methods that must NEVER be + * The dispatch()-routed "active incident" methods that must NEVER be * gated on is_inactive, each as a closure invoking it with plausible args. */ dataset('neverGatedMethods', [ @@ -298,6 +314,15 @@ public function emailAuthDegraded(string $domain, array $findings, ?string $prev $s, SiteSecurityScan::factory()->malwareFound()->create(['site_id' => $s->id]) )], + 'phpInUploadsDetected' => [fn (ChatNotifierDispatcher $d, Site $s) => $d->phpInUploadsDetected( + $s, + SiteSecurityScan::factory()->create([ + 'site_id' => $s->id, + 'scan_type' => SiteSecurityScan::TYPE_COMPANION_MALWARE, + 'status' => SiteSecurityScan::STATUS_WARNING, + ]), + ['wp-content/uploads/2026/01/new.php'] + )], 'ipBlocked' => [fn (ChatNotifierDispatcher $d, Site $s) => $d->ipBlocked(BlockedIp::factory()->create())], ]); diff --git a/tests/Feature/Chat/PhpInUploadsNoticeTest.php b/tests/Feature/Chat/PhpInUploadsNoticeTest.php new file mode 100644 index 00000000..6f4211ef --- /dev/null +++ b/tests/Feature/Chat/PhpInUploadsNoticeTest.php @@ -0,0 +1,100 @@ + true, + 'clockwork.mattermost.webhook_url' => 'https://chat.example.com/hooks/test', + ]); + Http::fake(['chat.example.com/*' => Http::response('ok', 200)]); +}); + +function phpUploadsNoticeScan(Site $site): SiteSecurityScan +{ + return SiteSecurityScan::factory()->create([ + 'site_id' => $site->id, + 'scan_type' => SiteSecurityScan::TYPE_COMPANION_MALWARE, + 'status' => SiteSecurityScan::STATUS_WARNING, + ]); +} + +it('posts the new paths with the site and scan', function () { + $site = Site::factory()->create(['domain' => 'notice.example.com']); + $scan = phpUploadsNoticeScan($site); + + $sent = app(MattermostNotifier::class)->phpInUploadsDetected($site, $scan, ['wp-content/uploads/2026/10/dropped.php']); + + expect($sent)->toBeTrue(); + Http::assertSent(function ($request) use ($scan) { + $attachment = $request['attachments'][0] ?? []; + $fields = collect($attachment['fields'] ?? [])->pluck('value', 'title'); + + return str_contains($request['text'], '1 new PHP file in uploads on notice.example.com') + && str_contains($attachment['text'] ?? '', '`wp-content/uploads/2026/10/dropped.php`') + && $fields['Site'] === 'notice.example.com' + && $fields['Scan'] === '#'.$scan->id; + }); +}); + +it('lists at most ten paths and counts the rest', function () { + $site = Site::factory()->create(['domain' => 'many.example.com']); + $paths = array_map(fn ($i) => "wp-content/uploads/f{$i}.php", range(1, 12)); + + app(MattermostNotifier::class)->phpInUploadsDetected($site, phpUploadsNoticeScan($site), $paths); + + Http::assertSent(function ($request) { + $text = $request['attachments'][0]['text'] ?? ''; + + return str_contains($request['text'], '12 new PHP files in uploads') + && str_contains($text, 'f10.php') + && ! str_contains($text, 'f11.php') + && str_contains($text, '…and 2 more'); + }); +}); + +it('defangs characters in a file name that could break formatting or trigger a mention', function () { + $site = Site::factory()->create(); + + app(MattermostNotifier::class)->phpInUploadsDetected($site, phpUploadsNoticeScan($site), ['wp-content/uploads/`x`.php']); + + Http::assertSent(function ($request) { + $text = $request['attachments'][0]['text'] ?? ''; + + return ! str_contains($text, '') + && str_contains($text, "‹!channel›'x'.php"); + }); +}); + +it('sends nothing when the event is switched off in notification settings', function () { + app(Settings::class)->put(MattermostNotifier::SETTINGS_KEY, ['php_in_uploads_detected' => false]); + $site = Site::factory()->create(); + + $sent = app(MattermostNotifier::class)->phpInUploadsDetected($site, phpUploadsNoticeScan($site), ['wp-content/uploads/a.php']); + + expect($sent)->toBeFalse(); + Http::assertNothingSent(); +}); + +it('sends nothing for an empty path list', function () { + $site = Site::factory()->create(); + + expect(app(MattermostNotifier::class)->phpInUploadsDetected($site, phpUploadsNoticeScan($site), []))->toBeFalse(); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Console/BackfillCompanionActionLogTest.php b/tests/Feature/Console/BackfillCompanionActionLogTest.php index 8e56d6f3..26d684f0 100644 --- a/tests/Feature/Console/BackfillCompanionActionLogTest.php +++ b/tests/Feature/Console/BackfillCompanionActionLogTest.php @@ -59,6 +59,30 @@ function backfillSite(array $overrides = []): Site expect($alreadyPushed->refresh()->companion_pushed_at->timestamp)->toBe($alreadyPushedAt->timestamp); }); + it('never pushes local-only rows (updates Control only observed or held back)', function () { + $site = backfillSite(['domain' => 'backfill-local-only.test']); + $observed = ActionLog::factory()->create([ + 'site_id' => $site->id, + 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE_OBSERVED, + 'companion_pushed_at' => null, + ]); + $refused = ActionLog::factory()->create([ + 'site_id' => $site->id, + 'action_type' => ActionLog::TYPE_THEME_UPDATE_REFUSED, + 'companion_pushed_at' => null, + ]); + + Http::fake(); + + $this->artisan('clockwork:backfill-companion-action-log') + ->expectsOutputToContain('sites_already_clean=1') + ->assertSuccessful(); + + Http::assertNothingSent(); + expect($observed->refresh()->companion_pushed_at)->toBeNull() + ->and($refused->refresh()->companion_pushed_at)->toBeNull(); + }); + it('skips sites whose Companion does not advertise the action-log capability, without any HTTP call', function () { $site = backfillSite(['domain' => 'backfill-nocap.test', 'companion_capabilities' => ['malware-scan']]); ActionLog::factory()->create(['site_id' => $site->id, 'companion_pushed_at' => null]); @@ -163,6 +187,43 @@ function backfillSite(array $overrides = []): Site Http::assertNotSent(fn ($request) => str_contains($request->url(), $other->domain)); }); + it('redacts licence keys and signed download URLs from old rows before pushing them again', function () { + // A row written before upgrader output was redacted at write time. + $site = backfillSite(['domain' => 'backfill-redact.test']); + $row = ActionLog::factory()->create([ + 'site_id' => $site->id, + 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE, + 'summary' => 'Plugin update failed: Example Pro', + 'details' => ['messages' => [ + 'Downloading update from https://vendor.example.com/dl.zip?license_key=FAKE-KEY-0000&token=FAKE-TOKEN-1111…', + 'Unpacking the update…', + ], 'from_version' => '1.0.0'], + 'error' => 'Download failed: https://vendor.example.com/dl.zip?token=FAKE-TOKEN-1111', + 'companion_pushed_at' => now()->subDays(10), + 'ran_at' => now()->subDays(10), + ]); + + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/action-log/append*" => Http::response(['ok' => true], 200, ['Content-Type' => 'application/json']), + ]); + + $this->artisan('clockwork:backfill-companion-action-log', ['--reset-since' => now()->subDays(5)->toDateString()]) + ->expectsOutputToContain('rows_ok=1') + ->assertSuccessful(); + + Http::assertSent(function ($request) { + $body = $request->body(); + + return str_contains($request->url(), 'action-log/append') + && ! str_contains($body, 'FAKE-KEY-0000') + && ! str_contains($body, 'FAKE-TOKEN-1111') + && str_contains($body, 'redacted') + && $request['details']['from_version'] === '1.0.0'; + }); + // The stored row is left as it is; only the mirror is redacted. + expect($row->refresh()->error)->toContain('FAKE-TOKEN-1111'); + }); + it('exits successfully with a warning when no Companion-equipped site matches', function () { $this->artisan('clockwork:backfill-companion-action-log') ->expectsOutputToContain('No Companion-equipped sites matched.') diff --git a/tests/Feature/Console/PressableTrafficReportTest.php b/tests/Feature/Console/PressableTrafficReportTest.php index 6b23d28f..0eacac35 100644 --- a/tests/Feature/Console/PressableTrafficReportTest.php +++ b/tests/Feature/Console/PressableTrafficReportTest.php @@ -4,7 +4,10 @@ use App\Models\Site; use App\Models\SiteTrafficDaily; +use Illuminate\Console\Scheduling\Event; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Modules\Pressable\PressableClient; @@ -18,6 +21,9 @@ | ->siteMetrics() calls (requests+http_status, uniques+hostname — both | "Past 1 month" — and requests+path for "Past 1 day" top-paths) and POSTs | it to each Companion-equipped Pressable site's /traffic-report endpoint. +| Independently of that push, every Pressable site gets the days Pressable +| actually returned upserted into site_traffic_daily (the client-report +| history) — never the zero-filled days of the Companion payload. | PressableClient is mocked directly as a collaborator (same reasoning as | PressableBackupsReportTest's docblock) since the three calls differ only | by argument, not by method name — each is matched here via a distinct @@ -37,11 +43,13 @@ function ptrSite(array $overrides = []): Site /** * Wires up the three distinct siteMetrics() call shapes the command makes, * each matched by its own ->with() so mismatched args fail loudly rather - * than silently returning the wrong fixture. + * than silently returning the wrong fixture. The top-paths call only feeds + * the Companion payload, so it happens once when the site gets the push and + * never when it doesn't. */ -function ptrMockPressable(Site $site, ?callable $configure = null): void +function ptrMockPressable(Site $site, ?callable $configure = null, bool $pushes = true): void { - test()->mock(PressableClient::class, function ($mock) use ($site, $configure) { + test()->mock(PressableClient::class, function ($mock) use ($site, $configure, $pushes) { $mock->shouldReceive('isConfigured')->andReturn(true); $today = Carbon::today(); @@ -63,6 +71,7 @@ function ptrMockPressable(Site $site, ?callable $configure = null): void $mock->shouldReceive('siteMetrics') ->with($site->pressable_site_id, ['requests'], ['path'], 'Past 1 day') + ->times($pushes ? 1 : 0) ->andReturn([ ['dimension' => ['/' => 100, '/wp-admin/' => 20, '/wp-content/uploads/img.jpg' => 5]], ]); @@ -84,6 +93,55 @@ function ptrFakeTraffic(Site $site, bool $ok = true, int $status = 200): void ]); } +/** + * Mocks the two "Past 1 month" series (requests by status, uniques by + * hostname) with the given periods; the top-paths call returns nothing. + * + * @param list> $requestPeriods + * @param list> $uniquePeriods + */ +function ptrMockSeries(Site $site, array $requestPeriods, array $uniquePeriods): void +{ + test()->mock(PressableClient::class, function ($mock) use ($site, $requestPeriods, $uniquePeriods) { + $mock->shouldReceive('isConfigured')->andReturn(true); + $mock->shouldReceive('siteMetrics')->with($site->pressable_site_id, ['requests'], ['http_status'], 'Past 1 month')->andReturn($requestPeriods); + $mock->shouldReceive('siteMetrics')->with($site->pressable_site_id, ['uniques'], ['hostname'], 'Past 1 month')->andReturn($uniquePeriods); + $mock->shouldReceive('siteMetrics')->with($site->pressable_site_id, ['requests'], ['path'], 'Past 1 day')->andReturn([]); + }); +} + +/** + * Seeds a site_traffic_daily row the way MySQL's DATE column stores it (a + * bare Y-m-d), so the command's (site_id, date) upsert key matches it on + * SQLite too. + * + * @param array $values + */ +function ptrSeedDay(Site $site, Carbon $day, array $values): void +{ + DB::table('site_traffic_daily')->insert(array_merge([ + 'site_id' => $site->id, + 'date' => $day->toDateString(), + 'requests' => 0, + 'unique_ips' => 0, + 'visits' => 0, + 'bytes_sent' => 0, + 'status_2xx' => 0, + 'status_3xx' => 0, + 'status_4xx' => 0, + 'status_5xx' => 0, + 'created_at' => now()->subMonth(), + 'updated_at' => now()->subMonth(), + ], $values)); +} + +function ptrRow(Site $site, Carbon $day): ?SiteTrafficDaily +{ + return SiteTrafficDaily::where('site_id', $site->id) + ->whereDate('date', $day->toDateString()) + ->first(); +} + describe('clockwork:pressable-traffic-report — happy path', function () { it('aggregates Edge Logs + Uniques&Views metrics into a 30-day rollup with correct totals and categorised top paths', function () { $site = ptrSite(); @@ -111,7 +169,21 @@ function ptrFakeTraffic(Site $site, bool $ok = true, int $status = 200): void && $body['top_paths_date'] === Carbon::today()->toDateString(); }); - expect(SiteTrafficDaily::where('site_id', $site->id)->count())->toBe(30); + // Only the two days Pressable returned are stored — not the 28 + // zero-filled ones the Companion chart needs. + expect(SiteTrafficDaily::where('site_id', $site->id)->count())->toBe(2); + + $today = ptrRow($site, Carbon::today()); + expect($today->requests)->toBe(35) + ->and($today->status_2xx)->toBe(30) + ->and($today->status_4xx)->toBe(5) + ->and($today->visits)->toBe(40) + ->and($today->unique_ips)->toBe(40) + ->and($today->bytes_sent)->toBe(0); + + $earlier = ptrRow($site, Carbon::today()->subDays(3)); + expect($earlier->requests)->toBe(10) + ->and($earlier->visits)->toBe(10); }); it('reports has_data=false and zeroed totals when Pressable returns no metrics at all', function () { @@ -132,21 +204,197 @@ function ptrFakeTraffic(Site $site, bool $ok = true, int $status = 200): void $body = json_decode($request->body(), true); return $body['has_data'] === false + && count($body['daily']) === 30 && $body['totals'] === ['today' => 0, 'week_7d' => 0, 'month_30d' => 0] && $body['top_paths'] === ['pages' => [], 'api' => [], 'uploads' => []]; }); + + expect(SiteTrafficDaily::where('site_id', $site->id)->count())->toBe(0); }); - it('skips a site that does not advertise the traffic-report capability', function () { + it('records history but does not push for a site that does not advertise the traffic-report capability', function () { $site = ptrSite(['companion_capabilities' => ['snapshot']]); + ptrMockPressable($site, pushes: false); + + Http::fake(); - $this->mock(PressableClient::class, fn ($mock) => $mock->shouldReceive('isConfigured')->andReturn(true)); + $this->artisan('clockwork:pressable-traffic-report') + ->expectsOutputToContain("capability 'traffic-report' not advertised") + ->assertSuccessful(); + + Http::assertNothingSent(); + expect(SiteTrafficDaily::where('site_id', $site->id)->count())->toBe(2); + }); + + it('records history for a Pressable site without Companion, without trying to push', function () { + $site = Site::factory()->pressable()->create(); + ptrMockPressable($site, pushes: false); Http::fake(); - $this->artisan('clockwork:pressable-traffic-report')->assertSuccessful(); + $this->artisan('clockwork:pressable-traffic-report') + ->expectsOutputToContain('push skipped: Companion not installed') + ->assertSuccessful(); Http::assertNothingSent(); + expect(ptrRow($site, Carbon::today())->requests)->toBe(35) + ->and(ptrRow($site, Carbon::today()->subDays(3))->visits)->toBe(10); + }); + + it('ignores sites that are not on Pressable or have no Pressable site id', function () { + Site::factory()->spinupwp()->withCompanionInstalled()->create(); + Site::factory()->pressable()->withCompanionInstalled()->create(['pressable_site_id' => null]); + + $this->mock(PressableClient::class, function ($mock) { + $mock->shouldReceive('isConfigured')->andReturn(true); + $mock->shouldReceive('siteMetrics')->never(); + }); + + Http::fake(); + + $this->artisan('clockwork:pressable-traffic-report') + ->expectsOutputToContain('No Pressable sites matched.') + ->assertSuccessful(); + + Http::assertNothingSent(); + expect(SiteTrafficDaily::count())->toBe(0); + }); +}); + +describe('clockwork:pressable-traffic-report — stored history', function () { + it('leaves days Pressable returned nothing for untouched, so earlier history is never overwritten with zeros', function () { + $site = ptrSite(); + $missing = Carbon::today()->subDays(5); + $returned = Carbon::today()->subDays(3); + + // Rows an earlier host's access-log rollup wrote. + ptrSeedDay($site, $missing, [ + 'requests' => 500, 'unique_ips' => 60, 'visits' => 50, 'bytes_sent' => 1234567, + 'status_2xx' => 480, 'status_4xx' => 20, 'top_paths' => json_encode([['path' => '/', 'hits' => 9]]), + ]); + ptrSeedDay($site, $returned, [ + 'requests' => 999, 'visits' => 99, 'bytes_sent' => 7654321, + 'top_paths' => json_encode([['path' => '/about/', 'hits' => 4]]), + ]); + + ptrMockPressable($site); + ptrFakeTraffic($site); + + $this->artisan('clockwork:pressable-traffic-report')->assertSuccessful(); + + $untouched = ptrRow($site, $missing); + expect($untouched->requests)->toBe(500) + ->and($untouched->visits)->toBe(50) + ->and($untouched->unique_ips)->toBe(60) + ->and($untouched->bytes_sent)->toBe(1234567) + ->and($untouched->status_2xx)->toBe(480) + ->and($untouched->status_4xx)->toBe(20) + ->and($untouched->top_paths)->toBe([['path' => '/', 'hits' => 9]]); + + // A returned day takes Pressable's counts but keeps what Pressable + // has no per-day figure for. + $updated = ptrRow($site, $returned); + expect($updated->requests)->toBe(10) + ->and($updated->visits)->toBe(10) + ->and($updated->bytes_sent)->toBe(7654321) + ->and($updated->top_paths)->toBe([['path' => '/about/', 'hits' => 4]]); + + // The Companion chart still gets the full zero-filled series. + Http::assertSent(fn ($request) => count(json_decode($request->body(), true)['daily']) === 30); + }); + + it('updates only the metric family Pressable returned for a day', function () { + $site = ptrSite(); + $requestsOnly = Carbon::today()->subDays(2); + $uniquesOnly = Carbon::today()->subDays(4); + + ptrSeedDay($site, $requestsOnly, ['requests' => 300, 'status_2xx' => 300, 'visits' => 77, 'unique_ips' => 88]); + ptrSeedDay($site, $uniquesOnly, ['requests' => 400, 'status_2xx' => 390, 'status_5xx' => 10, 'visits' => 66, 'unique_ips' => 70]); + + ptrMockSeries( + $site, + requestPeriods: [['timestamp' => $requestsOnly->copy()->setTime(8, 0)->timestamp, 'dimension' => ['200' => 12, '301' => 3]]], + uniquePeriods: [['timestamp' => $uniquesOnly->timestamp, 'dimension' => ['example.com' => 21]]], + ); + ptrFakeTraffic($site); + + $this->artisan('clockwork:pressable-traffic-report')->assertSuccessful(); + + $a = ptrRow($site, $requestsOnly); + expect($a->requests)->toBe(15) + ->and($a->status_2xx)->toBe(12) + ->and($a->status_3xx)->toBe(3) + ->and($a->visits)->toBe(77) + ->and($a->unique_ips)->toBe(88); + + $b = ptrRow($site, $uniquesOnly); + expect($b->requests)->toBe(400) + ->and($b->status_2xx)->toBe(390) + ->and($b->status_5xx)->toBe(10) + ->and($b->visits)->toBe(21) + ->and($b->unique_ips)->toBe(21); + }); + + it('treats empty buckets as no data', function () { + $site = ptrSite(); + $seeded = Carbon::today()->subDays(6); + $unseeded = Carbon::today()->subDays(7); + + ptrSeedDay($site, $seeded, ['requests' => 250, 'visits' => 25, 'unique_ips' => 30, 'status_2xx' => 250]); + + ptrMockSeries( + $site, + requestPeriods: [ + ['timestamp' => $seeded->copy()->setTime(8, 0)->timestamp, 'dimension' => []], + ['timestamp' => $unseeded->copy()->setTime(8, 0)->timestamp, 'dimension' => []], + ], + uniquePeriods: [ + ['timestamp' => $seeded->timestamp, 'dimension' => []], + ['timestamp' => $unseeded->timestamp], + ], + ); + ptrFakeTraffic($site); + + $this->artisan('clockwork:pressable-traffic-report')->assertSuccessful(); + + $row = ptrRow($site, $seeded); + expect($row->requests)->toBe(250) + ->and($row->visits)->toBe(25) + ->and($row->unique_ips)->toBe(30) + ->and(ptrRow($site, $unseeded))->toBeNull(); + }); + + it('stores only the last 28 days, while the Companion payload still carries all 30', function () { + $site = ptrSite(); + $oldest = Carbon::today()->subDays(29); + $edge = Carbon::today()->subDays(27); + + ptrMockSeries( + $site, + requestPeriods: [ + ['timestamp' => $oldest->copy()->setTime(16, 0)->timestamp, 'dimension' => ['200' => 7]], + ['timestamp' => $edge->copy()->setTime(16, 0)->timestamp, 'dimension' => ['200' => 9]], + ], + uniquePeriods: [ + ['timestamp' => $oldest->timestamp, 'dimension' => ['example.com' => 3]], + ['timestamp' => $edge->timestamp, 'dimension' => ['example.com' => 4]], + ], + ); + ptrFakeTraffic($site); + + $this->artisan('clockwork:pressable-traffic-report')->assertSuccessful(); + + expect(ptrRow($site, $oldest))->toBeNull() + ->and(ptrRow($site, $edge)->requests)->toBe(9) + ->and(SiteTrafficDaily::where('site_id', $site->id)->count())->toBe(1); + + Http::assertSent(function ($request) use ($oldest) { + $daily = collect(json_decode($request->body(), true)['daily'])->keyBy('date'); + + return $daily->count() === 30 + && $daily->get($oldest->toDateString())['requests'] === 7 + && $daily->get($oldest->toDateString())['visits'] === 3; + }); }); }); @@ -180,9 +428,10 @@ function ptrFakeTraffic(Site $site, bool $ok = true, int $status = 200): void Http::assertNothingSent(); Log::shouldHaveReceived('warning')->with('companion.pressable_traffic_report.fetch_failed', \Mockery::type('array')); + expect(SiteTrafficDaily::where('site_id', $site->id)->count())->toBe(0); }); - it('logs and continues (still SUCCESS overall) when the push to Companion itself fails', function () { + it('logs and continues (still SUCCESS overall) when the push to Companion itself fails, keeping the recorded history', function () { Log::spy(); $site = ptrSite(); ptrMockPressable($site); @@ -192,6 +441,7 @@ function ptrFakeTraffic(Site $site, bool $ok = true, int $status = 200): void $this->artisan('clockwork:pressable-traffic-report')->assertSuccessful(); Log::shouldHaveReceived('warning')->with('companion.pressable_traffic_report.push_failed', \Mockery::type('array')); + expect(SiteTrafficDaily::where('site_id', $site->id)->count())->toBe(2); }); }); @@ -215,3 +465,20 @@ function ptrFakeTraffic(Site $site, bool $ok = true, int $status = 200): void Http::assertNotSent(fn ($request) => str_contains($request->url(), $other->domain)); }); }); + +describe('clockwork:pressable-traffic-report — schedule', function () { + it('runs early enough to finish before the client-report run that reads its rows', function () { + $find = fn (string $command): Event => collect(app(Schedule::class)->events()) + ->first(fn (Event $event) => str_ends_with((string) $event->command, $command)); + + $from = Carbon::parse('2030-03-01 00:00:00', 'UTC'); + $traffic = Carbon::instance($find('clockwork:pressable-traffic-report')->nextRunDate($from)); + $reports = Carbon::instance($find('clockwork:send-client-reports')->nextRunDate($from)); + + // Same morning, at least an hour ahead of the reports (the run takes + // ~15-20 min), and late enough that the previous UTC day is complete. + expect($traffic->toDateString())->toBe($reports->toDateString()) + ->and($traffic->diffInMinutes($reports))->toBeGreaterThanOrEqual(60) + ->and($from->diffInMinutes($traffic))->toBeGreaterThanOrEqual(120); + }); +}); diff --git a/tests/Feature/Console/RefreshCompanionSnapshotTest.php b/tests/Feature/Console/RefreshCompanionSnapshotTest.php index f5a4c787..88cefd16 100644 --- a/tests/Feature/Console/RefreshCompanionSnapshotTest.php +++ b/tests/Feature/Console/RefreshCompanionSnapshotTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Console; +use App\Models\ActionLog; use App\Models\Server; use App\Models\Site; use Illuminate\Support\Facades\Http; @@ -98,6 +99,37 @@ function csSnapshotSite(array $overrides = []): Site Http::assertNotSent(fn ($request) => str_contains($request->url(), '/plugins')); Http::assertSent(fn ($request) => str_contains($request->url(), '/snapshot')); }); + + it('logs a plugin version that changed outside Control since the previous snapshot, without mirroring it to the site', function () { + $site = csSnapshotSite([ + 'companion_capabilities' => ['snapshot', 'action-log'], + 'companion_snapshot' => ['plugins' => ['plugins' => [ + ['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'version' => '2.9.0'], + ]]], + 'companion_snapshot_at' => now()->subDay(), + ]); + + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/snapshot*" => Http::response( + ['plugins' => ['plugins' => [ + ['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'version' => '2.9.1'], + ]]], + 200, + ['Content-Type' => 'application/json'], + ), + ]); + + $this->artisan('clockwork:refresh-companion-snapshot')->assertSuccessful(); + + $row = ActionLog::query()->where('site_id', $site->id)->where('action_type', ActionLog::TYPE_PLUGIN_UPDATE_OBSERVED)->sole(); + expect($row->details)->toMatchArray([ + 'slug' => 'example-forms/example-forms.php', + 'name' => 'Example Forms', + 'from_version' => '2.9.0', + 'to_version' => '2.9.1', + ]); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'action-log')); + }); }); describe('clockwork:refresh-companion-snapshot — failure handling', function () { diff --git a/tests/Feature/Console/RefreshPluginVulnerabilitiesTest.php b/tests/Feature/Console/RefreshPluginVulnerabilitiesTest.php index a8165fd3..e9ac3a6e 100644 --- a/tests/Feature/Console/RefreshPluginVulnerabilitiesTest.php +++ b/tests/Feature/Console/RefreshPluginVulnerabilitiesTest.php @@ -1,5 +1,6 @@ pluck('wordfence_id')->all())->toBe(['cf7-vuln']); }); + it('stores each feed operator as the right range, and an exact-version advisory as that version only', function () { + Site::factory()->create([ + 'companion_snapshot' => ['plugins' => ['plugins' => [['slug' => 'example-seo/example-seo.php']]]], + ]); + + $op = fn (?string $min, ?string $minOp, ?string $max, ?string $maxOp, string $unfixed = '0') => [ + 'min_version' => $min, 'min_operator' => $minOp, 'max_version' => $max, 'max_operator' => $maxOp, 'unfixed' => $unfixed, + ]; + + Http::fake([ + 'wpvulnerability.net/plugin/example-seo*' => Http::response(pluginVulnResponse([ + pluginVulnEntry(['uuid' => 'exact', 'name' => 'Example SEO [example-seo] == 3.0.123', 'operator' => $op(null, null, '3.0.123', 'eq')]), + pluginVulnEntry(['uuid' => 'exact-in-min', 'operator' => $op('2.5.0', 'eq', null, null)]), + pluginVulnEntry(['uuid' => 'below', 'operator' => $op(null, null, '3.0.97', 'lt')]), + pluginVulnEntry(['uuid' => 'up-to', 'operator' => $op(null, null, '2.0.6', 'lte', '1')]), + pluginVulnEntry(['uuid' => 'window', 'operator' => $op('1.0.0', 'gte', '1.4.0', 'lt')]), + pluginVulnEntry(['uuid' => 'after', 'operator' => $op('4.0.0', 'gt', null, null, '1')]), + pluginVulnEntry(['uuid' => 'numeric', 'operator' => ['max_version' => 2, 'max_operator' => 'lt', 'unfixed' => 0]]), + ])), + ]); + + $this->artisan('clockwork:refresh-plugin-vulnerabilities')->assertSuccessful(); + + $rows = DB::table('plugin_vulnerabilities')->get()->keyBy('wordfence_id'); + $range = fn (string $id) => [ + $rows[$id]->from_version, (bool) $rows[$id]->from_inclusive, + $rows[$id]->to_version, (bool) $rows[$id]->to_inclusive, + $rows[$id]->patched_in, + ]; + + expect($range('exact'))->toBe(['3.0.123', true, '3.0.123', true, null]) + ->and($range('exact-in-min'))->toBe(['2.5.0', true, '2.5.0', true, null]) + ->and($range('below'))->toBe([null, true, '3.0.97', false, '3.0.97']) + ->and($range('up-to'))->toBe([null, true, '2.0.6', true, null]) + ->and($range('window'))->toBe(['1.0.0', true, '1.4.0', false, '1.4.0']) + ->and($range('after'))->toBe(['4.0.0', false, null, true, null]) + ->and($range('numeric'))->toBe([null, true, '2', false, '2']); + + // The matcher sees the exact advisory only on that exact release. + $exact = PluginVulnerability::query()->where('wordfence_id', 'exact')->sole(); + expect($exact->rangeIncludes('3.0.121'))->toBeFalse() + ->and($exact->rangeIncludes('3.0.123'))->toBeTrue() + ->and($exact->rangeIncludes('3.0.124'))->toBeFalse(); + }); + it('de-duplicates unique plugin slugs across multiple sites into a single fetch each', function () { $snapshot = [ 'plugins' => [ diff --git a/tests/Feature/Console/RunCompanionMalwareScansTest.php b/tests/Feature/Console/RunCompanionMalwareScansTest.php index da39a3f9..305b8fc6 100644 --- a/tests/Feature/Console/RunCompanionMalwareScansTest.php +++ b/tests/Feature/Console/RunCompanionMalwareScansTest.php @@ -5,6 +5,8 @@ use App\Models\Server; use App\Models\Site; use App\Models\SiteSecurityScan; +use App\Services\Chat\ChatNotifier; +use App\Services\Security\CompanionMalwareScanner; use App\Services\Ssh\SshClient; use App\Support\Settings; use Illuminate\Support\Facades\Http; @@ -117,6 +119,94 @@ function rcmsFakeMalwareScan(Site $site, array $body, int $status = 200): void expect($scan->status)->toBe(SiteSecurityScan::STATUS_WARNING); }); + it('sets has_malware_hit on a positive signature finding and fires the malware alert once', function () { + $site = rcmsCompanionSite(); + rcmsFakeMalwareScan($site, CompanionFixtures::malwareScan([ + 'findings' => [ + ['kind' => 'eval_base64', 'path' => 'wp-content/themes/t/functions.php', 'evidence' => 'eval(base64_decode('], + ], + ])); + + $this->mock(ChatNotifier::class, function ($mock) { + $mock->shouldReceive('malwareFindingDetected')->once()->andReturn(true); + $mock->shouldNotReceive('phpInUploadsDetected'); + }); + + // The second run is still infected: no second alert. + $this->artisan('clockwork:run-companion-malware-scans')->assertSuccessful(); + $this->artisan('clockwork:run-companion-malware-scans')->assertSuccessful(); + + $scans = SiteSecurityScan::query()->where('site_id', $site->id)->get(); + expect($scans)->toHaveCount(2) + ->and($scans->every(fn ($s) => $s->has_malware_hit === true))->toBeTrue(); + }); + + it('keeps has_malware_hit false for review-only findings', function () { + $site = rcmsCompanionSite(); + SiteSecurityScan::factory()->create([ + 'site_id' => $site->id, + 'scan_type' => SiteSecurityScan::TYPE_COMPANION_MALWARE, + 'status' => SiteSecurityScan::STATUS_WARNING, + 'scanned_at' => now()->subDay(), + 'details' => ['findings' => [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/plugin-log.php', 'evidence' => 'x'], + ]], + ]); + rcmsFakeMalwareScan($site, CompanionFixtures::malwareScan([ + 'findings' => [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/plugin-log.php', 'evidence' => 'PHP file under wp-content/uploads'], + ], + ])); + + $this->mock(ChatNotifier::class, function ($mock) { + $mock->shouldNotReceive('malwareFindingDetected'); + $mock->shouldNotReceive('phpInUploadsDetected'); + }); + + $this->artisan('clockwork:run-companion-malware-scans')->assertSuccessful(); + + $scan = SiteSecurityScan::query()->where('site_id', $site->id)->latest('id')->first(); + expect($scan->status)->toBe(SiteSecurityScan::STATUS_WARNING) + ->and($scan->has_malware_hit)->toBeFalse(); + }); + + it('posts a notice once when a PHP file first appears in uploads, then stays quiet', function () { + $site = rcmsCompanionSite(); + // Earlier scan, stored before paths were normalized. + SiteSecurityScan::factory()->create([ + 'site_id' => $site->id, + 'scan_type' => SiteSecurityScan::TYPE_COMPANION_MALWARE, + 'status' => SiteSecurityScan::STATUS_WARNING, + 'scanned_at' => now()->subDay(), + 'details' => ['findings' => [ + ['kind' => 'php_in_uploads', 'path' => '/srv/htdocs/wp-content/uploads/plugin-log.php', 'evidence' => 'x'], + ]], + ]); + rcmsFakeMalwareScan($site, CompanionFixtures::malwareScan([ + 'findings' => [ + ['kind' => 'php_in_uploads', 'path' => '/srv/htdocs/wp-content/uploads/plugin-log.php', 'evidence' => 'PHP file under wp-content/uploads'], + ['kind' => 'php_in_uploads', 'path' => '/srv/htdocs/wp-content/uploads/2026/10/dropped.php', 'evidence' => 'PHP file under wp-content/uploads'], + ], + ])); + + $this->mock(ChatNotifier::class, function ($mock) use ($site) { + $mock->shouldNotReceive('malwareFindingDetected'); + $mock->shouldReceive('phpInUploadsDetected') + ->once() + ->withArgs(fn ($s, $scan, array $paths) => $s->is($site) && $paths === ['wp-content/uploads/2026/10/dropped.php']) + ->andReturn(true); + }); + + $this->artisan('clockwork:run-companion-malware-scans')->assertSuccessful(); + $this->artisan('clockwork:run-companion-malware-scans')->assertSuccessful(); + + $latest = SiteSecurityScan::query()->where('site_id', $site->id)->latest('id')->first(); + expect(collect($latest->details['findings'])->pluck('path')->all())->toBe([ + 'wp-content/uploads/plugin-log.php', + 'wp-content/uploads/2026/10/dropped.php', + ]); + }); + it('records a failed scan (and exits FAILURE overall) when the Companion endpoint errors', function () { $site = rcmsCompanionSite(); rcmsFakeMalwareScan($site, ['code' => 'internal_error', 'message' => 'scan crashed'], 500); @@ -138,7 +228,10 @@ function rcmsFakeMalwareScan(Site $site, array $body, int $status = 200): void ]); $this->mock(SshClient::class, function ($mock) { - $mock->shouldReceive('exec')->andReturn(''); + // Every check finishes and finds nothing. + $mock->shouldReceive('execChecked')->andReturnUsing(fn ($server, string $cmd) => str_starts_with($cmd, 'test -d ') + ? CompanionMalwareScanner::SSH_DOCROOT_OK + : CompanionMalwareScanner::SSH_DONE_SENTINEL); }); Http::fake(); // guard: SSH path must never touch HTTP either. diff --git a/tests/Feature/Console/RunNightlyPluginUpdatesTest.php b/tests/Feature/Console/RunNightlyPluginUpdatesTest.php index d13f0695..3c04ea78 100644 --- a/tests/Feature/Console/RunNightlyPluginUpdatesTest.php +++ b/tests/Feature/Console/RunNightlyPluginUpdatesTest.php @@ -2,12 +2,21 @@ namespace Tests\Feature\Console; +use App\Console\Commands\RunNightlyPluginUpdates; use App\Jobs\RunPluginUpdate; use App\Jobs\RunThemeUpdate; +use App\Models\ActionLog; +use App\Models\PluginUpdateFailureStreak; use App\Models\PluginUpdateIgnore; use App\Models\PluginUpdateJob; use App\Models\PluginVulnerability; use App\Models\Site; +use App\Services\ActionLog\ActionLogger; +use App\Services\Chat\ChatNotifier; +use App\Services\Updates\ThemeIdentityGuard; +use App\Support\UpgraderOutput; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; /* @@ -53,6 +62,26 @@ function themeSnapshot(array $themes): array ]; } +// Theme tuples are checked against the wordpress.org theme directory +// (ThemeIdentityGuard). A test lists a theme there by adding its slug to +// $this->themeDirectory; any other slug is not listed, as for a premium +// theme with its own updater. $this->themeDirectoryDown makes it fail. +beforeEach(function () { + $this->themeDirectory = []; + $this->themeDirectoryDown = false; + Http::fake(['api.wordpress.org/themes/*' => function ($request) { + if ($this->themeDirectoryDown) { + return Http::response('', 503); + } + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + $listing = $this->themeDirectory[$query['request']['slug'] ?? ''] ?? null; + + return $listing === null + ? Http::response(['error' => 'Theme not found'], 404) + : Http::response($listing); + }]); +}); + describe('happy path: enumerating and dispatching', function () { it('queues a PluginUpdateJob and dispatches RunPluginUpdate for a site with a pending plugin update', function () { Queue::fake(); @@ -189,31 +218,533 @@ function themeSnapshot(array $themes): array }); describe('CVE-priority ordering', function () { - it('assigns the same nightly batch_id to every tuple queued in one run, with the vulnerable plugin present', function () { + it('dispatches the vulnerable plugin first, matching the snapshot\'s dir/file.php slug to the advisory\'s directory slug', function () { + Queue::fake(); + + // Advisories key on the plugin directory; Companion snapshots use + // "directory/file.php". The vulnerable plugin sits on the + // alphabetically later site and after a safe plugin, so only the + // CVE priority can put it first. + PluginVulnerability::factory()->create([ + 'slug' => 'vuln-plugin', + 'from_version' => '0', + 'to_version' => '1.0', + 'to_inclusive' => true, + 'patched_in' => '1.1', + ]); + + $first = nightlySite([ + 'domain' => 'aaa.example.com', + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'safe-plugin/safe-plugin.php', 'name' => 'Safe Plugin', 'version' => '1.0', 'new_version' => '1.1', 'update_available' => true], + ]), + ]); + $second = nightlySite([ + 'domain' => 'zzz.example.com', + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'other-plugin/other-plugin.php', 'name' => 'Other Plugin', 'version' => '1.0', 'new_version' => '1.1', 'update_available' => true], + ['slug' => 'vuln-plugin/vuln-plugin.php', 'name' => 'Vuln Plugin', 'version' => '1.0', 'new_version' => '1.1', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates') + ->expectsOutputToContain('(1 vulnerable-first)') + ->assertSuccessful(); + + $dispatched = Queue::pushed(RunPluginUpdate::class) + ->map(fn ($job) => PluginUpdateJob::find($job->jobRowId)) + ->values(); + + expect($dispatched->map(fn ($row) => [$row->site_id, $row->target_slug])->all())->toBe([ + [$second->id, 'vuln-plugin/vuln-plugin.php'], + [$first->id, 'safe-plugin/safe-plugin.php'], + [$second->id, 'other-plugin/other-plugin.php'], + ]); + + // One batch for the whole night. + expect($dispatched->pluck('batch_id')->unique())->toHaveCount(1); + }); + + it('marks the vulnerable plugin in the dry-run listing', function () { Queue::fake(); PluginVulnerability::factory()->create([ 'slug' => 'vuln-plugin', 'from_version' => '0', - 'to_version' => '2.0.0', + 'to_version' => '1.0', + 'to_inclusive' => true, + 'patched_in' => '1.1', + ]); + + nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'vuln-plugin/vuln-plugin.php', 'name' => 'Vuln Plugin', 'version' => '1.0', 'new_version' => '1.1', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates', ['--dry-run' => true]) + ->expectsOutputToContain('!CVE') + ->assertSuccessful(); + + Queue::assertNothingPushed(); + }); +}); + +/** + * Seed an auto-failure pause the way UpdateFailureStreakRecorder leaves it: + * an auto_failure ignore row plus a streak recording the version that kept + * failing. Pass null to leave the streak out. + */ +function pauseAfterFailures( + Site $site, + string $slug, + ?string $failedVersion, + string $kind = PluginUpdateJob::KIND_PLUGIN, + ?Carbon $lastFailedAt = null, + ?string $reasonCode = null, +): PluginUpdateIgnore { + $ignore = PluginUpdateIgnore::create([ + 'site_id' => $site->id, + 'target_kind' => $kind, + 'target_slug' => $slug, + 'source' => PluginUpdateIgnore::SOURCE_AUTO_FAILURE, + 'failure_count' => 5, + 'client_visible' => true, + 'note' => 'Automatically paused after 5 consecutive nightly update failures.', + ]); + + if ($failedVersion !== null) { + PluginUpdateFailureStreak::create([ + 'site_id' => $site->id, + 'target_kind' => $kind, + 'target_slug' => $slug, + 'consecutive_failures' => 5, + 'last_from_version' => '1.0.0', + 'last_target_version' => $failedVersion, + 'last_failed_at' => $lastFailedAt ?? now()->subDay(), + 'reason_code' => $reasonCode, + 'ignored_at' => now()->subDay(), + 'ignore_id' => $ignore->id, + ]); + } + + return $ignore; +} + +function vulnerableAdvisory(string $dirSlug, string $affectedUpTo, string $patchedIn): PluginVulnerability +{ + return PluginVulnerability::factory()->create([ + 'slug' => $dirSlug, + 'from_version' => '0', + 'from_inclusive' => true, + 'to_version' => $affectedUpTo, + 'to_inclusive' => true, + 'patched_in' => $patchedIn, + ]); +} + +describe('auto-failure pauses', function () { + it('keeps an auto-paused plugin paused while the version that kept failing is still the one offered', function () { + Queue::fake(); + + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'stuck-plugin/stuck-plugin.php', 'name' => 'Stuck Plugin', 'version' => '1.0.0', 'new_version' => '1.1.0', 'update_available' => true], + ]), ]); + pauseAfterFailures($site, 'stuck-plugin/stuck-plugin.php', '1.1.0'); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(0); + Queue::assertNothingPushed(); + }); + + it('retries an auto-paused plugin once a newer release than the failing version is offered, leaving the pause in place', function () { + Queue::fake(); $site = nightlySite([ 'wp_plugin_updates' => true, 'companion_snapshot' => pluginSnapshot([ - ['slug' => 'safe-plugin', 'name' => 'Safe Plugin', 'version' => '1.0', 'new_version' => '1.1', 'update_available' => true], - ['slug' => 'vuln-plugin', 'name' => 'Vuln Plugin', 'version' => '1.0', 'new_version' => '1.1', 'update_available' => true], + ['slug' => 'stuck-plugin/stuck-plugin.php', 'name' => 'Stuck Plugin', 'version' => '1.0.0', 'new_version' => '1.2.0', 'update_available' => true], + ]), + ]); + $ignore = pauseAfterFailures($site, 'stuck-plugin/stuck-plugin.php', '1.1.0'); + + $this->artisan('clockwork:run-nightly-plugin-updates') + ->expectsOutputToContain('Retrying 1 auto-paused update (1 new release, 0 security fix)') + ->assertSuccessful(); + + $row = PluginUpdateJob::query()->where('site_id', $site->id)->sole(); + expect($row->target_slug)->toBe('stuck-plugin/stuck-plugin.php') + ->and($row->target_version)->toBe('1.2.0') + ->and($row->batch_id)->toStartWith('nightly-'); + Queue::assertPushed(RunPluginUpdate::class, fn ($job) => $job->jobRowId === $row->id); + + // Only a successful update lifts the pause. + expect(PluginUpdateIgnore::query()->whereKey($ignore->id)->exists())->toBeTrue(); + }); + + it('retries an auto-paused theme once a newer release is offered', function () { + Queue::fake(); + + $site = nightlySite([ + 'wp_theme_updates' => true, + 'companion_snapshot' => themeSnapshot([ + ['slug' => 'stuck-theme', 'name' => 'Stuck Theme', 'version' => '2.0', 'new_version' => '2.2', 'update_available' => true], ]), ]); + pauseAfterFailures($site, 'stuck-theme', '2.1', PluginUpdateJob::KIND_THEME); $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); - $rows = PluginUpdateJob::query()->where('site_id', $site->id)->get(); - expect($rows)->toHaveCount(2); - $batchIds = $rows->pluck('batch_id')->unique(); - expect($batchIds)->toHaveCount(1); + $row = PluginUpdateJob::query()->where('site_id', $site->id)->sole(); + expect($row->target_kind)->toBe(PluginUpdateJob::KIND_THEME) + ->and($row->target_version)->toBe('2.2'); + Queue::assertPushed(RunThemeUpdate::class, fn ($job) => $job->jobRowId === $row->id); + }); + + it('retries an auto-paused vulnerable plugin when the offered version contains the fix, even if that version failed before, once the back-off has passed', function () { + Queue::fake(); + + vulnerableAdvisory('vuln-plugin', '1.0.0', '1.1.0'); + + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'vuln-plugin/vuln-plugin.php', 'name' => 'Vuln Plugin', 'version' => '1.0.0', 'new_version' => '1.1.0', 'update_available' => true], + ]), + ]); + pauseAfterFailures($site, 'vuln-plugin/vuln-plugin.php', '1.1.0', lastFailedAt: now()->subDays(RunNightlyPluginUpdates::SECURITY_RETRY_BACKOFF_DAYS + 1)); + + $this->artisan('clockwork:run-nightly-plugin-updates') + ->expectsOutputToContain('Retrying 1 auto-paused update (0 new release, 1 security fix)') + ->assertSuccessful(); + + $row = PluginUpdateJob::query()->where('site_id', $site->id)->sole(); + expect($row->target_version)->toBe('1.1.0'); + Queue::assertPushed(RunPluginUpdate::class, 1); + }); + + it('keeps a security fix paused when that same version already failed within the back-off', function () { + Queue::fake(); + + vulnerableAdvisory('vuln-plugin', '1.0.0', '1.1.0'); + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'vuln-plugin/vuln-plugin.php', 'name' => 'Vuln Plugin', 'version' => '1.0.0', 'new_version' => '1.1.0', 'update_available' => true], + ]), + ]); + pauseAfterFailures($site, 'vuln-plugin/vuln-plugin.php', '1.1.0', lastFailedAt: now()->subDay()); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(0); + Queue::assertNothingPushed(); + }); + + it('never retries the same security fix version that failed for a licence or offer reason, however long ago', function (string $reason) { + Queue::fake(); + + vulnerableAdvisory('vuln-plugin', '1.0.0', '1.1.0'); + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'vuln-plugin/vuln-plugin.php', 'name' => 'Vuln Plugin', 'version' => '1.0.0', 'new_version' => '1.1.0', 'update_available' => true], + ]), + ]); + pauseAfterFailures($site, 'vuln-plugin/vuln-plugin.php', '1.1.0', lastFailedAt: now()->subDays(30), reasonCode: $reason); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + Queue::assertNothingPushed(); + })->with([UpgraderOutput::REASON_DOWNLOAD_UNAUTHORIZED, UpgraderOutput::REASON_NO_PACKAGE, UpgraderOutput::REASON_NOT_OFFERED]); + + it('tries a newer security fix version at once, whatever the last failure was', function () { + Queue::fake(); + + vulnerableAdvisory('vuln-plugin', '1.0.0', '1.1.0'); + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'vuln-plugin/vuln-plugin.php', 'name' => 'Vuln Plugin', 'version' => '1.0.0', 'new_version' => '1.2.0', 'update_available' => true], + ]), + ]); + pauseAfterFailures($site, 'vuln-plugin/vuln-plugin.php', '1.1.0', lastFailedAt: now()->subDay(), reasonCode: UpgraderOutput::REASON_DOWNLOAD_UNAUTHORIZED); + + $this->artisan('clockwork:run-nightly-plugin-updates') + ->expectsOutputToContain('Retrying 1 auto-paused update (0 new release, 1 security fix)') + ->assertSuccessful(); + + expect(PluginUpdateJob::query()->where('site_id', $site->id)->sole()->target_version)->toBe('1.2.0'); + }); + + it('tries a security fix only once a week when every retry fails, not every night', function () { + Queue::fake(); + + vulnerableAdvisory('vuln-plugin', '1.0.0', '1.1.0'); + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'vuln-plugin/vuln-plugin.php', 'name' => 'Vuln Plugin', 'version' => '1.0.0', 'new_version' => '1.1.0', 'update_available' => true], + ]), + ]); + pauseAfterFailures($site, 'vuln-plugin/vuln-plugin.php', '1.0.5', lastFailedAt: now()->subDay()); + + $queuedOn = []; + for ($night = 0; $night < 9; $night++) { + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + if (PluginUpdateJob::query()->where('site_id', $site->id)->where('status', PluginUpdateJob::STATUS_PENDING)->exists()) { + $queuedOn[] = $night; + } + failAllNightlyJobs(); + $this->travel(1)->days(); + } + + // Night 0 is the first try of 1.1.0; the next is a week later. + expect($queuedOn)->toBe([0, RunNightlyPluginUpdates::SECURITY_RETRY_BACKOFF_DAYS]); + }); + + it('keeps a vulnerable plugin paused when the offered version is below the highest patched_in', function () { + Queue::fake(); + + // Two advisories hit 1.0.0; the offered 1.5.0 fixes only the first. + vulnerableAdvisory('vuln-plugin', '1.4.0', '1.5.0'); + vulnerableAdvisory('vuln-plugin', '1.9.0', '2.0.0'); + + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'vuln-plugin/vuln-plugin.php', 'name' => 'Vuln Plugin', 'version' => '1.0.0', 'new_version' => '1.5.0', 'update_available' => true], + ]), + ]); + pauseAfterFailures($site, 'vuln-plugin/vuln-plugin.php', '1.5.0'); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(0); + Queue::assertNothingPushed(); + }); + + it('keeps an auto-pause that has no recorded failing version', function () { + Queue::fake(); + + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'stuck-plugin/stuck-plugin.php', 'name' => 'Stuck Plugin', 'version' => '1.0.0', 'new_version' => '1.2.0', 'update_available' => true], + ]), + ]); + pauseAfterFailures($site, 'stuck-plugin/stuck-plugin.php', null); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + Queue::assertNothingPushed(); + }); + + it('records a queued new-release retry as that version\'s attempt', function () { + Queue::fake(); + + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'stuck-plugin/stuck-plugin.php', 'name' => 'Stuck Plugin', 'version' => '1.0.0', 'new_version' => '1.2.0', 'update_available' => true], + ]), + ]); + pauseAfterFailures($site, 'stuck-plugin/stuck-plugin.php', '1.1.0'); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + $streak = PluginUpdateFailureStreak::query()->where('site_id', $site->id)->sole(); + expect($streak->last_target_version)->toBe('1.2.0') + ->and($streak->last_from_version)->toBe('1.0.0') + ->and($streak->last_retry_queued_at)->not->toBeNull() + // Queueing isn't a failure. + ->and($streak->consecutive_failures)->toBe(5); + }); + + it('tries a new release once, even when the retry ends without a counted failure', function (string $ending) { + Queue::fake(); + $this->mock(ChatNotifier::class)->shouldIgnoreMissing(); + + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_secret' => 'example-secret-for-tests', + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'stuck-plugin/stuck-plugin.php', 'name' => 'Stuck Plugin', 'version' => '1.0.0', 'new_version' => '2.1.0', 'update_available' => true], + ]), + ]); + pauseAfterFailures($site, 'stuck-plugin/stuck-plugin.php', '2.0.0'); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + $row = PluginUpdateJob::query()->where('site_id', $site->id)->sole(); + + match ($ending) { + // Companion answered 504: a transport error, never counted. + 'transport' => (function () use ($site, $row) { + Http::fake(["https://{$site->domain}/wp-json/clockwork/v1/plugins/update*" => Http::response(['message' => 'gateway timeout'], 504)]); + (new RunPluginUpdate($row->id))->handle(app(ActionLogger::class)); + })(), + // The worker timed out: only failed() runs. + 'timeout' => (new RunPluginUpdate($row->id))->failed(new \RuntimeException('timed out')), + // Never picked up: the reaper marks it failed. + 'reaped' => (function () use ($row) { + $row->forceFill(['queued_at' => now()->subHours(2)])->save(); + $this->artisan('clockwork:reap-stale-update-jobs')->assertSuccessful(); + })(), + }; + + expect($row->fresh()->status)->toBe(PluginUpdateJob::STATUS_FAILED); + + $this->travel(1)->days(); + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(1); + })->with(['transport', 'timeout', 'reaped']); + + it('never retries a manually ignored plugin, even for a new release that fixes a vulnerability', function () { + Queue::fake(); + + vulnerableAdvisory('held-plugin', '1.0.0', '1.1.0'); + + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'held-plugin/held-plugin.php', 'name' => 'Held Plugin', 'version' => '1.0.0', 'new_version' => '1.2.0', 'update_available' => true], + ]), + ]); + PluginUpdateIgnore::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'held-plugin/held-plugin.php', + 'source' => PluginUpdateIgnore::SOURCE_MANUAL, + 'client_visible' => false, + 'note' => 'Hold for now', + ]); + PluginUpdateFailureStreak::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'held-plugin/held-plugin.php', + 'consecutive_failures' => 5, + 'last_target_version' => '1.1.0', + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(0); + Queue::assertNothingPushed(); + }); +}); + +/** + * Mark every queued job failed so nothing is live: a second run would then + * re-queue everything if it were allowed to. + */ +function failAllNightlyJobs(): void +{ + PluginUpdateJob::query()->update(['status' => PluginUpdateJob::STATUS_FAILED]); +} + +function pendingUpdateSite(): Site +{ + return nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'some-plugin/some-plugin.php', 'name' => 'Some Plugin', 'version' => '1.0', 'new_version' => '1.1', 'update_available' => true], + ]), + ]); +} + +describe('once per local night', function () { + it('runs only once on the DST fall-back night, when 01:30 local happens twice', function () { + Queue::fake(); + $site = pendingUpdateSite(); + + // 01:45 EDT, before the clocks go back. + $this->travelTo(Carbon::parse('2026-11-01 05:45:00', 'UTC')); + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + $firstBatch = PluginUpdateJob::query()->where('site_id', $site->id)->sole()->batch_id; + expect($firstBatch)->toStartWith('nightly-2026-11-01-'); + failAllNightlyJobs(); + + // 01:45 EST, an hour later: the scheduler fires the chain again. + $this->travelTo(Carbon::parse('2026-11-01 06:45:00', 'UTC')); + $this->artisan('clockwork:run-nightly-plugin-updates') + ->expectsOutputToContain("Nightly batch {$firstBatch} already ran for 2026-11-01") + ->assertSuccessful(); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(1); + + // The next night runs normally. + $this->travelTo(Carbon::parse('2026-11-02 06:45:00', 'UTC')); + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(2) + ->and(PluginUpdateJob::query()->latest('id')->first()->batch_id)->toStartWith('nightly-2026-11-02-'); Queue::assertPushed(RunPluginUpdate::class, 2); }); + + it('decides "today" by the local date, not the UTC date', function () { + Queue::fake(); + $site = pendingUpdateSite(); + + // 21:30 EST on Nov 2 is already Nov 3 in UTC. + $this->travelTo(Carbon::parse('2026-11-03 02:30:00', 'UTC')); + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->sole()->batch_id)->toStartWith('nightly-2026-11-02-'); + failAllNightlyJobs(); + + // 01:40 EST on Nov 3 is a new local night, so it runs. + $this->travelTo(Carbon::parse('2026-11-03 06:40:00', 'UTC')); + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(2) + ->and(PluginUpdateJob::query()->latest('id')->first()->batch_id)->toStartWith('nightly-2026-11-03-'); + }); + + it('re-runs with --force, and only reports the skip under --dry-run', function () { + Queue::fake(); + $site = pendingUpdateSite(); + + $this->travelTo(Carbon::parse('2026-11-04 06:40:00', 'UTC')); + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + failAllNightlyJobs(); + + $this->travelTo(Carbon::parse('2026-11-04 15:00:00', 'UTC')); + $this->artisan('clockwork:run-nightly-plugin-updates', ['--dry-run' => true]) + ->expectsOutputToContain('a real run would be skipped') + ->assertSuccessful(); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(1); + + $this->artisan('clockwork:run-nightly-plugin-updates', ['--force' => true])->assertSuccessful(); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(2); + }); + + it('does not guard a --site run, and a --site run does not block the fleet-wide nightly', function () { + Queue::fake(); + $site = pendingUpdateSite(); + + // Operator runs one site just after local midnight. + $this->travelTo(Carbon::parse('2026-11-04 05:10:00', 'UTC')); + $this->artisan('clockwork:run-nightly-plugin-updates', ['--site' => (string) $site->id])->assertSuccessful(); + $siteBatch = PluginUpdateJob::query()->where('site_id', $site->id)->sole()->batch_id; + expect($siteBatch)->toStartWith('nightly-2026-11-04-site-'); + failAllNightlyJobs(); + + // The scheduled fleet run that night still goes ahead. + $this->travelTo(Carbon::parse('2026-11-04 06:40:00', 'UTC')); + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + $fleetBatch = PluginUpdateJob::query()->latest('id')->first()->batch_id; + expect($fleetBatch)->toStartWith('nightly-2026-11-04-') + ->and($fleetBatch)->not->toContain('-site-'); + failAllNightlyJobs(); + + // A later --site run is still allowed. + $this->artisan('clockwork:run-nightly-plugin-updates', ['--site' => (string) $site->id])->assertSuccessful(); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(3); + }); }); describe('--dry-run', function () { @@ -266,3 +797,213 @@ function themeSnapshot(array $themes): array ->and(PluginUpdateJob::query()->where('site_id', $other->id)->count())->toBe(0); }); }); + +describe('theme slug collisions', function () { + it('holds back a custom child theme whose folder name matches an unrelated wordpress.org theme', function () { + Queue::fake(); + $this->themeDirectory['studio'] = ['name' => 'Studio', 'slug' => 'studio', 'version' => '2.3.0']; + $this->mock(ChatNotifier::class, fn ($mock) => $mock->shouldReceive('send') + ->once() + ->withArgs(fn (string $title) => str_contains($title, 'Held back a theme update') && str_contains($title, 'Studio Child Theme'))); + + $site = nightlySite([ + 'wp_theme_updates' => true, + 'companion_snapshot' => themeSnapshot([ + ['slug' => 'studio', 'name' => 'Studio Child Theme', 'version' => '1.0.0', 'new_version' => '2.3.0', 'update_available' => true, 'active' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates') + ->expectsOutputToContain('Holding back 1 theme update') + ->expectsOutputToContain('held_back=1') + ->assertSuccessful(); + + Queue::assertNotPushed(RunThemeUpdate::class); + + // A skipped row with the reason, in tonight's batch for the summary mail. + $job = PluginUpdateJob::query()->where('site_id', $site->id)->sole(); + expect($job->status)->toBe(PluginUpdateJob::STATUS_SKIPPED) + ->and($job->reason_code)->toBe(ThemeIdentityGuard::REASON_CODE) + ->and($job->batch_id)->toStartWith('nightly-') + ->and($job->error)->toContain('"Studio Child Theme"') + ->and($job->error)->toContain('"Studio"'); + + // Held from now on, privately, and not as a failure. + $ignore = PluginUpdateIgnore::query()->where('site_id', $site->id)->sole(); + expect($ignore->source)->toBe(PluginUpdateIgnore::SOURCE_IDENTITY_MISMATCH) + ->and($ignore->target_kind)->toBe(PluginUpdateJob::KIND_THEME) + ->and($ignore->target_slug)->toBe('studio') + ->and($ignore->client_visible)->toBeFalse() + ->and(mb_strlen((string) $ignore->note))->toBeLessThanOrEqual(255); + expect(PluginUpdateFailureStreak::query()->count())->toBe(0); + + $log = ActionLog::query()->where('site_id', $site->id)->where('action_type', ActionLog::TYPE_THEME_UPDATE_REFUSED)->sole(); + expect($log->target)->toBe('studio') + ->and($log->details['reason_code'])->toBe(ThemeIdentityGuard::REASON_CODE) + ->and($log->companion_pushed_at)->toBeNull(); + }); + + it('tells ops once: the next night the held theme is skipped quietly', function () { + Queue::fake(); + $this->themeDirectory['studio'] = ['name' => 'Studio', 'slug' => 'studio', 'version' => '2.3.0']; + $this->mock(ChatNotifier::class, fn ($mock) => $mock->shouldReceive('send')->once()); + + $site = nightlySite([ + 'wp_theme_updates' => true, + 'companion_snapshot' => themeSnapshot([ + ['slug' => 'studio', 'name' => 'Studio Child Theme', 'version' => '1.0.0', 'new_version' => '2.3.0', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + $this->travel(1)->days(); + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + Queue::assertNothingPushed(); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->count())->toBe(1) + ->and(ActionLog::query()->where('action_type', ActionLog::TYPE_THEME_UPDATE_REFUSED)->count())->toBe(1); + }); + + it('holds back a custom theme whose name differs from the wordpress.org theme in the same folder', function () { + Queue::fake(); + $this->themeDirectory['example-custom'] = ['name' => 'Example Magazine', 'slug' => 'example-custom', 'version' => '3.0.0']; + $this->mock(ChatNotifier::class, fn ($mock) => $mock->shouldReceive('send')->once()); + + $site = nightlySite([ + 'wp_theme_updates' => true, + 'companion_snapshot' => themeSnapshot([ + ['slug' => 'example-custom', 'name' => 'Example Client Site', 'version' => '1.0', 'new_version' => '3.0.0', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + Queue::assertNotPushed(RunThemeUpdate::class); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->sole()->status)->toBe(PluginUpdateJob::STATUS_SKIPPED); + }); + + it('queues a wordpress.org theme whose installed name matches the directory listing', function () { + Queue::fake(); + $this->themeDirectory['twentytwentyfour'] = ['name' => 'Twenty Twenty-Four', 'slug' => 'twentytwentyfour', 'version' => '1.1']; + $this->mock(ChatNotifier::class, fn ($mock) => $mock->shouldNotReceive('send')); + + $site = nightlySite([ + 'wp_theme_updates' => true, + 'companion_snapshot' => themeSnapshot([ + ['slug' => 'twentytwentyfour', 'name' => 'Twenty Twenty-Four', 'version' => '1.0', 'new_version' => '1.1', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates')->expectsOutputToContain('held_back=0')->assertSuccessful(); + + $row = PluginUpdateJob::query()->where('site_id', $site->id)->sole(); + expect($row->status)->toBe(PluginUpdateJob::STATUS_PENDING); + Queue::assertPushed(RunThemeUpdate::class, fn ($job) => $job->jobRowId === $row->id); + expect(PluginUpdateIgnore::query()->count())->toBe(0); + }); + + it('queues a premium theme that is not on wordpress.org even when its name differs from its folder', function () { + Queue::fake(); + + $site = nightlySite([ + 'wp_theme_updates' => true, + 'companion_snapshot' => themeSnapshot([ + ['slug' => 'example-builder-theme', 'name' => 'Example Page Builder', 'version' => '1.7.19', 'new_version' => '1.7.20', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + Queue::assertPushed(RunThemeUpdate::class); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->sole()->status)->toBe(PluginUpdateJob::STATUS_PENDING); + }); + + it('holds back a child theme offered an update by something other than wordpress.org', function () { + Queue::fake(); + $this->mock(ChatNotifier::class, fn ($mock) => $mock->shouldReceive('send')->once()); + + $site = nightlySite([ + 'wp_theme_updates' => true, + 'companion_snapshot' => themeSnapshot([ + ['slug' => 'example-child', 'name' => 'Example Child', 'version' => '1.0', 'new_version' => '2.0', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + Queue::assertNotPushed(RunThemeUpdate::class); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->sole()->error)->toContain('child theme'); + }); + + it('stays quiet in chat for an inactive site but still holds the theme back', function () { + Queue::fake(); + $this->mock(ChatNotifier::class, fn ($mock) => $mock->shouldNotReceive('send')); + + $site = nightlySite([ + 'is_inactive' => true, + 'wp_theme_updates' => true, + 'companion_snapshot' => themeSnapshot([ + ['slug' => 'example-child', 'name' => 'Example Child', 'version' => '1.0', 'new_version' => '2.0', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + Queue::assertNotPushed(RunThemeUpdate::class); + expect(PluginUpdateIgnore::query()->where('site_id', $site->id)->count())->toBe(1); + }); + + it('queues the theme when the directory cannot be reached and nothing marks it as a child theme', function () { + Queue::fake(); + $this->themeDirectoryDown = true; + + $site = nightlySite([ + 'wp_theme_updates' => true, + 'companion_snapshot' => themeSnapshot([ + ['slug' => 'example-theme', 'name' => 'Example Theme', 'version' => '1.0', 'new_version' => '1.1', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + Queue::assertPushed(RunThemeUpdate::class); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->sole()->status)->toBe(PluginUpdateJob::STATUS_PENDING); + }); + + it('lists held-back themes in a dry run without writing anything', function () { + Queue::fake(); + $this->mock(ChatNotifier::class, fn ($mock) => $mock->shouldNotReceive('send')); + + nightlySite([ + 'wp_theme_updates' => true, + 'companion_snapshot' => themeSnapshot([ + ['slug' => 'example-child', 'name' => 'Example Child', 'version' => '1.0', 'new_version' => '2.0', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates', ['--dry-run' => true]) + ->expectsOutputToContain('HOLD') + ->assertSuccessful(); + + Queue::assertNothingPushed(); + expect(PluginUpdateJob::query()->count())->toBe(0) + ->and(PluginUpdateIgnore::query()->count())->toBe(0) + ->and(ActionLog::query()->where('action_type', ActionLog::TYPE_THEME_UPDATE_REFUSED)->count())->toBe(0); + }); + + it('never holds back plugin updates', function () { + Queue::fake(); + + $site = nightlySite([ + 'wp_plugin_updates' => true, + 'companion_snapshot' => pluginSnapshot([ + ['slug' => 'example-child/example-child.php', 'name' => 'Example Child Plugin', 'version' => '1.0', 'new_version' => '2.0', 'update_available' => true], + ]), + ]); + + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + + Queue::assertPushed(RunPluginUpdate::class); + expect(PluginUpdateJob::query()->where('site_id', $site->id)->sole()->status)->toBe(PluginUpdateJob::STATUS_PENDING); + }); +}); diff --git a/tests/Feature/Console/RunPerformanceScansTest.php b/tests/Feature/Console/RunPerformanceScansTest.php index c513dc40..db7d3653 100644 --- a/tests/Feature/Console/RunPerformanceScansTest.php +++ b/tests/Feature/Console/RunPerformanceScansTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Console; +use App\Console\Commands\RunPerformanceScans; use App\Models\Site; use App\Models\SitePerformanceScan; use App\Services\Performance\PerformanceScanResult; @@ -197,29 +198,79 @@ function rpsFakePsiOk(): void }); }); +/** + * A fresh domain whose rotation weekday (crc32(domain) % 7, 0 = Sunday) is + * $bucket. $prefix controls where it sorts. + */ +function rpsDomainInBucket(int $bucket, string $prefix = 'site'): string +{ + static $used = []; + + for ($i = 0; ; $i++) { + $domain = "{$prefix}-{$i}.example.com"; + if (! isset($used[$domain]) && crc32($domain) % 7 === $bucket) { + $used[$domain] = true; + + return $domain; + } + } +} + +function rpsRotationSite(int $bucket, string $prefix = 'site'): Site +{ + return Site::factory()->carePlan()->create(['domain' => rpsDomainInBucket($bucket, $prefix)]); +} + +/** @return list */ +function rpsScannedSiteIds(): array +{ + return SitePerformanceScan::query()->orderBy('id')->pluck('site_id')->all(); +} + describe('clockwork:run-performance-scans — weekly rotation', function () { + afterEach(fn () => Carbon::setTestNow()); + it('--weekly-rotation scans only tonight\'s 1/7th slice of the fleet', function () { config(['clockwork.gtmetrix.api_key' => 'test-key']); - // Pin "today" to a known UTC weekday (Wednesday = index 3) so the - // bucket math (`$i % 7 === $day`) is deterministic. - Carbon::setTestNow(Carbon::parse('2026-09-02 12:00:00', 'UTC')); // Wednesday + // Pin "today" to a known UTC weekday (Wednesday = 3), early in the + // month so the month-end catch-up stays out of it. + Carbon::setTestNow(Carbon::parse('2026-09-02 12:00:00', 'UTC')); expect(now('UTC')->dayOfWeek)->toBe(3); - // 7 sites, domain-sorted so their query order is deterministic; - // only the one at index 3 should be scanned tonight. - $sites = collect(range(0, 6))->map( - fn ($i) => Site::factory()->carePlan()->create(['domain' => "rotation-{$i}.example.test"]) - ); + // One site per weekday bucket; only Wednesday's is due tonight. + $sites = collect(range(0, 6))->map(fn (int $bucket) => rpsRotationSite($bucket)); rpsFakeGtmetrixOk(); $this->artisan('clockwork:run-performance-scans', ['--weekly-rotation' => true])->assertSuccessful(); - expect(SitePerformanceScan::query()->count())->toBe(1) - ->and(SitePerformanceScan::query()->first()->site_id)->toBe($sites[3]->id); + expect(rpsScannedSiteIds())->toBe([$sites[3]->id]); + }); - Carbon::setTestNow(); + it('buckets each site by crc32 of its own domain', function () { + $site = Site::factory()->make(['domain' => 'bucket-check.example.com']); + + expect(RunPerformanceScans::rotationBucket($site))->toBe(crc32('bucket-check.example.com') % 7); + }); + + it('keeps a site on the same weekday when other sites are enrolled', function () { + config(['clockwork.gtmetrix.api_key' => 'test-key']); + Carbon::setTestNow(Carbon::parse('2026-09-02 12:00:00', 'UTC')); // Wednesday = 3 + rpsFakeGtmetrixOk(); + + $wednesday = rpsRotationSite(3, 'm'); + rpsRotationSite(4, 'm'); + $this->artisan('clockwork:run-performance-scans', ['--weekly-rotation' => true])->assertSuccessful(); + + // Enrol sites that sort ahead of it. Position-based buckets moved + // every later site to another weekday when this happened. + foreach ([0, 1, 2, 5, 6] as $bucket) { + rpsRotationSite($bucket, 'a'); + } + $this->artisan('clockwork:run-performance-scans', ['--weekly-rotation' => true])->assertSuccessful(); + + expect(rpsScannedSiteIds())->toBe([$wednesday->id, $wednesday->id]); }); it('--site overrides --weekly-rotation entirely', function () { @@ -238,6 +289,108 @@ function rpsFakePsiOk(): void }); }); +describe('clockwork:run-performance-scans — month-end catch-up', function () { + // 2026-09-28 is a Monday (bucket 1) with 2 nights of September left + // after it. From a Monday, buckets 2 and 3 still come round this month; + // 4, 5, 6 and 0 don't. + beforeEach(function () { + config(['clockwork.gtmetrix.api_key' => 'test-key', 'clockwork.psi.api_key' => '']); + Carbon::setTestNow(Carbon::parse('2026-09-28 04:45:00', 'UTC')); + rpsFakeGtmetrixOk(); + }); + afterEach(fn () => Carbon::setTestNow()); + + $okScan = fn (Site $site, string $at) => SitePerformanceScan::factory()->create(['site_id' => $site->id, 'scanned_at' => $at]); + $rotate = fn () => test()->artisan('clockwork:run-performance-scans', ['--weekly-rotation' => true]); + $newScans = fn (int $afterId = 0) => SitePerformanceScan::query()->where('id', '>', $afterId)->orderBy('id')->pluck('site_id')->all(); + + it('adds sites with no OK scan this month to one of the last 7 nights', function () use ($okScan, $rotate, $newScans) { + $tonight = rpsRotationSite(1); + $lastScannedInAugust = rpsRotationSite(5); + $okScan($lastScannedInAugust, '2026-08-24 04:45:00'); + $failedThisMonth = rpsRotationSite(6); + SitePerformanceScan::factory()->failed()->create(['site_id' => $failedThisMonth->id, 'scanned_at' => '2026-09-27 04:45:00']); + $covered = rpsRotationSite(4); + $okScan($covered, '2026-09-24 04:45:00'); + $before = (int) SitePerformanceScan::query()->max('id'); + + $rotate() + ->expectsOutputToContain('Month-end catch-up: 2 site(s) with no scan this month added, 0 already-scanned site(s) moved to next week to make room, 0 waiting for the next night.') + ->assertSuccessful(); + + expect(collect($newScans($before))->sort()->values()->all()) + ->toBe(collect([$tonight->id, $lastScannedInAugust->id, $failedThisMonth->id])->sort()->values()->all()); + }); + + it('does nothing earlier in the month', function () use ($rotate, $newScans) { + Carbon::setTestNow(Carbon::parse('2026-09-23 04:45:00', 'UTC')); // Wednesday, 7 nights left + $tonight = rpsRotationSite(3); + rpsRotationSite(5); + + $rotate()->doesntExpectOutputToContain('Month-end catch-up')->assertSuccessful(); + + expect($newScans())->toBe([$tonight->id]); + }); + + it('stays within the daily credit budget and leaves the rest for the next night, not PSI', function () use ($rotate, $newScans) { + config(['clockwork.gtmetrix.daily_credits' => 2, 'clockwork.psi.api_key' => 'test-psi-key']); + $tonight = rpsRotationSite(1, 't'); + $first = rpsRotationSite(5, 'a'); + $second = rpsRotationSite(6, 'b'); + $third = rpsRotationSite(0, 'c'); + + $rotate()->expectsOutputToContain('1 site(s) with no scan this month added, 0 already-scanned site(s) moved to next week to make room, 2 waiting for the next night.') + ->assertSuccessful(); + expect($newScans())->toBe([$tonight->id, $first->id]); + + // Tuesday has no bucket of its own here, so both credits go to the + // two that waited. + Carbon::setTestNow(Carbon::parse('2026-09-29 04:45:00', 'UTC')); + $rotate()->assertSuccessful(); + + expect($newScans())->toBe([$tonight->id, $first->id, $second->id, $third->id]); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'googleapis.com')); + }); + + it('gives free credits to sites whose own weekday has passed for the month first', function () use ($rotate, $newScans) { + config(['clockwork.gtmetrix.daily_credits' => 2]); + $tonight = rpsRotationSite(1, 't'); + rpsRotationSite(2, 'a'); // its weekday is tomorrow + $noSlotLeft = rpsRotationSite(5, 'b'); + + $rotate()->assertSuccessful(); + + expect($newScans())->toBe([$tonight->id, $noSlotLeft->id]); + }); + + it('moves the most recently scanned site in tonight\'s bucket to next week to make room', function () use ($okScan, $rotate, $newScans) { + config(['clockwork.gtmetrix.daily_credits' => 2]); + $recent = rpsRotationSite(1, 'r'); + $okScan($recent, '2026-09-21 04:45:00'); + $older = rpsRotationSite(1, 's'); + $okScan($older, '2026-09-14 04:45:00'); + $missing = rpsRotationSite(5, 'g'); + $before = (int) SitePerformanceScan::query()->max('id'); + + $rotate()->expectsOutputToContain('1 site(s) with no scan this month added, 1 already-scanned site(s) moved to next week to make room, 0 waiting for the next night.') + ->assertSuccessful(); + + // The site with no scan this month runs first, so it gets a credit. + expect($newScans($before))->toBe([$missing->id, $older->id]); + }); + + it('never takes the slot of a site in tonight\'s bucket that has no scan this month', function () use ($rotate, $newScans) { + config(['clockwork.gtmetrix.daily_credits' => 1]); + $tonight = rpsRotationSite(1, 't'); + rpsRotationSite(5, 'g'); + + $rotate()->expectsOutputToContain('0 site(s) with no scan this month added, 0 already-scanned site(s) moved to next week to make room, 1 waiting for the next night.') + ->assertSuccessful(); + + expect($newScans())->toBe([$tonight->id]); + }); +}); + describe('clockwork:run-performance-scans — host reports (Pressable)', function () { function rpsPressableReport(): void { @@ -316,8 +469,8 @@ function rpsPressableReport(): void it('keeps Pressable sites out of the GTmetrix rotation', function () { config(['clockwork.gtmetrix.api_key' => 'test-key']); Carbon::setTestNow(Carbon::parse('2026-09-06 12:00:00', 'UTC')); // Sunday = bucket 0 - Site::factory()->pressable()->carePlan()->create(['domain' => 'a-pressable.example.test']); - $spinup = Site::factory()->carePlan()->create(['domain' => 'b-spinup.example.test']); + Site::factory()->pressable()->carePlan()->create(['domain' => rpsDomainInBucket(0, 'a-pressable')]); + $spinup = Site::factory()->carePlan()->create(['domain' => rpsDomainInBucket(0, 'b-spinup')]); rpsFakeGtmetrixOk(); $this->artisan('clockwork:run-performance-scans', ['--weekly-rotation' => true])->assertSuccessful(); diff --git a/tests/Feature/Console/SuppressMalwarePathTest.php b/tests/Feature/Console/SuppressMalwarePathTest.php index b2262ead..fa37db62 100644 --- a/tests/Feature/Console/SuppressMalwarePathTest.php +++ b/tests/Feature/Console/SuppressMalwarePathTest.php @@ -91,4 +91,97 @@ expect($site->refresh()->companion_finding_state['php_in_uploads_suppressed'])->toBe(['a.php']); }); + + it('stores an absolute path relative to the WordPress root', function (string $raw) { + $site = Site::factory()->create([ + 'domain' => 'suppress-abs.example.com', + 'wp_path' => '/sites/suppress-abs.example.com/files', + 'companion_finding_state' => null, + ]); + + $this->artisan('clockwork:suppress-malware-path', [ + '--site' => $site->domain, + '--path' => $raw, + ])->assertSuccessful(); + + expect($site->refresh()->companion_finding_state['php_in_uploads_suppressed']) + ->toBe(['wp-content/uploads/tool/run.php']); + })->with([ + 'site wp_path' => '/sites/suppress-abs.example.com/files/wp-content/uploads/tool/run.php', + 'managed-host docroot' => '/srv/htdocs/wp-content/uploads/tool/run.php', + 'backslash separators' => 'wp-content\\uploads\\tool\\run.php', + ]); + + it('treats an absolute path as already suppressed when its relative form is on the list', function () { + $site = Site::factory()->create([ + 'companion_finding_state' => ['php_in_uploads_suppressed' => ['wp-content/uploads/dup.php']], + ]); + + $this->artisan('clockwork:suppress-malware-path', [ + '--site' => $site->domain, + '--path' => '/srv/htdocs/wp-content/uploads/dup.php', + ]) + ->expectsOutputToContain('is already suppressed') + ->assertSuccessful(); + + expect($site->refresh()->companion_finding_state['php_in_uploads_suppressed']) + ->toBe(['wp-content/uploads/dup.php']); + }); + + it('removes an absolute entry stored before normalization when given its relative path', function () { + $site = Site::factory()->create([ + 'companion_finding_state' => ['php_in_uploads_suppressed' => ['/srv/htdocs/wp-content/uploads/old.php', 'b.php']], + ]); + + $this->artisan('clockwork:suppress-malware-path', [ + '--site' => $site->domain, + '--path' => 'wp-content/uploads/old.php', + '--unsuppress' => true, + ])->assertSuccessful(); + + expect($site->refresh()->companion_finding_state['php_in_uploads_suppressed'])->toBe(['b.php']); + }); + + it('suppresses a preg_replace /e finding by kind and path in its own list', function () { + $site = Site::factory()->create([ + 'companion_finding_state' => ['php_in_uploads_suppressed' => ['wp-content/uploads/existing.php']], + ]); + + $this->artisan('clockwork:suppress-malware-path', [ + '--site' => $site->domain, + '--kind' => 'preg_replace_e_modifier', + '--path' => 'wp-content/plugins/legacy-vendor/includes/helpers.php', + ])->assertSuccessful(); + + $state = $site->refresh()->companion_finding_state; + expect($state['preg_replace_e_modifier_suppressed'])->toBe(['wp-content/plugins/legacy-vendor/includes/helpers.php']) + ->and($state['php_in_uploads_suppressed'])->toBe(['wp-content/uploads/existing.php']); + }); + + it('refuses to suppress a positive malware signature kind', function () { + $site = Site::factory()->create(['companion_finding_state' => null]); + + $this->artisan('clockwork:suppress-malware-path', [ + '--site' => $site->domain, + '--kind' => 'eval_base64', + '--path' => 'wp-content/plugins/x/y.php', + ]) + ->expectsOutputToContain("Kind 'eval_base64' cannot be suppressed") + ->assertFailed(); + + expect($site->refresh()->companion_finding_state)->toBeNull(); + }); + + it('refuses a path that is only the site docroot', function () { + $site = Site::factory()->create(['companion_finding_state' => null]); + + $this->artisan('clockwork:suppress-malware-path', [ + '--site' => $site->domain, + '--path' => '/srv/htdocs/', + ]) + ->expectsOutputToContain('is the site\'s docroot') + ->assertFailed(); + + expect($site->refresh()->companion_finding_state)->toBeNull(); + }); }); diff --git a/tests/Feature/Console/SyncBillCarePlansTest.php b/tests/Feature/Console/SyncBillCarePlansTest.php new file mode 100644 index 00000000..8c5948df --- /dev/null +++ b/tests/Feature/Console/SyncBillCarePlansTest.php @@ -0,0 +1,73 @@ +shouldReceive('items')->andReturnUsing(function () use ($items) { + yield from $items; + }); + $mock->shouldReceive('invoicesSince')->andReturnUsing(function () use ($invoices) { + yield from $invoices; + }); + + return $mock; +} + +it('matches a www.-stored site and labels archived-only care-plan domains instead of calling them missing', function () { + $customer = BillComCustomer::create(['id' => '0cuCMDCP1', 'name' => 'Example Client', 'archived' => false]); + $site = Site::factory()->create([ + 'domain' => 'www.example.com', + 'bill_com_customer_id' => $customer->id, + 'care_plan_enabled' => false, + 'care_plan_override' => null, + ]); + Site::factory()->archived()->create(['domain' => 'example.net']); + + $this->instance(BillComClient::class, syncBillCarePlansClient( + items: [['id' => 'item-care-1', 'name' => 'WordPress Care Plan']], + invoices: [[ + 'customerId' => $customer->id, + 'invoiceNumber' => 'INV-6001', + 'invoiceDate' => '2026-08-01', + 'invoiceLineItems' => [ + ['itemId' => 'item-care-1', 'description' => 'http://www.example.com - WordPress Care Plan'], + ['itemId' => 'item-care-1', 'description' => 'WordPress Care Plan for example.net and missing-example.org'], + ], + ]], + )); + + $this->artisan('clockwork:sync-bill-care-plans') + ->expectsOutputToContain('Care-plan domains with no matching Site') + ->expectsOutputToContain('missing-example.org') + ->expectsOutputToContain('example.net (archived)') + ->doesntExpectOutputToContain('Domains shared by more than one active site') + ->assertSuccessful(); + + expect($site->refresh()->care_plan_enabled)->toBeTrue(); + + $details = ActionLog::query()->where('action_type', ActionLog::TYPE_BILL_COM_SYNC)->sole()->details; + expect($details['unmatched_care_plan_domains'])->toBe(['missing-example.org' => 1]) + ->and($details['archived_care_plan_domains'])->toBe(['example.net' => 1]) + ->and($details['sites_set_true'])->toBe(1); +}); diff --git a/tests/Feature/Console/SyncBillCustomersTest.php b/tests/Feature/Console/SyncBillCustomersTest.php new file mode 100644 index 00000000..c661180f --- /dev/null +++ b/tests/Feature/Console/SyncBillCustomersTest.php @@ -0,0 +1,63 @@ +shouldReceive('customers')->andReturnUsing(function () use ($customers) { + yield from $customers; + }); + $mock->shouldReceive('invoicesSince')->andReturnUsing(function () use ($invoices) { + yield from $invoices; + }); + + return $mock; +} + +it('reports unmatched, archived-only and colliding domains separately', function () { + Site::factory()->archived()->create(['domain' => 'www.example.net']); + Site::factory()->create(['domain' => 'example.com', 'bill_com_customer_id' => null]); + Site::factory()->create(['domain' => 'www.example.com', 'bill_com_customer_id' => null]); + + $this->instance(BillComClient::class, syncBillCustomersClient( + customers: [['id' => '0cuCMD1', 'name' => 'Example Client', 'archived' => false]], + invoices: [[ + 'customerId' => '0cuCMD1', + 'invoiceNumber' => 'INV-5001', + 'invoiceDate' => '2026-08-01', + 'invoiceLineItems' => [ + ['itemId' => 'item-1', 'description' => 'Hosting for example.com, example.net and missing-example.org'], + ], + ]], + )); + + $this->artisan('clockwork:sync-bill-customers', ['--dry-run' => true]) + ->expectsOutputToContain('1 unmatched domains') + ->expectsOutputToContain('missing-example.org (1 hit)') + ->expectsOutputToContain('example.net (archived)') + ->expectsOutputToContain('example.com (2 sites)') + ->assertSuccessful(); + + $details = ActionLog::query()->where('action_type', ActionLog::TYPE_BILL_COM_SYNC)->sole()->details; + expect($details['unmatched_domains'])->toBe(['missing-example.org' => 1]) + ->and($details['archived_site_domains'])->toBe(['example.net' => 1]) + ->and($details['domain_collisions'])->toBe(['example.com' => 2]); +}); diff --git a/tests/Feature/Console/TestContactFormsTest.php b/tests/Feature/Console/TestContactFormsTest.php index 1060b2f7..fb524187 100644 --- a/tests/Feature/Console/TestContactFormsTest.php +++ b/tests/Feature/Console/TestContactFormsTest.php @@ -3,6 +3,7 @@ use App\Models\ContactFormTest; use App\Models\Site; use App\Services\Forms\ContactFormTester; +use Illuminate\Support\Carbon; /* |-------------------------------------------------------------------------- @@ -168,6 +169,50 @@ $this->artisan('clockwork:test-contact-forms')->assertSuccessful(); }); + it('treats rows as due when the previous run finished later in the day than this run starts', function () { + // Regression: the scheduled run starts at a fixed time but + // last_test_at is stamped a few minutes later when each test + // finishes. A strict 24h / 7d cutoff skipped these rows. + $this->travelTo(Carbon::parse('2026-03-10 06:05:00', 'UTC')); + + $site = Site::factory()->create(['care_plan_enabled' => true]); + + $daily = ContactFormTest::factory()->create([ + 'site_id' => $site->id, + 'slot' => 1, + 'frequency' => ContactFormTest::FREQUENCY_DAILY, + 'last_test_at' => Carbon::parse('2026-03-09 06:12:00', 'UTC'), + ]); + $weekly = ContactFormTest::factory()->create([ + 'site_id' => $site->id, + 'slot' => 2, + 'frequency' => ContactFormTest::FREQUENCY_WEEKLY, + 'last_test_at' => Carbon::parse('2026-03-03 06:12:00', 'UTC'), + ]); + // The grace window must not turn weekly into every six days. + ContactFormTest::factory()->create([ + 'site_id' => $site->id, + 'slot' => 3, + 'frequency' => ContactFormTest::FREQUENCY_WEEKLY, + 'last_test_at' => Carbon::parse('2026-03-04 06:12:00', 'UTC'), + ]); + + $this->mock(ContactFormTester::class, function ($mock) use ($daily, $weekly) { + $mock->shouldReceive('test') + ->once() + ->withArgs(fn (ContactFormTest $t) => $t->is($daily)) + ->andReturn(['result' => ContactFormTester::RESULT_SUCCESS, 'message' => 'ok']); + $mock->shouldReceive('test') + ->once() + ->withArgs(fn (ContactFormTest $t) => $t->is($weekly)) + ->andReturn(['result' => ContactFormTester::RESULT_SUCCESS, 'message' => 'ok']); + }); + + $this->artisan('clockwork:test-contact-forms') + ->expectsOutputToContain('passed=2, failed=0, skipped=0, transitions=0') + ->assertSuccessful(); + }); + it('--force bypasses the due filter for an otherwise-fresh row', function () { $site = Site::factory()->create(['care_plan_enabled' => true]); $fresh = ContactFormTest::factory()->create([ diff --git a/tests/Feature/Console/VerifyWpCoreChecksumsTest.php b/tests/Feature/Console/VerifyWpCoreChecksumsTest.php index b2d17a47..3ebfedef 100644 --- a/tests/Feature/Console/VerifyWpCoreChecksumsTest.php +++ b/tests/Feature/Console/VerifyWpCoreChecksumsTest.php @@ -6,6 +6,7 @@ use App\Services\Security\SecurityScanResult; use App\Services\Security\WpCoreChecksumVerifier; use App\Support\Settings; +use Illuminate\Support\Facades\Log; /* |-------------------------------------------------------------------------- @@ -86,16 +87,18 @@ function failedChecksumResult(Site $site): SecurityScanResult 'domain' => 'not-wp.example.com', ]); - $this->mock(WpCoreChecksumVerifier::class) - ->shouldReceive('verify') - ->once() - ->with(Mockery::on(fn (Site $s) => $s->is($paid))) - ->andReturn(cleanChecksumResult($paid)); + $this->mock(WpCoreChecksumVerifier::class, function ($mock) use ($paid) { + $mock->shouldReceive('hasTransport')->andReturn(true); + $mock->shouldReceive('verify') + ->once() + ->with(Mockery::on(fn (Site $s) => $s->is($paid))) + ->andReturn(cleanChecksumResult($paid)); + }); $this->mock(SecurityScanRecorder::class)->shouldReceive('record')->once(); $this->artisan('clockwork:verify-wp-core-checksums') - ->expectsOutputToContain('Done. clean=1, issues=0, failed=0') + ->expectsOutputToContain('Done. clean=1, issues=0, failed=0, skipped=0') ->assertSuccessful(); }); @@ -108,11 +111,13 @@ function failedChecksumResult(Site $site): SecurityScanResult 'domain' => 'diagnose-me.example.com', ]); - $this->mock(WpCoreChecksumVerifier::class) - ->shouldReceive('verify') - ->once() - ->with(Mockery::on(fn (Site $s) => $s->is($site))) - ->andReturn(cleanChecksumResult($site)); + $this->mock(WpCoreChecksumVerifier::class, function ($mock) use ($site) { + $mock->shouldReceive('hasTransport')->andReturn(true); + $mock->shouldReceive('verify') + ->once() + ->with(Mockery::on(fn (Site $s) => $s->is($site))) + ->andReturn(cleanChecksumResult($site)); + }); $this->mock(SecurityScanRecorder::class)->shouldReceive('record')->once(); @@ -129,6 +134,7 @@ function failedChecksumResult(Site $site): SecurityScanResult $failed = Site::factory()->create(['server_id' => $server->id, 'is_wordpress' => true, 'care_plan_enabled' => true, 'domain' => 'failed.example.com']); $this->mock(WpCoreChecksumVerifier::class, function ($mock) use ($clean, $issues, $failed) { + $mock->shouldReceive('hasTransport')->andReturn(true); $mock->shouldReceive('verify')->with(Mockery::on(fn (Site $s) => $s->is($clean)))->andReturn(cleanChecksumResult($clean)); $mock->shouldReceive('verify')->with(Mockery::on(fn (Site $s) => $s->is($issues)))->andReturn(issuesChecksumResult($issues)); $mock->shouldReceive('verify')->with(Mockery::on(fn (Site $s) => $s->is($failed)))->andReturn(failedChecksumResult($failed)); @@ -137,7 +143,7 @@ function failedChecksumResult(Site $site): SecurityScanResult $this->mock(SecurityScanRecorder::class)->shouldReceive('record')->times(3); $this->artisan('clockwork:verify-wp-core-checksums') - ->expectsOutputToContain('Done. clean=1, issues=1, failed=1') + ->expectsOutputToContain('Done. clean=1, issues=1, failed=1, skipped=0') ->assertFailed(); }); @@ -145,7 +151,10 @@ function failedChecksumResult(Site $site): SecurityScanResult $server = Server::factory()->create(); $site = Site::factory()->create(['server_id' => $server->id, 'is_wordpress' => true, 'care_plan_enabled' => true]); - $this->mock(WpCoreChecksumVerifier::class)->shouldReceive('verify')->once()->andReturn(cleanChecksumResult($site)); + $this->mock(WpCoreChecksumVerifier::class, function ($mock) use ($site) { + $mock->shouldReceive('hasTransport')->andReturn(true); + $mock->shouldReceive('verify')->once()->andReturn(cleanChecksumResult($site)); + }); $this->mock(SecurityScanRecorder::class)->shouldReceive('record')->once(); $this->artisan('clockwork:verify-wp-core-checksums')->assertSuccessful(); @@ -153,3 +162,81 @@ function failedChecksumResult(Site $site): SecurityScanResult expect(app(Settings::class)->get('security_scans.checksums_last_run_at'))->not->toBeNull(); }); }); + +/** + * A standalone site on a host with no SSH, no Pressable command API and no + * host command runner: core checksums can never run on it. + */ +function standaloneChecksumSite(array $overrides = []): Site +{ + return Site::factory()->custom()->carePlan()->withCompanionInstalled()->create(array_merge([ + 'is_wordpress' => true, + 'domain' => 'standalone.example.com', + ], $overrides)); +} + +describe('clockwork:verify-wp-core-checksums — sites with no checksum transport', function () { + // Such a site used to write a failed 'no_command_runner' row every night + // and turn the whole run red. + it('skips it, records nothing for it, and still succeeds', function () { + Log::spy(); + $standalone = standaloneChecksumSite(); + $server = Server::factory()->create(); + $hosted = Site::factory()->create(['server_id' => $server->id, 'is_wordpress' => true, 'care_plan_enabled' => true, 'domain' => 'hosted.example.com']); + + // Partial mock: the real hasTransport() decides, verify() is stubbed. + $this->partialMock(WpCoreChecksumVerifier::class, function ($mock) use ($hosted) { + $mock->shouldReceive('verify') + ->once() + ->with(Mockery::on(fn (Site $s) => $s->is($hosted))) + ->andReturn(cleanChecksumResult($hosted)); + }); + $this->mock(SecurityScanRecorder::class) + ->shouldReceive('record') + ->once() + ->with(Mockery::on(fn (SecurityScanResult $r) => $r->site->is($hosted))); + + $this->artisan('clockwork:verify-wp-core-checksums') + ->expectsOutputToContain('Done. clean=1, issues=0, failed=0, skipped=1') + ->assertSuccessful(); + + Log::shouldHaveReceived('info') + ->with('verify-wp-core-checksums: skipped sites with no checksum transport', Mockery::on( + fn (array $context) => $context['count'] === 1 && $context['site_ids'] === [$standalone->id] + )) + ->once(); + expect(app(Settings::class)->get('security_scans.checksums_last_run_at'))->not->toBeNull(); + }); + + it('says why when the site was asked for by --site', function () { + standaloneChecksumSite(['care_plan_enabled' => false]); + + $this->partialMock(WpCoreChecksumVerifier::class)->shouldNotReceive('verify'); + $this->mock(SecurityScanRecorder::class)->shouldNotReceive('record'); + + $this->artisan('clockwork:verify-wp-core-checksums', ['--site' => 'standalone.example.com']) + ->expectsOutputToContain('standalone.example.com: skipped, no SSH, Pressable or host command runner to run wp-cli') + ->expectsOutputToContain('Done. clean=0, issues=0, failed=0, skipped=1') + ->assertSuccessful(); + }); +}); + +describe('WpCoreChecksumVerifier::hasTransport', function () { + it('is true for a site on a server we can SSH into', function () { + $site = Site::factory()->spinupwp()->create(); + + expect(app(WpCoreChecksumVerifier::class)->hasTransport($site))->toBeTrue(); + }); + + it('is true for a Pressable site, through its command API', function () { + $site = Site::factory()->pressable()->create(); + + expect(app(WpCoreChecksumVerifier::class)->hasTransport($site))->toBeTrue(); + }); + + it('is false for a standalone site on a host with no shell access', function () { + $site = Site::factory()->custom()->create(); + + expect(app(WpCoreChecksumVerifier::class)->hasTransport($site))->toBeFalse(); + }); +}); diff --git a/tests/Feature/Controllers/BackupRelaySettingsControllerTest.php b/tests/Feature/Controllers/BackupRelaySettingsControllerTest.php index 9b7fd4d6..f92055d4 100644 --- a/tests/Feature/Controllers/BackupRelaySettingsControllerTest.php +++ b/tests/Feature/Controllers/BackupRelaySettingsControllerTest.php @@ -149,6 +149,109 @@ public function test_bulk_update_toggles_selected_sites(): void $this->assertFalse($site2->fresh()->backup_relay_enabled); } + public function test_single_site_toggle_refuses_a_direct_upload_site_without_backup_create(): void + { + Storage::fake('s3'); + + $site = $this->customSite('old-plugin.example.com', ['snapshot'], enabled: false); + + $this->actingAs($this->user) + ->patchJson(route('settings.backup-relay.update'), [ + 'site_id' => $site->id, + 'enabled' => true, + ]) + ->assertStatus(422) + ->assertJson([ + 'success' => false, + 'enabled' => false, + 'message' => 'Backup relay was not enabled for old-plugin.example.com: Companion 1.30.3 lacks the backup-create capability; update Companion or switch to Renegade.', + ]); + + $this->assertFalse($site->fresh()->backup_relay_enabled); + } + + public function test_single_site_toggle_enables_a_direct_upload_site_with_backup_create(): void + { + Storage::fake('s3'); + + $site = $this->customSite('new-plugin.example.com', ['snapshot', 'backup-create'], enabled: false); + + $this->actingAs($this->user) + ->patchJson(route('settings.backup-relay.update'), [ + 'site_id' => $site->id, + 'enabled' => true, + ]) + ->assertOk() + ->assertJson(['success' => true, 'enabled' => true]); + + $this->assertTrue($site->fresh()->backup_relay_enabled); + } + + public function test_bulk_update_skips_sites_whose_plugin_lacks_backup_create(): void + { + Storage::fake('s3'); + + $blocked = $this->customSite('blocked.example.com', ['snapshot'], enabled: false); + $capable = $this->customSite('capable.example.com', ['backup-create'], enabled: false); + $alreadyOn = $this->customSite('already-on.example.com', ['snapshot'], enabled: true); + $hosted = Site::query()->create([ + 'domain' => 'hosted.example.com', + 'hosting_provider' => Site::HOSTING_PROVIDER_PRESSABLE, + 'pressable_site_id' => '3', + 'backup_relay_enabled' => false, + 'is_wordpress' => true, + ]); + + $this->actingAs($this->user) + ->patch(route('settings.backup-relay.update'), [ + 'sites' => [$blocked->id, $capable->id, $alreadyOn->id, $hosted->id], + ]) + ->assertRedirect() + ->assertSessionHas('status', 'Backup relay settings updated for 3 site(s).') + ->assertSessionHas('warning', 'Not enabled: blocked.example.com (Companion 1.30.3 lacks the backup-create capability; update Companion or switch to Renegade.)'); + + $this->assertFalse($blocked->fresh()->backup_relay_enabled); + $this->assertTrue($capable->fresh()->backup_relay_enabled); + // Already on: left on, so its failures stay visible. + $this->assertTrue($alreadyOn->fresh()->backup_relay_enabled); + $this->assertTrue($hosted->fresh()->backup_relay_enabled); + } + + public function test_bulk_update_via_ajax_lists_blocked_sites(): void + { + Storage::fake('s3'); + + $blocked = $this->customSite('blocked-ajax.example.com', [], enabled: false); + $capable = $this->customSite('capable-ajax.example.com', ['backup-create'], enabled: false); + + $this->actingAs($this->user) + ->patchJson(route('settings.backup-relay.update'), [ + 'sites' => [$blocked->id, $capable->id], + ]) + ->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('enabled_count', 1) + ->assertJsonPath('blocked.0.site_id', $blocked->id) + ->assertJsonPath('blocked.0.domain', 'blocked-ajax.example.com'); + + $this->assertFalse($blocked->fresh()->backup_relay_enabled); + $this->assertTrue($capable->fresh()->backup_relay_enabled); + } + + /** + * @param array $capabilities + */ + private function customSite(string $domain, array $capabilities, bool $enabled): Site + { + return Site::factory()->custom()->withCompanionInstalled()->create([ + 'domain' => $domain, + 'companion_variant' => 'companion', + 'companion_version' => '1.30.3', + 'companion_capabilities' => $capabilities, + 'backup_relay_enabled' => $enabled, + ]); + } + public function test_run_now_executes_in_repo_mode(): void { app(Settings::class)->put('backup_relay.mode', 'in_repo'); diff --git a/tests/Feature/Controllers/CompanionSettingsControllerTest.php b/tests/Feature/Controllers/CompanionSettingsControllerTest.php index dec0f1d2..e7b65926 100644 --- a/tests/Feature/Controllers/CompanionSettingsControllerTest.php +++ b/tests/Feature/Controllers/CompanionSettingsControllerTest.php @@ -7,6 +7,7 @@ use App\Services\Companion\CompanionBrandingManager; use App\Services\Process\BackgroundArtisan; use App\Services\Process\BackgroundArtisanResult; +use App\Support\Settings; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Queue; @@ -323,7 +324,8 @@ ->assertSee('Primary Brand Color') ->assertSee('Accent Strip') ->assertSee('Website Care Report') - ->assertSee('Email Header Background', false); + ->assertSee('Email Header Background', false) + ->assertSee('Leave empty to use the Companion Support Portal URL, then your Agency Website', false); }); it('saves custom reports branding and redirects to reports tab', function () { @@ -382,6 +384,73 @@ $response->assertSessionHasErrors(['support_email']); }); + it('rejects a reports support URL clients could not reach', function (string $url) { + $user = User::factory()->create(); + + $this->actingAs($user)->patch(route('settings.companion.update'), [ + 'tab' => 'reports', + 'support_url' => $url, + ])->assertSessionHasErrors(['support_url']); + + expect(app(CompanionBrandingManager::class)->getReportsBranding()['support_url'])->not->toBe($url); + })->with([ + 'localhost' => 'http://localhost:8000', + 'loopback IPv4' => 'http://127.0.0.1:8000/support', + 'loopback IPv6' => 'http://[::1]:8000/', + 'private network' => 'http://10.0.0.5/help', + 'link-local' => 'http://169.254.10.10/', + 'single-label host' => 'http://intranet/help', + 'internal-only name' => 'https://control.internal/help', + 'mDNS name' => 'https://office.local/help', + 'not http(s)' => 'ftp://example.com/support', + 'not a URL' => 'support page', + ]); + + it('rejects this app\'s own URL as the reports support URL', function () { + config(['app.url' => 'https://control.example.com']); + $user = User::factory()->create(); + + $this->actingAs($user)->patch(route('settings.companion.update'), [ + 'tab' => 'reports', + 'support_url' => 'https://control.example.com/help', + ])->assertSessionHasErrors(['support_url']); + }); + + it('accepts a blank reports support URL, and a public one', function () { + $user = User::factory()->create(); + $manager = app(CompanionBrandingManager::class); + + $this->actingAs($user)->patch(route('settings.companion.update'), [ + 'tab' => 'reports', + 'support_url' => '', + ])->assertSessionHasNoErrors()->assertRedirect(route('settings.companion.index', ['tab' => 'reports'])); + expect(app(Settings::class)->get('reports.branding.support_url'))->toBe(''); + + $this->actingAs($user)->patch(route('settings.companion.update'), [ + 'tab' => 'reports', + 'support_url' => 'https://example.com/support', + ])->assertSessionHasNoErrors(); + expect($manager->getReportsBranding()['support_url'])->toBe('https://example.com/support'); + }); + + it('rejects localhost for the shared support and agency website URLs but still allows a blank support URL', function () { + $user = User::factory()->create(); + + $this->actingAs($user)->patch(route('settings.companion.update'), [ + 'company_url' => 'http://localhost:8000', + 'support_url' => 'http://127.0.0.1/help', + ])->assertSessionHasErrors(['company_url', 'support_url']); + + $this->actingAs($user)->patch(route('settings.companion.update'), [ + 'company_url' => 'https://example.com', + 'support_url' => '', + ])->assertSessionHasNoErrors(); + + $branding = app(CompanionBrandingManager::class)->get(); + expect($branding['company_url'])->toBe('https://example.com') + ->and($branding['support_url'])->toBe(''); + }); + it('resets reports styling to defaults', function () { $user = User::factory()->create(); $manager = app(CompanionBrandingManager::class); diff --git a/tests/Feature/Controllers/MonitoringControllerTest.php b/tests/Feature/Controllers/MonitoringControllerTest.php index c1f2785c..a5d697bf 100644 --- a/tests/Feature/Controllers/MonitoringControllerTest.php +++ b/tests/Feature/Controllers/MonitoringControllerTest.php @@ -68,6 +68,36 @@ ->assertViewHas('currentlyMaintenance', 1); }); + it('renders a site that rate-limits the probe as blocked, not down, with its event in the feed', function () { + $server = Server::factory()->create(); + $site = Site::factory()->spinupwp()->create([ + 'server_id' => $server->id, + 'domain' => 'ratelimited.example.com', + 'uptime_monitoring_enabled' => true, + 'uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED, + 'uptime_probe_blocked_since' => now()->subHours(3), + ]); + SiteUptimeEvent::create([ + 'site_id' => $site->id, + 'event_type' => SiteUptimeEvent::TYPE_PROBE_BLOCKED, + 'status_code' => 429, + 'error' => 'HTTP 429 (rate-limited — probe blocked)', + 'event_at' => now()->subHours(3), + ]); + + $response = $this->actingAs(User::factory()->create()) + ->get(route('monitoring.index')); + + $response->assertOk() + ->assertSee('ratelimited.example.com') + ->assertSee('BLOCKED') + ->assertSee('Probe blocked (HTTP 429) for 3h') + ->assertSee('1 probe blocked') + ->assertSee('not counted as downtime') + ->assertViewHas('currentlyProbeBlocked', 1) + ->assertViewHas('currentlyDown', 0); + }); + it('renders separate 7d and 30d fleet uptime headline figures', function () { $server = Server::factory()->create(); $site = Site::factory()->spinupwp()->create([ @@ -173,6 +203,50 @@ $response->assertSessionHasErrors('interval_minutes'); }); + it('shows the probe-blocked notice hours on the settings page, defaulting to 6', function () { + $this->actingAs(User::factory()->create()) + ->get(route('monitoring.settings')) + ->assertOk() + ->assertSee('Notify when a site blocks the probe for') + ->assertSee('name="probe_blocked_alert_hours"', false) + ->assertViewHas('probeBlockedAlertHours', 6); + }); + + it('saves the probe-blocked notice hours', function () { + $this->actingAs(User::factory()->create()) + ->patch(route('monitoring.settings.update'), [ + 'interval_minutes' => 5, + 'failure_threshold' => 2, + 'probe_blocked_alert_hours' => 12, + ]) + ->assertRedirect(route('monitoring.settings')); + + expect((int) app(Settings::class)->get('monitoring.uptime_probe_blocked_alert_hours'))->toBe(12); + }); + + it('keeps the stored probe-blocked hours when the field is not posted', function () { + app(Settings::class)->put('monitoring.uptime_probe_blocked_alert_hours', 9); + + $this->actingAs(User::factory()->create()) + ->patch(route('monitoring.settings.update'), [ + 'interval_minutes' => 5, + 'failure_threshold' => 2, + ]) + ->assertRedirect(route('monitoring.settings')); + + expect((int) app(Settings::class)->get('monitoring.uptime_probe_blocked_alert_hours'))->toBe(9); + }); + + it('rejects out-of-range probe-blocked hours', function () { + $this->actingAs(User::factory()->create()) + ->patch(route('monitoring.settings.update'), [ + 'interval_minutes' => 5, + 'failure_threshold' => 2, + 'probe_blocked_alert_hours' => 0, + ]) + ->assertSessionHasErrors('probe_blocked_alert_hours'); + }); + it('classifies active outage as Not Our Fault without setting standing site SLA policy', function () { $server = Server::factory()->create(); $site = Site::factory()->spinupwp()->create([ diff --git a/tests/Feature/Controllers/SitesControllerTest.php b/tests/Feature/Controllers/SitesControllerTest.php index 9e036f91..9fde5f54 100644 --- a/tests/Feature/Controllers/SitesControllerTest.php +++ b/tests/Feature/Controllers/SitesControllerTest.php @@ -9,10 +9,13 @@ use App\Models\SiteIngestExclusion; use App\Models\SiteUptimeEvent; use App\Models\User; +use App\Services\Chat\ChatNotifier; use App\Services\Companion\CompanionInstaller; use App\Services\DigitalOcean\SpacesClient; use App\Services\Fail2ban\Fail2banClient; use App\Services\HostingProvider\HostingProviderRegistry; +use App\Services\Process\BackgroundArtisan; +use App\Services\Process\BackgroundArtisanResult; use App\Services\Security\PluginVulnerabilityMatcher; use App\Services\Sites\WpConfigExtractor; use App\Services\Sites\WpPluginDetector; @@ -465,6 +468,33 @@ function companionSite(array $overrides = []): Site $response->assertOk()->assertJsonPath('ok', true)->assertJsonPath('status_code', 200); }); + + it('reports a rate-limited probe as blocked rather than down', function () { + [, $site] = spinupSite(['uptime_monitoring_enabled' => true, 'uptime_state' => 'up']); + + $this->mock(UptimeProber::class) + ->shouldReceive('probe') + ->twice() + ->andReturn(UptimeProbeResult::blocked(429, 90, 'HTTP 429 (rate-limited — probe blocked)')); + + $this->mock(ChatNotifier::class)->shouldNotReceive('siteWentDown'); + + $user = User::factory()->create(); + + // One 429 could be a blip: the state waits for the next in a row. + $this->actingAs($user)->post(route('sites.uptime.recheck', $site)) + ->assertOk() + ->assertJsonPath('ok', true) + ->assertJsonPath('status_code', 429) + ->assertJsonPath('state', 'up') + ->assertJsonPath('message', 'Probe blocked (HTTP 429) — not counted as down'); + + $this->travel(5)->minutes(); + $this->actingAs($user)->post(route('sites.uptime.recheck', $site->fresh())) + ->assertOk() + ->assertJsonPath('state', Site::UPTIME_STATE_PROBE_BLOCKED) + ->assertJsonPath('message', 'Probe blocked (HTTP 429) — not counted as down'); + }); }); describe('refreshWpPlugins', function () { @@ -919,6 +949,39 @@ function companionSite(array $overrides = []): Site $response->assertOk()->assertJsonPath('ok', true)->assertJsonPath('after_version', '5.4'); expect(ActionLog::query()->where('site_id', $site->id)->where('action_type', ActionLog::TYPE_PLUGIN_UPDATE)->exists())->toBeTrue(); }); + + it('keeps the licence key in the upgrader download line out of the action log and the response', function () { + $site = companionSite(['companion_capabilities' => ['updates']]); + $fakeLicense = 'FAKE-LICENSE-1234-5678-9012'; + + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/plugins/update*" => Http::response([ + 'ok' => true, + 'slug' => 'sample-pro/sample-pro.php', + 'before_version' => '2.0.0', + 'after_version' => '2.1.0', + 'was_active' => true, + 'reactivated' => true, + 'messages' => [ + "Downloading update from https://updates.example.com/?action=package_download&license={$fakeLicense}&domain=client.example.org…", + 'Unpacking the update…', + 'Plugin updated successfully.', + ], + 'elapsed_ms' => 1500, + ], 200, ['Content-Type' => 'application/json']), + ]); + + $response = $this->actingAs(User::factory()->create()) + ->post(route('sites.companion.plugin-update', $site), ['slug' => 'sample-pro/sample-pro.php']); + + $response->assertOk() + ->assertJsonPath('messages.0', 'Downloading update from https://updates.example.com/[redacted]…'); + expect($response->getContent())->not->toContain($fakeLicense); + + $log = ActionLog::query()->where('site_id', $site->id)->where('action_type', ActionLog::TYPE_PLUGIN_UPDATE)->first(); + expect($log->details['messages'][0])->toBe('Downloading update from https://updates.example.com/[redacted]…') + ->and(json_encode($log->details))->not->toContain($fakeLicense); + }); }); describe('flushPressableObjectCache', function () { @@ -1128,6 +1191,51 @@ function companionSite(array $overrides = []): Site }); }); +describe('toggleCarePlan performance baseline', function () { + // The weekly rotation only reaches a site on its own weekday, so a site + // put on a care plan late in the month could have no speed test for + // that month's report. + it('starts one background performance scan when the care plan is switched on', function () { + [, $site] = spinupSite(['care_plan_enabled' => false]); + $this->mock(BackgroundArtisan::class, function ($mock) use ($site) { + $mock->shouldReceive('start') + ->once() + ->withArgs(fn (string $lockKey, array $commands) => $lockKey === 'performance-baseline:'.$site->id + && $commands === ['clockwork:run-performance-scans --site='.$site->id.' --strategy=mobile']) + ->andReturn(BackgroundArtisanResult::ok()); + }); + + $this->actingAs(User::factory()->create()) + ->postJson(route('sites.care-plan', $site), ['enabled' => '1']) + ->assertOk(); + }); + + it('starts nothing when the care plan is switched off or was already on', function () { + [, $off] = spinupSite(['care_plan_enabled' => true]); + [, $alreadyOn] = spinupSite(['care_plan_enabled' => true]); + $this->mock(BackgroundArtisan::class)->shouldNotReceive('start'); + + $this->actingAs(User::factory()->create()) + ->postJson(route('sites.care-plan', $off), ['enabled' => '0']) + ->assertOk(); + $this->actingAs(User::factory()->create()) + ->postJson(route('sites.care-plan', $alreadyOn), ['enabled' => '1']) + ->assertOk(); + }); + + it('still saves the toggle when the background launch fails', function () { + [, $site] = spinupSite(['care_plan_enabled' => false]); + $this->mock(BackgroundArtisan::class)->shouldReceive('start')->once()->andThrow(new RuntimeException('no php binary')); + + $this->actingAs(User::factory()->create()) + ->postJson(route('sites.care-plan', $site), ['enabled' => '1']) + ->assertOk() + ->assertJsonPath('care_plan_enabled', true); + + expect($site->fresh()->care_plan_enabled)->toBeTrue(); + }); +}); + describe('archive', function () { it('rejects a confirmation that does not match the domain', function () { [, $site] = spinupSite(); diff --git a/tests/Feature/Jobs/UpdateJobPipelineTest.php b/tests/Feature/Jobs/UpdateJobPipelineTest.php index 2ecca2ca..a8c5b414 100644 --- a/tests/Feature/Jobs/UpdateJobPipelineTest.php +++ b/tests/Feature/Jobs/UpdateJobPipelineTest.php @@ -8,6 +8,7 @@ use App\Models\Site; use App\Services\ActionLog\ActionLogger; use App\Services\Chat\ChatNotifier; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; @@ -192,6 +193,258 @@ function fakePluginUpdateEndpoint(Site $site, array $response, int $status = 200 }); }); +/* +| Realistic upgrader output as Companion returns it in `messages`. The +| licence key and signature are invented; hosts are reserved example +| domains. Field Companion/Renegade versions answer ok=true and +| upgrade_completed=true for these even though the upgrader failed. +*/ +const PIPELINE_FAKE_LICENSE = 'FAKE-LICENSE-9999-8888-7777'; + +function licensedDownloadLine(): string +{ + return 'Downloading update from https://updates.example.com/?action=package_download&license=' + .PIPELINE_FAKE_LICENSE.'&domain=client.example.org…'; +} + +/** + * @param list $messages + * @return array + */ +function stalledUpgraderResponse(array $messages, string $version = '1.0.0'): array +{ + return [ + 'ok' => true, + 'upgrade_completed' => true, + 'slug' => 'sample-plugin/sample-plugin.php', + 'before_version' => $version, + 'after_version' => $version, + 'was_active' => true, + 'reactivated' => true, + 'messages' => $messages, + 'elapsed_ms' => 900, + ]; +} + +function makeVerifyCapableSite(): Site +{ + // post-update-verify capability + a snapshot means verifyAndRepair() + // WOULD run whenever the job believes the upgrade completed. + return makeCompanionSite([ + 'companion_capabilities' => ['updates', 'post-update-verify'], + 'companion_snapshot' => [ + 'plugins' => ['plugins' => [ + ['slug' => 'sample-plugin/sample-plugin.php', 'name' => 'Sample Plugin', 'version' => '1.0.0', 'active' => true], + ]], + 'themes' => ['items' => []], + ], + 'companion_snapshot_at' => now(), + ]); +} + +function fakeUpdateAndVerify(Site $site, array $updateResponse): void +{ + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/plugins/update*" => Http::response($updateResponse, 200), + "https://{$site->domain}/wp-json/clockwork/v1/post-update-verify*" => Http::response(['ok' => true, 'repairs' => []], 200), + ]); +} + +describe('AbstractRunUpdate stalled no-op with a real upgrader error', function () { + it('records the upgrader error and fs_permission instead of "no longer offered", skips verify, and keeps the licence key out of everything it stores', function () { + $site = makeVerifyCapableSite(); + $job = PluginUpdateJob::factory()->for($site)->create([ + 'target_slug' => 'sample-plugin/sample-plugin.php', + 'target_name' => 'Sample Plugin', + 'before_version' => '1.0.0', + 'target_version' => '1.1.0', + 'batch_id' => 'nightly-'.now()->format('Y-m-d'), + ]); + + fakeUpdateAndVerify($site, stalledUpgraderResponse([ + licensedDownloadLine(), + 'Unpacking the update…', + 'Could not copy file. sample-plugin/includes/class-admin.php', + 'Error: Could not copy file.', + ])); + + $this->mock(ChatNotifier::class, function ($mock) { + $mock->shouldReceive('pluginUpdateFailed') + ->once() + ->withArgs(fn (Site $s, PluginUpdateJob $j) => str_contains((string) $j->error, 'Could not copy file. sample-plugin/includes/class-admin.php') + && ! str_contains((string) $j->error, PIPELINE_FAKE_LICENSE)) + ->andReturn(true); + }); + + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + + $job->refresh(); + expect($job->status)->toBe(PluginUpdateJob::STATUS_FAILED) + ->and($job->reason_code)->toBe('fs_permission') + ->and($job->error)->toBe('Could not copy file. sample-plugin/includes/class-admin.php — Error: Could not copy file. (WordPress upgrader error; still on 1.0.0, expected 1.1.0.)') + ->and($job->error)->not->toContain('no longer offered') + ->and($job->messages[0])->toBe('Downloading update from https://updates.example.com/[redacted]…') + ->and(json_encode($job->messages))->not->toContain(PIPELINE_FAKE_LICENSE); + + // The upgrader failed, so there was no file swap to verify. + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/post-update-verify')); + + $log = ActionLog::where('action_type', ActionLog::TYPE_PLUGIN_UPDATE)->where('site_id', $site->id)->first(); + expect($log->ok)->toBeFalse() + ->and($log->details['reason_code'])->toBe('fs_permission') + ->and($log->details['upgrade_completed'])->toBeFalse() + ->and($log->summary)->toContain('Could not copy file.') + ->and(json_encode($log->details))->not->toContain(PIPELINE_FAKE_LICENSE) + ->and($log->summary.$log->error)->not->toContain(PIPELINE_FAKE_LICENSE); + }); + + it('classifies a refused licensed download as download_unauthorized', function () { + $site = makeCompanionSite(); + $job = PluginUpdateJob::factory()->for($site)->create([ + 'target_slug' => 'sample-pro/sample-pro.php', + 'before_version' => '3.0.0', + 'target_version' => '4.0.0', + 'batch_id' => (string) Str::uuid(), + ]); + + fakeUpdateAndVerify($site, stalledUpgraderResponse([ + licensedDownloadLine(), + 'Download failed. Unauthorized', + 'Error: Download failed.', + ], '3.0.0')); + + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + + $job->refresh(); + expect($job->status)->toBe(PluginUpdateJob::STATUS_FAILED) + ->and($job->reason_code)->toBe('download_unauthorized') + ->and($job->error)->toStartWith('Download failed. Unauthorized — Error: Download failed.') + ->and($job->error)->not->toContain(PIPELINE_FAKE_LICENSE); + }); + + it('never copies the download line into the error, even when it directly precedes the Error: line', function () { + $site = makeCompanionSite(); + $job = PluginUpdateJob::factory()->for($site)->create([ + 'target_slug' => 'sample-pro/sample-pro.php', + 'before_version' => '3.0.0', + 'target_version' => '4.0.0', + 'batch_id' => (string) Str::uuid(), + ]); + + fakeUpdateAndVerify($site, stalledUpgraderResponse([ + licensedDownloadLine(), + 'Error: Download failed.', + ], '3.0.0')); + + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + + $job->refresh(); + expect($job->error)->toStartWith('Error: Download failed. (WordPress upgrader error;') + ->and($job->error)->not->toContain('Downloading') + ->and($job->error)->not->toContain('updates.example.com') + ->and($job->reason_code)->toBe('upgrader_error'); + }); + + it('keeps the "no longer offered" explanation, as reason not_offered, when the upgrader logged no error', function () { + $site = makeCompanionSite(); + $job = PluginUpdateJob::factory()->for($site)->create([ + 'target_slug' => 'js_composer/js_composer.php', + 'before_version' => '8.7.2', + 'target_version' => '9.0.1', + 'batch_id' => (string) Str::uuid(), + ]); + + fakeUpdateAndVerify($site, stalledUpgraderResponse(['Plugin is already up to date.'], '8.7.2')); + + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + + $job->refresh(); + expect($job->status)->toBe(PluginUpdateJob::STATUS_FAILED) + ->and($job->reason_code)->toBe('not_offered') + ->and($job->error)->toContain("WordPress's own update-checker no longer offered this update") + ->and($job->error)->toContain('still on 8.7.2, expected 9.0.1'); + + // Logged as the failure it is, not as "Plugin updated: 8.7.2 → 8.7.2". + $log = ActionLog::where('action_type', ActionLog::TYPE_PLUGIN_UPDATE)->where('site_id', $site->id)->first(); + expect($log->ok)->toBeFalse() + ->and($log->summary)->toStartWith('Plugin update failed:'); + }); + + it('does not fail an update that reached its target just because an Error: line is present', function () { + $site = makeCompanionSite(); + $job = PluginUpdateJob::factory()->for($site)->create([ + 'target_slug' => 'sample-plugin/sample-plugin.php', + 'before_version' => '1.0.0', + 'target_version' => '1.1.0', + 'batch_id' => (string) Str::uuid(), + ]); + + fakeUpdateAndVerify($site, array_merge(stalledUpgraderResponse([ + licensedDownloadLine(), + 'Error: Could not remove the old translation file.', + 'Plugin updated successfully.', + ]), ['after_version' => '1.1.0'])); + + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + + $job->refresh(); + expect($job->status)->toBe(PluginUpdateJob::STATUS_COMPLETE) + ->and($job->error)->toBeNull() + ->and($job->reason_code)->toBeNull() + ->and(json_encode($job->messages))->not->toContain(PIPELINE_FAKE_LICENSE); + }); + + it('keeps Companion\'s own error when it reports ok=false, but still records the upgrader reason code', function () { + $site = makeCompanionSite(); + $job = PluginUpdateJob::factory()->for($site)->create([ + 'target_slug' => 'sample-plugin/sample-plugin.php', + 'before_version' => '1.0.0', + 'target_version' => '1.1.0', + 'batch_id' => (string) Str::uuid(), + ]); + + fakeUpdateAndVerify($site, array_merge(stalledUpgraderResponse([ + licensedDownloadLine(), + 'Unpacking the update…', + 'Could not create directory. /var/www/html/wp-content/upgrade/sample-plugin_1.1.0-AbC12d-x9Yz', + 'Error: Could not create directory.', + ]), ['ok' => false, 'upgrade_completed' => false, 'error' => 'Could not create directory.'])); + + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + + $job->refresh(); + expect($job->status)->toBe(PluginUpdateJob::STATUS_FAILED) + ->and($job->error)->toBe('Could not create directory.') + ->and($job->reason_code)->toBe('fs_permission'); + }); + + it('redacts URLs quoted in a transport exception message', function () { + $site = makeCompanionSite(); + $job = PluginUpdateJob::factory()->for($site)->create([ + 'target_slug' => 'sample-plugin/sample-plugin.php', + 'batch_id' => (string) Str::uuid(), + ]); + + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/plugins/update*" => function () { + throw new ConnectionException( + 'cURL error 28 while fetching https://updates.example.com/pkg.zip?license='.PIPELINE_FAKE_LICENSE + ); + }, + ]); + + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + + $job->refresh(); + expect($job->status)->toBe(PluginUpdateJob::STATUS_FAILED) + ->and($job->error)->toContain('cURL error 28') + ->and($job->error)->not->toContain(PIPELINE_FAKE_LICENSE); + + $log = ActionLog::where('site_id', $site->id)->first(); + expect($log->summary.$log->error)->not->toContain(PIPELINE_FAKE_LICENSE); + }); +}); + describe('AbstractRunUpdate per-site locking', function () { it('re-queues with a 15s delay instead of running when another update already holds the site lock', function () { $site = makeCompanionSite(); diff --git a/tests/Feature/Migrations/BackfillIsWordpressOnCustomCompanionSitesTest.php b/tests/Feature/Migrations/BackfillIsWordpressOnCustomCompanionSitesTest.php new file mode 100644 index 00000000..1de2b57b --- /dev/null +++ b/tests/Feature/Migrations/BackfillIsWordpressOnCustomCompanionSitesTest.php @@ -0,0 +1,53 @@ +create($attributes); + DB::table('sites')->where('id', $site->id)->update(['is_wordpress' => false]); + + return $site; +} + +function runIsWordpressBackfill(): void +{ + $migration = require database_path('migrations/2026_11_03_120300_backfill_is_wordpress_on_custom_companion_sites.php'); + $migration->up(); +} + +describe('is_wordpress backfill for standalone Companion sites', function () { + $snapshot = ['wp_core' => ['current_version' => '6.8.3'], 'plugins' => ['plugins' => []]]; + + it('flags custom-host sites whose Companion snapshot reports a WordPress core', function () use ($snapshot) { + $enrolled = legacyNonWordpressSite(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null, 'spinupwp_id' => null, 'companion_installed' => true, 'companion_snapshot' => $snapshot]); + $archived = legacyNonWordpressSite(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null, 'spinupwp_id' => null, 'companion_installed' => true, 'companion_snapshot' => $snapshot, 'archived_at' => now()]); + + runIsWordpressBackfill(); + + expect((bool) DB::table('sites')->where('id', $enrolled->id)->value('is_wordpress'))->toBeTrue() + ->and((bool) DB::table('sites')->where('id', $archived->id)->value('is_wordpress'))->toBeTrue(); + }); + + it('leaves sites it has no proof for alone', function () use ($snapshot) { + $noCore = legacyNonWordpressSite(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null, 'spinupwp_id' => null, 'companion_installed' => true, 'companion_snapshot' => ['plugins' => ['plugins' => []]]]); + $noSnapshot = legacyNonWordpressSite(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null, 'spinupwp_id' => null, 'companion_installed' => true, 'companion_snapshot' => null]); + $noCompanion = legacyNonWordpressSite(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null, 'spinupwp_id' => null, 'companion_installed' => false, 'companion_snapshot' => $snapshot]); + $otherHost = legacyNonWordpressSite(['companion_installed' => true, 'companion_snapshot' => $snapshot]); + + runIsWordpressBackfill(); + + foreach ([$noCore, $noSnapshot, $noCompanion, $otherHost] as $site) { + expect((bool) DB::table('sites')->where('id', $site->id)->value('is_wordpress'))->toBeFalse(); + } + }); +}); diff --git a/tests/Feature/Migrations/RepairExactVersionPluginVulnerabilitiesTest.php b/tests/Feature/Migrations/RepairExactVersionPluginVulnerabilitiesTest.php new file mode 100644 index 00000000..7e6941b1 --- /dev/null +++ b/tests/Feature/Migrations/RepairExactVersionPluginVulnerabilitiesTest.php @@ -0,0 +1,76 @@ +insertGetId([ + 'wordfence_id' => $id, + 'slug' => 'example-plugin', + 'software_type' => 'plugin', + 'from_version' => $from, + 'from_inclusive' => true, + 'to_version' => $to, + 'to_inclusive' => $toInclusive, + 'patched_in' => $patchedIn, + 'title' => $title, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function runExactVersionRepair(): void +{ + $migration = require database_path('migrations/2026_11_05_120000_repair_exact_version_plugin_vulnerabilities.php'); + $migration->up(); +} + +function vulnRange(int $id): array +{ + $row = DB::table('plugin_vulnerabilities')->where('id', $id)->first(); + + return [$row->from_version, (bool) $row->from_inclusive, $row->to_version, (bool) $row->to_inclusive]; +} + +describe('exact-version vulnerability repair', function () { + it('turns rows stored as "<= X" for a "== X" advisory into exact ranges', function () { + $plain = legacyVulnRow('a', null, '3.0.123', true, 'Example Plugin [example-plugin] == 3.0.123'); + $unfixed = legacyVulnRow('b', null, '7.1.0', true, 'Example Plugin – Shop [example-plugin] == 7.1.0 (unfixed)'); + + runExactVersionRepair(); + + expect(vulnRange($plain))->toBe(['3.0.123', true, '3.0.123', true]) + ->and(vulnRange($unfixed))->toBe(['7.1.0', true, '7.1.0', true]); + }); + + it('leaves real ranges and rows the title does not prove alone', function () { + $upTo = legacyVulnRow('c', null, '2.0.6', true, 'Example Plugin [example-plugin] <= 2.0.6'); + $below = legacyVulnRow('d', null, '3.0.97', false, 'Example Plugin [example-plugin] < 3.0.97', '3.0.97'); + $window = legacyVulnRow('e', '1.0.0', '1.4.0', true, 'Example Plugin [example-plugin] >= 1.0.0 - <= 1.4.0'); + $otherVersion = legacyVulnRow('f', null, '5.0.0', true, 'Example Plugin [example-plugin] == 4.9.0'); + + runExactVersionRepair(); + + expect(vulnRange($upTo))->toBe([null, true, '2.0.6', true]) + ->and(vulnRange($below))->toBe([null, true, '3.0.97', false]) + ->and(vulnRange($window))->toBe(['1.0.0', true, '1.4.0', true]) + ->and(vulnRange($otherVersion))->toBe([null, true, '5.0.0', true]); + }); + + it('drops the broken copy when the exact row already exists', function () { + $exact = legacyVulnRow('g', '3.0.123', '3.0.123', true, 'Example Plugin [example-plugin] == 3.0.123'); + $broken = legacyVulnRow('g', null, '3.0.123', true, 'Example Plugin [example-plugin] == 3.0.123'); + + runExactVersionRepair(); + + expect(DB::table('plugin_vulnerabilities')->where('id', $broken)->exists())->toBeFalse() + ->and(vulnRange($exact))->toBe(['3.0.123', true, '3.0.123', true]); + }); +}); diff --git a/tests/Feature/Models/SiteIsWordpressSelfHealTest.php b/tests/Feature/Models/SiteIsWordpressSelfHealTest.php new file mode 100644 index 00000000..e3062bb2 --- /dev/null +++ b/tests/Feature/Models/SiteIsWordpressSelfHealTest.php @@ -0,0 +1,50 @@ +custom()->withCompanionInstalled()->create(['is_wordpress' => false]); + expect($site->fresh()->is_wordpress)->toBeFalse(); + + $site->forceFill([ + 'companion_snapshot' => ['wp_core' => ['current_version' => '6.8.3'], 'plugins' => ['plugins' => []]], + 'companion_snapshot_at' => now(), + ])->save(); + + expect($site->fresh()->is_wordpress)->toBeTrue(); + }); + + it('sets it on create too', function () { + $site = Site::factory()->custom()->create([ + 'is_wordpress' => false, + 'companion_snapshot' => ['wp_core' => ['current_version' => '6.8.3']], + ]); + + expect($site->fresh()->is_wordpress)->toBeTrue(); + }); + + it('leaves a site without a WordPress core in its snapshot alone', function () { + $site = Site::factory()->custom()->create(['is_wordpress' => false]); + + $site->forceFill(['companion_snapshot' => ['plugins' => ['plugins' => []]]])->save(); + + expect($site->fresh()->is_wordpress)->toBeFalse(); + }); + + it('never clears the flag', function () { + $site = Site::factory()->create(['is_wordpress' => true, 'companion_snapshot' => null]); + + $site->forceFill(['notes' => 'touched'])->save(); + + expect($site->fresh()->is_wordpress)->toBeTrue(); + }); +}); diff --git a/tests/Feature/Modules/ClientReportAccuracyTest.php b/tests/Feature/Modules/ClientReportAccuracyTest.php index 327a2634..c95c7593 100644 --- a/tests/Feature/Modules/ClientReportAccuracyTest.php +++ b/tests/Feature/Modules/ClientReportAccuracyTest.php @@ -1,8 +1,11 @@ , html: string, text: string} */ +/** @return array{data: array, html: string, text: string, preview: string, public: string, reasons: list} */ function compileSeptember(Site $site): array { $start = Carbon::parse('2026-09-01'); @@ -69,9 +82,40 @@ function compileSeptember(Site $site): array 'data' => $data, 'html' => $mail->render(), 'text' => view($content->text, $content->with)->render(), + 'preview' => view('client-reports::reports.show', ['report' => $report, 'isOperatorPreview' => true])->render(), + 'public' => view('client-reports::reports.show', ['report' => $report, 'isOperatorPreview' => false])->render(), + 'reasons' => ReportReviewFlags::reasons($data), ]; } +/** + * A Companion malware scan with the details the scanner records. + * + * @param list $findings + * @param array $details + */ +function companionMalwareScan(Site $site, string $at, string $status, array $findings = [], array $details = []): SiteSecurityScan +{ + return SiteSecurityScan::factory()->create([ + 'site_id' => $site->id, + 'scan_type' => SiteSecurityScan::TYPE_COMPANION_MALWARE, + 'scanned_at' => Carbon::parse($at), + 'status' => $status, + 'details' => array_replace([ + 'transport' => 'companion', 'findings' => $findings, 'scanned_files_count' => 12000, + 'scan_aborted' => false, 'abort_reason' => null, + ], $details), + ]); +} + +/** A snapshot with Limit Login Attempts Reloaded active. */ +function llarSnapshot(): array +{ + return ['plugins' => ['plugins' => [ + ['slug' => 'limit-login-attempts-reloaded/limit-login-attempts-reloaded.php', 'active' => true], + ]]]; +} + describe('known plugin vulnerabilities (#8)', function () { it('counts real matches from the vulnerability feed instead of always reporting none', function () { PluginVulnerability::factory()->create(['slug' => 'leaky-forms', 'to_version' => '2.0.0']); @@ -89,8 +133,11 @@ function compileSeptember(Site $site): array 'vulnerable_plugins' => ['Leaky Forms'], 'vulnerabilities_as_of' => '2026-10-01', ]); - expect($report['html'])->toContain('1 found in 1 plugin')->toContain('(as of Oct 1)')->not->toContain('None found'); - expect($report['text'])->toContain('Known plugin vulnerabilities (as of Oct 1): 1 found in 1 plugin'); + // The date is when the plugin list was checked (the snapshot date), so + // it says "checked", and the client is told which plugin it is. + expect($report['html'])->toContain('1 found in 1 plugin')->toContain('(checked Oct 1)')->not->toContain('as of') + ->toContain('Plugin affected: Leaky Forms')->not->toContain('None found'); + expect($report['text'])->toContain("Known plugin vulnerabilities (checked Oct 1): 1 found in 1 plugin\nPlugin affected: Leaky Forms\n"); }); it('says "None found" only when the plugins were actually checked', function () { @@ -123,6 +170,86 @@ function compileSeptember(Site $site): array ->and($report['html'])->not->toContain('None found'); }); + it('records each vulnerable plugin by its file slug, with the version that fixes it, the update offered and any pause', function () { + PluginVulnerability::factory()->create(['slug' => 'example-forms', 'to_version' => '2.0.9', 'patched_in' => '2.1.0']); + PluginVulnerability::factory()->create(['slug' => 'example-forms', 'to_version' => '1.9.0', 'patched_in' => '1.9.1']); + PluginVulnerability::factory()->create(['slug' => 'example-gallery', 'to_version' => '*', 'patched_in' => '2.0.0']); + PluginVulnerability::factory()->create(['slug' => 'example-gallery', 'to_version' => '*', 'patched_in' => null]); + $site = accuracySite(['companion_snapshot' => ['plugins' => ['plugins' => [ + ['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'version' => '1.5.0', 'active' => true, 'update_available' => false], + // A backup copy with the same name and an update waiting: not the vulnerable plugin. + ['slug' => 'example-forms-old/example-forms.php', 'name' => 'Example Forms', 'version' => '1.0.0', 'active' => false, 'update_available' => true, 'new_version' => '2.1.0'], + ['slug' => 'example-gallery/example-gallery.php', 'name' => 'Example Gallery', 'version' => '3.0', 'active' => true, 'update_available' => true, 'new_version' => '3.1'], + ]]]]); + PluginUpdateIgnore::factory()->create([ + 'site_id' => $site->id, + 'target_slug' => 'example-gallery/example-gallery.php', + 'source' => PluginUpdateIgnore::SOURCE_AUTO_FAILURE, + 'failure_count' => 3, + 'ignored_at' => Carbon::parse('2026-09-25 04:00'), + ]); + + $report = compileSeptember($site); + + expect($report['data']['security']['vulnerable_plugin_items'])->toBe([ + ['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'version' => '1.5.0', 'fixed_in' => '2.1.0', 'fixed_after' => null, 'update_offered' => null, 'paused' => null], + ['slug' => 'example-gallery/example-gallery.php', 'name' => 'Example Gallery', 'version' => '3.0', 'fixed_in' => null, 'fixed_after' => null, 'update_offered' => '3.1', 'paused' => [ + 'source' => 'auto_failure', 'since' => '2026-09-25', 'failure_count' => 3, 'note' => null, + ]], + ]) + ->and($report['data']['updates']['pending_plugin_items'])->toBe([ + ['slug' => 'example-forms-old/example-forms.php', 'name' => 'Example Forms', 'new_version' => '2.1.0'], + ['slug' => 'example-gallery/example-gallery.php', 'name' => 'Example Gallery', 'new_version' => '3.1'], + ]) + // Matching by name would have said the vulnerable Example Forms had an update waiting. + ->and($report['reasons'])->toContain('Updates are paused for a plugin with a known vulnerability: Example Gallery 3.0 (no fixed version yet; auto-paused on 2026-09-25 after 3 failed attempts; 3.1 is waiting).') + // (The plugins still waiting are named once, in the "no updates applied" reason.) + ->and(implode(' ', array_filter($report['reasons'], fn ($r) => ! str_starts_with($r, 'No updates were applied'))))->not->toContain('Example Forms'); + }); + + it('holds the report when a waiting update reaches the fixed version, but not when it falls short', function () { + PluginVulnerability::factory()->create(['slug' => 'example-forms', 'to_version' => '1.1.9', 'patched_in' => '1.2.0']); + PluginVulnerability::factory()->create(['slug' => 'example-slider', 'to_version' => '2.0.0', 'to_inclusive' => false, 'patched_in' => '2.0.1']); + $site = accuracySite(['companion_snapshot' => ['plugins' => ['plugins' => [ + ['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'version' => '1.0.0', 'active' => true, 'update_available' => true, 'new_version' => '1.2.0'], + ['slug' => 'example-slider/example-slider.php', 'name' => 'Example Slider', 'version' => '1.4.0', 'active' => true, 'update_available' => true, 'new_version' => '1.5.0'], + ]]]]); + + $reasons = compileSeptember($site)['reasons']; + + expect($reasons)->toContain("A plugin with a known vulnerability has an update waiting that fixes it, but it wasn't applied: Example Forms 1.0.0 → 1.2.0 (fixed in 1.2.0).") + ->and(implode(' ', array_filter($reasons, fn ($r) => ! str_starts_with($r, 'No updates were applied'))))->not->toContain('Example Slider'); + }); + + it('reads an advisory with no patched version but a closed upper bound as fixed in the next version after it', function () { + // Affects up to and including 5.2.4, no patched version named: 5.2.5 on is clear. + PluginVulnerability::factory()->create(['slug' => 'example-widgets', 'to_version' => '5.2.4', 'to_inclusive' => true, 'patched_in' => null]); + PluginVulnerability::factory()->create(['slug' => 'example-widgets', 'to_version' => '4.5.0', 'to_inclusive' => false, 'patched_in' => '4.5.1']); + // An advisory for exactly one version (stored from = to): any later release fixes it. + PluginVulnerability::factory()->create(['slug' => 'example-seo-pro', 'from_version' => '4.1.7', 'to_version' => '4.1.7', 'to_inclusive' => true, 'patched_in' => null]); + // Affects versions below 3.0 (exclusive), no patched version named: 3.0 fixes it. + PluginVulnerability::factory()->create(['slug' => 'example-tabs', 'to_version' => '3.0', 'to_inclusive' => false, 'patched_in' => null]); + $site = accuracySite(['companion_snapshot' => ['plugins' => ['plugins' => [ + ['slug' => 'example-widgets/example-widgets.php', 'name' => 'Example Widgets', 'version' => '4.1.0', 'active' => true, 'update_available' => true, 'new_version' => '4.9.30'], + ['slug' => 'example-tabs/example-tabs.php', 'name' => 'Example Tabs', 'version' => '2.2', 'active' => true, 'update_available' => true, 'new_version' => '3.0'], + ['slug' => 'example-seo-pro/example-seo-pro.php', 'name' => 'Example SEO Pro', 'version' => '4.1.7', 'active' => true, 'update_available' => true, 'new_version' => '4.1.8'], + ]]]]); + PluginUpdateIgnore::factory()->create([ + 'site_id' => $site->id, 'target_slug' => 'example-widgets/example-widgets.php', 'source' => PluginUpdateIgnore::SOURCE_MANUAL, + 'note' => 'Licence lapsed', 'ignored_at' => Carbon::parse('2026-09-02 04:00'), + ]); + + $report = compileSeptember($site); + $items = collect($report['data']['security']['vulnerable_plugin_items'])->keyBy('slug'); + + expect($items['example-widgets/example-widgets.php'])->toMatchArray(['fixed_in' => null, 'fixed_after' => '5.2.4']) + ->and($items['example-tabs/example-tabs.php'])->toMatchArray(['fixed_in' => '3.0', 'fixed_after' => null]) + ->and($items['example-seo-pro/example-seo-pro.php'])->toMatchArray(['fixed_in' => null, 'fixed_after' => '4.1.7']) + ->and($report['reasons'])->toContain('Updates are paused for a plugin with a known vulnerability: Example Widgets 4.1.0 (fixed in the next version after 5.2.4; paused by hand since 2026-09-02: Licence lapsed; 4.9.30 is waiting).') + ->toContain("A plugin with a known vulnerability has an update waiting that fixes it, but it wasn't applied: Example Tabs 2.2 → 3.0 (fixed in 3.0); Example SEO Pro 4.1.7 → 4.1.8 (fixed in the next version after 4.1.7).") + ->and(implode(' ', $report['reasons']))->not->toContain('no fixed version yet'); + }); + it('notes how many matches are in inactive plugins', function () { PluginVulnerability::factory()->create(['slug' => 'dormant', 'to_version' => '2.0.0']); $site = accuracySite(['companion_snapshot' => ['plugins' => ['plugins' => [ @@ -223,7 +350,8 @@ function recordCheck(Site $site, bool $ok, ?Carbon $at = null): void $report = compileSeptember($site); expect($report['data']['backups']['recent'][0])->toMatchArray(['kinds' => ['db', 'full'], 'bytes' => 500 * 1024 ** 2]) - ->and($report['html'])->toContain('by SpinupWP, stored in DigitalOcean Spaces')->toContain('Files & database')->toContain('500 MB'); + ->and($report['data']['backups']['destination'])->toBe('by SpinupWP to DigitalOcean Spaces') + ->and($report['html'])->toContain('by SpinupWP to DigitalOcean Spaces')->toContain('Files & database')->toContain('500 MB'); }); it('flags backups that stopped before the last 10 days instead of showing them to the client', function () { @@ -256,6 +384,97 @@ function recordCheck(Site $site, bool $ok, ?Carbon $at = null): void ->not->toContain('Backed up on'); }); + it('holds database backups far smaller than the database instead of showing them to the client', function () { + $site = accuracySite([ + 'hosting_provider' => Site::HOSTING_PROVIDER_SPINUPWP, + 'companion_snapshot' => ['plugins' => ['plugins' => []], 'environment' => ['db_size_bytes' => 3 * 1024 ** 3]], + ]); + foreach (range(1, 30) as $day) { + SiteBackup::create([ + 'site_id' => $site->id, 'source' => SiteBackup::SOURCE_SPINUPWP_SPACES, 'kind' => SiteBackup::KIND_DATABASE, + 'external_ref' => "db-{$day}", 'backup_at' => Carbon::parse(sprintf('2026-09-%02d 07:00', $day)), 'bytes' => 9200, + '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', 'verified' => false, 'days_with_backups' => 30, 'db_backup_bytes' => 9200, 'db_size_bytes' => 3 * 1024 ** 3, + ]) + ->and($report['html'])->not->toContain('backed up') + ->and($report['text'])->not->toContain('BACKUPS') + ->and($report['preview'])->toContain('Backups hidden from the client: the newest database backup is far smaller than the database (9 KB for 3.0 GB).') + ->and($report['reasons'])->toContain('Database backups are 9 KB but the database is 3.0 GB, so they look empty or broken and the Backups section was left out.'); + }); + + it('keeps database backups of a plausible size, and a small database\'s small backups', function (int $dbBytes, int $backupBytes) { + $site = accuracySite([ + 'hosting_provider' => Site::HOSTING_PROVIDER_PRESSABLE, 'pressable_site_id' => (string) random_int(1000, 999999), + 'companion_snapshot' => ['plugins' => ['plugins' => []], 'environment' => ['db_size_bytes' => $dbBytes]], + ]); + foreach (range(1, 30) as $day) { + recordBackup($site, SiteBackup::KIND_DATABASE, sprintf('2026-09-%02d 23:00:00', $day), $backupBytes); + } + recordCheck($site, true); + + expect(compileSeptember($site)['data']['backups']['status'])->toBe('verified'); + })->with([ + 'compressed to a few percent' => [100 * 1024 ** 2, 3 * 1024 ** 2], + 'a small database' => [5 * 1024 ** 2, 60 * 1024], + ]); + + it('draws the line at about 1% of the database', function () { + expect(ClientReportCompiler::backupTooSmall(9 * 1024, 3 * 1024 ** 3))->toBeTrue() + ->and(ClientReportCompiler::backupTooSmall(500 * 1024, 100 * 1024 ** 2))->toBeTrue() + ->and(ClientReportCompiler::backupTooSmall(90 * 1024, 11 * 1024 ** 2))->toBeTrue() + ->and(ClientReportCompiler::backupTooSmall(2 * 1024 ** 2, 100 * 1024 ** 2))->toBeFalse() + // Under 100 KB is fine for a database that small. + ->and(ClientReportCompiler::backupTooSmall(90 * 1024, 8 * 1024 ** 2))->toBeFalse(); + }); + + it('holds a month a daily source backed up on far fewer days than it had', function () { + $site = pressableBackupSite(); + recordBackup($site, SiteBackup::KIND_FILES, '2026-08-20 00:00:00'); // records go back before the period + foreach (range(15, 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', 'covers_period' => true, 'daily' => true, 'days_with_backups' => 16, 'period_days' => 30]) + ->and($report['reasons'])->toContain('Backed up on only 16 of 30 days this month.') + // The client still reads the plain count, never a promise of daily backups. + ->and($report['html'])->toContain('backed up on 16 days this month')->not->toContain('daily'); + }); + + 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) { + SiteBackup::create([ + 'site_id' => $weekly->id, 'source' => SiteBackup::SOURCE_SPINUPWP_SPACES, 'kind' => SiteBackup::KIND_FULL, + 'external_ref' => $at, 'backup_at' => Carbon::parse($at.' 08:00'), 'bytes' => 500 * 1024 ** 2, + 'is_manual' => false, 'first_seen_at' => now(), 'last_seen_at' => now(), + ]); + } + SiteBackupCheck::create(['site_id' => $weekly->id, 'source' => SiteBackup::SOURCE_SPINUPWP_SPACES, 'checked_at' => now(), 'ok' => true]); + + $nearlyEveryDay = pressableBackupSite(); + foreach (range(1, 30) as $day) { + if (! in_array($day, [5, 12, 19], true)) { + recordBackup($nearlyEveryDay, SiteBackup::KIND_FILES, sprintf('2026-09-%02d 00:00:00', $day)); + } + } + recordCheck($nearlyEveryDay, true); + + $weeklyReport = compileSeptember($weekly); + expect($weeklyReport['data']['backups'])->toMatchArray(['status' => 'verified', 'daily' => false, 'days_with_backups' => 4]) + ->and(implode(' ', $weeklyReport['reasons']))->not->toContain('Backed up on only') + ->and(implode(' ', compileSeptember($nearlyEveryDay)['reasons']))->not->toContain('Backed up on only'); + }); + it('treats a failed or stale check as "could not check", not as no backups', function () { $failing = pressableBackupSite(); recordCheck($failing, false); @@ -267,106 +486,780 @@ function recordCheck(Site $site, bool $ok, ?Carbon $at = null): void }); }); +describe('backups from our relay that keeps failing', function () { + /** A standalone site whose off-site backups come from our relay (S3 archives). */ + function failingRelaySite(array $attributes = []): Site + { + return accuracySite(array_merge([ + 'hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null, 'backup_relay_enabled' => true, + ], $attributes)); + } + + function failingRelayArchive(Site $site, string $at): void + { + SiteBackup::create([ + 'site_id' => $site->id, 'source' => SiteBackup::SOURCE_S3_ARCHIVE, 'kind' => SiteBackup::KIND_FULL, + 'external_ref' => 'archive-'.$at, 'backup_at' => Carbon::parse($at), 'bytes' => 80 * 1024 ** 2, + 'is_manual' => false, 'first_seen_at' => now(), 'last_seen_at' => now(), + ]); + } + + it('reports "failing" with the relay\'s error, hidden from the client and held for review', function () { + $site = failingRelaySite([ + 'backup_relay_failing_since' => Carbon::parse('2026-09-20 05:10:00'), + 'backup_relay_last_attempt_at' => Carbon::parse('2026-10-02 05:10:00'), + 'backup_relay_last_error' => "BackupRelay: example.com: upload to https://storage.example.com/bucket/site.tar.gz?X-Amz-Signature=abc123&X-Amz-Credential=key failed\n with HTTP 403", + ]); + SiteBackupCheck::create(['site_id' => $site->id, 'source' => SiteBackup::SOURCE_S3_ARCHIVE, 'checked_at' => now(), 'ok' => true]); + + $report = compileSeptember($site); + $backups = $report['data']['backups']; + + expect($backups)->toMatchArray([ + 'source' => SiteBackup::SOURCE_S3_ARCHIVE, 'status' => 'failing', 'verified' => false, 'days_with_backups' => 0, + 'relay_failing_since' => '2026-09-20T05:10:00+00:00', + 'relay_error' => 'BackupRelay: example.com: upload to https://storage.example.com/bucket/site.tar.gz failed with HTTP 403', + ]) + ->and(json_encode($report['data']))->not->toContain('X-Amz')->not->toContain('abc123') + ->and($report['reasons'])->toContain('Off-site backups have been failing since Sep 20, 2026: BackupRelay: example.com: upload to https://storage.example.com/bucket/site.tar.gz failed with HTTP 403.') + ->and($report['html'])->not->toContain('backed up')->not->toContain('BackupRelay') + ->and($report['text'])->not->toContain('BACKUPS')->not->toContain('BackupRelay') + ->and($report['preview'])->toContain('Backups hidden from the client: no off-site archive this period; the backup relay has been failing since Sep 20, 2026.') + ->toContain('>Failing') + ->toContain('Last relay error: BackupRelay: example.com') + ->and($report['public'])->not->toContain('Failing')->not->toContain('BackupRelay')->not->toContain('Backups'); + }); + + it('keeps no more than about 200 characters of the error', function () { + $site = failingRelaySite(['backup_relay_failing_since' => Carbon::parse('2026-09-01 05:00:00'), 'backup_relay_last_error' => str_repeat('Connection reset by peer. ', 40)]); + + $error = compileSeptember($site)['data']['backups']['relay_error']; + + expect(mb_strlen($error))->toBeLessThanOrEqual(ClientReportCompiler::RELAY_ERROR_MAX_LENGTH + 3) + ->and($error)->toStartWith('Connection reset by peer.')->toEndWith('...'); + }); + + it('says nothing about a relay that only started failing after the period', function () { + $site = failingRelaySite(['backup_relay_failing_since' => Carbon::parse('2026-10-02 05:00:00'), 'backup_relay_last_error' => 'Example timeout']); + SiteBackupCheck::create(['site_id' => $site->id, 'source' => SiteBackup::SOURCE_S3_ARCHIVE, 'checked_at' => now(), 'ok' => true]); + + $report = compileSeptember($site); + + expect($report['data']['backups'])->toMatchArray(['status' => 'none_found', 'relay_failing_since' => null, 'relay_error' => null]) + ->and(implode(' ', $report['reasons']))->not->toContain('failing'); + }); + + it('names the failure when relay archives stopped part-way through the period', function () { + $site = failingRelaySite(['backup_relay_failing_since' => Carbon::parse('2026-09-12 05:00:00'), 'backup_relay_last_error' => 'Example lacks the backup-create capability.']); + foreach (range(1, 11) as $day) { + failingRelayArchive($site, sprintf('2026-09-%02d 05:00:00', $day)); + } + + $report = compileSeptember($site); + + expect($report['data']['backups']['status'])->toBe('stale') + ->and($report['reasons'])->toContain('Backups stopped before the last 10 days of the month. Off-site backups have been failing since Sep 12, 2026: Example lacks the backup-create capability.'); + }); + + it('still shows archives made in the last days of the period, even if the relay fails now', function () { + $site = failingRelaySite(['backup_relay_failing_since' => Carbon::parse('2026-09-30 05:00:00'), 'backup_relay_last_error' => 'Example timeout']); + foreach (range(1, 29) as $day) { + failingRelayArchive($site, sprintf('2026-09-%02d 05:00:00', $day)); + } + failingRelayArchive($site, '2026-08-31 05:00:00'); + + $report = compileSeptember($site); + + expect($report['data']['backups'])->toMatchArray(['status' => 'verified', 'verified' => true, 'days_with_backups' => 29]) + ->and(implode(' ', $report['reasons']))->not->toContain('failing'); + }); + + it('holds a report whose relay archives only began at the end of the month for a site enrolled before it', function () { + $site = failingRelaySite(['created_at' => Carbon::parse('2026-08-15 12:00:00')]); + foreach ([28, 29, 30] as $day) { + failingRelayArchive($site, sprintf('2026-09-%02d 05:00:00', $day)); + } + + $report = compileSeptember($site); + + expect($report['data']['backups'])->toMatchArray([ + 'status' => 'verified', 'covers_period' => false, 'days_with_backups' => 3, + 'archives_began' => '2026-09-28', 'expected_from' => '2026-09-01', + ]) + ->and($report['reasons'])->toContain('Off-site backups began on Sep 28, 2026; none were made before that this month, although the site was enrolled earlier (backed up on 3 of 30 days).') + ->and($report['preview'])->toContain('Off-site backups began on 2026-09-28; none were made before that this period') + ->and($report['public'])->not->toContain('Off-site backups began'); + }); + + it('counts the days a site joined part-way through the month was expected to have backups, not the whole month', function () { + $site = failingRelaySite(['created_at' => Carbon::parse('2026-09-27 12:00:00')]); + failingRelayArchive($site, '2026-09-30 05:00:00'); + + $report = compileSeptember($site); + + // Sep 27 – Sep 30. + expect($report['data']['backups'])->toMatchArray(['archives_began' => '2026-09-30', 'expected_from' => '2026-09-27', 'expected_days' => 4, 'period_days' => 30]) + ->and($report['reasons'])->toContain('Off-site backups began on Sep 30, 2026; none were made before that this month, although the site was enrolled earlier (backed up on 1 of 4 days).'); + }); + + it('does not flag relay archives that began the day after a site joined part-way through the month', function () { + $site = failingRelaySite(['created_at' => Carbon::parse('2026-09-15 12:00:00')]); + foreach (range(16, 30) as $day) { + failingRelayArchive($site, sprintf('2026-09-%02d 05:00:00', $day)); + } + + $report = compileSeptember($site); + + expect($report['data']['backups'])->toMatchArray(['status' => 'verified', 'archives_began' => null]) + ->and(implode(' ', $report['reasons']))->not->toContain('Off-site backups began')->not->toContain('Backed up on only'); + }); + + it('keeps the plain low-count flag for relay archives that reach back before the period', function () { + $site = failingRelaySite(['created_at' => Carbon::parse('2026-08-15 12:00:00')]); + failingRelayArchive($site, '2026-08-20 05:00:00'); + foreach ([28, 29, 30] as $day) { + failingRelayArchive($site, sprintf('2026-09-%02d 05:00:00', $day)); + } + + $report = compileSeptember($site); + + expect($report['data']['backups'])->toMatchArray(['covers_period' => true, 'archives_began' => null]) + ->and($report['reasons'])->toContain('Backed up on only 3 of 30 days this month.'); + }); + + it('still treats a host\'s records that start mid-month as where our records start', function () { + $site = pressableBackupSite(); + $site->update(['created_at' => Carbon::parse('2026-08-15 12:00:00')]); + foreach (range(24, 30) as $day) { + recordBackup($site, SiteBackup::KIND_FILES, sprintf('2026-09-%02d 03:00:00', $day)); + } + recordCheck($site, true); + + $report = compileSeptember($site->fresh()); + + expect($report['data']['backups'])->toMatchArray(['status' => 'verified', 'covers_period' => false, 'archives_began' => null]) + ->and(implode(' ', $report['reasons']))->not->toContain('Off-site backups began'); + }); + + it('ignores relay state on a site whose backups come from its host', function () { + $site = pressableBackupSite(); + $site->update(['backup_relay_failing_since' => Carbon::parse('2026-09-05'), 'backup_relay_last_error' => 'Example timeout']); + recordCheck($site, true); + + expect(compileSeptember($site->fresh())['data']['backups'])->toMatchArray(['status' => 'none_found', 'relay_failing_since' => null]); + }); +}); + describe('WordPress core file check (#5)', function () { it('reports "Not checked" instead of "Ok" when no checksum scan ran in the period', function () { $site = accuracySite(); - // Outside the period, so it must not count. - SiteSecurityScan::factory()->create([ - 'site_id' => $site->id, - 'scan_type' => SiteSecurityScan::TYPE_CORE_CHECKSUMS, - 'scanned_at' => Carbon::parse('2026-08-15'), - ]); + // Outside the period, so it must not count. + SiteSecurityScan::factory()->create([ + 'site_id' => $site->id, + 'scan_type' => SiteSecurityScan::TYPE_CORE_CHECKSUMS, + 'scanned_at' => Carbon::parse('2026-08-15'), + ]); + + $report = compileSeptember($site); + + expect($report['data']['security']['checksum_status'])->toBeNull() + ->and($report['html'])->toContain('Not checked this month')->not->toMatch('/>\s*Ok\s*and($report['text'])->toContain('WordPress core files: Not checked this month'); + }); + + it('uses the latest completed checksum scan inside the period', function () { + $site = accuracySite(); + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_CORE_CHECKSUMS, 'scanned_at' => Carbon::parse('2026-09-10'), 'status' => SiteSecurityScan::STATUS_ISSUES_FOUND]); + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_CORE_CHECKSUMS, 'scanned_at' => Carbon::parse('2026-09-20'), 'status' => SiteSecurityScan::STATUS_CLEAN]); + // A later scan that never completed is not evidence either way. + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_CORE_CHECKSUMS, 'scanned_at' => Carbon::parse('2026-09-25'), 'status' => SiteSecurityScan::STATUS_FAILED]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['checksum_status' => 'clean', 'checksum_checked_at' => '2026-09-20']) + ->and($report['html'])->toContain('Verified clean'); + }); + + it('leaves the row out of both emails on a host with no way to run the check, and says why only in the preview', function () { + // A custom host: no server SSH, no Pressable API, no command runner. + $site = accuracySite(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null, 'spinupwp_id' => null]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['checksum_supported' => false, 'checksum_status' => null]) + ->and($report['html'])->not->toContain('WordPress core files') + ->and($report['text'])->not->toContain('WordPress core files') + ->and($report['preview'])->toContain('Not checked (no checksum path for this host) — hidden from the client') + ->and($report['public'])->not->toContain('WordPress Core Files')->not->toContain('no checksum path') + ->and(implode(' ', $report['reasons']))->not->toContain('core-file'); + }); + + it('holds the report when the host can run the check but none ran this month', function () { + $report = compileSeptember(accuracySite()); + + expect($report['data']['security']['checksum_supported'])->toBeTrue() + ->and($report['html'])->toContain('WordPress core files')->toContain('Not checked this month') + ->and($report['reasons'])->toContain('No WordPress core-file check ran this month, although this host supports one, so the client would read "Not checked this month".'); + }); + + it('knows which hosts can run the check', function (Closure $makeSite, bool $supported) { + expect(compileSeptember($makeSite())['data']['security']['checksum_supported'])->toBe($supported); + })->with([ + 'server with SSH' => [fn () => accuracySite(), true], + 'Pressable' => [fn () => Site::factory()->pressable()->create(['is_wordpress' => true, 'uptime_last_checked_at' => now()]), true], + 'SSH host with no server' => [fn () => accuracySite(['server_id' => null]), false], + 'SSH host on an ignored server' => [fn () => accuracySite(['server_id' => Server::factory()->create(['is_ignored' => true])->id]), false], + 'custom host' => [fn () => accuracySite(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null]), false], + 'not WordPress' => [fn () => accuracySite(['is_wordpress' => false, 'companion_snapshot' => null]), false], + ]); + + it('leaves the WordPress row out entirely for non-WordPress sites', function () { + $report = compileSeptember(accuracySite(['is_wordpress' => false, 'companion_snapshot' => null])); + + expect($report['data']['security']['checksum_status'])->toBeNull() + ->and($report['html'])->not->toContain('WordPress core files') + ->and($report['text'])->not->toContain('WordPress core files'); + }); +}); + +describe('WordPress detection', function () { + it('treats a site as WordPress when Companion reports a core, even if the flag is off', function () { + $report = compileSeptember(accuracySite(['is_wordpress' => false, 'companion_snapshot' => ['wp_core' => ['version' => '6.8'], 'plugins' => ['plugins' => []]]])); + + expect($report['data']['site']['is_wordpress'])->toBeTrue() + ->and($report['html'])->toContain('WordPress core files')->toContain('Not checked this month'); + }); +}); + +describe('malware scan', function () { + it('shows flagged malware findings instead of an all-clear', function () { + $site = accuracySite(); + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_COMPANION_MALWARE, 'scanned_at' => Carbon::parse('2026-09-18'), 'status' => SiteSecurityScan::STATUS_WARNING]); + + $report = compileSeptember($site); + + expect($report['data']['security']['malware_status'])->toBe('warning') + ->and($report['html'])->toContain('Malware scan')->toContain('Issues flagged') + ->and($report['text'])->toContain('Malware scan: Issues flagged'); + }); + + it('says "Not checked this month" when no malware scan completed', function () { + $report = compileSeptember(accuracySite()); + + expect($report['data']['security']['malware_status'])->toBeNull() + ->and($report['text'])->toContain('Malware scan: Not checked this month'); + }); + + it('says "Partial scan", never "No malware found", when the latest clean scan stopped at its limit', function () { + $site = accuracySite(); + $scan = companionMalwareScan($site, '2026-09-28', SiteSecurityScan::STATUS_CLEAN, [], ['scan_aborted' => true, 'abort_reason' => 'max_files_visited', 'scanned_files_count' => 75000]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['malware_status' => 'incomplete', 'malware_scan_id' => $scan->id, 'malware_scan_aborted' => true]) + ->and($report['html'])->toContain('Partial scan')->not->toContain('No malware found') + ->and($report['text'])->toContain('Malware scan: Partial scan') + ->and($report['preview'])->toContain('Partial scan')->not->toContain('No malware found') + ->and($report['reasons'])->toContain("The latest malware scan (scan #{$scan->id}) stopped before it finished (limit: max_files_visited, 75,000 files checked), so the client sees \"Partial scan\" instead of an all-clear."); + }); + + it('counts a clean SSH scan whose every check finished as complete, although SSH never counts files', function () { + $site = accuracySite(); + companionMalwareScan($site, '2026-09-28', SiteSecurityScan::STATUS_CLEAN, [], [ + 'transport' => 'ssh', 'scanned_files_count' => 0, 'truncated_checks' => [], 'incomplete_checks' => [], 'ssh_checks_verified' => true, + ]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['malware_status' => 'clean', 'malware_transport' => 'ssh', 'malware_truncated_checks' => [], 'malware_ssh_checks_verified' => true]) + ->and($report['html'])->toContain('No malware found') + ->and($report['text'])->not->toContain('Partial scan') + ->and(implode(' ', $report['reasons']))->not->toContain('malware'); + }); + + it('says "Partial scan" for a clean SSH scan that hit its result limit', function () { + $site = accuracySite(); + $scan = companionMalwareScan($site, '2026-09-28', SiteSecurityScan::STATUS_CLEAN, [], [ + 'transport' => 'ssh', 'scanned_files_count' => 0, 'scan_aborted' => true, 'abort_reason' => 'ssh_result_limit', 'truncated_checks' => ['php_in_uploads'], + ]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['malware_status' => 'incomplete', 'malware_truncated_checks' => ['php_in_uploads']]) + ->and($report['text'])->toContain('Malware scan: Partial scan') + ->and($report['reasons'])->toContain("The latest malware scan (scan #{$scan->id}) ran over SSH and hit its result limit (php_in_uploads); files past the limit were not checked, so the client sees \"Partial scan\" instead of an all-clear."); + }); + + it('keeps an SSH scan recorded before every check proved it finished as partial', function (array $details) { + // Those rows always said scan_aborted=false, with or without an + // (empty) truncated_checks: a check that timed out still looked clean. + $site = accuracySite(); + $scan = companionMalwareScan($site, '2026-09-28', SiteSecurityScan::STATUS_CLEAN, [], ['transport' => 'ssh', 'scanned_files_count' => 0, ...$details]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['malware_status' => 'incomplete', 'malware_ssh_checks_verified' => null]) + ->and($report['text'])->toContain('Malware scan: Partial scan') + ->and($report['reasons'])->toContain("The latest malware scan (scan #{$scan->id}) ran over SSH before scans recorded whether every check finished; it can't vouch for the whole site, so the client sees \"Partial scan\" instead of an all-clear."); + })->with([ + 'before cut-short scans were noticed' => [[]], + 'with an empty truncation list but no completion marker' => [['truncated_checks' => []]], + ]); + + it('says "Partial scan" and names the checks for a clean SSH scan whose checks didn\'t finish', function () { + $site = accuracySite(); + $scan = companionMalwareScan($site, '2026-09-28', SiteSecurityScan::STATUS_CLEAN, [], [ + 'transport' => 'ssh', 'scanned_files_count' => 0, 'scan_aborted' => true, 'abort_reason' => 'ssh_check_incomplete', + 'truncated_checks' => [], 'incomplete_checks' => ['php_in_uploads', 'shell_c99'], 'ssh_checks_verified' => false, + ]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['malware_status' => 'incomplete', 'malware_incomplete_checks' => ['php_in_uploads', 'shell_c99']]) + ->and($report['html'])->toContain('Partial scan')->not->toContain('No malware found') + ->and($report['reasons'])->toContain("The latest malware scan (scan #{$scan->id}) ran over SSH and some checks didn't finish (php_in_uploads, shell_c99); they timed out or were cut off, so they can't vouch for the files they didn't list, so the client sees \"Partial scan\" instead of an all-clear."); + }); + + it('reads a real SSH scan of a SpinupWP-style server as complete in the next report', function () { + // End to end: the scanner's own SSH result, recorded, then compiled. + $server = Server::factory()->create(['ssh_password' => 'not-empty']); + $site = accuracySite(['server_id' => $server->id, 'companion_installed' => false, 'companion_secret' => null, 'wp_path' => '/sites/ssh.example.com/files']); + $this->mock(SshClient::class, fn ($mock) => $mock->shouldReceive('execChecked')->andReturnUsing(fn ($server, string $cmd) => str_starts_with($cmd, 'test -d ') + ? CompanionMalwareScanner::SSH_DOCROOT_OK + : CompanionMalwareScanner::SSH_DONE_SENTINEL)); + $this->travelTo(Carbon::parse('2026-09-28 06:00:00')); + app(SecurityScanRecorder::class)->record(app(CompanionMalwareScanner::class)->scanSite($site)); + $this->travelBack(); + + $scan = SiteSecurityScan::query()->where('site_id', $site->id)->where('scan_type', SiteSecurityScan::TYPE_COMPANION_MALWARE)->sole(); + expect($scan->details)->toMatchArray(['transport' => 'ssh', 'scan_aborted' => false, 'truncated_checks' => [], 'ssh_checks_verified' => true]); + + expect(compileSeptember($site)['data']['security']['malware_status'])->toBe('clean'); + }); + + it('reads a real SSH scan whose grep timed out as partial in the next report', function () { + $server = Server::factory()->create(['ssh_password' => 'not-empty']); + $site = accuracySite(['server_id' => $server->id, 'companion_installed' => false, 'companion_secret' => null, 'wp_path' => '/sites/ssh.example.com/files']); + $this->mock(SshClient::class, fn ($mock) => $mock->shouldReceive('execChecked')->andReturnUsing(fn ($server, string $cmd) => match (true) { + str_starts_with($cmd, 'test -d ') => CompanionMalwareScanner::SSH_DOCROOT_OK, + str_contains($cmd, 'FilesMan') => throw new SshCommandTimedOut('SSH command timed out after 60s.'), + default => CompanionMalwareScanner::SSH_DONE_SENTINEL, + })); + $this->travelTo(Carbon::parse('2026-09-28 06:00:00')); + app(SecurityScanRecorder::class)->record(app(CompanionMalwareScanner::class)->scanSite($site)); + $this->travelBack(); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['malware_status' => 'incomplete', 'malware_incomplete_checks' => ['shell_filesman']]) + ->and($report['html'])->not->toContain('No malware found'); + }); + + it('keeps a finished clean scan as "No malware found", and a partial scan that flagged files as flagged', function () { + $clean = accuracySite(); + companionMalwareScan($clean, '2026-09-28', SiteSecurityScan::STATUS_CLEAN); + $flagged = accuracySite(); + companionMalwareScan($flagged, '2026-09-28', SiteSecurityScan::STATUS_WARNING, [['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/example/a.php', 'evidence' => 'x']], ['scan_aborted' => true, 'abort_reason' => 'max_files_visited']); + + $cleanReport = compileSeptember($clean); + $flaggedReport = compileSeptember($flagged); + + expect($cleanReport['data']['security']['malware_status'])->toBe('clean') + ->and($cleanReport['html'])->toContain('No malware found') + ->and(implode(' ', $cleanReport['reasons']))->not->toContain('malware') + ->and($flaggedReport['data']['security']['malware_status'])->toBe('warning') + ->and($flaggedReport['html'])->toContain('Issues flagged'); + }); + + it('keeps the stored status of a SiteCheck scan standing in for Companion', function () { + $site = accuracySite(); + SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_SITECHECK, 'scanned_at' => Carbon::parse('2026-09-18'), 'status' => SiteSecurityScan::STATUS_CLEAN, 'details' => ['scan' => [], 'site' => []]]); + + $security = compileSeptember($site)['data']['security']; + + expect($security)->toMatchArray(['malware_status' => 'clean', 'malware_finding_kinds' => null, 'malware_worst_in_period' => null]); + }); + + it('names the finding kinds and the scan for the reviewer, and never stores the paths', function () { + $site = accuracySite(); + $findings = [ + ...array_map(fn (int $n) => ['kind' => 'php_in_uploads', 'path' => "wp-content/uploads/example-cache/file-{$n}.php", 'evidence' => 'PHP file under uploads'], range(1, 4)), + ['kind' => 'wp_config_recently_modified', 'path' => 'wp-config.php', 'evidence' => 'mtime within 30 days'], + ]; + $scan = companionMalwareScan($site, '2026-09-28', SiteSecurityScan::STATUS_WARNING, $findings); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray([ + 'malware_status' => 'warning', + 'malware_scan_id' => $scan->id, + 'malware_finding_kinds' => ['php_in_uploads' => 4, 'wp_config_recently_modified' => 1], + ]) + ->and(json_encode($report['data']))->not->toContain('example-cache')->not->toContain('wp-config.php') + ->and($report['reasons'])->toContain("The malware scan flagged files to review: php_in_uploads ×4, wp_config_recently_modified ×1 (scan #{$scan->id}).") + // The client wording is unchanged. + ->and($report['text'])->toContain('Malware scan: Issues flagged'); + }); + + it('holds a report whose earlier scans found malware even though the latest one is clean', function () { + $site = accuracySite(); + $signature = [['kind' => 'eval_base64', 'path' => 'wp-content/themes/example/x.php', 'evidence' => 'Substring match']]; + companionMalwareScan($site, '2026-09-05 03:00', SiteSecurityScan::STATUS_ISSUES_FOUND, $signature); + companionMalwareScan($site, '2026-09-06 03:00', SiteSecurityScan::STATUS_ISSUES_FOUND, $signature, ['scan_aborted' => true, 'abort_reason' => 'max_files_visited']); + companionMalwareScan($site, '2026-09-06 15:00', SiteSecurityScan::STATUS_ISSUES_FOUND, $signature); + companionMalwareScan($site, '2026-09-20 03:00', SiteSecurityScan::STATUS_CLEAN); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray([ + 'malware_status' => 'clean', + 'malware_worst_in_period' => 'issues_found', + 'malware_last_issue_at' => '2026-09-06', + 'malware_issue_days' => 2, + ]) + ->and($report['reasons'])->toContain('An earlier malware scan this month flagged malware (on 2 days, last on 2026-09-06), but the latest scan did not: check it was cleaned up.'); + }); + + it('does not count earlier scans that only flagged the scanner\'s own signature list', function () { + // Stored before the scanner skipped its own source: every signature + // it looks for, found in its own MalwareScanner.php. + $site = accuracySite(['wp_path' => '/srv/htdocs']); + $own = fn (string $path) => array_map(fn (string $kind) => ['kind' => $kind, 'path' => $path, 'evidence' => 'Substring match'], + ['eval_base64', 'shell_c99', 'shell_wso', 'preg_replace_e_modifier']); + companionMalwareScan($site, '2026-09-05 03:00', SiteSecurityScan::STATUS_ISSUES_FOUND, $own('wp-content/plugins/clockwork-companion/src/SecurityScans/MalwareScanner.php')); + companionMalwareScan($site, '2026-09-06 03:00', SiteSecurityScan::STATUS_ISSUES_FOUND, $own('/srv/htdocs/wp-content/plugins/clockwork-companion/src\\SecurityScans\\MalwareScanner.php')); + companionMalwareScan($site, '2026-09-20 03:00', SiteSecurityScan::STATUS_CLEAN); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray([ + 'malware_status' => 'clean', + 'malware_worst_in_period' => 'clean', + 'malware_last_issue_at' => null, + 'malware_issue_days' => 0, + ]) + ->and(implode(' ', $report['reasons']))->not->toContain('earlier malware scan'); + }); + + it('still counts a scan that flagged real malware beside the scanner\'s own source', function () { + $site = accuracySite(); + companionMalwareScan($site, '2026-09-05 03:00', SiteSecurityScan::STATUS_ISSUES_FOUND, [ + ['kind' => 'eval_base64', 'path' => 'wp-content/plugins/clockwork-companion/src/SecurityScans/MalwareScanner.php', 'evidence' => 'Substring match'], + ['kind' => 'eval_base64', 'path' => 'wp-content/themes/example/x.php', 'evidence' => 'Substring match'], + ]); + companionMalwareScan($site, '2026-09-20 03:00', SiteSecurityScan::STATUS_CLEAN); + + expect(compileSeptember($site)['data']['security'])->toMatchArray(['malware_worst_in_period' => 'issues_found', 'malware_issue_days' => 1]); + }); + + it('reads a latest scan whose only signatures were the scanner\'s own as the review-only findings it also had', function () { + $site = accuracySite(); + companionMalwareScan($site, '2026-09-20 03:00', SiteSecurityScan::STATUS_ISSUES_FOUND, [ + ['kind' => 'shell_c99', 'path' => 'wp-content/plugins/clockwork-companion/src/SecurityScans/MalwareScanner.php', 'evidence' => 'c99shell'], + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/example/a.php', 'evidence' => 'PHP file under uploads'], + ]); + + $security = compileSeptember($site)['data']['security']; + + expect($security)->toMatchArray([ + 'malware_status' => 'warning', + 'malware_worst_in_period' => 'warning', + 'malware_finding_kinds' => ['php_in_uploads' => 1], + ]); + }); +}); + +describe('threats blocked', function () { + it('leaves the count out when nothing on the site reports blocks', function () { + $report = compileSeptember(accuracySite()); + + expect($report['data']['security']['blocked_threats_count'])->toBeNull() + ->and($report['html'])->not->toContain('Malicious visitors blocked')->not->toContain('Threats blocked') + ->not->toContain('block attackers') + ->and($report['text'])->not->toContain('Malicious visitors blocked'); + }); + + // Was "shows a real 0 when a blocker reports to Control": an active blocker + // alone isn't evidence that its lockouts reach us (it can be switched off, + // or the site left out of every pull), so it no longer yields a 0. + it('leaves the count out when a blocker is installed but nothing shows its lockouts reach Control', function () { + $report = compileSeptember(accuracySite(['companion_snapshot' => llarSnapshot()])); + + expect($report['data']['security']['blocked_threats_count'])->toBeNull() + ->and($report['html'])->not->toContain('Threats blocked')->not->toContain('Malicious visitors blocked') + ->not->toContain('block attackers') + ->and($report['text'])->not->toContain('Malicious visitors blocked') + // The operator preview gives the shared reason the count is hidden. + ->and(ReportPhrases::blockedVisitorsNote($report['data']['security']))->not->toBeNull() + ->and($report['preview'])->toContain(e(ReportPhrases::blockedVisitorsNote($report['data']['security']))); + }); + + it('shows a real 0, blocked at the server, when the server has a jail and its lockouts were pulled', function () { + $server = Server::factory()->create(['clockwork_jail_provisioned_at' => Carbon::parse('2026-08-01'), 'last_llar_pull_at' => now()]); + $report = compileSeptember(accuracySite(['server_id' => $server->id, 'companion_snapshot' => llarSnapshot()])); + + expect($report['data']['security'])->toMatchArray(['blocked_threats_count' => 0, 'blocks_at_server' => true]) + ->and($report['html'])->toContain('block attackers at the server') + ->and($report['text'])->toContain('Malicious visitors blocked: 0'); + }); + + it('does not count a jail whose lockouts were last pulled before the period', function () { + $server = Server::factory()->create(['clockwork_jail_provisioned_at' => Carbon::parse('2026-08-01'), 'last_llar_pull_at' => Carbon::parse('2026-08-20')]); + + expect(compileSeptember(accuracySite(['server_id' => $server->id, 'companion_snapshot' => llarSnapshot()]))['data']['security']['blocked_threats_count'])->toBeNull(); + }); + + // Was "shows a real 0 … when lockouts from a serverless site reached the + // review queue": those rows are the lockouts that never became a ban, so + // they prove the count left visitors out, not that 0 was measured. + it('hides the count, without holding the report, when lockouts in the period never became bans', function (string $status) { + $site = accuracySite(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null, 'companion_snapshot' => llarSnapshot()]); + ReviewQueueEntry::factory()->count(2)->create(['site_id' => $site->id, 'source' => ReviewQueueEntry::SOURCE_LLAR, 'status' => $status, 'created_at' => Carbon::parse('2026-09-14'), 'updated_at' => Carbon::parse('2026-09-14')]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['blocked_threats_count' => null, 'blocks_at_server' => false, 'unbanned_lockouts' => 2]) + ->and($report['html'])->not->toContain('Malicious visitors blocked')->not->toContain('block malicious visitors') + ->and($report['text'])->not->toContain('Malicious visitors blocked') + ->and($report['preview'])->toContain('2 lockouts this period never became a ban') + ->and($report['preview'])->toContain(e(ReportPhrases::blockedVisitorsNote($report['data']['security']))) + ->and(implode(' ', $report['reasons']))->not->toContain('never became a ban'); + })->with([ReviewQueueEntry::STATUS_PENDING, ReviewQueueEntry::STATUS_QUEUED_FOR_BAN, ReviewQueueEntry::STATUS_FAILED]); + + it('hides the count even beside real bans when some lockouts were never banned', function () { + $server = Server::factory()->create(['clockwork_jail_provisioned_at' => Carbon::parse('2026-08-01'), 'last_llar_pull_at' => now()]); + $site = accuracySite(['server_id' => $server->id, 'companion_snapshot' => llarSnapshot()]); + BlockedIp::factory()->count(3)->create(['site_id' => $site->id, 'banned_at' => Carbon::parse('2026-09-10')]); + ReviewQueueEntry::factory()->create(['site_id' => $site->id, 'status' => ReviewQueueEntry::STATUS_FAILED, 'created_at' => Carbon::parse('2026-09-20'), 'updated_at' => Carbon::parse('2026-09-20')]); + + expect(compileSeptember($site)['data']['security'])->toMatchArray(['blocked_threats_count' => null, 'blocks_at_server' => false, 'unbanned_lockouts' => 1]); + }); + + it('does not read a review-queue row from before the period, merely updated in it, as a measured 0', function () { + $site = accuracySite(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null, 'companion_snapshot' => llarSnapshot()]); + ReviewQueueEntry::factory()->create(['site_id' => $site->id, 'status' => ReviewQueueEntry::STATUS_FAILED, 'created_at' => Carbon::parse('2026-08-20'), 'updated_at' => Carbon::parse('2026-09-02')]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['blocked_threats_count' => null, 'unbanned_lockouts' => 0]) + ->and($report['text'])->not->toContain('Malicious visitors blocked'); + }); + + it('does not count a jail whose lockouts stopped being pulled part-way through the period', function () { + $server = Server::factory()->create(['clockwork_jail_provisioned_at' => Carbon::parse('2026-08-01'), 'last_llar_pull_at' => Carbon::parse('2026-09-15')]); + + expect(compileSeptember(accuracySite(['server_id' => $server->id, 'companion_snapshot' => llarSnapshot()]))['data']['security']['blocked_threats_count'])->toBeNull(); + }); + + // Was "counts real bans in the period whatever else is known": bans in + // the period prove some lockouts reached us, not that all of them did + // all month, so a site with no lockout feed doesn't get a total. + it('does not show bans as a monthly total when nothing shows the site\'s lockouts reached Control all period', function () { + $site = accuracySite(); + BlockedIp::factory()->count(2)->create(['site_id' => $site->id, 'banned_at' => Carbon::parse('2026-09-10')]); + BlockedIp::factory()->create(['site_id' => $site->id, 'banned_at' => Carbon::parse('2026-08-10')]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['blocked_threats_count' => null, 'blocks_at_server' => false, 'blocks_unmeasured' => 'no_lockout_feed']) + ->and($report['html'])->not->toContain('Malicious visitors blocked')->not->toContain('block malicious visitors') + ->and($report['text'])->not->toContain('Malicious visitors blocked') + ->and(ReportPhrases::blockedVisitorsNote($report['data']['security']))->toContain('hidden from the client'); + }); + + it('counts real bans, leaving out those lifted as false positives', function () { + $server = Server::factory()->create(['clockwork_jail_provisioned_at' => Carbon::parse('2026-08-01'), 'last_llar_pull_at' => now()]); + $site = accuracySite(['server_id' => $server->id, 'companion_snapshot' => llarSnapshot()]); + $ban = fn (string $at, array $extra = []) => BlockedIp::factory()->create(['site_id' => $site->id, 'server_id' => $server->id, 'banned_at' => Carbon::parse($at), ...$extra]); + $ban('2026-09-10 12:00'); + $ban('2026-09-29 12:00'); + // Unbanned days later: a real ban that expired. + $ban('2026-09-12 12:00', ['unbanned_at' => Carbon::parse('2026-09-15 12:00'), 'decided_by' => 'retention']); + // A rule that misfired, reversed minutes later. + $ban('2026-09-18 14:10', ['source' => BlockedIp::SOURCE_EXPLOIT_SHIELD, 'decided_by' => 'false_positive_ua_rule', 'unbanned_at' => Carbon::parse('2026-09-18 14:18')]); + // Lifted by hand within the hour. + $ban('2026-09-20 10:00', ['decided_by' => 'manual', 'unbanned_at' => Carbon::parse('2026-09-20 10:40')]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['blocked_threats_count' => 3, 'blocks_unmeasured' => null]) + ->and($report['text'])->toContain('Malicious visitors blocked: 3'); + }); + + it('hides the count when no lockout has reached Control from any site since well before the period end', function () { + // The pull keeps running (and stamping last_llar_pull_at), but nothing arrives after Sep 12. + $server = Server::factory()->create(['clockwork_jail_provisioned_at' => Carbon::parse('2026-08-01'), 'last_llar_pull_at' => now()]); + $site = accuracySite(['server_id' => $server->id, 'companion_snapshot' => llarSnapshot()]); + BlockedIp::factory()->create(['site_id' => $site->id, 'server_id' => $server->id, 'banned_at' => Carbon::parse('2026-09-03 08:00')]); + BlockedIp::factory()->create(['site_id' => null, 'server_id' => $server->id, 'banned_at' => Carbon::parse('2026-09-12 09:15')]); + // A manual ban later on doesn't show lockouts were still arriving. + BlockedIp::factory()->create(['site_id' => $site->id, 'source' => BlockedIp::SOURCE_MANUAL, 'banned_at' => Carbon::parse('2026-09-25 10:00')]); + + $report = compileSeptember($site); + + expect($report['data']['security'])->toMatchArray(['blocked_threats_count' => null, 'blocks_unmeasured' => 'ingestion_stopped', 'lockouts_last_ingested_at' => '2026-09-12T09:15:00+00:00']) + ->and($report['text'])->not->toContain('Malicious visitors blocked') + ->and($report['html'])->not->toContain('block attackers') + ->and(ReportPhrases::blockedVisitorsNote($report['data']['security']))->toContain('since Sep 12, 2026'); + }); + + it('hides the count when the site no longer reports its lockouts, whatever it banned before', function () { + // Companion stopped advertising its blocker part-way through the month. + $server = Server::factory()->create(['clockwork_jail_provisioned_at' => Carbon::parse('2026-08-01'), 'last_llar_pull_at' => now()]); + $site = accuracySite(['server_id' => $server->id, 'companion_capabilities' => ['malware-scan'], 'companion_snapshot' => ['plugins' => ['plugins' => []]]]); + BlockedIp::factory()->create(['site_id' => $site->id, 'server_id' => $server->id, 'banned_at' => Carbon::parse('2026-09-09 11:00')]); + BlockedIp::factory()->create(['site_id' => null, 'server_id' => $server->id, 'banned_at' => Carbon::parse('2026-09-30 11:00')]); + + expect(compileSeptember($site)['data']['security'])->toMatchArray(['blocked_threats_count' => null, 'blocks_unmeasured' => 'no_lockout_feed']); + }); +}); + +describe('traffic', function () { + /** + * One row per day of September from $first to $last, each written early + * on the day after it (as the hourly rollup does). + * + * @param array $row + */ + function recordTraffic(Site $site, int $first = 1, int $last = 30, array $row = []): void + { + foreach (range($first, $last) as $day) { + $date = Carbon::parse(sprintf('2026-09-%02d', $day)); + DB::table('site_traffic_daily')->insert(array_replace([ + 'site_id' => $site->id, 'date' => $date->toDateString(), 'requests' => 5000, 'visits' => 80, 'bytes_sent' => 0, + 'top_ips' => null, 'created_at' => $date->copy()->addDay()->setTime(5, 0), 'updated_at' => $date->copy()->addDay()->setTime(5, 0), + ], $row)); + } + } + + /** A site whose server we can read access logs from. */ + function trafficSite(): Site + { + return accuracySite(['server_id' => Server::factory()->create(['last_ssh_ok_at' => now()])->id]); + } + + it('leaves out data transferred when the host reports no byte counts', function () { + $site = accuracySite(); + recordTraffic($site); $report = compileSeptember($site); - expect($report['data']['security']['checksum_status'])->toBeNull() - ->and($report['html'])->toContain('Not checked this month')->not->toMatch('/>\s*Ok\s*and($report['text'])->toContain('WordPress core files: Not checked this month'); + expect($report['data']['traffic'])->toMatchArray([ + 'has_traffic' => true, 'total_requests' => 150000, 'bandwidth_mb' => null, + 'status' => 'ok', 'source' => 'nginx', 'days_covered' => 30, 'period_days' => 30, 'last_day_complete' => true, + ]) + ->and($report['html'])->toContain('Visits')->not->toContain('Data transferred') + ->and($report['preview'])->toContain('Recorded on 30 of 30 days, from the server's access logs.') + ->and($report['public'])->not->toContain('Recorded on 30 of 30 days') + ->and(implode(' ', $report['reasons']))->not->toContain('Traffic'); }); - it('uses the latest completed checksum scan inside the period', function () { + it('hides traffic recorded on only some days of the month, and holds the report', function () { $site = accuracySite(); - SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_CORE_CHECKSUMS, 'scanned_at' => Carbon::parse('2026-09-10'), 'status' => SiteSecurityScan::STATUS_ISSUES_FOUND]); - SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_CORE_CHECKSUMS, 'scanned_at' => Carbon::parse('2026-09-20'), 'status' => SiteSecurityScan::STATUS_CLEAN]); - // A later scan that never completed is not evidence either way. - SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_CORE_CHECKSUMS, 'scanned_at' => Carbon::parse('2026-09-25'), 'status' => SiteSecurityScan::STATUS_FAILED]); + recordTraffic($site, 10, 10); $report = compileSeptember($site); - expect($report['data']['security'])->toMatchArray(['checksum_status' => 'clean', 'checksum_checked_at' => '2026-09-20']) - ->and($report['html'])->toContain('Verified clean'); + expect($report['data']['traffic'])->toMatchArray(['has_traffic' => true, 'total_requests' => 5000, 'status' => 'partial', 'days_covered' => 1, 'period_days' => 30]) + ->and($report['html'])->not->toContain('Visits') + ->and($report['text'])->not->toContain('TRAFFIC') + ->and($report['preview'])->not->toContain('Traffic Analytics') + ->toContain('Traffic hidden from the client: traffic was recorded on only 1 of 30 days.') + ->and($report['reasons'])->toContain('Traffic was recorded on only 1 of 30 days, so the Traffic section was left out.'); }); - it('leaves the WordPress row out entirely for non-WordPress sites', function () { - $report = compileSeptember(accuracySite(['is_wordpress' => false, 'companion_snapshot' => null])); + it('allows one missing day, but not a missing or unfinished last day', function () { + $missingMidMonth = accuracySite(); + recordTraffic($missingMidMonth, 1, 14); + recordTraffic($missingMidMonth, 16, 30); - expect($report['data']['security']['checksum_status'])->toBeNull() - ->and($report['html'])->not->toContain('WordPress core files') - ->and($report['text'])->not->toContain('WordPress core files'); + $missingLastDay = accuracySite(); + recordTraffic($missingLastDay, 1, 29); + + // The host's feed wrote the last day before it was over. + $unfinishedLastDay = accuracySite(); + recordTraffic($unfinishedLastDay, 1, 29); + recordTraffic($unfinishedLastDay, 30, 30, ['updated_at' => Carbon::parse('2026-09-30 06:37')]); + + expect(compileSeptember($missingMidMonth)['data']['traffic'])->toMatchArray(['status' => 'ok', 'days_covered' => 29]); + + foreach ([$missingLastDay, $unfinishedLastDay] as $site) { + $report = compileSeptember($site); + expect($report['data']['traffic'])->toMatchArray(['status' => 'partial', 'last_day_complete' => false]) + ->and($report['reasons'])->toContain("The period's last day of traffic wasn't complete when the report was compiled, so the Traffic section was left out."); + } }); -}); -describe('WordPress detection', function () { - it('treats a site as WordPress when Companion reports a core, even if the flag is off', function () { - $report = compileSeptember(accuracySite(['is_wordpress' => false, 'companion_snapshot' => ['wp_core' => ['version' => '6.8'], 'plugins' => ['plugins' => []]]])); + it('does not count days the feed had nothing for', function () { + $site = accuracySite(); + recordTraffic($site, 1, 15); + recordTraffic($site, 16, 30, ['requests' => 0, 'visits' => 0]); - expect($report['data']['site']['is_wordpress'])->toBeTrue() - ->and($report['html'])->toContain('WordPress core files')->toContain('Not checked this month'); + expect(compileSeptember($site)['data']['traffic'])->toMatchArray(['status' => 'partial', 'days_covered' => 15]); }); -}); -describe('malware scan', function () { - it('shows flagged malware findings instead of an all-clear', function () { + it('treats access logs that see two IP addresses as a proxy, not visitors', function () { $site = accuracySite(); - SiteSecurityScan::factory()->create(['site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_COMPANION_MALWARE, 'scanned_at' => Carbon::parse('2026-09-18'), 'status' => SiteSecurityScan::STATUS_WARNING]); + recordTraffic($site, row: ['requests' => 1000, 'visits' => 2, 'top_ips' => json_encode([ + ['ip' => '10.0.0.4', 'hits' => 480], ['ip' => '10.0.0.5', 'hits' => 470], ['ip' => '203.0.113.9', 'hits' => 50], + ])]); $report = compileSeptember($site); - expect($report['data']['security']['malware_status'])->toBe('warning') - ->and($report['html'])->toContain('Malware scan')->toContain('Issues flagged') - ->and($report['text'])->toContain('Malware scan: Issues flagged'); + expect($report['data']['traffic'])->toMatchArray(['status' => 'suspect', 'top_ip_share' => 0.95]) + ->and($report['html'])->not->toContain('Visits') + ->and($report['reasons'])->toContain('95% of requests came from two IP addresses, which looks like a proxy or gateway in front of the site rather than visitors, so the Traffic section was left out.'); }); - it('says "Not checked this month" when no malware scan completed', function () { - $report = compileSeptember(accuracySite()); + it('says "one IP address" when every listed request came from one', function () { + $site = accuracySite(); + recordTraffic($site, row: ['requests' => 1000, 'visits' => 1, 'top_ips' => json_encode([['ip' => '10.0.0.4', 'hits' => 1000]])]); - expect($report['data']['security']['malware_status'])->toBeNull() - ->and($report['text'])->toContain('Malware scan: Not checked this month'); + $report = compileSeptember($site); + + expect($report['data']['traffic'])->toMatchArray(['status' => 'suspect', 'top_ip_share' => 1.0, 'top_ip_count' => 1]) + ->and($report['reasons'])->toContain('100% of requests came from one IP address, which looks like a proxy or gateway in front of the site rather than visitors, so the Traffic section was left out.'); }); -}); -describe('threats blocked', function () { - it('leaves the count out when nothing on the site reports blocks', function () { - $report = compileSeptember(accuracySite()); + it('does not judge a host feed by its IP list, and keeps real spread-out visitors', function () { + $ips = json_encode([['ip' => '198.51.100.1', 'hits' => 900], ['ip' => '198.51.100.2', 'hits' => 90]]); + $pressable = accuracySite(['hosting_provider' => Site::HOSTING_PROVIDER_PRESSABLE, 'pressable_site_id' => '4242', 'server_id' => null]); + recordTraffic($pressable, row: ['requests' => 1000, 'top_ips' => $ips]); + $spread = accuracySite(); + recordTraffic($spread, row: ['requests' => 1000, 'top_ips' => json_encode([['ip' => '198.51.100.1', 'hits' => 300], ['ip' => '198.51.100.2', 'hits' => 200]])]); - expect($report['data']['security']['blocked_threats_count'])->toBeNull() - ->and($report['html'])->not->toContain('Malicious visitors blocked')->not->toContain('Threats blocked') - ->not->toContain('block attackers') - ->and($report['text'])->not->toContain('Malicious visitors blocked'); + expect(compileSeptember($pressable)['data']['traffic'])->toMatchArray(['status' => 'ok', 'source' => 'pressable', 'top_ip_share' => 0.99]) + ->and(compileSeptember($spread)['data']['traffic'])->toMatchArray(['status' => 'ok', 'top_ip_share' => 0.5]); }); - it('shows a real 0 when a blocker reports to Control', function () { - $site = accuracySite(['companion_snapshot' => ['plugins' => ['plugins' => [ - ['slug' => 'limit-login-attempts-reloaded/limit-login-attempts-reloaded.php', 'active' => true], - ]]]]); + it('treats a busy site with almost no visits as suspect', function () { + $site = accuracySite(); + recordTraffic($site, row: ['requests' => 70000, 'visits' => 1]); $report = compileSeptember($site); - expect($report['data']['security']['blocked_threats_count'])->toBe(0) - ->and($report['html'])->toContain('block attackers at the server') - ->and($report['text'])->toContain('Malicious visitors blocked: 0'); + expect($report['data']['traffic'])->toMatchArray(['status' => 'suspect', 'top_ip_share' => null]) + ->and($report['reasons'])->toContain("30 visits for 2,100,000 requests doesn't look like real visitors, so the Traffic section was left out."); }); -}); -describe('traffic', function () { - it('leaves out data transferred when the host reports no byte counts', function () { - $site = accuracySite(); - DB::table('site_traffic_daily')->insert(['site_id' => $site->id, 'date' => '2026-09-10', 'requests' => 5000, 'visits' => 80, 'bytes_sent' => 0, 'created_at' => now(), 'updated_at' => now()]); + it('holds a report with no traffic from a host we collect it from, but not from one we don\'t', function () { + $collected = compileSeptember(trafficSite()); + $notCollected = compileSeptember(accuracySite(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'server_id' => null])); - $report = compileSeptember($site); + expect($collected['data']['traffic'])->toMatchArray(['has_traffic' => false, 'status' => 'no_data', 'days_covered' => 0]) + ->and($collected['reasons'])->toContain("No traffic was recorded for this period, although this host's traffic is collected, so the Traffic section was left out.") + ->and($notCollected['data']['traffic'])->toMatchArray(['has_traffic' => false, 'status' => 'unsupported']) + ->and(implode(' ', $notCollected['reasons']))->not->toContain('Traffic') + ->and($notCollected['preview'])->toContain('Traffic hidden from the client: traffic isn't collected for this host.'); + }); + + it('reports traffic that exists even when the host can no longer be read', function () { + // The factory server has no SSH credentials, so the host reads as unsupported now. + $site = accuracySite(); + recordTraffic($site); - expect($report['data']['traffic'])->toMatchArray(['has_traffic' => true, 'total_requests' => 5000, 'bandwidth_mb' => null]) - ->and($report['html'])->toContain('Visits')->not->toContain('Data transferred'); + expect($site->supportsTrafficReport())->toBeFalse() + ->and(compileSeptember($site)['data']['traffic']['status'])->toBe('ok'); }); }); @@ -407,9 +1300,10 @@ function recordCheck(Site $site, bool $ok, ?Carbon $at = null): void }); describe('contact form tests (#4)', function () { - it('counts runs recorded as "success" as delivered', function () { + it('counts runs recorded as "success" as reported sent', function () { // Regression: the compiler looked for status "ok", which is never - // written, so every tested site was told "0% delivered". + // written, so every tested site was told "0% delivered". Only the + // site's own "sent" is measured, so the wording claims no more. $site = accuracySite(); ContactFormTestRun::factory()->count(3)->create(['site_id' => $site->id, 'ran_at' => Carbon::parse('2026-09-05'), 'status' => ContactFormTestRun::STATUS_SUCCESS]); ContactFormTestRun::factory()->create(['site_id' => $site->id, 'ran_at' => Carbon::parse('2026-09-06'), 'status' => ContactFormTestRun::STATUS_FAILED]); @@ -417,8 +1311,9 @@ function recordCheck(Site $site, bool $ok, ?Carbon $at = null): void $report = compileSeptember($site); expect($report['data']['forms'])->toMatchArray(['total_synthetic_tests' => 4, 'successful_tests' => 3, 'pass_rate' => 75.0]) - ->and($report['html'])->toContain('75% delivered') - ->and($report['text'])->toContain('4 test submissions, 75% delivered'); + ->and($report['html'])->toContain('your site reported sending the notification email for')->toContain('75% of them.') + ->not->toContain('% delivered') + ->and($report['text'])->toContain('4 live test submissions, notification email reported sent for 75% (inbox delivery not checked)'); }); it('never claims delivery from lab runs, which suppress the email', function () { @@ -438,6 +1333,20 @@ function recordCheck(Site $site, bool $ok, ?Carbon $at = null): void ->and($report['text'])->not->toContain('% delivered'); }); + it('counts a lab run as accepted only when the form took the submission, and holds the report when one did not', function () { + $site = accuracySite(); + $lab = ['site_id' => $site->id, 'ran_at' => Carbon::parse('2026-09-07'), 'mode' => ContactFormTestRun::MODE_LAB, 'mail_outcome' => ContactFormTestRun::MAIL_SUPPRESSED]; + ContactFormTestRun::factory()->count(2)->create($lab + ['accepted' => true, 'status' => ContactFormTestRun::STATUS_SUCCESS]); + // Companion answered, but the form refused the submission. + ContactFormTestRun::factory()->create($lab + ['accepted' => false, 'status' => ContactFormTestRun::STATUS_SUCCESS]); + + $report = compileSeptember($site); + + expect($report['data']['forms'])->toMatchArray(['lab_tests' => 3, 'lab_accepted' => 2]) + ->and($report['html'])->toContain('accepted 2 of 3') + ->and($report['reasons'])->toContain('Contact-form checks failed: the forms accepted 2 of 3 automated check submissions this month.'); + }); + it('does not count a live run whose email failed as delivered', function () { $site = accuracySite(); ContactFormTestRun::factory()->create(['site_id' => $site->id, 'ran_at' => Carbon::parse('2026-09-07'), 'mail_outcome' => ContactFormTestRun::MAIL_FAILED]); @@ -500,11 +1409,44 @@ function recordCheck(Site $site, bool $ok, ?Carbon $at = null): void }); describe('uptime', function () { - function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false): void + function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false, ?int $status = null): void { - SiteUptimeEvent::create(['site_id' => $site->id, 'event_type' => $type, 'event_at' => Carbon::parse($at), 'is_sla_exempt' => $exempt]); + SiteUptimeEvent::create(['site_id' => $site->id, 'event_type' => $type, 'event_at' => Carbon::parse($at), 'is_sla_exempt' => $exempt, 'status_code' => $status]); } + it('holds a report with a completed outage of an hour or more, even one past the listed five, so a person adds context', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + // 6h 21m from 2:10 AM EDT on Sep 1. + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-01 06:10:00', status: 500); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-01 12:31:00', status: 200); + // Five short ones later in the month push it off the client's list. + foreach ([10, 12, 14, 16, 18] as $day) { + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, "2026-09-{$day} 10:00:00", status: 503); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, "2026-09-{$day} 10:20:00", status: 200); + } + // 59 minutes: under the bar. + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-20 10:00:00', status: 503); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-20 10:59:00', status: 200); + + $report = compileSeptember($site); + + expect($report['data']['uptime']['outages'])->toHaveCount(5) + ->and($report['data']['uptime']['long_outages'])->toBe([ + ['started_at' => '2026-09-01T06:10:00+00:00', 'minutes' => 381, 'started_before_period' => false, 'ongoing' => false], + ]) + ->and($report['reasons'])->toContain('The site was down for an hour or more: Sep 1, 2:10 AM EDT for 6h 21m. Add context for the client before this goes out.'); + }); + + it('does not add the long-outage reason for an outage still open at the end, which has its own', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-30 20:00:00', status: 500); + + $reasons = compileSeptember($site)['reasons']; + + expect($reasons)->toContain('The site was still down at the end of the month.') + ->and(implode(' ', $reasons))->not->toContain('an hour or more'); + }); + it('measures real outage length from down to up instead of 5 minutes per outage', function () { // Regression: the compiler read a non-existent duration_seconds column // and counted every outage as 5 minutes. @@ -517,30 +1459,243 @@ function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false) expect($uptime)->toMatchArray(['outages_count' => 1, 'downtime_minutes' => 240]) ->and($uptime['outages'])->toHaveCount(1) ->and($uptime['outages'][0])->toBe(['started_at' => '2026-09-10T10:00:00+00:00', 'minutes' => 240, 'started_before_period' => false, 'ongoing' => false]); - expect(compileSeptember($site)['html'])->toContain('Down Sep 10, 10:00 AM UTC')->toContain('4h 0m'); + // Shown in the report timezone (clockwork.reports.timezone), not UTC. + expect(compileSeptember($site)['html'])->toContain('Down Sep 10, 6:00 AM EDT')->toContain('4h 0m')->not->toContain('UTC'); }); it('counts an outage that started before the period from the start of the period', function () { + // The period is September's days in the report timezone (EDT): Sep 1 + // begins at 04:00 UTC. $site = accuracySite(['uptime_monitoring_enabled' => true]); - uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-08-31 23:00:00'); - uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-01 01:30:00'); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-01 03:00:00'); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-01 05:30:00'); $report = compileSeptember($site); expect($report['data']['uptime'])->toMatchArray(['outages_count' => 1, 'downtime_minutes' => 90]) ->and($report['data']['uptime']['outages'][0]['started_before_period'])->toBeTrue() - ->and($report['html'])->toContain('Down since before Sep 1, 12:00 AM UTC'); + // Its start is clamped to the period start, which isn't when it began, so no time is given. + ->and($report['html'])->toContain('Down since before Sep 1<')->not->toContain('12:00 AM') + ->and($report['text'])->toContain('- Down since before Sep 1 for 90 min'); }); it('runs an outage still open at the end of the period to the end', function () { + // Sep 30 ends at midnight EDT, 04:00 UTC on Oct 1. $site = accuracySite(['uptime_monitoring_enabled' => true]); - uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-30 22:00:00'); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-10-01 02:00:00'); $report = compileSeptember($site); expect($report['data']['uptime']['downtime_minutes'])->toBe(120) ->and($report['data']['uptime']['outages'][0]['ongoing'])->toBeTrue() - ->and($report['text'])->toContain('(still down at period end)'); + ->and($report['text'])->toContain('- Down Sep 30, 10:00 PM EDT for 120 min (still down at period end)'); + }); + + it('holds the report when a real outage is still open at the end of the period', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-10-01 02:00:00', status: 503); + + $report = compileSeptember($site); + + expect($report['data']['uptime'])->toMatchArray(['outages_count' => 1, 'downtime_minutes' => 120, 'probe_blocked_minutes' => 0, 'probe_blocked_since' => null]) + ->and(ReportReviewFlags::reasons($report['data']))->toContain('The site was still down at the end of the month.') + ->and(implode(' ', ReportReviewFlags::reasons($report['data'])))->not->toContain('HTTP 429'); + }); + + it('does not count time the monitor was rate-limited (HTTP 429) as downtime', function () { + // A host that rate-limits the monitor answers 429: the probe was + // blocked, which says nothing about whether visitors reached the site. + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-12 08:00:00', status: 429); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-12 20:00:00', status: 200); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-20 10:00:00', status: 502); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-20 10:30:00', status: 200); + + $report = compileSeptember($site); + $uptime = $report['data']['uptime']; + + expect($uptime)->toMatchArray([ + 'outages_count' => 1, + 'downtime_minutes' => 30, + 'uptime_percentage' => 99.93, + 'probe_blocked_minutes' => 720, + 'probe_blocked_since' => null, + ]) + ->and($uptime['outages'])->toHaveCount(1) + ->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([]); + }); + + 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 () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-30 23:40:00', status: 429); + + $report = compileSeptember($site); + $preview = view('client-reports::reports.show', ['report' => ClientReport::latest('id')->first(), 'isOperatorPreview' => true])->render(); + $public = view('client-reports::reports.show', ['report' => ClientReport::latest('id')->first(), 'isOperatorPreview' => false])->render(); + + expect($report['data']['uptime'])->toMatchArray([ + 'outages_count' => 0, + 'downtime_minutes' => 0, + 'uptime_percentage' => 100, + 'outages' => [], + 'probe_blocked_minutes' => 260, + 'probe_blocked_since' => '2026-09-30T23:40:00+00:00', + ]) + ->and($report['html'])->not->toContain('still down') + ->and($report['text'])->not->toContain('still down') + ->and(ReportReviewFlags::reasons(['meta' => $report['data']['meta'], 'uptime' => $report['data']['uptime']]))->toBe([ + 'The uptime monitor was blocked (HTTP 429) from Sep 30, 7:40 PM EDT; the site was not necessarily down.', + ]) + // The blocked time is for the operator only. + ->and($preview)->toContain('The uptime monitor was blocked (HTTP 429) for 260 min this period.') + ->and($public)->not->toContain('HTTP 429'); + }); + + it('treats a 429 block carried in from before the period as blocked, not down', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-01 02:00:00', status: 429); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-01 07:00:00', status: 200); + + expect(compileSeptember($site)['data']['uptime'])->toMatchArray([ + 'outages_count' => 0, 'downtime_minutes' => 0, 'outages' => [], 'probe_blocked_minutes' => 180, 'probe_blocked_since' => null, + ]); + }); + + describe('the monitor\'s probe_blocked events', function () { + it('never counts a probe_blocked stretch as downtime', 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 20:00:00', status: 200); + + $report = compileSeptember($site); + + expect($report['data']['uptime'])->toMatchArray([ + 'outages_count' => 0, 'downtime_minutes' => 0, 'uptime_percentage' => 100, 'outages' => [], + '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'); + }); + + it('ends an outage where the site starts answering 429, and counts the block apart (down → probe_blocked → up)', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-15 10:00:00', status: 502); + uptimeEvent($site, SiteUptimeEvent::TYPE_PROBE_BLOCKED, '2026-09-15 10:40:00', status: 429); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-15 14:40:00', status: 200); + + $uptime = compileSeptember($site)['data']['uptime']; + + expect($uptime)->toMatchArray(['outages_count' => 1, 'downtime_minutes' => 40, 'probe_blocked_minutes' => 240, 'probe_blocked_since' => null]) + ->and($uptime['outages'])->toBe([['started_at' => '2026-09-15T10:00:00+00:00', 'minutes' => 40, 'started_before_period' => false, 'ongoing' => false]]); + }); + + it('reads the same from events the real uptime state machine wrote', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true, 'uptime_state' => 'down', 'uptime_consecutive_failures' => 2, 'uptime_down_since' => Carbon::parse('2026-09-15 10:00:00')]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-15 10:00:00', status: 502); + $chat = $this->mock(ChatNotifier::class); + $chat->shouldNotReceive('siteWentUp'); + $chat->shouldNotReceive('siteWentDown'); + $chat->shouldReceive('uptimeProbeBlocked')->once()->andReturn(true); + $this->mock(SmsNotifier::class)->shouldNotReceive('siteWentUp'); + + // A down site that then answers only 429 for the configured six + // hours: the outage is closed at the first 429. + $blocked = UptimeProbeResult::blocked(429, 150, 'HTTP 429 (rate-limited — probe blocked)'); + for ($at = Carbon::parse('2026-09-15 10:40:00'); $at->lessThanOrEqualTo(Carbon::parse('2026-09-15 16:40:00')); $at->addMinutes(30)) { + $this->travelTo($at); + app(UptimeStateUpdater::class)->update($site->fresh(), $blocked); + } + $this->travelTo(Carbon::parse('2026-09-15 17:40:00')); + app(UptimeStateUpdater::class)->update($site->fresh(), UptimeProbeResult::success(200, 120)); + $this->travelBack(); + $site = $site->fresh(); + $site->forceFill(['uptime_last_checked_at' => now()])->save(); + + expect(SiteUptimeEvent::where('site_id', $site->id)->orderBy('event_at')->get()->map(fn ($e) => [$e->event_type, $e->event_at->format('H:i')])->all()) + ->toBe([[SiteUptimeEvent::TYPE_DOWN, '10:00'], [SiteUptimeEvent::TYPE_PROBE_BLOCKED, '10:40'], [SiteUptimeEvent::TYPE_UP, '17:40']]); + expect(compileSeptember($site)['data']['uptime']) + ->toMatchArray(['outages_count' => 1, 'downtime_minutes' => 40, 'probe_blocked_minutes' => 420, 'probe_blocked_since' => null]); + }); + + it('leaves a single 429 on the last probe of the month out of the report', function () { + // 03:55 UTC on Oct 1 is 11:55 PM EDT on Sep 30, the month's last probe. + $site = accuracySite(['uptime_monitoring_enabled' => true, 'uptime_state' => 'up']); + $chat = $this->mock(ChatNotifier::class); + $chat->shouldNotReceive('uptimeProbeBlocked'); + $chat->shouldNotReceive('siteWentUp'); + + $this->travelTo(Carbon::parse('2026-10-01 03:55:00')); + app(UptimeStateUpdater::class)->update($site->fresh(), UptimeProbeResult::blocked(429, 150, 'HTTP 429 (rate-limited — probe blocked)')); + $this->travelTo(Carbon::parse('2026-10-01 04:00:00')); + app(UptimeStateUpdater::class)->update($site->fresh(), UptimeProbeResult::success(200, 120)); + $this->travelBack(); + $site = $site->fresh(); + $site->forceFill(['uptime_last_checked_at' => now()])->save(); + + $report = compileSeptember($site); + + expect(SiteUptimeEvent::where('site_id', $site->id)->count())->toBe(0) + ->and(ActionLog::where('site_id', $site->id)->where('action_type', ActionLog::TYPE_UPTIME_TRANSITION)->count())->toBe(0) + ->and($report['data']['uptime'])->toMatchArray(['probe_blocked_since' => null, 'probe_blocked_minutes' => 0]) + ->and(implode(' ', $report['reasons']))->not->toContain('HTTP 429'); + }); + + it('says the monitor was blocked, not that the site was down, when a probe_blocked stretch is open at the end of the month', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-30 22:00:00', status: 503); + uptimeEvent($site, SiteUptimeEvent::TYPE_PROBE_BLOCKED, '2026-09-30 23:40:00', status: 429); + + $report = compileSeptember($site); + + expect($report['data']['uptime'])->toMatchArray([ + 'outages_count' => 1, 'downtime_minutes' => 100, 'probe_blocked_minutes' => 260, 'probe_blocked_since' => '2026-09-30T23:40:00+00:00', + ]) + ->and($report['data']['uptime']['outages'][0]['ongoing'])->toBeFalse() + ->and($report['text'])->not->toContain('still down') + ->and($report['reasons'])->toContain('The uptime monitor was blocked (HTTP 429) from Sep 30, 7:40 PM EDT; the site was not necessarily down.') + ->not->toContain('The site was still down at the end of the month.'); + }); + + it('starts a real outage when a blocked site goes down', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_PROBE_BLOCKED, '2026-09-10 08:00:00', status: 429); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-10 09:00:00', status: 502); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-10 10:00:00', status: 200); + + $uptime = compileSeptember($site)['data']['uptime']; + + expect($uptime)->toMatchArray(['outages_count' => 1, 'downtime_minutes' => 60, 'probe_blocked_minutes' => 60]) + ->and($uptime['outages'][0])->toMatchArray(['started_at' => '2026-09-10T09:00:00+00:00', 'minutes' => 60]); + }); + + it('carries a probe_blocked stretch in from before the period as blocked', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_PROBE_BLOCKED, '2026-09-01 02:00:00', status: 429); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-01 07:00:00', status: 200); + + expect(compileSeptember($site)['data']['uptime'])->toMatchArray([ + 'outages_count' => 0, 'downtime_minutes' => 0, 'outages' => [], 'probe_blocked_minutes' => 180, 'probe_blocked_since' => null, + ]); + }); + + it('joins a 429 down recorded before the monitor had a blocked state to the probe_blocked event after it', function () { + // Deployed mid-block: the old row (down, 429) and the new one + // (probe_blocked) are one stretch, which began with the old row. + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-29 10:00:00', status: 429); + uptimeEvent($site, SiteUptimeEvent::TYPE_PROBE_BLOCKED, '2026-09-30 10:00:00', status: 429); + + $report = compileSeptember($site); + + expect($report['data']['uptime'])->toMatchArray([ + 'outages_count' => 0, 'downtime_minutes' => 0, 'outages' => [], + 'probe_blocked_minutes' => 42 * 60, 'probe_blocked_since' => '2026-09-29T10:00:00+00:00', + ]) + ->and($report['reasons'])->toContain('The uptime monitor was blocked (HTTP 429) from Sep 29, 6:00 AM EDT; the site was not necessarily down.'); + }); }); it('leaves uptime out for a site the probe skips, instead of claiming 100%', function () { @@ -553,7 +1708,8 @@ function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false) expect($report['data']['uptime'])->toMatchArray(['monitored' => false, 'probe_stopped' => false]) ->and($report['html'])->not->toContain('around the clock')->not->toContain('Uptime') ->and($report['text'])->not->toContain('UPTIME') - ->and($preview)->not->toContain('Uptime & Availability') + ->and($preview)->not->toContain('Uptime & Availability') + ->toContain('Uptime hidden from the client: the uptime probe doesn't check this site.') ->and(implode(' ', ReportReviewFlags::reasons($report['data'])))->not->toContain('Uptime'); }); @@ -579,6 +1735,288 @@ function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false) }); }); +describe('the period in the report timezone', function () { + it('keeps an outage at 01:00 UTC on the 1st in the month before, where its local time falls', function () { + // 01:00 UTC on Sep 1 is Aug 31, 9:00 PM EDT; 01:00 UTC on Oct 1 is Sep 30. + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-01 01:00:00', status: 502); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-01 01:30:00', status: 200); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-10-01 01:00:00', status: 502); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-10-01 01:30:00', status: 200); + + $report = compileSeptember($site); + + expect($report['data']['uptime'])->toMatchArray(['outages_count' => 1, 'downtime_minutes' => 30, 'total_period_minutes' => 30 * 24 * 60]) + ->and($report['data']['uptime']['outages'][0])->toMatchArray(['started_at' => '2026-10-01T01:00:00+00:00', 'started_before_period' => false]) + ->and($report['text'])->toContain('- Down Sep 30, 9:00 PM EDT for 30 min')->not->toContain('Aug 31') + ->and($report['html'])->toContain('Down Sep 30, 9:00 PM EDT')->not->toContain('Aug 31'); + }); + + it('counts uptime monitoring that began late on the first day, local time, as the whole month', function () { + // 01:30 UTC on Sep 2 is still Sep 1 in New York. + $site = accuracySite(['uptime_monitoring_enabled' => true, 'created_at' => Carbon::parse('2026-09-02 01:00:00')]); + uptimeSeed($site, '2026-09-02 01:30:00'); + + $report = compileSeptember($site); + + expect($report['data']['meta']['coverage_start'])->toBeNull() + ->and($report['data']['uptime'])->toMatchArray(['monitored_since' => null, 'total_period_minutes' => 30 * 24 * 60]) + ->and($report['text'])->not->toContain('onitoring began') + ->and(implode(' ', $report['reasons']))->not->toContain('onitoring began'); + }); + + it('reads a site added late on the last day, local time, as inside the period, not after it', function () { + // 02:00 UTC on Oct 1 is Sep 30, 10:00 PM EDT. + $site = accuracySite(['uptime_monitoring_enabled' => true, 'created_at' => Carbon::parse('2026-10-01 02:00:00')]); + uptimeSeed($site, '2026-10-01 02:00:00'); + + $report = compileSeptember($site); + $note = 'Monitoring began Sep 30, so this report covers Sep 30 – Sep 30.'; + + expect($report['data']['meta']['coverage_start'])->toBe('2026-10-01T02:00:00+00:00') + ->and($report['data']['uptime'])->toMatchArray(['monitored' => true, 'monitored_since' => '2026-10-01T02:00:00+00:00', 'total_period_minutes' => 120]) + ->and($report['text'])->toContain($note)->not->toContain('after the end') + ->and($report['reasons'])->toContain('Monitoring began Sep 30, so this report covers only part of the month.') + ->and(implode(' ', $report['reasons']))->not->toContain('after the end')->not->toContain('Uptime monitoring began'); + }); + + it('shows an outage carried in from before the period as since before the local first day', function () { + expect(ReportPhrases::outageStart(['started_at' => '2026-09-01T04:00:00+00:00', 'started_before_period' => true])) + ->toBe('Down since before Sep 1') + // Reports compiled before the period was measured locally clamped to midnight UTC. + ->and(ReportPhrases::outageStart(['started_at' => '2026-09-01T00:00:00+00:00', 'started_before_period' => true])) + ->toBe('Down since before Sep 1'); + }); +}); + +describe('partial-month coverage', function () { + /** The seed transition the uptime probe (or the one-off backfill) writes. */ + function uptimeSeed(Site $site, string $at, bool $backfilled = false): void + { + app(ActionLogger::class)->record( + actionType: ActionLog::TYPE_UPTIME_TRANSITION, + summary: "{$site->domain} monitoring started — confirmed up", + site: $site, + target: 'up', + details: ['status_code' => 200, 'seed' => true] + ($backfilled ? ['backfilled' => true] : []), + ok: true, + actor: $backfilled ? 'backfill' : 'scheduled', + ranAt: Carbon::parse($at), + ); + } + + it('measures uptime from when monitoring began for a site enrolled part-way through the month', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true, 'uptime_state' => 'unknown', 'created_at' => Carbon::parse('2026-09-26 18:10:00')]); + + // The first successful probe seeds the start of monitoring, exactly as the probe does. + $this->mock(ChatNotifier::class)->shouldNotReceive('siteWentUp'); + $this->mock(SmsNotifier::class)->shouldNotReceive('siteWentUp'); + $this->travelTo(Carbon::parse('2026-09-26 18:20:00')); + app(UptimeStateUpdater::class)->update($site, UptimeProbeResult::success(200, 150)); + $this->travelBack(); + $site->forceFill(['uptime_last_checked_at' => now()])->save(); + + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-27 10:00:00', status: 503); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-27 11:42:00', status: 200); + + $report = compileSeptember($site); + $preview = view('client-reports::reports.show', ['report' => ClientReport::latest('id')->first(), 'isOperatorPreview' => true])->render(); + $note = 'Monitoring began Sep 26, so this report covers Sep 26 – Sep 30.'; + + // 102 minutes down out of the 6,340 it was watched (to midnight EDT + // on Sep 30), not out of the whole month's 43,200. + expect($report['data']['meta']['coverage_start'])->toBe('2026-09-26T18:10:00+00:00') + ->and($report['data']['uptime'])->toMatchArray([ + 'monitored' => true, + 'monitored_since' => '2026-09-26T18:20:00+00:00', + 'total_period_minutes' => 6340, + 'downtime_minutes' => 102, + 'uptime_percentage' => 98.39, + 'outages_count' => 1, + ]) + ->and($report['html'])->toContain($note)->toContain('(monitoring began Sep 26)')->toContain('98.39%') + ->and($report['text'])->toContain($note)->toContain('Availability: 98.39% (monitoring began Sep 26)') + ->and($preview)->toContain($note)->toContain('Monitoring began')->not->toContain('24 / 7 / 365') + ->and(ReportReviewFlags::reasons($report['data']))->toContain('Monitoring began Sep 26, so this report covers only part of the month.') + // Uptime began the same day the site was added: one reason, not two. + ->and(implode(' ', ReportReviewFlags::reasons($report['data'])))->not->toContain('Uptime monitoring began'); + }); + + it('falls back to when the site was added when its monitoring has no seed', function () { + // A site whose first probes failed goes straight to down and never gets a seed. + $site = accuracySite(['uptime_monitoring_enabled' => true, 'created_at' => Carbon::parse('2026-09-20 15:00:00')]); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-20 15:10:00', status: 500); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-20 16:10:00', status: 200); + + $data = compileSeptember($site)['data']; + + expect($data['meta']['coverage_start'])->toBe('2026-09-20T15:00:00+00:00') + ->and($data['uptime'])->toMatchArray([ + 'monitored_since' => '2026-09-20T15:00:00+00:00', + 'total_period_minutes' => 15180, + 'downtime_minutes' => 60, + 'uptime_percentage' => 99.6, + ]) + ->and(ReportReviewFlags::reasons($data))->toContain('Monitoring began Sep 20, so this report covers only part of the month.'); + }); + + it('leaves a site watched for the whole month unchanged', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true, 'created_at' => Carbon::parse('2026-05-01 12:00:00')]); + uptimeSeed($site, '2026-06-01 09:00:00'); + uptimeEvent($site, SiteUptimeEvent::TYPE_DOWN, '2026-09-10 10:00:00', status: 503); + uptimeEvent($site, SiteUptimeEvent::TYPE_UP, '2026-09-10 14:00:00', status: 200); + + $report = compileSeptember($site); + + expect($report['data']['meta']['coverage_start'])->toBeNull() + ->and($report['data']['uptime'])->toMatchArray([ + 'monitored' => true, + 'monitored_since' => null, + 'total_period_minutes' => 30 * 24 * 60, + 'downtime_minutes' => 240, + 'uptime_percentage' => 99.44, + ]) + ->and($report['html'])->not->toContain('Monitoring began')->not->toContain('monitoring began') + ->and($report['text'])->not->toContain('onitoring began') + ->and(implode(' ', ReportReviewFlags::reasons($report['data'])))->not->toContain('Monitoring began'); + }); + + it('counts monitoring that began on the first day as the whole month', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true]); + uptimeSeed($site, '2026-09-01 14:00:00'); + + $data = compileSeptember($site)['data']; + + expect($data['meta']['coverage_start'])->toBeNull() + ->and($data['uptime'])->toMatchArray(['monitored_since' => null, 'total_period_minutes' => 30 * 24 * 60]) + ->and(ReportReviewFlags::reasons(['meta' => $data['meta'], 'uptime' => $data['uptime']]))->toBe([]); + }); + + it('ignores a backfilled seed, which is dated when the backfill ran rather than when monitoring began', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true, 'created_at' => Carbon::parse('2026-05-01 12:00:00')]); + uptimeSeed($site, '2026-09-15 12:00:00', backfilled: true); + + $data = compileSeptember($site)['data']; + + expect($data['meta']['coverage_start'])->toBeNull() + ->and($data['uptime'])->toMatchArray(['monitored_since' => null, 'total_period_minutes' => 30 * 24 * 60]); + }); + + it('leaves uptime out and says so when monitoring began only after the period', function () { + $site = accuracySite(['uptime_monitoring_enabled' => true, 'created_at' => Carbon::parse('2026-05-01 12:00:00')]); + uptimeSeed($site, '2026-10-05 16:00:00'); + + $report = compileSeptember($site); + $preview = view('client-reports::reports.show', ['report' => ClientReport::latest('id')->first(), 'isOperatorPreview' => true])->render(); + + // The site was there all month, so only the uptime section is left + // out; nothing says the report covers none of the month. + expect($report['data']['meta']['coverage_start'])->toBeNull() + ->and($report['data']['uptime'])->toMatchArray(['monitored' => false, 'probe_stopped' => false, 'monitored_since' => '2026-10-05T16:00:00+00:00']) + ->and($report['html'])->not->toContain('Uptime & availability')->not->toContain('Monitoring began') + ->and($report['text'])->not->toContain('UPTIME')->not->toContain('Monitoring began') + ->and($preview)->toContain('Uptime hidden from the client: uptime monitoring began Oct 5, after this period.') + ->and(ReportReviewFlags::reasons($report['data'])) + ->toContain('Uptime monitoring began Oct 5, after this period, so the Uptime section was left out.') + ->and(implode(' ', ReportReviewFlags::reasons($report['data'])))->not->toContain('covers none of it'); + }); + + /** A care-plan toggle row, as the site page's toggle (or a one-off CLI run) writes it. */ + function carePlanToggle(Site $site, string $at, array $details, string $target = 'on'): void + { + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_CARE_PLAN_TOGGLED, 'target' => $target, + 'summary' => "Care plan toggled on {$site->domain}", 'details' => $details, 'ok' => true, 'ran_at' => Carbon::parse($at), 'actor' => 'manual']); + } + + it('treats a care plan switched on mid-month as the start of what the report covers', function () { + $site = accuracySite(['created_at' => Carbon::parse('2026-04-10 12:00:00')]); + carePlanToggle($site, '2026-08-03 21:05', ['now' => false, 'override' => true, 'previous' => true], 'off'); + carePlanToggle($site, '2026-09-26 16:20', ['now' => true, 'override' => true, 'previous' => false]); + + $report = compileSeptember($site); + $note = 'Your care plan began Sep 26, so this report covers Sep 26 – Sep 30.'; + + expect($report['data']['meta'])->toMatchArray(['coverage_start' => '2026-09-26T16:20:00+00:00', 'coverage_source' => 'care_plan']) + ->and($report['html'])->toContain($note) + ->and($report['text'])->toContain($note) + ->and($report['preview'])->toContain($note) + ->and($report['reasons'])->toContain('The care plan was switched on Sep 26, so this report covers only part of the month.') + ->and(implode(' ', $report['reasons']))->not->toContain('Monitoring began'); + }); + + it('reads a care plan switched on from the command line the same way', function () { + $site = accuracySite(['created_at' => Carbon::parse('2026-04-10 12:00:00')]); + carePlanToggle($site, '2026-09-20 15:00', ['enabled' => true, 'override' => true, 'previous' => false], ''); + + expect(compileSeptember($site)['data']['meta'])->toMatchArray(['coverage_start' => '2026-09-20T15:00:00+00:00', 'coverage_source' => 'care_plan']); + }); + + it('takes the later of the site being added and its care plan being switched on', function () { + $site = accuracySite(['created_at' => Carbon::parse('2026-09-24 12:00:00')]); + carePlanToggle($site, '2026-09-10 12:00', ['now' => true, 'override' => true, 'previous' => false]); + + $report = compileSeptember($site); + + expect($report['data']['meta'])->toMatchArray(['coverage_start' => '2026-09-24T12:00:00+00:00', 'coverage_source' => 'site_added']) + ->and($report['reasons'])->toContain('Monitoring began Sep 24, so this report covers only part of the month.'); + }); + + it('does not read a care plan that was already on, re-synced, or switched on on the first day as a late start', function (array $details, string $at) { + $site = accuracySite(['created_at' => Carbon::parse('2026-04-10 12:00:00')]); + carePlanToggle($site, $at, $details); + + expect(compileSeptember($site)['data']['meta'])->toMatchArray(['coverage_start' => null, 'coverage_source' => null]); + })->with([ + 'already on' => [['now' => true, 'override' => true, 'previous' => true, 'already_enabled' => true], '2026-09-20 15:00'], + 're-synced' => [['source' => 'manual-resync'], '2026-09-20 15:00'], + 'override cleared' => [['override' => null], '2026-09-20 15:00'], + 'the first day' => [['now' => true, 'override' => true, 'previous' => false], '2026-09-01 15:00'], + // 10 PM EDT on Aug 31. + 'the evening before the period' => [['now' => true, 'override' => true, 'previous' => false], '2026-09-01 02:00'], + 'before the period' => [['now' => true, 'override' => true, 'previous' => false], '2026-08-20 15:00'], + ]); + + it('ignores care-plan toggles while care plans are switched off fleet-wide', function () { + config(['clockwork.care_plans.enabled' => false]); + $site = accuracySite(['created_at' => Carbon::parse('2026-04-10 12:00:00')]); + carePlanToggle($site, '2026-09-20 15:00', ['now' => true, 'override' => true, 'previous' => false]); + + expect(compileSeptember($site)['data']['meta']['coverage_start'])->toBeNull(); + }); + + it('says only the uptime is partial when monitoring is switched on mid-month for a site added long before', function () { + // Added months ago with uptime monitoring off; an update ran on the + // 5th, then monitoring was switched on and first probed on the 17th. + $site = accuracySite([ + 'created_at' => Carbon::parse('2026-04-10 12:00:00'), + 'uptime_monitoring_enabled' => false, + 'uptime_state' => 'unknown', + ]); + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE, 'target' => 'example-forms/example-forms.php', + 'summary' => 'Plugin updated: Example Forms 1.0 → 1.1', 'ok' => true, 'ran_at' => Carbon::parse('2026-09-05 14:00'), 'actor' => 'test']); + + $site->forceFill(['uptime_monitoring_enabled' => true])->save(); + $this->mock(ChatNotifier::class)->shouldNotReceive('siteWentUp'); + $this->mock(SmsNotifier::class)->shouldNotReceive('siteWentUp'); + $this->travelTo(Carbon::parse('2026-09-17 14:00:00')); + app(UptimeStateUpdater::class)->update($site->fresh(), UptimeProbeResult::success(200, 150)); + $this->travelBack(); + $site = $site->fresh(); + $site->forceFill(['uptime_last_checked_at' => now()])->save(); + + $report = compileSeptember($site); + + expect($report['data']['meta']['coverage_start'])->toBeNull() + ->and($report['data']['uptime']['monitored_since'])->toBe('2026-09-17T14:00:00+00:00') + ->and($report['data']['updates']['plugins_count'])->toBe(1) + ->and($report['html'])->not->toContain('so this report covers')->toContain('(monitoring began Sep 17)')->toContain('Example Forms') + ->and($report['text'])->not->toContain('so this report covers')->toContain('(monitoring began Sep 17)') + ->and($report['preview'])->not->toContain('so this report covers') + ->and($report['reasons'])->toContain('Uptime monitoring began Sep 17, so uptime covers only part of the month.') + ->and(implode(' ', $report['reasons']))->not->toContain('this report covers only part'); + }); +}); + describe('reports compiled before these fixes', function () { it('does not re-show their broken forms rate or their unchecked "None" vulnerabilities', function () { $site = accuracySite(); @@ -658,10 +2096,10 @@ function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false) expect($w['data']['updates']['pending_plugins'])->toBe(['WP Rocket']) ->and($w['html'])->toContain('No updates were applied this month.')->not->toContain('already up to date') ->and($w['text'])->toContain('No updates were applied this month.') - ->and($c['html'])->toContain('Everything was already up to date'); - // The preview (what prints as the PDF) follows the same rule. - expect($wPreview)->toContain('No updates were applied this period.')->not->toContain('up-to-date throughout') - ->and($cPreview)->toContain('up-to-date throughout this period'); + ->and($c['html'])->toContain('Everything was up to date at the end of the month.'); + // The preview (what prints as the PDF) says the same sentences. + expect($wPreview)->toContain('No updates were applied this month.')->not->toContain('Everything was up to date') + ->and($cPreview)->toContain('Everything was up to date at the end of the month.'); }); it('does not say "already up to date" while a theme or WordPress update is waiting', function () { @@ -695,14 +2133,44 @@ function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false) ->toContain("Companion hasn't reported since 2026-08-20, so waiting updates and plugin vulnerabilities are out of date."); }); + it('holds a report that applied nothing while updates were waiting, naming them', function () { + $site = accuracySite(['companion_snapshot' => [ + 'plugins' => ['plugins' => [ + ['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'active' => true, 'update_available' => true, 'new_version' => '2.0'], + ['slug' => 'example-seo/example-seo.php', 'name' => 'Example SEO', 'active' => true, 'update_available' => false], + ]], + 'themes' => ['items' => []], 'wp_core' => ['update_available' => true], + ]]); + + $report = compileSeptember($site); + + expect($report['html'])->toContain('No updates were applied this month.') + ->and($report['reasons'])->toContain('No updates were applied this month although updates were waiting: Example Forms, WordPress core, so the client would read "No updates were applied this month". Check why they are failing or paused.'); + }); + + it('does not hold a report for waiting updates when one was applied, or when nothing is known to be waiting', function () { + $applied = accuracySite(['companion_snapshot' => [ + 'plugins' => ['plugins' => [['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'active' => true, 'update_available' => true, 'new_version' => '2.0']]], + 'themes' => ['items' => []], 'wp_core' => ['update_available' => false], + ]]); + ActionLog::create(['site_id' => $applied->id, 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE, 'target' => 'example-seo/example-seo.php', + 'summary' => 'Plugin updated: Example SEO 1.0 → 1.1', 'ok' => true, 'ran_at' => Carbon::parse('2026-09-12 06:00'), 'actor' => 'test']); + // No snapshot at all: nothing is known either way. + $unknown = accuracySite(['companion_snapshot' => null, 'companion_snapshot_at' => null]); + + expect(implode(' ', compileSeptember($applied)['reasons']))->not->toContain('No updates were applied') + ->and(implode(' ', compileSeptember($unknown)['reasons']))->not->toContain('No updates were applied'); + }); + it('counts an update applied after the period as waiting at its end', function () { // A held report recompiled after the month, or a manual update applied on the 1st. $site = accuracySite(['companion_snapshot' => [ 'plugins' => ['plugins' => [['name' => 'WP Rocket', 'active' => true, 'update_available' => false]]], 'themes' => ['items' => []], 'wp_core' => ['update_available' => false], ]]); + // 06:40 UTC on Oct 1 is Oct 1 in the report timezone too. ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE, 'target' => 'wp-rocket/wp-rocket.php', - 'summary' => 'Plugin updated: WP Rocket 3.1 → 3.2', 'ok' => true, 'ran_at' => Carbon::parse('2026-10-01 01:40'), 'actor' => 'test']); + 'summary' => 'Plugin updated: WP Rocket 3.1 → 3.2', 'ok' => true, 'ran_at' => Carbon::parse('2026-10-01 06:40'), 'actor' => 'test']); $report = compileSeptember($site); @@ -710,6 +2178,165 @@ function uptimeEvent(Site $site, string $type, string $at, bool $exempt = false) ->and($report['html'])->toContain('No updates were applied this month.')->not->toContain('already up to date'); }); + it('dates updates by the report timezone\'s calendar day, as the uptime section does', function () { + $site = accuracySite(); + $update = fn (string $at, string $name) => ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE, + 'target' => strtolower($name).'/x.php', 'summary' => "Plugin updated: {$name} 1.0 → 1.1", 'ok' => true, 'ran_at' => Carbon::parse($at), 'actor' => 'test']); + // 8:50 PM EDT on Sep 15. + $update('2026-09-16 00:50', 'Evening'); + // 9:30 PM EDT on Aug 31: the month before. + $update('2026-09-01 01:30', 'August'); + // 9:30 PM EDT on Sep 30: still September. + $update('2026-10-01 01:30', 'Lastday'); + + $report = compileSeptember($site); + $dates = array_column($report['data']['updates']['items']['plugins'], 'date', 'target'); + + expect($dates)->toBe(['lastday/x.php' => '2026-09-30', 'evening/x.php' => '2026-09-15']) + ->and($report['data']['updates']['total'])->toBe(2) + ->and($report['text'])->toContain('Evening 1.0 → 1.1 (Sep 15)')->toContain('Lastday 1.0 → 1.1 (Sep 30)')->not->toContain('August') + ->and($report['html'])->toContain('Sep 15')->not->toContain('Sep 16'); + }); + + it('leaves the agency\'s own connector plugins out of the client\'s updates and counts', function () { + $site = accuracySite(); + foreach ([ + ['clockwork-renegade/clockwork-renegade.php', 'Plugin updated: Clockwork Renegade 1.0.4 → 1.0.5'], + ['clockwork-companion/clockwork-companion.php', 'Plugin updated: Clockwork Companion 1.38.0 → 1.39.0'], + ['example-forms/example-forms.php', 'Plugin updated: Example Forms 2.0 → 2.1'], + ] as $i => [$target, $summary]) { + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE, 'target' => $target, + 'summary' => $summary, 'details' => ['slug' => $target], 'ok' => true, 'ran_at' => Carbon::parse('2026-09-1'.$i.' 06:00'), 'actor' => 'auto']); + } + + $report = compileSeptember($site); + + expect($report['data']['updates'])->toMatchArray(['total' => 1, 'plugins_count' => 1]) + ->and($report['html'])->not->toContain('Clockwork Renegade')->not->toContain('Clockwork Companion')->toContain('Example Forms 2.0 → 2.1') + ->and($report['text'])->toContain('UPDATES APPLIED (1)')->not->toContain('Clockwork Renegade')->not->toContain('Clockwork Companion'); + }); + + it('never says no updates were needed, only that nothing was waiting at the end of the month', function () { + $site = accuracySite(['companion_snapshot' => [ + 'plugins' => ['plugins' => [['name' => 'Akismet', 'active' => true, 'update_available' => false]]], + 'themes' => ['items' => []], 'wp_core' => ['current_version' => '6.8.3', 'update_available' => false], + ]]); + + $report = compileSeptember($site); + + expect($report['html'])->toContain('Everything was up to date at the end of the month.') + ->not->toContain('no updates were needed')->not->toContain('already up to date') + ->and($report['text'])->toContain('Everything was up to date at the end of the month.')->not->toContain('already up to date'); + }); + + it('lists version changes made outside Control, once each, labelled like any other update', function () { + $site = accuracySite(['companion_snapshot' => [ + 'plugins' => ['plugins' => [['slug' => 'example-gallery/example-gallery.php', 'name' => 'Example Gallery', 'version' => '2.1', 'active' => true, 'update_available' => false]]], + 'themes' => ['items' => []], 'wp_core' => ['current_version' => '6.8.3', 'update_available' => false], + ]]); + $observed = fn (string $type, string $slug, string $name, string $from, string $to, string $at) => ActionLog::create([ + 'site_id' => $site->id, 'action_type' => $type, 'target' => $slug, 'summary' => "{$name} {$from} → {$to} (seen)", 'ok' => true, + 'details' => ['slug' => $slug, 'name' => $name, 'from_version' => $from, 'to_version' => $to, 'observed_at' => Carbon::parse($at)->toIso8601String()], + 'ran_at' => Carbon::parse($at), 'actor' => 'observed', + ]); + $observed('plugin_update_observed', 'example-gallery/example-gallery.php', 'Example Gallery', '2.0', '2.1', '2026-09-12 07:00'); + // WordPress gives theme names HTML-encoded. + $observed('theme_update_observed', 'example-theme', 'Example Theme & Blocks', '1.4', '1.5', '2026-09-14 07:00'); + $observed('core_update_observed', 'wordpress', 'WordPress', '6.8.2', '6.8.3', '2026-09-20 07:00'); + // Control applied this one itself and the snapshot saw the change too: listed once. + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE, 'target' => 'example-seo/example-seo.php', + 'summary' => 'Plugin updated: Example SEO 5.0 → 5.1', 'ok' => true, 'ran_at' => Carbon::parse('2026-09-15 06:00'), 'actor' => 'auto', + 'details' => ['slug' => 'example-seo/example-seo.php', 'before_version' => '5.0', 'after_version' => '5.1']]); + $observed('plugin_update_observed', 'example-seo/example-seo.php', 'Example SEO', '5.0', '5.1', '2026-09-16 07:00'); + // The agency's own plugin and a change seen before the period stay out. + $observed('plugin_update_observed', 'clockwork-companion/clockwork-companion.php', 'Clockwork Companion', '1.38.0', '1.39.0', '2026-09-17 07:00'); + $observed('plugin_update_observed', 'example-old/example-old.php', 'Example Old', '1.0', '1.1', '2026-08-30 07:00'); + + $report = compileSeptember($site); + $updates = $report['data']['updates']; + + expect($updates)->toMatchArray(['total' => 4, 'plugins_count' => 2, 'themes_count' => 1, 'core_count' => 1]) + ->and(array_column($updates['items']['plugins'], 'observed', 'target'))->toBe(['example-seo/example-seo.php' => false, 'example-gallery/example-gallery.php' => true]) + ->and($report['html'])->toContain('Example Gallery 2.0 → 2.1')->toContain('Example Theme & Blocks 1.4 → 1.5')->not->toContain('&amp;')->toContain('6.8.2 → 6.8.3') + ->not->toContain('(seen)')->not->toContain('Clockwork Companion')->not->toContain('Example Old') + ->and(substr_count($report['html'], 'Example SEO 5.0 → 5.1'))->toBe(1) + ->and($report['text'])->toContain('UPDATES APPLIED (4)')->toContain('- [Plugin] Example Gallery 2.0 → 2.1 (Sep 12)') + ->toContain('- [Theme] Example Theme & Blocks 1.4 → 1.5 (Sep 14)'); + }); + + it('counts a version change seen after the period as waiting at its end', function () { + $site = accuracySite(['companion_snapshot' => [ + 'plugins' => ['plugins' => []], 'themes' => ['items' => []], 'wp_core' => ['update_available' => false], + ]]); + ActionLog::create(['site_id' => $site->id, 'action_type' => 'plugin_update_observed', 'target' => 'example-gallery/example-gallery.php', + 'summary' => 'Example Gallery 2.0 → 2.1', 'ok' => true, 'ran_at' => Carbon::parse('2026-10-02 07:00'), 'actor' => 'system', + 'details' => ['slug' => 'example-gallery/example-gallery.php', 'name' => 'Example Gallery', 'from_version' => '2.0', 'to_version' => '2.1', 'observed_at' => '2026-10-02T07:00:00+00:00']]); + + $report = compileSeptember($site); + + expect($report['data']['updates'])->toMatchArray(['total' => 0, 'updated_since_period' => 1]) + ->and($report['html'])->toContain('No updates were applied this month.'); + }); + + it('does not list an update that was undone, and holds the report to say so', function () { + // The nightly run replaced a custom child theme with an unrelated + // directory theme of the same slug; it was restored and paused. + $site = accuracySite(['companion_snapshot_at' => Carbon::parse('2026-10-01 06:00'), 'companion_snapshot' => [ + 'plugins' => ['plugins' => []], + 'themes' => ['items' => [['name' => 'Example Child Theme', 'slug' => 'example-child', 'version' => '2.0.3', 'update_available' => false]]], + 'wp_core' => ['update_available' => false], + ]]); + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_THEME_UPDATE, 'target' => 'example-child', + 'summary' => 'Theme updated: Example Child Theme 2.0.3 → 2.4.0', 'ok' => true, 'ran_at' => Carbon::parse('2026-09-01 09:30'), 'actor' => 'auto', + 'details' => ['slug' => 'example-child', 'before_version' => '2.0.3', 'after_version' => '2.4.0']]); + PluginUpdateIgnore::create(['site_id' => $site->id, 'target_kind' => 'theme', 'target_slug' => 'example-child', + 'source' => PluginUpdateIgnore::SOURCE_MANUAL, 'note' => 'Custom theme; never update.', 'ignored_at' => Carbon::parse('2026-09-01 15:10')]); + + $report = compileSeptember($site); + + expect($report['data']['updates'])->toMatchArray(['total' => 0, 'themes_count' => 0]) + ->and($report['data']['updates']['not_kept'])->toBe([['type' => 'theme', 'label' => 'Example Child Theme 2.0.3 → 2.4.0', 'date' => '2026-09-01', 'installed_version' => '2.0.3']]) + ->and($report['html'])->not->toContain('2.4.0') + ->and($report['text'])->not->toContain('2.4.0') + ->and($report['reasons'])->toContain('An update was undone after it ran, so it is left out of the updates list: Example Child Theme 2.0.3 → 2.4.0 (Sep 1; the site is on 2.0.3 now). Add context for the client if they noticed.'); + }); + + it('keeps an update whose version is lower now when nothing says it was undone, or when the snapshot is older than the update', function () { + $snapshot = fn (string $at) => ['companion_snapshot_at' => Carbon::parse($at), 'companion_snapshot' => [ + 'plugins' => ['plugins' => [['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'version' => '2.0', 'update_available' => false]]], + 'themes' => ['items' => []], 'wp_core' => ['update_available' => false], + ]]; + $updated = function (Site $site) { + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE, 'target' => 'example-forms/example-forms.php', + 'summary' => 'Plugin updated: Example Forms 2.0 → 2.1', 'ok' => true, 'ran_at' => Carbon::parse('2026-09-20 06:00'), 'actor' => 'auto', + 'details' => ['slug' => 'example-forms/example-forms.php', 'before_version' => '2.0', 'after_version' => '2.1']]); + }; + $noEvidence = accuracySite($snapshot('2026-10-01 06:00')); + $updated($noEvidence); + // Paused afterwards, but the snapshot predates the update, so it can't say what's installed since. + $oldSnapshot = accuracySite($snapshot('2026-09-19 06:00')); + $updated($oldSnapshot); + PluginUpdateIgnore::create(['site_id' => $oldSnapshot->id, 'target_kind' => 'plugin', 'target_slug' => 'example-forms/example-forms.php', + 'source' => PluginUpdateIgnore::SOURCE_AUTO_FAILURE, 'ignored_at' => Carbon::parse('2026-09-25 06:00')]); + + expect(compileSeptember($noEvidence)['data']['updates']['plugins_count'])->toBe(1) + ->and(compileSeptember($oldSnapshot)['data']['updates']['plugins_count'])->toBe(1); + }); + + it('reads an auto-pause logged after the update as the update not sticking', function () { + $site = accuracySite(['companion_snapshot_at' => Carbon::parse('2026-10-01 06:00'), 'companion_snapshot' => [ + 'plugins' => ['plugins' => [['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'version' => '2.0', 'update_available' => true, 'new_version' => '2.1']]], + 'themes' => ['items' => []], 'wp_core' => ['update_available' => false], + ]]); + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE, 'target' => 'example-forms/example-forms.php', + 'summary' => 'Plugin updated: Example Forms 2.0 → 2.1', 'ok' => true, 'ran_at' => Carbon::parse('2026-09-20 06:00'), 'actor' => 'auto', + 'details' => ['slug' => 'example-forms/example-forms.php', 'before_version' => '2.0', 'after_version' => '2.1']]); + ActionLog::create(['site_id' => $site->id, 'action_type' => ActionLog::TYPE_PLUGIN_UPDATE_AUTO_IGNORED, 'target' => 'example-forms/example-forms.php', + 'summary' => 'Paused updates for Example Forms', 'ok' => true, 'ran_at' => Carbon::parse('2026-09-25 06:00'), 'actor' => 'auto']); + + expect(compileSeptember($site)['data']['updates'])->toMatchArray(['plugins_count' => 0, 'total' => 0]); + }); + it('reads the WordPress version from the Companion snapshot', function () { $site = accuracySite(['companion_snapshot' => ['wp_core' => ['current_version' => '6.8.3'], 'plugins' => ['plugins' => []]]]); diff --git a/tests/Feature/Modules/ClientReportClientsTest.php b/tests/Feature/Modules/ClientReportClientsTest.php new file mode 100644 index 00000000..354e5a8e --- /dev/null +++ b/tests/Feature/Modules/ClientReportClientsTest.php @@ -0,0 +1,375 @@ +mockIssueCounterZero(); + $this->user = User::factory()->create(); +}); + +afterEach(fn () => Carbon::setTestNow()); + +function clientsSite(?Client $client, string $domain, bool $carePlan = true, array $attributes = []): Site +{ + return Site::factory()->create(array_merge([ + 'domain' => $domain, + 'client_id' => $client?->id, + 'care_plan_enabled' => $carePlan, + ], $attributes)); +} + +function clientsSchedule(Site $site, array $attributes = []): ClientReportSchedule +{ + return ClientReportSchedule::create(array_merge([ + 'site_id' => $site->id, + 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => ['owner@example.com'], + 'is_enabled' => true, + 'next_run_at' => Carbon::parse('2026-12-01'), + ], $attributes)); +} + +/** + * The Clients page cut in two: the listed sections, and the collapsed "No + * care plan" section folded away below them ('' when there is none). + * + * @return array{listed: string, folded: string} + */ +function clientsSections(string $html): array +{ + $at = strpos($html, 'id="cr-no-care-plan"'); + + return $at === false + ? ['listed' => $html, 'folded' => ''] + : ['listed' => substr($html, 0, $at), 'folded' => substr($html, $at)]; +} + +function clientsReport(Site $site, string $month, array $attributes = [], array $meta = []): ClientReport +{ + $start = Carbon::parse($month.'-01'); + + return ClientReport::create(array_merge([ + 'site_id' => $site->id, + 'title' => "Report {$site->domain} {$month}", + 'period_start' => $start, + 'period_end' => $start->copy()->endOfMonth(), + 'sections_data' => ['meta' => array_merge(['schema_version' => ClientReportCompiler::SCHEMA_VERSION], $meta)], + 'client_email' => 'owner@example.com', + 'status' => ClientReport::STATUS_GENERATED, + ], $attributes)); +} + +describe('Clients tab', function () { + it('is the module landing page and needs a login', function () { + auth()->logout(); + $this->get(route('client-reports.index'))->assertRedirect(route('login')); + $this->get(route('client-reports.clients.show', 'unassigned'))->assertRedirect(route('login')); + }); + + it('lists each client with its sites\' care plan, schedule, recipients and a month-by-month history linking to each report', function () { + $acme = Client::create(['name' => 'Jane Example', 'company_name' => 'Acme Example Co', 'email' => 'jane@example.com']); + $covered = clientsSite($acme, 'covered.example.com'); + $schedule = clientsSchedule($covered, ['recipients' => ['jane@example.com', 'ops@example.com']]); + $sent = clientsReport($covered, '2026-09', ['schedule_id' => $schedule->id, 'status' => ClientReport::STATUS_SENT, 'sent_to' => ['jane@example.com']]); + $held = clientsReport($covered, '2026-10', ['schedule_id' => $schedule->id], ['review_reasons' => ['No backups were found this month.']]); + clientsSite($acme, 'nocare.example.com', carePlan: false); + clientsSite($acme, 'gap.example.com'); + + $html = $this->actingAs($this->user)->get(route('client-reports.index'))->assertOk()->getContent(); + + expect($html) + ->toContain('Acme Example Co') + ->toContain('Jane Example') + ->toContain('covered.example.com') + ->toContain('jane@example.com, ops@example.com') + ->toContain('Scheduled · auto') + ->toContain('No care plan — reports won\'t send') + ->toContain('action="'.route('client-reports.schedules.quick', Site::where('domain', 'gap.example.com')->first()).'"') + // History strip: a link per month that has a report, titled with its state. + ->toContain('href="'.route('client-reports.show', $sent).'"') + ->toContain('Sep 2026: Sent') + ->toContain('href="'.route('client-reports.show', $held).'"') + ->toContain('Oct 2026: Needs review') + ->toContain('Aug 2026: no report'); + }); + + it('marks months a no-care-plan site\'s schedule skipped', function () { + $client = Client::create(['name' => 'Skip Example', 'email' => 'skip@example.com']); + $site = clientsSite($client, 'skipped.example.com', carePlan: false); + $schedule = clientsSchedule($site); + $schedule->forceFill(['created_at' => Carbon::parse('2026-09-15')])->save(); + + $this->actingAs($this->user)->get(route('client-reports.index')) + ->assertOk() + ->assertSee('Oct 2026: skipped (no care plan)') + ->assertSee('Sep 2026: skipped (no care plan)') + ->assertSee('Aug 2026: no report'); + }); + + it('splits sites without a client by care plan, each Unassigned group after the clients of its kind', function () { + $zed = Client::create(['name' => 'Zed Example', 'email' => 'zed@example.com']); + clientsSite($zed, 'zed.example.com'); + Client::create(['name' => 'Empty Example', 'email' => 'empty@example.com']); // no sites: no care plan + clientsSite(null, 'orphan.example.com'); + clientsSite(null, 'orphan-nocare.example.com', carePlan: false); + + $sections = clientsSections($this->actingAs($this->user)->get(route('client-reports.index', ['sort' => 'az']))->assertOk()->getContent()); + + expect($sections['listed'])->toContain('Zed Example')->toContain('Unassigned sites')->toContain('orphan.example.com') + ->not->toContain('orphan-nocare.example.com')->not->toContain('Empty Example') + ->and(strpos($sections['listed'], 'Zed Example'))->toBeLessThan(strpos($sections['listed'], 'Unassigned sites')) + ->and($sections['folded'])->toContain('Unassigned sites')->toContain('orphan-nocare.example.com')->toContain('Empty Example') + ->not->toContain('>orphan.example.com<'); + }); + + it('lists care-plan clients first and folds the rest into a collapsed "No care plan" section by default', function () { + $care = Client::create(['name' => 'Care Example', 'email' => 'care@example.com']); + clientsSite($care, 'care.example.com'); + clientsSite($care, 'care-extra.example.com', carePlan: false); // stays with its client, marked + $none = Client::create(['name' => 'Nocare Example', 'email' => 'nocare@example.com']); + clientsSite($none, 'nocare.example.com', carePlan: false); + + $this->actingAs($this->user); + $html = $this->get(route('client-reports.index'))->assertOk()->getContent(); + $sections = clientsSections($html); + + expect($sections['listed'])->toContain('Care plan clients — reported monthly')->toContain('Care Example') + ->toContain('care-extra.example.com')->toContain('No care plan — reports won\'t send') + ->not->toContain('Nocare Example') + ->and($html)->toMatch('/
    /') // closed: no "open" + ->and($sections['folded'])->toContain('No care plan — reports aren\'t sent')->toContain('1')->toContain('Nocare Example'); + + // "No care plan" lists only those, open; "All" lists both, care-plan first. + $no = $this->get(route('client-reports.index', ['care' => 'no']))->getContent(); + expect($no)->toContain('Nocare Example')->not->toContain('Care plan clients — reported monthly')->not->toContain('id="cr-no-care-plan"'); + $all = $this->get(route('client-reports.index', ['care' => 'all']))->getContent(); + expect($all)->not->toContain('id="cr-no-care-plan"') + ->and(strpos($all, 'Care plan clients — reported monthly'))->toBeLessThan(strpos($all, 'No care plan — reports aren')) + ->and(strpos($all, 'Care Example'))->toBeLessThan(strpos($all, 'Nocare Example')); + }); + + it('puts clients needing attention first by default, or sorts A–Z', function () { + $alpha = Client::create(['name' => 'Alpha Example', 'email' => 'a@example.com']); + $alphaSite = clientsSite($alpha, 'alpha.example.com'); + clientsSchedule($alphaSite); // scheduled, nothing waiting + $mike = Client::create(['name' => 'Mike Example', 'email' => 'm@example.com']); + clientsSite($mike, 'mike.example.com'); // care plan, no schedule + $zulu = Client::create(['name' => 'Zulu Example', 'email' => 'z@example.com']); + $zuluSite = clientsSite($zulu, 'zulu.example.com'); + clientsReport($zuluSite, '2026-10', ['schedule_id' => clientsSchedule($zuluSite)->id], ['review_reasons' => ['No backups were found this month.']]); + + $this->actingAs($this->user); + $attention = $this->get(route('client-reports.index'))->getContent(); + $az = $this->get(route('client-reports.index', ['sort' => 'az']))->getContent(); + + expect(strpos($attention, 'Mike Example'))->toBeLessThan(strpos($attention, 'Zulu Example')) + ->and(strpos($attention, 'Zulu Example'))->toBeLessThan(strpos($attention, 'Alpha Example')) + ->and(strpos($az, 'Alpha Example'))->toBeLessThan(strpos($az, 'Mike Example')) + ->and(strpos($az, 'Mike Example'))->toBeLessThan(strpos($az, 'Zulu Example')); + }); + + it('keeps the care-plan selection, order and filters in every link, and counts the selection in the tiles', function () { + foreach (range(1, ClientsOverviewController::PER_PAGE + 1) as $i) { + clientsSite(Client::create(['name' => sprintf('Nocare %02d Example', $i), 'email' => "n{$i}@example.com"]), sprintf('nocare%02d.example.com', $i), carePlan: false); + } + $care = Client::create(['name' => 'Care Example', 'email' => 'care@example.com']); + clientsSite($care, 'care.example.com'); + + $this->actingAs($this->user); + $no = $this->get(route('client-reports.index', ['care' => 'no', 'sort' => 'az', 'q' => 'nocare']))->assertOk()->getContent(); + + expect($no) + // Pagination keeps every setting. + ->toContain(e(route('client-reports.index', ['q' => 'nocare', 'care' => 'no', 'sort' => 'az', 'page' => 2]))) + // Switching the selection keeps the search and order; the form carries the selection. + ->toContain(e(route('client-reports.index', ['q' => 'nocare', 'sort' => 'az']))) + ->toContain('') + ->toContain('') + // The tiles count the "No care plan" selection. + ->toMatch('/Clients<\/div>\s*]*>'.(ClientsOverviewController::PER_PAGE + 1).'<\/div>/'); + + $yes = $this->get(route('client-reports.index'))->getContent(); + expect($yes)->toMatch('/Clients<\/div>\s*]*>1<\/div>/'); + }); + + it('filters by search, waiting reports and missing schedules within each section', function () { + $care = Client::create(['name' => 'Care Example', 'email' => 'care@example.com']); + $careSite = clientsSite($care, 'care.example.com'); + clientsReport($careSite, '2026-10', ['schedule_id' => clientsSchedule($careSite)->id], ['review_reasons' => ['No backups were found this month.']]); + + $none = Client::create(['name' => 'Nocare Example', 'email' => 'nocare@example.com']); + clientsSite($none, 'nocare.example.com', carePlan: false); + + $gap = Client::create(['name' => 'Gap Example', 'email' => 'gap@example.com']); + clientsSite($gap, 'gap.example.com'); + + $this->actingAs($this->user); + $page = fn (array $query) => clientsSections($this->get(route('client-reports.index', $query))->assertOk()->getContent()); + + $search = $page(['q' => 'nocare.example']); + expect($search['listed'])->not->toContain('Gap Example')->not->toContain('Care Example') + ->and($search['folded'])->toContain('Nocare Example'); + expect($page(['care' => 'no'])['listed'])->toContain('Nocare Example')->not->toContain('Care Example')->not->toContain('Gap Example'); + expect($page([])['listed'])->toContain('Care Example')->toContain('Gap Example')->not->toContain('Nocare Example'); + $waiting = $page(['waiting' => 1]); + expect($waiting['listed'])->toContain('Care Example')->not->toContain('Gap Example') + ->and($waiting['folded'])->toBe(''); + expect($page(['missing' => 1])['listed'])->toContain('Gap Example')->not->toContain('Care Example')->not->toContain('Nocare Example'); + }); + + it('does not count an inactive care-plan site as missing a schedule', function () { + $client = Client::create(['name' => 'Inactive Example', 'email' => 'inactive@example.com']); + clientsSite($client, 'paused.example.com', attributes: ['is_inactive' => true]); + + $this->actingAs($this->user)->get(route('client-reports.index', ['missing' => 1])) + ->assertOk()->assertDontSee('Inactive Example'); + }); +}); + +describe('client reports page', function () { + it('shows the client\'s contacts, each site\'s schedule and full report history with every action', function () { + $client = Client::create(['name' => 'Pat Example', 'company_name' => 'Page Example Ltd', 'email' => 'pat@example.com', 'additional_emails' => ['billing@example.com']]); + $site = clientsSite($client, 'page.example.com'); + $schedule = clientsSchedule($site, ['recipients' => ['pat@example.com']]); + $old = clientsReport($site, '2026-06', ['schedule_id' => $schedule->id, 'status' => ClientReport::STATUS_SENT, 'sent_to' => ['pat@example.com']]); + $held = clientsReport($site, '2026-10', ['schedule_id' => $schedule->id], ['review_reasons' => [ScheduledReportRunner::FIRST_REPORT_REASON, 'No backups were found this month.']]); + $replaced = clientsReport($site, '2026-10', ['schedule_id' => $schedule->id, 'status' => ClientReport::STATUS_SUPERSEDED]); + + $html = $this->actingAs($this->user)->get(route('client-reports.clients.show', $client->id))->assertOk()->getContent(); + + expect($html) + ->toContain('Page Example Ltd') + ->toContain('billing@example.com') + ->toContain('1 of 1 site on a care plan') + ->toContain('Next run')->toContain('Dec 1, 2026') + ->toContain(route('client-reports.schedules.edit', $schedule)) + // Every report of the site, oldest included, with its state and reason chips. + ->toContain('June 2026')->toContain('October 2026') + ->toContain(ReportState::label(ReportState::NEEDS_REVIEW)) + ->toContain('Backups') + ->toContain(e(route('client-reports.email', [$held, 'period' => 'all', 'client' => (string) $client->id, 'from' => 'client']))) + ->toContain('action="'.route('client-reports.recompile', $held).'"') + ->toContain('') + ->toContain('action="'.route('client-reports.send', $held).'"') + ->toContain('action="'.route('client-reports.send-test', $held).'"') + ->toContain('action="'.route('client-reports.send-test', $old).'"') + // The replaced copy stays out of the way until asked for. + ->not->toContain(e(route('client-reports.show', [$replaced, 'period' => 'all', 'client' => (string) $client->id, 'from' => 'client']))) + ->toContain('Show 1 replaced copy'); + + $this->get(route('client-reports.clients.show', [$client->id, 'superseded' => 1])) + ->assertSee(e(route('client-reports.show', [$replaced, 'period' => 'all', 'client' => (string) $client->id, 'from' => 'client', 'superseded' => '1'])), false); + }); + + it('explains the consequences before a real send: who gets it and that the schedule then sends by itself', function () { + $client = Client::create(['name' => 'Confirm Example', 'email' => 'confirm@example.com']); + $site = clientsSite($client, 'confirm.example.com'); + $schedule = clientsSchedule($site, ['recipients' => ['confirm@example.com']]); + clientsReport($site, '2026-10', ['schedule_id' => $schedule->id, 'client_email' => 'confirm@example.com'], ['review_reasons' => [ScheduledReportRunner::FIRST_REPORT_REASON]]); + + $this->actingAs($this->user)->get(route('client-reports.clients.show', $client->id)) + ->assertOk() + ->assertSee('Email report to confirm@example.com?') + ->assertSee('once it has been sent, the schedule emails its clean reports automatically every month') + ->assertSee('Only you get it, with [TEST] in the subject'); + }); + + it('steps to the previous and next client in the list\'s order, care-plan clients first, and offers every client in the switcher', function () { + $alpha = Client::create(['name' => 'Alpha Example', 'email' => 'a@example.com']); + $bravo = Client::create(['name' => 'Bravo Example', 'email' => 'b@example.com']); + $charlie = Client::create(['name' => 'Charlie Example', 'email' => 'c@example.com']); + clientsSite($alpha, 'alpha.example.com'); + clientsSite($bravo, 'bravo.example.com', carePlan: false); + clientsSite($charlie, 'charlie.example.com'); + + $this->actingAs($this->user); + + // Default list: Alpha, Charlie (care plan), then the folded Bravo. + $this->get(route('client-reports.clients.show', $alpha->id)) + ->assertOk() + ->assertSee('href="'.route('client-reports.clients.show', $charlie->id).'" class="cr-btn" rel="next"', false) + ->assertSee('1 of 3') + ->assertSee('
    "))->toBe(1) + ->and($html)->toMatch('/Availability.*?font-weight:700; color:'.preg_quote($color, '/').';">'.preg_quote($label, '/').'with([ + 'good' => [100.0, '100%', '#047857'], + 'just good' => [99.9, '99.9%', '#047857'], + 'amber' => [99.83, '99.83%', '#b45309'], + 'red' => [97.08, '97.08%', '#b91c1c'], +]); + +it('gives the plain-text part the same traffic and backup details as the HTML', function () { + $mail = new ClientReportMail(clientReportMailFixture([ + 'meta' => ['schema_version' => 6], + 'traffic' => ['status' => 'ok'], + 'backups' => [ + 'status' => 'verified', 'verified' => true, 'source' => 'pressable', 'covers_period' => true, 'days_with_backups' => 30, + 'recent' => [ + ['date' => '2026-09-30', 'kinds' => ['db', 'fs'], 'bytes' => 3 * 1024 ** 3, 'manual' => false], + ['date' => '2026-09-29', 'kinds' => ['db'], 'bytes' => 60 * 1024 ** 2, 'manual' => false], + ['date' => '2026-09-28', 'kinds' => ['fs'], 'bytes' => null, 'manual' => true], + ], + ], + ])); + $content = $mail->content(); + $text = view($content->text, $content->with)->render(); + $html = $mail->render(); + + expect($html)->toContain('Data transferred')->toContain('40.7 GB')->toContain('Files & database')->toContain('Database') + ->and($text)->toContain("Requests served: 1,429,336\nData transferred: 40.7 GB") + ->toContain('- Wed, Sep 30: Files & database, 3.0 GB') + ->toContain('- Tue, Sep 29: Database, 60 MB') + ->toContain('- Mon, Sep 28: Files (manual)'); +}); + +it('leaves "Data transferred" out of both parts when the host reports no byte counts', function () { + $mail = new ClientReportMail(clientReportMailFixture(['traffic' => ['bandwidth_mb' => null]])); + $content = $mail->content(); + + expect($mail->render())->not->toContain('Data transferred') + ->and(view($content->text, $content->with)->render())->not->toContain('Data transferred'); +}); + it('cleans raw action-log summaries', function (string $summary, ?string $target, string $expected) { expect(ClientReportMail::cleanUpdateSummary($summary, $target))->toBe($expected); })->with([ ['Plugin updated: Yoast SEO 26.1 → 26.2', null, 'Yoast SEO 26.1 → 26.2'], ['Theme updated: Astra 4.1 → 4.2 [1 repair: x]', null, 'Astra 4.1 → 4.2'], + ['Theme updated: Example & Co 1.0 → 1.1', null, 'Example & Co 1.0 → 1.1'], ['', 'wp-mail-smtp/wp_mail_smtp.php', 'Wp Mail Smtp'], ['', null, 'Software update'], ]); diff --git a/tests/Feature/Modules/ClientReportMalwareReviewTest.php b/tests/Feature/Modules/ClientReportMalwareReviewTest.php new file mode 100644 index 00000000..a606cd9b --- /dev/null +++ b/tests/Feature/Modules/ClientReportMalwareReviewTest.php @@ -0,0 +1,214 @@ +travelTo(Carbon::parse('2026-12-10 09:00:00')); +}); + +function reviewSite(): Site +{ + return 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(), + ]); +} + +/** A Companion malware scan in October with these findings. */ +function reviewScan(Site $site, string $status, array $kinds, string $at = '2026-10-20 03:00'): SiteSecurityScan +{ + return SiteSecurityScan::factory()->create([ + 'site_id' => $site->id, 'scan_type' => SiteSecurityScan::TYPE_COMPANION_MALWARE, 'scanned_at' => Carbon::parse($at), 'status' => $status, + 'details' => [ + 'transport' => 'companion', 'scanned_files_count' => 9000, 'scan_aborted' => false, 'abort_reason' => null, + 'findings' => array_map(fn (string $kind) => ['kind' => $kind, 'path' => "wp-content/uploads/example/{$kind}.php", 'evidence' => 'example'], $kinds), + ], + ]); +} + +/** The October report from an auto schedule that has never sent, held for review as the runner leaves it. */ +function heldReviewReport(Site $site, array $schedule = []): ClientReport +{ + $schedule = ClientReportSchedule::create(array_merge([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => ['client@example.com'], + 'is_enabled' => true, 'next_run_at' => Carbon::parse('2027-01-01 00:00:00'), + ], $schedule)); + $start = Carbon::parse('2026-10-01'); + $end = Carbon::parse('2026-10-31'); + $data = app(ClientReportCompiler::class)->compile($site, $start, $end); + $data['meta']['review_reasons'] = [ScheduledReportRunner::FIRST_REPORT_REASON, ...ReportReviewFlags::reasons($data)]; + + return ClientReport::create([ + 'site_id' => $site->id, 'schedule_id' => $schedule->id, 'title' => 'Maintenance Report (Oct 2026)', + 'period_start' => $start, 'period_end' => $end, 'sections_data' => $data, + 'client_email' => 'client@example.com', 'status' => ClientReport::STATUS_GENERATED, + ]); +} + +/** @return array{html: string, text: string} */ +function reviewEmail(ClientReport $report): array +{ + $mail = new ClientReportMail($report->fresh()); + $content = $mail->content(); + + return ['html' => $mail->render(), 'text' => view($content->text, $content->with)->render()]; +} + +it('requires authentication', function () { + $site = reviewSite(); + reviewScan($site, SiteSecurityScan::STATUS_WARNING, ['php_in_uploads']); + $report = heldReviewReport($site); + + $this->post(route('client-reports.malware-reviewed', $report), ['note' => 'Checked'])->assertRedirect(route('login')); + + expect($report->fresh()->sections_data['security'])->not->toHaveKey('malware_reviewed'); +}); + +it('records who reviewed the findings, clears that reason only, and changes the client line in both emails', function () { + $this->mockIssueCounterZero(); + $site = reviewSite(); + $scan = reviewScan($site, SiteSecurityScan::STATUS_WARNING, ['php_in_uploads']); + $report = heldReviewReport($site); + $user = User::factory()->create(['name' => 'Example Operator']); + + expect(implode(' ', $report->sections_data['meta']['review_reasons']))->toContain('The malware scan flagged files to review: php_in_uploads ×1') + ->and(reviewEmail($report)['html'])->toContain('Issues flagged'); + $this->actingAs($user)->get(route('client-reports.show', $report))->assertOk()->assertSee('Mark findings reviewed'); + + $this->actingAs($user)->post(route('client-reports.malware-reviewed', $report), ['note' => 'Example plugin cache file; checked by hand.']) + ->assertRedirect(route('client-reports.show', $report)) + ->assertSessionHas('status', fn ($m) => str_contains($m, 'Marked the malware findings reviewed') && str_contains($m, ScheduledReportRunner::FIRST_REPORT_REASON)); + + $data = $report->fresh()->sections_data; + $email = reviewEmail($report); + + expect($data['security']['malware_reviewed'])->toMatchArray([ + 'scan_id' => $scan->id, 'finding_kinds' => ['php_in_uploads' => 1], 'reviewed_by' => 'Example Operator', + 'reviewed_by_id' => $user->id, 'reviewed_at' => now()->toIso8601String(), 'note' => 'Example plugin cache file; checked by hand.', + ]) + // Still held for its other reason. + ->and($data['meta']['review_reasons'])->toBe([ScheduledReportRunner::FIRST_REPORT_REASON]) + ->and($email['html'])->toContain('Reviewed — no threat found')->not->toContain('Issues flagged') + ->and($email['text'])->toContain('Malware scan: Reviewed — no threat found')->not->toContain('Issues flagged') + ->and(ReportReviewFlags::reasons($data))->toBe([]); + $this->actingAs($user)->get(route('client-reports.show', $report))->assertOk()->assertDontSee('Mark findings reviewed'); +}); + +it('needs a note saying what was checked', function () { + $site = reviewSite(); + reviewScan($site, SiteSecurityScan::STATUS_WARNING, ['php_in_uploads']); + $report = heldReviewReport($site); + + $this->actingAs(User::factory()->create())->post(route('client-reports.malware-reviewed', $report), ['note' => ' ']) + ->assertSessionHasErrors('note'); + + expect($report->fresh()->sections_data['security'])->not->toHaveKey('malware_reviewed'); +}); + +it('refuses suspected malware, a partial or clean scan, and a scan with no finding kinds', function (string $status, array $kinds) { + $this->mockIssueCounterZero(); + $site = reviewSite(); + reviewScan($site, $status, $kinds); + $report = heldReviewReport($site); + $user = User::factory()->create(); + + $this->actingAs($user)->get(route('client-reports.show', $report))->assertOk()->assertDontSee('Mark findings reviewed'); + $this->actingAs($user)->post(route('client-reports.malware-reviewed', $report), ['note' => 'Looks fine']) + ->assertSessionHas('status_error', fn ($m) => str_contains($m, 'review-tier')); + + expect($report->fresh()->sections_data['security'])->not->toHaveKey('malware_reviewed'); +})->with([ + 'suspected malware' => [SiteSecurityScan::STATUS_ISSUES_FOUND, ['eval_base64']], + 'review-tier beside malware' => [SiteSecurityScan::STATUS_ISSUES_FOUND, ['php_in_uploads', 'shell_c99']], + 'clean' => [SiteSecurityScan::STATUS_CLEAN, []], + 'a warning with nothing recorded' => [SiteSecurityScan::STATUS_WARNING, []], +]); + +it('refuses a report that is sent, partly sent, superseded or not held', function (array $attributes, string $error) { + $site = reviewSite(); + reviewScan($site, SiteSecurityScan::STATUS_WARNING, ['wp_config_recently_modified']); + $report = heldReviewReport($site); + $data = $report->sections_data; + if (($attributes['held'] ?? true) === false) { + unset($data['meta']['review_reasons']); + } + unset($attributes['held']); + $report->update(['sections_data' => $data, ...$attributes]); + + $this->actingAs(User::factory()->create())->post(route('client-reports.malware-reviewed', $report), ['note' => 'Checked']) + ->assertSessionHas('status_error', fn ($m) => str_contains($m, $error)); + + expect($report->fresh()->sections_data['security'])->not->toHaveKey('malware_reviewed'); +})->with([ + 'sent' => [['status' => ClientReport::STATUS_SENT, 'sent_to' => ['client@example.com']], 'not been sent'], + 'partly sent' => [['sent_to' => ['client@example.com']], 'not been sent'], + 'superseded' => [['status' => ClientReport::STATUS_SUPERSEDED], 'not been sent'], + 'not held' => [['held' => false], 'held for review'], +]); + +it('keeps the review through a Recompile while the scan and its findings are unchanged', function () { + Mail::fake(); + $site = reviewSite(); + reviewScan($site, SiteSecurityScan::STATUS_WARNING, ['php_in_uploads', 'preg_replace_e_modifier']); + $report = heldReviewReport($site); + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('client-reports.malware-reviewed', $report), ['note' => 'Legacy vendor code']); + $this->actingAs($user)->post(route('client-reports.recompile', $report)); + + $fresh = ClientReport::query()->where('status', ClientReport::STATUS_GENERATED)->sole(); + + expect($fresh->id)->not->toBe($report->id) + ->and($fresh->sections_data['security']['malware_reviewed'])->toMatchArray(['note' => 'Legacy vendor code', 'reviewed_by_id' => $user->id]) + ->and($fresh->sections_data['meta']['review_reasons'])->toBe([ScheduledReportRunner::FIRST_REPORT_REASON]) + ->and(reviewEmail($fresh)['html'])->toContain('Reviewed — no threat found'); + Mail::assertNothingSent(); +}); + +it('drops the review on Recompile once a newer scan, or different findings, are what the report shows', function (string $newerStatus, array $newerKinds) { + Mail::fake(); + $site = reviewSite(); + reviewScan($site, SiteSecurityScan::STATUS_WARNING, ['php_in_uploads']); + $report = heldReviewReport($site); + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('client-reports.malware-reviewed', $report), ['note' => 'Checked']); + reviewScan($site, $newerStatus, $newerKinds, '2026-10-28 03:00'); + $this->actingAs($user)->post(route('client-reports.recompile', $report)); + + $fresh = ClientReport::query()->where('status', ClientReport::STATUS_GENERATED)->sole(); + + expect($fresh->sections_data['security'])->not->toHaveKey('malware_reviewed') + ->and(implode(' ', $fresh->sections_data['meta']['review_reasons']))->toContain('The malware scan flagged files to review') + ->and(reviewEmail($fresh)['html'])->toContain('Issues flagged'); +})->with([ + 'same findings, newer scan' => [SiteSecurityScan::STATUS_WARNING, ['php_in_uploads']], + 'more findings' => [SiteSecurityScan::STATUS_WARNING, ['php_in_uploads', 'php_in_uploads']], +]); diff --git a/tests/Feature/Modules/ClientReportPreviewPhrasesTest.php b/tests/Feature/Modules/ClientReportPreviewPhrasesTest.php new file mode 100644 index 00000000..4364ffc3 --- /dev/null +++ b/tests/Feature/Modules/ClientReportPreviewPhrasesTest.php @@ -0,0 +1,124 @@ +create(['domain' => 'phrases-'.uniqid().'.example.com']); + + return ClientReport::create([ + 'site_id' => $site->id, + 'title' => 'Phrases test', + 'period_start' => Carbon::parse('2026-09-01'), + 'period_end' => Carbon::parse('2026-09-30'), + 'status' => 'generated', + 'sections_data' => array_replace_recursive([ + 'meta' => ['schema_version' => ClientReportCompiler::SCHEMA_VERSION, 'custom_notes' => null, 'period_start' => '2026-09-01', 'period_end' => '2026-09-30'], + 'branding' => [ + 'company_name' => 'Example Agency', 'logo_url' => '', 'primary_color' => '#2D2062', + 'accent_color' => '#7EFF83', 'support_email' => 'support@example.com', 'footer_text' => '', + ], + 'site' => ['domain' => $site->domain, 'is_wordpress' => true], + 'updates' => [ + 'total' => 0, 'items' => ['core' => [], 'themes' => [], 'plugins' => []], + 'pending_plugins' => [], 'pending_themes' => [], 'pending_core' => false, 'updated_since_period' => 0, + ], + 'uptime' => ['monitored' => true, 'probe_stopped' => false, 'uptime_percentage' => 100, 'outages_count' => 0, 'downtime_minutes' => 0, 'outages' => []], + 'security' => [ + 'total_scans' => 30, 'vulnerabilities_checked' => true, 'vulnerabilities_as_of' => '2026-10-01', + 'active_vulnerabilities' => 0, 'vulnerable_plugins' => [], 'inactive_plugin_vulnerabilities' => 0, + 'blocked_threats_count' => 4, 'checksum_status' => 'clean', 'malware_status' => 'clean', 'blacklist_status' => 'clean', + ], + 'performance' => ['has_performance' => false, 'mobile' => null, 'desktop' => null], + 'forms' => ['live_tests' => 0, 'lab_tests' => 0, 'lab_accepted' => 0, 'pass_rate' => null, 'total_synthetic_tests' => 0], + 'traffic' => ['has_traffic' => false, 'total_visits' => 0, 'total_requests' => 0, 'bandwidth_mb' => null], + 'backups' => [ + 'verified' => true, 'status' => 'verified', 'source' => 'pressable', 'destination' => 'by your host, Pressable', + 'days_with_backups' => 30, 'covers_period' => true, 'records_from' => '2026-08-01', + 'recent' => [['date' => '2026-09-30', 'kinds' => ['db', 'fs'], 'bytes' => 60 * 1024 ** 2, 'manual' => false]], + 'last_backup_at' => '2026-09-30', 'check_error' => null, + ], + 'work_log' => ['total_hours' => 0, 'entries' => []], + ], $sections), + ]); +} + +/** @return array{html: string, preview: string, public: string} */ +function phrasesViews(ClientReport $report): array +{ + return [ + 'html' => (new ClientReportMail($report))->render(), + 'preview' => view('client-reports::reports.show', ['report' => $report, 'isOperatorPreview' => true])->render(), + 'public' => view('client-reports::reports.show', ['report' => $report, 'isOperatorPreview' => false])->render(), + ]; +} + +it('reads reviewed malware findings as "Reviewed — no threat found", like the email', function () { + $views = phrasesViews(phrasesReport(['security' => [ + 'malware_status' => 'warning', 'malware_scan_id' => 42, 'malware_finding_kinds' => ['php_in_uploads' => 1], + 'malware_reviewed' => ['scan_id' => 42, 'finding_kinds' => ['php_in_uploads' => 1], 'reviewed_by' => 'Example Operator', 'note' => 'Checked.'], + ]])); + + expect($views['html'])->toContain('Reviewed — no threat found') + ->and($views['preview'])->toContain('Reviewed — no threat found')->not->toContain('Issues flagged') + ->and($views['public'])->toContain('Reviewed — no threat found')->not->toContain('Issues flagged'); +}); + +it('colours uptime the way the email does: green from 99.9%, amber from 99%, red below', function (float $pct, string $class, string $label) { + $views = phrasesViews(phrasesReport(['uptime' => ['uptime_percentage' => $pct]])); + + expect($views['public'])->toMatch('/
    '.preg_quote($label, '/').'<\/div>/') + ->and($views['html'])->toContain($label); +})->with([ + 'good' => [99.95, 'text-emerald-600', '99.95%'], + 'fair' => [99.5, 'text-amber-600', '99.5%'], + 'poor' => [97.0, 'text-red-600', '97%'], +]); + +it('says the email\'s "no updates" sentence', function () { + $current = phrasesViews(phrasesReport()); + $waiting = phrasesViews(phrasesReport(['updates' => ['pending_plugins' => ['Example Plugin']]])); + + expect($current['public'])->toContain('Everything was up to date at the end of the month.') + ->and($waiting['public'])->toContain('No updates were applied this month.')->not->toContain('Everything was up to date'); +}); + +it('names backup kinds and sizes, and data transferred, as the email does', function () { + $views = phrasesViews(phrasesReport([ + 'traffic' => ['has_traffic' => true, 'status' => 'ok', 'total_visits' => 1200, 'total_requests' => 9000, 'bandwidth_mb' => 2048, 'days_covered' => 30, 'period_days' => 30], + ])); + + expect($views['public'])->toContain('Files & database')->toContain('Wed, Sep 30')->toContain('60 MB') + ->and($views['html'])->toContain('Files & database'); + expect($views['html'])->toContain('Data transferred')->toContain('2.0 GB') + ->and($views['public'])->toContain('Data Transferred')->toContain('2.0 GB'); + + // No byte counts: the email leaves the row out, and so does the page. + $unmeasured = phrasesViews(phrasesReport([ + 'traffic' => ['has_traffic' => true, 'status' => 'ok', 'total_visits' => 1200, 'total_requests' => 9000, 'bandwidth_mb' => null, 'days_covered' => 30, 'period_days' => 30], + ])); + expect($unmeasured['html'])->not->toContain('Data transferred') + ->and($unmeasured['public'])->not->toContain('Data Transferred')->not->toContain('Not measured'); +}); + +it('says when the care plan began for a site that joined part-way through the month', function () { + $views = phrasesViews(phrasesReport(['meta' => ['coverage_start' => '2026-09-12T14:00:00+00:00', 'coverage_source' => 'care_plan']])); + + expect($views['public'])->toContain('Your care plan began Sep 12') + ->and($views['html'])->toContain('Your care plan began Sep 12'); +}); diff --git a/tests/Feature/Modules/ClientReportSchedulesTest.php b/tests/Feature/Modules/ClientReportSchedulesTest.php index 4cf512be..903bd7af 100644 --- a/tests/Feature/Modules/ClientReportSchedulesTest.php +++ b/tests/Feature/Modules/ClientReportSchedulesTest.php @@ -1,11 +1,16 @@ carePlan()->create(array_merge(['hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'backup_relay_enabled' => false, 'uptime_last_checked_at' => now()], $attributes)); + 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)); +} + +/** + * A clean core-file check last month, so a site whose host can run one isn't + * also held for "no check ran" when a test is about something else. + */ +function checkedCoreFiles(Site $site): void +{ + SiteSecurityScan::factory()->coreChecksums()->create([ + 'site_id' => $site->id, + 'scanned_at' => now()->subMonthNoOverflow()->startOfMonth()->addDays(9), + ]); } it('requires authentication for schedule management routes', function () { @@ -67,7 +88,7 @@ function reportableSite(array $attributes = []): Site ->assertSee('boss@scheduled.test') ->assertSee('Auto-Send') ->assertSee('unscheduled.test') - ->assertSee('Unscheduled Managed Sites'); + ->assertSee('Care-plan sites without a report schedule'); }); it('renders the create schedule form with available sites and templates', function () { @@ -688,6 +709,32 @@ function dueAutoSchedule(array $recipients): ClientReportSchedule ->and($schedule->fresh()->next_run_at->isFuture())->toBeTrue(); }); + it('records the sites a run skipped for having no care plan, once per run', function () { + Mail::fake(); + foreach (['first-no-plan.example.com', 'second-no-plan.example.com'] as $domain) { + $site = Site::factory()->create(['domain' => $domain, 'care_plan_enabled' => false, 'hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM]); + ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => ['client@example.com'], + 'is_enabled' => true, 'next_run_at' => now()->subHour(), + ]); + } + + $this->artisan('clockwork:send-client-reports') + ->expectsOutputToContain('first-no-plan.example.com: skipped this month (care plan is off') + ->assertSuccessful(); + + $log = ActionLog::where('action_type', SendScheduledClientReports::ACTION_SKIPPED)->sole(); + expect($log->site_id)->toBeNull() + ->and($log->details)->toBe(['domains' => ['first-no-plan.example.com', 'second-no-plan.example.com'], 'reason' => 'no_care_plan']) + ->and($log->summary)->toBe('Skipped 2 scheduled client reports (no care plan): first-no-plan.example.com, second-no-plan.example.com') + ->and($log->actor)->toBe('scheduled'); + + // Nothing due, nothing skipped: no entry. + $this->artisan('clockwork:send-client-reports')->assertSuccessful(); + expect(ActionLog::where('action_type', SendScheduledClientReports::ACTION_SKIPPED)->count())->toBe(1); + }); + it('refuses Send now for a site with no care plan', function () { $user = User::factory()->create(); $site = Site::factory()->create(['care_plan_enabled' => false]); @@ -707,6 +754,7 @@ function dueAutoSchedule(array $recipients): ClientReportSchedule Mail::fake(); // 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); $schedule = ClientReportSchedule::create([ 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => ['client@flagged.test'], @@ -735,6 +783,7 @@ function dueAutoSchedule(array $recipients): ClientReportSchedule it('checks a draft-mode report too, and never emails it unchecked after switching to auto', function () { Mail::fake(); $site = Site::factory()->carePlan()->create(['domain' => 'was-draft.test', 'hosting_provider' => Site::HOSTING_PROVIDER_SPINUPWP, 'uptime_last_checked_at' => now()]); + checkedCoreFiles($site); $schedule = ClientReportSchedule::create([ 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_DRAFT, 'recipients' => ['client@was-draft.test'], @@ -793,6 +842,32 @@ function dueAutoSchedule(array $recipients): ClientReportSchedule expect(ClientReport::sole()->status)->toBe('sent'); }); + it('recompiles an unsent copy compiled under older report rules instead of emailing it as it is', function () { + Mail::fake(); + $site = reportableSite(['domain' => 'outdated.test']); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => ['client@outdated.test'], + 'is_enabled' => true, 'next_run_at' => now()->subHour(), 'last_sent_at' => now()->subMonths(2), + ]); + // Last month's copy from before the latest rules: not held, nobody has it. + $data = app(ClientReportCompiler::class)->compile($site, now()->subMonthNoOverflow()->startOfMonth(), now()->subMonthNoOverflow()->endOfMonth()); + $data['meta']['schema_version'] = 3; + $old = ClientReport::create([ + 'site_id' => $site->id, 'schedule_id' => $schedule->id, 'title' => 'Old copy', + 'period_start' => now()->subMonthNoOverflow()->startOfMonth(), 'period_end' => now()->subMonthNoOverflow()->endOfMonth(), + 'sections_data' => $data, 'status' => ClientReport::STATUS_GENERATED, + ]); + + $this->artisan('clockwork:send-client-reports')->assertSuccessful(); + + $sent = ClientReport::query()->where('status', 'sent')->sole(); + expect($old->fresh()->status)->toBe(ClientReport::STATUS_SUPERSEDED) + ->and($sent->id)->not->toBe($old->id) + ->and($sent->sections_data['meta']['schema_version'])->toBe(ClientReportCompiler::SCHEMA_VERSION); + Mail::assertSent(ClientReportMail::class, fn ($mail) => $mail->report->is($sent)); + }); + it('holds a schedule\'s first report, and sends the next clean one by itself once that was sent by hand', function () { Mail::fake(); $user = User::factory()->create(); @@ -1064,6 +1139,67 @@ function dueAutoSchedule(array $recipients): ClientReportSchedule ->and($schedule->next_run_at->lt(now()->addDays(8)))->toBeTrue(); }); + it('does not tell WordPress reports are active for a site with no Control schedule', function () { + $client = Client::create(['name' => 'Jane Example', 'email' => 'jane@no-schedule.example.com']); + $site = reportableSite(['domain' => 'no-schedule.example.com', 'client_id' => $client->id]); + + $payload = app(ReportRecipientsSyncService::class)->resolvePayloadForSite($site); + + // The client's addresses are still offered, but nothing will send. + expect($payload)->toMatchArray(['report_enabled' => false, 'report_recipients' => ['jane@no-schedule.example.com']]); + }); + + it('reports active only while the site\'s schedule is enabled', function () { + $site = reportableSite(['domain' => 'scheduled.example.com']); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, 'delivery_mode' => ClientReportSchedule::MODE_AUTO, + 'recipients' => ['owner@scheduled.example.com'], 'is_enabled' => true, + ]); + $service = app(ReportRecipientsSyncService::class); + + expect($service->resolvePayloadForSite($site)['report_enabled'])->toBeTrue(); + + $schedule->update(['is_enabled' => false]); + + expect($service->resolvePayloadForSite($site->fresh())['report_enabled'])->toBeFalse(); + }); + + it('seeds a WordPress that has never had recipients with "not active" when Control has no schedule', function () { + Http::fake(['*' => Http::response(['ok' => true], 200)]); + $client = Client::create(['name' => 'Jane Example', 'email' => 'jane@unseeded.example.com']); + $site = Site::factory()->carePlan()->withCompanionInstalled()->create([ + 'domain' => 'unseeded.example.com', 'hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, 'client_id' => $client->id, + ]); + + app(ReportRecipientsSyncService::class)->reconcileFromSnapshot($site, [ + 'client_notifications' => ['report_recipients' => [], 'report_frequency' => 'monthly', 'report_enabled' => false], + ]); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/client-notifications') && $request['report_enabled'] === false); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/client-notifications') && $request['report_enabled'] === true); + expect(ClientReportSchedule::where('site_id', $site->id)->exists())->toBeFalse(); + }); + + it('corrects a WordPress that an earlier push left showing reports as active with no Control schedule', function () { + Http::fake(['*' => Http::response(['ok' => true], 200)]); + $site = Site::factory()->carePlan()->withCompanionInstalled()->create([ + 'domain' => 'seeded-active.example.com', 'hosting_provider' => Site::HOSTING_PROVIDER_CUSTOM, + ]); + $service = app(ReportRecipientsSyncService::class); + $seeded = ['report_recipients' => ['owner@seeded-active.example.com'], 'report_frequency' => 'monthly', + 'report_recipients_updated_at' => now()->subMonth()->toIso8601String(), 'report_recipients_updated_by' => 'control']; + + $service->reconcileFromSnapshot($site, ['client_notifications' => $seeded + ['report_enabled' => true]]); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/client-notifications') && $request['report_enabled'] === false); + + // Once WordPress says "not active", nothing more is pushed. + Http::fake(['*' => Http::response(['ok' => true], 200)]); + $service->reconcileFromSnapshot($site->fresh(), ['client_notifications' => $seeded + ['report_enabled' => false]]); + + Http::assertNothingSent(); + }); + it('pushes a disabled payload when a schedule is removed', function () { $site = reportableSite(['domain' => 'removed.test']); diff --git a/tests/Feature/Modules/ClientReportTemplatesTest.php b/tests/Feature/Modules/ClientReportTemplatesTest.php index d90dfff8..dda2997c 100644 --- a/tests/Feature/Modules/ClientReportTemplatesTest.php +++ b/tests/Feature/Modules/ClientReportTemplatesTest.php @@ -52,6 +52,25 @@ ->assertSee('Security & Firewall', false); }); + it('describes each section by what the report actually contains', function () { + $descriptions = collect(ClientReportTemplate::SECTIONS)->pluck('description')->implode(' '); + + // Promises the compiler never delivers: no referrer, page-view, TTFB, + // translation or restore data is compiled, and scheduled form tests + // suppress the email, so they can't speak to delivery. + foreach (['referrer', 'page view', 'unique visits', 'TTFB', 'time to first byte', 'translation', 'deliverability', 'restore readiness'] as $promise) { + expect(stripos($descriptions, $promise))->toBeFalse("Section descriptions still promise \"{$promise}\"."); + } + + expect(ClientReportTemplate::SECTIONS['forms']['description'])->toContain('delivery is only reported for live tests'); + + $this->mockIssueCounterZero(); + $this->actingAs(User::factory()->create()) + ->get(route('client-reports.templates.create')) + ->assertOk() + ->assertSee(ClientReportTemplate::SECTIONS['forms']['description']); + }); + it('stores a new custom template with selected sections', function () { $user = User::factory()->create(); diff --git a/tests/Feature/Modules/ClientReportTestSendTest.php b/tests/Feature/Modules/ClientReportTestSendTest.php new file mode 100644 index 00000000..cf548c84 --- /dev/null +++ b/tests/Feature/Modules/ClientReportTestSendTest.php @@ -0,0 +1,184 @@ +create(['domain' => 'test-send-'.Str::lower(Str::random(6)).'.example.com', 'care_plan_enabled' => true]); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => ['client-one@example.com', 'client-two@example.com'], + 'is_enabled' => true, 'next_run_at' => Carbon::parse('2027-01-01'), + ]); + + return ClientReport::create(array_merge([ + 'site_id' => $site->id, + 'schedule_id' => $schedule->id, + 'title' => 'Test send', + 'period_start' => Carbon::parse('2026-10-01'), + 'period_end' => Carbon::parse('2026-10-31'), + 'sections_data' => app(ClientReportCompiler::class)->compile($site, Carbon::parse('2026-10-01'), Carbon::parse('2026-10-31')), + 'client_email' => 'client-one@example.com, client-two@example.com', + 'status' => ClientReport::STATUS_GENERATED, + ], $attributes)); +} + +describe('send test to me', function () { + it('needs a login', function () { + Mail::fake(); + $this->post(route('client-reports.send-test', testSendReport()))->assertRedirect(route('login')); + Mail::assertNothingSent(); + }); + + it('emails the exact client email to the operator only, marked [TEST], and changes nothing', function () { + Mail::fake(); + $operator = User::factory()->create(['email' => 'operator@example.com']); + $report = testSendReport(); + $before = $report->fresh()->only(['status', 'sent_to', 'sent_at', 'client_email', 'sections_data', 'updated_at']); + $schedule = $report->schedule; + $expectedSubject = (new ClientReportMail($report))->envelope()->subject; + + $this->actingAs($operator)->post(route('client-reports.send-test', $report)) + ->assertSessionHas('status', fn (string $m) => str_contains($m, 'operator@example.com only')); + + Mail::assertSent(ClientReportTestMail::class, 1); + Mail::assertSent(ClientReportTestMail::class, function (ClientReportTestMail $mail) use ($report, $expectedSubject) { + return $mail->hasTo('operator@example.com') + && count($mail->to) === 1 && $mail->cc === [] && $mail->bcc === [] + && $mail->report->is($report) + && $mail->envelope()->subject === '[TEST] '.$expectedSubject; + }); + // Never to a client address. + Mail::assertNotSent(ClientReportMail::class, fn ($mail) => $mail->hasTo('client-one@example.com') || $mail->hasTo('client-two@example.com')); + + expect($report->fresh()->only(['status', 'sent_to', 'sent_at', 'client_email', 'sections_data', 'updated_at']))->toEqual($before) + ->and($schedule->fresh()->last_sent_at)->toBeNull() + ->and($schedule->fresh()->next_run_at->toDateString())->toBe('2027-01-01'); + }); + + it('works for any report, even one that may not be sent for real, without touching it', function () { + Mail::fake(); + $operator = User::factory()->create(['email' => 'operator@example.com']); + $outdated = testSendReport(['sections_data' => ['meta' => ['schema_version' => 1]]]); + $replaced = testSendReport(['status' => ClientReport::STATUS_SUPERSEDED]); + + $this->actingAs($operator)->post(route('client-reports.send-test', $outdated))->assertSessionHas('status'); + $this->actingAs($operator)->post(route('client-reports.send-test', $replaced))->assertSessionHas('status'); + + Mail::assertSent(ClientReportTestMail::class, 2); + Mail::assertSent(ClientReportTestMail::class, fn ($mail) => $mail->hasTo('operator@example.com')); + expect($outdated->fresh()->status)->toBe(ClientReport::STATUS_GENERATED) + ->and($replaced->fresh()->status)->toBe(ClientReport::STATUS_SUPERSEDED); + }); + + it('says so when the test email fails, and still changes nothing', function () { + Mail::shouldReceive('to')->once()->with('operator@example.com')->andThrow(new RuntimeException('Example transport failure')); + $operator = User::factory()->create(['email' => 'operator@example.com']); + $report = testSendReport(); + + $this->actingAs($operator)->post(route('client-reports.send-test', $report)) + ->assertSessionHas('status_error', fn (string $m) => str_contains($m, 'Example transport failure')); + + expect($report->fresh()->status)->toBe(ClientReport::STATUS_GENERATED) + ->and($report->fresh()->sent_to)->toBeNull() + ->and($report->schedule->fresh()->last_sent_at)->toBeNull(); + }); +}); + +describe('one-click schedules', function () { + beforeEach(function () { + Carbon::setTestNow('2026-11-05 10:00:00'); + Mail::fake(); + $this->user = User::factory()->create(); + }); + + afterEach(fn () => Carbon::setTestNow()); + + it('creates a monthly auto schedule to the client\'s contacts, sending nothing', function () { + $client = Client::create(['name' => 'Quick Example', 'email' => 'quick@example.com', 'additional_emails' => ['also@example.com']]); + $site = Site::factory()->create(['domain' => 'quick.example.com', 'client_id' => $client->id, 'care_plan_enabled' => true]); + + $this->actingAs($this->user)->post(route('client-reports.schedules.quick', $site)) + ->assertSessionHas('status', fn (string $m) => str_contains($m, 'quick@example.com, also@example.com')); + + $schedule = ClientReportSchedule::where('site_id', $site->id)->sole(); + expect($schedule->delivery_mode)->toBe(ClientReportSchedule::MODE_AUTO) + ->and($schedule->frequency)->toBe(ClientReportSchedule::FREQUENCY_MONTHLY) + ->and($schedule->recipients)->toBe(['quick@example.com', 'also@example.com']) + ->and($schedule->is_enabled)->toBeTrue() + ->and($schedule->last_sent_at)->toBeNull() + ->and($schedule->next_run_at->toDateString())->toBe('2026-12-01'); + Mail::assertNothingSent(); + expect(ClientReport::count())->toBe(0); + }); + + it('creates a draft schedule when there are no contacts to send to', function () { + $site = Site::factory()->create(['domain' => 'nocontacts.example.com', 'client_id' => null, 'client_email' => null, 'care_plan_enabled' => true]); + + $this->actingAs($this->user)->post(route('client-reports.schedules.quick', $site))->assertSessionHas('status'); + + $schedule = ClientReportSchedule::where('site_id', $site->id)->sole(); + expect($schedule->delivery_mode)->toBe(ClientReportSchedule::MODE_DRAFT) + ->and($schedule->recipients)->toBe([]); + }); + + it('refuses a site with no care plan, an inactive site, and a site that already has one', function () { + $noCare = Site::factory()->create(['care_plan_enabled' => false]); + $inactive = Site::factory()->create(['care_plan_enabled' => true, 'is_inactive' => true]); + $scheduled = Site::factory()->create(['care_plan_enabled' => true]); + ClientReportSchedule::create(['site_id' => $scheduled->id, 'frequency' => 'monthly', 'delivery_mode' => 'draft', 'recipients' => [], 'is_enabled' => false]); + + $this->actingAs($this->user); + $this->post(route('client-reports.schedules.quick', $noCare))->assertSessionHas('status_error'); + $this->post(route('client-reports.schedules.quick', $inactive))->assertSessionHas('status_error'); + $this->post(route('client-reports.schedules.quick', $scheduled))->assertSessionHas('status', fn (string $m) => str_contains($m, 'already has')); + + expect(ClientReportSchedule::count())->toBe(1) + ->and(ClientReportSchedule::first()->is_enabled)->toBeFalse(); + }); + + it('creates one for every active care-plan site without one, and nothing else', function () { + $a = Site::factory()->create(['care_plan_enabled' => true]); + $b = Site::factory()->create(['care_plan_enabled' => true]); + $noCare = Site::factory()->create(['care_plan_enabled' => false]); + $inactive = Site::factory()->create(['care_plan_enabled' => true, 'is_inactive' => true]); + + $this->actingAs($this->user)->post(route('client-reports.schedules.quick-all')) + ->assertSessionHas('status', fn (string $m) => str_contains($m, 'Created 2 monthly report schedule(s)') && str_contains($m, 'Nothing was sent.')); + + expect(ClientReportSchedule::pluck('site_id')->sort()->values()->all())->toBe(collect([$a->id, $b->id])->sort()->values()->all()); + Mail::assertNothingSent(); + }); + + it('lets the client page toggle a schedule in place', function () { + $site = Site::factory()->create(['care_plan_enabled' => true]); + $schedule = ClientReportSchedule::create(['site_id' => $site->id, 'frequency' => 'monthly', 'delivery_mode' => 'auto', 'recipients' => ['a@example.com'], 'is_enabled' => true]); + + $this->actingAs($this->user) + ->from('/client-reports/clients/unassigned') + ->patch(route('client-reports.schedules.toggle', $schedule), ['return' => 'back']) + ->assertRedirect('/client-reports/clients/unassigned'); + + expect($schedule->fresh()->is_enabled)->toBeFalse(); + }); +}); diff --git a/tests/Feature/Modules/ClientReportViewsTest.php b/tests/Feature/Modules/ClientReportViewsTest.php new file mode 100644 index 00000000..52bb04fe --- /dev/null +++ b/tests/Feature/Modules/ClientReportViewsTest.php @@ -0,0 +1,513 @@ +first() ?? Site::factory()->create(['domain' => 'www.views-example.com']); + $start = Carbon::parse('2026-09-01'); + $end = Carbon::parse('2026-09-30'); + + return ClientReport::create([ + 'site_id' => $site->id, + 'title' => 'Views test', + 'period_start' => $start, + 'period_end' => $end, + 'status' => 'generated', + 'sections_data' => array_replace([ + 'meta' => ['schema_version' => ClientReportCompiler::SCHEMA_VERSION, 'custom_notes' => null], + 'branding' => [ + 'company_name' => 'Example Agency', 'logo_url' => '', 'primary_color' => '#2D2062', + 'accent_color' => '#7EFF83', 'support_email' => 'support@example.com', 'footer_text' => '', + ], + 'site' => ['domain' => 'www.views-example.com', 'is_wordpress' => true], + 'updates' => [ + 'total' => 0, 'items' => ['core' => [], 'themes' => [], 'plugins' => []], + 'pending_plugins' => [], 'pending_themes' => [], 'pending_core' => false, 'updated_since_period' => 0, + ], + 'uptime' => ['monitored' => true, 'probe_stopped' => false, 'uptime_percentage' => 100, 'outages_count' => 0, 'downtime_minutes' => 0, 'outages' => []], + 'security' => [ + 'total_scans' => 30, 'vulnerabilities_checked' => true, 'vulnerabilities_as_of' => '2026-10-01', + 'active_vulnerabilities' => 0, 'vulnerable_plugins' => [], 'inactive_plugin_vulnerabilities' => 0, + 'blocked_threats_count' => 4, 'checksum_status' => 'clean', 'malware_status' => 'clean', 'blacklist_status' => 'clean', + ], + 'performance' => ['has_performance' => false, 'mobile' => null, 'desktop' => null], + 'forms' => ['live_tests' => 0, 'lab_tests' => 0, 'lab_accepted' => 0, 'pass_rate' => null, 'total_synthetic_tests' => 0], + 'traffic' => ['has_traffic' => false, 'total_visits' => 0, 'total_requests' => 0, 'bandwidth_mb' => null], + 'backups' => [ + 'verified' => true, 'status' => 'verified', 'source' => 'pressable', 'destination' => 'by your host, Pressable', + 'days_with_backups' => 30, 'covers_period' => true, 'records_from' => '2026-08-01', + 'recent' => [['date' => '2026-09-30', 'kinds' => ['db', 'fs'], 'bytes' => 60 * 1024 ** 2, 'manual' => false]], + 'last_backup_at' => '2026-09-30', 'check_error' => null, + ], + 'work_log' => ['total_hours' => 0, 'entries' => []], + ], $sections), + ]); +} + +/** @return array{html: string, text: string, preview: string, public: string} */ +function renderReportViews(ClientReport $report): array +{ + $mail = new ClientReportMail($report); + $content = $mail->content(); + + return [ + 'html' => $mail->render(), + 'text' => view($content->text, $content->with)->render(), + 'preview' => view('client-reports::reports.show', ['report' => $report, 'isOperatorPreview' => true])->render(), + 'public' => view('client-reports::reports.show', ['report' => $report, 'isOperatorPreview' => false])->render(), + ]; +} + +/** The operator note partial: a no-print box holding the note. */ +function hiddenNotePattern(string $note): string +{ + return '/
    \s*]*><\/i>\s*
    \s*
    '.preg_quote(e($note), '/').'<\/div>/'; +} + +describe('which sections the client sees', function () { + it('hides an empty work log from the email, the text part and the printout, and tells only the operator why', function () { + $views = renderReportViews(viewsReport()); + + expect($views['html'])->not->toContain('Additional work') + ->and($views['text'])->not->toContain('ADDITIONAL WORK') + ->and($views['preview'])->not->toContain('0.00 hours')->not->toContain('No manual work') + ->toMatch(hiddenNotePattern('Work log hidden from the client: no hours were logged this period.')) + ->and($views['public'])->not->toContain('Work log')->not->toContain('hidden from the client'); + }); + + it('shows every section with something true to say in all three views', function () { + $views = renderReportViews(viewsReport([ + 'performance' => ['has_performance' => true, 'mobile' => ['score' => 91, 'lcp_ms' => 1800, 'scanned_at' => '2026-09-20'], 'desktop' => null], + 'forms' => ['live_tests' => 0, 'lab_tests' => 4, 'lab_accepted' => 4, 'pass_rate' => null], + 'traffic' => ['has_traffic' => true, 'total_visits' => 1234, 'total_requests' => 5678, 'bandwidth_mb' => 10.0], + 'work_log' => ['total_hours' => 1.5, 'entries' => [['worked_on' => '2026-09-12', 'hours' => 1.5, 'description' => 'Fixed the contact page layout', 'user' => null]]], + ])); + + expect($views['html'])->toContain('Speed & performance')->toContain('>Traffic<')->toContain('Contact forms') + ->toContain('Backups')->toContain('Additional work this month')->toContain('Fixed the contact page layout') + ->and($views['text'])->toContain('SPEED & PERFORMANCE')->toContain('TRAFFIC')->toContain('CONTACT FORMS') + ->toContain('BACKUPS')->toContain('ADDITIONAL WORK THIS MONTH') + ->and($views['preview'])->toContain('Speed & Performance')->toContain('Traffic Analytics')->toContain('Contact Forms') + ->toContain('Backups & Recovery')->toContain('Fixed the contact page layout')->toContain('1.50 hours') + ->not->toContain('hidden from the client'); + }); + + it('decides visibility and the operator notes once, from the stored data', function () { + $data = viewsReport([ + 'uptime' => ['monitored' => false, 'probe_stopped' => true, 'last_checked_at' => '2026-09-12'], + 'backups' => ['verified' => false, 'status' => 'stale', 'last_backup_at' => '2026-09-05', 'recent' => []], + ])->sections_data; + + expect(ReportSections::visibility($data))->toBe([ + 'updates' => true, 'uptime' => false, 'security' => true, 'performance' => false, + 'traffic' => false, 'forms' => false, 'backups' => false, 'work_log' => false, + ])->and(ReportSections::hiddenNotes($data))->toBe([ + 'uptime' => "Uptime hidden from the client: the uptime probe hasn't checked the site since 2026-09-12.", + 'performance' => 'Speed & performance hidden from the client: no speed test ran in this period.', + 'traffic' => 'Traffic hidden from the client: no traffic was recorded for this period.', + 'forms' => 'Contact forms hidden from the client: no form tests ran in this period.', + 'backups' => 'Backups hidden from the client: no backups in the last 10 days of the period (last one 2026-09-05).', + 'work_log' => 'Work log hidden from the client: no hours were logged this period.', + ]); + + // A section the template left out was never compiled: no note for it. + unset($data['traffic']); + expect(ReportSections::hiddenNotes($data))->not->toHaveKey('traffic'); + }); + + it('never shows forms numbers from a report compiled before they were counted correctly', function () { + $data = viewsReport(['meta' => ['custom_notes' => null], 'forms' => ['pass_rate' => 0, 'total_synthetic_tests' => 12, 'live_tests' => 12]])->sections_data; + + expect(ReportSections::visibility($data)['forms'])->toBeFalse() + ->and(ReportSections::hiddenNotes($data)['forms'])->toBe('Contact forms hidden from the client: compiled before form results were counted correctly.'); + }); +}); + +describe('the preview updates table', function () { + it('lists a theme-only month instead of contradicting its own count', function () { + $views = renderReportViews(viewsReport(['updates' => [ + 'total' => 1, 'items' => ['core' => [], 'plugins' => [], 'themes' => [ + ['target' => 'example-theme', 'summary' => 'Theme updated: Example Theme 6.1 → 6.2', 'date' => '2026-09-14'], + ]], + // Something else was still waiting, which is what used to print "No updates were applied". + 'pending_plugins' => ['Example Builder'], 'pending_themes' => [], 'pending_core' => false, 'updated_since_period' => 0, + ]])); + + expect($views['preview'])->toContain('Example Theme 6.1 → 6.2')->toContain('1 update performed') + ->toMatch('/>1<\/div>\s*]*>Updates Completed/') + ->not->toContain('No updates were applied this period.') + ->and($views['html'])->toContain('Example Theme 6.1 → 6.2'); + }); + + it('lists core, theme and plugin updates, counted from the same rows as the email', function () { + $views = renderReportViews(viewsReport(['updates' => [ + // The stored total also counts a translations run, which neither view lists. + 'total' => 5, 'translations_count' => 1, + 'items' => [ + 'core' => [['target' => 'core', 'summary' => 'WordPress core updated: 6.8.2 → 6.8.3', 'date' => '2026-09-03']], + 'themes' => [['target' => 'example-theme', 'summary' => 'Theme updated: Example Theme 6.1 → 6.2', 'date' => '2026-09-14']], + 'plugins' => [ + ['target' => 'example-forms/example-forms.php', 'summary' => 'Plugin updated: Example Forms 2.0 → 2.1 [1 repair: reactivated]', 'date' => '2026-09-20'], + ['target' => 'example-seo/example-seo.php', 'summary' => '', 'date' => '2026-09-22'], + ], + ], + 'pending_plugins' => [], 'pending_themes' => [], 'pending_core' => false, 'updated_since_period' => 0, + ]])); + + expect($views['preview'])->toContain('4 updates performed')->toMatch('/>4<\/div>\s*]*>Updates Completed/') + ->toContain('6.8.2 → 6.8.3')->toContain('Example Theme 6.1 → 6.2')->toContain('Example Forms 2.0 → 2.1')->toContain('Example Seo') + ->toContain('>WordPress<')->toContain('>Theme<')->toContain('>Plugin<') + ->not->toContain('Plugin updated:')->not->toContain('1 repair')->not->toContain('example-forms/example-forms.php') + ->and($views['text'])->toContain('UPDATES APPLIED (4)'); + }); +}); + +describe('contact forms in the preview', function () { + it('shows lab checks with the email\'s wording, in a section and a tile', function () { + $views = renderReportViews(viewsReport(['forms' => ['live_tests' => 0, 'lab_tests' => 19, 'lab_accepted' => 18, 'pass_rate' => null]])); + + expect($views['preview'])->toContain('Your forms accepted 18 of 19 automated check submissions.') + ->toContain("These checks confirm the form works without sending an email, so they don't test the email.") + ->toMatch('/>18\/19<\/div>\s*]*>Form Checks Passed/') + ->not->toContain('% delivered')->not->toContain('Form Deliverability') + ->and($views['html'])->toContain('accepted 18 of 19 automated check submissions'); + }); + + it('shows live tests with the email\'s wording, claiming only that the site reported sending', function () { + $views = renderReportViews(viewsReport(['forms' => ['live_tests' => 4, 'lab_tests' => 0, 'lab_accepted' => 0, 'pass_rate' => 75]])); + + expect($views['preview'])->toContain('We sent 4 test submissions through your forms — your site reported sending the notification email for') + ->toContain('>75% of them.')->toContain("We don't check the inbox, so this doesn't confirm the emails arrived.") + ->toMatch('/>75%<\/div>\s*]*>Form Emails Sent/')->not->toContain('Deliverability') + ->not->toContain('% delivered')->not->toContain('reach you') + ->and($views['html'])->toContain('your site reported sending the notification email for')->toContain('75% of them.') + ->not->toContain('% delivered')->not->toContain('reach you') + ->and($views['text'])->toContain('4 live test submissions, notification email reported sent for 75% (inbox delivery not checked)') + ->not->toContain('% delivered'); + }); + + it('leaves the section out when no form tests ran, with a note only the operator sees', function () { + $views = renderReportViews(viewsReport()); + + expect($views['preview'])->not->toContain('Contact Forms') + ->toMatch(hiddenNotePattern('Contact forms hidden from the client: no form tests ran in this period.')) + ->and($views['public'])->not->toContain('Contact forms') + ->and($views['html'])->not->toContain('Contact forms'); + }); +}); + +describe('the public view and the printout', function () { + it('keeps staff names off the printout and the public view', function () { + $views = renderReportViews(viewsReport(['work_log' => ['total_hours' => 2, 'entries' => [ + ['worked_on' => '2026-09-12', 'hours' => 2, 'description' => 'Rebuilt the donation form', 'user' => 'Example Staffer'], + ]]])); + + expect($views['preview'])->toMatch('/Example Staffer<\/span>/') + ->and($views['public'])->toContain('Rebuilt the donation form')->not->toContain('Example Staffer') + ->and($views['html'])->not->toContain('Example Staffer') + ->and($views['text'])->not->toContain('Example Staffer'); + }); + + it('shows internal wording only to the operator, never on the public view', function () { + $views = renderReportViews(viewsReport([ + 'site' => ['domain' => 'www.views-example.com', 'is_wordpress' => false], + 'security' => ['total_scans' => 3, 'vulnerabilities_checked' => false, 'blocked_threats_count' => null, 'checksum_status' => null], + 'backups' => ['verified' => false, 'status' => 'not_checked', 'source' => 'pressable', 'recent' => [], 'check_error' => 'Example API returned 503'], + ])); + + expect($views['preview'])->toContain('no blocker reports to Control')->toContain('no WordPress detected') + ->toMatch(hiddenNotePattern('Backups hidden from the client: backups could not be checked.')) + ->toContain('Last check failed: Example API returned 503'); + + foreach (['Control', 'no WordPress detected', 'hidden from the client', 'Last check failed', 'Backups & Recovery', 'Email version', 'IP bans'] as $internal) { + expect($views['public'])->not->toContain($internal); + } + // Like the email: no core-files row for a non-WordPress site, no blocked count without a blocker. + expect($views['public'])->not->toContain('WordPress Core Files')->not->toContain('Malicious Requests Deflected') + ->and($views['html'])->not->toContain('WordPress core files')->not->toContain('Malicious visitors blocked'); + }); + + it('serves the public page without operator notes', function () { + $report = viewsReport(); + + $this->get(route('client-reports.public', ['token' => $report->public_token])) + ->assertOk() + ->assertSee('Website Care Report') + ->assertDontSee('hidden from the client') + ->assertDontSee('Email version'); + }); +}); + +describe('outage times', function () { + function outageReport(string $startedAt, bool $beforePeriod = false): ClientReport + { + return viewsReport(['uptime' => [ + 'monitored' => true, 'uptime_percentage' => 99.44, 'outages_count' => 1, 'downtime_minutes' => 240, + 'outages' => [['started_at' => $startedAt, 'minutes' => 240, 'started_before_period' => $beforePeriod, 'ongoing' => false]], + ]]); + } + + it('shows outage start times in the configured report timezone, not UTC', function () { + config(['clockwork.reports.timezone' => 'America/Los_Angeles']); + $views = renderReportViews(outageReport('2026-09-10T10:00:00+00:00')); + + expect($views['html'])->toContain('Down Sep 10, 3:00 AM PDT')->not->toContain('UTC') + ->and($views['text'])->toContain('- Down Sep 10, 3:00 AM PDT for 240 min'); + }); + + it('defaults to the operators\' timezone, with the right abbreviation for the date', function () { + expect(config('clockwork.reports.timezone'))->toBe('America/New_York') + ->and(ReportPhrases::outageStart(['started_at' => '2026-09-10T10:00:00+00:00']))->toBe('Down Sep 10, 6:00 AM EDT') + ->and(ReportPhrases::outageStart(['started_at' => '2026-12-10T15:30:00+00:00']))->toBe('Down Dec 10, 10:30 AM EST'); + }); + + it('falls back to UTC for an invalid timezone setting instead of breaking the email', function () { + config(['clockwork.reports.timezone' => 'Not/A_Zone']); + + expect(ReportPhrases::timezone())->toBe('UTC') + ->and(renderReportViews(outageReport('2026-09-10T10:00:00+00:00'))['html'])->toContain('Down Sep 10, 10:00 AM UTC'); + }); + + it('gives only the date for an outage already open when the period began', function () { + $views = renderReportViews(outageReport('2026-09-01T00:00:00+00:00', beforePeriod: true)); + + expect($views['html'])->toContain('Down since before Sep 1<')->not->toContain('Aug 31') + ->and($views['text'])->toContain('- Down since before Sep 1 for 240 min'); + }); +}); + +describe('known plugin vulnerabilities', function () { + it('says when the plugins were checked and names the affected plugins in every view', function () { + $views = renderReportViews(viewsReport(['security' => [ + 'total_scans' => 30, 'vulnerabilities_checked' => true, 'vulnerabilities_as_of' => '2026-10-01', + 'active_vulnerabilities' => 3, 'vulnerable_plugins' => ['Alpha Forms', 'Beta Gallery'], 'inactive_plugin_vulnerabilities' => 1, + 'blocked_threats_count' => null, 'checksum_status' => 'clean', + ]])); + + expect($views['html'])->toContain('(checked Oct 1)')->toContain('3 found in 2 plugins (1 in inactive plugins)') + ->toContain('Plugins affected: Alpha Forms, Beta Gallery')->not->toContain('as of') + ->and($views['text'])->toContain("Known plugin vulnerabilities (checked Oct 1): 3 found in 2 plugins (1 in inactive plugins)\nPlugins affected: Alpha Forms, Beta Gallery\n") + ->and($views['preview'])->toContain('3 found in 2 plugins (1 in inactive plugins)') + ->toContain('Plugins affected: Alpha Forms, Beta Gallery')->toContain('Checked Oct 1')->not->toContain('As of'); + }); + + it('names no plugins when none were found or the plugins were not checked', function () { + $none = renderReportViews(viewsReport()); + $unchecked = renderReportViews(viewsReport(['security' => ['total_scans' => 1, 'vulnerabilities_checked' => false, 'vulnerable_plugins' => ['Stale Plugin'], 'active_vulnerabilities' => 1]])); + + expect($none['html'])->toContain('None found')->not->toContain('affected:') + ->and($none['text'])->toContain('Known plugin vulnerabilities (checked Oct 1): None found') + ->and($unchecked['html'])->toContain('Not checked')->not->toContain('Stale Plugin') + ->and($unchecked['preview'])->not->toContain('Stale Plugin'); + }); +}); + +describe('security', function () { + it('shows a partial malware scan, leaves out core files a host cannot check, and only claims the server where it bans', function () { + $views = renderReportViews(viewsReport(['security' => [ + 'total_scans' => 30, 'vulnerabilities_checked' => true, 'vulnerabilities_as_of' => '2026-10-01', + 'active_vulnerabilities' => 0, 'vulnerable_plugins' => [], 'inactive_plugin_vulnerabilities' => 0, + 'blocked_threats_count' => 2, 'blocks_at_server' => false, + 'checksum_supported' => false, 'checksum_status' => null, + 'malware_status' => 'incomplete', 'malware_checked_at' => '2026-09-30', 'blacklist_status' => 'clean', + ]])); + + expect($views['html'])->toContain('Partial scan')->not->toContain('No malware found') + ->not->toContain('WordPress core files') + ->toContain('block malicious visitors')->not->toContain('at the server') + ->and($views['text'])->toContain('Malware scan: Partial scan')->not->toContain('WordPress core files') + ->and($views['preview'])->toContain('Partial scan') + ->toContain('Not checked (no checksum path for this host) — hidden from the client') + ->and($views['public'])->toContain('Partial scan')->not->toContain('WordPress Core Files')->not->toContain('no checksum path'); + }); + + it('keeps reports compiled before these keys were stored reading as they did', function () { + $views = renderReportViews(viewsReport()); + + expect($views['html'])->toContain('WordPress core files')->toContain('Verified clean') + ->toContain('block attackers at the server')->toContain('No malware found') + ->and($views['text'])->toContain('WordPress core files: Verified clean') + ->and($views['public'])->toContain('WordPress Core Files'); + }); +}); + +describe('backup destination', function () { + it('reads as a whole phrase for every backup source', function (string $source, string $phrase) { + $views = renderReportViews(viewsReport(['backups' => [ + 'verified' => true, 'status' => 'verified', 'source' => $source, 'destination' => ReportPhrases::BACKUP_DESTINATIONS[$source], + 'days_with_backups' => 30, 'covers_period' => true, 'recent' => [['date' => '2026-09-30', 'kinds' => ['full'], 'bytes' => null, 'manual' => false]], + ]])); + + expect($views['html'])->toContain("backed up on 30 days this month {$phrase}, so it can be restored") + ->and($views['text'])->toContain("Backed up on 30 days this month {$phrase}. Most recent:") + ->and($views['preview'])->toContain("Backed up on 30 days this period {$phrase}."); + foreach (['html', 'text', 'preview'] as $view) { + expect($views[$view])->not->toContain('by by')->not->toContain('by to '); + } + })->with([ + 'host' => ['pressable', 'by your host, Pressable'], + 'object storage' => ['spinupwp_spaces', 'by SpinupWP to DigitalOcean Spaces'], + 'our archives' => ['s3_archive', 'to independent off-site storage (Amazon S3)'], + ]); + + it('keeps reports compiled before the phrase was stored whole reading correctly', function (array $backups, ?string $expected) { + expect(ReportPhrases::backupDestination($backups))->toBe($expected); + })->with([ + 'old destination, known source' => [['source' => 's3_archive', 'destination' => 'independent off-site storage (Amazon S3)'], 'to independent off-site storage (Amazon S3)'], + 'old destination, no source' => [['destination' => 'Example Snapshots'], 'by Example Snapshots'], + 'whole phrase, no source' => [['destination' => 'to Example Storage'], 'to Example Storage'], + 'nothing known' => [['source' => null, 'destination' => null], null], + ]); + + it('says where an older report was backed up without a dangling "by"', function () { + $views = renderReportViews(viewsReport(['backups' => [ + 'verified' => true, 'status' => 'verified', 'source' => 'pressable', 'destination' => 'your host, Pressable', + 'days_with_backups' => 9, 'covers_period' => false, 'records_from' => '2026-09-22', + 'recent' => [['date' => '2026-09-30', 'kinds' => ['fs'], 'bytes' => null, 'manual' => false]], + ]])); + + expect($views['html'])->toContain('Your site is backed up by your host, Pressable, so it can be restored') + ->and($views['text'])->toContain('Backed up by your host, Pressable. Most recent:') + ->and($views['preview'])->toContain('Backed up by your host, Pressable.') + ->toContain('Our records for this site start 2026-09-22') + ->and($views['public'])->not->toContain('Our records for this site start'); + }); +}); + +describe('partial-month coverage', function () { + it('says when monitoring began in every view of a site enrolled part-way through the month', function () { + // Late evening UTC is still the same day in the report timezone. + $views = renderReportViews(viewsReport([ + 'meta' => ['schema_version' => ClientReportCompiler::SCHEMA_VERSION, 'custom_notes' => null, 'period_start' => '2026-09-01', 'period_end' => '2026-09-30', 'coverage_start' => '2026-09-26T18:20:00+00:00'], + 'uptime' => [ + 'monitored' => true, 'probe_stopped' => false, 'monitored_since' => '2026-09-26T18:20:00+00:00', 'uptime_percentage' => 98.33, + 'outages_count' => 1, 'downtime_minutes' => 102, 'total_period_minutes' => 6100, + 'outages' => [['started_at' => '2026-09-27T10:00:00+00:00', 'minutes' => 102, 'started_before_period' => false, 'ongoing' => false]], + ], + ])); + $note = 'Monitoring began Sep 26, so this report covers Sep 26 – Sep 30.'; + + expect($views['html'])->toContain($note)->toContain('(monitoring began Sep 26)') + ->and($views['text'])->toContain("for September 2026.\n".$note)->toContain('Availability: 98.33% (monitoring began Sep 26)') + ->and($views['preview'])->toContain($note)->toContain('Monitoring began')->not->toContain('24 / 7 / 365') + ->and($views['public'])->toContain($note)->toContain('Monitoring began'); + }); + + it('keeps a full month, and reports compiled before coverage was recorded, as they were', function () { + $views = renderReportViews(viewsReport()); + + expect($views['html'])->not->toContain('onitoring began') + ->and($views['text'])->not->toContain('onitoring began') + ->and($views['preview'])->toContain('24 / 7 / 365')->not->toContain('onitoring began'); + }); + + it('shows the time the monitor was blocked only to the operator', function () { + $views = renderReportViews(viewsReport(['uptime' => [ + 'monitored' => true, 'probe_stopped' => false, 'uptime_percentage' => 100, 'outages_count' => 0, 'downtime_minutes' => 0, + 'outages' => [], 'probe_blocked_minutes' => 720, 'probe_blocked_since' => null, + ]])); + + expect($views['preview'])->toContain('The uptime monitor was blocked (HTTP 429) for 720 min this period.') + ->and($views['public'])->not->toContain('HTTP 429') + ->and($views['html'])->not->toContain('HTTP 429') + ->and($views['text'])->not->toContain('HTTP 429'); + }); +}); + +describe('traffic', function () { + /** A month of traffic as the compiler stores it now. */ + function trafficSection(array $overrides = []): array + { + return array_replace(['has_traffic' => true, 'total_visits' => 1234, 'total_requests' => 56789, 'bandwidth_mb' => null, 'status' => 'ok', + 'source' => 'nginx', 'days_covered' => 30, 'period_days' => 30, 'last_day_complete' => true, 'top_ip_share' => 0.2], $overrides); + } + + it('shows complete traffic, and reports compiled before traffic had a status, in every view', function (array $section) { + $views = renderReportViews(viewsReport(['traffic' => $section])); + + expect($views['html'])->toContain('>Traffic<')->toContain('56,789') + ->and($views['text'])->toContain("TRAFFIC\nVisits: 1,234\nRequests served: 56,789") + ->and($views['preview'])->toContain('Traffic Analytics')->toContain('56,789'); + })->with([ + 'ok' => [trafficSection()], + 'compiled before statuses' => [['has_traffic' => true, 'total_visits' => 1234, 'total_requests' => 56789, 'bandwidth_mb' => null]], + ]); + + it('says where complete traffic came from only to the operator', function () { + $views = renderReportViews(viewsReport(['traffic' => trafficSection(['source' => 'pressable'])])); + + expect($views['preview'])->toContain('Recorded on 30 of 30 days, from Pressable's traffic feed.') + ->and($views['public'])->toContain('Traffic Analytics')->not->toContain('Recorded on') + ->and($views['html'])->not->toContain('Recorded on'); + }); + + it('hides incomplete or unbelievable traffic from the client, and tells the operator why', function (array $section, string $note) { + $report = viewsReport(['traffic' => trafficSection($section)]); + $views = renderReportViews($report); + + expect(ReportSections::visibility($report->sections_data)['traffic'])->toBeFalse() + ->and($views['html'])->not->toContain('>Traffic<')->not->toContain('56,789') + ->and($views['text'])->not->toContain('TRAFFIC') + ->and($views['preview'])->not->toContain('Traffic Analytics')->toMatch(hiddenNotePattern($note)) + ->and($views['public'])->not->toContain('Traffic')->not->toContain('56,789'); + })->with([ + 'partial' => [['status' => 'partial', 'days_covered' => 12], 'Traffic hidden from the client: traffic was recorded on only 12 of 30 days.'], + 'suspect' => [['status' => 'suspect', 'top_ip_share' => 0.97], 'Traffic hidden from the client: 97% of requests came from two IP addresses, which looks like a proxy or gateway in front of the site rather than visitors.'], + 'no data' => [['status' => 'no_data', 'has_traffic' => false, 'days_covered' => 0], "Traffic hidden from the client: no traffic was recorded for this period, although this host's traffic is collected."], + 'unsupported' => [['status' => 'unsupported', 'has_traffic' => false, 'days_covered' => 0], "Traffic hidden from the client: traffic isn't collected for this host."], + ]); +}); + +describe('implausibly small backups', function () { + it('hides them from the client and gives the operator both sizes', function () { + $views = renderReportViews(viewsReport(['backups' => [ + 'verified' => false, 'status' => 'suspect', 'source' => 'spinupwp_spaces', 'days_with_backups' => 30, 'covers_period' => true, + 'recent' => [['date' => '2026-09-30', 'kinds' => ['db'], 'bytes' => 9200, 'manual' => false]], + 'db_backup_bytes' => 9200, 'db_size_bytes' => 3 * 1024 ** 3, + ]])); + + expect($views['html'])->not->toContain('backed up') + ->and($views['text'])->not->toContain('BACKUPS') + ->and($views['preview'])->not->toContain('Backups & Recovery') + ->toMatch(hiddenNotePattern('Backups hidden from the client: the newest database backup is far smaller than the database (9 KB for 3.0 GB).')) + ->and($views['public'])->not->toContain('Backups'); + }); +}); + +describe('off-site backups that keep failing', function () { + it('shows the operator a Failing badge and the relay error, and the client nothing', function () { + $views = renderReportViews(viewsReport(['backups' => [ + 'verified' => false, 'status' => 'failing', 'source' => 's3_archive', 'days_with_backups' => 0, 'recent' => [], + 'relay_failing_since' => '2026-09-14T12:00:00+00:00', 'relay_error' => 'Example upload failed with HTTP 403', + ]])); + + expect($views['preview']) + ->toMatch(hiddenNotePattern('Backups hidden from the client: no off-site archive this period; the backup relay has been failing since Sep 14, 2026.')) + ->toMatch('/Failing<\/span>/') + ->toContain('Last relay error: Example upload failed with HTTP 403') + ->not->toContain('Backups & Recovery') + ->and($views['html'])->not->toContain('backed up')->not->toContain('Example upload failed') + ->and($views['text'])->not->toContain('BACKUPS')->not->toContain('Example upload failed') + ->and($views['public'])->not->toContain('Failing')->not->toContain('Example upload failed')->not->toContain('Backups'); + }); +}); diff --git a/tests/Feature/Modules/ClientReportsControllerTest.php b/tests/Feature/Modules/ClientReportsControllerTest.php index 621c12cf..f4127e97 100644 --- a/tests/Feature/Modules/ClientReportsControllerTest.php +++ b/tests/Feature/Modules/ClientReportsControllerTest.php @@ -2,12 +2,17 @@ use App\Models\Site; use App\Models\SiteTrafficDaily; +use App\Models\SiteWorkLog; use App\Models\User; use App\Services\Companion\CompanionBrandingManager; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Str; use Modules\ClientReports\Mail\ClientReportMail; use Modules\ClientReports\Models\ClientReport; +use Modules\ClientReports\Models\ClientReportSchedule; use Modules\ClientReports\Services\ClientReportCompiler; +use Modules\ClientReports\Services\ScheduledReportRunner; use Tests\Concerns\RendersAuthenticatedPages; uses(RendersAuthenticatedPages::class); @@ -37,6 +42,7 @@ ]); $this->get(route('client-reports.index'))->assertRedirect(route('login')); + $this->get(route('client-reports.reports'))->assertRedirect(route('login')); $this->get(route('client-reports.show', $report))->assertRedirect(route('login')); $this->post(route('client-reports.generate'))->assertRedirect(route('login')); $this->post(route('client-reports.send', $report))->assertRedirect(route('login')); @@ -56,7 +62,7 @@ 'status' => 'generated', ]); - $response = $this->actingAs(User::factory()->create())->get(route('client-reports.index')); + $response = $this->actingAs(User::factory()->create())->get(route('client-reports.reports')); $response->assertOk()->assertSee('Existing Report'); }); @@ -114,14 +120,23 @@ it('shows the Traffic Analytics section when the period had recorded traffic', function () { $site = Site::factory()->create(['domain' => 'traffic-report.test']); - $periodStart = now()->subMonth()->startOfMonth(); - SiteTrafficDaily::factory()->create([ - 'site_id' => $site->id, - 'date' => $periodStart->copy()->addDays(5)->toDateString(), - 'visits' => 1234, - 'requests' => 5678, - 'bytes_sent' => 10 * 1024 * 1024, - ]); + $periodStart = now()->subMonthNoOverflow()->startOfMonth(); + // Every day of the period, each row written after its day was over: + // a month with days missing is held back as partial. + $days = $periodStart->daysInMonth; + foreach (range(0, $days - 1) as $offset) { + $day = $periodStart->copy()->addDays($offset); + SiteTrafficDaily::factory()->create([ + 'site_id' => $site->id, + 'date' => $day->toDateString(), + 'visits' => 41, + 'requests' => 1003, + 'bytes_sent' => $offset === 0 ? 10 * 1024 * 1024 : 0, + 'top_ips' => null, + 'created_at' => $day->copy()->addDay()->setTime(5, 0), + 'updated_at' => $day->copy()->addDay()->setTime(5, 0), + ]); + } $response = $this->actingAs(User::factory()->create())->post(route('client-reports.generate'), [ 'site_id' => $site->id, @@ -135,9 +150,10 @@ ->get(route('client-reports.show', $report)) ->assertOk() ->assertSee('Traffic Analytics') - ->assertSee('1,234') - ->assertSee('5,678') - ->assertSee('10.0 MB'); + ->assertSee(number_format(41 * $days)) + ->assertSee(number_format(1003 * $days)) + ->assertSee('10 MB') // ReportPhrases::bandwidth(), as in the email + ->assertSee("Recorded on {$days} of {$days} days", false); }); it('hides the Traffic Analytics section entirely when no traffic was recorded for the period', function () { @@ -160,6 +176,7 @@ describe('email version', function () { it('shows the email exactly as the client gets it, with who it goes to and why it was held', function () { + $this->mockIssueCounterZero(); // the email version renders inside the app layout $user = User::factory()->create(); $site = Site::factory()->create(['domain' => 'email-version.test']); $data = app(ClientReportCompiler::class)->compile($site, now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()); @@ -183,7 +200,27 @@ ->assertSee(route('client-reports.email', $report), false); }); + it('shows the From line the client will see: the sending address under the report brand name', function () { + $this->mockIssueCounterZero(); + config(['mail.from.address' => 'reports@example.com', 'mail.from.name' => 'Internal Control App']); + app(CompanionBrandingManager::class)->saveReportsBranding(['company_name' => 'Example Agency']); + $user = User::factory()->create(); + $site = Site::factory()->create(['domain' => 'from-line.example.com']); + $report = ClientReport::create([ + 'site_id' => $site->id, 'title' => 'From line', 'period_start' => now()->subMonth()->startOfMonth(), + 'period_end' => now()->subMonth()->endOfMonth(), + 'sections_data' => app(ClientReportCompiler::class)->compile($site, now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()), + 'client_email' => 'a@example.com', 'status' => 'generated', + ]); + + $this->actingAs($user)->get(route('client-reports.email', $report)) + ->assertOk() + ->assertSee('Example Agency ') + ->assertDontSee('Internal Control App'); + }); + it('shows the plain-text part too, and says so when everyone already has the report', function () { + $this->mockIssueCounterZero(); $user = User::factory()->create(); $site = Site::factory()->create(['domain' => 'text-part.test']); $report = ClientReport::create([ @@ -230,17 +267,18 @@ }); describe('send', function () { - function sendableReport(?string $clientEmail): ClientReport + /** A report compiled under the current rules, so nothing stops it being sent. */ + function sendableReport(?string $clientEmail, array $attributes = []): ClientReport { - return ClientReport::create([ + return ClientReport::create(array_merge([ 'site_id' => Site::factory()->create()->id, 'title' => 'Send Test Report', 'period_start' => now()->subMonth()->startOfMonth(), 'period_end' => now()->subMonth()->endOfMonth(), - 'sections_data' => [], + 'sections_data' => ['meta' => ['schema_version' => ClientReportCompiler::SCHEMA_VERSION]], 'client_email' => $clientEmail, 'status' => 'generated', - ]); + ], $attributes)); } it('emails every address in a scheduled report\'s comma-joined recipient list', function () { @@ -297,15 +335,263 @@ function sendableReport(?string $clientEmail): ClientReport Mail::assertNothingSent(); }); + it('refuses to send an unsent report compiled under older report rules, and leaves the schedule as it was', function () { + Mail::fake(); + $site = Site::factory()->create(); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => ['client@example.com'], + 'is_enabled' => true, 'next_run_at' => Carbon::parse('2027-01-01 00:00:00'), + ]); + $report = sendableReport('client@example.com', [ + 'site_id' => $site->id, 'schedule_id' => $schedule->id, + 'sections_data' => ['meta' => ['schema_version' => 3, 'review_reasons' => ['Backups could not be checked.']]], + ]); + + $this->actingAs(User::factory()->create()) + ->post(route('client-reports.send', $report)) + ->assertSessionHas('status_error', 'This report was compiled before the latest report fixes. Press Recompile, check the new copy, then send that one.'); + + Mail::assertNothingSent(); + expect($report->fresh()->status)->toBe('generated') + ->and($schedule->fresh()->last_sent_at)->toBeNull(); + }); + + it('tells an operator to generate a new report when an outdated one has no schedule to recompile it from', function () { + Mail::fake(); + $report = sendableReport('client@example.com', ['sections_data' => []]); + + $this->actingAs(User::factory()->create()) + ->post(route('client-reports.send', $report)) + ->assertSessionHas('status_error', 'This report was compiled before the latest report fixes. Generate a new report for this period instead.'); + + Mail::assertNothingSent(); + }); + + it('still finishes delivering an outdated report that some recipients already have', function () { + Mail::fake(); + $report = sendableReport('one@client.test, two@client.test', [ + 'sections_data' => ['meta' => ['schema_version' => 3]], + 'sent_to' => ['one@client.test'], + ]); + + $this->actingAs(User::factory()->create()) + ->post(route('client-reports.send', $report)) + ->assertSessionHas('status', 'Report emailed to two@client.test.'); + + Mail::assertSent(ClientReportMail::class, fn ($mail) => $mail->hasTo('two@client.test')); + Mail::assertNotSent(ClientReportMail::class, fn ($mail) => $mail->hasTo('one@client.test')); + }); + + it('marks an outdated unsent report on the list and in the preview, without a send button', function () { + $this->mockIssueCounterZero(); + $report = sendableReport('outdated@client.test', ['sections_data' => ['meta' => ['schema_version' => 3]]]); + + $this->actingAs(User::factory()->create()) + ->get(route('client-reports.reports')) + ->assertOk() + ->assertSee('Outdated') + ->assertSee('Compiled before the latest report fixes') + ->assertDontSee('action="'.route('client-reports.send', $report).'"', false) + ->assertDontSee('Email report to outdated@client.test?'); + + $site = Site::factory()->create(); + $data = app(ClientReportCompiler::class)->compile($site, now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()); + $data['meta']['schema_version'] = 3; + $compiled = sendableReport('outdated@client.test', ['site_id' => $site->id, 'sections_data' => $data]); + + $this->get(route('client-reports.show', $compiled)) + ->assertOk() + ->assertSee('Outdated: compiled before the latest report fixes'); + // The public view never shows it. + $this->get(route('client-reports.public', $compiled->public_token)) + ->assertOk() + ->assertDontSee('Outdated'); + }); + it('asks for an address on the reports list when a report has none', function () { $this->mockIssueCounterZero(); sendableReport(null); sendableReport('saved@client.test'); $this->actingAs(User::factory()->create()) - ->get(route('client-reports.index')) + ->get(route('client-reports.reports')) ->assertOk() ->assertSee('Send this report to which email address(es)?') ->assertSee('Email report to saved@client.test?'); }); }); + +describe('recompile', function () { + /** + * A care-plan site whose report has nothing to hold on its own: no backup + * source, a running uptime probe, a host with no core-file check, and + * enrolled long before October. + */ + function recompileSite(): Site + { + return 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(), + ]); + } + + function recompileSchedule(Site $site, array $attributes = []): ClientReportSchedule + { + return ClientReportSchedule::create(array_merge([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => ['client@example.com'], + 'is_enabled' => true, 'next_run_at' => Carbon::parse('2027-01-01 00:00:00'), + ], $attributes)); + } + + /** An October report from the schedule, held for review, as the Nov 1 run left it. */ + function heldOctoberReport(ClientReportSchedule $schedule, array $attributes = []): ClientReport + { + $start = Carbon::parse('2026-10-01'); + $end = Carbon::parse('2026-10-31'); + $data = app(ClientReportCompiler::class)->compile($schedule->site, $start, $end); + $data['meta']['review_reasons'] = [ScheduledReportRunner::FIRST_REPORT_REASON]; + + return ClientReport::create(array_merge([ + 'site_id' => $schedule->site_id, 'schedule_id' => $schedule->id, 'title' => 'Maintenance Report (Oct 2026)', + 'period_start' => $start, 'period_end' => $end, 'sections_data' => $data, + 'client_email' => 'client@example.com', 'status' => ClientReport::STATUS_GENERATED, + ], $attributes)); + } + + beforeEach(function () { + // A month later than the reports: "last month" is November, not October. + $this->travelTo(Carbon::parse('2026-12-10 09:00:00')); + }); + + it('requires authentication', function () { + $report = heldOctoberReport(recompileSchedule(recompileSite())); + + $this->post(route('client-reports.recompile', $report))->assertRedirect(route('login')); + expect(ClientReport::count())->toBe(1); + }); + + it('recompiles a held report for its own period, supersedes the old copy and holds the new one, without sending', function () { + Mail::fake(); + $schedule = recompileSchedule(recompileSite()); + $old = heldOctoberReport($schedule); + // Logged after the draft was compiled: the reason drafts need refreshing. + SiteWorkLog::create(['site_id' => $schedule->site_id, 'worked_on' => '2026-10-20', 'hours' => 1.5, 'description' => 'Example plugin cleanup']); + $nextRun = $schedule->next_run_at->toIso8601String(); + + $response = $this->actingAs(User::factory()->create())->post(route('client-reports.recompile', $old)); + + $fresh = ClientReport::query()->where('status', ClientReport::STATUS_GENERATED)->sole(); + $response->assertRedirect(route('client-reports.show', $fresh)) + ->assertSessionHas('status', fn ($m) => str_contains($m, 'Recompiled from current data for Oct 1 – Oct 31, 2026. Nothing was sent.') + && str_contains($m, ScheduledReportRunner::FIRST_REPORT_REASON)); + + Mail::assertNothingSent(); + Mail::assertNothingQueued(); + expect($old->fresh()->status)->toBe(ClientReport::STATUS_SUPERSEDED) + ->and($fresh->id)->not->toBe($old->id) + ->and($fresh->schedule_id)->toBe($schedule->id) + ->and($fresh->period_start->toDateString())->toBe('2026-10-01') + ->and($fresh->period_end->toDateString())->toBe('2026-10-31') + ->and($fresh->sections_data['meta']['period_start'])->toBe('2026-10-01') + ->and($fresh->sections_data['work_log']['total_hours'])->toEqual(1.5) + ->and($fresh->sections_data['meta']['review_reasons'])->toBe([ScheduledReportRunner::FIRST_REPORT_REASON]) + ->and($fresh->sent_to)->toBeNull() + // Not a scheduled cycle: the schedule is left exactly as it was. + ->and($schedule->fresh()->last_sent_at)->toBeNull() + ->and($schedule->fresh()->next_run_at->toIso8601String())->toBe($nextRun); + }); + + it('never sends, even when the new copy is clean and the schedule sends clean reports by itself', function () { + Mail::fake(); + $schedule = recompileSchedule(recompileSite(), ['last_sent_at' => Carbon::parse('2026-10-01 06:00:00')]); + $old = heldOctoberReport($schedule); + + $this->actingAs(User::factory()->create())->post(route('client-reports.recompile', $old)) + ->assertSessionHas('status', fn ($m) => str_contains($m, 'Nothing needs review')); + + Mail::assertNothingSent(); + Mail::assertNothingQueued(); + $fresh = ClientReport::query()->where('status', ClientReport::STATUS_GENERATED)->sole(); + expect($fresh->sections_data['meta'])->not->toHaveKey('review_reasons') + ->and($fresh->sent_to)->toBeNull() + ->and($schedule->fresh()->last_sent_at->toDateString())->toBe('2026-10-01'); + }); + + it('does not add the first-report reason for a draft-mode schedule, like a scheduled run', function () { + Mail::fake(); + $schedule = recompileSchedule(recompileSite(), ['delivery_mode' => ClientReportSchedule::MODE_DRAFT]); + + $run = app(ScheduledReportRunner::class)->recompile(heldOctoberReport($schedule)); + + Mail::assertNothingSent(); + expect($run->isDraft)->toBeTrue() + ->and($run->heldReasons)->toBe([]) + ->and($run->report->sections_data['meta'])->not->toHaveKey('review_reasons'); + }); + + it('keeps the report\'s custom note', function () { + $schedule = recompileSchedule(recompileSite()); + $old = heldOctoberReport($schedule); + $old->update(['sections_data' => array_replace_recursive($old->sections_data, ['meta' => ['custom_notes' => 'Example note for the client.']])]); + + $run = app(ScheduledReportRunner::class)->recompile($old->fresh()); + + expect($run->report->sections_data['meta']['custom_notes'])->toBe('Example note for the client.'); + }); + + it('refuses reports that reached someone, were replaced, or have no schedule', function (Closure $makeReport, string $error) { + Mail::fake(); + $report = $makeReport(); + $before = ClientReport::count(); + + $this->actingAs(User::factory()->create())->post(route('client-reports.recompile', $report)) + ->assertSessionHas('status_error', $error); + + Mail::assertNothingSent(); + expect(ClientReport::count())->toBe($before) + ->and($report->fresh()->status)->toBe($report->status); + expect(fn () => app(ScheduledReportRunner::class)->recompile($report))->toThrow(InvalidArgumentException::class); + })->with([ + 'sent' => [fn () => heldOctoberReport(recompileSchedule(recompileSite()), ['status' => ClientReport::STATUS_SENT, 'sent_to' => ['client@example.com']]), "This report has already been sent to someone, so it can't be recompiled."], + 'partly sent' => [fn () => heldOctoberReport(recompileSchedule(recompileSite()), ['sent_to' => ['client@example.com']]), "This report has already been sent to someone, so it can't be recompiled."], + 'superseded' => [fn () => heldOctoberReport(recompileSchedule(recompileSite()), ['status' => ClientReport::STATUS_SUPERSEDED]), 'A newer report for this period replaced this one. Recompile that one instead.'], + 'generated by hand' => [fn () => heldOctoberReport(recompileSchedule(recompileSite()), ['schedule_id' => null]), 'Only scheduled reports can be recompiled. Generate a new report for this period instead.'], + ]); + + it('leaves the old copy in place when compiling fails', function () { + Mail::fake(); + $old = heldOctoberReport(recompileSchedule(recompileSite())); + $this->mock(ClientReportCompiler::class)->shouldReceive('compile')->andThrow(new RuntimeException('Example compile failure')); + + $this->actingAs(User::factory()->create())->post(route('client-reports.recompile', $old)) + ->assertSessionHas('status_error', fn ($m) => str_contains($m, 'Example compile failure')); + + Mail::assertNothingSent(); + expect($old->fresh()->status)->toBe(ClientReport::STATUS_GENERATED) + ->and(ClientReport::count())->toBe(1); + }); + + it('offers Recompile on unsent scheduled reports in the history and the preview, never on the public view', function () { + $this->mockIssueCounterZero(); + $user = User::factory()->create(); + $held = heldOctoberReport(recompileSchedule(recompileSite())); + $sent = heldOctoberReport(recompileSchedule(recompileSite()), ['status' => ClientReport::STATUS_SENT, 'sent_to' => ['client@example.com']]); + $manual = heldOctoberReport(recompileSchedule(recompileSite()), ['schedule_id' => null]); + + $index = $this->actingAs($user)->get(route('client-reports.reports'))->assertOk()->getContent(); + expect($index)->toContain('action="'.route('client-reports.recompile', $held).'"') + ->not->toContain('action="'.route('client-reports.recompile', $sent).'"') + ->not->toContain('action="'.route('client-reports.recompile', $manual).'"'); + + $this->actingAs($user)->get(route('client-reports.show', $held)) + ->assertOk()->assertSee(route('client-reports.recompile', $held))->assertSee('Recompile'); + $this->actingAs($user)->get(route('client-reports.show', $sent)) + ->assertOk()->assertDontSee(route('client-reports.recompile', $sent)); + + $this->post('/logout'); + $this->get(route('client-reports.public', $held->public_token)) + ->assertOk()->assertDontSee('/recompile', false)->assertDontSee('Recompile'); + }); +}); diff --git a/tests/Feature/Modules/RecompileHeldClientReportsTest.php b/tests/Feature/Modules/RecompileHeldClientReportsTest.php new file mode 100644 index 00000000..04babecf --- /dev/null +++ b/tests/Feature/Modules/RecompileHeldClientReportsTest.php @@ -0,0 +1,78 @@ +carePlan()->create([ + 'domain' => 'bulk-'.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(), + ]); + $schedule = ClientReportSchedule::create([ + 'site_id' => $site->id, 'frequency' => ClientReportSchedule::FREQUENCY_MONTHLY, + 'delivery_mode' => ClientReportSchedule::MODE_AUTO, 'recipients' => ['client@example.com'], + 'is_enabled' => true, 'next_run_at' => Carbon::parse('2027-01-01 00:00:00'), 'last_sent_at' => now()->subMonths(2), + ]); + $start = Carbon::parse('2026-10-01'); + $end = Carbon::parse('2026-10-31'); + $data = app(ClientReportCompiler::class)->compile($site, $start, $end); + $data['meta'] = array_replace($data['meta'], $meta); + + return ClientReport::create(array_merge([ + 'site_id' => $site->id, 'schedule_id' => $schedule->id, 'title' => 'October report', + 'period_start' => $start, 'period_end' => $end, 'sections_data' => $data, + 'status' => ClientReport::STATUS_GENERATED, + ], $attributes)); +} + +it('recompiles every unsent scheduled report, never sends, and leaves the schedules where they were', function () { + Mail::fake(); + $outdated = bulkRecompileReport(['schema_version' => 3, 'review_reasons' => ['Backups could not be checked.']]); + $current = bulkRecompileReport(); + $sent = bulkRecompileReport(['schema_version' => 3], ['status' => ClientReport::STATUS_SENT, 'sent_to' => ['client@example.com']]); + $nextRun = $outdated->schedule->next_run_at->toIso8601String(); + + $this->artisan('clockwork:recompile-held-client-reports') + ->expectsOutputToContain('Recompiled 2 report(s)') + ->assertSuccessful(); + + Mail::assertNothingSent(); + expect($outdated->fresh()->status)->toBe(ClientReport::STATUS_SUPERSEDED) + ->and($current->fresh()->status)->toBe(ClientReport::STATUS_SUPERSEDED) + ->and($sent->fresh()->status)->toBe(ClientReport::STATUS_SENT) + ->and($outdated->schedule->fresh()->next_run_at->toIso8601String())->toBe($nextRun); + + $fresh = ClientReport::query()->where('schedule_id', $outdated->schedule_id)->where('status', ClientReport::STATUS_GENERATED)->sole(); + expect($fresh->sections_data['meta']['schema_version'])->toBe(ClientReportCompiler::SCHEMA_VERSION) + ->and($fresh->isStale())->toBeFalse(); +}); + +it('limits itself to outdated reports with --outdated, and changes nothing on --dry-run', function () { + Mail::fake(); + $outdated = bulkRecompileReport(['schema_version' => 3]); + $current = bulkRecompileReport(); + + $this->artisan('clockwork:recompile-held-client-reports', ['--outdated' => true, '--dry-run' => true]) + ->expectsOutputToContain("#{$outdated->id}") + ->expectsOutputToContain('1 report(s) would be recompiled') + ->assertSuccessful(); + expect($outdated->fresh()->status)->toBe(ClientReport::STATUS_GENERATED); + + $this->artisan('clockwork:recompile-held-client-reports', ['--outdated' => true])->assertSuccessful(); + + Mail::assertNothingSent(); + expect($outdated->fresh()->status)->toBe(ClientReport::STATUS_SUPERSEDED) + ->and($current->fresh()->status)->toBe(ClientReport::STATUS_GENERATED); +}); diff --git a/tests/Feature/Security/CompanionMalwareScannerTest.php b/tests/Feature/Security/CompanionMalwareScannerTest.php new file mode 100644 index 00000000..c55508c3 --- /dev/null +++ b/tests/Feature/Security/CompanionMalwareScannerTest.php @@ -0,0 +1,465 @@ + Http::preventStrayRequests()); + +function cmsCompanionSite(array $overrides = []): Site +{ + return Site::factory()->carePlan()->withCompanionInstalled()->create($overrides); +} + +/** + * @param list $findings + */ +function cmsFakeCompanionFindings(Site $site, array $findings, array $extra = []): void +{ + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/malware-scan*" => Http::response( + CompanionFixtures::malwareScan(array_merge(['findings' => $findings], $extra)), + 200, + ['Content-Type' => 'application/json'], + ), + ]); +} + +function cmsSshSite(array $overrides = []): Site +{ + $server = Server::factory()->create(['ssh_password' => 'not-empty']); + + return Site::factory()->carePlan()->create(array_merge([ + 'server_id' => $server->id, + 'companion_installed' => false, + 'companion_secret' => null, + 'wp_path' => '/sites/ssh.example.com/files', + ], $overrides)); +} + +/** + * Mock the SSH transport the way a finished check answers: the docroot + * pre-check prints its OK marker, $uploads answers the find-in-uploads check, + * and $signatures maps a needle substring to the grep output for that needle. + * Every other command (the wp-config stat) returns nothing. Each check's + * output ends with the completion sentinel, except for a check named in + * $cutOff (its output is cut off) or $timedOut (execChecked throws). Name + * the uploads check 'find', a grep by its needle, the stat 'stat'. + * + * @param array $signatures + * @param list $commands receives every command run + * @param list $cutOff + * @param list $timedOut + */ +function cmsFakeSsh(string $uploads = '', array $signatures = [], array &$commands = [], array $cutOff = [], array $timedOut = [], bool $docrootOk = true): void +{ + test()->mock(SshClient::class, function ($mock) use ($uploads, $signatures, &$commands, $cutOff, $timedOut, $docrootOk) { + $mock->shouldReceive('execChecked')->andReturnUsing(function ($server, string $cmd) use ($uploads, $signatures, &$commands, $cutOff, $timedOut, $docrootOk) { + $commands[] = $cmd; + if (str_starts_with($cmd, 'test -d ')) { + return $docrootOk ? CompanionMalwareScanner::SSH_DOCROOT_OK."\n" : ''; + } + + [$name, $out] = match (true) { + str_starts_with($cmd, 'find ') => ['find', $uploads], + str_starts_with($cmd, 'stat ') => ['stat', ''], + default => [null, ''], + }; + foreach ($signatures + ['__none__' => ''] as $needle => $grepOut) { + if ($name === null && str_starts_with($cmd, 'grep ') && str_contains($cmd, $needle)) { + [$name, $out] = [$needle, $grepOut]; + } + } + foreach (['c99shell', 'r57shell', 'FilesMan', 'WSOsetcookie', 'eval(base64_decode(', 'eval(gzinflate(', 'eval(str_rot13('] as $needle) { + if ($name === null && str_contains($cmd, $needle)) { + $name = $needle; + } + } + + if (in_array($name, $timedOut, true)) { + throw new SshCommandTimedOut('SSH command timed out after 60s.'); + } + + return in_array($name, $cutOff, true) + ? $out + : rtrim($out, "\n")."\n".CompanionMalwareScanner::SSH_DONE_SENTINEL."\n"; + }); + }); +} + +describe('normalizeFindingPath', function () { + it('normalizes a path to be relative to the WordPress root', function (string $raw, string $expected) { + expect(CompanionMalwareScanner::normalizeFindingPath($raw))->toBe($expected); + })->with([ + 'already relative' => ['wp-content/uploads/a.php', 'wp-content/uploads/a.php'], + 'backslash separators' => ['wp-content\\plugins\\demo\\src\\File.php', 'wp-content/plugins/demo/src/File.php'], + 'managed-host docroot' => ['/srv/htdocs/wp-content/uploads/a.php', 'wp-content/uploads/a.php'], + 'root-level file under a managed-host docroot' => ['/srv/htdocs/dropper.php', 'dropper.php'], + 'wp-config under a managed-host core root' => ['/wordpress/core/wp-config.php', 'wp-config.php'], + 'repeated slashes' => ['//srv//htdocs//wp-content/uploads/a.php', 'wp-content/uploads/a.php'], + 'leading ./' => ['./wp-content/uploads/a.php', 'wp-content/uploads/a.php'], + 'outside every known root stays absolute' => ['/tmp/elsewhere/a.php', '/tmp/elsewhere/a.php'], + 'a sibling of a known root is not stripped' => ['/srv/htdocs-old/a.php', '/srv/htdocs-old/a.php'], + ]); + + it('strips the site\'s own wp_path, with or without a trailing slash', function (string $wpPath) { + $site = Site::factory()->make(['wp_path' => $wpPath]); + + expect(CompanionMalwareScanner::normalizeFindingPath('/sites/norm.example.com/files/wp-content/uploads/b.php', $site)) + ->toBe('wp-content/uploads/b.php') + ->and(CompanionMalwareScanner::normalizeFindingPath('/sites/norm.example.com/files/top.php', $site)) + ->toBe('top.php'); + })->with(['/sites/norm.example.com/files', '/sites/norm.example.com/files/']); + + it('strips an ABSPATH reported in the Companion snapshot', function () { + $site = Site::factory()->make([ + 'wp_path' => null, + 'companion_snapshot' => ['environment' => ['abspath' => '/var/www/html/']], + ]); + + expect(CompanionMalwareScanner::normalizeFindingPath('/var/www/html/wp-content/uploads/c.php', $site)) + ->toBe('wp-content/uploads/c.php'); + }); + + it('prefers the longest matching root when one docroot sits inside another', function () { + $site = Site::factory()->make(['wp_path' => '/srv/htdocs/blog']); + + expect(CompanionMalwareScanner::normalizeFindingPath('/srv/htdocs/blog/wp-content/uploads/d.php', $site)) + ->toBe('wp-content/uploads/d.php'); + }); + + it('never treats a bare / wp_path as a docroot', function () { + $site = Site::factory()->make(['wp_path' => '/']); + + expect(CompanionMalwareScanner::normalizeFindingPath('/etc/e.php', $site))->toBe('/etc/e.php'); + }); +}); + +describe('own-scanner exclusion', function () { + it('recognises our scanner source reported with backslash separators', function () { + expect(CompanionMalwareScanner::isOwnScannerSource('wp-content/plugins/clockwork-companion/src\\SecurityScans\\MalwareScanner.php')) + ->toBeTrue(); + }); + + it('drops a self-match reported with backslashes and an absolute docroot, so the scan stays clean', function () { + $site = cmsCompanionSite(); + cmsFakeCompanionFindings($site, [ + ['kind' => 'eval_base64', 'path' => '/srv/htdocs/wp-content/plugins/clockwork-companion/src\\SecurityScans\\MalwareScanner.php', 'evidence' => 'eval(base64_decode('], + ['kind' => 'shell_c99', 'path' => 'wp-content\\plugins\\clockwork-renegade\\src\\SecurityScans\\MalwareScanner.php', 'evidence' => 'c99shell'], + ]); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->status)->toBe(SiteSecurityScan::STATUS_CLEAN) + ->and($result->hasMalwareHit)->toBeFalse(); + }); +}); + +describe('suppression', function () { + it('stores finding paths relative to the WordPress root', function () { + $site = cmsCompanionSite(); + cmsFakeCompanionFindings($site, [ + ['kind' => 'php_in_uploads', 'path' => '/srv/htdocs/wp-content/uploads/tool/run.php', 'evidence' => 'PHP file under wp-content/uploads'], + ]); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->details['findings'][0]['path'])->toBe('wp-content/uploads/tool/run.php'); + }); + + it('matches a relative suppression entry against an absolute finding path', function () { + $site = cmsCompanionSite([ + 'companion_finding_state' => ['php_in_uploads_suppressed' => ['wp-content/uploads/tool/run.php']], + ]); + cmsFakeCompanionFindings($site, [ + ['kind' => 'php_in_uploads', 'path' => '/srv/htdocs/wp-content/uploads/tool/run.php', 'evidence' => 'PHP file under wp-content/uploads'], + ]); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->status)->toBe(SiteSecurityScan::STATUS_CLEAN) + ->and($result->details['findings'])->toBe([]); + }); + + it('keeps matching an absolute suppression entry stored before normalization existed', function () { + $site = cmsCompanionSite([ + 'companion_finding_state' => ['php_in_uploads_suppressed' => ['/srv/htdocs/wp-content/uploads/tool/run.php']], + ]); + cmsFakeCompanionFindings($site, [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/tool/run.php', 'evidence' => 'PHP file under wp-content/uploads'], + ]); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->status)->toBe(SiteSecurityScan::STATUS_CLEAN); + }); + + it('suppresses a vendor preg_replace /e finding by kind and path', function () { + $site = cmsCompanionSite([ + 'companion_finding_state' => ['preg_replace_e_modifier_suppressed' => ['wp-content/plugins/legacy-vendor/includes/helpers.php']], + ]); + cmsFakeCompanionFindings($site, [ + ['kind' => 'preg_replace_e_modifier', 'path' => 'wp-content/plugins/legacy-vendor/includes/helpers.php', 'evidence' => 'preg_replace with /e'], + ]); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->status)->toBe(SiteSecurityScan::STATUS_CLEAN); + }); + + it('scopes a suppression to its kind, so another kind on the same path is still reported', function () { + $path = 'wp-content/plugins/legacy-vendor/includes/helpers.php'; + $site = cmsCompanionSite([ + 'companion_finding_state' => [ + 'preg_replace_e_modifier_suppressed' => [$path], + 'php_in_uploads_suppressed' => ['wp-content/uploads/other.php'], + ], + ]); + cmsFakeCompanionFindings($site, [ + ['kind' => 'preg_replace_e_modifier', 'path' => $path, 'evidence' => 'preg_replace with /e'], + ['kind' => 'eval_base64', 'path' => $path, 'evidence' => 'eval(base64_decode('], + ['kind' => 'preg_replace_e_modifier', 'path' => 'wp-content/uploads/other.php', 'evidence' => 'preg_replace with /e'], + ]); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect(collect($result->details['findings'])->map(fn ($f) => $f['kind'].' '.$f['path'])->all())->toBe([ + 'eval_base64 '.$path, + 'preg_replace_e_modifier wp-content/uploads/other.php', + ])->and($result->status)->toBe(SiteSecurityScan::STATUS_ISSUES_FOUND); + }); +}); + +describe('has_malware_hit', function () { + it('is set when a finding kind is a positive malware signature', function () { + $site = cmsCompanionSite(); + cmsFakeCompanionFindings($site, [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/a.php', 'evidence' => 'PHP file under wp-content/uploads'], + ['kind' => 'eval_base64', 'path' => 'wp-content/uploads/a.php', 'evidence' => 'eval(base64_decode('], + ]); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->status)->toBe(SiteSecurityScan::STATUS_ISSUES_FOUND) + ->and($result->hasMalwareHit)->toBeTrue(); + }); + + it('stays false when every finding is review-tier', function () { + $site = cmsCompanionSite(); + cmsFakeCompanionFindings($site, [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/a.php', 'evidence' => 'PHP file under wp-content/uploads'], + ['kind' => 'preg_replace_e_modifier', 'path' => 'wp-content/plugins/legacy-vendor/x.php', 'evidence' => 'preg_replace with /e'], + ]); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->status)->toBe(SiteSecurityScan::STATUS_WARNING) + ->and($result->hasMalwareHit)->toBeFalse(); + }); + + it('stays false on a clean scan', function () { + $site = cmsCompanionSite(); + cmsFakeCompanionFindings($site, []); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->status)->toBe(SiteSecurityScan::STATUS_CLEAN) + ->and($result->hasMalwareHit)->toBeFalse(); + }); +}); + +describe('SSH transport', function () { + it('excludes both of our plugin directories from the signature grep', function () { + $site = cmsSshSite(); + $commands = []; + cmsFakeSsh('', [], $commands); + + app(CompanionMalwareScanner::class)->scanSite($site); + + $greps = array_values(array_filter($commands, fn ($c) => str_starts_with($c, 'grep '))); + expect($greps)->not->toBeEmpty(); + foreach ($greps as $grep) { + expect($grep)->toContain('--exclude-dir=clockwork-companion') + ->toContain('--exclude-dir=clockwork-renegade'); + } + }); + + it('reports a complete scan as not aborted and stores relative paths', function () { + $site = cmsSshSite(); + cmsFakeSsh("/sites/ssh.example.com/files/wp-content/uploads/a.php\n"); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->details['transport'])->toBe('ssh') + ->and($result->details['scan_aborted'])->toBeFalse() + ->and($result->details['abort_reason'])->toBeNull() + // Stored empty, so reports can tell a complete SSH scan from one + // recorded before truncation was detected. + ->and($result->details['truncated_checks'])->toBe([]) + ->and($result->details['incomplete_checks'])->toBe([]) + ->and($result->details['ssh_checks_verified'])->toBeTrue() + ->and($result->details['findings'][0]['path'])->toBe('wp-content/uploads/a.php'); + }); + + it('marks the scan incomplete when a check times out, instead of reading its silence as clean', function () { + $site = cmsSshSite(); + cmsFakeSsh('', [], timedOut: ['c99shell']); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->status)->toBe(SiteSecurityScan::STATUS_CLEAN) + ->and($result->details)->toMatchArray([ + 'scan_aborted' => true, + 'abort_reason' => CompanionMalwareScanner::SSH_INCOMPLETE_REASON, + 'incomplete_checks' => ['shell_c99'], + 'ssh_checks_verified' => false, + ]); + }); + + it('marks the scan incomplete when a check\'s output lost its completion sentinel', function () { + $site = cmsSshSite(); + cmsFakeSsh("/sites/ssh.example.com/files/wp-content/uploads/a.php\n", [], cutOff: ['find']); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + // The files it did list are still findings. + expect($result->status)->toBe(SiteSecurityScan::STATUS_WARNING) + ->and($result->details['findings'])->toHaveCount(1) + ->and($result->details)->toMatchArray([ + 'scan_aborted' => true, + 'abort_reason' => CompanionMalwareScanner::SSH_INCOMPLETE_REASON, + 'incomplete_checks' => ['php_in_uploads'], + 'ssh_checks_verified' => false, + ]); + }); + + it('fails the scan when the docroot is missing or holds no wp-config.php, rather than reporting it clean', function () { + $site = cmsSshSite(); + $commands = []; + cmsFakeSsh('', [], $commands, docrootOk: false); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->status)->toBe(SiteSecurityScan::STATUS_FAILED) + ->and($result->error)->toContain('docroot /sites/ssh.example.com/files not found') + ->and($commands)->toHaveCount(1); + }); + + it('hands upload paths to sh as arguments, so a quote in a file name cannot stop the check', function () { + $cmd = CompanionMalwareScanner::sshUploadsCommand('/sites/ssh.example.com/files'); + + expect($cmd)->not->toContain('xargs') + ->toContain("-exec sh -c 'for f do ") + ->toContain('_ {} +') + ->toEndWith('; echo '.CompanionMalwareScanner::SSH_DONE_SENTINEL); + }); + + // Runs the real command in a local POSIX shell over a temp directory: a + // mocked transport can't show that a quote in a file name no longer stops + // the check, which is the bug this guards. Nothing leaves the machine. + it('lists every PHP file in uploads when one name holds a quote, run by a real shell', function () { + $root = sys_get_temp_dir().'/cw-ssh-uploads-'.uniqid(); + mkdir($root.'/wp-content/uploads/sub', 0777, true); + $padding = str_repeat('x', 40); + foreach (['a.php', "it's.php", 'sub/d "x".php', 'c.php'] as $name) { + file_put_contents("{$root}/wp-content/uploads/{$name}", " basename($l), array_slice($lines, 0, -1)); + sort($found); + expect(end($lines))->toBe(CompanionMalwareScanner::SSH_DONE_SENTINEL) + ->and($found)->toBe(['a.php', 'c.php', 'd "x".php', "it's.php"]); + })->skip(! is_executable('/bin/sh'), 'needs a local POSIX shell'); + + it('records the empty truncation list on a clean SSH scan, and never on a Companion scan', function () { + $ssh = cmsSshSite(); + cmsFakeSsh(''); + $companion = cmsCompanionSite(); + cmsFakeCompanionFindings($companion, []); + + $sshResult = app(CompanionMalwareScanner::class)->scanSite($ssh); + $companionResult = app(CompanionMalwareScanner::class)->scanSite($companion); + + expect($sshResult->status)->toBe(SiteSecurityScan::STATUS_CLEAN) + ->and($sshResult->details)->toMatchArray(['scan_aborted' => false, 'truncated_checks' => [], 'ssh_checks_verified' => true]) + ->and($companionResult->details)->not->toHaveKey('truncated_checks') + ->and($companionResult->details)->not->toHaveKey('ssh_checks_verified'); + }); + + it('does not call a check truncated when it returns exactly the limit', function () { + $site = cmsSshSite(); + $lines = collect(range(1, CompanionMalwareScanner::SSH_UPLOADS_LIMIT)) + ->map(fn ($i) => "/sites/ssh.example.com/files/wp-content/uploads/f{$i}.php") + ->implode("\n"); + cmsFakeSsh($lines); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->details['scan_aborted'])->toBeFalse() + ->and($result->details['findings'])->toHaveCount(CompanionMalwareScanner::SSH_UPLOADS_LIMIT); + }); + + it('marks the scan aborted when the uploads check hits its limit', function () { + $site = cmsSshSite(); + $commands = []; + $lines = collect(range(1, CompanionMalwareScanner::SSH_UPLOADS_LIMIT + 1)) + ->map(fn ($i) => "/sites/ssh.example.com/files/wp-content/uploads/f{$i}.php") + ->implode("\n"); + cmsFakeSsh($lines, [], $commands); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->details['scan_aborted'])->toBeTrue() + ->and($result->details['abort_reason'])->toBe(CompanionMalwareScanner::SSH_ABORT_REASON) + ->and($result->details['truncated_checks'])->toBe(['php_in_uploads']) + ->and($result->details['findings'])->toHaveCount(CompanionMalwareScanner::SSH_UPLOADS_LIMIT); + + // The command asks for one more line than it keeps, which is how + // truncation is told apart from a result that is exactly at the limit. + $find = collect($commands)->first(fn ($c) => str_starts_with($c, 'find ')); + expect($find)->toContain('head -n '.(CompanionMalwareScanner::SSH_UPLOADS_LIMIT + 1)); + }); + + it('marks the scan aborted when a signature grep hits its limit', function () { + $site = cmsSshSite(); + $lines = collect(range(1, CompanionMalwareScanner::SSH_SIGNATURE_LIMIT + 1)) + ->map(fn ($i) => "/sites/ssh.example.com/files/wp-content/themes/t/f{$i}.php") + ->implode("\n"); + cmsFakeSsh('', ['c99shell' => $lines]); + + $result = app(CompanionMalwareScanner::class)->scanSite($site); + + expect($result->details['scan_aborted'])->toBeTrue() + ->and($result->details['abort_reason'])->toBe(CompanionMalwareScanner::SSH_ABORT_REASON) + ->and($result->details['truncated_checks'])->toBe(['shell_c99']) + ->and($result->details['findings'])->toHaveCount(CompanionMalwareScanner::SSH_SIGNATURE_LIMIT) + ->and($result->status)->toBe(SiteSecurityScan::STATUS_ISSUES_FOUND) + ->and($result->hasMalwareHit)->toBeTrue(); + }); +}); diff --git a/tests/Feature/Security/PluginVulnerabilityMatcherTest.php b/tests/Feature/Security/PluginVulnerabilityMatcherTest.php new file mode 100644 index 00000000..db2ff80b --- /dev/null +++ b/tests/Feature/Security/PluginVulnerabilityMatcherTest.php @@ -0,0 +1,78 @@ +create([ + 'companion_snapshot' => ['plugins' => ['plugins' => [ + ['slug' => $slug, 'name' => 'Example SEO Pro', 'version' => $version, 'active' => true], + ]]], + ]); +} + +function matcherAdvisory(string $id, ?string $from, bool $fromInclusive, ?string $to, bool $toInclusive, ?string $patchedIn): PluginVulnerability +{ + return PluginVulnerability::factory()->create([ + 'wordfence_id' => $id, + 'slug' => 'example-seo-pro', + 'from_version' => $from, + 'from_inclusive' => $fromInclusive, + 'to_version' => $to, + 'to_inclusive' => $toInclusive, + 'patched_in' => $patchedIn, + ]); +} + +describe('PluginVulnerabilityMatcher', function () { + beforeEach(function () { + // "== 3.0.123" stored as an exact range, and "< 3.0.97". + matcherAdvisory('exact', '3.0.123', true, '3.0.123', true, null); + matcherAdvisory('below', null, true, '3.0.97', false, '3.0.97'); + }); + + it('does not report an exact-version advisory against an older release', function () { + $site = matcherSiteWith('example-seo-pro/example-seo-pro.php', '3.0.121'); + + expect((new PluginVulnerabilityMatcher)->forSite($site))->toBe([]); + }); + + it('reports only the range advisory for a release below its fix', function () { + $site = matcherSiteWith('example-seo-pro/example-seo-pro.php', '3.0.96'); + + $hits = (new PluginVulnerabilityMatcher)->forSite($site); + + expect($hits)->toHaveCount(1) + ->and($hits[0]['vulnerability']->wordfence_id)->toBe('below') + ->and($hits[0]['plugin_slug'])->toBe('example-seo-pro') + ->and($hits[0]['patch_available'])->toBeTrue(); + }); + + it('reports the exact-version advisory on that exact release', function () { + $site = matcherSiteWith('example-seo-pro/example-seo-pro.php', '3.0.123'); + + $hits = (new PluginVulnerabilityMatcher)->forSite($site); + + expect($hits)->toHaveCount(1) + ->and($hits[0]['vulnerability']->wordfence_id)->toBe('exact') + ->and($hits[0]['patch_available'])->toBeFalse(); + }); + + it('honours exclusive lower bounds', function () { + matcherAdvisory('after', '4.0.0', false, null, true, null); + + expect((new PluginVulnerabilityMatcher)->forSite(matcherSiteWith('example-seo-pro/example-seo-pro.php', '4.0.0')))->toBe([]); + + $hits = (new PluginVulnerabilityMatcher)->forSite(matcherSiteWith('example-seo-pro/example-seo-pro.php', '4.0.1')); + expect(collect($hits)->pluck('vulnerability.wordfence_id')->all())->toBe(['after']); + }); +}); diff --git a/tests/Feature/Security/SecurityScanAndChecksumTest.php b/tests/Feature/Security/SecurityScanAndChecksumTest.php index 62a55d3c..98ea71c9 100644 --- a/tests/Feature/Security/SecurityScanAndChecksumTest.php +++ b/tests/Feature/Security/SecurityScanAndChecksumTest.php @@ -442,3 +442,209 @@ function spinupSiteForChecksumVerify(): Site expect($row->summary)->toBe('Core file integrity issue — 2 unexpected.'); }); }); + +/** + * A Companion malware result carrying the given findings. Review-tier only + * (php_in_uploads etc.), so has_malware_hit stays false and only the + * new-PHP-in-uploads notice is in play. + * + * @param list $findings + */ +function companionMalwareResult(Site $site, array $findings, string $status = SiteSecurityScan::STATUS_WARNING): SecurityScanResult +{ + return new SecurityScanResult( + site: $site, + scanType: SiteSecurityScan::TYPE_COMPANION_MALWARE, + status: $status, + modifiedFilesCount: count($findings), + summary: count($findings).' finding(s) via companion', + details: [ + 'transport' => 'companion', + 'findings' => array_map(fn ($f) => $f + ['evidence' => 'PHP file under wp-content/uploads'], $findings), + 'scanned_files_count' => 100, + 'scan_aborted' => false, + 'abort_reason' => null, + ], + ); +} + +function priorCompanionScan(Site $site, array $attributes): SiteSecurityScan +{ + return SiteSecurityScan::factory()->create(array_merge([ + 'site_id' => $site->id, + 'scan_type' => SiteSecurityScan::TYPE_COMPANION_MALWARE, + 'status' => SiteSecurityScan::STATUS_WARNING, + 'has_malware_hit' => false, + ], $attributes)); +} + +describe('SecurityScanRecorder — failed scans and the malware alert', function () { + it('does not re-alert when a failed scan sits between two malware hits', function () { + $site = Site::factory()->create(); + SiteSecurityScan::factory()->malwareFound()->create([ + 'site_id' => $site->id, + 'scan_type' => SiteSecurityScan::TYPE_SITECHECK, + 'scanned_at' => now()->subHours(2), + ]); + SiteSecurityScan::factory()->create([ + 'site_id' => $site->id, + 'scan_type' => SiteSecurityScan::TYPE_SITECHECK, + 'status' => SiteSecurityScan::STATUS_FAILED, + 'has_malware_hit' => false, + 'scanned_at' => now()->subHour(), + ]); + + $this->mock(ChatNotifier::class)->shouldNotReceive('malwareFindingDetected'); + + $row = app(SecurityScanRecorder::class)->record(new SecurityScanResult( + site: $site, + scanType: SiteSecurityScan::TYPE_SITECHECK, + status: SiteSecurityScan::STATUS_ISSUES_FOUND, + hasMalwareHit: true, + )); + + expect($row->has_malware_hit)->toBeTrue(); + }); + + it('still alerts on clean → hit when a failed scan sits between them', function () { + $site = Site::factory()->create(); + SiteSecurityScan::factory()->create([ + 'site_id' => $site->id, + 'scan_type' => SiteSecurityScan::TYPE_SITECHECK, + 'status' => SiteSecurityScan::STATUS_CLEAN, + 'scanned_at' => now()->subHours(2), + ]); + SiteSecurityScan::factory()->create([ + 'site_id' => $site->id, + 'scan_type' => SiteSecurityScan::TYPE_SITECHECK, + 'status' => SiteSecurityScan::STATUS_FAILED, + 'scanned_at' => now()->subHour(), + ]); + + $this->mock(ChatNotifier::class)->shouldReceive('malwareFindingDetected')->once()->andReturn(true); + + app(SecurityScanRecorder::class)->record(new SecurityScanResult( + site: $site, + scanType: SiteSecurityScan::TYPE_SITECHECK, + status: SiteSecurityScan::STATUS_ISSUES_FOUND, + hasMalwareHit: true, + )); + }); +}); + +describe('SecurityScanRecorder — new PHP file in uploads notice', function () { + it('notifies only the paths the previous scan did not report, comparing normalized paths', function () { + $site = Site::factory()->create(); + // An older row that stored the absolute path a managed host reported. + priorCompanionScan($site, [ + 'scanned_at' => now()->subDay(), + 'details' => ['findings' => [ + ['kind' => 'php_in_uploads', 'path' => '/srv/htdocs/wp-content/uploads/known.php', 'evidence' => 'x'], + ]], + ]); + + $this->mock(ChatNotifier::class, function ($mock) use ($site) { + $mock->shouldNotReceive('malwareFindingDetected'); + $mock->shouldReceive('phpInUploadsDetected') + ->once() + ->withArgs(fn (Site $s, SiteSecurityScan $scan, array $paths) => $s->is($site) + && $scan->scan_type === SiteSecurityScan::TYPE_COMPANION_MALWARE + && $paths === ['wp-content/uploads/2026/01/new.php']) + ->andReturn(true); + }); + + app(SecurityScanRecorder::class)->record(companionMalwareResult($site, [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/known.php'], + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/2026/01/new.php'], + ])); + }); + + it('stays quiet when every path was already on the previous scan', function () { + $site = Site::factory()->create(); + priorCompanionScan($site, [ + 'scanned_at' => now()->subDay(), + 'details' => ['findings' => [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/known.php', 'evidence' => 'x'], + ]], + ]); + + $this->mock(ChatNotifier::class)->shouldNotReceive('phpInUploadsDetected'); + + app(SecurityScanRecorder::class)->record(companionMalwareResult($site, [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/known.php'], + ])); + }); + + it('diffs against the latest non-failed scan, so a failed scan in between does not re-alert', function () { + $site = Site::factory()->create(); + priorCompanionScan($site, [ + 'scanned_at' => now()->subDays(2), + 'details' => ['findings' => [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/known.php', 'evidence' => 'x'], + ]], + ]); + priorCompanionScan($site, [ + 'status' => SiteSecurityScan::STATUS_FAILED, + 'scanned_at' => now()->subDay(), + 'details' => ['error' => 'transport unavailable'], + ]); + + $this->mock(ChatNotifier::class)->shouldNotReceive('phpInUploadsDetected'); + + app(SecurityScanRecorder::class)->record(companionMalwareResult($site, [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/known.php'], + ])); + }); + + it('notifies every path on a site\'s first scan, since nobody has reviewed them yet', function () { + $site = Site::factory()->create(); + + $this->mock(ChatNotifier::class)->shouldReceive('phpInUploadsDetected') + ->once() + ->withArgs(fn (Site $s, SiteSecurityScan $scan, array $paths) => $paths === ['wp-content/uploads/a.php', 'wp-content/uploads/b.php']) + ->andReturn(true); + + app(SecurityScanRecorder::class)->record(companionMalwareResult($site, [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/a.php'], + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/b.php'], + ])); + }); + + it('ignores new paths of other finding kinds', function () { + $site = Site::factory()->create(); + + $this->mock(ChatNotifier::class)->shouldNotReceive('phpInUploadsDetected'); + + app(SecurityScanRecorder::class)->record(companionMalwareResult($site, [ + ['kind' => 'preg_replace_e_modifier', 'path' => 'wp-content/plugins/legacy-vendor/x.php'], + ['kind' => 'wp_config_recently_modified', 'path' => 'wp-config.php'], + ])); + }); + + it('only applies to Companion malware scans', function () { + $site = Site::factory()->create(); + + $this->mock(ChatNotifier::class)->shouldNotReceive('phpInUploadsDetected'); + + app(SecurityScanRecorder::class)->record(new SecurityScanResult( + site: $site, + scanType: SiteSecurityScan::TYPE_SITECHECK, + status: SiteSecurityScan::STATUS_WARNING, + details: ['findings' => [['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/a.php']]], + )); + }); + + it('swallows a notifier exception so the scan row is still persisted', function () { + $site = Site::factory()->create(); + $this->mock(ChatNotifier::class) + ->shouldReceive('phpInUploadsDetected') + ->once() + ->andThrow(new RuntimeException('webhook down')); + + $row = app(SecurityScanRecorder::class)->record(companionMalwareResult($site, [ + ['kind' => 'php_in_uploads', 'path' => 'wp-content/uploads/a.php'], + ])); + + $this->assertDatabaseHas('site_security_scans', ['id' => $row->id, 'status' => SiteSecurityScan::STATUS_WARNING]); + }); +}); diff --git a/tests/Feature/Services/ActionLoggerCompanionMirrorTest.php b/tests/Feature/Services/ActionLoggerCompanionMirrorTest.php new file mode 100644 index 00000000..0e7fbd27 --- /dev/null +++ b/tests/Feature/Services/ActionLoggerCompanionMirrorTest.php @@ -0,0 +1,63 @@ +create([ + 'domain' => 'mirror.example.com', + 'companion_installed' => true, + 'companion_secret' => 'test-secret-'.uniqid(), + ]); + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/action-log/append*" => Http::response(['ok' => true], 200, ['Content-Type' => 'application/json']), + ]); + + app(ActionLogger::class)->record( + actionType: ActionLog::TYPE_PLUGIN_UPDATE, + summary: 'Plugin update failed: https://vendor.example.com/dl.zip?license_key=FAKE-KEY-2222', + site: $site, + target: 'example-pro/example-pro.php', + details: ['messages' => ['Downloading update from https://vendor.example.com/dl.zip?token=FAKE-TOKEN-3333…'], 'attempt' => 2], + ok: false, + error: 'Download failed: https://vendor.example.com/dl.zip?token=FAKE-TOKEN-3333', + actor: 'scheduled', + ); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'action-log/append') + && ! str_contains($request->body(), 'FAKE-KEY-2222') + && ! str_contains($request->body(), 'FAKE-TOKEN-3333') + && $request['details']['attempt'] === 2 + && $request['target'] === 'example-pro/example-pro.php'); +}); + +it('keeps local-only rows (observed or held-back updates) out of the Companion mirror', function (string $type) { + $site = Site::factory()->create([ + 'domain' => 'local-only.example.com', + 'companion_installed' => true, + 'companion_secret' => 'test-secret-'.uniqid(), + ]); + Http::fake(); + + $row = app(ActionLogger::class)->record( + actionType: $type, + summary: 'Example row', + site: $site, + target: 'example-plugin/example-plugin.php', + actor: 'auto', + ); + + Http::assertNothingSent(); + expect($row)->not->toBeNull() + ->and($row->companion_pushed_at)->toBeNull() + ->and(ActionLog::query()->where('action_type', $type)->count())->toBe(1); +})->with(ActionLog::LOCAL_ONLY_TYPES); diff --git a/tests/Feature/SiteOverviewDashboardTest.php b/tests/Feature/SiteOverviewDashboardTest.php index 96509e11..eb2a8b91 100644 --- a/tests/Feature/SiteOverviewDashboardTest.php +++ b/tests/Feature/SiteOverviewDashboardTest.php @@ -274,6 +274,64 @@ ->assertSee('unhosted-client.example.com'); }); + it('shows the last failed backup attempt on custom unhosted sites', function () { + $this->mockIssueCounterZero(); + $user = User::factory()->create(); + + $site = Site::factory()->custom()->create([ + 'domain' => 'failing-relay.example.com', + 'backup_relay_enabled' => true, + 'backup_relay_frequency' => 'daily', + 'backup_relay_last_archived_at' => now()->subDays(4), + 'backup_relay_last_attempt_at' => now()->subHours(2), + 'backup_relay_last_error' => 'BackupRelay: Failed direct backup for failing-relay.example.com: HTTP 502 error code: 502', + 'backup_relay_failing_since' => '2026-10-27 04:30:00', + ]); + + $this->actingAs($user) + ->get(route('sites.show', $site)) + ->assertOk() + ->assertSee('Failing since 2026-10-27, 04:30') + ->assertSee('BackupRelay: Failed direct backup for failing-relay.example.com: HTTP 502 error code: 502'); + }); + + it('explains why relay cannot be turned on when the site plugin lacks backup-create', function () { + $this->mockIssueCounterZero(); + $user = User::factory()->create(); + + $site = Site::factory()->custom()->withCompanionInstalled()->create([ + 'domain' => 'old-plugin-widget.example.com', + 'companion_variant' => 'companion', + 'companion_version' => '1.30.3', + 'companion_capabilities' => ['snapshot'], + 'backup_relay_enabled' => false, + ]); + + $this->actingAs($user) + ->get(route('sites.show', $site)) + ->assertOk() + ->assertSee('Companion 1.30.3 lacks the backup-create capability; update Companion or switch to Renegade.'); + }); + + it('includes the last relay attempt in the backups-history schedule', function () { + $user = User::factory()->create(); + + $site = Site::factory()->custom()->create([ + 'domain' => 'history-failing.example.com', + 'backup_relay_enabled' => true, + 'backup_relay_last_attempt_at' => '2026-10-28 04:31:00', + 'backup_relay_last_error' => 'HTTP 500 Database dump failed', + 'backup_relay_failing_since' => '2026-10-27 04:30:00', + ]); + + $this->actingAs($user) + ->getJson(route('sites.backups.history', $site)) + ->assertOk() + ->assertJsonPath('schedule.last_error', 'HTTP 500 Database dump failed') + ->assertJsonPath('schedule.failing_since', $site->fresh()->backup_relay_failing_since->toIso8601String()) + ->assertJsonPath('schedule.last_attempt_at', $site->fresh()->backup_relay_last_attempt_at->toIso8601String()); + }); + it('renders companion only badge and disables server telemetry for custom standalone sites', function () { $this->mockIssueCounterZero(); $user = User::factory()->create(); diff --git a/tests/Feature/Sites/AddStandaloneSiteTest.php b/tests/Feature/Sites/AddStandaloneSiteTest.php index 6cb8b512..59864a40 100644 --- a/tests/Feature/Sites/AddStandaloneSiteTest.php +++ b/tests/Feature/Sites/AddStandaloneSiteTest.php @@ -4,6 +4,8 @@ use App\Models\Site; use App\Models\User; +use App\Services\Process\BackgroundArtisan; +use App\Services\Process\BackgroundArtisanResult; use App\Support\SsrfGuard; use Illuminate\Support\Facades\Http; @@ -124,7 +126,7 @@ 'https://wpengine-client.com/wp-json/clockwork/v1/health*' => Http::response([ 'ok' => true, 'version' => '1.35.0', - 'capabilities' => ['snapshot', 'updates', 'malware-scan', 'admins', 'sso'], + 'capabilities' => ['snapshot', 'updates', 'malware-scan', 'admins', 'sso', 'backup-create'], 'is_multisite' => false, ], 200), 'https://wpengine-client.com/wp-json/clockwork/v1/snapshot*' => Http::response([ @@ -152,12 +154,48 @@ ->and($site->backup_relay_enabled)->toBeTrue() ->and($site->backup_relay_frequency)->toBe('daily') ->and($site->cert_source)->toBe(Site::CERT_SOURCE_LIVE_PROBE) - ->and($site->auto_updates_paused)->toBeFalse(); + ->and($site->auto_updates_paused)->toBeFalse() + // The handshake only answers from the WordPress plugin; without + // the flag the site sat out every WordPress-only job. + ->and($site->is_wordpress)->toBeTrue(); $response->assertRedirect(route('sites.show', $site)); $response->assertSessionHas('flash'); }); + it('starts one background performance scan for the new site', function () { + SsrfGuard::fake(['enrolled.example.com' => ['93.184.216.34']]); + Http::fake([ + 'https://enrolled.example.com/wp-json/clockwork/v1/health*' => Http::response([ + 'ok' => true, + 'version' => '1.35.0', + 'capabilities' => ['updates'], + 'is_multisite' => false, + ], 200), + 'https://enrolled.example.com*' => Http::response('Test

    Hello World

    This is a real site with plenty of content to pass body check.

    ', 200), + ]); + + $launched = []; + $this->mock(BackgroundArtisan::class, function ($mock) use (&$launched) { + $mock->shouldReceive('start')->andReturnUsing(function (string $lockKey, array $commands) use (&$launched) { + $launched[] = [$lockKey, $commands]; + + return BackgroundArtisanResult::ok(); + }); + }); + + $this->post(route('sites.store'), [ + 'domain' => 'enrolled.example.com', + 'companion_secret' => bin2hex(random_bytes(32)), + ])->assertRedirect(); + + $site = Site::where('domain', 'enrolled.example.com')->firstOrFail(); + expect($launched)->toBe([[ + 'performance-baseline:'.$site->id, + ['clockwork:run-performance-scans --site='.$site->id.' --strategy=mobile'], + ]]); + }); + it('successfully connects and enrolls a standalone site via ManageWP-style Connection Key', function () { $secret = bin2hex(random_bytes(32)); $connectionPayload = json_encode([ @@ -172,7 +210,7 @@ 'https://client-wpengine.com/wp-json/clockwork/v1/health*' => Http::response([ 'ok' => true, 'version' => '1.35.0', - 'capabilities' => ['snapshot', 'updates', 'malware-scan'], + 'capabilities' => ['snapshot', 'updates', 'malware-scan', 'backup-create'], 'is_multisite' => false, ], 200), 'https://client-wpengine.com/wp-json/clockwork/v1/snapshot*' => Http::response([ @@ -198,4 +236,38 @@ $response->assertRedirect(route('sites.show', $site)); }); + + it('enrolls a site whose plugin lacks backup-create but leaves off-site backups off', function () { + $secret = bin2hex(random_bytes(32)); + + SsrfGuard::fake(['old-plugin.example.com' => ['93.184.216.34']]); + Http::fake([ + 'https://old-plugin.example.com/wp-json/clockwork/v1/health*' => Http::response([ + 'ok' => true, + 'version' => '1.30.3', + 'capabilities' => ['snapshot', 'updates', 'malware-scan'], + 'is_multisite' => false, + ], 200), + 'https://old-plugin.example.com/wp-json/clockwork/v1/snapshot*' => Http::response([ + 'ok' => true, + 'plugins' => ['active' => []], + ], 200), + 'https://old-plugin.example.com*' => Http::response('

    Hello World

    This is a real site with plenty of content to pass body check.

    ', 200), + ]); + + $response = $this->post(route('sites.store'), [ + 'domain' => 'old-plugin.example.com', + 'companion_secret' => $secret, + ]); + + $site = Site::where('domain', 'old-plugin.example.com')->first(); + expect($site)->not->toBeNull() + ->and($site->companion_installed)->toBeTrue() + ->and($site->backup_relay_enabled)->toBeFalse() + ->and($site->backup_relay_frequency)->toBe('daily'); + + $response->assertRedirect(route('sites.show', $site)); + $response->assertSessionHas('flash', fn (string $flash) => str_contains($flash, 'successfully connected and enrolled') + && str_contains($flash, 'Off-site backups were not turned on: Companion 1.30.3 lacks the backup-create capability; update Companion or switch to Renegade.')); + }); }); diff --git a/tests/Feature/Updates/ObservedUpdateRecorderTest.php b/tests/Feature/Updates/ObservedUpdateRecorderTest.php new file mode 100644 index 00000000..2b6a75ad --- /dev/null +++ b/tests/Feature/Updates/ObservedUpdateRecorderTest.php @@ -0,0 +1,231 @@ + ['plugins' => array_map(fn ($p) => ['slug' => $p[0], 'name' => $p[1], 'version' => $p[2], 'active' => true], $plugins)], + 'themes' => ['items' => array_map(fn ($t) => ['slug' => $t[0], 'name' => $t[1], 'version' => $t[2], 'active' => false], $themes)], + ]; + if ($core !== null) { + $snapshot['wp_core'] = ['current_version' => $core, 'update_available' => false]; + } + + return $snapshot; +} + +function observedSite(array $snapshot, ?Carbon $at = null): Site +{ + return Site::factory()->withCompanionInstalled()->create([ + 'companion_snapshot' => $snapshot, + 'companion_snapshot_at' => $at ?? now()->subDay(), + ]); +} + +function storeSnapshot(Site $site, array $snapshot, ?Carbon $at = null): void +{ + $site->forceFill([ + 'companion_snapshot' => $snapshot, + 'companion_snapshot_at' => $at ?? now(), + ])->save(); +} + +function observedRows(Site $site): Collection +{ + return ActionLog::query() + ->where('site_id', $site->id) + ->whereIn('action_type', ActionLog::OBSERVED_UPDATE_TYPES) + ->orderBy('id') + ->get(); +} + +function controlJob(Site $site, string $kind, ?string $slug, array $overrides = []): PluginUpdateJob +{ + return PluginUpdateJob::factory()->for($site)->create(array_merge([ + 'target_kind' => $kind, + 'target_slug' => $slug, + 'status' => PluginUpdateJob::STATUS_COMPLETE, + 'before_version' => '1.0.0', + 'target_version' => '1.1.0', + 'after_version' => '1.1.0', + 'batch_id' => 'nightly-'.now()->format('Y-m-d'), + ], $overrides)); +} + +beforeEach(function () { + Http::fake(); +}); + +describe('ObservedUpdateRecorder', function () { + it('logs one row per plugin, theme and core version Control did not change', function () { + $site = observedSite(observedSnapshot( + [['example-forms/example-forms.php', 'Example Forms', '2.9.0'], ['example-seo/example-seo.php', 'Example SEO', '5.1']], + [['example-theme', 'Example Theme', '3.2']], + '6.8.2', + )); + $at = Carbon::parse('2026-10-20 06:00:00'); + + storeSnapshot($site, observedSnapshot( + [['example-forms/example-forms.php', 'Example Forms', '2.9.1'], ['example-seo/example-seo.php', 'Example SEO', '5.1']], + [['example-theme', 'Example Theme', '3.3']], + '6.8.3', + ), $at); + + $rows = observedRows($site); + expect($rows->pluck('action_type')->all())->toBe([ + ActionLog::TYPE_PLUGIN_UPDATE_OBSERVED, + ActionLog::TYPE_THEME_UPDATE_OBSERVED, + ActionLog::TYPE_CORE_UPDATE_OBSERVED, + ]); + + $plugin = $rows[0]; + expect($plugin->target)->toBe('example-forms/example-forms.php') + ->and($plugin->actor)->toBe(ObservedUpdateRecorder::ACTOR) + ->and($plugin->ok)->toBeTrue() + ->and($plugin->ran_at->equalTo($at))->toBeTrue() + ->and($plugin->summary)->toBe('Plugin updated outside Control: Example Forms 2.9.0 → 2.9.1') + ->and($plugin->details)->toMatchArray([ + 'slug' => 'example-forms/example-forms.php', + 'name' => 'Example Forms', + 'from_version' => '2.9.0', + 'to_version' => '2.9.1', + 'observed_at' => $at->toIso8601String(), + ]) + ->and($plugin->details['previous_snapshot_at'])->not->toBeNull(); + + expect($rows[1]->details)->toMatchArray(['slug' => 'example-theme', 'name' => 'Example Theme', 'from_version' => '3.2', 'to_version' => '3.3']) + ->and($rows[2]->target)->toBe(ObservedUpdateRecorder::CORE_SLUG) + ->and($rows[2]->details)->toMatchArray(['slug' => 'wordpress', 'name' => 'WordPress', 'from_version' => '6.8.2', 'to_version' => '6.8.3']); + }); + + it('is informational only: no Companion push, chat alert, failure streak or pause', function () { + $this->mock(ChatNotifier::class, fn ($mock) => $mock->shouldNotReceive( + 'send', 'pluginUpdateFailed', 'pluginUpdateAutoIgnored', + )); + $site = observedSite(observedSnapshot([['example-forms/example-forms.php', 'Example Forms', '2.9.0']])); + $site->forceFill(['auto_updates_paused' => false])->save(); + + storeSnapshot($site, observedSnapshot([['example-forms/example-forms.php', 'Example Forms', '2.9.1']])); + + expect(observedRows($site))->toHaveCount(1) + ->and(observedRows($site)->first()->companion_pushed_at)->toBeNull(); + Http::assertNothingSent(); + expect(PluginUpdateFailureStreak::query()->count())->toBe(0) + ->and(PluginUpdateIgnore::query()->count())->toBe(0) + ->and(PluginUpdateJob::query()->count())->toBe(0) + ->and($site->refresh()->auto_updates_paused)->toBeFalse(); + }); + + it('skips a change a recent Control job made, and still logs other changes', function () { + $site = observedSite(observedSnapshot([ + ['example-forms/example-forms.php', 'Example Forms', '1.0.0'], + ['example-seo/example-seo.php', 'Example SEO', '5.1'], + ], [['example-theme', 'Example Theme', '3.2']], '6.8.2')); + controlJob($site, PluginUpdateJob::KIND_PLUGIN, 'example-forms/example-forms.php'); + controlJob($site, PluginUpdateJob::KIND_THEME, 'example-theme', ['after_version' => '3.3']); + controlJob($site, PluginUpdateJob::KIND_CORE, null, ['after_version' => '6.8.3']); + + storeSnapshot($site, observedSnapshot([ + ['example-forms/example-forms.php', 'Example Forms', '1.1.0'], + ['example-seo/example-seo.php', 'Example SEO', '5.2'], + ], [['example-theme', 'Example Theme', '3.3']], '6.8.3')); + + expect(observedRows($site)->pluck('target')->all())->toBe(['example-seo/example-seo.php']); + }); + + it('counts a failed Control job only when it left the site on the new version', function () { + $site = observedSite(observedSnapshot([ + ['example-forms/example-forms.php', 'Example Forms', '1.0.0'], + ['example-seo/example-seo.php', 'Example SEO', '5.1'], + ])); + // Files swapped, then reactivation failed: Control made this change. + controlJob($site, PluginUpdateJob::KIND_PLUGIN, 'example-forms/example-forms.php', ['status' => PluginUpdateJob::STATUS_FAILED, 'after_version' => '1.1.0']); + // Nothing changed when Control tried: the new version came from elsewhere. + controlJob($site, PluginUpdateJob::KIND_PLUGIN, 'example-seo/example-seo.php', ['status' => PluginUpdateJob::STATUS_FAILED, 'before_version' => '5.1', 'after_version' => '5.1']); + + storeSnapshot($site, observedSnapshot([ + ['example-forms/example-forms.php', 'Example Forms', '1.1.0'], + ['example-seo/example-seo.php', 'Example SEO', '5.2'], + ])); + + expect(observedRows($site)->pluck('target')->all())->toBe(['example-seo/example-seo.php']); + }); + + it('only credits Control jobs from the last 48 hours, or since the previous snapshot when that is older', function () { + $recent = observedSite(observedSnapshot([['example-forms/example-forms.php', 'Example Forms', '1.0.0']]), now()->subDay()); + controlJob($recent, PluginUpdateJob::KIND_PLUGIN, 'example-forms/example-forms.php', ['created_at' => now()->subHours(ObservedUpdateRecorder::CONTROL_WINDOW_HOURS + 2)]); + storeSnapshot($recent, observedSnapshot([['example-forms/example-forms.php', 'Example Forms', '1.1.0']])); + + $stale = observedSite(observedSnapshot([['example-forms/example-forms.php', 'Example Forms', '1.0.0']]), now()->subDays(5)); + controlJob($stale, PluginUpdateJob::KIND_PLUGIN, 'example-forms/example-forms.php', ['created_at' => now()->subDays(3)]); + storeSnapshot($stale, observedSnapshot([['example-forms/example-forms.php', 'Example Forms', '1.1.0']])); + + expect(observedRows($recent))->toHaveCount(1) + ->and(observedRows($stale))->toHaveCount(0); + }); + + it('ignores downgrades, items added or removed, and unchanged versions', function () { + $site = observedSite(observedSnapshot([ + ['rolled-back/rolled-back.php', 'Rolled Back', '2.0.0'], + ['removed/removed.php', 'Removed', '1.0'], + ['same/same.php', 'Same', '3.0'], + ], [], '6.8.3')); + + storeSnapshot($site, observedSnapshot([ + ['rolled-back/rolled-back.php', 'Rolled Back', '1.9.0'], + ['added/added.php', 'Added', '1.0'], + ['same/same.php', 'Same', '3.0'], + ], [], '6.8.3')); + + expect(observedRows($site))->toHaveCount(0); + }); + + it('logs a change once when the snapshot flips back and forth', function () { + $old = observedSnapshot([['example-forms/example-forms.php', 'Example Forms', '1.0.0']]); + $new = observedSnapshot([['example-forms/example-forms.php', 'Example Forms', '1.1.0']]); + $site = observedSite($old); + + storeSnapshot($site, $new, now()->subHours(3)); + storeSnapshot($site, $old, now()->subHours(2)); + storeSnapshot($site, $new, now()->subHour()); + + expect(observedRows($site))->toHaveCount(1); + }); + + it('does nothing for a first snapshot or a save that leaves the snapshot alone', function () { + $site = Site::factory()->withCompanionInstalled()->create(['companion_snapshot' => null]); + storeSnapshot($site, observedSnapshot([['example-forms/example-forms.php', 'Example Forms', '1.0.0']])); + + $site->forceFill(['auto_updates_paused_reason' => 'unrelated edit'])->save(); + + expect(observedRows($site))->toHaveCount(0); + }); + + it('saves a malformed snapshot without logging anything', function () { + $site = observedSite(observedSnapshot([['example-forms/example-forms.php', 'Example Forms', '1.0.0']], [], '6.8.2')); + + storeSnapshot($site, ['plugins' => 'not-a-list', 'themes' => ['items' => [['name' => 'No slug']]], 'wp_core' => ['current_version' => ['bad']]]); + + expect($site->refresh()->companion_snapshot['plugins'])->toBe('not-a-list') + ->and(observedRows($site))->toHaveCount(0); + }); +}); diff --git a/tests/Feature/Updates/ThemeIdentityGuardTest.php b/tests/Feature/Updates/ThemeIdentityGuardTest.php new file mode 100644 index 00000000..c11f4c44 --- /dev/null +++ b/tests/Feature/Updates/ThemeIdentityGuardTest.php @@ -0,0 +1,101 @@ + 'example-theme', + 'name' => 'Example Theme', + 'version' => '1.0.0', + 'new_version' => '1.1.0', + 'update_available' => true, + ], $overrides); +} + +describe('ThemeIdentityGuard', function () { + it('accepts a theme whose name matches the wordpress.org listing for its folder', function () { + Http::fake(['api.wordpress.org/themes/*' => Http::response(['name' => 'Example', 'slug' => 'example-theme', 'version' => '1.1.0'])]); + + // "Example Theme" vs "Example": a trailing "Theme" is not a difference. + expect((new ThemeIdentityGuard)->mismatchReason(guardTheme()))->toBeNull(); + }); + + it('refuses a theme whose name differs from the wordpress.org listing for its folder', function () { + Http::fake(['api.wordpress.org/themes/*' => Http::response(['name' => 'Studio', 'slug' => 'studio', 'version' => '2.3.0'])]); + + $reason = (new ThemeIdentityGuard)->mismatchReason(guardTheme([ + 'slug' => 'studio', 'name' => 'Studio Child Theme', 'version' => '1.0.0', 'new_version' => '2.3.0', + ])); + + expect($reason)->toContain('"Studio Child Theme"') + ->and($reason)->toContain('"Studio"') + ->and($reason)->toContain('latest 2.3.0') + ->and($reason)->toContain('Updating to 2.3.0'); + }); + + it('lets a matching wordpress.org listing outrank the child-theme name check', function () { + Http::fake(['api.wordpress.org/themes/*' => Http::response(['name' => 'Example Child', 'slug' => 'example-child', 'version' => '2.0'])]); + + expect((new ThemeIdentityGuard)->mismatchReason(guardTheme(['slug' => 'example-child', 'name' => 'Example Child'])))->toBeNull(); + }); + + it('compares names without HTML entities, case or punctuation', function () { + expect(ThemeIdentityGuard::sameName('Dine & Drink', 'Dine & Drink'))->toBeTrue() + ->and(ThemeIdentityGuard::sameName('Twenty Twenty-Four', 'twenty twenty four'))->toBeTrue() + ->and(ThemeIdentityGuard::sameName('Example Child Theme', 'Example'))->toBeFalse() + ->and(ThemeIdentityGuard::sameName('', ''))->toBeFalse(); + }); + + describe('when wordpress.org does not list the folder', function () { + beforeEach(function () { + Http::fake(['api.wordpress.org/themes/*' => Http::response(['error' => 'Theme not found'], 404)]); + }); + + it('accepts a premium theme whatever its name', function () { + expect((new ThemeIdentityGuard)->mismatchReason(guardTheme(['slug' => 'example-builder', 'name' => 'Example Page Builder'])))->toBeNull(); + }); + + it('refuses a theme named as a child theme', function () { + expect((new ThemeIdentityGuard)->mismatchReason(guardTheme(['name' => 'Example Child'])))->toContain('child theme'); + }); + + it('refuses a theme whose snapshot names a parent template', function () { + $reason = (new ThemeIdentityGuard)->mismatchReason(guardTheme(['template' => 'example-parent'])); + + expect($reason)->toContain('child theme of "example-parent"'); + }); + + it('does not take a parent template equal to its own folder as a child theme', function () { + expect((new ThemeIdentityGuard)->mismatchReason(guardTheme(['template' => 'example-theme'])))->toBeNull(); + }); + }); + + it('falls back to the snapshot checks when the directory cannot be reached', function () { + Http::fake(['api.wordpress.org/themes/*' => Http::response('', 503)]); + $guard = new ThemeIdentityGuard; + + expect($guard->mismatchReason(guardTheme()))->toBeNull() + ->and($guard->mismatchReason(guardTheme(['slug' => 'other-child', 'name' => 'Other Child'])))->toContain('child theme'); + }); + + it('asks the directory once per folder name', function () { + Http::fake(['api.wordpress.org/themes/*' => Http::response(['name' => 'Example', 'version' => '1.1.0'])]); + $guard = new ThemeIdentityGuard; + + $guard->mismatchReason(guardTheme()); + $guard->mismatchReason(guardTheme(['version' => '0.9'])); + + Http::assertSentCount(1); + Http::assertSent(fn ($request) => str_contains(urldecode($request->url()), 'request[slug]=example-theme') + && str_contains($request->url(), 'action=theme_information')); + }); +}); diff --git a/tests/Feature/Updates/UpdateFailureStreakTest.php b/tests/Feature/Updates/UpdateFailureStreakTest.php index 9a7620f3..9feb7511 100644 --- a/tests/Feature/Updates/UpdateFailureStreakTest.php +++ b/tests/Feature/Updates/UpdateFailureStreakTest.php @@ -11,10 +11,12 @@ use App\Models\Site; use App\Models\User; use App\Services\ActionLog\ActionLogger; +use App\Services\Chat\ChatNotifier; use App\Services\Updates\UpdateFailureStreakRecorder; use App\Support\IssueCounter; use App\Support\Settings; use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; @@ -49,6 +51,43 @@ function fakeNightlyJob(Site $site, string $slug = 'stubborn-plugin', array $ove ], $overrides)); } +/* +| Upgrader output as Companion returns it for a failed upgrade it still +| reports as ok=true (before == after, below the target). The licence key +| is invented; hosts are reserved example domains. +*/ +const STREAK_FAKE_LICENSE = 'FAKE-LICENSE-5555-4444-3333'; + +/** + * @param list $messages + * @return array + */ +function stalledWithUpgraderError(array $messages): array +{ + return [ + 'ok' => true, + 'upgrade_completed' => true, + 'before_version' => '1.0.0', + 'after_version' => '1.0.0', + 'was_active' => true, + 'reactivated' => true, + 'messages' => $messages, + ]; +} + +/** + * @return list + */ +function copyFailedMessages(string $slug): array +{ + return [ + 'Downloading update from https://downloads.example.org/packages/'.$slug.'.1.1.0.zip?license='.STREAK_FAKE_LICENSE.'…', + 'Unpacking the update…', + "Could not copy file. {$slug}/includes/class-main.php", + 'Error: Could not copy file.', + ]; +} + describe('Update failure streaks and auto-ignore', function () { it('increments streak on nightly failure with companion error response, does not auto-ignore before threshold', function () { $site = makeExampleSite(); @@ -153,7 +192,7 @@ function fakeNightlyJob(Site $site, string $slug = 'stubborn-plugin', array $ove ], ]); - PluginUpdateIgnore::create([ + $ignore = PluginUpdateIgnore::create([ 'site_id' => $site->id, 'target_kind' => PluginUpdateJob::KIND_PLUGIN, 'target_slug' => 'stubborn-plugin', @@ -163,12 +202,192 @@ function fakeNightlyJob(Site $site, string $slug = 'stubborn-plugin', array $ove 'note' => 'Auto-ignored', ]); + // The snapshot still offers the version that kept failing. + PluginUpdateFailureStreak::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'stubborn-plugin', + 'consecutive_failures' => 5, + 'last_from_version' => '1.0.0', + 'last_target_version' => '1.1.0', + 'ignored_at' => now(), + 'ignore_id' => $ignore->id, + ]); + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); Queue::assertNothingPushed(); expect(PluginUpdateJob::where('site_id', $site->id)->where('target_slug', 'stubborn-plugin')->count())->toBe(0); }); + it('lifts the auto-pause, clears the streak and re-pushes exceptions when an update reaches its target version', function () { + $site = makeExampleSite(['companion_installed' => true]); + + $ignore = PluginUpdateIgnore::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'stubborn-plugin', + 'source' => PluginUpdateIgnore::SOURCE_AUTO_FAILURE, + 'failure_count' => 5, + 'client_visible' => true, + ]); + // A second paused plugin on the same site stays in the pushed list. + PluginUpdateIgnore::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'other-plugin', + 'source' => PluginUpdateIgnore::SOURCE_AUTO_FAILURE, + 'failure_count' => 5, + 'client_visible' => true, + ]); + $streak = PluginUpdateFailureStreak::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'stubborn-plugin', + 'consecutive_failures' => 5, + 'last_target_version' => '1.1.0', + 'ignored_at' => now(), + 'ignore_id' => $ignore->id, + ]); + + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/update-exceptions*" => Http::response(['ok' => true], 200), + ]); + + // A nightly retry of a newer release that went through. + $job = fakeNightlyJob($site, 'stubborn-plugin', [ + 'target_version' => '1.2.0', + 'status' => PluginUpdateJob::STATUS_COMPLETE, + 'after_version' => '1.2.0', + ]); + app(UpdateFailureStreakRecorder::class)->record($job, [ + 'ok' => true, + 'slug' => 'stubborn-plugin', + 'before_version' => '1.0.0', + 'after_version' => '1.2.0', + ]); + + expect(PluginUpdateIgnore::whereKey($ignore->id)->exists())->toBeFalse() + ->and(PluginUpdateIgnore::where('site_id', $site->id)->where('target_slug', 'other-plugin')->exists())->toBeTrue(); + + $streak->refresh(); + expect($streak->consecutive_failures)->toBe(0) + ->and($streak->ignored_at)->toBeNull() + ->and($streak->ignore_id)->toBeNull(); + + Http::assertSent(function ($request) use ($site) { + return strtok($request->url(), '?') === "https://{$site->domain}/wp-json/clockwork/v1/update-exceptions" + && collect($request['items'] ?? [])->pluck('slug')->all() === ['other-plugin']; + }); + }); + + it('lifts the auto-pause when a nightly retry of a new release succeeds through the update job', function () { + $site = makeExampleSite(); + + $ignore = PluginUpdateIgnore::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'stubborn-plugin', + 'source' => PluginUpdateIgnore::SOURCE_AUTO_FAILURE, + 'failure_count' => 5, + 'client_visible' => true, + ]); + PluginUpdateFailureStreak::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'stubborn-plugin', + 'consecutive_failures' => 5, + 'last_target_version' => '1.1.0', + 'ignored_at' => now(), + 'ignore_id' => $ignore->id, + ]); + + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/plugins/update*" => Http::response([ + 'ok' => true, + 'slug' => 'stubborn-plugin', + 'before_version' => '1.0.0', + 'after_version' => '1.2.0', + 'was_active' => true, + 'reactivated' => true, + 'elapsed_ms' => 400, + ], 200), + ]); + + $job = fakeNightlyJob($site, 'stubborn-plugin', ['target_version' => '1.2.0']); + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + + expect($job->refresh()->status)->toBe(PluginUpdateJob::STATUS_COMPLETE) + ->and(PluginUpdateIgnore::whereKey($ignore->id)->exists())->toBeFalse(); + + $streak = PluginUpdateFailureStreak::where('site_id', $site->id)->where('target_slug', 'stubborn-plugin')->first(); + expect($streak->consecutive_failures)->toBe(0) + ->and($streak->ignored_at)->toBeNull(); + }); + + it('keeps the auto-pause when a completed job cannot show it reached the target version', function () { + $site = makeExampleSite(['companion_installed' => true]); + + $ignore = PluginUpdateIgnore::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'stubborn-plugin', + 'source' => PluginUpdateIgnore::SOURCE_AUTO_FAILURE, + 'failure_count' => 5, + 'client_visible' => true, + ]); + $streak = PluginUpdateFailureStreak::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'stubborn-plugin', + 'consecutive_failures' => 5, + 'last_target_version' => '1.1.0', + 'ignored_at' => now(), + 'ignore_id' => $ignore->id, + ]); + + Http::fake(); + + $job = fakeNightlyJob($site, 'stubborn-plugin', [ + 'status' => PluginUpdateJob::STATUS_COMPLETE, + 'after_version' => null, + ]); + app(UpdateFailureStreakRecorder::class)->record($job, ['ok' => true, 'slug' => 'stubborn-plugin']); + + expect(PluginUpdateIgnore::whereKey($ignore->id)->exists())->toBeTrue(); + $streak->refresh(); + expect($streak->consecutive_failures)->toBe(0) + ->and($streak->ignored_at)->not->toBeNull() + ->and($streak->ignore_id)->toBe($ignore->id); + Http::assertNothingSent(); + }); + + it('never lifts a manual ignore after a successful update', function () { + $site = makeExampleSite(['companion_installed' => true]); + + $ignore = PluginUpdateIgnore::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_PLUGIN, + 'target_slug' => 'held-plugin', + 'source' => PluginUpdateIgnore::SOURCE_MANUAL, + 'client_visible' => false, + 'note' => 'Hold this one', + ]); + + Http::fake(); + + $job = fakeNightlyJob($site, 'held-plugin', [ + 'requested_by_user_id' => User::factory()->create()->id, + 'batch_id' => (string) Str::uuid(), + 'status' => PluginUpdateJob::STATUS_COMPLETE, + 'after_version' => '1.1.0', + ]); + app(UpdateFailureStreakRecorder::class)->record($job, ['ok' => true, 'slug' => 'held-plugin', 'after_version' => '1.1.0']); + + expect(PluginUpdateIgnore::whereKey($ignore->id)->exists())->toBeTrue(); + Http::assertNothingSent(); + }); + it('resets streak to 0 on any successful update after prior failures', function () { $site = makeExampleSite(); @@ -588,6 +807,64 @@ function fakeNightlyJob(Site $site, string $slug = 'stubborn-plugin', array $ove expect($payload['items'])->toBeEmpty(); }); + it('does not convert a theme held as a different theme into a client-visible auto-ignore', function () { + $site = makeExampleSite(); + + PluginUpdateIgnore::create([ + 'site_id' => $site->id, + 'target_kind' => PluginUpdateJob::KIND_THEME, + 'target_slug' => 'held-theme', + 'source' => PluginUpdateIgnore::SOURCE_IDENTITY_MISMATCH, + 'client_visible' => false, + 'note' => 'Held by the nightly loop', + ]); + + $job = fakeNightlyJob($site, 'held-theme', [ + 'target_kind' => PluginUpdateJob::KIND_THEME, + 'status' => PluginUpdateJob::STATUS_FAILED, + 'error' => 'Theme update failed.', + ]); + + $recorder = app(UpdateFailureStreakRecorder::class); + for ($i = 1; $i <= 5; $i++) { + $recorder->record($job, ['ok' => false, 'error' => 'Theme update failed.', 'slug' => 'held-theme']); + } + + $ignore = PluginUpdateIgnore::where('site_id', $site->id)->where('target_slug', 'held-theme')->sole(); + expect($ignore->source)->toBe(PluginUpdateIgnore::SOURCE_IDENTITY_MISMATCH) + ->and($ignore->client_visible)->toBeFalse() + ->and($recorder->buildExceptionsPayload($site)['items'])->toBeEmpty(); + }); + + it('stores the reason code and a redacted last_error on the streak and the auto-ignore', function () { + $site = makeExampleSite(); + + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/plugins/update*" => Http::response(stalledWithUpgraderError([ + 'Downloading update from https://vendor.example.net/download?license_key='.STREAK_FAKE_LICENSE.'…', + 'Download failed. Unauthorized', + 'Error: Download failed.', + ]), 200), + ]); + + for ($i = 1; $i <= 5; $i++) { + $job = fakeNightlyJob($site, 'licensed-plugin'); + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + } + + $streak = PluginUpdateFailureStreak::where('site_id', $site->id)->where('target_slug', 'licensed-plugin')->first(); + expect($streak->reason_code)->toBe('download_unauthorized') + ->and($streak->last_error)->toStartWith('Download failed. Unauthorized — Error: Download failed.') + ->and($streak->last_error)->not->toContain(STREAK_FAKE_LICENSE); + + // A licence problem is specific to the plugin: per-plugin pause as before. + $ignore = PluginUpdateIgnore::where('site_id', $site->id)->where('target_slug', 'licensed-plugin')->first(); + expect($ignore)->not->toBeNull() + ->and($ignore->source)->toBe(PluginUpdateIgnore::SOURCE_AUTO_FAILURE) + ->and($ignore->last_error)->not->toContain(STREAK_FAKE_LICENSE) + ->and($site->refresh()->auto_updates_paused)->toBeFalse(); + }); + it('pushes an updated exceptions list when an auto-ignore is converted to a manual ignore', function () { $site = makeExampleSite(['companion_installed' => true]); $user = User::factory()->create(); @@ -624,3 +901,276 @@ function fakeNightlyJob(Site $site, string $slug = 'stubborn-plugin', array $ove }); }); }); + +describe('File-permission failures pause the site, not each plugin', function () { + it('pauses the whole site once, with one action log and one ops alert, instead of auto-ignoring each plugin', function () { + $site = makeExampleSite(); + + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/plugins/update*" => fn (Request $request) => Http::response( + stalledWithUpgraderError(copyFailedMessages((string) $request['slug'])), + 200, + ), + ]); + + $this->mock(ChatNotifier::class, function ($mock) use ($site) { + $mock->shouldReceive('pluginUpdateFailed')->andReturn(true); + $mock->shouldReceive('pluginUpdateAutoIgnored')->never(); + $mock->shouldReceive('send') + ->once() + ->withArgs(fn (string $text, array $attachments) => str_contains($text, $site->domain) + && str_contains($text, 'filesystem not writable') + && ! str_contains((string) json_encode($attachments), STREAK_FAKE_LICENSE)) + ->andReturn(true); + }); + + // Two plugins on the same site fail the same way every night; both + // cross the threshold on night 5. + for ($night = 1; $night <= 5; $night++) { + foreach (['alpha-plugin', 'beta-plugin'] as $slug) { + $job = fakeNightlyJob($site, $slug); + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + } + } + + $site->refresh(); + expect($site->auto_updates_paused)->toBeTrue() + ->and($site->auto_updates_paused_reason)->toStartWith('Filesystem not writable'); + + // No per-plugin pauses, so nothing client-visible either. + expect(PluginUpdateIgnore::where('site_id', $site->id)->count())->toBe(0) + ->and(ActionLog::where('site_id', $site->id)->where('action_type', ActionLog::TYPE_PLUGIN_UPDATE_AUTO_IGNORED)->count())->toBe(0); + + foreach (['alpha-plugin', 'beta-plugin'] as $slug) { + $streak = PluginUpdateFailureStreak::where('site_id', $site->id)->where('target_slug', $slug)->first(); + expect($streak->consecutive_failures)->toBe(5) + ->and($streak->reason_code)->toBe('fs_permission') + ->and($streak->ignored_at)->toBeNull() + ->and($streak->last_error)->toContain("Could not copy file. {$slug}/includes/class-main.php") + ->and($streak->last_error)->not->toContain(STREAK_FAKE_LICENSE); + } + + $pauseLogs = ActionLog::where('site_id', $site->id)->where('action_type', ActionLog::TYPE_AUTO_UPDATES_TOGGLED)->get(); + expect($pauseLogs)->toHaveCount(1) + ->and($pauseLogs[0]->actor)->toBe('auto') + ->and($pauseLogs[0]->target)->toBe('paused') + ->and($pauseLogs[0]->details['reason_code'])->toBe('fs_permission') + ->and($pauseLogs[0]->details['now'])->toBeTrue() + ->and((string) json_encode($pauseLogs[0]->details))->not->toContain(STREAK_FAKE_LICENSE); + + // A paused site drops out of the nightly loop's candidate query. + Queue::fake(); + $site->forceFill([ + 'companion_installed' => true, + 'companion_snapshot' => [ + 'plugins' => ['plugins' => [ + ['slug' => 'alpha-plugin', 'name' => 'Alpha', 'version' => '1.0.0', 'new_version' => '1.1.0', 'update_available' => true], + ]], + 'themes' => ['items' => []], + ], + ])->save(); + $this->artisan('clockwork:run-nightly-plugin-updates')->assertSuccessful(); + Queue::assertNothingPushed(); + }); + + it('keeps the per-plugin auto-ignore when another update on the site succeeded during the streak', function () { + $site = makeExampleSite(); + + Http::fake([ + "https://{$site->domain}/wp-json/clockwork/v1/plugins/update*" => Http::response( + stalledWithUpgraderError(copyFailedMessages('alpha-plugin')), + 200, + ), + ]); + + $this->mock(ChatNotifier::class, function ($mock) { + $mock->shouldReceive('pluginUpdateFailed')->andReturn(true); + $mock->shouldReceive('pluginUpdateAutoIgnored')->once()->andReturn(true); + $mock->shouldReceive('send')->never(); + }); + + for ($night = 1; $night <= 5; $night++) { + $job = fakeNightlyJob($site, 'alpha-plugin'); + (new RunPluginUpdate($job->id))->handle(app(ActionLogger::class)); + + if ($night === 1) { + // Another plugin on the same site updated fine: the site's + // files are writable, only this plugin's are not. + PluginUpdateJob::factory()->for($site)->complete()->create([ + 'batch_id' => 'nightly-'.now()->format('Y-m-d'), + 'target_slug' => 'healthy-plugin', + 'before_version' => '2.0.0', + 'after_version' => '2.1.0', + 'target_version' => '2.1.0', + ]); + } + } + + expect($site->refresh()->auto_updates_paused)->toBeFalse(); + + $ignore = PluginUpdateIgnore::where('site_id', $site->id)->where('target_slug', 'alpha-plugin')->first(); + expect($ignore)->not->toBeNull() + ->and($ignore->source)->toBe(PluginUpdateIgnore::SOURCE_AUTO_FAILURE); + }); + + it('leaves a site that is already paused alone: same reason, no log, no alert, no per-plugin ignore', function () { + $site = makeExampleSite([ + 'auto_updates_paused' => true, + 'auto_updates_paused_reason' => 'Paused by an operator during a redesign', + ]); + + $this->mock(ChatNotifier::class, function ($mock) { + $mock->shouldReceive('send')->never(); + $mock->shouldReceive('pluginUpdateAutoIgnored')->never(); + }); + + $error = 'Could not copy file. alpha-plugin/includes/class-main.php — Error: Could not copy file.'; + $job = fakeNightlyJob($site, 'alpha-plugin', [ + 'status' => PluginUpdateJob::STATUS_FAILED, + 'error' => $error, + 'reason_code' => 'fs_permission', + ]); + + $recorder = app(UpdateFailureStreakRecorder::class); + for ($i = 1; $i <= 5; $i++) { + $recorder->record($job, ['ok' => true, 'error' => $error, 'reason_code' => 'fs_permission']); + } + + $site->refresh(); + expect($site->auto_updates_paused)->toBeTrue() + ->and($site->auto_updates_paused_reason)->toBe('Paused by an operator during a redesign') + ->and(ActionLog::where('site_id', $site->id)->where('action_type', ActionLog::TYPE_AUTO_UPDATES_TOGGLED)->count())->toBe(0) + ->and(PluginUpdateIgnore::where('site_id', $site->id)->count())->toBe(0); + }); + + /** One counted (or, with $result null, uncounted transport) nightly failure, each its own job. */ + function recordNightlyFailure(Site $site, string $slug, ?string $reasonCode, ?array $result = []): PluginUpdateJob + { + $error = $reasonCode === 'fs_permission' + ? "Could not copy file. {$slug}/includes/class-main.php — Error: Could not copy file." + : 'The update is not offered to this site.'; + $job = fakeNightlyJob($site, $slug, ['status' => PluginUpdateJob::STATUS_FAILED, 'error' => $error, 'reason_code' => $reasonCode]); + app(UpdateFailureStreakRecorder::class)->record($job, $result === null ? null : ['ok' => false, 'error' => $error, 'reason_code' => $reasonCode]); + + return $job; + } + + it('does not pause the site on the first file-permission failure of a plugin already paused for another reason', function () { + $site = makeExampleSite(); + $this->mock(ChatNotifier::class, function ($mock) { + $mock->shouldReceive('pluginUpdateAutoIgnored')->once()->andReturn(true); + $mock->shouldReceive('send')->never(); + }); + + for ($i = 1; $i <= 5; $i++) { + recordNightlyFailure($site, 'alpha-plugin', 'not_offered'); + } + // A retry of a new release, weeks later, fails once in its own folder. + recordNightlyFailure($site, 'alpha-plugin', 'fs_permission'); + + $streak = PluginUpdateFailureStreak::where('site_id', $site->id)->sole(); + expect($site->refresh()->auto_updates_paused)->toBeFalse() + ->and($streak->consecutive_failures)->toBe(6) + ->and($streak->consecutive_fs_failures)->toBe(1) + ->and(PluginUpdateIgnore::where('site_id', $site->id)->value('source'))->toBe(PluginUpdateIgnore::SOURCE_AUTO_FAILURE); + }); + + it('auto-ignores the plugin, not the site, when only the last of the failures was a file-permission one', function () { + $site = makeExampleSite(); + $this->mock(ChatNotifier::class, function ($mock) { + $mock->shouldReceive('pluginUpdateAutoIgnored')->once()->andReturn(true); + $mock->shouldReceive('send')->never(); + }); + + for ($i = 1; $i <= 4; $i++) { + recordNightlyFailure($site, 'alpha-plugin', 'download_unauthorized'); + } + recordNightlyFailure($site, 'alpha-plugin', 'fs_permission'); + + expect($site->refresh()->auto_updates_paused)->toBeFalse() + ->and(PluginUpdateIgnore::where('site_id', $site->id)->where('target_slug', 'alpha-plugin')->exists())->toBeTrue(); + }); + + it('sees a success on the site inside the run even when an uncounted failure came between', function () { + $site = makeExampleSite(); + $this->mock(ChatNotifier::class, function ($mock) { + $mock->shouldReceive('pluginUpdateAutoIgnored')->once()->andReturn(true); + $mock->shouldReceive('send')->never(); + }); + + $first = recordNightlyFailure($site, 'alpha-plugin', 'fs_permission'); + // Another plugin on the site updated fine: its files are writable. + PluginUpdateJob::factory()->for($site)->complete()->create([ + 'batch_id' => 'nightly-'.now()->format('Y-m-d'), + 'target_slug' => 'healthy-plugin', + 'before_version' => '2.0.0', + 'after_version' => '2.1.0', + 'target_version' => '2.1.0', + ]); + // A transport failure: never counted, and must not move the run's start. + recordNightlyFailure($site, 'alpha-plugin', null, result: null); + for ($i = 1; $i <= 4; $i++) { + recordNightlyFailure($site, 'alpha-plugin', 'fs_permission'); + } + + $streak = PluginUpdateFailureStreak::where('site_id', $site->id)->sole(); + expect($streak->consecutive_fs_failures)->toBe(5) + ->and($streak->fs_streak_started_job_id)->toBe($first->id) + ->and($site->refresh()->auto_updates_paused)->toBeFalse() + ->and(PluginUpdateIgnore::where('site_id', $site->id)->where('target_slug', 'alpha-plugin')->exists())->toBeTrue(); + }); + + it('counts only the file-permission run in the site pause reason', function () { + $site = makeExampleSite(); + $this->mock(ChatNotifier::class, function ($mock) { + // Five failures in all come first: the plugin is paused on its own. + $mock->shouldReceive('pluginUpdateAutoIgnored')->once()->andReturn(true); + $mock->shouldReceive('send')->once()->andReturn(true); + }); + + recordNightlyFailure($site, 'alpha-plugin', 'not_offered'); + for ($i = 1; $i <= 5; $i++) { + recordNightlyFailure($site, 'alpha-plugin', 'fs_permission'); + } + + expect(PluginUpdateFailureStreak::where('site_id', $site->id)->value('consecutive_failures'))->toBe(6) + ->and($site->refresh()->auto_updates_paused)->toBeTrue() + ->and($site->auto_updates_paused_reason)->toContain('5 nightly updates in a row failed with a file-permission error'); + }); + + it('clears the file-permission run when the plugin updates', function () { + $site = makeExampleSite(); + recordNightlyFailure($site, 'alpha-plugin', 'fs_permission'); + $ok = fakeNightlyJob($site, 'alpha-plugin', ['status' => PluginUpdateJob::STATUS_COMPLETE, 'after_version' => '1.1.0']); + app(UpdateFailureStreakRecorder::class)->record($ok, ['ok' => true]); + + expect(PluginUpdateFailureStreak::where('site_id', $site->id)->sole()) + ->consecutive_failures->toBe(0) + ->consecutive_fs_failures->toBe(0) + ->fs_streak_started_job_id->toBeNull(); + }); + + it('honors the auto-pause kill switch for the site-level pause too', function () { + $site = makeExampleSite(); + app(Settings::class)->put('updates.auto_ignore_enabled', false); + + $this->mock(ChatNotifier::class, function ($mock) { + $mock->shouldReceive('send')->never(); + }); + + $error = 'Could not create directory. /var/www/html/wp-content/upgrade/alpha-plugin_1.1.0-AbC12d — Error: Could not create directory.'; + $job = fakeNightlyJob($site, 'alpha-plugin', [ + 'status' => PluginUpdateJob::STATUS_FAILED, + 'error' => $error, + 'reason_code' => 'fs_permission', + ]); + + $recorder = app(UpdateFailureStreakRecorder::class); + for ($i = 1; $i <= 6; $i++) { + $recorder->record($job, ['ok' => true, 'error' => $error, 'reason_code' => 'fs_permission']); + } + + expect($site->refresh()->auto_updates_paused)->toBeFalse() + ->and(PluginUpdateFailureStreak::where('site_id', $site->id)->value('consecutive_failures'))->toBe(6); + }); +}); diff --git a/tests/Feature/Uptime/UptimeProbeBlockedTest.php b/tests/Feature/Uptime/UptimeProbeBlockedTest.php new file mode 100644 index 00000000..b5d874e8 --- /dev/null +++ b/tests/Feature/Uptime/UptimeProbeBlockedTest.php @@ -0,0 +1,718 @@ +mock(ChatNotifier::class); + $chat->shouldNotReceive('siteWentDown'); + $chat->shouldNotReceive('siteWentUp'); + + $sms = test()->mock(SmsNotifier::class); + $sms->shouldNotReceive('siteWentDown'); + $sms->shouldNotReceive('siteWentUp'); + + return $chat; +} + +describe('UptimeProber on HTTP 429', function () { + it('returns a blocked result that is neither a success nor a plain failure, keeping Retry-After', function () { + Http::fake([ + 'https://blocked.example.com/' => Http::response('Too Many Requests', 429, ['Retry-After' => '120']), + ]); + + $result = (new UptimeProber)->probe('https://blocked.example.com/'); + + expect($result->isProbeBlocked)->toBeTrue() + ->and($result->succeeded)->toBeFalse() + ->and($result->isMaintenance)->toBeFalse() + ->and($result->statusCode)->toBe(429) + ->and($result->retryAfter)->toBe('120') + ->and($result->error)->toContain('HTTP 429') + ->and($result->error)->toContain('Retry-After: 120'); + }); + + it('returns a blocked result without Retry-After when the header is absent', function () { + Http::fake([ + 'https://blocked.example.com/' => Http::response('Too Many Requests', 429), + ]); + + $result = (new UptimeProber)->probe('https://blocked.example.com/'); + + expect($result->isProbeBlocked)->toBeTrue() + ->and($result->retryAfter)->toBeNull() + ->and($result->error)->toBe('HTTP 429 (rate-limited — probe blocked)'); + }); + + it('caps an oversized Retry-After header', function () { + Http::fake([ + 'https://blocked.example.com/' => Http::response('Too Many Requests', 429, ['Retry-After' => str_repeat('9', 400)]), + ]); + + $result = (new UptimeProber)->probe('https://blocked.example.com/'); + + expect(mb_strlen((string) $result->retryAfter))->toBe(64) + ->and(mb_strlen((string) $result->error))->toBeLessThan(500); + }); + + it('still treats other 4xx responses as failures, not blocked probes', function () { + Http::fake([ + 'https://blocked.example.com/' => Http::response('Not Found', 404), + ]); + + $result = (new UptimeProber)->probe('https://blocked.example.com/'); + + expect($result->isProbeBlocked)->toBeFalse() + ->and($result->succeeded)->toBeFalse() + ->and($result->statusCode)->toBe(404); + }); +}); + +describe('UptimeStateUpdater with blocked probes', function () { + it('moves an up site to probe_blocked after the threshold of 429s in a row, without touching the failure streak or last-up, dating the event at the first', function () { + $lastUp = now()->subMinutes(10)->startOfSecond(); + $site = Site::factory()->create([ + 'uptime_state' => 'up', + 'uptime_consecutive_failures' => 1, + 'uptime_last_up_at' => $lastUp, + 'uptime_down_since' => null, + ]); + + $chat = quietUptimeChannels(); + $chat->shouldNotReceive('uptimeProbeBlocked'); + + $firstAt = now()->startOfSecond(); + app(UptimeStateUpdater::class)->update($site, blockedProbe('120')); + expect($site->refresh()->uptime_state)->toBe('up') + ->and(SiteUptimeEvent::where('site_id', $site->id)->count())->toBe(0); + + $this->travel(5)->minutes(); + app(UptimeStateUpdater::class)->update($site, blockedProbe('120')); + $site->refresh(); + + expect($site->uptime_state)->toBe(Site::UPTIME_STATE_PROBE_BLOCKED) + ->and($site->uptime_consecutive_failures)->toBe(1) + ->and($site->uptime_last_up_at?->timestamp)->toBe($lastUp->timestamp) + ->and($site->uptime_down_since)->toBeNull() + ->and($site->uptime_probe_blocked_since)->not->toBeNull() + ->and($site->uptime_probe_blocked_alerted_at)->toBeNull() + ->and($site->uptime_last_status_code)->toBe(429) + ->and($site->uptime_last_checked_at)->not->toBeNull() + ->and($site->healthColor())->toBe('yellow'); + + $events = SiteUptimeEvent::where('site_id', $site->id)->get(); + expect($events)->toHaveCount(1) + ->and($events->first()->event_type)->toBe(SiteUptimeEvent::TYPE_PROBE_BLOCKED) + ->and($events->first()->status_code)->toBe(429) + ->and($events->first()->event_at->timestamp)->toBe($firstAt->timestamp) + ->and($events->first()->is_sla_exempt)->toBeFalse() + ->and($site->uptime_probe_blocked_since?->timestamp)->toBe($firstAt->timestamp); + + $log = ActionLog::where('site_id', $site->id)->where('target', Site::UPTIME_STATE_PROBE_BLOCKED)->sole(); + expect($log->ran_at->timestamp)->toBe($firstAt->timestamp); + }); + + it('leaves no trace of a single 429 followed by a success', function () { + $site = Site::factory()->create(['uptime_state' => 'up', 'uptime_consecutive_failures' => 0]); + + quietUptimeChannels()->shouldNotReceive('uptimeProbeBlocked'); + + $updater = app(UptimeStateUpdater::class); + $updater->update($site, blockedProbe()); + $this->travel(5)->minutes(); + $updater->update($site, UptimeProbeResult::success(200, 80)); + $site->refresh(); + + expect($site->uptime_state)->toBe('up') + ->and($site->uptime_consecutive_failures)->toBe(0) + ->and($site->uptime_consecutive_blocked)->toBe(0) + ->and($site->uptime_probe_blocked_since)->toBeNull() + ->and(SiteUptimeEvent::where('site_id', $site->id)->count())->toBe(0) + ->and(ActionLog::where('site_id', $site->id)->count())->toBe(0); + }); + + it('does not block on 429s broken up by another answer', function () { + $site = Site::factory()->create(['uptime_state' => 'up', 'uptime_consecutive_failures' => 0]); + + quietUptimeChannels(); + + $updater = app(UptimeStateUpdater::class); + $updater->update($site, blockedProbe()); + $this->travel(5)->minutes(); + $updater->update($site, UptimeProbeResult::transportFailed('Connection timed out')); + $this->travel(5)->minutes(); + $updater->update($site, blockedProbe()); + $site->refresh(); + + expect($site->uptime_state)->toBe('up') + ->and($site->uptime_consecutive_blocked)->toBe(1) + ->and(SiteUptimeEvent::where('site_id', $site->id)->count())->toBe(0); + }); + + it('blocks on the first 429 when the threshold is set to 1', function () { + app(Settings::class)->put('monitoring.uptime_failure_threshold', 1); + $site = Site::factory()->create(['uptime_state' => 'up']); + + quietUptimeChannels(); + + app(UptimeStateUpdater::class)->update($site, blockedProbe()); + + expect($site->refresh()->uptime_state)->toBe(Site::UPTIME_STATE_PROBE_BLOCKED); + }); + + it('never takes a site down however many 429s arrive in a row, and records the stretch once', function () { + $site = Site::factory()->create(['uptime_state' => 'up', 'uptime_consecutive_failures' => 0]); + + $chat = quietUptimeChannels(); + $chat->shouldNotReceive('uptimeProbeBlocked'); + + $updater = app(UptimeStateUpdater::class); + $updater->update($site, blockedProbe()); + $site->refresh(); + $blockedSince = $site->uptime_probe_blocked_since; + expect($blockedSince)->not->toBeNull(); + + for ($i = 0; $i < 10; $i++) { + $this->travel(10)->minutes(); + $updater->update($site, blockedProbe()); + } + $site->refresh(); + + expect($site->uptime_state)->toBe(Site::UPTIME_STATE_PROBE_BLOCKED) + ->and($site->uptime_consecutive_failures)->toBe(0) + ->and($site->uptime_down_since)->toBeNull() + ->and($site->uptime_probe_blocked_since?->timestamp)->toBe($blockedSince->timestamp) + ->and(SiteUptimeEvent::where('site_id', $site->id)->count())->toBe(1); + }); + + it('does not set last-up for a site first seen blocked', function () { + $site = Site::factory()->create([ + 'uptime_state' => 'unknown', + 'uptime_last_up_at' => null, + ]); + + quietUptimeChannels(); + + app(UptimeStateUpdater::class)->update($site, blockedProbe()); + $this->travel(5)->minutes(); + app(UptimeStateUpdater::class)->update($site, blockedProbe()); + $site->refresh(); + + expect($site->uptime_state)->toBe(Site::UPTIME_STATE_PROBE_BLOCKED) + ->and($site->uptime_last_up_at)->toBeNull(); + }); + + it('keeps an outage open through a 429, so a later failure is the same outage', function () { + $downSince = now()->subMinutes(30)->startOfSecond(); + $site = Site::factory()->create([ + 'uptime_state' => 'down', + 'uptime_consecutive_failures' => 3, + 'uptime_down_since' => $downSince, + ]); + + quietUptimeChannels(); + $updater = app(UptimeStateUpdater::class); + + $updater->update($site, blockedProbe()); + $site->refresh(); + expect($site->uptime_state)->toBe('down') + ->and($site->uptime_down_since?->timestamp)->toBe($downSince->timestamp) + ->and($site->uptime_consecutive_failures)->toBe(3) + ->and($site->uptime_last_status_code)->toBe(429); + + // 502, 502 after it: no new down event, no second alert. + $this->travel(5)->minutes(); + $updater->update($site, UptimeProbeResult::badStatus(502, 90, 'HTTP 502')); + $this->travel(5)->minutes(); + $updater->update($site, UptimeProbeResult::badStatus(502, 90, 'HTTP 502')); + $site->refresh(); + + expect($site->uptime_state)->toBe('down') + ->and($site->uptime_down_since?->timestamp)->toBe($downSince->timestamp) + ->and($site->uptime_probe_blocked_since)->toBeNull() + ->and($site->uptime_consecutive_blocked)->toBe(0) + ->and(SiteUptimeEvent::where('site_id', $site->id)->count())->toBe(0); + }); + + it('recovers a down site normally when a success follows a 429, with the full downtime', function () { + $site = Site::factory()->create([ + 'uptime_state' => 'down', + 'uptime_consecutive_failures' => 2, + 'uptime_down_since' => now()->subMinutes(30), + ]); + + $chat = $this->mock(ChatNotifier::class); + $chat->shouldReceive('siteWentUp')->once() + ->withArgs(fn (Site $s, ?int $sec) => $s->is($site) && $sec >= 35 * 60) + ->andReturn(true); + $chat->shouldNotReceive('siteWentDown'); + $sms = $this->mock(SmsNotifier::class); + $sms->shouldReceive('siteWentUp')->once()->andReturn(false); + + $updater = app(UptimeStateUpdater::class); + $updater->update($site, blockedProbe()); + $this->travel(5)->minutes(); + $updater->update($site, UptimeProbeResult::success(200, 80)); + + expect($site->refresh()->uptime_state)->toBe('up') + ->and(SiteUptimeEvent::where('site_id', $site->id)->pluck('event_type')->all())->toBe([SiteUptimeEvent::TYPE_UP]); + }); + + it('alerts once for a real outage that the host rate-limits now and then (502, 502, 429, 502, 502, 429, ...)', function () { + $site = Site::factory()->create(['uptime_state' => 'up', 'uptime_consecutive_failures' => 0]); + + $chat = $this->mock(ChatNotifier::class); + $chat->shouldReceive('siteWentDown')->once()->andReturn(true); + $chat->shouldNotReceive('siteWentUp'); + $chat->shouldNotReceive('uptimeProbeBlocked'); + $sms = $this->mock(SmsNotifier::class); + $sms->shouldReceive('siteWentDown')->once()->andReturn(false); + $sms->shouldNotReceive('siteWentUp'); + + $updater = app(UptimeStateUpdater::class); + foreach ([502, 502, 429, 502, 502, 429, 502, 502, 429, 502, 502] as $code) { + $updater->update($site, $code === 429 ? blockedProbe() : UptimeProbeResult::badStatus($code, 90, "HTTP {$code}")); + $this->travel(5)->minutes(); + } + + expect($site->refresh()->uptime_state)->toBe('down') + ->and(SiteUptimeEvent::where('site_id', $site->id)->pluck('event_type')->all())->toBe([SiteUptimeEvent::TYPE_DOWN]); + }); + + it('closes an open outage at the first 429 once a down site has only answered 429 for the configured hours, without claiming recovery', function () { + $site = Site::factory()->create([ + 'uptime_state' => 'down', + 'uptime_consecutive_failures' => 3, + 'uptime_down_since' => now()->subMinutes(30), + 'uptime_last_up_at' => now()->subMinutes(40), + ]); + $lastUp = $site->uptime_last_up_at; + $firstAt = now()->startOfSecond(); + + $chat = quietUptimeChannels(); + $chat->shouldReceive('uptimeProbeBlocked')->once()->andReturn(true); + + $updater = app(UptimeStateUpdater::class); + $updater->update($site, blockedProbe()); + for ($i = 0; $i < 11; $i++) { + $this->travel(30)->minutes(); + $updater->update($site, blockedProbe()); + } + expect($site->refresh()->uptime_state)->toBe('down'); + + // Six hours of nothing but 429s. + $this->travel(30)->minutes(); + $updater->update($site, blockedProbe()); + $site->refresh(); + + expect($site->uptime_state)->toBe(Site::UPTIME_STATE_PROBE_BLOCKED) + ->and($site->uptime_down_since)->toBeNull() + ->and($site->uptime_consecutive_failures)->toBe(0) + ->and($site->uptime_last_up_at?->timestamp)->toBe($lastUp->timestamp); + + $event = SiteUptimeEvent::where('site_id', $site->id)->sole(); + expect($event->event_type)->toBe(SiteUptimeEvent::TYPE_PROBE_BLOCKED) + ->and($event->event_at->timestamp)->toBe($firstAt->timestamp); + + $log = ActionLog::where('site_id', $site->id)->where('target', Site::UPTIME_STATE_PROBE_BLOCKED)->first(); + expect($log)->not->toBeNull() + ->and($log->summary)->toContain('outage closed after 30 min') + ->and($log->summary)->toContain('recovery not confirmed'); + }); + + it('keeps a site in maintenance through a 429, without a second maintenance notice', function () { + $site = Site::factory()->create([ + 'uptime_state' => 'maintenance', + 'uptime_maintenance_since' => now()->subMinutes(20), + ]); + + $chat = quietUptimeChannels(); + $chat->shouldNotReceive('siteExitedMaintenance'); + $chat->shouldNotReceive('siteEnteredMaintenance'); + + $updater = app(UptimeStateUpdater::class); + $updater->update($site, blockedProbe()); + expect($site->refresh()->uptime_state)->toBe('maintenance') + ->and($site->uptime_maintenance_since)->not->toBeNull(); + + $this->travel(5)->minutes(); + $updater->update($site, UptimeProbeResult::maintenance(503, 90, 'HTTP 503 (maintenance)')); + + expect($site->refresh()->uptime_state)->toBe('maintenance') + ->and(SiteUptimeEvent::where('site_id', $site->id)->count())->toBe(0); + }); + + it('moves a site in maintenance to probe_blocked only after the configured hours of 429s', function () { + app(Settings::class)->put(UptimeStateUpdater::SETTING_PROBE_BLOCKED_ALERT_HOURS, 1); + $site = Site::factory()->create([ + 'uptime_state' => 'maintenance', + 'uptime_maintenance_since' => now()->subMinutes(20), + ]); + + $chat = quietUptimeChannels(); + $chat->shouldNotReceive('siteExitedMaintenance'); + $chat->shouldReceive('uptimeProbeBlocked')->once()->andReturn(true); + + $updater = app(UptimeStateUpdater::class); + $updater->update($site, blockedProbe()); + $this->travel(1)->hours(); + $updater->update($site, blockedProbe()); + $site->refresh(); + + expect($site->uptime_state)->toBe(Site::UPTIME_STATE_PROBE_BLOCKED) + ->and($site->uptime_maintenance_since)->toBeNull(); + }); + + it('returns a blocked site to up on the next success, closing the stretch quietly', function () { + $site = Site::factory()->create([ + 'uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED, + 'uptime_probe_blocked_since' => now()->subHours(7), + 'uptime_probe_blocked_alerted_at' => now()->subHour(), + 'uptime_last_up_at' => null, + ]); + + $chat = quietUptimeChannels(); + $chat->shouldNotReceive('uptimeProbeBlocked'); + + app(UptimeStateUpdater::class)->update($site, UptimeProbeResult::success(200, 80)); + $site->refresh(); + + expect($site->uptime_state)->toBe('up') + ->and($site->uptime_last_up_at)->not->toBeNull() + ->and($site->uptime_probe_blocked_since)->toBeNull() + ->and($site->uptime_probe_blocked_alerted_at)->toBeNull(); + + expect(SiteUptimeEvent::where('site_id', $site->id)->value('event_type'))->toBe(SiteUptimeEvent::TYPE_UP); + }); + + it('keeps the blocked stretch open through a single failure below the threshold', function () { + $since = now()->subHours(2)->startOfSecond(); + $site = Site::factory()->create([ + 'uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED, + 'uptime_consecutive_failures' => 0, + 'uptime_probe_blocked_since' => $since, + ]); + + quietUptimeChannels(); + + app(UptimeStateUpdater::class)->update($site, UptimeProbeResult::transportFailed('Connection timed out')); + $site->refresh(); + + expect($site->uptime_state)->toBe(Site::UPTIME_STATE_PROBE_BLOCKED) + ->and($site->uptime_consecutive_failures)->toBe(1) + ->and($site->uptime_probe_blocked_since?->timestamp)->toBe($since->timestamp) + ->and(SiteUptimeEvent::where('site_id', $site->id)->count())->toBe(0); + }); + + it('still takes a blocked site down on a real outage, ending the blocked stretch', function () { + $site = Site::factory()->create([ + 'uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED, + 'uptime_consecutive_failures' => 0, + 'uptime_probe_blocked_since' => now()->subHours(2), + ]); + + $chat = $this->mock(ChatNotifier::class); + $chat->shouldReceive('siteWentDown') + ->once() + ->withArgs(fn (Site $s, ?int $code) => $s->is($site) && $code === 502) + ->andReturn(true); + $chat->shouldNotReceive('uptimeProbeBlocked'); + + $sms = $this->mock(SmsNotifier::class); + $sms->shouldReceive('siteWentDown')->once()->andReturn(false); + + $updater = app(UptimeStateUpdater::class); + $updater->update($site, UptimeProbeResult::badStatus(502, 90, 'HTTP 502')); + $updater->update($site, UptimeProbeResult::badStatus(502, 90, 'HTTP 502')); + $site->refresh(); + + expect($site->uptime_state)->toBe('down') + ->and($site->uptime_down_since)->not->toBeNull() + ->and($site->uptime_probe_blocked_since)->toBeNull() + ->and(SiteUptimeEvent::where('site_id', $site->id)->value('event_type'))->toBe(SiteUptimeEvent::TYPE_DOWN); + }); +}); + +describe('probe-blocked ops notice', function () { + it('sends one notice once the site has blocked the probe for the default 6 hours', function () { + $site = Site::factory()->create(['uptime_state' => 'up']); + + $chat = quietUptimeChannels(); + $chat->shouldReceive('uptimeProbeBlocked') + ->once() + ->withArgs(fn (Site $s, int $sec, ?int $code, ?string $retryAfter) => $s->is($site) + && $sec >= 6 * 3600 + && $code === 429 + && $retryAfter === '120') + ->andReturn(true); + + $updater = app(UptimeStateUpdater::class); + + // Ten-minute cadence: nothing until the stretch reaches six hours... + $updater->update($site, blockedProbe('120')); + for ($i = 0; $i < 35; $i++) { + $this->travel(10)->minutes(); + $updater->update($site, blockedProbe('120')); + } + expect($site->refresh()->uptime_probe_blocked_alerted_at)->toBeNull(); + + // ...the probe at the six-hour mark sends it... + $this->travel(10)->minutes(); + $updater->update($site, blockedProbe('120')); + expect($site->refresh()->uptime_probe_blocked_alerted_at)->not->toBeNull(); + + // ...and that stays the only notice, however long it keeps blocking. + for ($i = 0; $i < 12; $i++) { + $this->travel(10)->minutes(); + $updater->update($site, blockedProbe('120')); + } + + expect(ActionLog::where('site_id', $site->id)->where('summary', 'like', '%ops notified%')->count())->toBe(1); + }); + + it('uses the hours configured on Monitoring settings', function () { + app(Settings::class)->put(UptimeStateUpdater::SETTING_PROBE_BLOCKED_ALERT_HOURS, 2); + + $site = Site::factory()->create([ + 'uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED, + 'uptime_probe_blocked_since' => now()->subHours(2)->subMinute(), + ]); + + $chat = quietUptimeChannels(); + $chat->shouldReceive('uptimeProbeBlocked')->once()->andReturn(true); + + app(UptimeStateUpdater::class)->update($site, blockedProbe()); + }); + + it('stays quiet below the configured hours', function () { + $site = Site::factory()->create([ + 'uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED, + 'uptime_probe_blocked_since' => now()->subHours(5)->subMinutes(50), + ]); + + $chat = quietUptimeChannels(); + $chat->shouldNotReceive('uptimeProbeBlocked'); + + app(UptimeStateUpdater::class)->update($site, blockedProbe()); + + expect($site->refresh()->uptime_probe_blocked_alerted_at)->toBeNull(); + }); + + it('respects the per-site ignore flag', function () { + $site = Site::factory()->create([ + 'uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED, + 'uptime_probe_blocked_since' => now()->subHours(8), + 'uptime_ignored_at' => now()->subDay(), + ]); + + $chat = quietUptimeChannels(); + $chat->shouldNotReceive('uptimeProbeBlocked'); + + app(UptimeStateUpdater::class)->update($site, blockedProbe()); + + expect($site->refresh()->uptime_probe_blocked_alerted_at)->toBeNull(); + }); + + it('can notify again for a new blocked stretch after the site got through', function () { + $site = Site::factory()->create([ + 'uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED, + 'uptime_probe_blocked_since' => now()->subHours(8), + 'uptime_probe_blocked_alerted_at' => now()->subHours(2), + ]); + + $chat = quietUptimeChannels(); + $chat->shouldReceive('uptimeProbeBlocked')->once()->andReturn(true); + + $updater = app(UptimeStateUpdater::class); + + // Already notified for this stretch: silent. + $updater->update($site, blockedProbe()); + + // A success ends it; a fresh six-hour stretch notifies once more. + $updater->update($site, UptimeProbeResult::success(200, 70)); + $updater->update($site, blockedProbe()); + $this->travel(6)->hours(); + $updater->update($site, blockedProbe()); + }); + + it('is a gated chat event that names the site and the duration in generic terms', function () { + config([ + 'clockwork.mattermost.enabled' => true, + 'clockwork.mattermost.webhook_url' => 'https://chat.example.com/hooks/test', + ]); + Http::fake(['chat.example.com/*' => Http::response('ok', 200)]); + + $site = Site::factory()->create(['domain' => 'blocked.example.com']); + + expect(ChatNotifier::EVENTS)->toHaveKey('uptime_probe_blocked'); + + $sent = app(MattermostNotifier::class)->uptimeProbeBlocked($site, 6 * 3600 + 600, 429, '120'); + + expect($sent)->toBeTrue(); + Http::assertSent(function ($request) { + $title = (string) $request['text']; + $body = (string) ($request['attachments'][0]['text'] ?? ''); + + return str_contains($title, 'blocked.example.com') + && str_contains($title, 'HTTP 429') + && str_contains($title, '6h 10m') + && str_contains($body, 'not counted as downtime'); + }); + }); + + it('is not sent when the event is switched off for the channel', function () { + config([ + 'clockwork.mattermost.enabled' => true, + 'clockwork.mattermost.webhook_url' => 'https://chat.example.com/hooks/test', + ]); + Http::fake(); + app(Settings::class)->put(MattermostNotifier::SETTINGS_KEY, ['uptime_probe_blocked' => false]); + + $site = Site::factory()->create(); + + expect(app(MattermostNotifier::class)->uptimeProbeBlocked($site, 7 * 3600, 429))->toBeFalse(); + Http::assertNothingSent(); + }); + + it('never reaches the client-facing Slack channel', function () { + $site = Site::factory()->create(); + + expect((new ClientSlackNotifier)->uptimeProbeBlocked($site, 7 * 3600, 429))->toBeFalse(); + }); +}); + +describe('uptime stats with blocked stretches', function () { + it('never counts a blocked stretch as downtime', function () { + $site = Site::factory()->create(['uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED]); + + SiteUptimeEvent::create([ + 'site_id' => $site->id, + 'event_type' => SiteUptimeEvent::TYPE_PROBE_BLOCKED, + 'status_code' => 429, + 'error' => 'HTTP 429 (rate-limited — probe blocked)', + 'event_at' => now()->subHours(20), + ]); + + $pct = app(UptimeStatsCalculator::class)->siteUptime($site, now()->subDay(), now()); + + expect($pct)->toBe(100.0); + }); + + it('stops counting an outage once the site answers 429', function () { + $site = Site::factory()->create([ + 'uptime_state' => 'down', + 'uptime_consecutive_failures' => 2, + 'uptime_down_since' => now()->subHours(12), + ]); + SiteUptimeEvent::create([ + 'site_id' => $site->id, + 'event_type' => SiteUptimeEvent::TYPE_DOWN, + 'status_code' => 502, + 'event_at' => now()->subHours(12), + ]); + + quietUptimeChannels()->shouldReceive('uptimeProbeBlocked')->andReturn(true); + + // Down for 12h, then blocked for the following 12h: once the 429s + // have lasted the configured hours, the outage ends at the first one. + $updater = app(UptimeStateUpdater::class); + for ($hour = 0; $hour < 12; $hour++) { + $updater->update($site, blockedProbe()); + $this->travel(1)->hours(); + } + + $pct = app(UptimeStatsCalculator::class)->siteUptime($site, now()->subDay(), now()); + + expect($pct)->toBe(50.0); + }); +}); + +describe('clockwork:check-site-uptime with a rate-limited site', function () { + it('reports the site as blocked rather than down', function () { + Http::fake(['*' => Http::response('Too Many Requests', 429)]); + + $site = Site::factory()->create([ + 'domain' => 'blocked.example.com', + 'uptime_monitoring_enabled' => true, + 'uptime_state' => 'up', + ]); + + quietUptimeChannels(); + + $this->artisan('clockwork:check-site-uptime', ['--site' => $site->id]) + ->expectsOutputToContain('up=0 down=0 blocked=1') + ->assertExitCode(0); + // One 429 could be a blip; the next one in a row confirms it. + expect($site->refresh()->uptime_state)->toBe('up'); + + $this->travel(5)->minutes(); + $this->artisan('clockwork:check-site-uptime', ['--site' => $site->id])->assertExitCode(0); + + expect($site->refresh()->uptime_state)->toBe(Site::UPTIME_STATE_PROBE_BLOCKED); + }); +}); + +describe('site dashboard with a blocked probe', function () { + it('labels the uptime widget and its event list as blocked, not down', function () { + $this->mockIssueCounterZero(); + + $site = Site::factory()->create([ + 'domain' => 'blocked.example.com', + 'uptime_monitoring_enabled' => true, + 'uptime_state' => Site::UPTIME_STATE_PROBE_BLOCKED, + 'uptime_probe_blocked_since' => now()->subHours(2), + 'uptime_last_status_code' => 429, + ]); + SiteUptimeEvent::create([ + 'site_id' => $site->id, + 'event_type' => SiteUptimeEvent::TYPE_PROBE_BLOCKED, + 'status_code' => 429, + 'error' => 'HTTP 429 (rate-limited — probe blocked)', + 'event_at' => now()->subHours(2), + ]); + + $this->actingAs(User::factory()->create()) + ->get(route('sites.show', $site)) + ->assertOk() + ->assertSee('Probe blocked') + ->assertSee('⊘ Blocked') + ->assertSee('not counted as downtime') + ->assertDontSee('↓ Down'); + }); +}); diff --git a/tests/README.md b/tests/README.md index d7c45f38..6c425050 100644 --- a/tests/README.md +++ b/tests/README.md @@ -84,7 +84,7 @@ Match whatever sentinel/exit-code convention the real caller expects — read th **Service-class collaborators** (anything injected via the constructor, e.g. `ChatNotifier`, `Fail2banClient`, `CompanionInstaller`, a provider's own `*Client`) — `$this->mock(ClassName::class, fn ($mock) => $mock->shouldReceive('method')->once()->with(...)->andReturn(...))`. This is the single most common pattern in the suite. Always assert both directions where relevant: `->once()` (or `->times(N)`) for "this must fire," `->never()` (or `shouldNotReceive(...)`) for "this must NOT fire under this condition" — a test that only checks the happy path can't catch a regression that makes something fire unconditionally. -**Filesystem** — `Storage::fake('disk-name')` for anything going through Laravel's Storage/Flysystem abstraction (e.g. DigitalOcean Spaces). Real temp-directory writes are fine for a check whose entire job is confirming real filesystem access works (`StorageWritableCheck`) — don't mock the thing you're testing. +**Filesystem** — `Storage::fake('disk-name')` for anything going through Laravel's Storage/Flysystem abstraction (e.g. DigitalOcean Spaces). Real temp-directory writes are fine for a check whose entire job is confirming real filesystem access works (`StorageWritableCheck`) — don't mock the thing you're testing. The same goes for a shell command whose quoting is the thing under test: `CompanionMalwareScannerTest` runs the SSH uploads check's command in a local `/bin/sh` over a temp directory (no SSH, no network), because only a real shell shows that an odd file name can't stop it. ## Testing an Artisan command diff --git a/tests/TestCase.php b/tests/TestCase.php index 331b5567..0f9c62f0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,6 +2,7 @@ namespace Tests; +use App\Services\Process\DetachedShell; use App\Support\SsrfGuard; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Facades\Process; @@ -25,5 +26,14 @@ protected function setUp(): void Process::fake([ 'git *' => Process::result(output: 'main', exitCode: 0), ]); + + // BackgroundArtisan launches detached `php artisan` jobs through a raw + // exec() that Process::fake() can't see. Swallow them so no Feature + // test can start a real background process; tests that assert the + // launched command mock DetachedShell or BackgroundArtisan themselves. + $this->instance(DetachedShell::class, new class extends DetachedShell + { + public function run(string $command): void {} + }); } } diff --git a/tests/Unit/Modules/BillComInvoiceDomainExtractorTest.php b/tests/Unit/Modules/BillComInvoiceDomainExtractorTest.php new file mode 100644 index 00000000..97faf470 --- /dev/null +++ b/tests/Unit/Modules/BillComInvoiceDomainExtractorTest.php @@ -0,0 +1,60 @@ +toBe($expected); + })->with([ + 'plain' => ['Website hosting for example.com', 'example.com'], + 'scheme + www' => ['http://www.example.com - - Plugin updates, backups, security scans, etc.', 'example.com'], + 'https + www + path' => ['Care plan: https://www.example.org/ (monthly)', 'example.org'], + 'bare www' => ['Monthly maintenance — www.example.net', 'example.net'], + 'uppercase' => ['Hosting for WWW.EXAMPLE.COM', 'example.com'], + ]); + + it('normalizes a www. prefix so it matches a www-stored or bare site alike', function () { + expect(InvoiceDomainExtractor::extractAll('http://www.example.com - WordPress Care Plan')) + ->toBe(['example.com']); + }); + + it('returns every distinct domain in order, deduped after normalization', function () { + expect(InvoiceDomainExtractor::extractAll('HOSTING WEBSITES: example.com, www.example.org, http://www.example.com')) + ->toBe(['example.com', 'example.org']); + }); + + it('returns nothing for a description without a domain', function () { + expect(InvoiceDomainExtractor::extractAll('WordPress Care Plan - 12 months of backups'))->toBe([]) + ->and(InvoiceDomainExtractor::extract(''))->toBeNull(); + }); + + it('ignores TLDs outside the static allow-list by default', function () { + expect(InvoiceDomainExtractor::extractAll('Care plan for example.tv'))->toBe([]) + ->and(InvoiceDomainExtractor::extractAll('Care plan for example.law'))->toBe([]); + }); + + it('recognises a .tv domain when tv is passed as an extra TLD', function () { + expect(InvoiceDomainExtractor::extractAll('http://www.example.tv - WordPress Care Plan', ['tv'])) + ->toBe(['example.tv']); + }); + + it('recognises a .law domain when law is passed as an extra TLD', function () { + expect(InvoiceDomainExtractor::extract('Monthly maintenance — Example.LAW', ['law', 'tv'])) + ->toBe('example.law'); + }); + + it('keeps rejecting dotted words on TLDs nobody passed in', function () { + expect(InvoiceDomainExtractor::extractAll('See readme.txt and index.php for example.tv', ['tv'])) + ->toBe(['example.tv']); + }); + + it('ignores malformed extra TLDs instead of widening the pattern', function () { + expect(InvoiceDomainExtractor::extractAll('Hosting for example.tv and example.x', ['.TV', 'x', '[a-z]+', 'c|m', ''])) + ->toBe(['example.tv']); + }); + + it('still tells .co and .com apart for the same name', function () { + expect(InvoiceDomainExtractor::extractAll('Hosting for example.co and example.com')) + ->toBe(['example.co', 'example.com']); + }); +}); diff --git a/tests/Unit/Modules/ClientReportReviewCategoriesTest.php b/tests/Unit/Modules/ClientReportReviewCategoriesTest.php new file mode 100644 index 00000000..4b52508c --- /dev/null +++ b/tests/Unit/Modules/ClientReportReviewCategoriesTest.php @@ -0,0 +1,94 @@ +toBe($category); + })->with([ + 'first report' => [ScheduledReportRunner::FIRST_REPORT_REASON, Categories::FIRST_REPORT], + 'malware flagged' => ['The malware scan flagged files to review: php_in_uploads ×2 (scan #1). The scan also stopped before it finished.', Categories::MALWARE], + 'malware cleaned' => ['An earlier malware scan this month flagged malware (on 2 days), but the latest scan did not: check it was cleaned up.', Categories::MALWARE], + 'core files' => ['WordPress core files differ from the official release.', Categories::MALWARE], + 'core file check' => ['No WordPress core-file check ran this month, although this host supports one, so the client would read "Not checked this month".', Categories::MALWARE], + 'blocklist' => ['The site is on a spam or malware blocklist.', Categories::MALWARE], + 'paused vulnerable plugin' => ['Updates are paused for a plugin with a known vulnerability: Example Plugin 1.0 (fixed in 1.1; auto-paused on 2026-10-19 after 5 failed attempts; 1.2 is waiting).', Categories::VULNERABILITIES], + 'waiting vulnerable plugin' => ['A plugin with a known vulnerability has an update waiting: Example Plugin.', Categories::VULNERABILITIES], + 'no backups' => ['No backups were found this month.', Categories::BACKUPS], + 'few backup days' => ['Backed up on only 4 of 31 days this month.', Categories::BACKUPS], + 'relay failing' => ['Off-site backups have been failing since Oct 3.', Categories::BACKUPS], + 'no updates applied' => ['No updates were applied this month although updates were waiting: Example Plugin, so the client would read "No updates were applied this month". Check why they are failing or paused.', Categories::UPDATES], + 'companion silent' => ['Companion hasn\'t reported since Oct 2, so waiting updates and plugin vulnerabilities are out of date.', Categories::UPDATES], + 'uptime unchecked' => ['Uptime monitoring is on, but the site hasn\'t been checked since Oct 2, so the Uptime section was left out.', Categories::UPTIME], + 'uptime began' => ['Uptime monitoring began Oct 12, so uptime covers only part of the month.', Categories::UPTIME], + 'still down' => ['The site was still down at the end of the month.', Categories::UPTIME], + 'probe blocked' => ['The uptime monitor was blocked (HTTP 429) from Oct 3, 9:00 AM; the site was not necessarily down.', Categories::UPTIME], + 'proxy traffic' => ['100% of requests came from two IP addresses, which looks like a proxy or gateway in front of the site rather than visitors, so the Traffic section was left out.', Categories::TRAFFIC], + 'lab forms' => ['Contact-form checks failed: the forms accepted 18 of 19 automated check submissions this month.', Categories::FORMS], + 'live forms' => ['Not every live contact-form test reported its email as sent: 75% of 4 test submissions did.', Categories::FORMS], + 'partial month' => ['Monitoring began Oct 12, so this report covers only part of the month.', Categories::PARTIAL_MONTH], + 'after period' => ['Monitoring began Nov 2, after the end of this report\'s period, so it covers none of it.', Categories::PARTIAL_MONTH], + 'unrecognised' => ['Something nobody has written a rule for yet.', Categories::OTHER], + ]); + + it('groups a report\'s reasons by category in the order they first appear, ignoring blanks', function () { + $reasons = [ + ScheduledReportRunner::FIRST_REPORT_REASON, + 'No backups were found this month.', + '', + 'The malware scan flagged something (scan #4).', + 'Backed up on only 4 of 31 days this month.', + null, + ]; + + expect(Categories::group($reasons))->toBe([ + Categories::FIRST_REPORT => [ScheduledReportRunner::FIRST_REPORT_REASON], + Categories::BACKUPS => ['No backups were found this month.', 'Backed up on only 4 of 31 days this month.'], + Categories::MALWARE => ['The malware scan flagged something (scan #4).'], + ])->and(Categories::forReasons($reasons))->toBe([Categories::FIRST_REPORT, Categories::BACKUPS, Categories::MALWARE]); + }); + + it('knows when a report is held only for being its schedule\'s first', function () { + expect(Categories::onlyFirstReport([ScheduledReportRunner::FIRST_REPORT_REASON]))->toBeTrue() + ->and(Categories::onlyFirstReport([ScheduledReportRunner::FIRST_REPORT_REASON, 'No backups were found this month.']))->toBeFalse() + ->and(Categories::onlyFirstReport([]))->toBeFalse(); + }); + + it('offers every category but "first report" as a list filter, each with a label, chip, icon and site tab', function () { + $options = Categories::filterOptions(); + + expect($options)->not->toHaveKey(Categories::FIRST_REPORT) + ->and(array_keys($options))->toContain(Categories::MALWARE, Categories::BACKUPS, Categories::UPDATES, Categories::VULNERABILITIES, Categories::PARTIAL_MONTH, Categories::TRAFFIC, Categories::UPTIME, Categories::FORMS) + ->and(Categories::siteTab(Categories::MALWARE))->toBe('security') + ->and(Categories::siteTab(Categories::FORMS))->toBe('forms') + ->and(Categories::siteTab(Categories::FIRST_REPORT))->toBeNull() + ->and(Categories::isKnown('nonsense'))->toBeFalse(); + }); +}); + +describe('ReportState::classify', function () { + it('works out where a report stands', function (string $status, array $sentTo, bool $stale, array $reasons, string $state) { + expect(ReportState::classify($status, $sentTo, $stale, $reasons))->toBe($state); + })->with([ + 'replaced copy' => [ClientReport::STATUS_SUPERSEDED, [], false, ['No backups were found this month.'], ReportState::SUPERSEDED], + 'sent' => [ClientReport::STATUS_SENT, ['a@example.com'], true, [], ReportState::SENT], + 'partly sent' => [ClientReport::STATUS_GENERATED, ['a@example.com'], true, [], ReportState::PARTLY_SENT], + 'outdated beats held' => [ClientReport::STATUS_GENERATED, [], true, ['No backups were found this month.'], ReportState::OUTDATED], + 'held' => [ClientReport::STATUS_GENERATED, [], false, [ScheduledReportRunner::FIRST_REPORT_REASON, 'No backups were found this month.'], ReportState::NEEDS_REVIEW], + 'first report only' => [ClientReport::STATUS_GENERATED, [], false, [ScheduledReportRunner::FIRST_REPORT_REASON], ReportState::READY], + 'clean draft' => [ClientReport::STATUS_GENERATED, [], false, [], ReportState::READY], + ]); +}); diff --git a/tests/Unit/ReportPhrasesTest.php b/tests/Unit/ReportPhrasesTest.php new file mode 100644 index 00000000..2855ffac --- /dev/null +++ b/tests/Unit/ReportPhrasesTest.php @@ -0,0 +1,39 @@ +toBe($first); + })->with([ + ['Jane Example', 'Jane'], + ['Sam', 'Sam'], + [' Robin Sample ', 'Robin'], + ["D'Arcy O'Example", "D'Arcy"], + ['Mary-Jane Example', 'Mary-Jane'], + ['Jo Ann Example', 'Jo'], + ['Élodie Exemple', 'Élodie'], + ]); + + it('gives no name for anything that is not plainly one person\'s', function (?string $name) { + expect(ReportPhrases::firstName($name))->toBeNull(); + })->with([ + 'empty' => '', + 'null' => null, + 'an organisation word' => 'Example Institute', + 'a church' => 'St Example Church', + 'an acronym' => 'EXCO', + 'an acronym with a word' => 'XY Example', + 'digits' => 'EXC4P', + 'an initial' => 'R. Example', + 'a bare initial' => 'R Example', + 'a title' => 'Dr. Jane Example', + 'a note in brackets' => 'Jane (Example Strategy)', + 'an ampersand' => 'Example & Sons', + 'more than three words' => 'Jane Mary Ann Example', + 'run together' => 'ExampleSupply', + 'all lower case' => 'jane example', + 'an email address' => 'jane@example.com', + 'a domain' => 'example.com', + ]); +}); diff --git a/tests/Unit/ReportReviewFlagsTest.php b/tests/Unit/ReportReviewFlagsTest.php index bce63076..581e1073 100644 --- a/tests/Unit/ReportReviewFlagsTest.php +++ b/tests/Unit/ReportReviewFlagsTest.php @@ -1,6 +1,7 @@ ['period_start' => '2026-10-01', 'period_end' => '2026-10-31', 'coverage_start' => '2026-10-19T16:30:00+00:00'], + 'uptime' => ['monitored_since' => '2026-10-19T16:30:00+00:00', 'outages' => []], + ]))->toBe(['Monitoring began Oct 19, so this report covers only part of the month.']); +}); + +it('flags a report whose monitoring began only after its period', function () { + expect(ReportReviewFlags::reasons([ + 'meta' => ['period_start' => '2026-09-01', 'period_end' => '2026-09-30', 'coverage_start' => '2026-10-05T16:00:00+00:00'], + ]))->toBe(["Monitoring began Oct 5, after the end of this report's period, so it covers none of it."]); +}); + +it('says only the uptime is partial when uptime monitoring began after the site was added', function () { + expect(ReportReviewFlags::reasons([ + 'meta' => ['period_start' => '2026-10-01', 'period_end' => '2026-10-31', 'coverage_start' => null], + 'uptime' => ['monitored_since' => '2026-10-17T14:00:00+00:00', 'outages' => []], + ]))->toBe(['Uptime monitoring began Oct 17, so uptime covers only part of the month.']) + ->and(ReportReviewFlags::reasons([ + 'meta' => ['period_start' => '2026-10-01', 'period_end' => '2026-10-31', 'coverage_start' => '2026-10-10T12:00:00+00:00'], + 'uptime' => ['monitored_since' => '2026-10-17T14:00:00+00:00', 'outages' => []], + ]))->toBe([ + 'Monitoring began Oct 10, so this report covers only part of the month.', + 'Uptime monitoring began Oct 17, so uptime covers only part of the month.', + ]) + ->and(ReportReviewFlags::reasons([ + 'meta' => ['period_start' => '2026-09-01', 'period_end' => '2026-09-30', 'coverage_start' => null], + 'uptime' => ['monitored_since' => '2026-10-05T16:00:00+00:00', 'outages' => []], + ]))->toBe(['Uptime monitoring began Oct 5, after this period, so the Uptime section was left out.']); +}); + +it('says the monitor was blocked, not that the site was down, when only a 429 is still open', function () { + $reasons = ReportReviewFlags::reasons([ + 'uptime' => ['outages' => [['ongoing' => false]], 'probe_blocked_since' => '2026-10-30T14:05:00+00:00'], + ]); + + // The time is shown in the report timezone, which needs the app config; the wording doesn't. + expect($reasons)->toHaveCount(1) + ->and($reasons[0])->toStartWith('The uptime monitor was blocked (HTTP 429) from Oct 30, ') + ->toEndWith('; the site was not necessarily down.'); +}); + +it('holds completed outages of an hour or more, read from the outages list on reports compiled before long_outages', function () { + $long = ['started_at' => '2026-10-01T06:10:00+00:00', 'minutes' => 381, 'started_before_period' => false, 'ongoing' => false]; + $carried = ['started_at' => '2026-10-01T04:00:00+00:00', 'minutes' => 60, 'started_before_period' => true, 'ongoing' => false]; + $short = ['started_at' => '2026-10-09T10:00:00+00:00', 'minutes' => 59, 'started_before_period' => false, 'ongoing' => false]; + + // The time is shown in the report timezone, which needs the app config; the wording doesn't. + $when = fn (array $o) => preg_replace('/^Down /', '', ReportPhrases::outageStart($o)); + $expected = 'The site was down for an hour or more: '.$when($carried).' for 1h 0m; '.$when($long).' for 6h 21m. Add context for the client before this goes out.'; + + expect(ReportReviewFlags::reasons(['uptime' => ['outages' => [$short, $long, $carried]]]))->toBe([$expected]) + ->and(ReportReviewFlags::reasons(['uptime' => ['outages' => [$short], 'long_outages' => [$carried, $long]]]))->toBe([$expected]) + ->and(ReportReviewFlags::reasons(['uptime' => ['outages' => [$long], 'long_outages' => []]]))->toBe([]); +}); + +it('still says the site was down when a real outage is open at the end of the month', function () { + expect(ReportReviewFlags::reasons([ + 'uptime' => ['outages' => [['ongoing' => true]], 'probe_blocked_since' => null], + ]))->toBe(['The site was still down at the end of the month.']); +}); + +it('adds nothing for a full month or a report compiled before coverage was recorded', function () { + expect(ReportReviewFlags::reasons([ + 'meta' => ['period_start' => '2026-10-01', 'period_end' => '2026-10-31', 'coverage_start' => null], + 'uptime' => ['monitored_since' => null, 'probe_blocked_minutes' => 30, 'probe_blocked_since' => null, 'outages' => []], + ]))->toBe([]) + ->and(ReportReviewFlags::reasons(['meta' => ['period_end' => '2026-10-31'], 'uptime' => ['outages' => []]]))->toBe([]); +}); + +describe('malware', function () { + it('names the finding kinds and the scan, and says when a flagged scan stopped early', function () { + expect(ReportReviewFlags::reasons(['security' => [ + 'malware_status' => 'warning', 'malware_scan_id' => 123, 'malware_finding_kinds' => ['php_in_uploads' => 4], + ]]))->toBe(['The malware scan flagged files to review: php_in_uploads ×4 (scan #123).']) + ->and(ReportReviewFlags::reasons(['security' => [ + 'malware_status' => 'issues_found', 'malware_scan_id' => 7, 'malware_scan_aborted' => true, + 'malware_finding_kinds' => ['eval_base64' => 2, 'php_in_uploads' => 1], + ]]))->toBe(['The malware scan found suspected malware: eval_base64 ×2, php_in_uploads ×1 (scan #7). The scan also stopped before it finished.']) + // A SiteCheck result has no finding kinds. + ->and(ReportReviewFlags::reasons(['security' => ['malware_status' => 'issues_found', 'malware_scan_id' => 9, 'malware_finding_kinds' => null]])) + ->toBe(['The malware scan flagged something (scan #9).']); + }); + + it('holds a scan that did not cover the whole site', function () { + expect(ReportReviewFlags::reasons(['security' => [ + 'malware_status' => 'incomplete', 'malware_scan_id' => 5, 'malware_transport' => 'companion', + 'malware_scan_aborted' => true, 'malware_abort_reason' => 'max_wall_seconds', 'malware_scanned_files' => 1200, + ]]))->toBe(['The latest malware scan (scan #5) stopped before it finished (limit: max_wall_seconds, 1,200 files checked), so the client sees "Partial scan" instead of an all-clear.']) + ->and(ReportReviewFlags::reasons(['security' => [ + 'malware_status' => 'incomplete', 'malware_scan_id' => 6, 'malware_transport' => 'ssh', 'malware_scan_aborted' => false, + ]]))->toBe(['The latest malware scan (scan #6) ran over SSH before scans recorded whether every check finished; it can\'t vouch for the whole site, so the client sees "Partial scan" instead of an all-clear.']) + ->and(ReportReviewFlags::reasons(['security' => [ + 'malware_status' => 'incomplete', 'malware_scan_id' => 7, 'malware_transport' => 'ssh', 'malware_scan_aborted' => true, + 'malware_abort_reason' => 'ssh_check_incomplete', 'malware_incomplete_checks' => ['shell_wso'], + ]]))->toBe(['The latest malware scan (scan #7) ran over SSH and some checks didn\'t finish (shell_wso); they timed out or were cut off, so they can\'t vouch for the files they didn\'t list, so the client sees "Partial scan" instead of an all-clear.']) + ->and(ReportReviewFlags::reasons(['security' => [ + 'malware_status' => 'incomplete', 'malware_scan_id' => 8, 'malware_transport' => 'ssh', 'malware_scan_aborted' => true, + 'malware_abort_reason' => 'ssh_result_limit', 'malware_truncated_checks' => ['php_in_uploads', 'shell_c99'], + ]]))->toBe(['The latest malware scan (scan #8) ran over SSH and hit its result limit (php_in_uploads, shell_c99); files past the limit were not checked, so the client sees "Partial scan" instead of an all-clear.']); + }); + + it('lets a clean SSH scan through, whatever its file count says', function () { + expect(ReportReviewFlags::reasons(['security' => [ + 'malware_status' => 'clean', 'malware_transport' => 'ssh', 'malware_scan_aborted' => false, 'malware_scanned_files' => 0, 'malware_truncated_checks' => [], + ]]))->toBe([]); + }); + + it('flags malware an earlier scan found when the latest scan is clean, and not twice when it still finds it', function () { + $earlier = ['malware_worst_in_period' => 'issues_found', 'malware_last_issue_at' => '2026-10-26', 'malware_issue_days' => 1]; + + expect(ReportReviewFlags::reasons(['security' => ['malware_status' => 'clean', ...$earlier]])) + ->toBe(['An earlier malware scan this month flagged malware (on 1 day, last on 2026-10-26), but the latest scan did not: check it was cleaned up.']) + ->and(ReportReviewFlags::reasons(['security' => ['malware_status' => 'issues_found', ...$earlier]])) + ->toBe(['The malware scan flagged something.']) + ->and(ReportReviewFlags::reasons(['security' => ['malware_status' => 'clean', 'malware_worst_in_period' => 'warning']]))->toBe([]); + }); +}); + +describe('WordPress core files', function () { + it('flags a missing check only where the host can run one', function () { + expect(ReportReviewFlags::reasons(['security' => ['checksum_supported' => true, 'checksum_status' => null]])) + ->toBe(['No WordPress core-file check ran this month, although this host supports one, so the client would read "Not checked this month".']) + ->and(ReportReviewFlags::reasons(['security' => ['checksum_supported' => true, 'checksum_status' => 'clean']]))->toBe([]) + ->and(ReportReviewFlags::reasons(['security' => ['checksum_supported' => false, 'checksum_status' => null]]))->toBe([]) + // Older reports didn't record it. + ->and(ReportReviewFlags::reasons(['security' => ['checksum_status' => null]]))->toBe([]); + }); +}); + +describe('vulnerable plugins joined to waiting updates by slug', function () { + function vulnerableItem(array $overrides = []): array + { + return array_replace([ + 'slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'version' => '1.0', + 'fixed_in' => '1.2', 'update_offered' => null, 'paused' => null, + ], $overrides); + } + + it('holds when the update waiting reaches the fixed version', function () { + expect(ReportReviewFlags::reasons([ + 'security' => ['vulnerable_plugins' => ['Example Forms'], 'vulnerable_plugin_items' => [vulnerableItem(['update_offered' => '1.3'])]], + 'updates' => ['pending_plugins' => ['Example Forms'], 'pending_plugin_items' => [['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'new_version' => '1.3']]], + ]))->toBe(["A plugin with a known vulnerability has an update waiting that fixes it, but it wasn't applied: Example Forms 1.0 → 1.3 (fixed in 1.2)."]); + }); + + it('lets through an update that falls short of the fix, one with no fix yet, and a same-named plugin\'s update', function () { + expect(ReportReviewFlags::reasons([ + 'security' => ['vulnerable_plugin_items' => [ + vulnerableItem(['update_offered' => '1.1']), + vulnerableItem(['slug' => 'example-gallery/example-gallery.php', 'name' => 'Example Gallery', 'fixed_in' => null, 'update_offered' => '9.0']), + vulnerableItem(['slug' => 'example-seo/example-seo.php', 'name' => 'Example SEO']), + ]], + 'updates' => ['pending_plugins' => ['Example Forms', 'Example Gallery', 'Example SEO'], 'pending_plugin_items' => [ + ['slug' => 'example-forms/example-forms.php', 'name' => 'Example Forms', 'new_version' => '1.1'], + ['slug' => 'example-gallery/example-gallery.php', 'name' => 'Example Gallery', 'new_version' => '9.0'], + // A copy in another folder, with the same name and a fixing version. + ['slug' => 'example-seo-backup/example-seo.php', 'name' => 'Example SEO', 'new_version' => '5.0'], + ]], + ]))->toBe([]); + }); + + it('holds a vulnerable plugin whose updates are paused, naming the fix and why', function () { + expect(ReportReviewFlags::reasons([ + 'security' => ['vulnerable_plugin_items' => [ + vulnerableItem(['paused' => ['source' => 'auto_failure', 'since' => '2026-10-30', 'failure_count' => 9, 'note' => null]]), + vulnerableItem(['slug' => 'example-gallery/example-gallery.php', 'name' => 'Example Gallery', 'version' => '3.0', 'fixed_in' => null, + 'paused' => ['source' => 'manual', 'since' => '2026-09-01', 'failure_count' => null, 'note' => 'Licence expired']]), + ]], + 'updates' => ['pending_plugin_items' => []], + ]))->toBe(['Updates are paused for a plugin with a known vulnerability: Example Forms 1.0 (fixed in 1.2; auto-paused on 2026-10-30 after 9 failed attempts); Example Gallery 3.0 (no fixed version yet; paused by hand since 2026-09-01: Licence expired).']); + }); + + it('reads a fix only known as "after" a closed bound, and holds only an update past it', function () { + $item = fn (string $offered) => vulnerableItem(['fixed_in' => null, 'fixed_after' => '5.2.4', 'update_offered' => $offered]); + + expect(ReportReviewFlags::reasons(['security' => ['vulnerable_plugin_items' => [$item('5.2.5')]]])) + ->toBe(["A plugin with a known vulnerability has an update waiting that fixes it, but it wasn't applied: Example Forms 1.0 → 5.2.5 (fixed in the next version after 5.2.4)."]) + ->and(ReportReviewFlags::reasons(['security' => ['vulnerable_plugin_items' => [$item('5.2.4')]]]))->toBe([]) + ->and(ReportReviewFlags::reasons(['security' => ['vulnerable_plugin_items' => [ + vulnerableItem(['fixed_in' => null, 'fixed_after' => '5.2.4', 'paused' => ['source' => 'manual', 'since' => null, 'failure_count' => null, 'note' => null]]), + ]]]))->toBe(['Updates are paused for a plugin with a known vulnerability: Example Forms 1.0 (fixed in the next version after 5.2.4; paused by hand).']); + }); + + it('says an update held back as a different package is held back, not paused by hand', function () { + expect(ReportReviewFlags::reasons(['security' => ['vulnerable_plugin_items' => [ + vulnerableItem(['paused' => ['source' => 'identity_mismatch', 'since' => '2026-10-01', 'failure_count' => null, 'note' => 'Different package']]), + ]]]))->toBe(['Updates are paused for a plugin with a known vulnerability: Example Forms 1.0 (fixed in 1.2; update held back since 2026-10-01: the offered package appears to be a different theme).']); + }); + + it('uses the version the plugin recorded as offered when there is no list of waiting updates', function () { + expect(ReportReviewFlags::reasons([ + 'security' => ['vulnerable_plugin_items' => [vulnerableItem(['update_offered' => '1.2'])]], + 'updates' => ['pending_plugins' => null, 'pending_plugin_items' => null], + ]))->toBe(["A plugin with a known vulnerability has an update waiting that fixes it, but it wasn't applied: Example Forms 1.0 → 1.2 (fixed in 1.2)."]) + ->and(ReportReviewFlags::reasons(['security' => ['vulnerable_plugin_items' => [vulnerableItem(['update_offered' => '1.2'])]]])) + ->toHaveCount(1); + }); +}); + +describe('backups', function () { + it('holds database backups far smaller than the database, with both sizes', function () { + expect(ReportReviewFlags::reasons(['backups' => ['status' => 'suspect', 'db_backup_bytes' => 9200, 'db_size_bytes' => 3 * 1024 ** 3]])) + ->toBe(['Database backups are 9 KB but the database is 3.0 GB, so they look empty or broken and the Backups section was left out.']) + ->and(ReportReviewFlags::reasons(['backups' => ['status' => 'suspect']])) + ->toBe(['Database backups are far smaller than the database, so they look empty or broken and the Backups section was left out.']); + }); + + it('counts only the days backups were expected, from when the site\'s coverage began', function () { + $relay = ['status' => 'verified', 'verified' => true, 'source' => 's3_archive', 'covers_period' => false, 'daily' => true, + 'days_with_backups' => 1, 'period_days' => 31, 'expected_days' => 4, 'archives_began' => '2026-10-31', 'expected_from' => '2026-10-28']; + $daily = ['status' => 'verified', 'verified' => true, 'covers_period' => true, 'daily' => true, 'days_with_backups' => 5, 'period_days' => 31, 'expected_days' => 12]; + + expect(ReportReviewFlags::reasons(['backups' => $relay])) + ->toBe(['Off-site backups began on Oct 31, 2026; none were made before that this month, although the site was enrolled earlier (backed up on 1 of 4 days).']) + ->and(ReportReviewFlags::reasons(['backups' => $daily]))->toBe(['Backed up on only 5 of 12 days this month.']) + ->and(ReportReviewFlags::reasons(['backups' => ['days_with_backups' => 10] + $daily]))->toBe([]); + }); + + it('holds a daily source that backed up on far fewer days than the period had', function () { + $backups = ['status' => 'verified', 'verified' => true, 'covers_period' => true, 'daily' => true, 'days_with_backups' => 12, 'period_days' => 31]; + + expect(ReportReviewFlags::reasons(['backups' => $backups]))->toBe(['Backed up on only 12 of 31 days this month.']) + // A few days short is normal. + ->and(ReportReviewFlags::reasons(['backups' => ['days_with_backups' => 28] + $backups]))->toBe([]) + // A weekly schedule, records that start part-way through, and older reports say nothing. + ->and(ReportReviewFlags::reasons(['backups' => ['daily' => false] + $backups]))->toBe([]) + ->and(ReportReviewFlags::reasons(['backups' => ['covers_period' => false] + $backups]))->toBe([]) + ->and(ReportReviewFlags::reasons(['backups' => ['status' => 'verified', 'covers_period' => true, 'days_with_backups' => 3]]))->toBe([]); + }); + + it('holds relay archives that began part-way through the month for a site enrolled before it', function () { + $backups = ['status' => 'verified', 'verified' => true, 'source' => 's3_archive', 'covers_period' => false, 'daily' => true, + 'days_with_backups' => 3, 'period_days' => 31, 'archives_began' => '2026-10-29', 'expected_from' => '2026-10-01']; + + expect(ReportReviewFlags::reasons(['backups' => $backups])) + ->toBe(['Off-site backups began on Oct 29, 2026; none were made before that this month, although the site was enrolled earlier (backed up on 3 of 31 days).']) + // Without the marker (older reports, other sources) nothing changes. + ->and(ReportReviewFlags::reasons(['backups' => ['archives_began' => null] + $backups]))->toBe([]) + ->and(ReportReviewFlags::reasons(['backups' => ['source' => 'pressable'] + $backups]))->toBe([]); + }); + + it('holds off-site backups that have been failing, with when it began and the error', function () { + $failing = ['status' => 'failing', 'verified' => false, 'relay_failing_since' => '2026-10-03T12:00:00+00:00']; + + expect(ReportReviewFlags::reasons(['backups' => $failing + ['relay_error' => 'Upload to storage failed with HTTP 403']])) + ->toBe(['Off-site backups have been failing since Oct 3, 2026: Upload to storage failed with HTTP 403.']) + ->and(ReportReviewFlags::reasons(['backups' => $failing + ['relay_error' => null]])) + ->toBe(['Off-site backups have been failing since Oct 3, 2026.']) + ->and(ReportReviewFlags::reasons(['backups' => ['status' => 'failing']])) + ->toBe(['Off-site backups were failing at the end of the month.']) + // Archives that stopped part-way: the stale reason says why. + ->and(ReportReviewFlags::reasons(['backups' => ['status' => 'stale', 'relay_failing_since' => '2026-10-03T12:00:00+00:00', 'relay_error' => 'Example timeout.']])) + ->toBe(['Backups stopped before the last 10 days of the month. Off-site backups have been failing since Oct 3, 2026: Example timeout.']); + }); + + it('never repeats a URL query string from the relay error', function () { + $reasons = ReportReviewFlags::reasons(['backups' => [ + 'status' => 'failing', 'relay_failing_since' => '2026-10-03T12:00:00+00:00', + 'relay_error' => 'PUT s3://example-bucket/site.tar.gz?X-Amz-Signature=abc123 and https://storage.example.com/a.zip?token=def456#frag failed', + ]]); + + expect($reasons)->toBe(['Off-site backups have been failing since Oct 3, 2026: PUT s3://example-bucket/site.tar.gz and https://storage.example.com/a.zip failed.']) + ->and(implode(' ', $reasons))->not->toContain('abc123')->not->toContain('def456'); + }); +}); + +describe('traffic', function () { + it('holds traffic the client won\'t see, saying why', function (array $traffic, string $reason) { + expect(ReportReviewFlags::reasons(['traffic' => $traffic]))->toBe([$reason]); + })->with([ + 'days missing' => [ + ['status' => 'partial', 'source' => 'nginx', 'days_covered' => 12, 'period_days' => 31, 'last_day_complete' => true], + 'Traffic was recorded on only 12 of 31 days, so the Traffic section was left out.', + ], + 'last day unfinished' => [ + ['status' => 'partial', 'source' => 'pressable', 'days_covered' => 31, 'period_days' => 31, 'last_day_complete' => false], + "The period's last day of traffic wasn't complete when the report was compiled, so the Traffic section was left out.", + ], + 'a proxy in front of the site' => [ + ['status' => 'suspect', 'source' => 'nginx', 'days_covered' => 12, 'period_days' => 31, 'top_ip_share' => 0.998, 'total_visits' => 40, 'total_requests' => 1500000], + '100% of requests came from two IP addresses, which looks like a proxy or gateway in front of the site rather than visitors; also, traffic was recorded on only 12 of 31 days, so the Traffic section was left out.', + ], + 'a proxy seen as one address' => [ + ['status' => 'suspect', 'source' => 'nginx', 'days_covered' => 31, 'period_days' => 31, 'top_ip_share' => 1.0, 'top_ip_count' => 1, 'total_visits' => 31, 'total_requests' => 90000], + '100% of requests came from one IP address, which looks like a proxy or gateway in front of the site rather than visitors, so the Traffic section was left out.', + ], + 'visits that cannot be real' => [ + ['status' => 'suspect', 'source' => 'pressable', 'days_covered' => 31, 'period_days' => 31, 'top_ip_share' => 0.95, 'total_visits' => 33, 'total_requests' => 410000], + "33 visits for 410,000 requests doesn't look like real visitors, so the Traffic section was left out.", + ], + 'nothing from a host we collect from' => [ + ['status' => 'no_data', 'has_traffic' => false], + "No traffic was recorded for this period, although this host's traffic is collected, so the Traffic section was left out.", + ], + ]); + + it('lets complete traffic, hosts we don\'t collect from and older reports through', function () { + expect(ReportReviewFlags::reasons(['traffic' => ['status' => 'ok', 'days_covered' => 31, 'period_days' => 31]]))->toBe([]) + ->and(ReportReviewFlags::reasons(['traffic' => ['status' => 'unsupported', 'has_traffic' => false]]))->toBe([]) + ->and(ReportReviewFlags::reasons(['traffic' => ['has_traffic' => true, 'total_visits' => 10, 'total_requests' => 900000]]))->toBe([]); + }); +}); + +describe('contact forms', function () { + it('holds a report whose form checks or delivery tests failed', function () { + expect(ReportReviewFlags::reasons(['forms' => ['live_tests' => 0, 'pass_rate' => null, 'lab_tests' => 19, 'lab_accepted' => 18]])) + ->toBe(['Contact-form checks failed: the forms accepted 18 of 19 automated check submissions this month.']) + ->and(ReportReviewFlags::reasons(['forms' => ['live_tests' => 4, 'pass_rate' => 75.0, 'lab_tests' => 0, 'lab_accepted' => 0]])) + ->toBe(['Not every live contact-form test reported its email as sent: 75% of 4 test submissions did.']); + }); + + it('lets passing and untested forms through', function () { + expect(ReportReviewFlags::reasons(['forms' => ['live_tests' => 2, 'pass_rate' => 100.0, 'lab_tests' => 5, 'lab_accepted' => 5]]))->toBe([]) + ->and(ReportReviewFlags::reasons(['forms' => ['live_tests' => 0, 'pass_rate' => null, 'lab_tests' => 0, 'lab_accepted' => 0]]))->toBe([]); + }); +}); + +describe('nothing applied while updates were waiting', function () { + $updates = fn (array $over = []) => ['updates' => array_replace([ + 'total' => 0, 'plugins_count' => 0, 'themes_count' => 0, 'core_count' => 0, 'translations_count' => 0, + 'items' => ['plugins' => [], 'themes' => [], 'core' => []], + 'pending_plugins' => [], 'pending_themes' => [], 'pending_core' => false, + 'updated_since_period' => 0, 'snapshot_at' => '2026-10-31', 'snapshot_stale' => false, + ], $over)]; + $reason = 'No updates were applied this month although updates were waiting'; + + it('holds a report that applied nothing while plugins were waiting', function () use ($updates, $reason) { + expect(ReportReviewFlags::reasons($updates(['pending_plugins' => ['Example Forms', 'Example SEO']]))) + ->toBe([$reason.': Example Forms, Example SEO, so the client would read "No updates were applied this month". Check why they are failing or paused.']); + }); + + it('holds a report when the only sign is an update applied after the period', function () use ($updates, $reason) { + expect(ReportReviewFlags::reasons($updates(['updated_since_period' => 2]))) + ->toBe([$reason.', so the client would read "No updates were applied this month". Check why they are failing or paused.']); + }); + + it('names at most five and counts the rest', function () use ($updates) { + $names = array_map(fn ($i) => "Example {$i}", range(1, 7)); + + expect(ReportReviewFlags::reasons($updates(['pending_plugins' => $names]))[0])->toContain('Example 5 and 2 more,'); + }); + + it('stays quiet when an update was applied, nothing was waiting, the snapshot is stale or the section is older', function () use ($updates, $reason) { + // A translations run counts toward the total but isn't a listed row, so it doesn't count as applied. + expect(ReportReviewFlags::reasons($updates(['pending_plugins' => ['Example Forms'], 'total' => 1, 'translations_count' => 1])))->toHaveCount(1) + ->and(ReportReviewFlags::reasons($updates(['pending_plugins' => ['Example Forms'], 'total' => 1, 'plugins_count' => 1])))->toBe([]) + ->and(ReportReviewFlags::reasons($updates()))->toBe([]) + ->and(ReportReviewFlags::reasons($updates(['pending_plugins' => null, 'pending_themes' => null, 'pending_core' => null])))->toBe([]) + ->and(implode(' ', ReportReviewFlags::reasons($updates(['pending_plugins' => null, 'snapshot_stale' => true]))))->not->toContain($reason) + ->and(ReportReviewFlags::reasons(['updates' => ['total' => 0, 'pending_plugins' => ['Example Forms']]]))->toBe([]); + }); +}); + +describe('malware findings marked reviewed', function () { + function reviewedSecurity(array $overrides = [], array $mark = []): array + { + return array_replace([ + 'malware_status' => 'warning', 'malware_scan_id' => 41, 'malware_finding_kinds' => ['php_in_uploads' => 2, 'wp_config_recently_modified' => 1], + 'malware_reviewed' => array_replace(['scan_id' => 41, 'finding_kinds' => ['wp_config_recently_modified' => 1, 'php_in_uploads' => 2], + 'reviewed_by' => 'Example Operator', 'reviewed_at' => '2026-11-03T10:00:00+00:00', 'note' => 'Checked'], $mark), + ], $overrides); + } + + it('reads the mark only for the same scan and the same findings, in any order', function () { + expect(ReportReviewFlags::malwareReviewed(reviewedSecurity()))->toBeTrue() + ->and(ReportReviewFlags::malwareReviewed(reviewedSecurity(mark: ['scan_id' => 40])))->toBeFalse() + ->and(ReportReviewFlags::malwareReviewed(reviewedSecurity(mark: ['finding_kinds' => ['php_in_uploads' => 2]])))->toBeFalse() + ->and(ReportReviewFlags::malwareReviewed(reviewedSecurity(['malware_finding_kinds' => ['php_in_uploads' => 3, 'wp_config_recently_modified' => 1]])))->toBeFalse() + ->and(ReportReviewFlags::malwareReviewed(reviewedSecurity(['malware_reviewed' => null])))->toBeFalse(); + }); + + it('never applies to suspected malware, even with a matching mark', function () { + $kinds = ['php_in_uploads' => 1, 'eval_base64' => 1]; + $security = reviewedSecurity(['malware_status' => 'issues_found', 'malware_finding_kinds' => $kinds], ['finding_kinds' => $kinds]); + + expect(ReportReviewFlags::malwareReviewed($security))->toBeFalse() + ->and(ReportReviewFlags::malwareReviewed(['malware_status' => 'warning'] + $security))->toBeFalse() + ->and(ReportReviewFlags::canMarkMalwareReviewed(['security' => $security]))->toBeFalse() + ->and(ReportReviewFlags::reasons(['security' => $security]))->toBe(['The malware scan found suspected malware: php_in_uploads ×1, eval_base64 ×1 (scan #41).']); + }); + + it('drops the malware reason once reviewed, keeping the others', function () { + $data = ['security' => reviewedSecurity(['malware_reviewed' => null]), 'backups' => ['status' => 'stale']]; + $data['meta']['review_reasons'] = ['First report.', ...ReportReviewFlags::reasons($data)]; + + $marked = ReportReviewFlags::withMalwareReviewed($data, ['reviewed_by' => 'Example Operator', 'note' => 'Checked']); + + expect(ReportReviewFlags::canMarkMalwareReviewed($data))->toBeTrue() + ->and($data['meta']['review_reasons'])->toHaveCount(3) + ->and($marked['meta']['review_reasons'])->toBe(['First report.', 'Backups stopped before the last 10 days of the month.']) + ->and($marked['security']['malware_reviewed'])->toMatchArray(['scan_id' => 41, 'reviewed_by' => 'Example Operator']) + ->and(ReportReviewFlags::canMarkMalwareReviewed($marked))->toBeFalse(); + }); +}); diff --git a/tests/Unit/Support/DomainNormalizerTest.php b/tests/Unit/Support/DomainNormalizerTest.php new file mode 100644 index 00000000..43082fdd --- /dev/null +++ b/tests/Unit/Support/DomainNormalizerTest.php @@ -0,0 +1,48 @@ +toBe($expected); + })->with([ + 'bare' => ['example.com', 'example.com'], + 'uppercase' => ['Example.COM', 'example.com'], + 'leading www' => ['www.example.com', 'example.com'], + 'scheme + www + path' => [' https://www.Example.com/about/ ', 'example.com'], + 'http scheme' => ['http://example.com', 'example.com'], + 'protocol-relative' => ['//www.example.com/', 'example.com'], + 'port' => ['example.com:443', 'example.com'], + 'query and fragment' => ['example.com?x=1#top', 'example.com'], + 'trailing dot' => ['sub.example.org.', 'sub.example.org'], + 'www + trailing dot' => ['www.example.org.', 'example.org'], + ]); + + it('keeps subdomains other than a single leading www.', function () { + expect(DomainNormalizer::normalize('shop.example.com'))->toBe('shop.example.com') + ->and(DomainNormalizer::normalize('www.shop.example.com'))->toBe('shop.example.com') + ->and(DomainNormalizer::normalize('wwwexample.com'))->toBe('wwwexample.com'); + }); + + it('does not reduce "www." to a bare TLD', function () { + expect(DomainNormalizer::normalize('www.com'))->toBe('www.com'); + }); + + it('returns an empty string for blank input', function () { + expect(DomainNormalizer::normalize(' '))->toBe(''); + }); +}); + +describe('DomainNormalizer::tld()', function () { + it('returns the alphabetic top-level label', function () { + expect(DomainNormalizer::tld('www.example.tv'))->toBe('tv') + ->and(DomainNormalizer::tld('https://Example.LAW/'))->toBe('law') + ->and(DomainNormalizer::tld('shop.example.co.uk'))->toBe('uk'); + }); + + it('returns null when there is no usable TLD', function () { + expect(DomainNormalizer::tld('localhost'))->toBeNull() + ->and(DomainNormalizer::tld('192.0.2.10'))->toBeNull() + ->and(DomainNormalizer::tld(''))->toBeNull(); + }); +}); diff --git a/tests/Unit/Support/UpgraderOutputTest.php b/tests/Unit/Support/UpgraderOutputTest.php new file mode 100644 index 00000000..d52b3b61 --- /dev/null +++ b/tests/Unit/Support/UpgraderOutputTest.php @@ -0,0 +1,235 @@ +…" with +| the vendor's (often licensed or signed) package URL, then the outcome, and +| on failure a detail line followed by "Error: …". +*/ + +const FAKE_LICENSE = 'FAKE-LICENSE-0000-1111-2222'; +const FAKE_JWT = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsaWNlbnNlIjoiZmFrZSJ9.ZmFrZXNpZ25hdHVyZQ'; +const FAKE_AWS_KEY = 'AKIAFAKEFAKEFAKE0000'; +const FAKE_SIGNATURE = 'ZmFrZXNpZ25hdHVyZTEyMw%3D%3D'; + +function fakeLicensedDownloadLine(): string +{ + return 'Downloading update from https://updates.example.com/?action=package_download&license='.FAKE_LICENSE.'&domain=client.example.org&version=2.0.0…'; +} + +function fakeJwtPathDownloadLine(): string +{ + return 'Downloading update from https://vendor.example.net/api/v2/plugins/download/'.FAKE_JWT.'/sample_pro…'; +} + +function fakeSignedDownloadLine(): string +{ + return 'Downloading update from https://downloads.example.org/packages/sample-plugin/sample-plugin_2.0.0.zip?AWSAccessKeyId='.FAKE_AWS_KEY.'&Expires=1790000000&Signature='.FAKE_SIGNATURE.'…'; +} + +describe('UpgraderOutput::redactText', function () { + it('cuts a licensed download URL down to its origin and keeps the ellipsis', function () { + $out = UpgraderOutput::redactText(fakeLicensedDownloadLine()); + + expect($out)->toBe('Downloading update from https://updates.example.com/[redacted]…') + ->not->toContain(FAKE_LICENSE) + ->not->toContain('client.example.org'); + }); + + it('drops a JWT carried in the download path', function () { + $out = UpgraderOutput::redactText(fakeJwtPathDownloadLine()); + + expect($out)->toBe('Downloading update from https://vendor.example.net/[redacted]…') + ->not->toContain('eyJ'); + }); + + it('drops the signature and access key of a signed download URL', function () { + $out = UpgraderOutput::redactText(fakeSignedDownloadLine()); + + expect($out)->not->toContain(FAKE_AWS_KEY) + ->not->toContain('ZmFrZXNpZ25hdHVyZTEyMw') + ->toContain('https://downloads.example.org/[redacted]'); + }); + + it('handles a download URL after the ellipsis entity has been decoded', function () { + $decoded = html_entity_decode(fakeLicensedDownloadLine(), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + expect(UpgraderOutput::redactText($decoded)) + ->toBe('Downloading update from https://updates.example.com/[redacted]…'); + }); + + it('leaves a download line without a URL untouched', function () { + expect(UpgraderOutput::redactText('Downloading update from 1…')) + ->toBe('Downloading update from 1…'); + }); + + it('keeps the path of other URLs but strips credentials, query string and fragment', function () { + $out = UpgraderOutput::redactText('See https://admin:hunter2@example.com/wp-admin/update-core.php?_wpnonce=abc123&key=SECRETVALUE#top for details.'); + + expect($out)->toBe('See https://example.com/wp-admin/update-core.php for details.'); + }); + + it('masks token-like path segments in other URLs but keeps ordinary slugs', function () { + $out = UpgraderOutput::redactText('Fetched https://example.com/dl/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b/plugin-name/readme.txt'); + + expect($out)->toBe('Fetched https://example.com/dl/[redacted]/plugin-name/readme.txt'); + }); + + it('masks secret-named key=value pairs, JWTs and bearer tokens outside URLs', function () { + $out = UpgraderOutput::redactText('retrying with license_key=ABCD1234EFGH&purchase_code=0000-1111 token '.FAKE_JWT.' header Bearer abcdef0123456789'); + + expect($out)->not->toContain('ABCD1234EFGH') + ->not->toContain('0000-1111') + ->not->toContain('eyJ') + ->not->toContain('abcdef0123456789') + ->toContain('license_key=[redacted]') + ->toContain('purchase_code=[redacted]') + ->toContain('Bearer [redacted]'); + }); + + it('leaves ordinary upgrader lines alone', function () { + foreach ([ + 'Unpacking the update…', + 'Could not copy file. sample-plugin/includes/class-admin.php', + 'Error: Could not copy file.', + 'Plugin updated successfully.', + 'The package could not be installed. PCLZIP_ERR_BAD_FORMAT (-10) : Unable to find End of Central Dir Record signature', + ] as $line) { + expect(UpgraderOutput::redactText($line))->toBe($line); + } + }); +}); + +describe('UpgraderOutput::redact', function () { + it('redacts every string in a nested update result, keeping keys and non-strings', function () { + $result = [ + 'ok' => true, + 'slug' => 'sample-plugin/sample-plugin.php', + 'before_version' => '1.0.0', + 'elapsed_ms' => 1200, + 'messages' => [fakeLicensedDownloadLine(), 'Unpacking the update…'], + 'nested' => ['url' => 'https://example.com/hook?token=SECRETVALUE'], + ]; + + $out = UpgraderOutput::redact($result); + + expect($out['ok'])->toBeTrue() + ->and($out['elapsed_ms'])->toBe(1200) + ->and($out['slug'])->toBe('sample-plugin/sample-plugin.php') + ->and($out['messages'][0])->toBe('Downloading update from https://updates.example.com/[redacted]…') + ->and($out['messages'][1])->toBe('Unpacking the update…') + ->and($out['nested']['url'])->toBe('https://example.com/hook') + ->and(json_encode($out))->not->toContain(FAKE_LICENSE) + ->and(json_encode($out))->not->toContain('SECRETVALUE'); + }); + + it('passes null through', function () { + expect(UpgraderOutput::redact(null))->toBeNull(); + }); +}); + +describe('UpgraderOutput::errorLines', function () { + it('returns the last Error: line and the detail line before it, decoded', function () { + $lines = UpgraderOutput::errorLines([ + fakeSignedDownloadLine(), + 'Unpacking the update…', + 'Could not copy file. sample-plugin/includes/class-admin.php', + 'Error: Could not copy file.', + ]); + + expect($lines)->toBe([ + 'Could not copy file. sample-plugin/includes/class-admin.php', + 'Error: Could not copy file.', + ]); + }); + + it('never returns the download line, even when it directly precedes the error', function () { + $lines = UpgraderOutput::errorLines([ + fakeLicensedDownloadLine(), + 'Error: Download failed.', + ]); + + expect($lines)->toBe(['Error: Download failed.']) + ->and(implode("\n", $lines))->not->toContain(FAKE_LICENSE); + }); + + it('picks the last Error: line when there are several', function () { + $lines = UpgraderOutput::errorLines([ + 'Error: First problem.', + 'Retrying…', + 'Update package not available.', + 'Error: Update package not available.', + ]); + + expect($lines)->toBe(['Update package not available.', 'Error: Update package not available.']); + }); + + it('returns nothing when the upgrader logged no error', function () { + expect(UpgraderOutput::errorLines(['Plugin is already up to date.']))->toBe([]) + ->and(UpgraderOutput::errorLines([]))->toBe([]) + ->and(UpgraderOutput::errorLines(null))->toBe([]); + }); + + it('skips non-string entries', function () { + expect(UpgraderOutput::errorLines([['nested'], 'Download failed. Forbidden', 'Error: Download failed.'])) + ->toBe(['Download failed. Forbidden', 'Error: Download failed.']); + }); +}); + +describe('UpgraderOutput::classify', function () { + it('maps upgrader errors to reason codes', function (array $messages, string $expected) { + expect(UpgraderOutput::classify(UpgraderOutput::errorLines($messages)))->toBe($expected); + })->with([ + 'copy failed' => [[ + fakeSignedDownloadLine(), + 'Unpacking the update…', + 'Could not copy file. sample-plugin/includes/class-admin.php', + 'Error: Could not copy file.', + ], UpgraderOutput::REASON_FS_PERMISSION], + 'upgrade dir not creatable' => [[ + fakeSignedDownloadLine(), + 'Unpacking the update…', + 'Could not create directory. /var/www/html/wp-content/upgrade/sample-plugin_2.0.0-AbC12d-x9Yz', + 'Error: Could not create directory.', + ], UpgraderOutput::REASON_FS_PERMISSION], + 'licence refused' => [[ + fakeJwtPathDownloadLine(), + 'Download failed. Unauthorized', + 'Error: Download failed.', + ], UpgraderOutput::REASON_DOWNLOAD_UNAUTHORIZED], + 'forbidden' => [[ + fakeLicensedDownloadLine(), + 'Download failed. Forbidden', + 'Error: Download failed.', + ], UpgraderOutput::REASON_DOWNLOAD_UNAUTHORIZED], + 'not a zip' => [[ + fakeLicensedDownloadLine(), + 'Unpacking the update…', + 'The package could not be installed. PCLZIP_ERR_BAD_FORMAT (-10) : Unable to find End of Central Dir Record signature', + 'Error: The package could not be installed.', + ], UpgraderOutput::REASON_BAD_PACKAGE], + 'no download URL' => [[ + 'Downloading update from 1…', + 'Download failed. A valid URL was not provided.', + 'Error: Download failed.', + ], UpgraderOutput::REASON_NO_PACKAGE], + 'no package' => [[ + 'Update package not available.', + 'Error: Update package not available.', + ], UpgraderOutput::REASON_NO_PACKAGE], + 'anything else' => [[ + fakeSignedDownloadLine(), + 'Download failed. cURL error 28: Operation timed out after 30001 milliseconds', + 'Error: Download failed.', + ], UpgraderOutput::REASON_UPGRADER_ERROR], + ]); +});