From c6cffc491d18c4adce09fd55ec249391cecf4f7a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 16:15:20 +0800 Subject: [PATCH 1/3] feat(socket): admin socket settings with signed config for the socket server System administrators can now manage the socket server from the console instead of only through the environment: - system.socket.enabled / mode / token_ttl / origins / publisher_origin settings; a missing or cleared one falls back to the environment (SOCKETCLUSTER_AUTH_ENABLED, _AUTH_MODE, _TOKEN_TTL, _OPTIONS.origins, _ORIGIN), which is the behaviour before this change. - SocketSettings resolves the effective values and their sources; the stored values are cached for 60 seconds and the cache is cleared on save. An unreachable settings table reads as nothing stored. - SocketToken::enabled(), the default token TTL and the websocket publisher's Origin header follow the effective settings. - GET/POST int/v1/settings/socket (AdminRequest): effective settings plus the socket server's signed /stats (2 s timeout, short reason on failure). Switching on without SOCKETCLUSTER_AUTH_KEY is refused with 422 key_missing; saves are recorded in the activity log. - GET int/v1/socket/config, signed with the authorize key and answered while the switch is off, so the socket server can apply the switch, mode and origins at runtime. The auth key stays environment-only and is never stored or returned; only key_configured is reported. --- config/broadcasting.connections.php | 6 + .../Internal/v1/SettingController.php | 53 ++++ src/Http/Controllers/SocketAuthController.php | 23 +- src/Http/Middleware/VerifySocketSignature.php | 17 +- .../SocketCluster/SocketClusterService.php | 58 +++- src/Support/SocketCluster/SocketSettings.php | 203 +++++++++++++ src/Support/SocketCluster/SocketSignature.php | 9 +- src/Support/SocketCluster/SocketToken.php | 20 +- src/routes.php | 6 + .../Support/SocketSettingsFixtures.php | 71 +++++ .../Http/SocketSettingsControllerTest.php | 272 +++++++++++++++++ tests/Unit/RoutesContractTest.php | 21 ++ tests/Unit/Support/SocketSettingsTest.php | 273 ++++++++++++++++++ 13 files changed, 1014 insertions(+), 18 deletions(-) create mode 100644 src/Support/SocketCluster/SocketSettings.php create mode 100644 tests/Fixtures/Support/SocketSettingsFixtures.php create mode 100644 tests/Unit/Http/SocketSettingsControllerTest.php create mode 100644 tests/Unit/Support/SocketSettingsTest.php diff --git a/config/broadcasting.connections.php b/config/broadcasting.connections.php index f124df25..12b24b89 100644 --- a/config/broadcasting.connections.php +++ b/config/broadcasting.connections.php @@ -37,6 +37,12 @@ 'auth_key' => env('SOCKETCLUSTER_AUTH_KEY'), 'publish_url' => env('SOCKETCLUSTER_PUBLISH_URL', 'http://' . env('SOCKETCLUSTER_HOST', 'socket') . ':8001'), 'token_ttl' => (int) env('SOCKETCLUSTER_TOKEN_TTL', 900), + + // Environment fallbacks for the admin socket settings (Configure -> Socket). The + // socket server reads these itself; the API reads them only to report the effective + // values. Blank means "the socket server's own default". + 'auth_mode' => env('SOCKETCLUSTER_AUTH_MODE'), + 'socket_options' => env('SOCKETCLUSTER_OPTIONS'), ], // for apple apn diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index 775ea427..ead184a7 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -20,6 +20,9 @@ use Fleetbase\Services\OAuth\OAuthFlowService; use Fleetbase\Services\SmsService; use Fleetbase\Support\PlatformApi; +use Fleetbase\Support\SocketCluster\SocketClusterService; +use Fleetbase\Support\SocketCluster\SocketSettings; +use Fleetbase\Support\SocketCluster\SocketToken; use Fleetbase\Support\Utils; use Fleetbase\Twilio\Manager as TwilioManager; use Fleetbase\Twilio\Support\Laravel\Facade as TwilioFacade; @@ -1103,6 +1106,56 @@ public function testSocketcluster(AdminRequest $request) ); } + /** + * Socket server settings: the effective values with their sources, and the socket + * server's live status. + * + * The auth key is never part of the response; `settings.key_configured` says whether it is set. + */ + public function getSocketSettings(AdminRequest $request) + { + return response()->json($this->socketSettingsPayload()); + } + + /** + * Save any subset of the socket settings; a null value clears it back to the environment. + * + * Switching socket authentication on needs SOCKETCLUSTER_AUTH_KEY, which can only be set in + * the environment, so it is refused (422 `key_missing`) without one. + */ + public function saveSocketSettings(AdminRequest $request) + { + $validated = $request->validate(SocketSettings::rules()); + + if (($validated['enabled'] ?? null) !== null && filter_var($validated['enabled'], FILTER_VALIDATE_BOOLEAN) && SocketToken::key() === null) { + return response()->error('Socket authentication cannot be switched on until SOCKETCLUSTER_AUTH_KEY is set on the API and the socket server.', 422, ['code' => 'key_missing']); + } + + $change = SocketSettings::store($validated); + + activity('settings') + ->causedBy($request->user()) + ->event('socket_settings_updated') + ->withProperties(['fields' => array_keys(array_intersect_key($validated, array_flip(SocketSettings::FIELDS))), 'old' => $change['before'], 'new' => $change['after']]) + ->log('Socket settings updated'); + + return response()->json($this->socketSettingsPayload()); + } + + /** + * @return array{settings: array, status: ?array, status_error: ?string} + */ + protected function socketSettingsPayload(): array + { + $stats = SocketClusterService::stats(); + + return [ + 'settings' => SocketSettings::effective(), + 'status' => $stats['status'], + 'status_error' => $stats['error'], + ]; + } + /** * OAuth configuration, as an administrator should see it. * diff --git a/src/Http/Controllers/SocketAuthController.php b/src/Http/Controllers/SocketAuthController.php index 82315838..97329b22 100644 --- a/src/Http/Controllers/SocketAuthController.php +++ b/src/Http/Controllers/SocketAuthController.php @@ -8,6 +8,7 @@ use Fleetbase\Support\SocketCluster\ChannelDecision; use Fleetbase\Support\SocketCluster\SocketChannelRegistry; use Fleetbase\Support\SocketCluster\SocketPrincipal; +use Fleetbase\Support\SocketCluster\SocketSettings; use Fleetbase\Support\SocketCluster\SocketToken; use Fleetbase\Support\Utils; use Illuminate\Http\Request; @@ -15,8 +16,8 @@ /** * Socket token minting for console, API and platform clients, and the channel authorize - * endpoint the socket server calls. Every action answers 404 while SOCKETCLUSTER_AUTH_KEY - * is unset, which clients read as "connect anonymously". + * and config endpoints the socket server calls. Every action answers 404 while + * SOCKETCLUSTER_AUTH_KEY is unset, which clients read as "connect anonymously". */ class SocketAuthController extends Controller { @@ -106,6 +107,24 @@ public function authorizeChannel(Request $request) return response()->json($decision->toArray()); } + /** + * GET int/v1/socket/config: the effective socket settings the socket server applies at runtime. + * + * Signed with the authorize key and answered while the switch is off, so the socket + * server learns when an administrator switches socket authentication on. + */ + public function config(Request $request) + { + $settings = SocketSettings::effective(); + + return response()->json([ + 'enabled' => $settings['enabled'], + 'mode' => $settings['mode'], + 'origins' => $settings['origins'], + 'updated_at' => SocketSettings::updatedAt(), + ]); + } + protected static function disabled() { return response()->json(['error' => 'Not Found'], 404); diff --git a/src/Http/Middleware/VerifySocketSignature.php b/src/Http/Middleware/VerifySocketSignature.php index ff7ea394..49dbc307 100644 --- a/src/Http/Middleware/VerifySocketSignature.php +++ b/src/Http/Middleware/VerifySocketSignature.php @@ -9,14 +9,20 @@ /** * Admits only requests the socket server signed with the derived authorize key. * - * Used instead of session or token auth on the channel authorize endpoint, which only the - * socket server calls. + * Used instead of session or token auth on the endpoints only the socket server calls. They + * answer 404 while socket authentication is off; with the `key-only` parameter + * (`VerifySocketSignature:key-only`) a configured key is enough, whatever the switch. */ class VerifySocketSignature { - public function handle(Request $request, \Closure $next) + public const KEY_ONLY = 'key-only'; + + public function handle(Request $request, \Closure $next, ?string $requirement = null) { - if (!SocketToken::enabled()) { + $requireSwitch = $requirement !== self::KEY_ONLY; + $configured = $requireSwitch ? SocketToken::enabled() : SocketToken::key() !== null; + + if (!$configured) { return response()->json(['error' => 'Not Found'], 404); } @@ -24,7 +30,8 @@ public function handle(Request $request, \Closure $next) SocketSignature::AUTHORIZE, $request->header(SocketSignature::HEADER_TIMESTAMP), $request->header(SocketSignature::HEADER_SIGNATURE), - $request->getContent() + $request->getContent(), + $requireSwitch ); if (!$valid) { diff --git a/src/Support/SocketCluster/SocketClusterService.php b/src/Support/SocketCluster/SocketClusterService.php index af83c187..5da6f9e5 100644 --- a/src/Support/SocketCluster/SocketClusterService.php +++ b/src/Support/SocketCluster/SocketClusterService.php @@ -2,6 +2,7 @@ namespace Fleetbase\Support\SocketCluster; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; use WebSocket\Client; @@ -233,6 +234,14 @@ public static function publishesOverHttp(): bool * The socket server's internal publish endpoint. */ public static function publishUrl(): string + { + return static::internalUrl('/publish'); + } + + /** + * A path on the socket server's internal listener (SOCKETCLUSTER_PUBLISH_URL). + */ + public static function internalUrl(string $path): string { $base = config('broadcasting.connections.socketcluster.publish_url'); @@ -240,7 +249,46 @@ public static function publishUrl(): string $base = 'http://' . config('broadcasting.connections.socketcluster.options.host', 'socket') . ':8001'; } - return rtrim($base, '/') . '/publish'; + return rtrim($base, '/') . $path; + } + + /** + * The socket server's counters from its internal `/stats` endpoint, signed with the publish key. + * + * Never throws: when the key is not configured or the server cannot be reached within two + * seconds, `status` is null and `error` gives a short reason. + * + * @return array{status: ?array, error: ?string} + */ + public static function stats(): array + { + if (SocketToken::key() === null) { + return ['status' => null, 'error' => 'SOCKETCLUSTER_AUTH_KEY is not configured.']; + } + + try { + $response = Http::withHeaders(SocketSignature::headers(SocketSignature::PUBLISH, '')) + ->acceptJson() + ->connectTimeout(2) + ->timeout(2) + ->get(static::internalUrl('/stats')); + } catch (ConnectionException $e) { + return ['status' => null, 'error' => 'The socket server could not be reached or did not answer within 2 seconds.']; + } catch (\Throwable $e) { + return ['status' => null, 'error' => 'The socket server status request failed.']; + } + + if (!$response->successful()) { + return ['status' => null, 'error' => 'The socket server answered with HTTP status ' . $response->status() . '.']; + } + + $status = $response->json(); + + if (!is_array($status)) { + return ['status' => null, 'error' => 'The socket server returned an invalid status response.']; + } + + return ['status' => $status, 'error' => null]; } /** @@ -340,11 +388,19 @@ public function getOption(string $key, $default = null) /** * Get the connection options. * + * The handshake Origin is the effective publisher origin (the `system.socket.publisher_origin` + * setting, else SOCKETCLUSTER_ORIGIN); options passed in still take precedence. + * * @param array|string $options connection options or URI */ public function getOptions($options): array { $defaultConnectionOptions = config('broadcasting.connections.socketcluster.options', []); + $publisherOrigin = SocketSettings::effective()['publisher_origin']; + + if ($publisherOrigin !== null) { + $defaultConnectionOptions['headers'] = array_merge((array) ($defaultConnectionOptions['headers'] ?? []), ['Origin' => $publisherOrigin]); + } if (is_string($options)) { $options = parse_url($options); diff --git a/src/Support/SocketCluster/SocketSettings.php b/src/Support/SocketCluster/SocketSettings.php new file mode 100644 index 00000000..b2efcf04 --- /dev/null +++ b/src/Support/SocketCluster/SocketSettings.php @@ -0,0 +1,203 @@ + filter_var(config('broadcasting.connections.socketcluster.auth_enabled', false), FILTER_VALIDATE_BOOLEAN), + 'mode' => static::normalizeMode(config('broadcasting.connections.socketcluster.auth_mode')), + 'token_ttl' => $ttl > 0 ? $ttl : SocketToken::DEFAULT_TTL, + 'origins' => static::normalizeString(is_array($options) ? ($options['origins'] ?? null) : null), + 'publisher_origin' => static::normalizeString(config('broadcasting.connections.socketcluster.options.headers.Origin')), + ]; + } + + /** + * The stored settings, keyed by field, without the `system.socket.` prefix. + * + * Read on every broadcast and token, so it is cached; store() clears it. An unreachable + * settings table or cache (an install in progress, unit tests) reads as nothing stored. + */ + public static function stored(): array + { + try { + $rows = Cache::remember(static::CACHE_KEY, static::CACHE_TTL, function () { + return Setting::where('key', 'like', static::SETTING_PREFIX . '%')->get(['key', 'value'])->pluck('value', 'key')->all(); + }); + } catch (\Throwable $e) { + $rows = []; + } + + $stored = []; + foreach ((array) $rows as $key => $value) { + $stored[substr((string) $key, strlen(static::SETTING_PREFIX))] = $value; + } + + return $stored; + } + + /** + * The effective settings, where each came from, and whether the auth key is configured. + * + * @return array{enabled: bool, mode: ?string, token_ttl: int, origins: ?string, publisher_origin: ?string, key_configured: bool, sources: array} + */ + public static function effective(): array + { + $defaults = static::defaults(); + $stored = static::normalize(static::stored()); + $effective = []; + $sources = []; + + foreach (static::FIELDS as $field) { + $fromSetting = $stored[$field] !== null; + $effective[$field] = $fromSetting ? $stored[$field] : $defaults[$field]; + $sources[$field] = $fromSetting ? 'setting' : 'env'; + } + + $effective['key_configured'] = SocketToken::key() !== null; + $effective['sources'] = $sources; + + return $effective; + } + + /** + * When the stored settings last changed, or null if they never have. + */ + public static function updatedAt(): ?string + { + return static::normalizeString(static::stored()[static::UPDATED_AT] ?? null); + } + + /** + * Apply a change to some fields; a null value clears the field back to the environment. + * + * Fields not in the change are left as they are. Returns the stored values before and + * after the change, normalized, for the activity log. + * + * @return array{before: array, after: array} + */ + public static function store(array $changes): array + { + $before = static::normalize(static::stored()); + $changes = array_intersect_key(static::normalize($changes), array_flip(array_intersect(static::FIELDS, array_keys($changes)))); + + foreach ($changes as $field => $value) { + if ($value === null) { + Setting::where('key', static::SETTING_PREFIX . $field)->delete(); + } else { + Setting::configure(static::SETTING_PREFIX . $field, $value); + } + } + + Setting::configure(static::SETTING_PREFIX . static::UPDATED_AT, Carbon::now()->toIso8601ZuluString()); + static::forget(); + + return ['before' => $before, 'after' => array_merge($before, $changes)]; + } + + /** + * Drop the cached stored settings so the next read sees the database. + */ + public static function forget(): void + { + Cache::forget(static::CACHE_KEY); + } + + /** + * Coerce stored or submitted values to their canonical types; anything invalid becomes null. + */ + public static function normalize(array $values): array + { + $ttl = $values['token_ttl'] ?? null; + + return [ + 'enabled' => is_null($values['enabled'] ?? null) ? null : filter_var($values['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE), + 'mode' => static::normalizeMode($values['mode'] ?? null), + 'token_ttl' => is_numeric($ttl) && (int) $ttl >= self::MIN_TOKEN_TTL && (int) $ttl <= SocketToken::MAX_TTL ? (int) $ttl : null, + 'origins' => static::normalizeString($values['origins'] ?? null), + 'publisher_origin' => static::normalizeOrigin($values['publisher_origin'] ?? null), + ]; + } + + /** + * Validation rules for a save. Every field is optional and nullable (null = use the environment). + */ + public static function rules(): array + { + return [ + 'enabled' => ['sometimes', 'nullable', 'boolean'], + 'mode' => ['sometimes', 'nullable', 'string', 'in:' . implode(',', static::MODES)], + 'token_ttl' => ['sometimes', 'nullable', 'integer', 'min:' . self::MIN_TOKEN_TTL, 'max:' . SocketToken::MAX_TTL], + // SocketCluster's `origins` option: one or more host:port patterns separated by spaces. + 'origins' => ['sometimes', 'nullable', 'string', 'max:1024', 'regex:/^[\x21-\x7E]+( [\x21-\x7E]+)*$/'], + // An origin only: scheme, host and optional port, without a path. + 'publisher_origin' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:/^https?:\/\/[^\/\s?#]+\/?$/i'], + ]; + } + + protected static function normalizeMode($mode): ?string + { + $mode = is_string($mode) ? strtolower(trim($mode)) : null; + + return in_array($mode, static::MODES, true) ? $mode : null; + } + + protected static function normalizeString($value): ?string + { + $value = is_string($value) ? trim($value) : ''; + + return $value === '' ? null : $value; + } + + protected static function normalizeOrigin($value): ?string + { + $value = static::normalizeString($value); + + return $value === null ? null : rtrim($value, '/'); + } +} diff --git a/src/Support/SocketCluster/SocketSignature.php b/src/Support/SocketCluster/SocketSignature.php index ffdae52d..0a147c57 100644 --- a/src/Support/SocketCluster/SocketSignature.php +++ b/src/Support/SocketCluster/SocketSignature.php @@ -61,10 +61,15 @@ public static function headers(string $purpose, string $body): array /** * Whether a request's timestamp and signature are valid for the raw body, compared in constant time. + * + * By default the switch must be on too. With $requireSwitch false only the key is needed, + * for the config endpoint the socket server polls to learn that the switch was turned on. */ - public static function verify(string $purpose, ?string $timestamp, ?string $signature, string $body): bool + public static function verify(string $purpose, ?string $timestamp, ?string $signature, string $body, bool $requireSwitch = true): bool { - if (!SocketToken::enabled() || !is_string($timestamp) || !ctype_digit($timestamp) || !is_string($signature) || $signature === '') { + $configured = $requireSwitch ? SocketToken::enabled() : SocketToken::key() !== null; + + if (!$configured || !is_string($timestamp) || !ctype_digit($timestamp) || !is_string($signature) || $signature === '') { return false; } diff --git a/src/Support/SocketCluster/SocketToken.php b/src/Support/SocketCluster/SocketToken.php index c9115907..a2b4dc83 100644 --- a/src/Support/SocketCluster/SocketToken.php +++ b/src/Support/SocketCluster/SocketToken.php @@ -19,8 +19,9 @@ * Mints and verifies the HS256 tokens realtime clients present to the socket server. * * The signing key is SOCKETCLUSTER_AUTH_KEY, shared with the socket server. The feature is - * on only when SOCKETCLUSTER_AUTH_ENABLED is true and that key is at least 32 bytes long; - * otherwise nothing is minted and broadcasts use the websocket publisher. + * on only when the switch is on (the `system.socket.enabled` setting, else + * SOCKETCLUSTER_AUTH_ENABLED) and that key is at least 32 bytes long; otherwise nothing is + * minted and broadcasts use the websocket publisher. */ class SocketToken { @@ -59,16 +60,21 @@ public static function key(): ?string * Whether realtime channel authentication is switched on. * * The switch is separate from the key so the key can be provisioned (for example on - * the socket server) before every socket client is ready for tokens. + * the socket server) before every socket client is ready for tokens. An administrator's + * setting overrides SOCKETCLUSTER_AUTH_ENABLED. */ public static function switchedOn(): bool { - return filter_var(config('broadcasting.connections.socketcluster.auth_enabled', false), FILTER_VALIDATE_BOOLEAN); + return SocketSettings::effective()['enabled']; } + /** + * Whether socket tokens are minted and checked: a valid key and the switch on. The key is + * checked first so installs without one never read the stored settings. + */ public static function enabled(): bool { - return static::switchedOn() && static::key() !== null; + return static::key() !== null && static::switchedOn(); } /** @@ -204,12 +210,10 @@ public static function base32(string $bytes): string public static function defaultTtl(string $kind): int { - $configured = (int) config('broadcasting.connections.socketcluster.token_ttl', self::DEFAULT_TTL); - return match ($kind) { 'tracking' => self::TRACKING_TTL, 'system' => self::SYSTEM_TTL, - default => $configured > 0 ? $configured : self::DEFAULT_TTL, + default => SocketSettings::effective()['token_ttl'], }; } diff --git a/src/routes.php b/src/routes.php index 4f325624..12d14411 100644 --- a/src/routes.php +++ b/src/routes.php @@ -173,6 +173,10 @@ function ($router) { // never by a session or user token. $router->post('socket/authorize', [Fleetbase\Http\Controllers\SocketAuthController::class, 'authorizeChannel']) ->middleware(Fleetbase\Http\Middleware\VerifySocketSignature::class); + // Also socket server only, and answered while the auth switch is off: it is how + // the socket server learns the effective settings, including the switch. + $router->get('socket/config', [Fleetbase\Http\Controllers\SocketAuthController::class, 'config']) + ->middleware(Fleetbase\Http\Middleware\VerifySocketSignature::class . ':' . Fleetbase\Http\Middleware\VerifySocketSignature::KEY_ONLY); $router->group( ['prefix' => 'two-fa', 'middleware' => [Fleetbase\Http\Middleware\ThrottleRequests::class]], function ($router) { @@ -261,6 +265,8 @@ function ($router, $controller) { $router->post('branding', $controller('saveBrandingSettings')); $router->put('branding', $controller('saveBrandingSettings')); $router->post('test-socket', $controller('testSocketcluster')); + $router->get('socket', $controller('getSocketSettings')); + $router->post('socket', $controller('saveSocketSettings')); $router->get('notification-channels-config', $controller('getNotificationChannelsConfig')); $router->post('notification-channels-config', $controller('saveNotificationChannelsConfig')); $router->post('test-notification-channels-config', $controller('testNotificationChannelsConfig')); diff --git a/tests/Fixtures/Support/SocketSettingsFixtures.php b/tests/Fixtures/Support/SocketSettingsFixtures.php new file mode 100644 index 00000000..47ce400b --- /dev/null +++ b/tests/Fixtures/Support/SocketSettingsFixtures.php @@ -0,0 +1,71 @@ + 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $container = SocketAuthFixtures::container($key, array_merge([ + 'database.default' => 'mysql', + 'database.connections.mysql' => $sqlite, + 'fleetbase.connection.db' => 'mysql', + ], $config)); + + $container->instance('cache', new CacheRepository(new ArrayStore())); + Facade::clearResolvedInstance('cache'); + + $capsule = new Capsule($container); + $capsule->addConnection($sqlite, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $capsule->getConnection('mysql')->getSchemaBuilder()->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + + $validation = new ValidationFactory(new Translator(new ArrayLoader(), 'en')); + Request::macro('validate', function (array $rules) use ($validation) { + return $validation->make($this->all(), $rules)->validate(); + }); + + return $capsule; + } + + /** + * Undo setUp(): the socket auth fixtures and the validate macro. + */ + public static function reset(): void + { + $macros = new \ReflectionProperty(Request::class, 'macros'); + $macros->setAccessible(true); + $macros->setValue(null, array_diff_key($macros->getValue(), ['validate' => true])); + + SocketAuthFixtures::reset(); + } +} diff --git a/tests/Unit/Http/SocketSettingsControllerTest.php b/tests/Unit/Http/SocketSettingsControllerTest.php new file mode 100644 index 00000000..54ef31b3 --- /dev/null +++ b/tests/Unit/Http/SocketSettingsControllerTest.php @@ -0,0 +1,272 @@ +setUserResolver(fn () => SocketAuthFixtures::user(['uuid' => 'admin-1', 'type' => 'admin'])); + + return $request; +} + +function socket_settings_stats_fake(): void +{ + Http::swap(new HttpFactory()); + Http::fake(['*' => Http::response(['connections' => 2, 'config' => ['source' => 'api']], 200)]); +} + +/** + * Every value a response could leak the auth key through: the key and each derived key. + */ +function socket_settings_secrets(): array +{ + return array_merge([SocketAuthFixtures::KEY], array_map( + fn (string $purpose) => hash_hmac('sha256', 'fleetbase-socket:' . $purpose, SocketAuthFixtures::KEY), + [SocketSignature::PUBLISH, SocketSignature::AUTHORIZE, SocketSignature::TRACKING] + )); +} + +function socket_settings_expect_no_secrets(JsonResponse $response): void +{ + foreach (socket_settings_secrets() as $secret) { + expect($response->getContent())->not->toContain($secret); + } +} + +function socket_settings_config_request(?string $timestamp, ?string $signature): Request +{ + return Request::create('/int/v1/socket/config', 'GET', [], [], [], array_filter([ + 'HTTP_X_FLEETBASE_TIMESTAMP' => $timestamp, + 'HTTP_X_FLEETBASE_SIGNATURE' => $signature, + ])); +} + +afterEach(function () { + SocketSettingsFixtures::reset(); +}); + +test('the socket settings endpoints are for system administrators only', function () { + foreach (['getSocketSettings', 'saveSocketSettings'] as $method) { + $parameter = (new ReflectionMethod(SettingController::class, $method))->getParameters()[0]; + + expect($parameter->getType()->getName())->toBe(AdminRequest::class); + } + + $admin = AdminRequest::create('/int/v1/settings/socket'); + $admin->setUserResolver(fn () => SocketAuthFixtures::user(['type' => 'admin'])); + $user = AdminRequest::create('/int/v1/settings/socket'); + $user->setUserResolver(fn () => SocketAuthFixtures::user(['type' => 'user'])); + + expect($admin->authorize())->toBeTrue() + ->and($user->authorize())->toBeFalse() + ->and((new AdminRequest())->authorize())->toBeFalse(); +}); + +test('get socket settings returns the effective settings and the socket server status', function () { + SocketSettingsFixtures::setUp(); + socket_settings_stats_fake(); + + $response = (new SettingController())->getSocketSettings(socket_settings_admin_request()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true))->toBe([ + 'settings' => [ + 'enabled' => true, + 'mode' => null, + 'token_ttl' => 900, + 'origins' => null, + 'publisher_origin' => null, + 'key_configured' => true, + 'sources' => [ + 'enabled' => 'env', + 'mode' => 'env', + 'token_ttl' => 'env', + 'origins' => 'env', + 'publisher_origin' => 'env', + ], + ], + 'status' => ['connections' => 2, 'config' => ['source' => 'api']], + 'status_error' => null, + ]); + + socket_settings_expect_no_secrets($response); +}); + +test('get socket settings reports an unreachable socket server and a missing key', function () { + SocketSettingsFixtures::setUp(); + Http::fake(['*' => Http::response('bad gateway', 502)]); + + $unreachable = (new SettingController())->getSocketSettings(socket_settings_admin_request())->getData(true); + + config(['broadcasting.connections.socketcluster.auth_key' => null]); + + $keyless = (new SettingController())->getSocketSettings(socket_settings_admin_request())->getData(true); + + expect($unreachable['status'])->toBeNull() + ->and($unreachable['status_error'])->toBe('The socket server answered with HTTP status 502.') + ->and($keyless['settings']['key_configured'])->toBeFalse() + ->and($keyless['status'])->toBeNull() + ->and($keyless['status_error'])->toBe('SOCKETCLUSTER_AUTH_KEY is not configured.'); +}); + +test('save socket settings stores a subset, clears nulls, logs the change and answers like get', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, ['broadcasting.connections.socketcluster.auth_enabled' => false]); + socket_settings_stats_fake(); + SocketSettings::store(['origins' => 'http://localhost:*']); + + $response = (new SettingController())->saveSocketSettings(socket_settings_admin_request('POST', [ + 'enabled' => true, + 'mode' => 'log', + 'token_ttl' => 300, + 'origins' => null, + 'publisher_origin' => 'https://console.example.test/', + ])); + $payload = $response->getData(true); + + expect($response->getStatusCode())->toBe(200) + ->and(array_keys($payload))->toBe(['settings', 'status', 'status_error']) + ->and($payload['settings']['enabled'])->toBeTrue() + ->and($payload['settings']['mode'])->toBe('log') + ->and($payload['settings']['token_ttl'])->toBe(300) + ->and($payload['settings']['origins'])->toBeNull() + ->and($payload['settings']['publisher_origin'])->toBe('https://console.example.test') + ->and($payload['settings']['sources'])->toBe([ + 'enabled' => 'setting', + 'mode' => 'setting', + 'token_ttl' => 'setting', + 'origins' => 'env', + 'publisher_origin' => 'setting', + ]) + ->and($payload['status'])->toBe(['connections' => 2, 'config' => ['source' => 'api']]) + ->and(Setting::where('key', 'system.socket.origins')->exists())->toBeFalse() + ->and(TestActivityLogger::$logged)->toBe([[ + 'log' => 'settings', + 'description' => 'Socket settings updated', + 'causer' => 'admin-1', + 'event' => 'socket_settings_updated', + 'properties' => [ + 'fields' => ['enabled', 'mode', 'token_ttl', 'origins', 'publisher_origin'], + 'old' => ['enabled' => null, 'mode' => null, 'token_ttl' => null, 'origins' => 'http://localhost:*', 'publisher_origin' => null], + 'new' => ['enabled' => true, 'mode' => 'log', 'token_ttl' => 300, 'origins' => null, 'publisher_origin' => 'https://console.example.test'], + ], + ]]); + + socket_settings_expect_no_secrets($response); + + // Fields left out of a save keep their stored values. + $partial = (new SettingController())->saveSocketSettings(socket_settings_admin_request('POST', ['enabled' => null]))->getData(true); + + expect($partial['settings']['enabled'])->toBeFalse() + ->and($partial['settings']['sources']['enabled'])->toBe('env') + ->and($partial['settings']['mode'])->toBe('log') + ->and(TestActivityLogger::$logged[1]['properties']['fields'])->toBe(['enabled']); +}); + +test('socket authentication cannot be switched on without a configured key', function () { + SocketSettingsFixtures::setUp(null); + socket_settings_stats_fake(); + + $refused = (new SettingController())->saveSocketSettings(socket_settings_admin_request('POST', ['enabled' => true, 'mode' => 'enforce'])); + + expect($refused->getStatusCode())->toBe(422) + ->and($refused->getData(true)['code'])->toBe('key_missing') + ->and(Setting::query()->count())->toBe(0) + ->and(TestActivityLogger::$logged)->toBe([]); + + $switchedOff = (new SettingController())->saveSocketSettings(socket_settings_admin_request('POST', ['enabled' => false, 'mode' => 'off'])); + + expect($switchedOff->getStatusCode())->toBe(200) + ->and($switchedOff->getData(true)['settings']['enabled'])->toBeFalse() + ->and($switchedOff->getData(true)['settings']['mode'])->toBe('off'); +}); + +test('save socket settings rejects invalid values', function (array $input) { + SocketSettingsFixtures::setUp(); + + expect(fn () => (new SettingController())->saveSocketSettings(socket_settings_admin_request('POST', $input))) + ->toThrow(ValidationException::class); + + expect(Setting::query()->count())->toBe(0); +})->with([ + 'non boolean switch' => [['enabled' => 'maybe']], + 'unknown mode' => [['mode' => 'strict']], + 'ttl below the minimum' => [['token_ttl' => 59]], + 'ttl above the maximum' => [['token_ttl' => 3601]], + 'non numeric ttl' => [['token_ttl' => 'soon']], + 'origins with a tab' => [['origins' => "https://a.example.test:*\thttps://b.example.test:*"]], + 'origins as a list' => [['origins' => ['https://a.example.test:*']]], + 'origin without a scheme' => [['publisher_origin' => 'console.example.test']], + 'origin with a path' => [['publisher_origin' => 'https://console.example.test/app']], + 'origin with another type' => [['publisher_origin' => 'ftp://console.example.test']], +]); + +test('the signed config endpoint answers the effective settings while the switch is off', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, ['broadcasting.connections.socketcluster.auth_enabled' => false]); + + $middleware = new VerifySocketSignature(); + $controller = new SocketAuthController(); + $now = (string) SocketAuthFixtures::NOW; + $stale = (string) (SocketAuthFixtures::NOW - 120); + $next = fn (Request $request) => $controller->config($request); + $send = fn (?string $timestamp, ?string $signature) => $middleware->handle(socket_settings_config_request($timestamp, $signature), $next, VerifySocketSignature::KEY_ONLY); + + $before = $send($now, SocketSignature::sign(SocketSignature::AUTHORIZE, $now, '')); + + expect($before->getStatusCode())->toBe(200) + ->and($before->getData(true))->toBe(['enabled' => false, 'mode' => null, 'origins' => null, 'updated_at' => null]); + + SocketSettings::store(['enabled' => true, 'mode' => 'log', 'origins' => 'https://console.example.test:*', 'token_ttl' => 300]); + + $after = $send($now, SocketSignature::sign(SocketSignature::AUTHORIZE, $now, '')); + + expect($after->getData(true))->toBe([ + 'enabled' => true, + 'mode' => 'log', + 'origins' => 'https://console.example.test:*', + 'updated_at' => Carbon::createFromTimestampUTC(SocketAuthFixtures::NOW)->toIso8601ZuluString(), + ]); + + socket_settings_expect_no_secrets($after); + + SocketSettings::store(['enabled' => false]); + + $rejected = [ + $send($stale, SocketSignature::sign(SocketSignature::AUTHORIZE, $stale, '')), + $send($now, SocketSignature::sign(SocketSignature::PUBLISH, $now, '')), + $send($now, str_repeat('0', 64)), + $send(null, null), + ]; + + foreach ($rejected as $response) { + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toBe(['error' => 'invalid_signature']); + } + + // The authorize endpoint's default still needs the switch on. + $authorize = $middleware->handle(socket_settings_config_request($now, SocketSignature::sign(SocketSignature::AUTHORIZE, $now, '')), $next); + + expect($authorize->getStatusCode())->toBe(404) + ->and(SocketSignature::verify(SocketSignature::AUTHORIZE, $now, SocketSignature::sign(SocketSignature::AUTHORIZE, $now, ''), ''))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::AUTHORIZE, $now, SocketSignature::sign(SocketSignature::AUTHORIZE, $now, ''), '', false))->toBeTrue(); + + // Without a key there is nothing to verify the request with. + config(['broadcasting.connections.socketcluster.auth_key' => null]); + + expect($send($now, str_repeat('0', 64))->getStatusCode())->toBe(404) + ->and(SocketSignature::verify(SocketSignature::AUTHORIZE, $now, str_repeat('0', 64), '', false))->toBeFalse(); +}); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index 0d51d40c..cefee5d5 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -387,6 +387,27 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->and($authorize['middleware'])->toBe([Fleetbase\Http\Middleware\VerifySocketSignature::class]); }); + test('route file exposes socket settings to administrators and signed config to the socket server', function () { + $routes = routes_contract_rows(routes_contract_router()); + $settings = 'Fleetbase\\Http\\Controllers\\Internal\\v1\\SettingController'; + + foreach ([['GET', 'getSocketSettings'], ['POST', 'saveSocketSettings']] as [$method, $action]) { + $route = routes_contract_find($routes, $method, 'int/v1/settings/socket'); + + expect($route)->not->toBeNull() + ->and($route['action'])->toBe($settings . '@' . $action) + ->and($route['middleware'])->toContain('fleetbase.protected'); + } + + $config = routes_contract_find($routes, 'GET', 'int/v1/socket/config'); + + expect(routes_contract_index($routes, 'GET', 'int/v1/settings/socket')) + ->toBeLessThan(routes_contract_index($routes, 'GET', 'int/v1/settings/{setting}')) + ->and($config['action'])->toBe('Fleetbase\\Http\\Controllers\\SocketAuthController@config') + // Signature only, and answered while the switch is off. + ->and($config['middleware'])->toBe([Fleetbase\Http\Middleware\VerifySocketSignature::class . ':key-only']); + }); + test('route file exposes api rate limit administration as protected routes', function () { $routes = routes_contract_rows(routes_contract_router()); $controller = 'Fleetbase\\Http\\Controllers\\Internal\\v1\\RateLimitController'; diff --git a/tests/Unit/Support/SocketSettingsTest.php b/tests/Unit/Support/SocketSettingsTest.php new file mode 100644 index 00000000..83c272dd --- /dev/null +++ b/tests/Unit/Support/SocketSettingsTest.php @@ -0,0 +1,273 @@ + false, + 'broadcasting.connections.socketcluster.auth_mode' => ' LOG ', + 'broadcasting.connections.socketcluster.token_ttl' => 600, + 'broadcasting.connections.socketcluster.socket_options' => '{"origins":"https://console.example.test:*"}', + 'broadcasting.connections.socketcluster.options' => [ + 'secure' => false, + 'host' => 'socket.test', + 'port' => 8000, + 'path' => '/socketcluster/', + 'query' => [], + 'headers' => ['Origin' => 'https://console.example.test'], + ], + ]; +} + +function socket_settings_client_headers(SocketClusterService $service): array +{ + return (fn () => $this->options)->call($service->getClient())['headers'] ?? []; +} + +afterEach(function () { + SocketSettingsFixtures::reset(); +}); + +test('effective socket settings come from the environment until an administrator stores them', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, socket_settings_env()); + + expect(SocketSettings::effective())->toBe([ + 'enabled' => false, + 'mode' => 'log', + 'token_ttl' => 600, + 'origins' => 'https://console.example.test:*', + 'publisher_origin' => 'https://console.example.test', + 'key_configured' => true, + 'sources' => [ + 'enabled' => 'env', + 'mode' => 'env', + 'token_ttl' => 'env', + 'origins' => 'env', + 'publisher_origin' => 'env', + ], + ])->and(SocketSettings::updatedAt())->toBeNull(); + + $change = SocketSettings::store([ + 'enabled' => true, + 'mode' => 'enforce', + 'token_ttl' => '120', + 'origins' => ' https://ops.example.test:* http://localhost:* ', + 'publisher_origin' => 'https://ops.example.test/', + ]); + + expect(SocketSettings::effective())->toBe([ + 'enabled' => true, + 'mode' => 'enforce', + 'token_ttl' => 120, + 'origins' => 'https://ops.example.test:* http://localhost:*', + 'publisher_origin' => 'https://ops.example.test', + 'key_configured' => true, + 'sources' => [ + 'enabled' => 'setting', + 'mode' => 'setting', + 'token_ttl' => 'setting', + 'origins' => 'setting', + 'publisher_origin' => 'setting', + ], + ]) + ->and($change['before'])->toBe(['enabled' => null, 'mode' => null, 'token_ttl' => null, 'origins' => null, 'publisher_origin' => null]) + ->and($change['after']['token_ttl'])->toBe(120) + ->and(Setting::where('key', 'system.socket.mode')->value('value'))->toBe('enforce') + ->and(SocketSettings::updatedAt())->toBe(Carbon::createFromTimestampUTC(SocketAuthFixtures::NOW)->toIso8601ZuluString()); + + // A null clears one field back to the environment and leaves the rest as they are. + $cleared = SocketSettings::store(['mode' => null, 'token_ttl' => null]); + $after = SocketSettings::effective(); + + expect($cleared['after'])->toBe(['enabled' => true, 'mode' => null, 'token_ttl' => null, 'origins' => 'https://ops.example.test:* http://localhost:*', 'publisher_origin' => 'https://ops.example.test']) + ->and($after['mode'])->toBe('log') + ->and($after['token_ttl'])->toBe(600) + ->and($after['enabled'])->toBeTrue() + ->and($after['sources'])->toBe(['enabled' => 'setting', 'mode' => 'env', 'token_ttl' => 'env', 'origins' => 'setting', 'publisher_origin' => 'setting']) + ->and(Setting::where('key', 'system.socket.mode')->exists())->toBeFalse(); +}); + +test('environment fallbacks tolerate blank, invalid and array forms', function () { + SocketSettingsFixtures::setUp(null, [ + 'broadcasting.connections.socketcluster.auth_enabled' => 'true', + 'broadcasting.connections.socketcluster.auth_mode' => 'strict', + 'broadcasting.connections.socketcluster.token_ttl' => 0, + 'broadcasting.connections.socketcluster.socket_options' => 'not json', + ]); + + expect(SocketSettings::effective())->toMatchArray([ + 'enabled' => true, + 'mode' => null, + 'token_ttl' => SocketToken::DEFAULT_TTL, + 'origins' => null, + 'publisher_origin' => null, + 'key_configured' => false, + ]); + + config(['broadcasting.connections.socketcluster.socket_options' => ['origins' => 'http://localhost:*']]); + + expect(SocketSettings::defaults()['origins'])->toBe('http://localhost:*'); + + config(['broadcasting.connections.socketcluster.socket_options' => '{"origins":" "}']); + + expect(SocketSettings::defaults()['origins'])->toBeNull(); +}); + +test('stored values that are no longer valid fall back to the environment', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, socket_settings_env()); + + Setting::configure('system.socket.enabled', 'perhaps'); + Setting::configure('system.socket.mode', 'bogus'); + Setting::configure('system.socket.token_ttl', 10); + Setting::configure('system.socket.origins', ' '); + Setting::configure('system.socket.publisher_origin', ['not' => 'a string']); + + expect(SocketSettings::effective()['sources'])->toBe([ + 'enabled' => 'env', + 'mode' => 'env', + 'token_ttl' => 'env', + 'origins' => 'env', + 'publisher_origin' => 'env', + ]) + ->and(SocketSettings::normalize(['token_ttl' => 4000, 'enabled' => 'false', 'mode' => ' Enforce ']))->toBe([ + 'enabled' => false, + 'mode' => 'enforce', + 'token_ttl' => null, + 'origins' => null, + 'publisher_origin' => null, + ]); +}); + +test('stored socket settings are cached and the cache is busted on save', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, socket_settings_env()); + + expect(SocketSettings::effective()['mode'])->toBe('log') + ->and(Cache::get(SocketSettings::CACHE_KEY))->toBe([]); + + // Written behind the resolver's back: the cached read does not see it yet. + Setting::configure('system.socket.mode', 'off'); + + expect(SocketSettings::effective()['mode'])->toBe('log'); + + SocketSettings::store(['token_ttl' => 300]); + + expect(SocketSettings::effective()['mode'])->toBe('off') + ->and(SocketSettings::effective()['token_ttl'])->toBe(300) + ->and(Cache::get(SocketSettings::CACHE_KEY))->toHaveKeys(['system.socket.mode', 'system.socket.token_ttl', 'system.socket.updated_at']) + ->and(SocketSettings::CACHE_TTL)->toBe(60); +}); + +test('only the documented fields can be stored, never the auth key', function () { + SocketSettingsFixtures::setUp(); + + SocketSettings::store(['auth_key' => SocketAuthFixtures::OTHER_KEY, 'key' => SocketAuthFixtures::OTHER_KEY, 'mode' => 'log']); + + expect(Setting::query()->orderBy('key')->pluck('key')->all())->toBe(['system.socket.mode', 'system.socket.updated_at']) + ->and(json_encode(SocketSettings::effective()))->not->toContain(SocketAuthFixtures::KEY) + ->and(json_encode(SocketSettings::effective()))->not->toContain(SocketAuthFixtures::OTHER_KEY) + ->and(SocketToken::key())->toBe(SocketAuthFixtures::KEY); +}); + +test('an unreachable settings table falls back to the environment silently', function () { + $capsule = SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, socket_settings_env()); + $capsule->getConnection('mysql')->getSchemaBuilder()->drop('settings'); + + expect(SocketSettings::stored())->toBe([]) + ->and(SocketSettings::effective()['sources']['mode'])->toBe('env') + ->and(SocketSettings::updatedAt())->toBeNull(); +}); + +test('socket token switch, ttl and publisher origin follow the effective settings', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, socket_settings_env()); + + expect(SocketToken::switchedOn())->toBeFalse() + ->and(SocketToken::enabled())->toBeFalse() + ->and(SocketClusterService::publishesOverHttp())->toBeFalse() + ->and(SocketToken::defaultTtl('user'))->toBe(600) + ->and(socket_settings_client_headers(new SocketClusterService()))->toBe(['Origin' => 'https://console.example.test']); + + SocketSettings::store(['enabled' => true, 'token_ttl' => 120, 'publisher_origin' => 'https://ops.example.test']); + + expect(SocketToken::switchedOn())->toBeTrue() + ->and(SocketToken::enabled())->toBeTrue() + ->and(SocketClusterService::publishesOverHttp())->toBeTrue() + ->and(SocketToken::defaultTtl('api'))->toBe(120) + ->and(SocketToken::defaultTtl('tracking'))->toBe(SocketToken::TRACKING_TTL) + ->and(SocketToken::issue(new SocketPrincipal(kind: 'api', sub: 'cred-a'))['expires_in'])->toBe(120) + ->and(socket_settings_client_headers(new SocketClusterService()))->toBe(['Origin' => 'https://ops.example.test']) + // Options passed in still take precedence over the configured origin. + ->and(socket_settings_client_headers(new SocketClusterService(['headers' => ['Origin' => 'https://explicit.example.test']])))->toBe(['Origin' => 'https://explicit.example.test']); + + // The stored switch also turns socket auth off while the environment has it on. + config(['broadcasting.connections.socketcluster.auth_enabled' => true]); + SocketSettings::store(['enabled' => false]); + + expect(SocketToken::enabled())->toBeFalse(); + + // Without any origin at all the handshake sends none. + config(['broadcasting.connections.socketcluster.options.headers' => []]); + SocketSettings::store(['publisher_origin' => null]); + + expect(socket_settings_client_headers(new SocketClusterService()))->toBe([]); +}); + +test('socket server stats are fetched with a signed request and a two second timeout', function () { + SocketSettingsFixtures::setUp(); + Http::fake(['*' => Http::response(['connections' => 3, 'counters' => ['denied' => 1]], 200)]); + + $publishKey = hash_hmac('sha256', 'fleetbase-socket:publish', SocketAuthFixtures::KEY); + + expect(SocketClusterService::stats())->toBe(['status' => ['connections' => 3, 'counters' => ['denied' => 1]], 'error' => null]) + ->and(SocketClusterService::internalUrl('/stats'))->toBe('http://socket.test:8001/stats'); + + Http::assertSentCount(1); + Http::assertSent(function (HttpRequest $request) use ($publishKey) { + $timestamp = $request->header('X-Fleetbase-Timestamp')[0]; + + return $request->url() === 'http://socket.test:8001/stats' + && $request->method() === 'GET' + && $timestamp === (string) SocketAuthFixtures::NOW + && $request->header('X-Fleetbase-Signature')[0] === hash_hmac('sha256', $timestamp . '.', $publishKey); + }); +}); + +test('socket server stats report failures, timeouts and a missing key as a short reason', function () { + SocketSettingsFixtures::setUp(); + + $statsFor = function ($fake) { + Http::swap(new HttpFactory()); + Http::fake($fake); + + return SocketClusterService::stats(); + }; + + expect($statsFor(['*' => Http::response('unavailable', 503)]))->toBe(['status' => null, 'error' => 'The socket server answered with HTTP status 503.']) + ->and($statsFor(['*' => Http::response('not json', 200)]))->toBe(['status' => null, 'error' => 'The socket server returned an invalid status response.']) + ->and($statsFor(function () { + throw new ConnectionException('cURL error 28: Operation timed out after 2000 milliseconds'); + }))->toBe(['status' => null, 'error' => 'The socket server could not be reached or did not answer within 2 seconds.']) + ->and($statsFor(function () { + throw new RuntimeException('unexpected'); + }))->toBe(['status' => null, 'error' => 'The socket server status request failed.']); + + config(['broadcasting.connections.socketcluster.auth_key' => null]); + Http::swap(new HttpFactory()); + Http::fake(); + + expect(SocketClusterService::stats())->toBe(['status' => null, 'error' => 'SOCKETCLUSTER_AUTH_KEY is not configured.']); + + Http::assertNothingSent(); +}); From 807096c908f1198a65f3fef8e541062055767397 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 16:19:24 +0800 Subject: [PATCH 2/3] style(socket): use the imported socket classes in SettingController --- src/Http/Controllers/Internal/v1/SettingController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index ead184a7..40364756 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -1055,7 +1055,7 @@ public function testSentryConfig(AdminRequest $request) public function testSocketcluster(AdminRequest $request) { $userUuid = session('user'); - $scoped = \Fleetbase\Support\SocketCluster\SocketToken::enabled(); + $scoped = SocketToken::enabled(); if ($scoped && (!is_string($userUuid) || $userUuid === '')) { return response()->json([ @@ -1073,7 +1073,7 @@ public function testSocketcluster(AdminRequest $request) $sent = false; $response = null; - $socketClusterClient = new \Fleetbase\Support\SocketCluster\SocketClusterService(); + $socketClusterClient = new SocketClusterService(); try { $sent = $socketClusterClient->send($channel, [ From 249e0faa00d0b1336cac30540465b413e533db59 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 16:23:52 +0800 Subject: [PATCH 3/3] test(socket): bind the test container before the admin-guard test builds users --- tests/Unit/Http/SocketSettingsControllerTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Unit/Http/SocketSettingsControllerTest.php b/tests/Unit/Http/SocketSettingsControllerTest.php index 54ef31b3..01e14c78 100644 --- a/tests/Unit/Http/SocketSettingsControllerTest.php +++ b/tests/Unit/Http/SocketSettingsControllerTest.php @@ -61,6 +61,10 @@ function socket_settings_config_request(?string $timestamp, ?string $signature): }); test('the socket settings endpoints are for system administrators only', function () { + // The User models below boot their traits, which read config: bind a container first so + // this test does not depend on whatever an earlier test left behind. + SocketSettingsFixtures::setUp(); + foreach (['getSocketSettings', 'saveSocketSettings'] as $method) { $parameter = (new ReflectionMethod(SettingController::class, $method))->getParameters()[0];