artisan('lottery:admin-auth-sync')->assertExitCode(0); }); function integrationAdminToken(): string { $admin = AdminUser::query()->create([ 'username' => 'integration_admin', 'name' => 'Integration Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } test('super admin can create integration site and receive secrets once', function (): void { $token = integrationAdminToken(); $response = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'partner-a', 'name' => 'Partner A', 'wallet_api_url' => 'https://wallet.partner-a.test', 'status' => 1, ]); $response->assertCreated() ->assertJsonPath('code', 0) ->assertJsonPath('data.code', 'partner-a') ->assertJsonPath('data.secrets_display_once', true) ->assertJsonStructure([ 'data' => [ 'secrets' => ['sso_jwt_secret', 'wallet_api_key'], ], ]); $site = AdminSite::query()->where('code', 'partner-a')->first(); expect($site)->not->toBeNull(); expect($site?->decryptedSsoJwtSecret())->not->toBeEmpty(); expect(AuditLog::query()->where('module_code', 'integration')->where('action_code', 'create')->exists())->toBeTrue(); }); test('integration site code cannot be changed on update', function (): void { $token = integrationAdminToken(); $create = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'partner-b', 'name' => 'Partner B', ]); $create->assertCreated(); $id = (int) $create->json('data.id'); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/integration-sites/'.$id, [ 'name' => 'Partner B Renamed', 'status' => 1, ]) ->assertOk() ->assertJsonPath('data.code', 'partner-b') ->assertJsonPath('data.name', 'Partner B Renamed'); }); test('partner site config resolver cache roundtrip returns partner site config', function (): void { AdminSite::query()->create([ 'code' => 'cache-roundtrip', 'name' => 'Cache', 'currency_code' => 'NPR', 'status' => 1, 'is_default' => false, 'sso_jwt_secret_encrypted' => encrypt('cache-sso'), 'wallet_api_key_encrypted' => encrypt('cache-wallet'), ]); $resolver = app(PartnerSiteConfigResolver::class); $first = $resolver->resolveBySiteCode('cache-roundtrip'); $second = $resolver->resolveBySiteCode('cache-roundtrip'); expect($second)->toBeInstanceOf(PartnerSiteConfig::class) ->and($second->ssoJwtSecret)->toBe('cache-sso'); }); test('partner site config resolver reads database secrets', function (): void { AdminSite::query()->create([ 'code' => 'partner-db', 'name' => 'DB Partner', 'currency_code' => 'NPR', 'status' => 1, 'is_default' => false, 'sso_jwt_secret_encrypted' => encrypt('db-sso-secret'), 'wallet_api_key_encrypted' => encrypt('db-wallet-key'), 'wallet_api_url' => 'https://wallet.db.test', ]); $config = app(PartnerSiteConfigResolver::class)->resolveBySiteCode('partner-db'); expect($config->source)->toBe('database') ->and($config->ssoJwtSecret)->toBe('db-sso-secret') ->and($config->walletApiKey)->toBe('db-wallet-key') ->and($config->walletApiUrl)->toBe('https://wallet.db.test'); }); test('rotate secrets returns new plaintext once', function (): void { $token = integrationAdminToken(); $create = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'partner-rotate', 'name' => 'Rotate', ]); $id = (int) $create->json('data.id'); $oldSecret = (string) $create->json('data.secrets.sso_jwt_secret'); $rotate = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites/'.$id.'/rotate-secrets'); $rotate->assertOk(); $newSecret = (string) $rotate->json('data.secrets.sso_jwt_secret'); expect($newSecret)->not->toBe($oldSecret); expect(AuditLog::query()->where('action_code', 'rotate_secrets')->exists())->toBeTrue(); }); test('connectivity test probes partner balance api', function (): void { Http::fake([ 'https://wallet.probe.test/*' => Http::response([ 'success' => true, 'data' => ['main_balance' => 12345, 'currency_code' => 'NPR'], ], 200), ]); $token = integrationAdminToken(); $create = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'probe-site', 'name' => 'Probe', 'wallet_api_url' => 'https://wallet.probe.test', ]); $id = (int) $create->json('data.id'); $response = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites/'.$id.'/connectivity-test', [ 'site_player_id' => '10001', 'currency_code' => 'NPR', ]); $response->assertOk() ->assertJsonPath('data.probe.success', true) ->assertJsonPath('data.probe.main_balance_minor', 12345); }); test('export parameter sheet excludes plaintext secrets', function (): void { $token = integrationAdminToken(); $create = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'export-site', 'name' => 'Export', 'wallet_api_url' => 'https://wallet.export.test', ]); $id = (int) $create->json('data.id'); $response = $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/integration-sites/'.$id.'/export'); $response->assertOk() ->assertJsonPath('data.site_code', 'export-site') ->assertJsonPath('data.sso_secret_masked', '••••••••') ->assertJsonMissingPath('data.secrets') ->assertJsonMissingPath('data.sso_jwt_secret'); }); test('site scoped admin only sees bound integration sites', function (): void { $token = integrationAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'site-a', 'name' => 'Site A', ]) ->assertCreated(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'site-b', 'name' => 'Site B', ]) ->assertCreated(); $siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id'); $siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id'); $scopedAdmin = AdminUser::query()->create([ 'username' => 'integration_scoped', 'name' => 'Scoped', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); $roleId = (int) DB::table('admin_roles')->insertGetId([ 'slug' => 'integration_scoped_role', 'name' => 'Integration Scoped', 'code' => 'integration_scoped_role', 'created_at' => now(), 'updated_at' => now(), ]); $viewActionId = (int) DB::table('admin_menu_actions') ->where('permission_code', 'integration.site.view') ->value('id'); DB::table('admin_role_menu_actions')->insert([ 'role_id' => $roleId, 'menu_action_id' => $viewActionId, ]); DB::table('admin_user_site_roles')->insert([ 'admin_user_id' => $scopedAdmin->id, 'site_id' => $siteAId, 'role_id' => $roleId, 'granted_at' => now(), ]); $scopedToken = $scopedAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken; app('auth')->forgetGuards(); $list = $this->withHeader('Authorization', 'Bearer '.$scopedToken) ->getJson('/api/v1/admin/integration-sites') ->assertOk(); $codes = collect($list->json('data.items'))->pluck('code')->all(); expect($codes)->toContain('site-a')->not->toContain('site-b'); $this->withHeader('Authorization', 'Bearer '.$scopedToken) ->getJson('/api/v1/admin/integration-sites/'.$siteBId) ->assertForbidden(); }); test('player list is filtered by admin site binding', function (): void { $this->seed(\Database\Seeders\CurrencySeeder::class); Player::query()->create([ 'site_code' => 'site-a', 'site_player_id' => 'pa-1', 'username' => 'pa1', 'nickname' => 'PA1', 'default_currency' => 'NPR', 'status' => 0, ]); Player::query()->create([ 'site_code' => 'site-b', 'site_player_id' => 'pb-1', 'username' => 'pb1', 'nickname' => 'PB1', 'default_currency' => 'NPR', 'status' => 0, ]); AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]); AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]); $siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id'); $scopedAdmin = AdminUser::query()->create([ 'username' => 'player_scoped', 'name' => 'Player Scoped', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); $roleId = (int) DB::table('admin_roles')->insertGetId([ 'slug' => 'player_scoped_role', 'name' => 'Player Scoped', 'code' => 'player_scoped_role', 'created_at' => now(), 'updated_at' => now(), ]); $viewActionId = (int) DB::table('admin_menu_actions') ->where('permission_code', 'service.players.view') ->value('id'); DB::table('admin_role_menu_actions')->insert([ 'role_id' => $roleId, 'menu_action_id' => $viewActionId, ]); DB::table('admin_user_site_roles')->insert([ 'admin_user_id' => $scopedAdmin->id, 'site_id' => $siteAId, 'role_id' => $roleId, 'granted_at' => now(), ]); $scopedToken = $scopedAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken; $response = $this->withHeader('Authorization', 'Bearer '.$scopedToken) ->getJson('/api/v1/admin/players') ->assertOk(); $siteCodes = collect($response->json('data.items'))->pluck('site_code')->unique()->values()->all(); expect($siteCodes)->toBe(['site-a']); }); test('wallet_api_url rejects non-https', function (): void { $token = integrationAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'bad-https-1', 'name' => 'Bad HTTPS 1', 'wallet_api_url' => 'http://wallet.bad.test', ]) ->assertStatus(422) ->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'); }); test('wallet_api_url rejects localhost', function (): void { $token = integrationAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'bad-https-2', 'name' => 'Bad HTTPS 2', 'wallet_api_url' => 'https://localhost:8080', ]) ->assertStatus(422) ->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'); }); test('wallet_api_url rejects private ip with path', function (): void { $token = integrationAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'bad-https-3', 'name' => 'Bad HTTPS 3', 'wallet_api_url' => 'https://127.0.0.1/wallet', ]) ->assertStatus(422) ->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'); });