|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Tests\Feature;
|
|
|
|
|
|
|
|
|
|
use App\Models\Competition;
|
|
|
|
|
use App\Models\SmsVerification;
|
|
|
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
|
|
|
use Illuminate\Http\Client\Request;
|
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
|
use Tests\TestCase;
|
|
|
|
|
|
|
|
|
|
class AuthSmsControllerTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
protected function setUp(): void
|
|
|
|
|
{
|
|
|
|
|
parent::setUp();
|
|
|
|
|
|
|
|
|
|
config([
|
|
|
|
|
'app.url' => '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();
|
|
|
|
|
|
|
|
|
|
$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->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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|