You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

724 lines
26 KiB

<?php
namespace Tests\Feature;
use App\Models\Competition;
use App\Models\PublicSourceChannel;
use App\Models\SmsVerification;
use App\Support\SmsConfig;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Carbon;
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' => 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms',
'sms.tencentcloud.sdk_app_id' => null,
'sms.tencentcloud.app_key' => 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_public_source_resolve_and_registration_attribution(): void
{
$this->competition('main-event');
$sourceA = PublicSourceChannel::query()->create([
'source_code' => 'poster_A',
'source_name' => '海报 A',
'status' => PublicSourceChannel::STATUS_ENABLED,
]);
$sourceB = PublicSourceChannel::query()->create([
'source_code' => 'poster_B',
'source_name' => '海报 B',
'status' => PublicSourceChannel::STATUS_ENABLED,
]);
PublicSourceChannel::query()->create([
'source_code' => 'closed_src',
'source_name' => '停用渠道',
'status' => PublicSourceChannel::STATUS_DISABLED,
]);
$this->getJson('/api/v1/public/source-channels/resolve?source_code=poster_A')
->assertOk()
->assertJsonPath('valid', true)
->assertJsonPath('source_name', '海报 A');
$this->getJson('/api/v1/public/source-channels/resolve?source_code=closed_src')
->assertOk()
->assertJsonPath('valid', false);
$this->getJson('/api/v1/public/source-channels/resolve?source_code=bad%20code')
->assertOk()
->assertJsonPath('valid', false);
SmsVerification::query()->create([
'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN,
'mobile' => '13800138100',
'code' => '111111',
'provider' => SmsVerification::PROVIDER_DISABLED,
'status' => SmsVerification::STATUS_SENT,
'expires_at' => now()->addMinutes(5),
]);
$this->postJson('/api/auth/sms/login', [
'mobile' => '13800138100',
'code' => '111111',
'competition_slug' => 'main-event',
'public_source_code' => 'poster_A',
])->assertOk();
$this->assertDatabaseHas('users', [
'mobile' => '13800138100',
'public_source_channel_id' => $sourceA->id,
'public_source_code' => 'poster_A',
]);
$this->assertNotNull(DB::table('users')
->where('mobile', '13800138100')
->value('public_source_attributed_at'));
SmsVerification::query()->create([
'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN,
'mobile' => '13800138100',
'code' => '222222',
'provider' => SmsVerification::PROVIDER_DISABLED,
'status' => SmsVerification::STATUS_SENT,
'expires_at' => now()->addMinutes(5),
]);
$this->postJson('/api/auth/sms/login', [
'mobile' => '13800138100',
'code' => '222222',
'competition_slug' => 'main-event',
'public_source_code' => 'poster_B',
])->assertOk();
$this->assertDatabaseHas('users', [
'mobile' => '13800138100',
'public_source_channel_id' => $sourceA->id,
'public_source_code' => 'poster_A',
]);
$this->assertDatabaseMissing('users', [
'mobile' => '13800138100',
'public_source_channel_id' => $sourceB->id,
'public_source_code' => 'poster_B',
]);
SmsVerification::query()->create([
'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN,
'mobile' => '13800138101',
'code' => '333333',
'provider' => SmsVerification::PROVIDER_DISABLED,
'status' => SmsVerification::STATUS_SENT,
'expires_at' => now()->addMinutes(5),
]);
$this->postJson('/api/auth/sms/login', [
'mobile' => '13800138101',
'code' => '333333',
'competition_slug' => 'main-event',
'public_source_code' => 'bad code',
])->assertOk();
$this->assertDatabaseHas('users', [
'mobile' => '13800138101',
'public_source_channel_id' => null,
'public_source_code' => null,
]);
}
public function test_enabled_sender_fails_fast_when_app_key_is_missing(): void
{
config([
'sms.enabled' => true,
'sms.tencentcloud.sdk_app_id' => '1400000000',
]);
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_send_is_rejected_without_record_or_http_call_when_mobile_lock_is_occupied(): void
{
$mobile = '13800138009';
$this->enableTencentCloudConfig();
Http::fake();
$lock = Cache::lock(
'sms-send-lock:'.hash('sha256', $mobile),
max(30, (int) config('sms.timeout', 5) + 10)
);
$this->assertTrue($lock->get());
try {
$this->postJson('/api/auth/sms/send', [
'mobile' => $mobile,
])->assertUnprocessable()
->assertJsonPath('errors.mobile.0', '请勿频繁发送');
} finally {
$lock->release();
}
$this->assertSame(0, SmsVerification::query()->where('mobile', $mobile)->count());
Http::assertNothingSent();
}
public function test_provider_failure_releases_lock_and_allows_send_after_rate_limit_window(): void
{
$mobile = '13800138010';
$startedAt = Carbon::parse('2026-06-06 10:00:00');
$this->enableTencentCloudConfig();
config(['sms.timeout' => 120]);
Http::fakeSequence()
->push([
'result' => 1014,
'errmsg' => 'template incorrect',
'sid' => 'request-failed-lock-release',
], 200)
->push([
'result' => 0,
'errmsg' => 'OK',
'sid' => 'request-after-window',
], 200);
Carbon::setTestNow($startedAt);
try {
$this->postJson('/api/auth/sms/send', [
'mobile' => $mobile,
])->assertStatus(503)
->assertJsonPath('message', '发送失败');
Carbon::setTestNow($startedAt->copy()->addSeconds(61));
$this->postJson('/api/auth/sms/send', [
'mobile' => $mobile,
])->assertOk();
} finally {
Carbon::setTestNow();
}
$this->assertDatabaseHas('sms_verifications', [
'mobile' => $mobile,
'status' => SmsVerification::STATUS_FAILED,
'provider_request_id' => 'request-failed-lock-release',
]);
$this->assertDatabaseHas('sms_verifications', [
'mobile' => $mobile,
'status' => SmsVerification::STATUS_SENT,
'provider_request_id' => 'request-after-window',
]);
Http::assertSentCount(2);
}
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(function (Request $request) {
$this->assertDatabaseHas('sms_verifications', [
'mobile' => '13800138002',
'status' => SmsVerification::STATUS_PENDING,
]);
return Http::response([
'result' => 0,
'errmsg' => 'OK',
'ext' => '',
'fee' => 1,
'sid' => '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);
parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);
$expectedSig = hash(
'sha256',
'appkey=test-app-key'
.'&random='.$query['random']
.'&time='.$payload['time']
.'&mobile='.$payload['tel']['mobile']
);
return parse_url($request->url(), PHP_URL_SCHEME) === 'https'
&& parse_url($request->url(), PHP_URL_HOST) === 'yun.tim.qq.com'
&& parse_url($request->url(), PHP_URL_PATH) === '/v5/tlssmssvr/sendsms'
&& $query['sdkappid'] === '1400000000'
&& preg_match('/^\d+$/', (string) $query['random']) === 1
&& ! $request->hasHeader('Authorization')
&& ! $request->hasHeader('X-TC-Action')
&& $payload['ext'] === ''
&& $payload['extend'] === ''
&& count($payload['params']) === 2
&& preg_match('/^\d{6}$/', (string) $payload['params'][0]) === 1
&& $payload['params'][1] === '5'
&& $payload['sig'] === $expectedSig
&& $payload['sign'] === '元禾控股'
&& $payload['tel'] === ['mobile' => '13800138002', 'nationcode' => '86']
&& is_int($payload['time'])
&& $payload['tpl_id'] === 2175650;
});
$this->assertDatabaseHas('sms_verifications', [
'mobile' => '13800138002',
'provider' => SmsVerification::PROVIDER_TENCENTCLOUD,
'status' => SmsVerification::STATUS_SENT,
'provider_request_id' => 'request-1',
'provider_code' => '0',
'provider_message' => 'OK',
]);
}
public function test_runtime_configuration_has_no_cam_dependency(): void
{
$requiredEnv = SmsConfig::realSendingRequiredEnv();
$this->assertSame('TENCENT_SMS_APP_KEY', $requiredEnv['sms.tencentcloud.app_key']);
$this->assertNotContains('TENCENTCLOUD_SECRET_ID', $requiredEnv);
$this->assertNotContains('TENCENTCLOUD_SECRET_KEY', $requiredEnv);
$this->assertArrayNotHasKey('sms.tencentcloud.region', $requiredEnv);
$this->assertArrayNotHasKey('sms.tencentcloud.secret_id', $requiredEnv);
$this->assertArrayNotHasKey('sms.tencentcloud.secret_key', $requiredEnv);
}
public function test_http_500_with_result_zero_is_recorded_as_failed(): void
{
$this->enableTencentCloudConfig();
Http::fake([
'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([
'result' => 0,
'errmsg' => 'upstream unavailable',
'sid' => 'http-500-sid',
], 500),
]);
$this->postJson('/api/auth/sms/send', [
'mobile' => '13800138014',
])->assertStatus(503)
->assertJsonPath('message', '发送失败');
$record = SmsVerification::query()->where('mobile', '13800138014')->firstOrFail();
$this->assertSame(SmsVerification::STATUS_FAILED, $record->status);
$this->assertSame('HTTP_500', $record->provider_code);
$this->assertSame('http-500-sid', $record->provider_request_id);
$this->assertSame(500, $record->response_json['http_status']);
$this->assertSame(0, $record->response_json['result']);
$this->assertSame('upstream unavailable', $record->response_json['errmsg']);
$this->assertSame(0, SmsVerification::query()
->where('mobile', '13800138014')
->where('status', SmsVerification::STATUS_SENT)
->count());
}
public function test_network_error_is_recorded_with_locatable_code(): void
{
$this->enableTencentCloudConfig();
Http::fake(fn () => throw new \RuntimeException('connection refused'));
$this->postJson('/api/auth/sms/send', [
'mobile' => '13800138011',
])->assertStatus(503);
$this->assertDatabaseHas('sms_verifications', [
'mobile' => '13800138011',
'status' => SmsVerification::STATUS_FAILED,
'provider_code' => 'NETWORK_ERROR',
]);
}
public function test_invalid_json_response_is_recorded_with_locatable_code(): void
{
$this->enableTencentCloudConfig();
Http::fake([
'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response('not-json', 200),
]);
$this->postJson('/api/auth/sms/send', [
'mobile' => '13800138012',
])->assertStatus(503);
$this->assertDatabaseHas('sms_verifications', [
'mobile' => '13800138012',
'status' => SmsVerification::STATUS_FAILED,
'provider_code' => 'INVALID_JSON_RESPONSE',
]);
}
public function test_protocol_error_is_recorded_when_result_is_missing(): void
{
$this->enableTencentCloudConfig();
Http::fake([
'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([
'errmsg' => 'missing result',
'sid' => 'protocol-error-sid',
], 200),
]);
$this->postJson('/api/auth/sms/send', [
'mobile' => '13800138013',
])->assertStatus(503);
$this->assertDatabaseHas('sms_verifications', [
'mobile' => '13800138013',
'status' => SmsVerification::STATUS_FAILED,
'provider_request_id' => 'protocol-error-sid',
'provider_code' => 'PROTOCOL_ERROR',
]);
}
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://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([
'result' => 1014,
'errmsg' => 'template incorrect',
'sid' => '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' => '1014',
'provider_message' => 'template incorrect',
]);
$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://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([
'result' => 0,
'errmsg' => 'OK',
'sid' => '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.sdk_app_id' => '1400000000',
'sms.tencentcloud.app_key' => 'test-app-key',
]);
}
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('company')->nullable();
$table->string('password')->nullable();
$table->foreignId('public_source_channel_id')->nullable();
$table->string('public_source_code', 64)->nullable();
$table->timestamp('public_source_attributed_at')->nullable();
$table->timestamps();
});
Schema::create('public_source_channels', function (Blueprint $table) {
$table->id();
$table->string('source_code', 64)->unique();
$table->string('source_name', 100);
$table->string('status')->default(PublicSourceChannel::STATUS_ENABLED);
$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->string('project_code', 32)->nullable();
$table->foreignId('user_id');
$table->foreignId('competition_id');
$table->string('status')->default('draft');
$table->softDeletes();
$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();
});
}
}