From 8d3fc20d8775ff0604e40a1e350048261e83b57a Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 18 Aug 2026 00:03:37 -0400 Subject: [PATCH 1/3] Let sites check in from CLI, scheduler, and front-end requests. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outpost currently only hears from the Control Panel. Checking in after responses, on an hourly schedule, and after artisan/please runs gives us a heartbeat for sites that never open the CP — the foundation for better site tooling. Co-authored-by: Cursor --- src/Http/Controllers/PhoneHomeController.php | 6 +- src/Http/Middleware/CP/ContactOutpost.php | 23 --- src/Http/Middleware/PingOutpost.php | 31 ++++ src/Licensing/Outpost.php | 23 ++- src/Licensing/Radio.php | 141 ++++++++++++++++++ .../PingOutpostOnCommandFinished.php | 22 +++ src/Providers/AppServiceProvider.php | 11 +- src/Providers/CpServiceProvider.php | 1 - src/Providers/EventServiceProvider.php | 3 + tests/Http/Middleware/PingOutpostTest.php | 66 ++++++++ tests/Licensing/OutpostScheduleTest.php | 20 +++ tests/Licensing/OutpostTest.php | 24 +++ tests/Licensing/RadioTest.php | 126 ++++++++++++++++ .../PingOutpostOnCommandFinishedTest.php | 39 +++++ 14 files changed, 507 insertions(+), 29 deletions(-) delete mode 100644 src/Http/Middleware/CP/ContactOutpost.php create mode 100644 src/Http/Middleware/PingOutpost.php create mode 100644 src/Licensing/Radio.php create mode 100644 src/Listeners/PingOutpostOnCommandFinished.php create mode 100644 tests/Http/Middleware/PingOutpostTest.php create mode 100644 tests/Licensing/OutpostScheduleTest.php create mode 100644 tests/Licensing/RadioTest.php create mode 100644 tests/Listeners/PingOutpostOnCommandFinishedTest.php diff --git a/src/Http/Controllers/PhoneHomeController.php b/src/Http/Controllers/PhoneHomeController.php index 450dd112dd7..2f22f1f9178 100644 --- a/src/Http/Controllers/PhoneHomeController.php +++ b/src/Http/Controllers/PhoneHomeController.php @@ -4,17 +4,17 @@ use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Config; -use Statamic\Licensing\Outpost; +use Statamic\Licensing\Radio; class PhoneHomeController { - public function __invoke(Outpost $outpost, $token) + public function __invoke(Radio $radio, $token) { if (! password_verify(Config::getLicenseKey(), base64_decode($token))) { throw new NotFoundHttpException; } - $outpost->radio(); + $radio->ping(); return response()->json(['success' => true]); } diff --git a/src/Http/Middleware/CP/ContactOutpost.php b/src/Http/Middleware/CP/ContactOutpost.php deleted file mode 100644 index 33c7d1f4f71..00000000000 --- a/src/Http/Middleware/CP/ContactOutpost.php +++ /dev/null @@ -1,23 +0,0 @@ -outpost = $outpost; - } - - public function handle($request, Closure $next) - { - $this->outpost->radio(); - - return $next($request); - } -} diff --git a/src/Http/Middleware/PingOutpost.php b/src/Http/Middleware/PingOutpost.php new file mode 100644 index 00000000000..198b4b9f1e1 --- /dev/null +++ b/src/Http/Middleware/PingOutpost.php @@ -0,0 +1,31 @@ +radio->shouldPingDuringRequest($request)) { + $this->radio->ping(); + } + + return $next($request); + } + + public function terminate(Request $request, Response $response): void + { + if ($this->radio->shouldPingAfterResponse($request)) { + $this->radio->ping(); + } + } +} diff --git a/src/Licensing/Outpost.php b/src/Licensing/Outpost.php index 4f536cf1377..7c28bc57a5f 100644 --- a/src/Licensing/Outpost.php +++ b/src/Licensing/Outpost.php @@ -115,7 +115,7 @@ public function payload() { return [ 'key' => config('statamic.system.license_key'), - 'host' => request()->getHost(), + 'host' => $this->host(), 'ip' => request()->server('SERVER_ADDR'), 'port' => request()->server('SERVER_PORT'), 'statamic_version' => Statamic::version(), @@ -126,6 +126,27 @@ public function payload() ]; } + private function host(): ?string + { + return static::resolveHost( + request()->getHost(), + config('app.url'), + app()->runningInConsole(), + app()->runningUnitTests(), + ); + } + + public static function resolveHost(?string $requestHost, ?string $appUrl, bool $inConsole, bool $inTests): ?string + { + if ($inConsole && ! $inTests) { + $host = parse_url((string) $appUrl, PHP_URL_HOST); + + return $host ?: $requestHost; + } + + return $requestHost; + } + private function packagePayload() { return Facades\Addon::all()->mapWithKeys(function ($addon) { diff --git a/src/Licensing/Radio.php b/src/Licensing/Radio.php new file mode 100644 index 00000000000..a5b80f16a03 --- /dev/null +++ b/src/Licensing/Radio.php @@ -0,0 +1,141 @@ +outpost->radio(); + } catch (Throwable $e) { + Log::debug('Error contacting Outpost: '.$e->getMessage()); + } + } + + public function shouldPingRequest(Request $request): bool + { + if ($request->isLivePreview()) { + return false; + } + + return ! $this->isGlideRequest($request); + } + + public function shouldPingDuringRequest(Request $request): bool + { + return $this->isCpRequest($request) && $this->shouldPingRequest($request); + } + + public function shouldPingAfterResponse(Request $request): bool + { + if (app()->runningUnitTests()) { + return false; + } + + return ! $this->isCpRequest($request) && $this->shouldPingRequest($request); + } + + public function shouldPingCommand(?string $command): bool + { + if (app()->runningUnitTests() || $this->runningInCi()) { + return false; + } + + return ! $this->isCommandIgnored($command); + } + + public function isCommandIgnored(?string $command): bool + { + if (! $command) { + return true; + } + + if (in_array($command, $this->ignoredCommands(), true)) { + return true; + } + + foreach ($this->ignoredCommandPrefixes() as $prefix) { + if ($command === $prefix || str_starts_with($command, $prefix.':')) { + return true; + } + } + + return false; + } + + private function isCpRequest(Request $request): bool + { + if (! config('statamic.cp.enabled')) { + return false; + } + + $cp = config('statamic.cp.route'); + $path = $request->path(); + + return $path === $cp + || Str::startsWith($path, Str::finish($cp, '/')); + } + + private function isGlideRequest(Request $request): bool + { + $route = trim((string) Glide::route(), '/'); + + if ($route === '') { + return false; + } + + return $request->is($route, $route.'/*', '*/'.$route, '*/'.$route.'/*'); + } + + private function runningInCi(): bool + { + return filter_var($_SERVER['CI'] ?? $_ENV['CI'] ?? getenv('CI'), FILTER_VALIDATE_BOOLEAN); + } + + /** + * @return list + */ + private function ignoredCommandPrefixes(): array + { + return [ + 'horizon', + 'nightwatch', + 'octane', + 'pail', + 'pulse', + 'queue', + 'reverb', + 'schedule', + ]; + } + + /** + * @return list + */ + private function ignoredCommands(): array + { + return [ + 'completion', + 'docs', + 'dump-server', + 'help', + 'inspire', + 'list', + 'pest', + 'serve', + 'test', + 'tinker', + ]; + } +} diff --git a/src/Listeners/PingOutpostOnCommandFinished.php b/src/Listeners/PingOutpostOnCommandFinished.php new file mode 100644 index 00000000000..6026fa6f5f8 --- /dev/null +++ b/src/Listeners/PingOutpostOnCommandFinished.php @@ -0,0 +1,22 @@ +radio->shouldPingCommand($event->command)) { + return; + } + + $this->radio->ping(); + } +} diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index b30e1d23b6a..507d81d19cc 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -20,7 +20,9 @@ use Statamic\Facades\Token; use Statamic\Facades\User; use Statamic\Fields\FieldsetRecursionStack; +use Statamic\Http\Middleware\PingOutpost; use Statamic\Jobs\HandleEntrySchedule; +use Statamic\Licensing\Radio; use Statamic\Notifications\ElevatedSessionVerificationCode; use Statamic\Sites\Sites; use Statamic\Stache\Query\RevisionQueryBuilder; @@ -51,7 +53,8 @@ public function boot() ->pushMiddleware(\Statamic\Http\Middleware\PoweredByHeader::class) ->pushMiddleware(\Statamic\Http\Middleware\CheckComposerJsonScripts::class) ->pushMiddleware(\Statamic\Http\Middleware\CheckMultisite::class) - ->pushMiddleware(\Statamic\Http\Middleware\StopImpersonating::class); + ->pushMiddleware(\Statamic\Http\Middleware\StopImpersonating::class) + ->pushMiddleware(PingOutpost::class); $this->loadViewsFrom("{$this->root}/resources/views", 'statamic'); @@ -140,6 +143,12 @@ public function boot() if (config('statamic.system.handle_scheduled_entries')) { $this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute(); } + + $this->app->make(Schedule::class) + ->call(fn () => app(Radio::class)->ping()) + ->hourly() + ->name('statamic-outpost') + ->withoutOverlapping(); } public function register() diff --git a/src/Providers/CpServiceProvider.php b/src/Providers/CpServiceProvider.php index b640613b146..037099a55ec 100644 --- a/src/Providers/CpServiceProvider.php +++ b/src/Providers/CpServiceProvider.php @@ -81,7 +81,6 @@ protected function registerMiddlewareGroups() \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class, \Statamic\Http\Middleware\CP\HandleInertiaRequests::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, - \Statamic\Http\Middleware\CP\ContactOutpost::class, \Statamic\Http\Middleware\CP\AuthGuard::class, \Statamic\Http\Middleware\CP\AddToasts::class, \Statamic\Http\Middleware\CP\TrimStrings::class, diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index 0c8fa5b6a91..6117a48812f 100755 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -29,6 +29,9 @@ class EventServiceProvider extends ServiceProvider \Illuminate\Foundation\Http\Events\RequestHandled::class => [ \Statamic\Listeners\ClearState::class, ], + \Illuminate\Console\Events\CommandFinished::class => [ + \Statamic\Listeners\PingOutpostOnCommandFinished::class, + ], ]; protected $subscribe = [ diff --git a/tests/Http/Middleware/PingOutpostTest.php b/tests/Http/Middleware/PingOutpostTest.php new file mode 100644 index 00000000000..c95f494b67c --- /dev/null +++ b/tests/Http/Middleware/PingOutpostTest.php @@ -0,0 +1,66 @@ +assertTrue( + $this->app->make(\Illuminate\Contracts\Http\Kernel::class)->hasMiddleware(PingOutpost::class) + ); + } + + #[Test] + public function it_pings_during_cp_requests() + { + $request = Request::create('/cp/dashboard'); + $radio = $this->mock(Radio::class); + $radio->shouldReceive('shouldPingDuringRequest')->once()->with($request)->andReturnTrue(); + $radio->shouldReceive('ping')->once(); + $radio->shouldReceive('shouldPingAfterResponse')->once()->with($request)->andReturnFalse(); + + $middleware = new PingOutpost($radio); + $response = $middleware->handle($request, fn () => new Response); + + $middleware->terminate($request, $response); + } + + #[Test] + public function it_pings_after_front_end_responses() + { + $request = Request::create('/about'); + $radio = $this->mock(Radio::class); + $radio->shouldReceive('shouldPingDuringRequest')->once()->with($request)->andReturnFalse(); + $radio->shouldReceive('shouldPingAfterResponse')->once()->with($request)->andReturnTrue(); + $radio->shouldReceive('ping')->once(); + + $middleware = new PingOutpost($radio); + $response = $middleware->handle($request, fn () => new Response); + + $middleware->terminate($request, $response); + } + + #[Test] + public function it_does_not_ping_when_radio_declines() + { + $request = Request::create('/img/foo.jpg'); + $radio = $this->mock(Radio::class); + $radio->shouldReceive('shouldPingDuringRequest')->once()->with($request)->andReturnFalse(); + $radio->shouldReceive('shouldPingAfterResponse')->once()->with($request)->andReturnFalse(); + $radio->shouldReceive('ping')->never(); + + $middleware = new PingOutpost($radio); + $response = $middleware->handle($request, fn () => new Response); + + $middleware->terminate($request, $response); + } +} diff --git a/tests/Licensing/OutpostScheduleTest.php b/tests/Licensing/OutpostScheduleTest.php new file mode 100644 index 00000000000..7719699737a --- /dev/null +++ b/tests/Licensing/OutpostScheduleTest.php @@ -0,0 +1,20 @@ +app->make(Schedule::class)->events()) + ->first(fn ($event) => $event->description === 'statamic-outpost'); + + $this->assertNotNull($event); + $this->assertEquals('0 * * * *', $event->expression); + } +} diff --git a/tests/Licensing/OutpostTest.php b/tests/Licensing/OutpostTest.php index fd1f2d43763..d3acee3ed14 100644 --- a/tests/Licensing/OutpostTest.php +++ b/tests/Licensing/OutpostTest.php @@ -56,6 +56,30 @@ public function it_builds_the_request_payload() ], $this->outpost()->payload()); } + #[Test] + public function it_resolves_the_host_from_the_app_url_in_console() + { + $this->assertEquals( + 'mysite.com', + Outpost::resolveHost('localhost', 'https://mysite.com', inConsole: true, inTests: false) + ); + + $this->assertEquals( + 'localhost', + Outpost::resolveHost('localhost', 'https://mysite.com', inConsole: true, inTests: true) + ); + + $this->assertEquals( + 'localhost', + Outpost::resolveHost('localhost', 'https://mysite.com', inConsole: false, inTests: false) + ); + + $this->assertEquals( + 'localhost', + Outpost::resolveHost('localhost', null, inConsole: true, inTests: false) + ); + } + #[Test] public function it_contacts_the_outpost_and_caches_the_response() { diff --git a/tests/Licensing/RadioTest.php b/tests/Licensing/RadioTest.php new file mode 100644 index 00000000000..65a2fc14daa --- /dev/null +++ b/tests/Licensing/RadioTest.php @@ -0,0 +1,126 @@ +mock(Outpost::class); + $outpost->shouldReceive('radio')->once(); + + (new Radio($outpost))->ping(); + } + + #[Test] + public function it_swallows_outpost_exceptions() + { + $outpost = $this->mock(Outpost::class); + $outpost->shouldReceive('radio')->once()->andThrow(new RuntimeException('nope')); + + (new Radio($outpost))->ping(); + } + + #[Test] + public function it_pings_during_cp_requests() + { + $request = Request::create('/cp/dashboard'); + + $this->assertTrue($this->radio()->shouldPingDuringRequest($request)); + $this->assertFalse($this->radio()->shouldPingAfterResponse($request)); + } + + #[Test] + public function it_does_not_ping_after_front_end_responses_during_tests() + { + $request = Request::create('/about'); + + $this->assertFalse($this->radio()->shouldPingDuringRequest($request)); + $this->assertFalse($this->radio()->shouldPingAfterResponse($request)); + $this->assertTrue($this->radio()->shouldPingRequest($request)); + } + + #[Test] + public function it_skips_glide_requests() + { + $request = Request::create('/img/foo.jpg'); + + $this->assertFalse($this->radio()->shouldPingRequest($request)); + } + + #[Test] + public function it_skips_site_prefixed_glide_requests() + { + $request = Request::create('/fr/img/foo.jpg'); + + $this->assertFalse($this->radio()->shouldPingRequest($request)); + } + + #[Test] + public function it_does_not_ping_commands_during_tests() + { + $this->assertFalse($this->radio()->shouldPingCommand('statamic:stache:clear')); + $this->assertFalse($this->radio()->shouldPingCommand('migrate')); + } + + #[Test] + #[DataProvider('ignoredCommandsProvider')] + public function it_ignores_noisy_commands(?string $command) + { + $this->assertTrue($this->radio()->isCommandIgnored($command)); + } + + public static function ignoredCommandsProvider(): array + { + return [ + 'null' => [null], + 'empty' => [''], + 'list' => ['list'], + 'help' => ['help'], + 'tinker' => ['tinker'], + 'serve' => ['serve'], + 'schedule:run' => ['schedule:run'], + 'schedule:work' => ['schedule:work'], + 'queue:work' => ['queue:work'], + 'queue:listen' => ['queue:listen'], + 'horizon' => ['horizon'], + 'horizon:work' => ['horizon:work'], + 'octane:start' => ['octane:start'], + 'reverb:start' => ['reverb:start'], + 'test' => ['test'], + 'pest' => ['pest'], + ]; + } + + #[Test] + #[DataProvider('allowedCommandsProvider')] + public function it_does_not_ignore_normal_commands(string $command) + { + $this->assertFalse($this->radio()->isCommandIgnored($command)); + } + + public static function allowedCommandsProvider(): array + { + return [ + 'statamic:stache:clear' => ['statamic:stache:clear'], + 'statamic:install' => ['statamic:install'], + 'migrate' => ['migrate'], + 'statamic:make:user' => ['statamic:make:user'], + 'about' => ['about'], + ]; + } + + private function radio(): Radio + { + return new Radio($this->mock(Outpost::class)); + } +} diff --git a/tests/Listeners/PingOutpostOnCommandFinishedTest.php b/tests/Listeners/PingOutpostOnCommandFinishedTest.php new file mode 100644 index 00000000000..7bcebe867f2 --- /dev/null +++ b/tests/Listeners/PingOutpostOnCommandFinishedTest.php @@ -0,0 +1,39 @@ +mock(Radio::class); + $radio->shouldReceive('shouldPingCommand')->once()->with('statamic:stache:clear')->andReturnTrue(); + $radio->shouldReceive('ping')->once(); + + (new PingOutpostOnCommandFinished($radio))->handle($this->event('statamic:stache:clear')); + } + + #[Test] + public function it_does_not_ping_when_the_command_should_be_skipped() + { + $radio = $this->mock(Radio::class); + $radio->shouldReceive('shouldPingCommand')->once()->with('schedule:run')->andReturnFalse(); + $radio->shouldReceive('ping')->never(); + + (new PingOutpostOnCommandFinished($radio))->handle($this->event('schedule:run')); + } + + private function event(?string $command): CommandFinished + { + return new CommandFinished($command, new ArrayInput([]), new NullOutput, 0); + } +} From cc1f6cb6c037e66ad940b19aeea0ddfefb114447 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 18 Aug 2026 16:33:47 -0400 Subject: [PATCH 2/3] Treat host and port as environment noise so console pings reuse the cached response, and throttle Radio pings with a short-lived marker. Co-authored-by: Cursor --- src/Licensing/Outpost.php | 25 ++----------------- src/Licensing/Radio.php | 31 ++++++++++++++++++++++++ tests/Licensing/OutpostTest.php | 43 +++++++++++++++------------------ tests/Licensing/RadioTest.php | 29 +++++++++++++++++++++- tests/PhoneHomeTest.php | 7 ++++++ 5 files changed, 87 insertions(+), 48 deletions(-) diff --git a/src/Licensing/Outpost.php b/src/Licensing/Outpost.php index 7c28bc57a5f..01e8baec3fc 100644 --- a/src/Licensing/Outpost.php +++ b/src/Licensing/Outpost.php @@ -115,7 +115,7 @@ public function payload() { return [ 'key' => config('statamic.system.license_key'), - 'host' => $this->host(), + 'host' => request()->getHost(), 'ip' => request()->server('SERVER_ADDR'), 'port' => request()->server('SERVER_PORT'), 'statamic_version' => Statamic::version(), @@ -126,27 +126,6 @@ public function payload() ]; } - private function host(): ?string - { - return static::resolveHost( - request()->getHost(), - config('app.url'), - app()->runningInConsole(), - app()->runningUnitTests(), - ); - } - - public static function resolveHost(?string $requestHost, ?string $appUrl, bool $inConsole, bool $inTests): ?string - { - if ($inConsole && ! $inTests) { - $host = parse_url((string) $appUrl, PHP_URL_HOST); - - return $host ?: $requestHost; - } - - return $requestHost; - } - private function packagePayload() { return Facades\Addon::all()->mapWithKeys(function ($addon) { @@ -180,7 +159,7 @@ private function hasCachedResponse() private function payloadHasChanged($previous, $current) { - $exclude = ['ip', 'php_version']; + $exclude = ['host', 'ip', 'port', 'php_version']; return Arr::except($previous, $exclude) !== Arr::except($current, $exclude); } diff --git a/src/Licensing/Radio.php b/src/Licensing/Radio.php index a5b80f16a03..adfdac16110 100644 --- a/src/Licensing/Radio.php +++ b/src/Licensing/Radio.php @@ -2,20 +2,32 @@ namespace Statamic\Licensing; +use Illuminate\Contracts\Cache\Repository; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use InvalidArgumentException; use Statamic\Facades\Glide; use Statamic\Support\Str; use Throwable; class Radio { + const PING_CACHE_KEY = 'statamic.outpost.pinged'; + const PING_INTERVAL = 300; // seconds + public function __construct(private Outpost $outpost) { } public function ping(): void { + if ($this->recentlyPinged()) { + return; + } + + $this->markAsPinged(); + try { $this->outpost->radio(); } catch (Throwable $e) { @@ -23,6 +35,25 @@ public function ping(): void } } + private function recentlyPinged(): bool + { + return $this->cache()->has(self::PING_CACHE_KEY); + } + + private function markAsPinged(): void + { + $this->cache()->put(self::PING_CACHE_KEY, now()->timestamp, self::PING_INTERVAL); + } + + private function cache(): Repository + { + try { + return Cache::store('outpost'); + } catch (InvalidArgumentException $e) { + return Cache::store(); + } + } + public function shouldPingRequest(Request $request): bool { if ($request->isLivePreview()) { diff --git a/tests/Licensing/OutpostTest.php b/tests/Licensing/OutpostTest.php index d3acee3ed14..17a02670a96 100644 --- a/tests/Licensing/OutpostTest.php +++ b/tests/Licensing/OutpostTest.php @@ -56,30 +56,6 @@ public function it_builds_the_request_payload() ], $this->outpost()->payload()); } - #[Test] - public function it_resolves_the_host_from_the_app_url_in_console() - { - $this->assertEquals( - 'mysite.com', - Outpost::resolveHost('localhost', 'https://mysite.com', inConsole: true, inTests: false) - ); - - $this->assertEquals( - 'localhost', - Outpost::resolveHost('localhost', 'https://mysite.com', inConsole: true, inTests: true) - ); - - $this->assertEquals( - 'localhost', - Outpost::resolveHost('localhost', 'https://mysite.com', inConsole: false, inTests: false) - ); - - $this->assertEquals( - 'localhost', - Outpost::resolveHost('localhost', null, inConsole: true, inTests: false) - ); - } - #[Test] public function it_contacts_the_outpost_and_caches_the_response() { @@ -115,6 +91,25 @@ public function the_cached_response_is_used() $this->assertSame($first, $second); } + #[Test] + public function the_cached_response_is_used_when_only_the_environment_has_changed() + { + $outpost = $this->outpostWithJsonResponse(['newer' => 'response']); + + $payload = $outpost->payload(); + $payload['host'] = 'some-other-host.com'; + $payload['ip'] = '9.9.9.9'; + $payload['port'] = null; + $payload['php_version'] = '1.2.3'; + + $this->setCachedResponse($testCachedResponse = [ + 'cached' => 'response', + 'payload' => $payload, + ]); + + $this->assertEquals($testCachedResponse, $outpost->response()); + } + #[Test] public function license_key_file_is_used_when_it_exists() { diff --git a/tests/Licensing/RadioTest.php b/tests/Licensing/RadioTest.php index 65a2fc14daa..c5ff2ca559a 100644 --- a/tests/Licensing/RadioTest.php +++ b/tests/Licensing/RadioTest.php @@ -3,6 +3,8 @@ namespace Tests\Licensing; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use RuntimeException; @@ -12,6 +14,12 @@ class RadioTest extends TestCase { + public function setUp(): void + { + parent::setUp(); + Cache::store('outpost')->flush(); + } + #[Test] public function it_contacts_the_outpost() { @@ -21,13 +29,32 @@ public function it_contacts_the_outpost() (new Radio($outpost))->ping(); } + #[Test] + public function it_throttles_pings() + { + $outpost = $this->mock(Outpost::class); + $outpost->shouldReceive('radio')->twice(); + + $radio = new Radio($outpost); + + $radio->ping(); + $radio->ping(); // Throttled. + + Carbon::setTestNow(now()->addSeconds(Radio::PING_INTERVAL + 1)); + + $radio->ping(); + } + #[Test] public function it_swallows_outpost_exceptions() { $outpost = $this->mock(Outpost::class); $outpost->shouldReceive('radio')->once()->andThrow(new RuntimeException('nope')); - (new Radio($outpost))->ping(); + $radio = new Radio($outpost); + + $radio->ping(); + $radio->ping(); // Still throttled after a failure. } #[Test] diff --git a/tests/PhoneHomeTest.php b/tests/PhoneHomeTest.php index de6e21f14f5..0e685ef619d 100644 --- a/tests/PhoneHomeTest.php +++ b/tests/PhoneHomeTest.php @@ -2,6 +2,7 @@ namespace Tests; +use Illuminate\Support\Facades\Cache; use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -9,6 +10,12 @@ class PhoneHomeTest extends TestCase { + public function setUp(): void + { + parent::setUp(); + Cache::store('outpost')->flush(); + } + #[Test] #[DataProvider('algorithmProvider')] public function it_contacts_the_outpost($algo) From 77f028b8defe8c11123177621b9bc3a939b17708 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 18 Aug 2026 16:56:29 -0400 Subject: [PATCH 3/3] Make the ping throttle an atomic put-if-absent, let phone-home bypass it, and skip during-request pings in unit tests. Co-authored-by: Cursor --- src/Http/Controllers/PhoneHomeController.php | 2 +- src/Licensing/Radio.php | 32 ++++++++++++++------ tests/Licensing/RadioTest.php | 18 +++++++++-- tests/PhoneHomeTest.php | 7 ----- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/Http/Controllers/PhoneHomeController.php b/src/Http/Controllers/PhoneHomeController.php index 2f22f1f9178..f46213fdaed 100644 --- a/src/Http/Controllers/PhoneHomeController.php +++ b/src/Http/Controllers/PhoneHomeController.php @@ -14,7 +14,7 @@ public function __invoke(Radio $radio, $token) throw new NotFoundHttpException; } - $radio->ping(); + $radio->forcePing(); return response()->json(['success' => true]); } diff --git a/src/Licensing/Radio.php b/src/Licensing/Radio.php index adfdac16110..4acc4258a65 100644 --- a/src/Licensing/Radio.php +++ b/src/Licensing/Radio.php @@ -22,12 +22,22 @@ public function __construct(private Outpost $outpost) public function ping(): void { - if ($this->recentlyPinged()) { + if (! $this->markAsPinged()) { return; } - $this->markAsPinged(); + $this->contactOutpost(); + } + public function forcePing(): void + { + $this->cache()->put(self::PING_CACHE_KEY, now()->timestamp, self::PING_INTERVAL); + + $this->contactOutpost(); + } + + private function contactOutpost(): void + { try { $this->outpost->radio(); } catch (Throwable $e) { @@ -35,14 +45,14 @@ public function ping(): void } } - private function recentlyPinged(): bool - { - return $this->cache()->has(self::PING_CACHE_KEY); - } - - private function markAsPinged(): void + /** + * An atomic put-if-absent, so concurrent requests at marker expiry + * cannot herd into Outpost together. Returns false when the + * marker already exists and the ping should be skipped. + */ + private function markAsPinged(): bool { - $this->cache()->put(self::PING_CACHE_KEY, now()->timestamp, self::PING_INTERVAL); + return $this->cache()->add(self::PING_CACHE_KEY, now()->timestamp, self::PING_INTERVAL); } private function cache(): Repository @@ -65,6 +75,10 @@ public function shouldPingRequest(Request $request): bool public function shouldPingDuringRequest(Request $request): bool { + if (app()->runningUnitTests()) { + return false; + } + return $this->isCpRequest($request) && $this->shouldPingRequest($request); } diff --git a/tests/Licensing/RadioTest.php b/tests/Licensing/RadioTest.php index c5ff2ca559a..aeb2b162e4e 100644 --- a/tests/Licensing/RadioTest.php +++ b/tests/Licensing/RadioTest.php @@ -45,6 +45,19 @@ public function it_throttles_pings() $radio->ping(); } + #[Test] + public function force_pings_bypass_the_throttle_but_still_refresh_it() + { + $outpost = $this->mock(Outpost::class); + $outpost->shouldReceive('radio')->twice(); + + $radio = new Radio($outpost); + + $radio->ping(); + $radio->forcePing(); // Bypasses the throttle. + $radio->ping(); // Still throttled. + } + #[Test] public function it_swallows_outpost_exceptions() { @@ -58,12 +71,13 @@ public function it_swallows_outpost_exceptions() } #[Test] - public function it_pings_during_cp_requests() + public function it_does_not_ping_during_cp_requests_during_tests() { $request = Request::create('/cp/dashboard'); - $this->assertTrue($this->radio()->shouldPingDuringRequest($request)); + $this->assertFalse($this->radio()->shouldPingDuringRequest($request)); $this->assertFalse($this->radio()->shouldPingAfterResponse($request)); + $this->assertTrue($this->radio()->shouldPingRequest($request)); } #[Test] diff --git a/tests/PhoneHomeTest.php b/tests/PhoneHomeTest.php index 0e685ef619d..de6e21f14f5 100644 --- a/tests/PhoneHomeTest.php +++ b/tests/PhoneHomeTest.php @@ -2,7 +2,6 @@ namespace Tests; -use Illuminate\Support\Facades\Cache; use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -10,12 +9,6 @@ class PhoneHomeTest extends TestCase { - public function setUp(): void - { - parent::setUp(); - Cache::store('outpost')->flush(); - } - #[Test] #[DataProvider('algorithmProvider')] public function it_contacts_the_outpost($algo)