From 84f6562e99b4fae6f5fd53232b44d747b0f3ed5b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 19:24:56 +0800 Subject: [PATCH 1/3] feat(app-review): App Review access for apps built on Fleetbase Apple and Google reviewers can't receive a texted sign-in code. Extensions register what their app signs reviewers in to (a Storefront store, an organization's Navigator app) with a review number from the 555-0100-0199 range reserved for fiction; the owner turns access on in the Console and gives the app store that number and a six-digit code, stored hashed and accepted only for that subject. New review_accesses table, Fleetbase\Support\AppReview, and GET|PUT int/v1/review-access/{type}/{id}. Numbers in the fictional range are never texted: SmsService answers as sent without calling a provider, and SMS verification codes are saved without reserving or sending. Tests that sent to 555-01xx numbers use other test numbers. --- ...24_000000_create_review_accesses_table.php | 48 +++ .../Internal/v1/ReviewAccessController.php | 104 +++++ src/Models/ReviewAccess.php | 66 +++ src/Models/VerificationCode.php | 10 +- src/Services/SmsService.php | 9 + src/Support/AppReview.php | 381 ++++++++++++++++++ src/routes.php | 3 + .../SettingControllerExternalProbesTest.php | 24 +- .../Http/SettingControllerServicesTest.php | 10 +- .../Unit/Models/VerificationCodeGuardTest.php | 13 + tests/Unit/SmsServiceGuardTest.php | 10 + tests/Unit/Support/AppReviewTest.php | 298 ++++++++++++++ tests/Unit/Support/TwoFactorAuthTest.php | 2 +- 13 files changed, 958 insertions(+), 20 deletions(-) create mode 100644 migrations/2026_10_24_000000_create_review_accesses_table.php create mode 100644 src/Http/Controllers/Internal/v1/ReviewAccessController.php create mode 100644 src/Models/ReviewAccess.php create mode 100644 src/Support/AppReview.php create mode 100644 tests/Unit/Support/AppReviewTest.php diff --git a/migrations/2026_10_24_000000_create_review_accesses_table.php b/migrations/2026_10_24_000000_create_review_accesses_table.php new file mode 100644 index 00000000..dd834906 --- /dev/null +++ b/migrations/2026_10_24_000000_create_review_accesses_table.php @@ -0,0 +1,48 @@ +bigIncrements('id'); + $table->uuid('uuid')->unique(); + $table->uuid('company_uuid')->nullable()->index(); + $table->string('subject_type', 64); + $table->uuid('subject_uuid'); + $table->boolean('enabled')->default(false); + $table->string('code')->nullable(); + $table->timestamp('code_generated_at')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->uuid('updated_by_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['subject_type', 'subject_uuid']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('review_accesses'); + } +}; diff --git a/src/Http/Controllers/Internal/v1/ReviewAccessController.php b/src/Http/Controllers/Internal/v1/ReviewAccessController.php new file mode 100644 index 00000000..17721dfb --- /dev/null +++ b/src/Http/Controllers/Internal/v1/ReviewAccessController.php @@ -0,0 +1,104 @@ +subject($type, $id, $request); + if (!$subject) { + return response()->error('Not found.', 404); + } + + if (!$this->allowed($type, 'view_permission', $request)) { + return response()->authorizationError(); + } + + return response()->json(['review_access' => AppReview::status($subject)]); + } + + public function update(string $type, string $id, Request $request): JsonResponse + { + $subject = $this->subject($type, $id, $request); + if (!$subject) { + return response()->error('Not found.', 404); + } + + if (!$this->allowed($type, 'permission', $request)) { + return response()->authorizationError(); + } + + $request->validate([ + 'enabled' => 'sometimes|boolean', + 'regenerate_code' => 'sometimes|boolean', + ]); + + $enabled = $request->has('enabled') ? $request->boolean('enabled') : null; + + [, $code] = AppReview::update($subject, $enabled, $request->boolean('regenerate_code'), $request->session()->get('user')); + + $status = AppReview::status($subject); + if ($code !== null) { + $status['code'] = $code; + } + + return response()->json(['review_access' => $status]); + } + + protected function subject(string $type, string $id, Request $request): ?Model + { + return AppReview::resolveSubject($type, $id, $request->session()->get('company')); + } + + /** + * Administrators always may; anyone else needs the permission the type registered, when + * it registered one. + */ + protected function allowed(string $type, string $key, Request $request): bool + { + $permission = AppReview::definition($type)[$key] ?? null; + if (!$permission) { + return true; + } + + $user = Auth::getUserFromSession($request); + if (!$user) { + return false; + } + + if (!$user->isNotAdmin()) { + return true; + } + + // The permission or a wildcard over it, the way AuthorizationGuard reads them. + $names = [$permission]; + $parts = explode(' ', $permission); + if (count($parts) === 3) { + $names[] = implode(' ', [$parts[0], '*', $parts[2]]); + $names[] = implode(' ', [$parts[0], '*']); + } + + $permissions = Permission::findByNames($names); + + return $permissions->isNotEmpty() && !$user->doesntHavePermissions($permissions); + } +} diff --git a/src/Models/ReviewAccess.php b/src/Models/ReviewAccess.php new file mode 100644 index 00000000..14f280a4 --- /dev/null +++ b/src/Models/ReviewAccess.php @@ -0,0 +1,66 @@ + 'boolean', + 'code_generated_at' => 'datetime', + 'enabled_at' => 'datetime', + ]; + + /** + * Turned on and holding a code: the only state in which a reviewer can sign in. + */ + public function isUsable(): bool + { + return $this->enabled && $this->hasCode(); + } + + public function hasCode(): bool + { + return filled($this->code); + } + + /** + * Replace the code with the given one, stored hashed. + */ + public function setPlainCode(string $code): void + { + $this->code = Hash::make($code); + $this->code_generated_at = now(); + } + + public function checkCode(string $code): bool + { + return $this->hasCode() && $code !== '' && Hash::check($code, $this->code); + } +} diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index fed8e79d..057776e1 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Mail\VerificationMail; use Fleetbase\Services\SmsService; +use Fleetbase\Support\AppReview; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsGuard; use Fleetbase\Support\Sms\SmsLedger; @@ -396,10 +397,15 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $destination = data_get($options, 'phone') ?: data_get($subject, 'phone'); $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); + // A number reserved for fiction (an App Review number) is never texted: the code is + // saved as usual, but nothing is reserved or sent. App reviewers sign in with the code + // their app's owner set up instead (Fleetbase\Support\AppReview). + $fictional = $destination && AppReview::isFictionalPhone((string) $destination); + // Ask the guard BEFORE the code row exists: a refused send must leave nothing // behind, and the budgets must be counted even when the provider later fails. $reservation = null; - if ($destination) { + if ($destination && !$fictional) { $reservation = SmsGuard::make()->reserve(SmsContext::resolve((string) $destination, [ 'purpose' => $for, 'company_uuid' => $companyUuid, @@ -476,7 +482,7 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $provider = data_get($options, 'provider'); // Send SMS using SmsService with automatic provider routing - if ($destination) { + if ($destination && !$fictional) { try { $smsService = new SmsService(); $smsService->send($destination, $message, $smsOptions, $provider); diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php index c9c3848c..7f5d9b30 100644 --- a/src/Services/SmsService.php +++ b/src/Services/SmsService.php @@ -2,6 +2,7 @@ namespace Fleetbase\Services; +use Fleetbase\Support\AppReview; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsGuard; use Fleetbase\Support\Sms\SmsReservation; @@ -70,6 +71,14 @@ public function send(string $to, string $text, array $options = [], ?string $pro // Normalize phone number $normalizedPhone = $this->normalizePhoneNumber($to); + // A number reserved for fiction (the App Review numbers among them) can never reach + // anyone: answer as sent without calling a provider or counting it. + if (AppReview::isFictionalPhone($normalizedPhone)) { + Log::info('SMS to a fictional number skipped', ['to' => $to]); + + return ['success' => true, 'skipped' => true, 'provider' => null, 'message_id' => null]; + } + // Every send passes the guard before a provider is called. A caller that already // reserved the send (VerificationCode) hands its reservation over so it counts once. $reservation = $options['sms_reservation'] ?? null; diff --git a/src/Support/AppReview.php b/src/Support/AppReview.php new file mode 100644 index 00000000..237d833e --- /dev/null +++ b/src/Support/AppReview.php @@ -0,0 +1,381 @@ + Store::class, + * 'phone' => '+12025550143', + * 'permission' => 'storefront manage-app-review store', + * ]); + * + * Self-hosted installs can still allow review accounts instance-wide from env config; see + * configuredAccountMatches(). + */ +class AppReview +{ + /** + * Digits in a generated code. Fleetbase's apps take six-digit codes. + */ + public const CODE_LENGTH = 6; + + /** + * North American numbers with the 555 exchange and a line from 0100 to 0199 are reserved + * for fiction: they can never reach a real person. + */ + public const FICTIONAL_PHONE_PATTERN = '/^\+1[2-9]\d{2}55501\d{2}$/'; + + /** + * @var array, phone: string, name: string, permission: ?string, view_permission: ?string, resolve: ?callable}> + */ + protected static array $types = []; + + /** + * Register a type of subject an app signs reviewers in to. + * + * - `model`: the subject's model class (required) + * - `phone`: the review number, E.164 from the fictional range (required). Types whose + * accounts are the same kind of user may share one (a store and a network); types + * whose users differ (customers, drivers) need their own, since users are found by phone. + * - `name`: what to call the app in messages, e.g. "Storefront" + * - `permission`: the permission needed to change access; `view_permission` to read it + * - `resolve`: callable(string $id, ?string $companyUuid): ?Model finding the subject a + * Console user may manage; by default the model by uuid or public_id in the company + */ + public static function register(string $type, array $definition): void + { + $model = $definition['model'] ?? null; + $phone = static::normalizePhone($definition['phone'] ?? null); + + if (!is_string($model) || !class_exists($model)) { + throw new \InvalidArgumentException("App Review type [{$type}] needs a model class."); + } + + if (!static::isFictionalPhone($phone)) { + throw new \InvalidArgumentException("App Review type [{$type}] needs a review number from the fictional range +1 NXX 555 0100-0199."); + } + + static::$types[$type] = [ + 'type' => $type, + 'model' => $model, + 'phone' => $phone, + 'name' => $definition['name'] ?? $type, + 'permission' => $definition['permission'] ?? null, + 'view_permission' => $definition['view_permission'] ?? null, + 'resolve' => $definition['resolve'] ?? null, + ]; + } + + public static function forget(string $type): void + { + unset(static::$types[$type]); + } + + public static function types(): array + { + return static::$types; + } + + public static function definition(string $type): ?array + { + return static::$types[$type] ?? null; + } + + /** + * The registered type of a subject: the first type whose model it is. + */ + public static function typeOf(?Model $subject): ?string + { + if (!$subject) { + return null; + } + + foreach (static::$types as $type => $definition) { + if ($subject instanceof $definition['model']) { + return $type; + } + } + + return null; + } + + public static function phoneFor(string $type): ?string + { + return static::$types[$type]['phone'] ?? null; + } + + /** + * Whether a number is in the range reserved for fiction, however it is formatted. + */ + public static function isFictionalPhone(?string $phone): bool + { + $phone = static::normalizePhone($phone); + + return $phone !== null && preg_match(static::FICTIONAL_PHONE_PATTERN, $phone) === 1; + } + + /** + * Whether an identity is a registered review number: the given type's, or any type's. + */ + public static function isReviewPhone(?string $identity, ?string $type = null): bool + { + $phone = static::normalizePhone($identity); + if ($phone === null) { + return false; + } + + if ($type !== null) { + return static::phoneFor($type) === $phone; + } + + foreach (static::$types as $definition) { + if ($definition['phone'] === $phone) { + return true; + } + } + + return false; + } + + /** + * `+12025550143` as `+1 202 555 0143`. + */ + public static function formatPhone(?string $phone): ?string + { + $phone = static::normalizePhone($phone); + if ($phone === null || !preg_match('/^\+1(\d{3})(\d{3})(\d{4})$/', $phone, $parts)) { + return $phone; + } + + return "+1 {$parts[1]} {$parts[2]} {$parts[3]}"; + } + + /** + * The subject of a type a Console user in this company may manage, by uuid or public id. + */ + public static function resolveSubject(string $type, string $id, ?string $companyUuid): ?Model + { + $definition = static::definition($type); + if (!$definition) { + return null; + } + + if (is_callable($definition['resolve'])) { + return ($definition['resolve'])($id, $companyUuid); + } + + $model = $definition['model']; + $query = $model::query()->where(function ($query) use ($id, $model) { + $query->where('uuid', $id); + if (static::hasColumn($model, 'public_id')) { + $query->orWhere('public_id', $id); + } + }); + + if (is_a($model, Company::class, true)) { + return $companyUuid ? $query->where('uuid', $companyUuid)->first() : null; + } + + return $companyUuid ? $query->where('company_uuid', $companyUuid)->first() : null; + } + + /** + * The App Review access record of a subject, if it has one. + */ + public static function accessFor(?Model $subject): ?ReviewAccess + { + $type = static::typeOf($subject); + if (!$type || blank($subject->uuid)) { + return null; + } + + try { + return ReviewAccess::where('subject_type', $type)->where('subject_uuid', $subject->uuid)->first(); + } catch (\Throwable $e) { + // Before the migration has run there is no access, not an error. + return null; + } + } + + public static function isEnabledFor(?Model $subject): bool + { + return (bool) static::accessFor($subject)?->isUsable(); + } + + /** + * Whether a code signs the subject type's review number in to this subject: its access is + * on and the code is the one it generated. Another subject's code never matches. + */ + public static function acceptsCode(?Model $subject, ?string $identity, mixed $code): bool + { + $type = static::typeOf($subject); + if (!$type || !static::isReviewPhone($identity, $type) || blank($code) || !is_scalar($code)) { + return false; + } + + $access = static::accessFor($subject); + if (!$access || !$access->isUsable() || !$access->checkCode((string) $code)) { + return false; + } + + Log::info('[Fleetbase] App Review sign-in accepted.', [ + 'type' => $type, + 'subject' => $subject->public_id ?? $subject->uuid, + ]); + + return true; + } + + /** + * Change a subject's access. Turning it on without a code generates one, so it is never + * on with nothing to sign in with; `regenerate` replaces the code. Turning it off keeps + * the code, so turning it back on restores the same one. + * + * @return array{0: ReviewAccess, 1: ?string} the access and the new plain code, if one was generated + */ + public static function update(Model $subject, ?bool $enabled, bool $regenerate = false, ?string $updatedByUuid = null): array + { + $type = static::typeOf($subject); + if (!$type) { + throw new \InvalidArgumentException('No App Review type is registered for ' . get_class($subject) . '.'); + } + + $access = static::accessFor($subject) ?? new ReviewAccess([ + 'company_uuid' => $subject instanceof Company ? $subject->uuid : $subject->company_uuid, + 'subject_type' => $type, + 'subject_uuid' => $subject->uuid, + 'enabled' => false, + ]); + + if ($enabled !== null) { + if ($enabled && !$access->enabled) { + $access->enabled_at = now(); + } + $access->enabled = $enabled; + } + + $code = null; + if ($regenerate || ($access->enabled && !$access->hasCode())) { + $code = static::generateCode(); + $access->setPlainCode($code); + } + + $access->updated_by_uuid = $updatedByUuid; + $access->save(); + + return [$access, $code]; + } + + /** + * What the Console and the API show about a subject's access. Never the code or its hash; + * a caller adds `code` only to the response that generated it. + */ + public static function status(Model $subject): array + { + $type = static::typeOf($subject); + $access = static::accessFor($subject); + $phone = $type ? static::phoneFor($type) : null; + + return [ + 'type' => $type, + 'enabled' => (bool) $access?->enabled, + 'phone' => $phone, + 'phone_display' => static::formatPhone($phone), + 'has_code' => (bool) $access?->hasCode(), + 'code_generated_at' => $access?->code_generated_at?->toIso8601String(), + 'enabled_at' => $access?->enabled_at?->toIso8601String(), + ]; + } + + public static function generateCode(): string + { + return str_pad((string) random_int(0, (10 ** static::CODE_LENGTH) - 1), static::CODE_LENGTH, '0', STR_PAD_LEFT); + } + + /** + * Whether an identity is listed as an instance-wide review account in env config (for + * self-hosted installs) and a code is configured for those accounts. + * + * @param string $codeKey config key holding the code + * @param string $accountsKey config key holding the listed identities + */ + public static function isConfiguredAccount(string $codeKey, string $accountsKey, ?string $identity): bool + { + $configuredCode = config($codeKey); + + // `=== null || === ''` rather than blank(): a configured code of "0" is still a code. + if ($configuredCode === null || $configuredCode === '' || $identity === null || trim($identity) === '') { + return false; + } + + $accounts = array_map( + static fn ($account) => strtolower(trim((string) $account)), + (array) config($accountsKey, []) + ); + + return in_array(strtolower(trim($identity)), $accounts, true); + } + + /** + * Whether a code is the env-configured code for a listed review account. Only listed + * identities are accepted, so knowing the code is not enough to sign in as anyone else, + * and the comparison is constant-time. + */ + public static function configuredAccountMatches(string $codeKey, string $accountsKey, ?string $identity, mixed $code, string $channel = 'app'): bool + { + if ($code === null || $code === '' || !is_scalar($code) || !static::isConfiguredAccount($codeKey, $accountsKey, $identity)) { + return false; + } + + if (!hash_equals((string) config($codeKey), (string) $code)) { + return false; + } + + Log::warning('[Fleetbase] Verification bypass accepted for a review account.', [ + 'channel' => $channel, + 'identity' => $identity, + ]); + + return true; + } + + /** + * Digits with a leading `+`, or null for nothing. + */ + protected static function normalizePhone(?string $phone): ?string + { + if ($phone === null) { + return null; + } + + $digits = preg_replace('/\D+/', '', $phone); + + return $digits === '' ? null : '+' . $digits; + } + + protected static function hasColumn(string $model, string $column): bool + { + try { + $instance = new $model(); + + return $instance->getConnection()->getSchemaBuilder()->hasColumn($instance->getTable(), $column); + } catch (\Throwable $e) { + return false; + } + } +} diff --git a/src/routes.php b/src/routes.php index 25ffc43b..6cf62503 100644 --- a/src/routes.php +++ b/src/routes.php @@ -204,6 +204,9 @@ function ($router) { function ($router) { // Realtime socket token for the signed-in console user. $router->post('socket/token', [Fleetbase\Http\Controllers\SocketAuthController::class, 'token']); + // App Review access for whatever an extension registered (stores, Navigator, ...). + $router->get('review-access/{type}/{id}', 'ReviewAccessController@show'); + $router->put('review-access/{type}/{id}', 'ReviewAccessController@update'); $router->group( ['prefix' => 'lookup'], function ($router) { diff --git a/tests/Unit/Http/SettingControllerExternalProbesTest.php b/tests/Unit/Http/SettingControllerExternalProbesTest.php index dd7538d3..7c1df368 100644 --- a/tests/Unit/Http/SettingControllerExternalProbesTest.php +++ b/tests/Unit/Http/SettingControllerExternalProbesTest.php @@ -26,7 +26,7 @@ function setting_controller_external_probe_fixtures(array $config = []): void 'twilio.twilio.connections.twilio' => [ 'sid' => 'existing-sid', 'token' => 'existing-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], 'broadcasting.connections.socketcluster.auth_key' => null, 'broadcasting.connections.socketcluster.options' => [ @@ -71,7 +71,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ ->and(config('twilio.twilio.connections.twilio'))->toBe([ 'sid' => 'existing-sid', 'token' => 'existing-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ]); }); @@ -82,7 +82,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'override-sid', 'token' => 'override-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -107,7 +107,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'rest-sid', 'token' => 'rest-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -135,7 +135,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'error-sid', 'token' => 'error-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -158,7 +158,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'warning-sid', 'token' => 'warning-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -325,12 +325,12 @@ function setting_controller_twilio_facade_is_cached(): bool 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getData(true)['message'])->toBe('recorded') ->and(SettingControllerRecordingTwilioManager::$sent)->toBe([ - ['to' => '+15555550123', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], + ['to' => '+15555551023', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ]) ->and(setting_controller_twilio_facade_is_cached())->toBeFalse('the credentials under test do not outlive the request'); }); @@ -341,13 +341,13 @@ function setting_controller_twilio_facade_is_cached(): bool $response = (new SettingController())->testSmsProviderConfig(setting_controller_external_probe_request([ 'provider' => 'twilio', - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'config' => ['sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ])); expect($response->getData(true))->toMatchArray(['status' => 'error', 'message' => 'recorded']) ->and(SettingControllerRecordingTwilioManager::$sent)->toBe([ - ['to' => '+15555550123', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], + ['to' => '+15555551023', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ]) ->and(setting_controller_twilio_facade_is_cached())->toBeFalse(); }); @@ -361,11 +361,11 @@ function setting_controller_twilio_facade_is_cached(): bool 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect(app('twilio'))->toBe($twilio) - ->and($twilio->messages)->toBe([['+15555550123', 'This is a Twilio test from Fleetbase']]); + ->and($twilio->messages)->toBe([['+15555551023', 'This is a Twilio test from Fleetbase']]); }); test('a twilio manager bound but not yet built is built from the config just applied', function () { diff --git a/tests/Unit/Http/SettingControllerServicesTest.php b/tests/Unit/Http/SettingControllerServicesTest.php index 48205a2a..4391253d 100644 --- a/tests/Unit/Http/SettingControllerServicesTest.php +++ b/tests/Unit/Http/SettingControllerServicesTest.php @@ -103,7 +103,7 @@ function setting_controller_services_fixtures(): void 'services.twilio' => [ 'sid' => 'twilio-sid', 'token' => 'twilio-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], 'sentry.dsn' => 'https://sentry.example.test/1', 'sms.default_provider' => SmsService::PROVIDER_TWILIO, @@ -153,7 +153,7 @@ function setting_controller_services_request(array $input = []): AdminRequest 'googleMapsLocale' => 'mn', 'twilioSid' => 'twilio-sid', 'twilioToken' => 'twilio-token', - 'twilioFrom' => '+15555550100', + 'twilioFrom' => '+15555551000', 'sentryDsn' => 'https://sentry.example.test/1', ]) ->and($payload['sms']['defaultProvider'])->toBe(SmsService::PROVIDER_TWILIO) @@ -172,7 +172,7 @@ function setting_controller_services_request(array $input = []): AdminRequest SmsService::PROVIDER_TWILIO => [ 'sid' => 'twilio-sid', 'token' => 'twilio-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], ]) ->and($payload['sms']['available'][SmsService::PROVIDER_TWILIO])->toBe([ @@ -207,7 +207,7 @@ function setting_controller_services_request(array $input = []): AdminRequest $response = (new SettingController())->testSmsProviderConfig(setting_controller_services_request([ 'provider' => 'unsupported_gateway', - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'message' => 'Hello from Fleetbase', 'config' => [ 'api_key' => 'unsupported-key', @@ -237,7 +237,7 @@ function setting_controller_services_request(array $input = []): AdminRequest $response = (new SettingController())->testSmsProviderConfig(setting_controller_services_request([ 'provider' => SmsService::PROVIDER_TWILIO, - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'message' => 'Hello from Fleetbase', 'config' => [ 'sid' => 'temporary-twilio-sid', diff --git a/tests/Unit/Models/VerificationCodeGuardTest.php b/tests/Unit/Models/VerificationCodeGuardTest.php index aa0dfe5d..60890d9d 100644 --- a/tests/Unit/Models/VerificationCodeGuardTest.php +++ b/tests/Unit/Models/VerificationCodeGuardTest.php @@ -213,6 +213,19 @@ function verification_code_guard_subject(string $phone): User ->and($twilio->messages)->toBe([]); }); +it('never texts a number reserved for fiction, such as an App Review number, and still saves the code', function () { + $twilio = verification_code_guard_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+12025550143'), 'storefront_login', ['hashed' => true]); + $repeat = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+12025550143'), 'storefront_login', ['hashed' => true]); + + // Nothing was reserved, so the guard's cooldown never refuses the repeat either. + expect($code->exists)->toBeTrue() + ->and($repeat->exists)->toBeTrue() + ->and($code->meta)->not->toHaveKey('trace') + ->and($twilio->messages)->toBe([]); +}); + it('leaves the code row untouched when the guard is disabled', function () { $twilio = verification_code_guard_database(['enabled' => false]); diff --git a/tests/Unit/SmsServiceGuardTest.php b/tests/Unit/SmsServiceGuardTest.php index 5ad59e67..6b1d77fc 100644 --- a/tests/Unit/SmsServiceGuardTest.php +++ b/tests/Unit/SmsServiceGuardTest.php @@ -142,3 +142,13 @@ protected function sendViaTwilio(string $to, string $text, array $options = []): expect($failed)->not->toBeNull() ->and($failed[2]['error'])->toBe('invalid sender'); }); + +test('sms service never texts a number reserved for fiction, such as an App Review number', function () { + sms_service_guard_setup(); + $service = new SmsServiceGuardProbe(); + + $result = $service->send('+1 202 555 0143', 'hello', ['purpose' => 'storefront_login']); + + expect($result)->toMatchArray(['success' => true, 'skipped' => true]) + ->and($service->dispatches)->toBe([]); +}); diff --git a/tests/Unit/Support/AppReviewTest.php b/tests/Unit/Support/AppReviewTest.php new file mode 100644 index 00000000..af13bb7d --- /dev/null +++ b/tests/Unit/Support/AppReviewTest.php @@ -0,0 +1,298 @@ + 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $container = bind_test_container([ + 'database.default' => 'testing', + 'database.connections.testing' => $connection, + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'testing', + ]); + $container->instance('hash', new class { + public function make($value, array $options = []): string + { + return password_hash((string) $value, PASSWORD_BCRYPT); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return is_string($hashedValue) && password_verify((string) $value, $hashedValue); + } + }); + $container->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'testing'); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('testing'); + $container->instance('db', $capsule->getDatabaseManager()); + $container->instance('db.schema', $capsule->getConnection('testing')->getSchemaBuilder()); + Facade::clearResolvedInstance('db'); + Facade::clearResolvedInstance('db.schema'); + + $schema = $capsule->getConnection('testing')->getSchemaBuilder(); + foreach (['app_review_test_stores', 'app_review_test_networks'] as $table) { + $schema->create($table, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + // Company lives on the core connection. + $capsule->getConnection('mysql')->getSchemaBuilder()->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + // The migration, as it runs on the core connection. + (require __DIR__ . '/../../../migrations/2026_10_24_000000_create_review_accesses_table.php')->up(); + + $db = $capsule->getConnection('testing'); + $db->table('app_review_test_stores')->insert([ + ['uuid' => 'store-a', 'public_id' => 'store_a', 'company_uuid' => 'company-a'], + ['uuid' => 'store-b', 'public_id' => 'store_b', 'company_uuid' => 'company-b'], + ]); + $db->table('app_review_test_networks')->insert([ + ['uuid' => 'network-a', 'public_id' => 'network_a', 'company_uuid' => 'company-a'], + ]); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_a', 'name' => 'Corner Co'], + ['uuid' => 'company-b', 'public_id' => 'company_b', 'name' => 'Night Co'], + ]); + + ReviewAccess::creating(fn ($access) => $access->uuid ??= (string) Str::uuid()); + + AppReview::register('test-store', ['model' => AppReviewTestStore::class, 'phone' => '+1 (202) 555-0143', 'name' => 'Storefront']); + AppReview::register('test-network', ['model' => AppReviewTestNetwork::class, 'phone' => '+12025550143', 'name' => 'Storefront']); + AppReview::register('test-navigator', ['model' => Company::class, 'phone' => '+12025550144', 'name' => 'Navigator']); + + return $capsule; +} + +function app_review_store(string $uuid): AppReviewTestStore +{ + return AppReviewTestStore::where('uuid', $uuid)->first(); +} + +function app_review_console_request(string $company, array $input = [], string $method = 'PUT'): Request +{ + $request = Request::create('/int/v1/review-access', $method, $input); + $request->setLaravelSession(new SessionStore('app-review-test', new ArraySessionHandler(120))); + $request->session()->put('company', $company); + + return $request; +} + +afterEach(function () { + foreach (['test-store', 'test-network', 'test-navigator', 'test-guarded'] as $type) { + AppReview::forget($type); + } + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('review numbers must come from the range reserved for fiction', function () { + bind_test_container(); + + expect(AppReview::isFictionalPhone('+12025550143'))->toBeTrue() + ->and(AppReview::isFictionalPhone('+1 (415) 555-0199'))->toBeTrue() + ->and(AppReview::isFictionalPhone('+12025550200'))->toBeFalse() + ->and(AppReview::isFictionalPhone('+12025551234'))->toBeFalse() + ->and(AppReview::isFictionalPhone('+97699112233'))->toBeFalse() + ->and(AppReview::isFictionalPhone(null))->toBeFalse() + ->and(AppReview::formatPhone('+12025550143'))->toBe('+1 202 555 0143') + ->and(fn () => AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025551234']))->toThrow(InvalidArgumentException::class) + ->and(fn () => AppReview::register('test-guarded', ['model' => 'Missing\\Model', 'phone' => '+12025550143']))->toThrow(InvalidArgumentException::class); + + // libphonenumber accepts the numbers the apps register. + $util = libphonenumber\PhoneNumberUtil::getInstance(); + foreach (['+12025550143', '+12025550144'] as $phone) { + expect($util->isValidNumber($util->parse($phone, null)))->toBeTrue(); + } +}); + +test('turning access on creates a code that only works for that subject', function () { + app_review_database(); + $storeA = app_review_store('store-a'); + $storeB = app_review_store('store-b'); + + expect(AppReview::typeOf($storeA))->toBe('test-store') + ->and(AppReview::isEnabledFor($storeA))->toBeFalse() + ->and(AppReview::acceptsCode($storeA, '+12025550143', '000000'))->toBeFalse(); + + [$access, $codeA] = AppReview::update($storeA, true); + do { + [, $codeB] = AppReview::update($storeB, true, true); + } while ($codeB === $codeA); + + expect($codeA)->toMatch('/^\d{6}$/') + ->and($access->getRawOriginal('code'))->not->toBe($codeA) + ->and($access->toArray())->not->toHaveKey('code') + ->and(AppReview::isEnabledFor($storeA))->toBeTrue() + ->and(AppReview::acceptsCode($storeA, '+1 202 555 0143', $codeA))->toBeTrue() + ->and(AppReview::acceptsCode($storeA, '+12025550143', $codeB))->toBeFalse() + ->and(AppReview::acceptsCode($storeB, '+12025550143', $codeA))->toBeFalse() + // Only the type's own number signs in. + ->and(AppReview::acceptsCode($storeA, '+12025550144', $codeA))->toBeFalse() + ->and(AppReview::acceptsCode($storeA, '+97699112233', $codeA))->toBeFalse() + ->and(AppReview::acceptsCode(null, '+12025550143', $codeA))->toBeFalse(); +}); + +test('turning access off keeps the code, and regenerating replaces it', function () { + app_review_database(); + $store = app_review_store('store-a'); + + [, $code] = AppReview::update($store, true); + [, $none] = AppReview::update($store, false); + expect($none)->toBeNull() + ->and(AppReview::acceptsCode($store, '+12025550143', $code))->toBeFalse() + ->and(AppReview::status($store))->toMatchArray(['enabled' => false, 'has_code' => true]); + + [, $restored] = AppReview::update($store, true); + expect($restored)->toBeNull() + ->and(AppReview::acceptsCode($store, '+12025550143', $code))->toBeTrue(); + + [, $new] = AppReview::update($store, null, true); + expect($new)->toMatch('/^\d{6}$/') + ->and(AppReview::acceptsCode($store, '+12025550143', $new))->toBeTrue() + ->and($new === $code || !AppReview::acceptsCode($store, '+12025550143', $code))->toBeTrue() + ->and(ReviewAccess::count())->toBe(1); +}); + +test('a company can be the subject, with its own review number', function () { + app_review_database(); + $company = Company::where('uuid', 'company-a')->first(); + + [, $code] = AppReview::update($company, true); + + expect(AppReview::typeOf($company))->toBe('test-navigator') + ->and(AppReview::status($company))->toMatchArray(['type' => 'test-navigator', 'phone' => '+12025550144', 'phone_display' => '+1 202 555 0144', 'enabled' => true]) + ->and(AppReview::acceptsCode($company, '+12025550144', $code))->toBeTrue() + ->and(AppReview::acceptsCode($company, '+12025550143', $code))->toBeFalse() + ->and(ReviewAccess::first()->company_uuid)->toBe('company-a') + ->and(AppReview::resolveSubject('test-navigator', 'company_a', 'company-a')?->uuid)->toBe('company-a') + ->and(AppReview::resolveSubject('test-navigator', 'company_b', 'company-a'))->toBeNull(); +}); + +test('a subject is found by uuid or public id in the signed-in company only', function () { + app_review_database(); + + expect(AppReview::resolveSubject('test-store', 'store-a', 'company-a')?->uuid)->toBe('store-a') + ->and(AppReview::resolveSubject('test-store', 'store_a', 'company-a')?->uuid)->toBe('store-a') + ->and(AppReview::resolveSubject('test-store', 'store_b', 'company-a'))->toBeNull() + ->and(AppReview::resolveSubject('test-store', 'store_a', null))->toBeNull() + ->and(AppReview::resolveSubject('unknown-type', 'store_a', 'company-a'))->toBeNull(); +}); + +test('the instance-wide review accounts only accept their code for listed identities', function () { + bind_test_container([ + 'app.review.code' => '135790', + 'app.review.accounts' => [' Reviewer@Example.Test ', '+12025550100'], + ]); + + expect(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', 'reviewer@example.test'))->toBeTrue() + ->and(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', 'other@example.test'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', '135790'))->toBeTrue() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', '135791'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', 'other@example.test', '135790'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', null))->toBeFalse(); + + config(['app.review.code' => null]); + expect(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', ''))->toBeFalse() + ->and(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', '+12025550100'))->toBeFalse(); +}); + +test('the Console endpoint shows the code once and never again', function () { + app_review_database(); + app()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app())); + Request::macro('validate', function (array $rules) { + return app('validator')->make($this->all(), $rules)->validate(); + }); + $controller = new ReviewAccessController(); + + $initial = $controller->show('test-store', 'store_a', app_review_console_request('company-a', [], 'GET'))->getData(true)['review_access']; + expect($initial)->toBe([ + 'type' => 'test-store', + 'enabled' => false, + 'phone' => '+12025550143', + 'phone_display' => '+1 202 555 0143', + 'has_code' => false, + 'code_generated_at' => null, + 'enabled_at' => null, + ]); + + $on = $controller->update('test-store', 'store_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']; + $again = $controller->show('test-store', 'store_a', app_review_console_request('company-a', [], 'GET'))->getData(true)['review_access']; + $saved = $controller->update('test-store', 'store_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']; + + expect($on['enabled'])->toBeTrue() + ->and($on['code'])->toMatch('/^\d{6}$/') + ->and($again)->not->toHaveKey('code') + ->and($saved)->not->toHaveKey('code') + ->and(AppReview::acceptsCode(app_review_store('store-a'), '+12025550143', $on['code']))->toBeTrue(); + + // Another company's store, and a type nobody registered, are not found. + expect($controller->update('test-store', 'store_b', app_review_console_request('company-a', ['enabled' => true]))->getStatusCode())->toBe(404) + ->and($controller->show('nothing', 'store_a', app_review_console_request('company-a', [], 'GET'))->getStatusCode())->toBe(404) + ->and($controller->update('test-network', 'network_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']['type'])->toBe('test-network'); +}); + +test('a registered permission is required to change access', function () { + app_review_database(); + AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025550143', 'permission' => 'storefront manage-app-review store']); + + // Nobody signed in, so nobody holds the permission: refused before anything changes. + $response = (new ReviewAccessController())->update('test-guarded', 'store_a', app_review_console_request('company-a', ['enabled' => true])); + + expect($response->getStatusCode())->toBe(401) + ->and(ReviewAccess::count())->toBe(0); +})->skip(fn () => !method_exists(Fleetbase\Support\Auth::class, 'getUserFromSession')); diff --git a/tests/Unit/Support/TwoFactorAuthTest.php b/tests/Unit/Support/TwoFactorAuthTest.php index cb64d7a7..77a5163e 100644 --- a/tests/Unit/Support/TwoFactorAuthTest.php +++ b/tests/Unit/Support/TwoFactorAuthTest.php @@ -217,7 +217,7 @@ function two_factor_auth_fixtures(): array 'uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => $company->uuid, 'email' => 'user@example.com', - 'phone' => '+15555550100', + 'phone' => '+15555551000', 'username' => 'test-user', 'name' => 'Test User', ]); From 5f41a212f97762cc3d69bf23263802380215bd9c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 19:25:49 +0800 Subject: [PATCH 2/3] docs(release): App Review access in the v1.6.74 notes --- RELEASE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 09676734..85500db5 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,14 +1,17 @@ -# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, and safer upload names +# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, safer upload names, and App Review access ## Added - **Webhook endpoints can be managed with an API key** (#319). `/v1/webhook-endpoints` supports create, list (optionally filtered by `url`), show, update, `POST {id}/rotate-secret` and delete. Endpoints are scoped to the key's company and environment (`live`/`test`). The URL must pass `PublicWebhookUrl`, and events are checked against `config('api.events')` when it is set. Integrations such as WooCommerce and Shopify connectors can now register their own endpoints instead of merchants copying URLs into the console. - **Per-endpoint signing secrets** (#319). Each endpoint has its own secret (`whsec_…`), stored encrypted, hidden from serialisation and kept out of the activity log. Endpoints that already exist get one the first time it is needed. Deliveries now also carry `X-Fleetbase-Signature: t=,v1=.", secret)>`; `EndpointSignature::verify()` documents the receiver side. The legacy `Signature` header is unchanged. The console can reveal (`GET int/v1/webhook-endpoints/{id}/secret`, view permission) and rotate (`POST int/v1/webhook-endpoints/{id}/rotate-secret`, create permission) the secret. +- **App Review access** (#323). Apple and Google reviewers can't receive a texted sign-in code. Extensions register what their app signs reviewers in to (`Fleetbase\Support\AppReview::register()`, e.g. a Storefront store or an organization's Navigator app) with a review number from the 555-0100–0199 range reserved for fiction. Owners turn access on in the Console (`GET|PUT int/v1/review-access/{type}/{id}`) and give the app store that number and a six-digit code, stored hashed, shown once, and accepted only for that subject. Works on Fleetbase Cloud and self-hosted. `AppReview::configuredAccountMatches()` is the shared env fallback for self-hosted review accounts. ## Security +- **Numbers reserved for fiction are never texted** (#323). `SmsService::send()` answers as sent without calling a provider, and SMS verification codes for those numbers are saved but not sent. + - **Uploaded files are stored under an extension that matches their contents** (#320). The stored name used to keep the client's extension, so a file that passed content-type validation could be stored as `.php`. The client's extension is now kept only when it matches the detected type (or is a loosely detected data format such as `csv`, `json` or `xlsx`); otherwise the extension comes from the detected type. Script extensions (`php`, `phtml`, `phar`, `shtml`, `htaccess`, `cgi`, …) are never used and become `bin`. `original_filename` is unchanged. ## Upgrade Steps -- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column. +- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column and the `review_accesses` table. From b5fcd69129c24cedcce127da2ba50b9d6b0228fb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 25 Oct 2026 02:37:00 +0800 Subject: [PATCH 3/3] test(app-review): cover the registry's edge cases and the endpoint's permission check Unknown types, any type's number, a custom resolver, an unsaved or unregistered subject, the table before migration, an unreadable table, and the Console endpoint for nobody, a viewer, a permission or wildcard holder, an administrator and an unseeded permission. Brings both files to the 100% coverage gate. --- tests/Unit/Support/AppReviewTest.php | 145 ++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Support/AppReviewTest.php b/tests/Unit/Support/AppReviewTest.php index af13bb7d..3f9a20fd 100644 --- a/tests/Unit/Support/AppReviewTest.php +++ b/tests/Unit/Support/AppReviewTest.php @@ -20,6 +20,32 @@ class AppReviewTestStore extends FleetbaseModel protected $guarded = []; } +class AppReviewTestUnregistered extends FleetbaseModel +{ + protected $table = 'app_review_test_stores'; + protected $guarded = []; +} + +class AppReviewTestUnreachable extends FleetbaseModel +{ + protected $connection = 'missing-connection'; + protected $table = 'app_review_test_stores'; +} + +/** + * A Console user whose permissions are a plain list, so the endpoint's permission check can + * be exercised without the role tables. + */ +class AppReviewTestUser extends Fleetbase\Models\User +{ + public array $granted = []; + + public function doesntHavePermissions(Illuminate\Support\Collection|array $permissions): bool + { + return collect($permissions)->pluck('name')->intersect($this->granted)->isEmpty(); + } +} + class AppReviewTestNetwork extends FleetbaseModel { protected $table = 'app_review_test_networks'; @@ -132,7 +158,7 @@ function app_review_console_request(string $company, array $input = [], string $ } afterEach(function () { - foreach (['test-store', 'test-network', 'test-navigator', 'test-guarded'] as $type) { + foreach (['test-store', 'test-network', 'test-navigator', 'test-guarded', 'test-resolved', 'test-unreachable'] as $type) { AppReview::forget($type); } EloquentModel::unsetConnectionResolver(); @@ -296,3 +322,120 @@ function app_review_console_request(string $company, array $input = [], string $ expect($response->getStatusCode())->toBe(401) ->and(ReviewAccess::count())->toBe(0); })->skip(fn () => !method_exists(Fleetbase\Support\Auth::class, 'getUserFromSession')); + +test('the registry answers for unknown types, any type\'s number and numbers it cannot format', function () { + app_review_database(); + + expect(array_keys(AppReview::types()))->toContain('test-store', 'test-network', 'test-navigator') + ->and(AppReview::typeOf(new AppReviewTestUnregistered()))->toBeNull() + ->and(AppReview::typeOf(null))->toBeNull() + ->and(AppReview::definition('nothing'))->toBeNull() + ->and(AppReview::phoneFor('nothing'))->toBeNull() + // Without a type, any registered number is a review number. + ->and(AppReview::isReviewPhone('+1 202 555 0144'))->toBeTrue() + ->and(AppReview::isReviewPhone('+12025550199'))->toBeFalse() + ->and(AppReview::isReviewPhone(''))->toBeFalse() + ->and(AppReview::isReviewPhone(null, 'test-store'))->toBeFalse() + // Only North American numbers are spaced out. + ->and(AppReview::formatPhone('+97699112233'))->toBe('+97699112233') + ->and(AppReview::formatPhone(null))->toBeNull(); +}); + +test('a type can bring its own resolver for the subject a Console user may manage', function () { + app_review_database(); + $calls = []; + AppReview::register('test-resolved', [ + 'model' => AppReviewTestUnregistered::class, + 'phone' => '+12025550150', + 'resolve' => function (string $id, ?string $companyUuid) use (&$calls) { + $calls[] = [$id, $companyUuid]; + + return AppReviewTestUnregistered::where('public_id', $id)->first(); + }, + ]); + + expect(AppReview::resolveSubject('test-resolved', 'store_b', 'company-a')?->uuid)->toBe('store-b') + ->and($calls)->toBe([['store_b', 'company-a']]); +}); + +test('access is absent, not an error, for an unsaved subject, an unregistered one, or before the migration', function () { + $capsule = app_review_database(); + + expect(AppReview::accessFor(new AppReviewTestStore()))->toBeNull() + ->and(AppReview::accessFor(new AppReviewTestUnregistered(['uuid' => 'store-a'])))->toBeNull() + ->and(fn () => AppReview::update(new AppReviewTestUnregistered(['uuid' => 'store-a']), true))->toThrow(InvalidArgumentException::class); + + $capsule->getConnection('testing')->getSchemaBuilder()->drop('review_accesses'); + + expect(AppReview::accessFor(app_review_store('store-a')))->toBeNull() + ->and(AppReview::isEnabledFor(app_review_store('store-a')))->toBeFalse() + ->and(AppReview::acceptsCode(app_review_store('store-a'), '+12025550143', '123456'))->toBeFalse(); +}); + +test('a subject whose table cannot be read is matched by uuid only', function () { + app_review_database(); + $hasColumn = new ReflectionMethod(AppReview::class, 'hasColumn'); + + expect($hasColumn->invoke(null, AppReviewTestUnreachable::class, 'public_id'))->toBeFalse() + ->and($hasColumn->invoke(null, AppReviewTestStore::class, 'public_id'))->toBeTrue(); +}); + +test('the Console endpoint lets administrators and permission holders in, and nobody else', function () { + app_review_database(); + app()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app())); + Request::macro('validate', function (array $rules) { + return app('validator')->make($this->all(), $rules)->validate(); + }); + config(['permission.table_names.permissions' => 'permissions', 'auth.defaults.guard' => 'sanctum', 'auth.guards.sanctum' => ['driver' => 'sanctum']]); + Capsule::connection('mysql')->getSchemaBuilder()->create('permissions', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->string('guard_name')->nullable(); + $table->timestamps(); + }); + Capsule::connection('mysql')->table('permissions')->insert([ + ['id' => 'permission-manage', 'name' => 'storefront manage-app-review store', 'guard_name' => 'sanctum'], + ['id' => 'permission-wildcard', 'name' => 'storefront * store', 'guard_name' => 'sanctum'], + ['id' => 'permission-view', 'name' => 'storefront view store', 'guard_name' => 'sanctum'], + ]); + AppReview::forget('test-store'); + AppReview::register('test-guarded', [ + 'model' => AppReviewTestStore::class, + 'phone' => '+12025550143', + 'permission' => 'storefront manage-app-review store', + 'view_permission' => 'storefront view store', + ]); + + $as = function (?AppReviewTestUser $user, array $input = [], string $method = 'PUT') { + $request = app_review_console_request('company-a', $input, $method); + $request->setUserResolver(fn () => $user); + + return $request; + }; + $user = function (string $type, array $granted = []) { + $user = new AppReviewTestUser(); + $user->type = $type; + $user->granted = $granted; + + return $user; + }; + $controller = new ReviewAccessController(); + + $nobody = $controller->show('test-guarded', 'store_a', $as(null, [], 'GET')); + $viewer = $controller->show('test-guarded', 'store_a', $as($user('user', ['storefront view store']), [], 'GET')); + $readOnly = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront view store']), ['enabled' => true])); + $manager = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront manage-app-review store']), ['enabled' => true])); + $wildcard = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront * store']), ['enabled' => false])); + $admin = $controller->update('test-guarded', 'store_a', $as($user('admin'), ['regenerate_code' => true])); + + expect($nobody->getStatusCode())->toBe(401) + ->and($viewer->getData(true)['review_access']['type'])->toBe('test-guarded') + ->and($readOnly->getStatusCode())->toBe(401) + ->and($manager->getData(true)['review_access'])->toHaveKey('code') + ->and($wildcard->getData(true)['review_access']['enabled'])->toBeFalse() + ->and($admin->getData(true)['review_access'])->toHaveKey('code'); + + // A permission the install never seeded lets nobody but administrators in. + AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025550143', 'permission' => 'storefront unseeded store']); + expect($controller->update('test-guarded', 'store_a', $as($user('user', ['storefront unseeded store']), ['enabled' => true]))->getStatusCode())->toBe(401); +});