From 279047436754a3af78f3550be31b5acb3e4fb472 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:48:27 +0800 Subject: [PATCH] feat(sms): ledger, hashed single-use codes, learned breaker signals and the admin API Phase 1 of the SMS guard (phase 0 is the guard itself): - sms_sends ledger: every allowed send is reserved and settled with the provider outcome, every refusal is written with its reason, with tenant, purpose, source, address, destination country and the code row it carried. sms:ledger:prune masks numbers after 30 days and deletes rows after 90 (both configurable). - verification_codes gain company_uuid, identity, channel, ip_address, user_agent, sms_send_uuid and consumed_at (identity backfilled from meta for recent rows). generateSmsVerificationFor(['hashed' => true]) stores an HMAC and hands the message callback the plain code; findActiveFor()/findActiveForSubject() find the newest live code (new column or legacy meta), check() verifies, consume() uses it up. Everything degrades to meta-only on a table without the columns. - Two breaker signals read from the data: a burst to a country the company has never texted (armed after 50 ledgered sends), and SMS codes issued with almost none verified in the last hour. - SmsServiceChannel: a notification channel that sends through SmsService, so notification texts are guarded and ledgered too. - Admin API under int/v1/sms-guard: settings (mode, budgets, blocklists, alert recipients, per-company exceptions), reset, pauses, usage per organization and the ledger with filters. The console page follows separately. - The unrouted legacy Twilio-direct SMS login methods are removed. --- config/sms.php | 14 + ...26_10_13_000001_create_sms_sends_table.php | 56 ++++ ...ng_columns_to_verification_codes_table.php | 50 +++ src/Console/Commands/SmsGuardPause.php | 16 +- src/Console/Commands/SmsLedgerPrune.php | 36 +++ .../Internal/v1/AuthController.php | 163 ---------- .../Internal/v1/SmsGuardController.php | 300 ++++++++++++++++++ src/Models/SmsSend.php | 69 ++++ src/Models/VerificationCode.php | 163 +++++++++- .../Channels/SmsServiceChannel.php | 61 ++++ src/Notifications/SmsGuardAlert.php | 2 + src/Providers/CoreServiceProvider.php | 1 + src/Support/Sms/SmsBreaker.php | 114 ++++++- src/Support/Sms/SmsGuard.php | 29 +- src/Support/Sms/SmsLedger.php | 193 +++++++++++ src/Support/Sms/SmsPolicy.php | 8 + src/Support/Sms/SmsReservation.php | 5 + src/routes.php | 10 + tests/Fixtures/Support/SmsGuardFixture.php | 135 ++++++++ tests/Unit/Console/SmsLedgerPruneTest.php | 61 ++++ .../Http/AuthControllerVerificationTest.php | 179 ----------- tests/Unit/Http/SmsGuardControllerTest.php | 215 +++++++++++++ .../Models/VerificationCodeTracingTest.php | 218 +++++++++++++ .../Notifications/SmsServiceChannelTest.php | 81 +++++ tests/Unit/Support/Sms/SmsGuardLedgerTest.php | 260 +++++++++++++++ tests/Unit/Support/Sms/SmsLedgerTest.php | 122 +++++++ 26 files changed, 2193 insertions(+), 368 deletions(-) create mode 100644 migrations/2026_10_13_000001_create_sms_sends_table.php create mode 100644 migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php create mode 100644 src/Console/Commands/SmsLedgerPrune.php create mode 100644 src/Http/Controllers/Internal/v1/SmsGuardController.php create mode 100644 src/Models/SmsSend.php create mode 100644 src/Notifications/Channels/SmsServiceChannel.php create mode 100644 src/Support/Sms/SmsLedger.php create mode 100644 tests/Fixtures/Support/SmsGuardFixture.php create mode 100644 tests/Unit/Console/SmsLedgerPruneTest.php create mode 100644 tests/Unit/Http/SmsGuardControllerTest.php create mode 100644 tests/Unit/Models/VerificationCodeTracingTest.php create mode 100644 tests/Unit/Notifications/SmsServiceChannelTest.php create mode 100644 tests/Unit/Support/Sms/SmsGuardLedgerTest.php create mode 100644 tests/Unit/Support/Sms/SmsLedgerTest.php diff --git a/config/sms.php b/config/sms.php index 1e430a7c..4f9316a8 100644 --- a/config/sms.php +++ b/config/sms.php @@ -199,6 +199,20 @@ 'alert_percentages' => [50, 80], 'distinct_destinations_per_10m' => 25, 'auto_pauses_before_manual' => 2, + // Sends in 10 minutes to a country the company has not texted in the last + // 30 days, once it has at least `new_country_min_history` ledgered sends. + 'new_country_min_sends' => 10, + 'new_country_min_history' => 50, + // Verification codes issued by SMS in the last hour versus codes actually + // verified: pumping has a conversion of about zero. + 'conversion_min_issued' => 20, + 'conversion_min_percent' => 10, + ], + + // The send ledger (sms_sends): every send and refusal with who, where and why. + 'ledger' => [ + 'retention_days' => env('SMS_GUARD_LEDGER_RETENTION_DAYS', 90), + 'mask_after_days' => env('SMS_GUARD_LEDGER_MASK_AFTER_DAYS', 30), ], // Per-purpose overrides, keyed by the verification code's `for` value. diff --git a/migrations/2026_10_13_000001_create_sms_sends_table.php b/migrations/2026_10_13_000001_create_sms_sends_table.php new file mode 100644 index 00000000..e0db18ee --- /dev/null +++ b/migrations/2026_10_13_000001_create_sms_sends_table.php @@ -0,0 +1,56 @@ +uuid('uuid')->primary(); + $table->char('company_uuid', 36)->nullable()->index(); + $table->string('tenant_type', 32)->nullable(); + $table->char('tenant_uuid', 36)->nullable()->index(); + $table->string('purpose', 64)->index(); + $table->string('source', 32); + $table->string('destination', 32)->nullable(); + $table->string('destination_hash', 64)->nullable()->index(); + $table->char('destination_country', 3)->nullable()->index(); + $table->string('destination_type', 32)->nullable(); + $table->string('status', 16)->index(); + $table->string('block_reason', 64)->nullable(); + $table->string('provider', 32)->nullable(); + $table->string('provider_message_id', 128)->nullable(); + $table->text('error')->nullable(); + $table->string('subject_type')->nullable(); + $table->char('subject_uuid', 36)->nullable(); + $table->char('verification_code_uuid', 36)->nullable()->index(); + $table->string('ip_address', 45)->nullable()->index(); + $table->string('user_agent', 512)->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['company_uuid', 'created_at']); + $table->index(['tenant_uuid', 'created_at']); + $table->index(['status', 'created_at']); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sms_sends'); + } +}; diff --git a/migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php b/migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php new file mode 100644 index 00000000..5eb75bf8 --- /dev/null +++ b/migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php @@ -0,0 +1,50 @@ +identity` for recent rows + * so lookups can move off the JSON path at once. + */ + public function up(): void + { + Schema::table('verification_codes', function (Blueprint $table) { + $table->char('company_uuid', 36)->nullable()->index()->after('subject_type'); + $table->string('identity', 191)->nullable()->after('company_uuid'); + $table->string('channel', 16)->nullable()->after('identity'); + $table->string('ip_address', 45)->nullable()->after('channel'); + $table->string('user_agent', 512)->nullable()->after('ip_address'); + $table->char('sms_send_uuid', 36)->nullable()->index()->after('user_agent'); + $table->dateTime('consumed_at')->nullable()->after('expires_at'); + + $table->index(['for', 'identity', 'created_at']); + }); + + if (DB::connection()->getDriverName() === 'mysql') { + DB::table('verification_codes') + ->whereNull('identity') + ->where('created_at', '>=', now()->subDays(30)) + ->update(['identity' => DB::raw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.identity'))")]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('verification_codes', function (Blueprint $table) { + $table->dropIndex(['for', 'identity', 'created_at']); + $table->dropColumn(['company_uuid', 'identity', 'channel', 'ip_address', 'user_agent', 'sms_send_uuid', 'consumed_at']); + }); + } +}; diff --git a/src/Console/Commands/SmsGuardPause.php b/src/Console/Commands/SmsGuardPause.php index 0111b934..b7e77348 100644 --- a/src/Console/Commands/SmsGuardPause.php +++ b/src/Console/Commands/SmsGuardPause.php @@ -2,7 +2,6 @@ namespace Fleetbase\Console\Commands; -use Fleetbase\Models\Company; use Fleetbase\Support\Sms\SmsBreaker; use Fleetbase\Support\Sms\SmsGuard; use Illuminate\Console\Command; @@ -46,22 +45,11 @@ public function handle(): int */ public static function resolveScope(string $input): ?string { - $input = trim($input); - - if ($input === SmsBreaker::SCOPE_PLATFORM || str_starts_with($input, 'company:') || str_starts_with($input, 'tenant:')) { - return $input; - } - - $company = Company::withoutGlobalScopes() - ->where('uuid', $input) - ->orWhere('public_id', $input) - ->first(['uuid']); - - return $company ? 'company:' . $company->uuid : null; + return SmsBreaker::resolveScope($input); } public static function companyUuidFromScope(string $scope): ?string { - return str_starts_with($scope, 'company:') ? substr($scope, strlen('company:')) : null; + return SmsBreaker::companyUuidFromScope($scope); } } diff --git a/src/Console/Commands/SmsLedgerPrune.php b/src/Console/Commands/SmsLedgerPrune.php new file mode 100644 index 00000000..bb11744b --- /dev/null +++ b/src/Console/Commands/SmsLedgerPrune.php @@ -0,0 +1,36 @@ +option('days') !== null ? (int) $this->option('days') : (int) config('sms.guard.ledger.retention_days', 90); + $maskAfter = $this->option('mask-after') !== null ? (int) $this->option('mask-after') : (int) config('sms.guard.ledger.mask_after_days', 30); + + try { + $result = SmsLedger::prune($retention, $maskAfter); + } catch (\Throwable $e) { + $this->error('The SMS ledger could not be pruned: ' . $e->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf('SMS ledger: masked %d row(s) older than %d day(s), deleted %d row(s) older than %d day(s).', $result['masked'], $maskAfter, $result['deleted'], $retention)); + + return self::SUCCESS; + } +} diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index c7583fa5..844d588c 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -26,7 +26,6 @@ use Fleetbase\Support\Auth; use Fleetbase\Support\TwoFactorAuth; use Fleetbase\Support\Utils; -use Fleetbase\Twilio\Support\Laravel\Facade as Twilio; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; @@ -242,133 +241,6 @@ public function bootstrap(Request $request) ->header('Cache-Control', 'private, max-age=300'); } - /** - * Send a verification SMS code. - * - * @param \\Illuminate\Http\Request $request - * - * @return \Illuminate\Http\Response $response - */ - public function sendVerificationSms(Request $request) - { - // Users phone number - $phone = $queryPhone = $request->input('phone'); - $countryCode = $request->input('countryCode'); - $for = $request->input('driver'); - - // set phone number - if (!Str::startsWith($queryPhone, '+')) { - $queryPhone = '+' . $countryCode . $phone; - } - - // Make sure user exists with phone number - $userExistsQuery = User::where('phone', $queryPhone)->whereNull('deleted_at')->withoutGlobalScopes(); - - if ($for === 'driver') { - $userExistsQuery->where('type', 'driver'); - } - - $userExists = $userExistsQuery->exists(); - - if (!$userExists) { - return response()->error('No user with this phone # found.'); - } - - // Generate hto - $verifyCode = mt_rand(100000, 999999); - $verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_'); - - // Send user their verification code - try { - Twilio::message($queryPhone, 'Your Fleetbase authentication code is ' . $verifyCode); - } catch (\Exception|\Twilio\Exceptions\RestException $e) { - return response()->json(['error' => $e->getMessage()], 400); - } - - // Store verify code for this number with a 10-minute TTL to prevent replay attacks - Redis::setex($verifyCodeKey, 600, $verifyCode); - - // 200 OK - return response()->json(['status' => 'OK']); - } - - /** - * Authenticate a user with SMS code. - * - * @param \\Illuminate\Http\Request $request - * - * @return \Illuminate\Http\Response $response - */ - public function authenticateSmsCode(Request $request) - { - // Users phone number - $phone = $queryPhone = $request->input('phone'); - $countryCode = $request->input('countryCode'); - - // set phone number - if (!Str::startsWith($queryPhone, '+')) { - $queryPhone = '+' . $countryCode . $phone; - } - - // Users verfiy code entered - $verifyCode = $request->input('code'); - $verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_'); - - // Retrieve the stored verification code from Redis - $storedVerifyCode = Redis::get($verifyCodeKey); - - // Retrieve the optional testing bypass code from configuration. - // This is configurable via the SMS_AUTH_BYPASS_CODE environment variable - // and is intended for development/testing environments only. - // It MUST be left unset (null) in production deployments. - $bypassCode = config('fleetbase.sms_auth_bypass_code'); - - // Verify the submitted code against the stored OTP using a constant-time - // comparison to prevent timing attacks. If a bypass code is configured - // and the environment is not production, also allow that code. - $isValidOtp = !empty($storedVerifyCode) && hash_equals((string) $storedVerifyCode, (string) $verifyCode); - $isBypassValid = !empty($bypassCode) && !app()->environment('production') && hash_equals((string) $bypassCode, (string) $verifyCode); - - if (!$isValidOtp && !$isBypassValid) { - return response()->error('Invalid verification code'); - } - - // Remove from redis - Redis::del($verifyCodeKey); - - // get user for phone number - $user = User::where('phone', $queryPhone)->first(); - - // Attempt authentication - if ($user) { - // Set authenticatin user - Auth::login($user); - - // Generate token - try { - $token = $user->createToken($user->phone)->plainTextToken; - } catch (\Exception $e) { - return response()->error($e->getMessage()); - } - - // @codeCoverageIgnoreStart - // The driver relation is supplied by Fleet-Ops; core-api tests cannot load that package relation directly. - if ($user->type === 'driver') { - $user->load(['driver']); - } - // @codeCoverageIgnoreEnd - - // Send message to notify users authentication - return response()->json([ - 'token' => $token, - 'user' => $user, - ]); - } - - // If unable to authenticate user, respond with error - return response()->json('Authentication failed', 401); - } - /** * Create resend verification code session. * @@ -516,41 +388,6 @@ public function verifyEmail(Request $request) ]); } - /** - * Allow user to verify SMS code. - * - * @param \\Illuminate\Http\Request $request - * - * @return \Illuminate\Http\Response $response - */ - public function verifySmsCode(Request $request) - { - // Users phone number - $phone = $request->input('phone'); - - // Users verfiy code entered - $verifyCode = $request->input('code'); - $verifyCodeKey = Str::slug($phone . '_verify_code', '_'); - - // Generate hto - $storedVerifyCode = Redis::get($verifyCodeKey); - - // Verify - if ($verifyCode === $storedVerifyCode) { - // Remove from redis - Redis::del($verifyCodeKey); - - // 200 OK - return response()->json([ - 'status' => 'OK', - 'message' => 'Code verified', - ]); - } - - // 400 ERROR - return response()->error('Invalid verification code'); - } - /** * Creates a new company and user account. * diff --git a/src/Http/Controllers/Internal/v1/SmsGuardController.php b/src/Http/Controllers/Internal/v1/SmsGuardController.php new file mode 100644 index 00000000..2b1fcfd0 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/SmsGuardController.php @@ -0,0 +1,300 @@ +json($this->settingsPayload(SmsPolicy::settings())); + } + + /** + * Save the administrator's policy; it applies within a minute on every worker. + */ + public function saveSettings(AdminRequest $request): JsonResponse + { + $validated = $request->validate([ + 'enabled' => ['required', 'boolean'], + 'mode' => ['required', Rule::in([SmsPolicy::MODE_ENFORCE, SmsPolicy::MODE_MONITOR])], + 'alert_emails' => ['sometimes', 'array'], + 'alert_emails.*' => ['email'], + 'validation' => ['sometimes', 'array'], + 'validation.enabled' => ['sometimes', 'boolean'], + 'validation.allowed_types' => ['sometimes', 'array'], + 'validation.allowed_types.*' => ['string', 'max:32'], + 'validation.blocked_countries' => ['sometimes', 'array'], + 'validation.blocked_countries.*' => ['string', 'size:2'], + 'validation.blocked_prefixes' => ['sometimes', 'array'], + 'validation.blocked_prefixes.*' => ['string', 'max:8'], + 'limits' => ['sometimes', 'array'], + 'limits.destination.cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], + 'limits.destination.per_hour' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.destination.per_day' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.ip.per_15_minutes' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.ip.per_day' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.company.per_minute' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.company.per_hour' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.company.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.tenant.per_minute' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.tenant.per_hour' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.tenant.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.probation.days' => ['nullable', 'integer', 'min:0', 'max:365'], + 'limits.probation.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.platform.public.per_minute' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.public.per_hour' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.public.per_day' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.trusted.per_minute' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.trusted.per_hour' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.trusted.per_day' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'breaker' => ['sometimes', 'array'], + 'breaker.pause_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'], + 'breaker.alert_percentages' => ['sometimes', 'array'], + 'breaker.alert_percentages.*' => ['integer', 'min:1', 'max:99'], + 'breaker.distinct_destinations_per_10m' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'breaker.auto_pauses_before_manual' => ['nullable', 'integer', 'min:1', 'max:100'], + 'breaker.new_country_min_sends' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'breaker.new_country_min_history' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'breaker.conversion_min_issued' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'breaker.conversion_min_percent' => ['nullable', 'integer', 'min:0', 'max:100'], + 'overrides' => ['sometimes', 'array'], + 'overrides.*.company_uuid' => ['required', 'string', 'distinct', Rule::exists('companies', 'uuid')], + 'overrides.*.unlimited' => ['sometimes', 'boolean'], + 'overrides.*.per_minute' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'overrides.*.per_hour' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'overrides.*.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'overrides.*.allowed_countries' => ['sometimes', 'array'], + 'overrides.*.allowed_countries.*' => ['string', 'size:2'], + 'overrides.*.note' => ['nullable', 'string', 'max:255'], + ]); + + // Lists replace, maps merge: an override removed from the list is gone, while the + // pauses and anything not on the page survive the save. + $validated['overrides'] = array_values($validated['overrides'] ?? []); + $validated['alert_emails'] = array_values($validated['alert_emails'] ?? []); + + return response()->json($this->settingsPayload(SmsPolicy::update($validated))); + } + + /** + * Discard the administrator's policy and pauses, back to the environment defaults. + */ + public function resetSettings(AdminRequest $request): JsonResponse + { + return response()->json($this->settingsPayload(SmsPolicy::reset())); + } + + /** + * The pauses in force. + */ + public function pauses(AdminRequest $request): JsonResponse + { + return response()->json(['pauses' => $this->pausesPayload()]); + } + + /** + * Pause a company, a store or network, or the platform. + */ + public function pause(AdminRequest $request): JsonResponse + { + $validated = $request->validate([ + 'scope' => ['required', 'string', 'max:120'], + 'minutes' => ['nullable', 'integer', 'min:1', 'max:525600'], + 'reason' => ['nullable', 'string', 'max:255'], + ]); + + $scope = SmsBreaker::resolveScope($validated['scope']); + if ($scope === null) { + return response()->error('No organization matches that scope.', 422); + } + + SmsGuard::make()->breaker()->pause( + $scope, + isset($validated['minutes']) ? (int) $validated['minutes'] : null, + (string) ($validated['reason'] ?? 'manual'), + false, + SmsBreaker::companyUuidFromScope($scope) + ); + + return response()->json(['pauses' => $this->pausesPayload()]); + } + + /** + * Lift a pause. + */ + public function unpause(AdminRequest $request, string $scope): JsonResponse + { + $resolved = SmsBreaker::resolveScope(urldecode($scope)); + if ($resolved === null) { + return response()->error('No organization matches that scope.', 422); + } + + SmsGuard::make()->breaker()->unpause($resolved); + + return response()->json(['pauses' => $this->pausesPayload()]); + } + + /** + * Sends per organization over a recent window, against their budgets. + */ + public function usage(AdminRequest $request): JsonResponse + { + $request->validate([ + 'hours' => ['sometimes', 'integer', 'min:1', 'max:720'], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:200'], + ]); + + $hours = (int) $request->input('hours', 24); + $limit = (int) $request->input('limit', 50); + $settings = SmsPolicy::settings(); + $since = Carbon::now()->subHours($hours); + + $rows = SmsSend::query() + ->selectRaw('company_uuid, ' + . 'sum(case when status = ? then 1 else 0 end) as sent, ' + . 'sum(case when status = ? then 1 else 0 end) as failed, ' + . 'sum(case when status = ? then 1 else 0 end) as blocked, ' + . 'count(distinct destination_hash) as destinations, ' + . 'max(created_at) as last_send_at', [SmsSend::STATUS_SENT, SmsSend::STATUS_FAILED, SmsSend::STATUS_BLOCKED]) + ->where('created_at', '>=', $since) + ->whereNotNull('company_uuid') + ->groupBy('company_uuid') + ->orderByDesc('sent') + ->limit($limit) + ->get(); + + $companies = Company::whereIn('uuid', $rows->pluck('company_uuid')->all())->get(['uuid', 'public_id', 'name'])->keyBy('uuid'); + + $usage = $rows->map(function ($row) use ($companies, $settings) { + $company = $companies->get($row->company_uuid); + $override = SmsPolicy::overrideFor($row->company_uuid, $settings); + $budget = $override && $override['unlimited'] ? null : ($override['per_day'] ?? $settings['limits']['company']['per_day']); + + return [ + 'company_uuid' => $row->company_uuid, + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + 'sent' => (int) $row->sent, + 'failed' => (int) $row->failed, + 'blocked' => (int) $row->blocked, + 'destinations' => (int) $row->destinations, + 'last_send_at' => $row->last_send_at, + 'daily_budget' => $budget, + 'paused' => SmsGuard::make()->breaker()->pausedUntil('company:' . $row->company_uuid) !== null, + ]; + })->values(); + + return response()->json([ + 'hours' => $hours, + 'ledger_available' => SmsLedger::available(), + 'usage' => $usage, + ]); + } + + /** + * Recent ledger rows, newest first, filtered by organization, status, purpose or number. + */ + public function ledger(AdminRequest $request): JsonResponse + { + $request->validate([ + 'company_uuid' => ['sometimes', 'string', 'max:36'], + 'status' => ['sometimes', Rule::in([SmsSend::STATUS_RESERVED, SmsSend::STATUS_SENT, SmsSend::STATUS_FAILED, SmsSend::STATUS_BLOCKED])], + 'purpose' => ['sometimes', 'string', 'max:64'], + 'destination' => ['sometimes', 'string', 'max:32'], + 'hours' => ['sometimes', 'integer', 'min:1', 'max:2160'], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:500'], + ]); + + $query = SmsSend::query() + ->where('created_at', '>=', Carbon::now()->subHours((int) $request->input('hours', 24))) + ->orderByDesc('created_at') + ->limit((int) $request->input('limit', 100)); + + if ($request->filled('company_uuid')) { + $query->where('company_uuid', $request->input('company_uuid')); + } + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + if ($request->filled('purpose')) { + $query->where('purpose', $request->input('purpose')); + } + if ($request->filled('destination')) { + $query->where('destination_hash', SmsLedger::hash((string) $request->input('destination'))); + } + + return response()->json([ + 'ledger_available' => SmsLedger::available(), + 'sends' => $query->get(), + ]); + } + + protected function settingsPayload(array $settings): array + { + $companies = Company::whereIn('uuid', array_column($settings['overrides'], 'company_uuid')) + ->get(['uuid', 'public_id', 'name']) + ->keyBy('uuid'); + + $settings['overrides'] = array_map(function (array $override) use ($companies) { + $company = $companies->get($override['company_uuid']); + + return array_merge($override, [ + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + ]); + }, $settings['overrides']); + + return [ + 'settings' => $settings, + 'defaults' => SmsPolicy::defaults(), + 'pauses' => $this->pausesPayload(), + 'ledger_available' => SmsLedger::available(), + ]; + } + + /** + * @return array> + */ + protected function pausesPayload(): array + { + $pauses = collect(SmsGuard::make()->breaker()->pauses())->map(function (array $pause, string $scope) { + $pause['company_uuid'] = $pause['company_uuid'] ?: SmsBreaker::companyUuidFromScope($scope); + + return $pause; + }); + $uuids = $pauses->pluck('company_uuid')->filter()->unique()->values()->all(); + $names = $uuids ? Company::whereIn('uuid', $uuids)->get(['uuid', 'public_id', 'name'])->keyBy('uuid') : collect(); + + return $pauses->map(function (array $pause, string $scope) use ($names) { + $company = $pause['company_uuid'] ? $names->get($pause['company_uuid']) : null; + + return array_merge($pause, [ + 'scope' => $scope, + 'platform' => $scope === SmsBreaker::SCOPE_PLATFORM, + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + 'until_iso' => $pause['until'] ? Carbon::createFromTimestamp($pause['until'])->toIso8601String() : null, + ]); + })->values()->all(); + } +} diff --git a/src/Models/SmsSend.php b/src/Models/SmsSend.php new file mode 100644 index 00000000..8b2e2a06 --- /dev/null +++ b/src/Models/SmsSend.php @@ -0,0 +1,69 @@ + Json::class, + ]; + + public function getConnectionName() + { + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); + } + + protected static function booted(): void + { + static::creating(function (self $send) { + if (blank($send->uuid)) { + $send->uuid = (string) Str::uuid(); + } + }); + } +} diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index 15049dee..d31550c7 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -7,6 +7,8 @@ use Fleetbase\Services\SmsService; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsGuard; +use Fleetbase\Support\Sms\SmsLedger; +use Fleetbase\Support\Sms\SmsReservation; use Fleetbase\Support\Utils; use Fleetbase\Traits\Expirable; use Fleetbase\Traits\HasMetaAttributes; @@ -15,6 +17,7 @@ use Fleetbase\Twilio\Support\Laravel\Facade as Twilio; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Mail; class VerificationCode extends Model @@ -36,7 +39,7 @@ class VerificationCode extends Model * * @var array */ - protected $fillable = ['subject_uuid', 'subject_type', 'code', 'for', 'expires_at', 'meta', 'status']; + protected $fillable = ['subject_uuid', 'subject_type', 'code', 'for', 'expires_at', 'meta', 'status', 'company_uuid', 'identity', 'channel', 'ip_address', 'user_agent', 'sms_send_uuid', 'consumed_at']; /** * The attributes that should be cast to native types. @@ -44,10 +47,14 @@ class VerificationCode extends Model * @var array */ protected $casts = [ - 'expires_at' => 'datetime', - 'meta' => Json::class, + 'expires_at' => 'datetime', + 'consumed_at' => 'datetime', + 'meta' => Json::class, ]; + public const STATUS_USED = 'used'; + public const STATUS_LOCKED = 'locked'; + /** * Dynamic attributes that are appended to object. * @@ -167,6 +174,113 @@ public function attemptsLeft(int $maxAttempts = 3): int return max(0, $maxAttempts - (int) $this->getMeta('attempts', 0)); } + /** + * Mark a code used so it cannot verify again. The row stays, for the ledger. + */ + public function consume(): void + { + $this->status = self::STATUS_USED; + if (static::supportsTracingColumns()) { + $this->consumed_at = Carbon::now(); + } + $this->setMeta('consumed_at', Carbon::now()->toIso8601String()); + $this->save(); + } + + /** + * The newest live code filed under an identity for a purpose: not expired (the default + * scope), not used, not locked. Looks at the `identity` column where the table has it + * and at `meta->identity` otherwise, so rows written before the column exists resolve. + */ + public static function findActiveFor(string $for, string $identity, ?string $subjectUuid = null): ?static + { + $query = static::query()->where('for', $for); + + if (static::supportsTracingColumns()) { + $query->where(function ($query) use ($identity) { + $query->where('identity', $identity)->orWhere('meta->identity', $identity); + }); + } else { + $query->where('meta->identity', $identity); + } + + if ($subjectUuid) { + $query->where('subject_uuid', $subjectUuid); + } + + return $query + ->where(function ($query) { + $query->whereNull('status')->orWhereNotIn('status', [self::STATUS_USED, self::STATUS_LOCKED]); + }) + ->orderByDesc('created_at') + ->first(); + } + + /** + * The newest live code for a subject and purpose. + */ + public static function findActiveForSubject(string $for, string $subjectUuid): ?static + { + return static::query() + ->where('for', $for) + ->where('subject_uuid', $subjectUuid) + ->where(function ($query) { + $query->whereNull('status')->orWhereNotIn('status', [self::STATUS_USED, self::STATUS_LOCKED]); + }) + ->orderByDesc('created_at') + ->first(); + } + + /** + * Whether the table has the tracing columns added in October 2026 (company, identity, + * channel, address, ledger link, consumed_at). An install that has not migrated yet, + * and a test schema, keep working with `meta` alone. Remembered in the cache for five + * minutes, so a migration is picked up without a restart. + */ + public static function supportsTracingColumns(): bool + { + $model = new static(); + $check = function () use ($model): bool { + try { + return (bool) $model->getConnection()->getSchemaBuilder()->hasColumn($model->getTable(), 'identity'); + } catch (\Throwable $e) { + return false; + } + }; + + try { + return (bool) Cache::remember('verification_codes:tracing_columns:' . ($model->getConnectionName() ?: 'default'), 300, $check); + } catch (\Throwable $e) { + return $check(); + } + } + + /** + * What a code row keeps about who asked for it, set only where the table has the columns. + */ + protected static function tracingAttributes(?string $companyUuid, string $channel, ?string $identity, ?SmsReservation $reservation = null): array + { + if (!static::supportsTracingColumns()) { + return []; + } + + $request = null; + try { + $request = request(); + } catch (\Throwable $e) { + // no request: a console or queued send + } + + return [ + 'company_uuid' => $companyUuid ?: null, + 'identity' => $identity ?: null, + 'channel' => $channel, + 'ip_address' => $reservation?->context->ip ?? ($request ? $request->ip() : null), + 'user_agent' => $reservation?->context->userAgent ?? ($request && $request->userAgent() ? substr((string) $request->userAgent(), 0, 250) : null), + 'sms_send_uuid' => $reservation?->sendUuid, + ]; + } + /** * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ @@ -218,10 +332,16 @@ public static function generateFor($subject = null, $for = 'general_verification public static function generateEmailVerificationFor($subject, $for = 'email_verification', array $options = []) { $expireAfter = data_get($options, 'expireAfter'); + $meta = (array) data_get($options, 'meta', []); $verificationCode = static::generateFor($subject, $for, false); $verificationCode->expires_at = $expireAfter === null ? Carbon::now()->addHour() : $expireAfter; - $verificationCode->meta = data_get($options, 'meta', []); + $verificationCode->meta = $meta; $verificationCode->status = data_get($options, 'status', 'active'); + $verificationCode->forceFill(static::tracingAttributes( + data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'), + 'email', + (string) (data_get($meta, 'identity') ?? data_get($subject, 'email') ?? '') + )); $verificationCode->save(); if (isset($subject->email)) { @@ -292,16 +412,43 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $meta['trace'] = $reservation->trace(); } + // With `hashed`, the row keeps an HMAC of the code (as issue() does) and the plain + // code exists only in the message and on the returned instance's `plainCode`. + $plainCode = null; + if (data_get($options, 'hashed')) { + $plainCode = (string) random_int(100000, 999999); + $meta['hashed'] = true; + $meta['attempts'] = 0; + } + + $identity = (string) (data_get($meta, 'identity') ?? data_get($meta, 'phone') ?? $destination ?? ''); $verificationCode = static::generateFor($subject, $for, false); - $verificationCode->expires_at = $expireAfter === null ? Carbon::now()->addHour() : $expireAfter; + $verificationCode->expires_at = $expireAfter === null ? ($plainCode !== null ? Carbon::now()->addMinutes(10) : Carbon::now()->addHour()) : $expireAfter; $verificationCode->meta = $meta; + $verificationCode->status = data_get($options, 'status', $plainCode !== null ? 'active' : $verificationCode->status); + if ($plainCode !== null) { + $verificationCode->code = static::hashCode($plainCode); + } + $verificationCode->forceFill(static::tracingAttributes($companyUuid, 'sms', $identity, $reservation)); $verificationCode->save(); + $verificationCode->plainCode = $plainCode; + + if ($reservation && $reservation->sendUuid) { + SmsLedger::update($reservation->sendUuid, ['verification_code_uuid' => $verificationCode->uuid]); + } + + // Get message. A message callback reads `->code` off the instance it is given; for + // a hashed code it gets a copy carrying the plain code, never the stored HMAC. + $forMessage = $verificationCode; + if ($plainCode !== null) { + $forMessage = clone $verificationCode; + $forMessage->code = $plainCode; + } - // Get message - $message = 'Your ' . config('app.name') . ' verification code is ' . $verificationCode->code; + $message = 'Your ' . config('app.name') . ' verification code is ' . $forMessage->code; $messageCallback = data_get($options, 'messageCallback'); if (is_callable($messageCallback)) { - $message = $messageCallback($verificationCode); + $message = $messageCallback($forMessage); } // SMS service options, including the reservation so the send is counted once diff --git a/src/Notifications/Channels/SmsServiceChannel.php b/src/Notifications/Channels/SmsServiceChannel.php new file mode 100644 index 00000000..cb4b744d --- /dev/null +++ b/src/Notifications/Channels/SmsServiceChannel.php @@ -0,0 +1,61 @@ +destinationFor($notifiable, $notification); + if (blank($to)) { + return; + } + + $message = method_exists($notification, 'toSms') ? $notification->toSms($notifiable) : null; + $text = is_object($message) ? (string) ($message->content ?? '') : (string) $message; + if ($text === '') { + return; + } + + $options = [ + 'purpose' => $notification->smsPurpose ?? 'notification:' . class_basename($notification), + 'company_uuid' => $notification->smsCompanyUuid ?? data_get($notifiable, 'company_uuid'), + ]; + + ($this->service ?? app(SmsService::class))->send((string) $to, $text, $options); + } + + protected function destinationFor($notifiable, Notification $notification): ?string + { + if (is_object($notifiable) && method_exists($notifiable, 'routeNotificationFor')) { + foreach (['sms', 'twilio'] as $driver) { + $to = $notifiable->routeNotificationFor($driver, $notification); + if (filled($to)) { + return (string) $to; + } + } + } + + $phone = data_get($notifiable, 'phone'); + + return filled($phone) ? (string) $phone : null; + } +} diff --git a/src/Notifications/SmsGuardAlert.php b/src/Notifications/SmsGuardAlert.php index e83761ad..94d6a48a 100644 --- a/src/Notifications/SmsGuardAlert.php +++ b/src/Notifications/SmsGuardAlert.php @@ -47,6 +47,8 @@ public static function for(string $signal, SmsContext $context, array $data = [] 'distinct_destinations' => ['burst of SMS to new numbers', 'One tenant texted ' . ($data['distinct_destinations'] ?? '?') . ' distinct numbers inside ' . ($data['window_minutes'] ?? 10) . ' minutes. This is the SMS pumping pattern.'], 'company_budget_threshold' => ['tenant at ' . ($data['percent'] ?? '?') . '% of its daily SMS budget', 'The tenant has sent ' . ($data['sends_today'] ?? '?') . ' of ' . ($data['budget'] ?? '?') . ' messages today.'], 'platform_budget_threshold' => ['platform at ' . ($data['percent'] ?? '?') . '% of its hourly SMS budget', 'All tenants together have sent ' . ($data['sends_this_hour'] ?? '?') . ' of ' . ($data['budget'] ?? '?') . ' messages this hour.'], + 'new_destination_country' => ['SMS to a country new to the tenant', 'The tenant sent ' . ($data['sends_to_country'] ?? '?') . ' messages to ' . ($data['country'] ?? '?') . ' inside ' . ($data['window_minutes'] ?? 10) . ' minutes, a country it has not texted in the last 30 days (known: ' . implode(', ', (array) ($data['known_countries'] ?? [])) . ').'], + 'low_verification_conversion' => ['SMS codes going unverified', 'The tenant had ' . ($data['issued_last_hour'] ?? '?') . ' codes sent by SMS in the last hour and ' . ($data['verified_last_hour'] ?? '?') . ' verified. Real customers verify most of what they ask for.'], default => ['SMS guard signal: ' . $signal, 'The SMS guard raised ' . $signal . '.'], }; diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index a7392c06..4abaaaa3 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -106,6 +106,7 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\SmsGuardMode::class, \Fleetbase\Console\Commands\SmsGuardPause::class, \Fleetbase\Console\Commands\SmsGuardUnpause::class, + \Fleetbase\Console\Commands\SmsLedgerPrune::class, ]; /** diff --git a/src/Support/Sms/SmsBreaker.php b/src/Support/Sms/SmsBreaker.php index 35c22410..d017ce42 100644 --- a/src/Support/Sms/SmsBreaker.php +++ b/src/Support/Sms/SmsBreaker.php @@ -2,9 +2,12 @@ namespace Fleetbase\Support\Sms; +use Fleetbase\Models\Company; +use Fleetbase\Models\VerificationCode; use Fleetbase\Notifications\SmsGuardAlert; use Illuminate\Cache\RateLimiter; use Illuminate\Contracts\Cache\Repository as CacheRepository; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Notification; @@ -30,6 +33,8 @@ class SmsBreaker public const SIGNAL_BUDGET_THRESHOLD = 'company_budget_threshold'; public const SIGNAL_DISTINCT_DESTINATIONS = 'distinct_destinations'; public const SIGNAL_PLATFORM_THRESHOLD = 'platform_budget_threshold'; + public const SIGNAL_NEW_COUNTRY = 'new_destination_country'; + public const SIGNAL_LOW_CONVERSION = 'low_verification_conversion'; public const DISTINCT_WINDOW = 600; public const ALERT_WINDOW = 3600; @@ -113,12 +118,41 @@ public function pauses(): array return array_filter(SmsPolicy::settings()['pauses'], fn ($pause) => $pause['until'] === null || $pause['until'] > time()); } + /** + * A scope key for what an operator typed: "platform", a scope key as is, or a company + * uuid or public id resolved to its company scope. Null when no company matches. + */ + public static function resolveScope(string $input): ?string + { + $input = trim($input); + + if ($input === self::SCOPE_PLATFORM || str_starts_with($input, 'company:') || str_starts_with($input, 'tenant:')) { + return $input; + } + + try { + $company = Company::withoutGlobalScopes() + ->where('uuid', $input) + ->orWhere('public_id', $input) + ->first(['uuid']); + } catch (\Throwable $e) { + return null; + } + + return $company ? 'company:' . $company->uuid : null; + } + + public static function companyUuidFromScope(string $scope): ?string + { + return str_starts_with($scope, 'company:') ? substr($scope, strlen('company:')) : null; + } + /** * Read the counters after a reserved send and trip whatever crossed a line. * * @param array $counters the guard's counters for this send */ - public function observe(SmsContext $context, array $limits, array $counters, RateLimiter $limiter, bool $monitor, array $settings): void + public function observe(SmsContext $context, array $limits, array $counters, RateLimiter $limiter, bool $monitor, array $settings, ?string $country = null): void { $breaker = $settings['breaker']; @@ -128,11 +162,89 @@ public function observe(SmsContext $context, array $limits, array $counters, Rat if (!$limits['trusted']) { $this->observeDistinctDestinations($context, $breaker, $monitor); + $this->observeNewCountry($context, $country, $breaker, $monitor); + $this->observeConversion($context, $breaker, $monitor); } $this->observePlatform($context, $counters, $limiter, $breaker, $monitor); } + /** + * A burst of sends to a country the company has never texted. Learned from the ledger + * rather than configured, since tenants' countries cannot be guessed; armed only once + * the company has enough history for "never" to mean something. + */ + protected function observeNewCountry(SmsContext $context, ?string $country, array $breaker, bool $monitor): void + { + $threshold = $breaker['new_country_min_sends'] ?? null; + if (!$threshold || !$country || !$context->companyUuid) { + return; + } + + if (SmsLedger::sentCountFor($context->companyUuid) < ($breaker['new_country_min_history'] ?? 0)) { + return; + } + + $known = SmsLedger::countriesFor($context->companyUuid); + if (!$known || in_array(strtoupper($country), $known, true)) { + return; + } + + $scope = $context->tenantScope() ?? $context->companyScope(); + $count = $this->increment('new-country:' . $scope . ':' . strtoupper($country) . ':' . intdiv(time(), self::DISTINCT_WINDOW), self::DISTINCT_WINDOW); + if ($count >= $threshold) { + $this->trip($scope, self::SIGNAL_NEW_COUNTRY, $context, $breaker, $monitor, ['country' => strtoupper($country), 'sends_to_country' => $count, 'known_countries' => $known, 'window_minutes' => self::DISTINCT_WINDOW / 60]); + } + } + + /** + * Codes issued by SMS in the last hour against codes actually verified. Real customers + * verify most of what they ask for; pumping verifies nothing. + */ + protected function observeConversion(SmsContext $context, array $breaker, bool $monitor): void + { + $minIssued = $breaker['conversion_min_issued'] ?? null; + if (!$minIssued || !$context->companyUuid) { + return; + } + + $stats = $this->conversionFor($context->companyUuid); + if ($stats === null || $stats['issued'] < $minIssued) { + return; + } + + if ($stats['verified'] * 100 < $stats['issued'] * ($breaker['conversion_min_percent'] ?? 0)) { + $this->trip($context->companyScope(), self::SIGNAL_LOW_CONVERSION, $context, $breaker, $monitor, ['issued_last_hour' => $stats['issued'], 'verified_last_hour' => $stats['verified']]); + } + } + + /** + * Codes issued by SMS and verified for a company in the last hour, cached for a minute; + * null until the verification code table carries the tracing columns. + * + * @return array{issued: int, verified: int}|null + */ + protected function conversionFor(string $companyUuid): ?array + { + if (!VerificationCode::supportsTracingColumns()) { + return null; + } + + try { + return $this->cache->remember($this->key('conversion:' . $companyUuid), 60, function () use ($companyUuid) { + $since = Carbon::now()->subHour(); + $base = VerificationCode::withoutGlobalScopes()->where('company_uuid', $companyUuid)->where('channel', 'sms')->where('created_at', '>=', $since); + + return [ + 'issued' => (int) (clone $base)->count(), + 'verified' => (int) (clone $base)->whereNotNull('consumed_at')->count(), + ]; + }); + } catch (\Throwable $e) { + return null; + } + } + protected function observeCompanyBudget(SmsContext $context, array $counters, RateLimiter $limiter, array $breaker, bool $monitor): void { $scope = $context->companyScope(); diff --git a/src/Support/Sms/SmsGuard.php b/src/Support/Sms/SmsGuard.php index a8fc0f71..b783261f 100644 --- a/src/Support/Sms/SmsGuard.php +++ b/src/Support/Sms/SmsGuard.php @@ -3,6 +3,7 @@ namespace Fleetbase\Support\Sms; use Fleetbase\Exceptions\SmsBlockedException; +use Fleetbase\Models\SmsSend; use Illuminate\Cache\RateLimiter; use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Support\Arr; @@ -103,7 +104,7 @@ public function reserve(SmsContext $context): SmsReservation $this->limiter()->hit($counter['key'], $counter['decay']); } - $this->breaker()->observe($context, $limits, $counters, $this->limiter(), $monitor, $settings); + $this->breaker()->observe($context, $limits, $counters, $this->limiter(), $monitor, $settings, $validation['country']); } catch (SmsBlockedException $e) { throw $e; } catch (\Throwable $e) { @@ -116,10 +117,24 @@ public function reserve(SmsContext $context): SmsReservation throw SmsBlockedException::unavailable($context->toArray()); } - return new SmsReservation($context, SmsReservation::DECISION_DEGRADED, null, $validation['country'], $validation['type']); + return $this->ledgered(new SmsReservation($context, SmsReservation::DECISION_DEGRADED, null, $validation['country'], $validation['type'])); } - return new SmsReservation($context, $wouldBlock === null ? SmsReservation::DECISION_ALLOWED : SmsReservation::DECISION_MONITOR, $wouldBlock, $validation['country'], $validation['type']); + return $this->ledgered(new SmsReservation($context, $wouldBlock === null ? SmsReservation::DECISION_ALLOWED : SmsReservation::DECISION_MONITOR, $wouldBlock, $validation['country'], $validation['type'])); + } + + /** + * Put a send that is going ahead on the ledger as reserved; settle() records how it ended. + */ + protected function ledgered(SmsReservation $reservation): SmsReservation + { + $reservation->sendUuid = SmsLedger::record($reservation->context, SmsSend::STATUS_RESERVED, [ + 'destination_country' => $reservation->country, + 'destination_type' => $reservation->type, + 'meta' => array_filter(['decision' => $reservation->decision, 'would_block' => $reservation->wouldBlock]), + ]); + + return $reservation; } /** @@ -144,6 +159,13 @@ public function settle(SmsReservation $reservation, bool $success, array $detail } else { Log::warning('sms.failed', $entry); } + + SmsLedger::update($reservation->sendUuid, [ + 'status' => $success ? SmsSend::STATUS_SENT : SmsSend::STATUS_FAILED, + 'provider' => $details['provider'] ?? null, + 'provider_message_id' => isset($details['message_id']) ? (string) $details['message_id'] : null, + 'error' => $details['error'] ?? null, + ]); } public function breaker(): SmsBreaker @@ -258,6 +280,7 @@ protected function refuse(SmsContext $context, SmsBlockedException $exception, b } Log::warning('sms.blocked', $entry); + SmsLedger::record($context, SmsSend::STATUS_BLOCKED, ['block_reason' => $exception->reason()]); throw $exception; } diff --git a/src/Support/Sms/SmsLedger.php b/src/Support/Sms/SmsLedger.php new file mode 100644 index 00000000..da13281c --- /dev/null +++ b/src/Support/Sms/SmsLedger.php @@ -0,0 +1,193 @@ + $context->companyUuid, + 'tenant_type' => $context->tenantType, + 'tenant_uuid' => $context->tenantUuid, + 'purpose' => $context->purpose, + 'source' => $context->source, + 'destination' => $context->destination, + 'destination_hash' => self::hash($context->destination), + 'status' => $status, + 'subject_type' => $context->subjectType, + 'subject_uuid' => $context->subjectUuid, + 'ip_address' => $context->ip, + 'user_agent' => $context->userAgent, + ], $attributes)); + + return $send->uuid; + } catch (\Throwable $e) { + self::markUnavailable($e); + + return null; + } + } + + /** + * Update a row with how the send ended. + */ + public static function update(?string $uuid, array $attributes): void + { + if (!$uuid || !self::available()) { + return; + } + + try { + SmsSend::where('uuid', $uuid)->update(array_merge($attributes, ['updated_at' => Carbon::now()])); + } catch (\Throwable $e) { + self::markUnavailable($e); + } + } + + /** + * Countries a company has sent to in the last `$days`, cached for ten minutes. + * + * @return array + */ + public static function countriesFor(string $companyUuid, int $days = 30): array + { + if (!self::available()) { + return []; + } + + try { + return Cache::remember('sms-guard:countries:' . $companyUuid, 600, function () use ($companyUuid, $days) { + return SmsSend::where('company_uuid', $companyUuid) + ->where('status', SmsSend::STATUS_SENT) + ->where('created_at', '>=', Carbon::now()->subDays($days)) + ->whereNotNull('destination_country') + ->distinct() + ->pluck('destination_country') + ->map(fn ($country) => strtoupper((string) $country)) + ->values() + ->all(); + }); + } catch (\Throwable $e) { + return []; + } + } + + /** + * How many sends a company has on the ledger in the last `$days`, cached for ten minutes. + */ + public static function sentCountFor(string $companyUuid, int $days = 30): int + { + if (!self::available()) { + return 0; + } + + try { + return (int) Cache::remember('sms-guard:sent-count:' . $companyUuid, 600, function () use ($companyUuid, $days) { + return SmsSend::where('company_uuid', $companyUuid) + ->where('status', SmsSend::STATUS_SENT) + ->where('created_at', '>=', Carbon::now()->subDays($days)) + ->count(); + }); + } catch (\Throwable $e) { + return 0; + } + } + + /** + * Apply retention: mask the full number on rows older than `$maskAfterDays` (the hash + * and the last four digits stay, so per-number limits and forensics still work), and + * delete rows older than `$retentionDays`. + * + * @return array{masked: int, deleted: int} + */ + public static function prune(int $retentionDays, int $maskAfterDays): array + { + $masked = 0; + $deleted = 0; + + if ($retentionDays > 0) { + $deleted = SmsSend::where('created_at', '<', Carbon::now()->subDays($retentionDays))->delete(); + } + + if ($maskAfterDays > 0) { + $rows = SmsSend::where('created_at', '<', Carbon::now()->subDays($maskAfterDays)) + ->whereNotNull('destination') + ->where('destination', 'not like', '•%') + ->get(['uuid', 'destination']); + + foreach ($rows as $row) { + SmsSend::where('uuid', $row->uuid)->update(['destination' => self::mask($row->destination)]); + $masked++; + } + } + + return ['masked' => $masked, 'deleted' => (int) $deleted]; + } + + /** + * `•••4821`: the last four digits. + */ + public static function mask(string $destination): string + { + return '•••' . substr(preg_replace('/\D/', '', $destination) ?? '', -4); + } + + public static function hash(string $destination): string + { + return hash('sha256', PhoneValidator::normalize($destination)); + } + + /** + * Whether the ledger can be written: a database is bound and the table did not fail + * within the last minute. + */ + public static function available(): bool + { + if (Model::getConnectionResolver() === null) { + return false; + } + + try { + return !Cache::has(self::UNAVAILABLE_KEY); + } catch (\Throwable $e) { + return true; + } + } + + private static function markUnavailable(\Throwable $e): void + { + Log::warning('sms.ledger_unavailable', ['error' => $e->getMessage()]); + + try { + Cache::put(self::UNAVAILABLE_KEY, 1, 60); + } catch (\Throwable $ignored) { + // nothing to remember it in; the next write tries again + } + } +} diff --git a/src/Support/Sms/SmsPolicy.php b/src/Support/Sms/SmsPolicy.php index 0fda966e..bc88eace 100644 --- a/src/Support/Sms/SmsPolicy.php +++ b/src/Support/Sms/SmsPolicy.php @@ -222,6 +222,14 @@ public static function normalize(array $settings): array 'alert_percentages' => array_values(array_filter(array_map('intval', static::list($breaker['alert_percentages'] ?? [50, 80])), fn ($pct) => $pct > 0 && $pct < 100)), 'distinct_destinations_per_10m' => static::limit($breaker['distinct_destinations_per_10m'] ?? 25), 'auto_pauses_before_manual' => max(1, (int) ($breaker['auto_pauses_before_manual'] ?? 2)), + 'new_country_min_sends' => static::limit($breaker['new_country_min_sends'] ?? 10), + 'new_country_min_history' => max(0, (int) ($breaker['new_country_min_history'] ?? 50)), + 'conversion_min_issued' => static::limit($breaker['conversion_min_issued'] ?? 20), + 'conversion_min_percent' => max(0, min(100, (int) ($breaker['conversion_min_percent'] ?? 10))), + ], + 'ledger' => [ + 'retention_days' => max(0, (int) data_get($settings, 'ledger.retention_days', 90)), + 'mask_after_days' => max(0, (int) data_get($settings, 'ledger.mask_after_days', 30)), ], 'purposes' => $purposes, 'overrides' => array_values($overrides), diff --git a/src/Support/Sms/SmsReservation.php b/src/Support/Sms/SmsReservation.php index 92a27745..cfa66aec 100644 --- a/src/Support/Sms/SmsReservation.php +++ b/src/Support/Sms/SmsReservation.php @@ -25,6 +25,9 @@ final class SmsReservation public bool $settled = false; + /** the ledger row for this send, when the ledger could be written */ + public ?string $sendUuid = null; + public function __construct( public readonly SmsContext $context, public readonly string $decision = self::DECISION_ALLOWED, @@ -62,6 +65,7 @@ public function trace(): array 'destination_country' => $this->country, 'decision' => $this->decision, 'would_block' => $this->wouldBlock, + 'sms_send_uuid' => $this->sendUuid, ], fn ($value) => $value !== null); } @@ -72,6 +76,7 @@ public function toArray(): array 'would_block' => $this->wouldBlock, 'destination_country' => $this->country, 'destination_type' => $this->type, + 'sms_send_uuid' => $this->sendUuid, ]); } } diff --git a/src/routes.php b/src/routes.php index 9242e3d1..4f325624 100644 --- a/src/routes.php +++ b/src/routes.php @@ -280,6 +280,16 @@ function ($router, $controller) { $router->get('consumers', 'RateLimitController@consumers'); $router->post('consumers/{signature}/reset', 'RateLimitController@resetConsumer'); }); + $router->group(['prefix' => 'sms-guard'], function ($router) { + $router->get('settings', 'SmsGuardController@getSettings'); + $router->post('settings', 'SmsGuardController@saveSettings'); + $router->delete('settings', 'SmsGuardController@resetSettings'); + $router->get('usage', 'SmsGuardController@usage'); + $router->get('ledger', 'SmsGuardController@ledger'); + $router->get('pauses', 'SmsGuardController@pauses'); + $router->post('pauses', 'SmsGuardController@pause'); + $router->delete('pauses/{scope}', 'SmsGuardController@unpause')->where('scope', '.*'); + }); $router->fleetbaseRoutes('schedule-monitor', null, [], function ($router, $controller) { $router->get('tasks', $controller('tasks')); $router->get('{id}/logs', $controller('logs')); diff --git a/tests/Fixtures/Support/SmsGuardFixture.php b/tests/Fixtures/Support/SmsGuardFixture.php new file mode 100644 index 00000000..4f61a2a2 --- /dev/null +++ b/tests/Fixtures/Support/SmsGuardFixture.php @@ -0,0 +1,135 @@ + 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container(array_merge([ + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ], $config)); + $container->instance('cache', new CacheRepository(new ArrayStore())); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + if ($withLedger) { + self::smsSendsSchema($capsule); + } + + return $capsule; + } + + public static function smsSendsSchema(Capsule $capsule, string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('sms_sends', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('tenant_type')->nullable(); + $table->string('tenant_uuid')->nullable(); + $table->string('purpose'); + $table->string('source'); + $table->string('destination')->nullable(); + $table->string('destination_hash')->nullable(); + $table->string('destination_country')->nullable(); + $table->string('destination_type')->nullable(); + $table->string('status'); + $table->string('block_reason')->nullable(); + $table->string('provider')->nullable(); + $table->string('provider_message_id')->nullable(); + $table->text('error')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('verification_code_uuid')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + }); + } + + /** + * The verification_codes table as it looks after the October 2026 tracing migration. + */ + public static function verificationCodesSchema(Capsule $capsule, string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('verification_codes', function ($table) { + $table->string('uuid')->primary(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('identity')->nullable(); + $table->string('channel')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->string('sms_send_uuid')->nullable(); + $table->string('code')->nullable(); + $table->string('for')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('consumed_at')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + } + + public static function companiesSchema(Capsule $capsule, array $rows = [], string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->string('slug')->nullable(); + $table->text('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + if ($rows) { + $capsule->getConnection($connection)->table('companies')->insert($rows); + } + } + + public static function settingsSchema(Capsule $capsule, string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + } +} diff --git a/tests/Unit/Console/SmsLedgerPruneTest.php b/tests/Unit/Console/SmsLedgerPruneTest.php new file mode 100644 index 00000000..a9ca1c9a --- /dev/null +++ b/tests/Unit/Console/SmsLedgerPruneTest.php @@ -0,0 +1,61 @@ + 90, 'sms.guard.ledger.mask_after_days' => 30]); + Carbon::setTestNow(Carbon::parse('2026-10-13 12:00:00', 'UTC')); + + $context = new SmsContext('driver_login', '+447400100777', 'company-1'); + $old = SmsLedger::record($context, 'sent'); + $gone = SmsLedger::record($context, 'sent'); + SmsSend::where('uuid', $old)->update(['created_at' => Carbon::now()->subDays(40)]); + SmsSend::where('uuid', $gone)->update(['created_at' => Carbon::now()->subDays(100)]); + + $command = new SmsLedgerPrune(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + $tester->execute([]); + $maskedDestination = SmsSend::where('uuid', $old)->value('destination'); + + $explicit = new SmsLedgerPrune(); + $explicit->setLaravel(app()); + $explicitTester = new CommandTester($explicit); + $explicitTester->execute(['--days' => '10', '--mask-after' => '0']); + + expect($tester->getStatusCode())->toBe(0) + ->and($tester->getDisplay())->toContain('masked 1 row(s) older than 30 day(s), deleted 1 row(s) older than 90 day(s)') + ->and($maskedDestination)->toBe('•••0777') + ->and($explicitTester->getStatusCode())->toBe(0) + ->and($explicitTester->getDisplay())->toContain('deleted 1 row(s) older than 10 day(s)') + ->and(SmsSend::count())->toBe(0); +}); + +test('sms:ledger:prune fails cleanly without the ledger table', function () { + $capsule = SmsGuardFixture::database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->drop('sms_sends'); + + $command = new SmsLedgerPrune(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + $tester->execute([]); + + expect($tester->getStatusCode())->toBe(1) + ->and($tester->getDisplay())->toContain('The SMS ledger could not be pruned'); +}); diff --git a/tests/Unit/Http/AuthControllerVerificationTest.php b/tests/Unit/Http/AuthControllerVerificationTest.php index 63d1e919..c862e0ed 100644 --- a/tests/Unit/Http/AuthControllerVerificationTest.php +++ b/tests/Unit/Http/AuthControllerVerificationTest.php @@ -533,182 +533,3 @@ function auth_controller_verification_phone_key(string $phone): string ->and($user->last_login)->toBeNull(); } })->with([false, true]); - -test('verify sms code returns success and deletes the consumed verification key', function () { - $redis = auth_controller_verification_fixtures(); - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '765432'; - - $response = (new AuthController())->verifySmsCode(auth_controller_verification_request([ - 'phone' => '+15555550123', - 'code' => '765432', - ])); - - expect($response->getStatusCode())->toBe(200) - ->and($response->getData(true))->toBe([ - 'status' => 'OK', - 'message' => 'Code verified', - ]) - ->and($redis->deleted)->toBe([$key]) - ->and($redis->values)->not->toHaveKey($key); -}); - -test('verify sms code rejects mismatched codes without deleting the stored code', function () { - $redis = auth_controller_verification_fixtures(); - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '765432'; - - $response = (new AuthController())->verifySmsCode(auth_controller_verification_request([ - 'phone' => '+15555550123', - 'code' => '000000', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true))->toBe([ - 'errors' => ['Invalid verification code'], - ]) - ->and($redis->deleted)->toBe([]) - ->and($redis->values[$key])->toBe('765432'); -}); - -test('send verification sms requires a matching user and honors the driver filter', function () { - [$capsule] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'type' => 'user', - ]); - - $missingUser = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '555550123', - 'countryCode' => '1', - ])); - $wrongType = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'driver' => 'driver', - ])); - - expect($missingUser->getStatusCode())->toBe(400) - ->and($missingUser->getData(true))->toBe([ - 'errors' => ['No user with this phone # found.'], - ]) - ->and($wrongType->getStatusCode())->toBe(400) - ->and($wrongType->getData(true))->toBe([ - 'errors' => ['No user with this phone # found.'], - ]); -}); - -test('send verification sms stores a ttl backed code and reports twilio failures', function () { - [$capsule, $redis, , $twilio] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'type' => 'driver', - ]); - - $success = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'driver' => 'driver', - ])); - $key = auth_controller_verification_phone_key('+15555550123'); - - $twilio->exception = new RuntimeException('twilio unavailable'); - $failure = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '+15555550123', - 'countryCode' => '1', - ])); - - expect($success->getStatusCode())->toBe(200) - ->and($success->getData(true))->toBe(['status' => 'OK']) - ->and($twilio->messages)->toHaveCount(1) - ->and($twilio->messages[0]['to'])->toBe('+15555550123') - ->and($twilio->messages[0]['message'])->toStartWith('Your Fleetbase authentication code is ') - ->and($redis->ttls[$key])->toBe(600) - ->and($redis->values[$key])->toBeInt() - ->and($failure->getStatusCode())->toBe(400) - ->and($failure->getData(true))->toBe(['error' => 'twilio unavailable']); -}); - -test('authenticate sms code rejects invalid otp before querying users or deleting redis state', function () { - $redis = auth_controller_verification_fixtures([ - 'fleetbase.sms_auth_bypass_code' => '246810', - ]); - $key = auth_controller_verification_phone_key('+19765550123'); - $redis->values[$key] = '135790'; - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '9765550123', - 'countryCode' => '1', - 'code' => '000000', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true))->toBe([ - 'errors' => ['Invalid verification code'], - ]) - ->and($redis->deleted)->toBe([]) - ->and($redis->values[$key])->toBe('135790'); -}); - -test('authenticate sms code logs in matching users and consumes one time codes', function () { - [$capsule, $redis] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'status' => 'active', - ]); - $auth = new AuthControllerVerificationAuthFake(); - Illuminate\Support\Facades\Auth::swap($auth); - - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '135790'; - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'code' => '135790', - ])); - $payload = $response->getData(true); - - expect($response->getStatusCode())->toBe(200) - ->and($payload['token'])->toContain('|') - ->and($payload['user']['uuid'])->toBe('verification-user') - ->and($auth->loggedIn)->toBe(['verification-user']) - ->and($redis->deleted)->toBe([$key]) - ->and($redis->values)->not->toHaveKey($key); -}); - -test('authenticate sms code reports token creation failures after consuming a valid otp', function () { - [$capsule, $redis] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'status' => 'active', - ]); - Illuminate\Support\Facades\Auth::swap(new AuthControllerVerificationAuthFake()); - - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '135790'; - $capsule->getConnection('mysql')->getSchemaBuilder()->drop('personal_access_tokens'); - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'code' => '135790', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true)['errors'][0])->toContain('personal_access_tokens') - ->and($redis->deleted)->toBe([$key]) - ->and($redis->values)->not->toHaveKey($key); -}); - -test('authenticate sms code reports authentication failure when a valid otp no longer matches a user', function () { - [, $redis] = auth_controller_verification_database(); - $key = auth_controller_verification_phone_key('+19995550123'); - $redis->values[$key] = '135790'; - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '9995550123', - 'countryCode' => '1', - 'code' => '135790', - ])); - - expect($response->getStatusCode())->toBe(401) - ->and($response->getData(true))->toBe('Authentication failed') - ->and($redis->deleted)->toBe([$key]); -}); diff --git a/tests/Unit/Http/SmsGuardControllerTest.php b/tests/Unit/Http/SmsGuardControllerTest.php new file mode 100644 index 00000000..94f0944b --- /dev/null +++ b/tests/Unit/Http/SmsGuardControllerTest.php @@ -0,0 +1,215 @@ + 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'sms.guard' => ['enabled' => true, 'mode' => 'enforce', 'limits' => ['company' => ['per_day' => 200]]], + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + app()->forgetInstance(SmsGuard::class); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + SmsGuardFixture::smsSendsSchema($capsule); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-acme', 'public_id' => 'company_acme', 'name' => 'Acme'], + ['uuid' => 'company-globex', 'public_id' => 'company_globex', 'name' => 'Globex'], + ]); + + $validation = new ValidationFactory(new Translator(new ArrayLoader(), 'en')); + $validation->setPresenceVerifier(new DatabasePresenceVerifier($capsule->getDatabaseManager())); + Request::macro('validate', function (array $rules) use ($validation) { + return $validation->make($this->all(), $rules)->validate(); + }); + + return $capsule; +} + +function sms_guard_controller_request(string $method = 'GET', array $input = []): AdminRequest +{ + return AdminRequest::create('/int/v1/sms-guard/settings', $method, $input); +} + +afterEach(function () { + Carbon::setTestNow(); + + $macros = new ReflectionProperty(Request::class, 'macros'); + $macros->setAccessible(true); + $macros->setValue(null, array_diff_key($macros->getValue(), ['validate' => true])); + + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('sms guard controller returns the effective policy, defaults, pauses and ledger availability', function () { + sms_guard_controller_fixture(); + SmsPolicy::update(['mode' => 'monitor', 'overrides' => [['company_uuid' => 'company-acme', 'per_day' => 2000, 'note' => 'large client']]]); + SmsGuard::make()->breaker()->pause('company:company-globex', 30, 'incident'); + + $payload = (new SmsGuardController())->getSettings(sms_guard_controller_request())->getData(true); + + expect($payload['settings']['mode'])->toBe('monitor') + ->and($payload['settings']['limits']['company']['per_day'])->toBe(200) + ->and($payload['settings']['overrides'][0])->toMatchArray(['company_uuid' => 'company-acme', 'per_day' => 2000, 'note' => 'large client', 'company_id' => 'company_acme', 'company_name' => 'Acme']) + ->and($payload['defaults']['mode'])->toBe('enforce') + ->and($payload['pauses'])->toHaveCount(1) + ->and($payload['pauses'][0])->toMatchArray(['scope' => 'company:company-globex', 'reason' => 'incident', 'company_name' => 'Globex', 'platform' => false]) + ->and($payload['pauses'][0]['until_iso'])->toBeString() + ->and($payload['ledger_available'])->toBeTrue(); +}); + +test('sms guard controller saves a validated policy, replaces the override list and keeps pauses', function () { + sms_guard_controller_fixture(); + SmsGuard::make()->breaker()->pause('platform', null, 'freeze'); + + $saved = (new SmsGuardController())->saveSettings(sms_guard_controller_request('POST', [ + 'enabled' => true, + 'mode' => 'enforce', + 'alert_emails' => ['ops@example.test'], + 'validation' => ['enabled' => true, 'blocked_countries' => ['KP'], 'blocked_prefixes' => ['+881']], + 'limits' => ['company' => ['per_day' => 300, 'per_hour' => null], 'destination' => ['cooldown_seconds' => 30]], + 'breaker' => ['pause_minutes' => 15, 'alert_percentages' => [50], 'new_country_min_sends' => 5], + 'overrides' => [['company_uuid' => 'company-globex', 'unlimited' => true, 'note' => 'partner']], + ]))->getData(true); + + $trimmed = (new SmsGuardController())->saveSettings(sms_guard_controller_request('POST', ['enabled' => true, 'mode' => 'enforce', 'overrides' => []]))->getData(true); + + expect($saved['settings']['alert_emails'])->toBe(['ops@example.test']) + ->and($saved['settings']['validation']['blocked_countries'])->toBe(['KP']) + ->and($saved['settings']['limits']['company'])->toBe(['per_minute' => 10, 'per_hour' => null, 'per_day' => 300]) + ->and($saved['settings']['limits']['destination']['cooldown_seconds'])->toBe(30) + ->and($saved['settings']['breaker']['pause_minutes'])->toBe(15) + ->and($saved['settings']['breaker']['new_country_min_sends'])->toBe(5) + ->and($saved['settings']['overrides'][0])->toMatchArray(['company_uuid' => 'company-globex', 'unlimited' => true, 'company_name' => 'Globex']) + ->and($saved['pauses'][0]['scope'])->toBe('platform') + ->and($trimmed['settings']['overrides'])->toBe([]) + ->and($trimmed['settings']['limits']['company']['per_day'])->toBe(300, 'a save without limits keeps the stored ones') + ->and($trimmed['pauses'][0]['scope'])->toBe('platform', 'saving never drops a pause'); +}); + +test('sms guard controller rejects an unknown organization, a bad mode and a bad email', function () { + sms_guard_controller_fixture(); + + $cases = [ + ['enabled' => true, 'mode' => 'enforce', 'overrides' => [['company_uuid' => 'company-missing', 'per_day' => 5]]], + ['enabled' => true, 'mode' => 'loud'], + ['enabled' => true, 'mode' => 'monitor', 'alert_emails' => ['not-an-email']], + ]; + + foreach ($cases as $input) { + try { + (new SmsGuardController())->saveSettings(sms_guard_controller_request('POST', $input)); + expect(false)->toBeTrue('validation should have failed'); + } catch (ValidationException $e) { + expect($e->errors())->not->toBeEmpty(); + } + } +}); + +test('sms guard controller resets to the defaults and manages pauses through the api', function () { + sms_guard_controller_fixture(); + $controller = new SmsGuardController(); + + $paused = $controller->pause(sms_guard_controller_request('POST', ['scope' => 'company_acme', 'minutes' => 15, 'reason' => 'incident 7']))->getData(true); + $bad = $controller->pause(sms_guard_controller_request('POST', ['scope' => 'company_nobody'])); + $listed = $controller->pauses(sms_guard_controller_request())->getData(true); + $lifted = $controller->unpause(sms_guard_controller_request('DELETE'), 'company%3Acompany-acme')->getData(true); + $badLift = $controller->unpause(sms_guard_controller_request('DELETE'), 'company_nobody'); + + SmsPolicy::update(['mode' => 'monitor']); + $reset = $controller->resetSettings(sms_guard_controller_request('DELETE'))->getData(true); + + expect($paused['pauses'][0])->toMatchArray(['scope' => 'company:company-acme', 'reason' => 'incident 7', 'company_id' => 'company_acme', 'auto' => false]) + ->and($bad->getStatusCode())->toBe(422) + ->and($listed['pauses'])->toHaveCount(1) + ->and($lifted['pauses'])->toBe([]) + ->and($badLift->getStatusCode())->toBe(422) + ->and($reset['settings']['mode'])->toBe('enforce') + ->and(SmsPolicy::stored())->toBe([]); +}); + +test('sms guard controller summarises usage per organization and filters the ledger', function () { + sms_guard_controller_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-13 12:00:00', 'UTC')); + + $acme = new SmsContext('storefront_create_customer', '+447400100001', 'company-acme', 'store', 'store-1', 'storefront', '203.0.113.9'); + $globex = new SmsContext('driver_login', '+447400100002', 'company-globex', null, null, 'api', '198.51.100.7'); + + SmsLedger::update(SmsLedger::record($acme, 'reserved'), ['status' => 'sent']); + SmsLedger::update(SmsLedger::record($acme, 'reserved'), ['status' => 'sent']); + SmsLedger::record($acme, 'blocked', ['block_reason' => 'cooldown']); + SmsLedger::update(SmsLedger::record($globex, 'reserved'), ['status' => 'failed', 'error' => 'carrier']); + SmsLedger::record(new SmsContext('2fa', '+447400100003', null), 'reserved'); + SmsPolicy::update(['overrides' => [['company_uuid' => 'company-globex', 'unlimited' => true]]]); + SmsGuard::make()->breaker()->pause('company:company-acme', 5, 'test'); + + $usage = (new SmsGuardController())->usage(sms_guard_controller_request('GET', ['hours' => 1]))->getData(true); + $ledger = (new SmsGuardController())->ledger(sms_guard_controller_request('GET', ['status' => 'blocked']))->getData(true); + $byNumber = (new SmsGuardController())->ledger(sms_guard_controller_request('GET', ['destination' => '+44 7400 100002', 'company_uuid' => 'company-globex', 'purpose' => 'driver_login']))->getData(true); + + expect($usage['hours'])->toBe(1) + ->and($usage['ledger_available'])->toBeTrue() + ->and($usage['usage'])->toHaveCount(2) + ->and($usage['usage'][0])->toMatchArray(['company_uuid' => 'company-acme', 'company_name' => 'Acme', 'sent' => 2, 'failed' => 0, 'blocked' => 1, 'destinations' => 1, 'daily_budget' => 200, 'paused' => true]) + ->and($usage['usage'][1])->toMatchArray(['company_uuid' => 'company-globex', 'sent' => 0, 'failed' => 1, 'daily_budget' => null, 'paused' => false]) + ->and($ledger['sends'])->toHaveCount(1) + ->and($ledger['sends'][0]['block_reason'])->toBe('cooldown') + ->and($byNumber['sends'])->toHaveCount(1) + ->and($byNumber['sends'][0]['error'])->toBe('carrier') + ->and(SmsSend::count())->toBe(5); +}); diff --git a/tests/Unit/Models/VerificationCodeTracingTest.php b/tests/Unit/Models/VerificationCodeTracingTest.php new file mode 100644 index 00000000..45c32bd7 --- /dev/null +++ b/tests/Unit/Models/VerificationCodeTracingTest.php @@ -0,0 +1,218 @@ +messages[] = compact('to', 'message'); + + return (object) ['sid' => 'SM-traced']; + } +} + +class VerificationCodeTracingMailerFake +{ + public array $sent = []; + + public function to(mixed $recipient): self + { + return $this; + } + + public function send(mixed $mail): void + { + $this->sent[] = $mail; + } +} + +/** + * A verification_codes table WITH the tracing columns, the ledger table, and a guard on. + */ +function verification_code_tracing_database(bool $guard = true): VerificationCodeTracingTwilioFake +{ + EloquentModel::clearBootedModels(); + session()->flush(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'app.name' => 'Fleetbase', + 'app.key' => 'base64:test-app-key', + 'sms.default_provider' => 'twilio', + 'sms.guard' => [ + 'enabled' => $guard, + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'probation' => ['days' => 0]], + 'breaker' => ['alert_percentages' => [], 'distinct_destinations_per_10m' => 500, 'new_country_min_sends' => null, 'conversion_min_issued' => null], + ], + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + $container->instance('request', Request::create('/storefront/v1/customers/request-creation-code', 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_USER_AGENT' => 'StorefrontApp/3.1'])); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + $twilio = new VerificationCodeTracingTwilioFake(); + $container->instance('twilio', $twilio); + $container->instance('mail.manager', new VerificationCodeTracingMailerFake()); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + SmsGuardFixture::verificationCodesSchema($capsule); + SmsGuardFixture::companiesSchema($capsule); + SmsGuardFixture::smsSendsSchema($capsule); + + session(['company' => 'company-1', 'storefront_store' => 'store-1']); + + return $twilio; +} + +function verification_code_tracing_subject(array $attributes = []): User +{ + $user = new User(); + $user->setRawAttributes(array_merge(['uuid' => 'user-traced', 'name' => 'Ada Lovelace', 'phone' => '+447400100123', 'email' => 'ada@example.test'], $attributes), true); + + return $user; +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +it('fills the tracing columns and links the ledger row for an sms code', function () { + $twilio = verification_code_tracing_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_tracing_subject(), 'storefront_create_customer', [ + 'meta' => ['identity' => '+447400100123'], + ]); + + $send = SmsSend::first(); + + expect(VerificationCode::supportsTracingColumns())->toBeTrue() + ->and($code->company_uuid)->toBe('company-1') + ->and($code->identity)->toBe('+447400100123') + ->and($code->channel)->toBe('sms') + ->and($code->ip_address)->toBe('203.0.113.9') + ->and($code->user_agent)->toBe('StorefrontApp/3.1') + ->and($code->sms_send_uuid)->toBe($send->uuid) + ->and($code->consumed_at)->toBeNull() + ->and($send->verification_code_uuid)->toBe($code->uuid) + ->and($send->status)->toBe('sent') + ->and($twilio->messages)->toHaveCount(1); +}); + +it('issues a hashed single-use code when asked, and the message still carries the plain code', function () { + $twilio = verification_code_tracing_database(); + Carbon::setTestNow(Carbon::parse('2026-10-13 10:00:00', 'UTC')); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_tracing_subject(), 'storefront_create_customer', [ + 'hashed' => true, + 'meta' => ['identity' => '+447400100123'], + 'messageCallback' => fn (VerificationCode $verification) => 'Your code is ' . $verification->code, + ]); + + $stored = VerificationCode::withoutGlobalScopes()->where('uuid', $code->uuid)->first(); + $found = VerificationCode::findActiveFor('storefront_create_customer', '+447400100123'); + + expect($code->plainCode)->toMatch('/^\d{6}$/') + ->and($stored->code)->toBe(VerificationCode::hashCode($code->plainCode)) + ->and($stored->code)->not->toBe($code->plainCode) + ->and($stored->meta)->toMatchArray(['identity' => '+447400100123', 'hashed' => true, 'attempts' => 0]) + ->and($stored->status)->toBe('active') + ->and($stored->expires_at->toDateTimeString())->toBe('2026-10-13 10:10:00') + ->and($twilio->messages[0]['message'])->toBe('Your code is ' . $code->plainCode) + ->and($found?->uuid)->toBe($code->uuid) + ->and($found->check('000000'))->toBe(VerificationCode::CHECK_INVALID) + ->and($found->check($code->plainCode))->toBe(VerificationCode::CHECK_VALID); + + $found->consume(); + + expect(VerificationCode::findActiveFor('storefront_create_customer', '+447400100123'))->toBeNull() + ->and($found->fresh()->status)->toBe('used') + ->and($found->fresh()->consumed_at)->not->toBeNull() + ->and($found->fresh()->getMeta('consumed_at'))->not->toBeNull(); +}); + +it('finds live codes by identity or subject, skipping used, locked and expired ones, including legacy rows', function () { + verification_code_tracing_database(false); + $db = EloquentModel::getConnectionResolver()->connection('mysql'); + + $row = fn (string $uuid, string $code, ?string $identity, ?string $subject, ?string $status, Carbon $expires, Carbon $created, ?array $meta = null) => [ + 'uuid' => $uuid, + 'code' => $code, + 'for' => 'storefront_login', + 'identity' => $identity, + 'subject_uuid' => $subject, + 'status' => $status, + 'meta' => $meta ? json_encode($meta) : null, + 'expires_at' => $expires, + 'created_at' => $created, + 'updated_at' => Carbon::now(), + ]; + $db->table('verification_codes')->insert([ + $row('legacy', '111111', null, 'user-1', null, Carbon::now()->addHour(), Carbon::now()->subMinutes(3), ['identity' => '+447400100001']), + $row('used', '222222', '+447400100001', null, 'used', Carbon::now()->addHour(), Carbon::now()->subMinutes(2)), + $row('locked', '333333', '+447400100001', null, 'locked', Carbon::now()->addHour(), Carbon::now()->subMinute()), + $row('expired', '444444', '+447400100001', null, 'active', Carbon::now()->subMinute(), Carbon::now()), + $row('other', '555555', '+447400100002', 'user-2', 'active', Carbon::now()->addHour(), Carbon::now()), + ]); + + expect(VerificationCode::findActiveFor('storefront_login', '+447400100001')?->uuid)->toBe('legacy') + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100001', 'user-1')?->uuid)->toBe('legacy') + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100001', 'user-9'))->toBeNull() + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100002')?->uuid)->toBe('other') + ->and(VerificationCode::findActiveForSubject('storefront_login', 'user-2')?->uuid)->toBe('other') + ->and(VerificationCode::findActiveForSubject('storefront_login', 'user-9'))->toBeNull() + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100001')->check('111111'))->toBe(VerificationCode::CHECK_VALID); +}); + +it('traces email codes too and leaves tracing alone without the guard', function () { + verification_code_tracing_database(false); + + $email = VerificationCode::generateEmailVerificationFor(verification_code_tracing_subject(), 'storefront_create_customer', ['meta' => ['identity' => 'ada@example.test']]); + $sms = VerificationCode::generateSmsVerificationFor(verification_code_tracing_subject(), 'storefront_login'); + + expect($email->channel)->toBe('email') + ->and($email->identity)->toBe('ada@example.test') + ->and($email->company_uuid)->toBe('company-1') + ->and($email->ip_address)->toBe('203.0.113.9') + ->and($sms->channel)->toBe('sms') + ->and($sms->identity)->toBe('+447400100123') + ->and($sms->sms_send_uuid)->toBeNull() + ->and($sms->meta)->toBe([]) + ->and(SmsSend::count())->toBe(0); +}); diff --git a/tests/Unit/Notifications/SmsServiceChannelTest.php b/tests/Unit/Notifications/SmsServiceChannelTest.php new file mode 100644 index 00000000..86918ae4 --- /dev/null +++ b/tests/Unit/Notifications/SmsServiceChannelTest.php @@ -0,0 +1,81 @@ +sent[] = compact('to', 'text', 'options'); + + return ['success' => true]; + } +} + +class SmsServiceChannelTestNotification extends Notification +{ + public string $smsPurpose = 'storefront_order_alert'; + + public function __construct(public mixed $message) + { + } + + public function toSms($notifiable): mixed + { + return $this->message; + } +} + +function sms_service_channel_service(): SmsServiceChannelTestService +{ + bind_test_container(['sms.default_provider' => 'twilio']); + + return new SmsServiceChannelTestService(); +} + +test('sms service channel sends a notification text through SmsService with its purpose', function () { + $service = sms_service_channel_service(); + $channel = new SmsServiceChannel($service); + + $notifiable = new class { + public ?string $company_uuid = 'company-1'; + + public function routeNotificationFor(string $driver, $notification = null): ?string + { + return $driver === 'twilio' ? '+447400100123' : null; + } + }; + + $channel->send($notifiable, new SmsServiceChannelTestNotification('New order!')); + $channel->send($notifiable, new SmsServiceChannelTestNotification((object) ['content' => 'From an object'])); + + expect($service->sent)->toHaveCount(2) + ->and($service->sent[0])->toBe(['to' => '+447400100123', 'text' => 'New order!', 'options' => ['purpose' => 'storefront_order_alert', 'company_uuid' => 'company-1']]) + ->and($service->sent[1]['text'])->toBe('From an object'); +}); + +test('sms service channel falls back to a phone attribute and sends nothing without a number or text', function () { + $service = sms_service_channel_service(); + $channel = new SmsServiceChannel($service); + + $withPhone = new class { + public string $phone = '+447400100124'; + }; + $silent = new class { + public ?string $phone = null; + }; + + $channel->send($withPhone, new SmsServiceChannelTestNotification('Hello')); + $channel->send($silent, new SmsServiceChannelTestNotification('Hello')); + $channel->send($withPhone, new SmsServiceChannelTestNotification('')); + $channel->send($withPhone, new class extends Notification { + }); + + expect($service->sent)->toHaveCount(1) + ->and($service->sent[0]['to'])->toBe('+447400100124') + ->and($service->sent[0]['options']['company_uuid'])->toBeNull(); +}); diff --git a/tests/Unit/Support/Sms/SmsGuardLedgerTest.php b/tests/Unit/Support/Sms/SmsGuardLedgerTest.php new file mode 100644 index 00000000..3b25f9fd --- /dev/null +++ b/tests/Unit/Support/Sms/SmsGuardLedgerTest.php @@ -0,0 +1,260 @@ + */ + public array $alerts = []; + + protected function dispatchAlert(SmsGuardAlert $alert): void + { + $this->alerts[] = $alert; + } +} + +/** + * The guard with a real ledger and verification code table behind it. + * + * @return array{0: SmsGuard, 1: SmsGuardLedgerTestBreaker, 2: Capsule} + */ +function sms_guard_ledger_setup(array $guard = []): array +{ + EloquentModel::clearBootedModels(); + session()->flush(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $config = SmsPolicy::merge([ + 'enabled' => true, + 'mode' => 'enforce', + 'validation' => ['enabled' => true], + 'limits' => [ + 'destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], + 'ip' => ['per_15_minutes' => 500, 'per_day' => 500], + 'company' => ['per_minute' => 500, 'per_hour' => 500, 'per_day' => 500], + 'probation' => ['days' => 0], + 'platform' => ['public' => ['per_minute' => 500, 'per_hour' => 500, 'per_day' => 500]], + ], + 'breaker' => [ + 'alert_percentages' => [], + 'distinct_destinations_per_10m' => 500, + 'new_country_min_sends' => 3, + 'new_country_min_history' => 5, + 'conversion_min_issued' => 20, + 'conversion_min_percent' => 10, + ], + ], $guard); + + $container = bind_test_container([ + 'sms.guard' => $config, + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $cache = new CacheRepository(new ArrayStore()); + $container->instance('cache', $cache); + $container->instance('request', Request::create('/storefront/v1/customers/request-creation-code', 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.9'])); + Facade::clearResolvedInstances(); + session(['company' => 'company-1', 'storefront_store' => 'store-1']); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + SmsGuardFixture::smsSendsSchema($capsule); + SmsGuardFixture::verificationCodesSchema($capsule); + + $breaker = new SmsGuardLedgerTestBreaker($cache); + + return [new SmsGuard($cache, $breaker), $breaker, $capsule]; +} + +function sms_guard_ledger_seed_sent(Capsule $capsule, string $company, string $country, int $count): void +{ + $rows = []; + foreach (range(1, $count) as $i) { + $rows[] = [ + 'uuid' => (string) Str::uuid(), + 'company_uuid' => $company, + 'purpose' => 'storefront_create_customer', + 'source' => 'storefront', + 'destination' => '+4474001' . str_pad((string) $i, 5, '0', STR_PAD_LEFT), + 'destination_country' => $country, + 'status' => 'sent', + 'created_at' => Carbon::now()->subDay(), + 'updated_at' => Carbon::now()->subDay(), + ]; + } + $capsule->getConnection('mysql')->table('sms_sends')->insert($rows); +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('an allowed send is reserved on the ledger and settled with the provider outcome', function () { + [$guard] = sms_guard_ledger_setup(); + + $reservation = $guard->reserve(SmsContext::resolve('+44 7400 100123', ['purpose' => 'storefront_create_customer'])); + $guard->settle($reservation, true, ['provider' => 'twilio', 'message_id' => 'SM1']); + + $failed = $guard->reserve(SmsContext::resolve('+447400100124', ['purpose' => 'storefront_create_customer'])); + $guard->settle($failed, false, ['provider' => 'twilio', 'error' => 'carrier refused']); + + $sent = SmsSend::where('uuid', $reservation->sendUuid)->first(); + $lost = SmsSend::where('uuid', $failed->sendUuid)->first(); + + expect($reservation->sendUuid)->toBeString() + ->and($reservation->trace()['sms_send_uuid'])->toBe($reservation->sendUuid) + ->and($sent->status)->toBe('sent') + ->and($sent->company_uuid)->toBe('company-1') + ->and($sent->tenant_uuid)->toBe('store-1') + ->and($sent->destination)->toBe('+447400100123') + ->and($sent->destination_country)->toBe('GB') + ->and($sent->destination_type)->toBe('MOBILE') + ->and($sent->provider_message_id)->toBe('SM1') + ->and($sent->ip_address)->toBe('203.0.113.9') + ->and($sent->meta)->toBe(['decision' => 'allowed']) + ->and($lost->status)->toBe('failed') + ->and($lost->error)->toBe('carrier refused'); +}); + +test('a refused send is written to the ledger as blocked with its reason', function () { + [$guard] = sms_guard_ledger_setup(); + + try { + $guard->reserve(SmsContext::resolve('+4471', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + // expected + } + + $guard->breaker()->pause('company:company-1', 30, 'incident'); + try { + $guard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + // expected + } + + $rows = SmsSend::orderBy('created_at')->get(); + + expect($rows)->toHaveCount(2) + ->and($rows[0]->status)->toBe('blocked') + ->and($rows[0]->block_reason)->toBe('invalid_destination') + ->and($rows[0]->destination)->toBe('+4471') + ->and($rows[1]->status)->toBe('blocked') + ->and($rows[1]->block_reason)->toBe('paused') + ->and($rows[1]->company_uuid)->toBe('company-1'); +}); + +test('a burst of sends to a country the tenant has never texted pauses it', function () { + [$guard, $breaker, $capsule] = sms_guard_ledger_setup(); + + // Not armed yet: no history. Three sends to a new country go through. + foreach (['+97699112201', '+97699112202', '+97699112203'] as $number) { + $guard->reserve(SmsContext::resolve($number, ['purpose' => 'storefront_create_customer'])); + } + expect($breaker->alerts)->toBe([]) + ->and($breaker->pausedUntil('tenant:store:store-1'))->toBeNull(); + + // With history in GB, the same burst to Mongolia trips the breaker on the third send. + sms_guard_ledger_seed_sent($capsule, 'company-1', 'GB', 6); + Illuminate\Support\Facades\Cache::forget('sms-guard:countries:company-1'); + Illuminate\Support\Facades\Cache::forget('sms-guard:sent-count:company-1'); + + $guard->reserve(SmsContext::resolve('+97699112211', ['purpose' => 'storefront_create_customer'])); + $guard->reserve(SmsContext::resolve('+97699112212', ['purpose' => 'storefront_create_customer'])); + $guard->reserve(SmsContext::resolve('+97699112213', ['purpose' => 'storefront_create_customer'])); + + $refused = null; + try { + $guard->reserve(SmsContext::resolve('+97699112214', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + $refused = $e; + } + + // A known country is still fine for the company's other store. + session(['company' => 'company-1', 'storefront_store' => 'store-2']); + $known = $guard->reserve(SmsContext::resolve('+447400100199', ['purpose' => 'storefront_create_customer'])); + + expect($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_NEW_COUNTRY) + ->and($breaker->alerts[0]->data['country'])->toBe('MN') + ->and($breaker->alerts[0]->data['known_countries'])->toBe(['GB']) + ->and($breaker->alerts[0]->toArray(null)['title'])->toBe('SMS to a country new to the tenant') + ->and($breaker->pausedUntil('tenant:store:store-1')['reason'])->toBe(SmsBreaker::SIGNAL_NEW_COUNTRY) + ->and($refused?->status())->toBe(423) + ->and($known->decision)->toBe('allowed'); +}); + +test('codes that are sent by sms but never verified pause the company', function () { + [$guard, $breaker, $capsule] = sms_guard_ledger_setup(); + $codes = []; + foreach (range(1, 20) as $i) { + $codes[] = [ + 'uuid' => 'code-' . $i, + 'company_uuid' => 'company-1', + 'channel' => 'sms', + 'code' => '123456', + 'for' => 'storefront_create_customer', + 'status' => 'active', + 'expires_at' => Carbon::now()->addMinutes(10), + 'consumed_at' => $i <= 1 ? Carbon::now() : null, + 'created_at' => Carbon::now()->subMinutes(5), + 'updated_at' => Carbon::now()->subMinutes(5), + ]; + } + $capsule->getConnection('mysql')->table('verification_codes')->insert($codes); + + $guard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + + $refused = null; + try { + $guard->reserve(SmsContext::resolve('+447400100124', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + $refused = $e; + } + + expect($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_LOW_CONVERSION) + ->and($breaker->alerts[0]->data)->toMatchArray(['issued_last_hour' => 20, 'verified_last_hour' => 1]) + ->and($breaker->alerts[0]->toArray(null)['title'])->toBe('SMS codes going unverified') + ->and($breaker->pausedUntil('company:company-1'))->not->toBeNull() + ->and($refused?->status())->toBe(423); + + // Healthy conversion trips nothing. + [$healthyGuard, $healthyBreaker, $healthyCapsule] = sms_guard_ledger_setup(); + $healthyCapsule->getConnection('mysql')->table('verification_codes')->insert(array_map(fn ($code) => array_merge($code, ['consumed_at' => Carbon::now()]), $codes)); + $healthyGuard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + + expect($healthyBreaker->alerts)->toBe([]); +}); diff --git a/tests/Unit/Support/Sms/SmsLedgerTest.php b/tests/Unit/Support/Sms/SmsLedgerTest.php new file mode 100644 index 00000000..192d17c7 --- /dev/null +++ b/tests/Unit/Support/Sms/SmsLedgerTest.php @@ -0,0 +1,122 @@ + 'GB', 'destination_type' => 'MOBILE', 'meta' => ['decision' => 'allowed']]); + SmsLedger::update($uuid, ['status' => SmsSend::STATUS_SENT, 'provider' => 'twilio', 'provider_message_id' => 'SM1']); + SmsLedger::update(null, ['status' => 'ignored']); + + $row = SmsSend::where('uuid', $uuid)->first(); + + expect($uuid)->toBeString() + ->and($row->company_uuid)->toBe('company-1') + ->and($row->tenant_type)->toBe('store') + ->and($row->tenant_uuid)->toBe('store-1') + ->and($row->purpose)->toBe('storefront_create_customer') + ->and($row->source)->toBe('storefront') + ->and($row->destination)->toBe('+447400100123') + ->and($row->destination_hash)->toBe(hash('sha256', '+447400100123')) + ->and($row->destination_country)->toBe('GB') + ->and($row->status)->toBe('sent') + ->and($row->provider)->toBe('twilio') + ->and($row->provider_message_id)->toBe('SM1') + ->and($row->ip_address)->toBe('203.0.113.9') + ->and($row->user_agent)->toBe('StorefrontApp/3.1') + ->and($row->meta)->toBe(['decision' => 'allowed']) + ->and(SmsSend::count())->toBe(1); +}); + +test('sms ledger answers country and volume questions from sent rows only', function () { + SmsGuardFixture::database(); + + foreach ([['GB', 'sent'], ['GB', 'sent'], ['MN', 'sent'], ['US', 'blocked'], ['FR', 'failed']] as [$country, $status]) { + SmsLedger::record(sms_ledger_context(), $status, ['destination_country' => strtolower($country)]); + } + SmsLedger::record(sms_ledger_context('+15550001111', 'company-2'), 'sent', ['destination_country' => 'US']); + + expect(SmsLedger::countriesFor('company-1'))->toEqualCanonicalizing(['GB', 'MN']) + ->and(SmsLedger::sentCountFor('company-1'))->toBe(3) + ->and(SmsLedger::sentCountFor('company-2'))->toBe(1) + ->and(SmsLedger::countriesFor('company-none'))->toBe([]); +}); + +test('sms ledger deletes rows past retention and masks the number on older surviving rows', function () { + SmsGuardFixture::database(); + Carbon::setTestNow(Carbon::parse('2026-10-13 12:00:00', 'UTC')); + + $fresh = SmsLedger::record(sms_ledger_context('+447400100001'), 'sent'); + $old = SmsLedger::record(sms_ledger_context('+447400100002'), 'sent'); + $gone = SmsLedger::record(sms_ledger_context('+447400100003'), 'sent'); + SmsSend::where('uuid', $old)->update(['created_at' => Carbon::now()->subDays(45)]); + SmsSend::where('uuid', $gone)->update(['created_at' => Carbon::now()->subDays(120)]); + + $result = SmsLedger::prune(90, 30); + $again = SmsLedger::prune(90, 30); + + expect($result)->toBe(['masked' => 1, 'deleted' => 1]) + ->and($again)->toBe(['masked' => 0, 'deleted' => 0]) + ->and(SmsSend::where('uuid', $fresh)->value('destination'))->toBe('+447400100001') + ->and(SmsSend::where('uuid', $old)->value('destination'))->toBe('•••0002') + ->and(SmsSend::where('uuid', $old)->value('destination_hash'))->toBe(hash('sha256', '+447400100002')) + ->and(SmsSend::where('uuid', $gone)->exists())->toBeFalse() + ->and(SmsLedger::mask('+1 (415) 555-2671'))->toBe('•••2671') + ->and(SmsLedger::prune(0, 0))->toBe(['masked' => 0, 'deleted' => 0]); +}); + +test('sms ledger does nothing without a database', function () { + bind_test_container([]); + Facade::clearResolvedInstances(); + + expect(SmsLedger::available())->toBeFalse() + ->and(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeNull() + ->and(SmsLedger::countriesFor('company-1'))->toBe([]) + ->and(SmsLedger::sentCountFor('company-1'))->toBe(0); + + SmsLedger::update('some-uuid', ['status' => 'sent']); + expect(true)->toBeTrue(); +}); + +test('sms ledger stops writing for a minute after a failure and never throws', function () { + $capsule = SmsGuardFixture::database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->drop('sms_sends'); + + expect(SmsLedger::available())->toBeTrue() + ->and(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeNull() + ->and(Cache::has(SmsLedger::UNAVAILABLE_KEY))->toBeTrue() + ->and(SmsLedger::available())->toBeFalse() + ->and(collect(app('log')->entries)->pluck(1)->all())->toContain('sms.ledger_unavailable'); + + SmsGuardFixture::smsSendsSchema($capsule); + + // Still marked unavailable, so nothing is written even though the table is back. + expect(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeNull() + ->and(SmsSend::count())->toBe(0); + + Cache::forget(SmsLedger::UNAVAILABLE_KEY); + SmsLedger::update('missing-row', ['status' => 'sent']); + + expect(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeString() + ->and(SmsSend::count())->toBe(1); +});