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.
821 lines
37 KiB
821 lines
37 KiB
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\AdminUser;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationFile;
|
|
use App\Models\Competition;
|
|
use App\Models\CompetitionTrack;
|
|
use App\Models\SignupChannel;
|
|
use App\Models\User;
|
|
use App\Support\ChannelEntryEncryption;
|
|
use App\Support\ChannelEntrySignature;
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Foundation\Testing\TestCase;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Tests\CreatesApplication;
|
|
|
|
class ChannelSignupIntegrationTest extends TestCase
|
|
{
|
|
use CreatesApplication;
|
|
|
|
private string $logPath = '/tmp/cxcyds-channel-signup-test.log';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
config([
|
|
'database.default' => 'sqlite',
|
|
'database.connections.sqlite' => [
|
|
'driver' => 'sqlite',
|
|
'database' => ':memory:',
|
|
'prefix' => '',
|
|
'foreign_key_constraints' => false,
|
|
],
|
|
'logging.default' => 'single',
|
|
'logging.channels.single.path' => $this->logPath,
|
|
'app.url' => 'https://localhost',
|
|
]);
|
|
|
|
DB::purge();
|
|
DB::reconnect();
|
|
Log::forgetChannel();
|
|
@unlink($this->logPath);
|
|
|
|
$this->createSchema();
|
|
}
|
|
|
|
public function test_admin_signup_channel_contract(): void
|
|
{
|
|
Sanctum::actingAs(AdminUser::query()->create([
|
|
'username' => 'admin',
|
|
'password_hash' => 'unused',
|
|
'name' => '管理员',
|
|
'status' => 'active',
|
|
]));
|
|
|
|
$competition = $this->competition('main-event', ['name' => '主赛事']);
|
|
|
|
$create = $this->postJson("/api/v1/admin/competitions/{$competition->id}/signup-channels", [
|
|
'channel_name' => '小程序渠道',
|
|
'shared_secret' => 'secret-A',
|
|
'success_callback_url' => 'https://mini.example.com/result',
|
|
'remark' => '测试',
|
|
])->assertCreated();
|
|
|
|
$code = (string) $create->json('channel_code');
|
|
$this->assertNotSame('', $code);
|
|
$this->assertSame('小程序渠道', $create->json('channel_name'));
|
|
$this->assertSame(SignupChannel::CALLBACK_TYPE_WEB, $create->json('success_callback_type'));
|
|
$this->assertSame(ChannelEntryEncryption::ALGORITHM, $create->json('encryption_algorithm'));
|
|
$this->assertFalse($create->json('entry_encryption_enabled'));
|
|
$this->assertStringContainsString('BEGIN PUBLIC KEY', (string) $create->json('encryption_public_key'));
|
|
$create->assertJsonMissingPath('encryption_private_key');
|
|
$rawPrivateKey = (string) DB::table('signup_channels')
|
|
->where('id', $create->json('id'))
|
|
->value('encryption_private_key');
|
|
$this->assertNotSame('', $rawPrivateKey);
|
|
$this->assertStringNotContainsString('BEGIN PRIVATE KEY', $rawPrivateKey);
|
|
|
|
$this->getJson("/api/v1/admin/competitions/{$competition->id}/signup-channels")
|
|
->assertOk()
|
|
->assertJsonPath('data.0.competition_name', '主赛事')
|
|
->assertJsonMissingPath('data.0.shared_secret');
|
|
|
|
$this->getJson("/api/v1/admin/competitions/{$competition->id}/signup-channels/{$create->json('id')}")
|
|
->assertOk()
|
|
->assertJsonPath('shared_secret', 'secret-A');
|
|
|
|
$this->postJson("/api/v1/admin/competitions/{$competition->id}/signup-channels", [
|
|
'channel_code' => 'client-code',
|
|
'channel_name' => '非法渠道',
|
|
'shared_secret' => 'secret-B',
|
|
'success_callback_url' => 'https://mini.example.com/result',
|
|
])->assertUnprocessable();
|
|
|
|
$withoutCallback = $this->postJson("/api/v1/admin/competitions/{$competition->id}/signup-channels", [
|
|
'channel_name' => '无回调渠道',
|
|
'shared_secret' => 'secret-C',
|
|
])->assertCreated();
|
|
|
|
$this->assertSame('', $withoutCallback->json('success_callback_url'));
|
|
|
|
$this->putJson("/api/v1/admin/competitions/{$competition->id}/signup-channels/{$create->json('id')}", [
|
|
'success_callback_url' => '',
|
|
'entry_encryption_enabled' => true,
|
|
'success_callback_type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM,
|
|
'mini_program_callback_path' => '/pages/signup/result',
|
|
'mini_program_callback_method' => SignupChannel::MINI_PROGRAM_METHOD_REDIRECT_TO,
|
|
])->assertOk()
|
|
->assertJsonPath('success_callback_url', '')
|
|
->assertJsonPath('entry_encryption_enabled', true)
|
|
->assertJsonPath('success_callback_type', SignupChannel::CALLBACK_TYPE_MINI_PROGRAM)
|
|
->assertJsonPath('mini_program_callback_path', '/pages/signup/result');
|
|
|
|
$this->putJson("/api/v1/admin/competitions/{$competition->id}/signup-channels/{$create->json('id')}", [
|
|
'success_callback_type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM,
|
|
'mini_program_callback_path' => 'https://invalid.example.com/page',
|
|
])->assertUnprocessable();
|
|
|
|
$this->putJson("/api/v1/admin/competitions/{$competition->id}/signup-channels/{$create->json('id')}", [
|
|
'success_callback_type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM,
|
|
'mini_program_callback_path' => '',
|
|
])->assertUnprocessable();
|
|
|
|
$this->putJson("/api/v1/admin/competitions/{$competition->id}/signup-channels/{$create->json('id')}", [
|
|
'success_callback_type' => SignupChannel::CALLBACK_TYPE_WEB,
|
|
'success_callback_url' => 'ftp://mini.example.com/result',
|
|
])->assertUnprocessable();
|
|
}
|
|
|
|
public function test_channel_entry_success_errors_context_and_safe_logs(): void
|
|
{
|
|
$competition = $this->competition('main-event');
|
|
$channelA = $this->channel($competition, 'CHANNEL-A', ['shared_secret' => 'secret-A']);
|
|
$channelB = $this->channel($competition, 'CHANNEL-B', ['shared_secret' => 'secret-B']);
|
|
|
|
$response = $this
|
|
->withHeader('X-Forwarded-Proto', 'https')
|
|
->withHeader('X-Forwarded-Port', '443')
|
|
->get('/channel-entry?'.http_build_query($this->signed($channelA)));
|
|
$response->assertOk()
|
|
->assertHeader('Referrer-Policy', 'no-referrer')
|
|
->assertSee("localStorage.setItem('cxxfds_token'", false)
|
|
->assertSee('/admin/c/main-event/apply', false)
|
|
->assertSee('href="https://', false)
|
|
->assertDontSee('href="http://', false)
|
|
->assertSee("const redirectUrl = 'https:\\/\\/", false)
|
|
->assertDontSee("const redirectUrl = 'http:\\/\\/", false);
|
|
$cacheControl = (string) $response->headers->get('Cache-Control');
|
|
foreach (['private', 'no-store', 'no-cache', 'must-revalidate'] as $directive) {
|
|
$this->assertStringContainsString($directive, $cacheControl);
|
|
}
|
|
|
|
$user = User::query()->where('mobile', '13800138000')->firstOrFail();
|
|
$application = Application::query()
|
|
->where('user_id', $user->id)
|
|
->where('competition_id', $competition->id)
|
|
->firstOrFail();
|
|
|
|
$this->assertSame('CHANNEL-A', $user->first_channel_code);
|
|
$this->assertSame('CHANNEL-A', $user->last_channel_code);
|
|
$this->assertSame('CHANNEL-A', $application->signup_channel_code);
|
|
$this->assertSame('draft', $application->status);
|
|
$this->assertSame(1, DB::table('personal_access_tokens')->where('tokenable_id', $user->id)->count());
|
|
|
|
$badHash = $this->signed($channelA);
|
|
$badHash['hash'] = str_repeat('0', 64);
|
|
$this->assertChannelEntryError($badHash, 'HASH_INVALID');
|
|
$this->assertChannelEntryError($this->signed($channelA, ['timestamp' => (string) (time() - 301)]), 'TIMESTAMP_EXPIRED');
|
|
$this->assertChannelEntryError($this->signed($channelA, ['mobile' => '']), 'MOBILE_INVALID');
|
|
|
|
$plaintext = [
|
|
'channel_code' => $channelA->channel_code,
|
|
'timestamp' => (string) time(),
|
|
'name' => '张三',
|
|
'mobile' => '13800138000',
|
|
'company_name' => '测试企业',
|
|
'state' => 'plaintext',
|
|
];
|
|
$plaintext['hash'] = ChannelEntrySignature::make($plaintext, $channelA->shared_secret);
|
|
$this->assertChannelEntryError($plaintext, 'ENCRYPTION_INVALID');
|
|
$encryptedEmptyName = $this->signed($channelA);
|
|
$encryptedEmptyName['name'] = ChannelEntryEncryption::encrypt('', (string) $channelA->encryption_public_key);
|
|
$encryptedEmptyName['hash'] = ChannelEntrySignature::make($encryptedEmptyName, $channelA->shared_secret);
|
|
$this->assertChannelEntryError($encryptedEmptyName, 'ENCRYPTION_INVALID');
|
|
$this->assertChannelEntryError($this->signed($channelA, ['state' => 'a&b=c']), 'HASH_INVALID');
|
|
|
|
$legacyChannel = $this->channel($competition, 'CHANNEL-LEGACY', ['entry_encryption_enabled' => false]);
|
|
$legacyParameters = [
|
|
'channel_code' => $legacyChannel->channel_code,
|
|
'timestamp' => (string) time(),
|
|
'name' => '旧渠道用户',
|
|
'mobile' => '13600136000',
|
|
'company_name' => '旧渠道企业',
|
|
'state' => 'legacy',
|
|
];
|
|
$legacyParameters['hash'] = ChannelEntrySignature::make($legacyParameters, $legacyChannel->shared_secret);
|
|
$this->get('/channel-entry?'.http_build_query($legacyParameters))
|
|
->assertOk()
|
|
->assertSee('/admin/c/main-event/apply', false);
|
|
$legacyParameters['company_name'] = '旧渠道&企业';
|
|
$legacyParameters['hash'] = ChannelEntrySignature::make($legacyParameters, $legacyChannel->shared_secret);
|
|
$this->assertChannelEntryError($legacyParameters, 'HASH_INVALID');
|
|
|
|
$disabled = $this->channel($competition, 'CHANNEL-DISABLED', ['status' => SignupChannel::STATUS_DISABLED]);
|
|
$this->assertChannelEntryError($this->signed($disabled), 'CHANNEL_DISABLED');
|
|
|
|
$emptyOptional = $this->signed($channelA, [
|
|
'mobile' => '13900139000',
|
|
'company_name' => '',
|
|
'state' => '',
|
|
]);
|
|
$this->get('/channel-entry?'.http_build_query($emptyOptional))
|
|
->assertOk()
|
|
->assertSee('/admin/c/main-event/apply', false);
|
|
|
|
$forbiddenControls = $this->signed($channelA, ['mobile' => '13700137000']);
|
|
$forbiddenControls['competition_id'] = '999999';
|
|
$forbiddenControls['competition_slug'] = 'other-event';
|
|
$forbiddenControls['callback_url'] = 'https://evil.example.com/takeover';
|
|
$this->get('/channel-entry?'.http_build_query($forbiddenControls))
|
|
->assertOk()
|
|
->assertSee('/admin/c/main-event/apply', false)
|
|
->assertDontSee('evil.example.com', false);
|
|
|
|
$this->assertChannelEntryError(
|
|
$this->signed($this->channel($this->competition('unpublished', ['published' => false]), 'UNPUBLISHED')),
|
|
'COMPETITION_INVALID'
|
|
);
|
|
$notStarted = $this->competition('not-started', ['signup_open_at' => now()->addHour()]);
|
|
$this->assertChannelEntryError($this->signed($this->channel($notStarted, 'NOT-STARTED')), 'SIGNUP_NOT_STARTED');
|
|
$this->assertChannelEntryError(
|
|
$this->signed($this->channel($this->competition('closed-time', ['signup_close_at' => now()->subMinute()]), 'CLOSED-TIME')),
|
|
'SIGNUP_CLOSED'
|
|
);
|
|
$this->assertChannelEntryError(
|
|
$this->signed($this->channel($this->competition('reviewing', ['status' => 'reviewing']), 'REVIEWING')),
|
|
'SIGNUP_CLOSED'
|
|
);
|
|
$this->assertChannelEntryError(
|
|
$this->signed($this->channel($this->competition('ended', ['status' => 'ended']), 'ENDED')),
|
|
'SIGNUP_CLOSED'
|
|
);
|
|
|
|
$application->fill([
|
|
'player_name' => '保留姓名',
|
|
'company_name' => '保留企业',
|
|
'project_name' => '保留项目',
|
|
'intro' => '保留简介',
|
|
])->save();
|
|
|
|
$this->get('/channel-entry?'.http_build_query($this->signed($channelB, ['state' => 'state-B'])))
|
|
->assertOk();
|
|
|
|
$user->refresh();
|
|
$application->refresh();
|
|
$this->assertSame('CHANNEL-A', $user->first_channel_code);
|
|
$this->assertSame('CHANNEL-B', $user->last_channel_code);
|
|
$this->assertSame('CHANNEL-B', $application->signup_channel_code);
|
|
$this->assertSame('state-B', $application->signup_channel_state);
|
|
$this->assertSame('保留项目', $application->project_name);
|
|
|
|
$logs = file_get_contents($this->logPath) ?: '';
|
|
$this->assertStringContainsString('138****8000', $logs);
|
|
$this->assertStringNotContainsString('13800138000', $logs);
|
|
$this->assertStringNotContainsString('secret-A', $logs);
|
|
$this->assertDoesNotMatchRegularExpression('/\b\d+\|[A-Za-z0-9]{40}\b/', $logs);
|
|
}
|
|
|
|
public function test_artisan_generates_channel_entry_urls_from_database(): void
|
|
{
|
|
$competition = $this->competition('main-event');
|
|
$channel = $this->channel($competition, 'CHANNEL-CLI', ['shared_secret' => 'cli-secret']);
|
|
|
|
$exitCode = Artisan::call('channel:entry-urls', [
|
|
'--competition' => 'main-event',
|
|
'--channel' => 'CHANNEL-CLI',
|
|
'--mobile' => '13800138009',
|
|
'--name' => '命令测试',
|
|
'--company-name' => '测试公司',
|
|
'--state' => 'cli-state',
|
|
'--timestamp' => (string) time(),
|
|
'--base-url' => 'http://localhost',
|
|
'--require-encryption' => true,
|
|
'--plain' => true,
|
|
]);
|
|
|
|
$this->assertSame(0, $exitCode);
|
|
$output = trim(Artisan::output());
|
|
$this->assertStringStartsWith('http://localhost/channel-entry?', $output);
|
|
$this->assertStringNotContainsString('cli-secret', $output);
|
|
$this->assertStringNotContainsString('13800138009', $output);
|
|
$this->assertStringNotContainsString('命令测试', $output);
|
|
$this->assertStringNotContainsString('测试公司', $output);
|
|
|
|
$query = parse_url($output, PHP_URL_QUERY);
|
|
$this->assertIsString($query);
|
|
parse_str($query, $parameters);
|
|
$this->assertSame('CHANNEL-CLI', $parameters['channel_code'] ?? null);
|
|
$this->assertSame(
|
|
ChannelEntrySignature::make($parameters, $channel->shared_secret),
|
|
$parameters['hash'] ?? null
|
|
);
|
|
|
|
$this->get('/channel-entry?'.$query)
|
|
->assertOk()
|
|
->assertSee('/admin/c/main-event/apply', false);
|
|
}
|
|
|
|
public function test_submit_channel_callback_success_fallback_and_logs(): void
|
|
{
|
|
$competition = $this->competition('main-event', [
|
|
'settings' => [
|
|
'success_notice' => [
|
|
'enabled' => true,
|
|
'message' => '请关注后续通知,并保持手机畅通。',
|
|
],
|
|
],
|
|
]);
|
|
$track = CompetitionTrack::query()->create([
|
|
'competition_id' => $competition->id,
|
|
'track_code' => 'consumer',
|
|
'title' => '消费赛道',
|
|
'sort' => 1,
|
|
'is_enabled' => true,
|
|
]);
|
|
$channel = $this->channel($competition, 'CHANNEL-CALLBACK', [
|
|
'shared_secret' => 'callback-secret',
|
|
'success_callback_url' => 'https://mini.example.com/result?keep=1&status=old#done',
|
|
]);
|
|
$user = User::query()->create(['mobile' => '13800138001']);
|
|
$application = Application::query()->create([
|
|
'user_id' => $user->id,
|
|
'competition_id' => $competition->id,
|
|
'signup_channel_id' => $channel->id,
|
|
'signup_channel_code' => $channel->channel_code,
|
|
'signup_channel_state' => 'trace id=1&x',
|
|
'status' => 'draft',
|
|
]);
|
|
ApplicationFile::query()->create([
|
|
'application_id' => $application->id,
|
|
'kind' => 'plan',
|
|
'disk' => 'public',
|
|
'path' => 'plans/a.pdf',
|
|
'original_name' => 'a.pdf',
|
|
'size' => 100,
|
|
'mime' => 'application/pdf',
|
|
]);
|
|
|
|
Sanctum::actingAs($user);
|
|
|
|
$response = $this->postJson('/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code))
|
|
->assertOk()
|
|
->assertJsonPath('status', 'submitted')
|
|
->assertJsonPath('success_notice.enabled', true)
|
|
->assertJsonPath('success_notice.message', '请关注后续通知,并保持手机畅通。')
|
|
->assertJsonPath('channel_callback.type', SignupChannel::CALLBACK_TYPE_WEB)
|
|
->assertJsonStructure(['channel_callback' => ['redirect_url']]);
|
|
|
|
$redirectUrl = (string) $response->json('channel_callback.redirect_url');
|
|
$parts = parse_url($redirectUrl);
|
|
parse_str((string) ($parts['query'] ?? ''), $query);
|
|
|
|
$this->assertSame('https', $parts['scheme'] ?? null);
|
|
$this->assertSame('mini.example.com', $parts['host'] ?? null);
|
|
$this->assertSame('done', $parts['fragment'] ?? null);
|
|
$this->assertSame('1', $query['keep'] ?? null);
|
|
$this->assertSame('submitted', $query['status'] ?? null);
|
|
$this->assertSame('trace id=1&x', $query['state'] ?? null);
|
|
$this->assertSame('CHANNEL-CALLBACK', $query['channel_code'] ?? null);
|
|
$this->assertSame((string) $application->id, $query['application_id'] ?? null);
|
|
$this->assertSame($this->callbackHash($query, 'callback-secret'), $query['hash'] ?? null);
|
|
|
|
$miniProgramUser = User::query()->create(['mobile' => '13800138005']);
|
|
$miniProgramChannel = $this->channel($competition, 'CHANNEL-MINI-PROGRAM', [
|
|
'shared_secret' => 'mini-program-secret',
|
|
'success_callback_type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM,
|
|
'mini_program_callback_path' => '/pages/signup/result?keep=1&status=old',
|
|
'mini_program_callback_method' => SignupChannel::MINI_PROGRAM_METHOD_RE_LAUNCH,
|
|
]);
|
|
$miniProgramApplication = Application::query()->create([
|
|
'user_id' => $miniProgramUser->id,
|
|
'competition_id' => $competition->id,
|
|
'signup_channel_id' => $miniProgramChannel->id,
|
|
'signup_channel_code' => $miniProgramChannel->channel_code,
|
|
'signup_channel_state' => 'mini-state',
|
|
'status' => 'draft',
|
|
]);
|
|
ApplicationFile::query()->create([
|
|
'application_id' => $miniProgramApplication->id,
|
|
'kind' => 'plan',
|
|
'disk' => 'public',
|
|
'path' => 'plans/mini.pdf',
|
|
'original_name' => 'mini.pdf',
|
|
'size' => 100,
|
|
'mime' => 'application/pdf',
|
|
]);
|
|
Sanctum::actingAs($miniProgramUser);
|
|
$miniProgramResponse = $this->postJson(
|
|
'/api/applications/current/submit?competition_slug=main-event',
|
|
$this->submitPayload($track->track_code, 'mini@example.com', '13800138005')
|
|
)->assertOk()
|
|
->assertJsonPath('channel_callback.type', SignupChannel::CALLBACK_TYPE_MINI_PROGRAM)
|
|
->assertJsonPath('channel_callback.method', SignupChannel::MINI_PROGRAM_METHOD_RE_LAUNCH)
|
|
->assertJsonMissingPath('channel_callback.redirect_url');
|
|
|
|
$miniProgramPath = (string) $miniProgramResponse->json('channel_callback.path');
|
|
[$miniBasePath, $miniQueryString] = array_pad(explode('?', $miniProgramPath, 2), 2, '');
|
|
parse_str($miniQueryString, $miniQuery);
|
|
$this->assertSame('/pages/signup/result', $miniBasePath);
|
|
$this->assertSame('1', $miniQuery['keep'] ?? null);
|
|
$this->assertSame('submitted', $miniQuery['status'] ?? null);
|
|
$this->assertSame('mini-state', $miniQuery['state'] ?? null);
|
|
$this->assertSame((string) $miniProgramApplication->id, $miniQuery['application_id'] ?? null);
|
|
$this->assertSame($this->callbackHash($miniQuery, 'mini-program-secret'), $miniQuery['hash'] ?? null);
|
|
|
|
$switchTabUser = User::query()->create(['mobile' => '13800138015']);
|
|
$switchTabChannel = $this->channel($competition, 'CHANNEL-SWITCH-TAB', [
|
|
'shared_secret' => 'switch-tab-secret',
|
|
'success_callback_type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM,
|
|
'mini_program_callback_path' => '/pages/shop/index2?keep=1',
|
|
'mini_program_callback_method' => SignupChannel::MINI_PROGRAM_METHOD_SWITCH_TAB,
|
|
]);
|
|
$switchTabApplication = Application::query()->create([
|
|
'user_id' => $switchTabUser->id,
|
|
'competition_id' => $competition->id,
|
|
'signup_channel_id' => $switchTabChannel->id,
|
|
'signup_channel_code' => $switchTabChannel->channel_code,
|
|
'signup_channel_state' => 'switch-state',
|
|
'status' => 'draft',
|
|
]);
|
|
ApplicationFile::query()->create([
|
|
'application_id' => $switchTabApplication->id,
|
|
'kind' => 'plan',
|
|
'disk' => 'public',
|
|
'path' => 'plans/switch-tab.pdf',
|
|
'original_name' => 'switch-tab.pdf',
|
|
'size' => 100,
|
|
'mime' => 'application/pdf',
|
|
]);
|
|
Sanctum::actingAs($switchTabUser);
|
|
$this->postJson(
|
|
'/api/applications/current/submit?competition_slug=main-event',
|
|
$this->submitPayload($track->track_code, 'switch-tab@example.com', '13800138015')
|
|
)->assertOk()
|
|
->assertJsonPath('channel_callback.type', SignupChannel::CALLBACK_TYPE_MINI_PROGRAM)
|
|
->assertJsonPath('channel_callback.method', SignupChannel::MINI_PROGRAM_METHOD_SWITCH_TAB)
|
|
->assertJsonPath('channel_callback.path', '/pages/shop/index2');
|
|
|
|
$noneUser = User::query()->create(['mobile' => '13800138006']);
|
|
$noneChannel = $this->channel($competition, 'CHANNEL-NONE', [
|
|
'success_callback_type' => SignupChannel::CALLBACK_TYPE_NONE,
|
|
]);
|
|
$noneApplication = Application::query()->create([
|
|
'user_id' => $noneUser->id,
|
|
'competition_id' => $competition->id,
|
|
'signup_channel_id' => $noneChannel->id,
|
|
'signup_channel_code' => $noneChannel->channel_code,
|
|
'signup_channel_state' => 'none-state',
|
|
'status' => 'draft',
|
|
]);
|
|
ApplicationFile::query()->create([
|
|
'application_id' => $noneApplication->id,
|
|
'kind' => 'plan',
|
|
'disk' => 'public',
|
|
'path' => 'plans/none.pdf',
|
|
'original_name' => 'none.pdf',
|
|
'size' => 100,
|
|
'mime' => 'application/pdf',
|
|
]);
|
|
Sanctum::actingAs($noneUser);
|
|
$this->postJson(
|
|
'/api/applications/current/submit?competition_slug=main-event',
|
|
$this->submitPayload($track->track_code, 'none@example.com', '13800138006')
|
|
)->assertOk()
|
|
->assertJsonMissingPath('channel_callback');
|
|
|
|
$ordinaryUser = User::query()->create(['mobile' => '13800138002']);
|
|
$ordinaryApplication = Application::query()->create([
|
|
'user_id' => $ordinaryUser->id,
|
|
'competition_id' => $competition->id,
|
|
'status' => 'draft',
|
|
]);
|
|
ApplicationFile::query()->create([
|
|
'application_id' => $ordinaryApplication->id,
|
|
'kind' => 'plan',
|
|
'disk' => 'public',
|
|
'path' => 'plans/b.pdf',
|
|
'original_name' => 'b.pdf',
|
|
'size' => 100,
|
|
'mime' => 'application/pdf',
|
|
]);
|
|
Sanctum::actingAs($ordinaryUser);
|
|
$this->postJson('/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code, 'ordinary@example.com', '13800138002'))
|
|
->assertOk()
|
|
->assertJsonMissingPath('channel_callback');
|
|
|
|
$channel->update(['success_callback_url' => 'ftp://mini.example.com/result']);
|
|
$fallbackUser = User::query()->create(['mobile' => '13800138003']);
|
|
$fallbackApplication = Application::query()->create([
|
|
'user_id' => $fallbackUser->id,
|
|
'competition_id' => $competition->id,
|
|
'signup_channel_id' => $channel->id,
|
|
'signup_channel_code' => $channel->channel_code,
|
|
'status' => 'draft',
|
|
]);
|
|
ApplicationFile::query()->create([
|
|
'application_id' => $fallbackApplication->id,
|
|
'kind' => 'plan',
|
|
'disk' => 'public',
|
|
'path' => 'plans/c.pdf',
|
|
'original_name' => 'c.pdf',
|
|
'size' => 100,
|
|
'mime' => 'application/pdf',
|
|
]);
|
|
Sanctum::actingAs($fallbackUser);
|
|
$this->postJson('/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code, 'fallback@example.com', '13800138003'))
|
|
->assertOk()
|
|
->assertJsonPath('status', 'submitted')
|
|
->assertJsonMissingPath('channel_callback');
|
|
$this->assertSame('submitted', $fallbackApplication->refresh()->status);
|
|
|
|
$channel->update(['success_callback_url' => 'https://mini.example.com/code-only']);
|
|
$codeOnlyUser = User::query()->create(['mobile' => '13800138004']);
|
|
$codeOnlyApplication = Application::query()->create([
|
|
'user_id' => $codeOnlyUser->id,
|
|
'competition_id' => $competition->id,
|
|
'signup_channel_id' => null,
|
|
'signup_channel_code' => $channel->channel_code,
|
|
'status' => 'draft',
|
|
]);
|
|
ApplicationFile::query()->create([
|
|
'application_id' => $codeOnlyApplication->id,
|
|
'kind' => 'plan',
|
|
'disk' => 'public',
|
|
'path' => 'plans/d.pdf',
|
|
'original_name' => 'd.pdf',
|
|
'size' => 100,
|
|
'mime' => 'application/pdf',
|
|
]);
|
|
Sanctum::actingAs($codeOnlyUser);
|
|
$this->postJson('/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code, 'code-only@example.com', '13800138004'))
|
|
->assertOk()
|
|
->assertJsonStructure(['channel_callback' => ['redirect_url']]);
|
|
|
|
$logs = file_get_contents($this->logPath) ?: '';
|
|
$this->assertStringContainsString('"callback_result":"success"', $logs);
|
|
$this->assertStringContainsString('"callback_result":"failed"', $logs);
|
|
$this->assertStringNotContainsString('13800138001', $logs);
|
|
$this->assertStringNotContainsString('callback-secret', $logs);
|
|
}
|
|
|
|
private function createSchema(): void
|
|
{
|
|
Schema::create('admin_users', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->string('mobile')->nullable();
|
|
$table->string('username')->nullable()->unique();
|
|
$table->string('password_hash')->nullable();
|
|
$table->string('name')->default('');
|
|
$table->string('status')->default('active');
|
|
$table->timestamp('last_login_at')->nullable();
|
|
$table->timestamps();
|
|
});
|
|
Schema::create('competitions', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->string('slug')->unique();
|
|
$table->string('name');
|
|
$table->text('description')->nullable();
|
|
$table->text('pledge_content_html')->nullable();
|
|
$table->string('status')->default('draft');
|
|
$table->boolean('published')->default(false);
|
|
$table->dateTime('signup_open_at')->nullable();
|
|
$table->dateTime('signup_close_at')->nullable();
|
|
$table->foreignId('form_schema_id')->nullable();
|
|
$table->foreignId('review_form_schema_id')->nullable();
|
|
$table->json('branding_json')->nullable();
|
|
$table->json('settings')->nullable();
|
|
$table->json('scoring_rules_json')->nullable();
|
|
$table->timestamps();
|
|
});
|
|
Schema::create('form_schema_definitions', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('competition_id');
|
|
$table->string('purpose');
|
|
$table->string('name');
|
|
$table->unsignedInteger('version')->default(1);
|
|
$table->json('schema_json')->nullable();
|
|
$table->boolean('is_published')->default(true);
|
|
$table->timestamps();
|
|
});
|
|
Schema::create('competition_tracks', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('competition_id');
|
|
$table->string('track_code');
|
|
$table->string('title');
|
|
$table->text('description')->nullable();
|
|
$table->integer('sort')->default(0);
|
|
$table->boolean('is_enabled')->default(true);
|
|
$table->timestamps();
|
|
});
|
|
Schema::create('signup_channels', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('competition_id')->nullable();
|
|
$table->string('channel_code')->unique();
|
|
$table->string('channel_name');
|
|
$table->string('status')->default(SignupChannel::STATUS_ENABLED);
|
|
$table->string('shared_secret');
|
|
$table->string('encryption_algorithm')->nullable();
|
|
$table->boolean('entry_encryption_enabled')->default(false);
|
|
$table->text('encryption_public_key')->nullable();
|
|
$table->text('encryption_private_key')->nullable();
|
|
$table->string('success_callback_url', 2048);
|
|
$table->string('success_callback_type')->default(SignupChannel::CALLBACK_TYPE_WEB);
|
|
$table->string('mini_program_callback_path')->nullable();
|
|
$table->string('mini_program_callback_method')->default(SignupChannel::MINI_PROGRAM_METHOD_REDIRECT_TO);
|
|
$table->text('remark')->nullable();
|
|
$table->timestamps();
|
|
});
|
|
Schema::create('users', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->string('mobile')->unique();
|
|
$table->string('name')->nullable();
|
|
$table->string('email')->nullable()->unique();
|
|
$table->timestamp('email_verified_at')->nullable();
|
|
$table->string('password')->nullable();
|
|
$table->rememberToken();
|
|
$table->foreignId('first_channel_id')->nullable();
|
|
$table->string('first_channel_code')->nullable();
|
|
$table->timestamp('first_channel_entered_at')->nullable();
|
|
$table->foreignId('last_channel_id')->nullable();
|
|
$table->string('last_channel_code')->nullable();
|
|
$table->timestamp('last_channel_entered_at')->nullable();
|
|
$table->timestamps();
|
|
});
|
|
Schema::create('applications', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('user_id');
|
|
$table->foreignId('competition_id');
|
|
$table->foreignId('signup_channel_id')->nullable();
|
|
$table->string('signup_channel_code')->nullable();
|
|
$table->string('signup_channel_state', 2048)->nullable();
|
|
$table->timestamp('signup_channel_entered_at')->nullable();
|
|
$table->string('status')->default('draft');
|
|
$table->string('player_name')->nullable();
|
|
$table->string('school')->nullable();
|
|
$table->string('degree')->nullable();
|
|
$table->string('contact_email')->nullable();
|
|
$table->string('contact_mobile')->nullable();
|
|
$table->string('entry_group')->nullable();
|
|
$table->string('company_name')->nullable();
|
|
$table->string('project_name')->nullable();
|
|
$table->string('track')->nullable();
|
|
$table->string('location_country')->nullable();
|
|
$table->string('location_province')->nullable();
|
|
$table->string('location_city')->nullable();
|
|
$table->string('oversea_country')->nullable();
|
|
$table->text('intro')->nullable();
|
|
$table->timestamp('promise_signed_at')->nullable();
|
|
$table->mediumText('promise_signature')->nullable();
|
|
$table->timestamp('submitted_at')->nullable();
|
|
$table->timestamps();
|
|
$table->unique(['user_id', 'competition_id']);
|
|
});
|
|
Schema::create('application_files', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('application_id');
|
|
$table->string('kind');
|
|
$table->string('disk')->default('public');
|
|
$table->string('path');
|
|
$table->string('original_name');
|
|
$table->unsignedBigInteger('size')->default(0);
|
|
$table->string('mime')->nullable();
|
|
$table->timestamps();
|
|
});
|
|
Schema::create('application_review_records', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('application_id');
|
|
$table->timestamps();
|
|
});
|
|
Schema::create('application_review_scores', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('application_id');
|
|
$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();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
private function competition(string $slug, array $overrides = []): Competition
|
|
{
|
|
return Competition::query()->create(array_merge([
|
|
'slug' => $slug,
|
|
'name' => $slug,
|
|
'status' => 'signup_open',
|
|
'published' => true,
|
|
'signup_open_at' => now()->subHour(),
|
|
'signup_close_at' => now()->addHour(),
|
|
], $overrides));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
private function channel(Competition $competition, string $code, array $overrides = []): SignupChannel
|
|
{
|
|
$channel = new SignupChannel(array_merge([
|
|
'channel_name' => $code,
|
|
'status' => SignupChannel::STATUS_ENABLED,
|
|
'shared_secret' => "secret-{$code}",
|
|
'entry_encryption_enabled' => true,
|
|
'success_callback_url' => 'https://callback.example.com/result',
|
|
'success_callback_type' => SignupChannel::CALLBACK_TYPE_WEB,
|
|
'mini_program_callback_path' => null,
|
|
'mini_program_callback_method' => SignupChannel::MINI_PROGRAM_METHOD_REDIRECT_TO,
|
|
], $overrides));
|
|
$channel->channel_code = $code;
|
|
$competition->signupChannels()->save($channel);
|
|
|
|
return $channel;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $overrides
|
|
* @return array<string, string>
|
|
*/
|
|
private function signed(SignupChannel $channel, array $overrides = []): array
|
|
{
|
|
$plaintext = array_merge([
|
|
'channel_code' => $channel->channel_code,
|
|
'timestamp' => (string) time(),
|
|
'name' => '张三',
|
|
'mobile' => '13800138000',
|
|
'company_name' => '测试企业',
|
|
'state' => 'state-A',
|
|
], $overrides);
|
|
$parameters = $plaintext;
|
|
$parameters['name'] = $plaintext['name'] === ''
|
|
? ''
|
|
: ChannelEntryEncryption::encrypt($plaintext['name'], (string) $channel->encryption_public_key);
|
|
$parameters['mobile'] = $plaintext['mobile'] === ''
|
|
? ''
|
|
: ChannelEntryEncryption::encrypt($plaintext['mobile'], (string) $channel->encryption_public_key);
|
|
$parameters['company_name'] = $plaintext['company_name'] === ''
|
|
? ''
|
|
: ChannelEntryEncryption::encrypt($plaintext['company_name'], (string) $channel->encryption_public_key);
|
|
$parameters['hash'] = ChannelEntrySignature::make($parameters, $channel->shared_secret);
|
|
|
|
return $parameters;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $parameters
|
|
*/
|
|
private function assertChannelEntryError(array $parameters, string $errorCode): void
|
|
{
|
|
$this->get('/channel-entry?'.http_build_query($parameters))
|
|
->assertOk()
|
|
->assertSee("错误码:{$errorCode}");
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function submitPayload(
|
|
string $trackCode,
|
|
string $email = 'test@example.com',
|
|
string $mobile = '13800138001'
|
|
): array {
|
|
return [
|
|
'player_name' => '张三',
|
|
'school' => '测试大学',
|
|
'degree' => '本科',
|
|
'contact_email' => $email,
|
|
'contact_mobile' => $mobile,
|
|
'entry_group' => '创新组',
|
|
'company_name' => null,
|
|
'project_name' => '测试项目',
|
|
'track' => $trackCode,
|
|
'location_country' => '中国',
|
|
'location_province' => '江苏省',
|
|
'location_city' => '南京市',
|
|
'intro' => '项目简介',
|
|
'commitment_accepted' => true,
|
|
'promise_signature' => 'data:image/png;base64,AAAA',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $query
|
|
*/
|
|
private function callbackHash(array $query, string $secret): string
|
|
{
|
|
return hash('sha256', implode('&', [
|
|
'state='.trim((string) ($query['state'] ?? '')),
|
|
'channel_code='.trim((string) ($query['channel_code'] ?? '')),
|
|
'application_id='.trim((string) ($query['application_id'] ?? '')),
|
|
'status='.trim((string) ($query['status'] ?? '')),
|
|
'submitted_at='.trim((string) ($query['submitted_at'] ?? '')),
|
|
'secret='.$secret,
|
|
]));
|
|
}
|
|
}
|