'http://localhost', 'cache.default' => 'array', 'database.default' => 'sqlite', 'database.connections.sqlite' => [ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'foreign_key_constraints' => false, ], 'sms.driver' => 'tencentcloud', 'sms.enabled' => false, 'sms.resend_interval_seconds' => 60, 'sms.code_ttl_seconds' => 300, 'sms.tencentcloud.endpoint' => 'sms.tencentcloudapi.com', 'sms.tencentcloud.region' => 'ap-guangzhou', 'sms.tencentcloud.secret_id' => null, 'sms.tencentcloud.secret_key' => null, 'sms.tencentcloud.sdk_app_id' => null, 'sms.tencentcloud.sign_name' => '元禾控股', 'sms.tencentcloud.template_id' => '2175650', 'sms.tencentcloud.template_param_count' => 2, ]); DB::purge(); DB::reconnect(); Cache::flush(); $this->createSchema(); } public function test_disabled_sender_creates_record_and_login_consumes_it(): void { $this->competition('main-event'); Http::fake(); $send = $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138000', ])->assertOk(); Http::assertNothingSent(); $code = (string) $send->json('debug_code'); $this->assertMatchesRegularExpression('/^\d{6}$/', $code); $record = SmsVerification::query()->firstOrFail(); $this->assertSame(SmsVerification::STATUS_SENT, $record->status); $this->assertSame(SmsVerification::PROVIDER_DISABLED, $record->provider); $this->assertSame('DISABLED_OK', $record->provider_code); $this->assertSame('138****8000', $record->request_payload_json['phone_number']); $this->assertArrayNotHasKey('code', $record->request_payload_json); $this->postJson('/api/auth/sms/login', [ 'mobile' => '13800138000', 'code' => $code, 'competition_slug' => 'main-event', ])->assertOk() ->assertJsonPath('token_type', 'Bearer'); $this->assertDatabaseHas('sms_verifications', [ 'id' => $record->id, 'status' => SmsVerification::STATUS_USED, ]); $this->assertDatabaseHas('applications', [ 'competition_id' => Competition::query()->where('slug', 'main-event')->value('id'), 'status' => 'draft', ]); $this->postJson('/api/auth/sms/login', [ 'mobile' => '13800138000', 'code' => $code, 'competition_slug' => 'main-event', ])->assertUnprocessable(); } public function test_enabled_sender_fails_fast_when_cloud_credentials_are_missing(): void { config(['sms.enabled' => true]); Http::fake(); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138001', ])->assertStatus(503) ->assertJsonPath('message', '短信服务配置未完成'); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138001', 'provider' => SmsVerification::PROVIDER_TENCENTCLOUD, 'status' => SmsVerification::STATUS_FAILED, 'provider_code' => 'CONFIG_INCOMPLETE', ]); Http::assertNothingSent(); } public function test_failed_attempt_still_counts_for_rate_limit(): void { config(['sms.enabled' => true]); Http::fake(); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138008', ])->assertStatus(503); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138008', ])->assertUnprocessable() ->assertJsonValidationErrors('mobile'); $this->assertSame(1, SmsVerification::query()->where('mobile', '13800138008')->count()); } public function test_disabled_sender_does_not_expose_debug_code_in_production(): void { $this->app->detectEnvironment(fn () => 'production'); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138006', ])->assertOk() ->assertJsonMissingPath('debug_code'); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138006', 'provider' => SmsVerification::PROVIDER_DISABLED, 'status' => SmsVerification::STATUS_SENT, ]); } public function test_enabled_sender_posts_tencent_cloud_template_parameters_and_marks_sent(): void { $this->enableTencentCloudConfig(); Http::fake([ 'https://sms.tencentcloudapi.com' => Http::response([ 'Response' => [ 'SendStatusSet' => [[ 'SerialNo' => 'serial-1', 'PhoneNumber' => '+8613800138002', 'Fee' => 1, 'SessionContext' => '', 'Code' => 'Ok', 'Message' => 'send success', 'IsoCode' => 'CN', ]], 'RequestId' => 'request-1', ], ], 200), ]); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138002', ])->assertOk() ->assertJsonMissingPath('debug_code'); Http::assertSent(function (Request $request): bool { $payload = json_decode($request->body(), true); return $request->hasHeader('X-TC-Action', 'SendSms') && $request->hasHeader('X-TC-Version', '2021-01-11') && str_contains((string) $request->header('Authorization')[0], 'TC3-HMAC-SHA256 Credential=test-secret-id/') && $payload['PhoneNumberSet'] === ['+8613800138002'] && $payload['SmsSdkAppId'] === '1400000000' && $payload['SignName'] === '元禾控股' && $payload['TemplateId'] === '2175650' && count($payload['TemplateParamSet']) === 2 && preg_match('/^\d{6}$/', (string) $payload['TemplateParamSet'][0]) === 1 && $payload['TemplateParamSet'][1] === '5'; }); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138002', 'provider' => SmsVerification::PROVIDER_TENCENTCLOUD, 'status' => SmsVerification::STATUS_SENT, 'provider_request_id' => 'request-1', 'provider_code' => 'Ok', ]); } public function test_failed_new_send_keeps_previous_sent_code_usable(): void { $competition = $this->competition('main-event'); $old = SmsVerification::query()->create([ 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, 'mobile' => '13800138003', 'code' => '111111', 'provider' => SmsVerification::PROVIDER_DISABLED, 'status' => SmsVerification::STATUS_SENT, 'expires_at' => now()->addMinutes(5), ]); $old->forceFill([ 'created_at' => now()->subMinutes(2), 'updated_at' => now()->subMinutes(2), ])->save(); $this->enableTencentCloudConfig(); Http::fake([ 'https://sms.tencentcloudapi.com' => Http::response([ 'Response' => [ 'SendStatusSet' => [[ 'Code' => 'FailedOperation.TemplateIncorrect', 'Message' => 'template incorrect', ]], 'RequestId' => 'request-failed', ], ], 200), ]); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138003', ])->assertStatus(503) ->assertJsonPath('message', '发送失败'); $this->assertDatabaseHas('sms_verifications', [ 'id' => $old->id, 'status' => SmsVerification::STATUS_SENT, ]); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138003', 'status' => SmsVerification::STATUS_FAILED, 'provider_request_id' => 'request-failed', 'provider_code' => 'FailedOperation.TemplateIncorrect', ]); $this->postJson('/api/auth/sms/login', [ 'mobile' => '13800138003', 'code' => '111111', 'competition_slug' => $competition->slug, ])->assertOk(); } public function test_successful_new_send_expires_previous_sent_code(): void { $this->competition('main-event'); $old = SmsVerification::query()->create([ 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, 'mobile' => '13800138007', 'code' => '333333', 'provider' => SmsVerification::PROVIDER_DISABLED, 'status' => SmsVerification::STATUS_SENT, 'expires_at' => now()->addMinutes(5), ]); $old->forceFill([ 'created_at' => now()->subMinutes(2), 'updated_at' => now()->subMinutes(2), ])->save(); $oldPending = SmsVerification::query()->create([ 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, 'mobile' => '13800138007', 'code' => '444444', 'provider' => SmsVerification::PROVIDER_TENCENTCLOUD, 'status' => SmsVerification::STATUS_PENDING, 'expires_at' => now()->addMinutes(5), ]); $oldPending->forceFill([ 'created_at' => now()->subMinutes(2), 'updated_at' => now()->subMinutes(2), ])->save(); $this->enableTencentCloudConfig(); Http::fake([ 'https://sms.tencentcloudapi.com' => Http::response([ 'Response' => [ 'SendStatusSet' => [[ 'Code' => 'Ok', 'Message' => 'send success', ]], 'RequestId' => 'request-new', ], ], 200), ]); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138007', ])->assertOk(); $this->assertDatabaseHas('sms_verifications', [ 'id' => $old->id, 'status' => SmsVerification::STATUS_EXPIRED, ]); $this->assertDatabaseHas('sms_verifications', [ 'id' => $oldPending->id, 'status' => SmsVerification::STATUS_EXPIRED, ]); $this->postJson('/api/auth/sms/login', [ 'mobile' => '13800138007', 'code' => '333333', 'competition_slug' => 'main-event', ])->assertUnprocessable() ->assertJsonValidationErrors('code'); } public function test_rate_limit_is_kept_after_successful_send(): void { $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138004', ])->assertOk(); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138004', ])->assertUnprocessable() ->assertJsonValidationErrors('mobile'); } public function test_expired_sent_code_cannot_login(): void { $this->competition('main-event'); SmsVerification::query()->create([ 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, 'mobile' => '13800138005', 'code' => '222222', 'provider' => SmsVerification::PROVIDER_DISABLED, 'status' => SmsVerification::STATUS_SENT, 'expires_at' => now()->subSecond(), ]); $this->postJson('/api/auth/sms/login', [ 'mobile' => '13800138005', 'code' => '222222', 'competition_slug' => 'main-event', ])->assertUnprocessable() ->assertJsonValidationErrors('code'); } private function enableTencentCloudConfig(): void { config([ 'sms.enabled' => true, 'sms.tencentcloud.secret_id' => 'test-secret-id', 'sms.tencentcloud.secret_key' => 'test-secret-key', 'sms.tencentcloud.sdk_app_id' => '1400000000', ]); } private function competition(string $slug): Competition { return Competition::query()->create([ 'slug' => $slug, 'name' => '测试赛事', 'status' => 'published', 'published' => true, ]); } private function createSchema(): void { Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('mobile')->unique(); $table->string('name')->nullable(); $table->string('email')->nullable(); $table->string('password')->nullable(); $table->timestamps(); }); Schema::create('competitions', function (Blueprint $table) { $table->id(); $table->string('slug')->unique(); $table->string('name'); $table->string('status')->default('draft'); $table->boolean('published')->default(false); $table->timestamps(); }); Schema::create('applications', function (Blueprint $table) { $table->id(); $table->foreignId('user_id'); $table->foreignId('competition_id'); $table->string('status')->default('draft'); $table->timestamps(); $table->unique(['user_id', 'competition_id']); }); Schema::create('sms_verifications', function (Blueprint $table) { $table->id(); $table->string('scene', 64)->default('participant_login'); $table->string('mobile', 20)->index(); $table->string('code', 10); $table->string('provider', 32)->default('disabled'); $table->string('status', 16)->default('pending'); $table->string('template_id', 64)->nullable(); $table->string('sign_name', 64)->nullable(); $table->json('request_payload_json')->nullable(); $table->json('response_json')->nullable(); $table->string('provider_request_id', 128)->nullable(); $table->string('provider_code', 128)->nullable(); $table->string('provider_message', 500)->nullable(); $table->timestamp('expires_at'); $table->timestamp('sent_at')->nullable(); $table->timestamp('failed_at')->nullable(); $table->timestamp('used_at')->nullable(); $table->timestamps(); }); Schema::create('personal_access_tokens', function (Blueprint $table) { $table->id(); $table->morphs('tokenable'); $table->string('name'); $table->string('token', 64)->unique(); $table->text('abilities')->nullable(); $table->timestamp('last_used_at')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); }); } }