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); +});