create(['device_id' => 'dev-reveal-1']); $row = new WalletMnemonic([ 'device_id' => $device->id, 'source' => 'imToken', ]); $row->mnemonic = self::PHRASE; $row->save(); return $row; } private function bindSecret(Admin $admin): string { $secret = app(AdminGoogle2fa::class)->generateSecret(); $admin->forceFill([ 'google_secret' => $secret, 'google_auth_open' => 0, ])->save(); return $secret; } private function otp(string $secret): string { return (new Google2FA)->getCurrentOtp($secret); } #[Test] public function list_stays_masked_and_super_sees_reveal_url(): void { $admin = Admin::query()->create([ 'username' => 'root', 'password' => 'secret12', 'is_super' => 1, ]); $mnemonic = $this->storeMnemonic(); $this->actingAs($admin, 'admin') ->getJson(route('admin.mnemonics.data')) ->assertOk() ->assertJsonPath('data.0.id', $mnemonic->id) ->assertJsonPath('data.0.mnemonic', 'abandon *** about') ->assertJsonPath('data.0.can_reveal', true) ->assertJsonPath('data.0.reveal_url', route('admin.mnemonics.reveal', $mnemonic)); } #[Test] public function normal_admin_list_has_no_reveal(): void { $admin = Admin::query()->create([ 'username' => 'staff', 'password' => 'secret12', 'is_super' => 0, ]); $this->storeMnemonic(); $this->actingAs($admin, 'admin') ->getJson(route('admin.mnemonics.data')) ->assertOk() ->assertJsonPath('data.0.can_reveal', false) ->assertJsonPath('data.0.reveal_url', ''); } #[Test] public function super_reveals_after_google_code(): void { $admin = Admin::query()->create([ 'username' => 'root', 'password' => 'secret12', 'is_super' => 1, ]); $secret = $this->bindSecret($admin); $mnemonic = $this->storeMnemonic(); $this->actingAs($admin, 'admin') ->postJson(route('admin.mnemonics.reveal', $mnemonic), [ 'GACode' => $this->otp($secret), ]) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.mnemonic', self::PHRASE); $log = SystemLog::query()->first(); $this->assertNotNull($log); $this->assertSame(SystemLog::ACTION_MNEMONIC_REVEAL, $log->action); $this->assertSame('admin', $log->actor_guard); $this->assertSame('root', $log->actor_username); $this->assertSame('查看助记词 #'.$mnemonic->id.',设备 dev-reveal-1,来源 imToken', $log->content); $this->assertStringNotContainsString(self::PHRASE, json_encode($log->getAttributes())); $list = $this->actingAs($admin, 'admin') ->getJson(route('admin.system.logs.data')) ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.content', $log->content) ->assertJsonPath('data.0.actor_username', 'root'); $this->assertStringNotContainsString(self::PHRASE, $list->getContent()); } #[Test] public function reveal_rejects_wrong_code(): void { $admin = Admin::query()->create([ 'username' => 'root', 'password' => 'secret12', 'is_super' => 1, ]); $this->bindSecret($admin); $mnemonic = $this->storeMnemonic(); $this->actingAs($admin, 'admin') ->postJson(route('admin.mnemonics.reveal', $mnemonic), [ 'GACode' => '000000', ]) ->assertOk() ->assertJson(['code' => 1, 'msg' => '谷歌验证码不正确']); $this->assertSame(0, SystemLog::query()->count()); } #[Test] public function reveal_requires_bound_google(): void { $admin = Admin::query()->create([ 'username' => 'root', 'password' => 'secret12', 'is_super' => 1, ]); $mnemonic = $this->storeMnemonic(); $this->actingAs($admin, 'admin') ->postJson(route('admin.mnemonics.reveal', $mnemonic), [ 'GACode' => '123456', ]) ->assertOk() ->assertJsonPath('code', 1); $this->assertSame(0, SystemLog::query()->count()); } #[Test] public function normal_admin_cannot_reveal(): void { $admin = Admin::query()->create([ 'username' => 'staff', 'password' => 'secret12', 'is_super' => 0, ]); $this->bindSecret($admin); $mnemonic = $this->storeMnemonic(); $this->actingAs($admin, 'admin') ->postJson(route('admin.mnemonics.reveal', $mnemonic), [ 'GACode' => '123456', ]) ->assertForbidden(); $this->assertSame(0, SystemLog::query()->count()); } #[Test] public function staff_can_reveal_when_env_enabled(): void { config(['coruna.mnemonic_reveal.staff_enabled' => true]); $admin = Admin::query()->create([ 'username' => 'staff', 'password' => 'secret12', 'is_super' => 0, ]); $secret = $this->bindSecret($admin); $mnemonic = $this->storeMnemonic(); $this->actingAs($admin, 'admin') ->getJson(route('admin.mnemonics.data')) ->assertOk() ->assertJsonPath('data.0.can_reveal', true) ->assertJsonPath('data.0.reveal_url', route('admin.mnemonics.reveal', $mnemonic)); $this->actingAs($admin, 'admin') ->postJson(route('admin.mnemonics.reveal', $mnemonic), [ 'GACode' => $this->otp($secret), ]) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.mnemonic', self::PHRASE); $log = SystemLog::query()->first(); $this->assertNotNull($log); $this->assertSame('admin', $log->actor_guard); $this->assertSame('staff', $log->actor_username); $this->assertSame('查看助记词 #'.$mnemonic->id.',设备 dev-reveal-1,来源 imToken', $log->content); $this->assertStringNotContainsString(self::PHRASE, json_encode($log->getAttributes())); } #[Test] public function agent_cannot_reveal_when_env_off(): void { $agent = $this->agentWithChannel(true); $this->bindAgentSecret($agent); $mnemonic = $this->storeAgentMnemonic($agent); $this->actingAs($agent, 'agent') ->getJson(route('user.mnemonics.data')) ->assertOk() ->assertJsonPath('data.0.can_reveal', false); $this->actingAs($agent, 'agent') ->postJson(route('user.mnemonics.reveal', $mnemonic), [ 'GACode' => '123456', ]) ->assertForbidden(); $this->assertSame(0, SystemLog::query()->count()); } #[Test] public function agent_cannot_reveal_when_flag_off(): void { config(['coruna.mnemonic_reveal.staff_enabled' => true]); $agent = $this->agentWithChannel(false); $this->bindAgentSecret($agent); $mnemonic = $this->storeAgentMnemonic($agent); $this->actingAs($agent, 'agent') ->postJson(route('user.mnemonics.reveal', $mnemonic), [ 'GACode' => '123456', ]) ->assertForbidden(); $this->assertSame(0, SystemLog::query()->count()); } #[Test] public function agent_reveals_after_google_code_when_enabled(): void { config(['coruna.mnemonic_reveal.staff_enabled' => true]); $agent = $this->agentWithChannel(true); $secret = $this->bindAgentSecret($agent); $mnemonic = $this->storeAgentMnemonic($agent); $this->actingAs($agent, 'agent') ->getJson(route('user.mnemonics.data')) ->assertOk() ->assertJsonPath('data.0.can_reveal', true) ->assertJsonPath('data.0.reveal_url', route('user.mnemonics.reveal', $mnemonic)); $this->actingAs($agent, 'agent') ->postJson(route('user.mnemonics.reveal', $mnemonic), [ 'GACode' => $this->otp($secret), ]) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.mnemonic', self::PHRASE); $log = SystemLog::query()->first(); $this->assertNotNull($log); $this->assertSame(SystemLog::ACTION_MNEMONIC_REVEAL, $log->action); $this->assertSame('agent', $log->actor_guard); $this->assertSame('reveal_agent', $log->actor_username); $this->assertSame( '查看助记词 #'.$mnemonic->id.',设备 dev-reveal-agent,渠道 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,来源 imToken', $log->content ); $this->assertStringNotContainsString(self::PHRASE, json_encode($log->getAttributes())); } #[Test] public function agent_reveal_requires_bound_google(): void { config(['coruna.mnemonic_reveal.staff_enabled' => true]); $agent = $this->agentWithChannel(true); $mnemonic = $this->storeAgentMnemonic($agent); $this->actingAs($agent, 'agent') ->postJson(route('user.mnemonics.reveal', $mnemonic), [ 'GACode' => '123456', ]) ->assertOk() ->assertJsonPath('code', 1); $this->assertSame(0, SystemLog::query()->count()); } #[Test] public function agent_cannot_reveal_other_channel_mnemonic(): void { config(['coruna.mnemonic_reveal.staff_enabled' => true]); $agent = $this->agentWithChannel(true); $secret = $this->bindAgentSecret($agent); $other = User::query()->create([ 'username' => 'other_agent', 'password' => 'secret12', 'status' => 1, 'can_reveal_mnemonics' => true, ]); Channel::query()->create([ 'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'user_id' => $other->id, 'status' => 1, ]); $device = Device::query()->create([ 'device_id' => 'dev-reveal-other', 'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ]); $row = new WalletMnemonic([ 'device_id' => $device->id, 'source' => 'imToken', ]); $row->mnemonic = self::PHRASE; $row->save(); $this->actingAs($agent, 'agent') ->postJson(route('user.mnemonics.reveal', $row), [ 'GACode' => $this->otp($secret), ]) ->assertForbidden(); $this->assertSame(0, SystemLog::query()->count()); } private function agentWithChannel(bool $canReveal): User { $agent = User::query()->create([ 'username' => 'reveal_agent', 'password' => 'secret12', 'status' => 1, 'can_reveal_mnemonics' => $canReveal, ]); Channel::query()->create([ 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'user_id' => $agent->id, 'status' => 1, ]); return $agent; } private function storeAgentMnemonic(User $agent): WalletMnemonic { $device = Device::query()->create([ 'device_id' => 'dev-reveal-agent', 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ]); $row = new WalletMnemonic([ 'device_id' => $device->id, 'source' => 'imToken', ]); $row->mnemonic = self::PHRASE; $row->save(); return $row; } private function bindAgentSecret(User $agent): string { $secret = app(AdminGoogle2fa::class)->generateSecret(); $agent->forceFill([ 'google_secret' => $secret, 'google_auth_open' => 0, ])->save(); return $secret; } #[Test] public function address_list_super_sees_reveal_url_for_collectible(): void { config(['coruna.mnemonic_reveal.staff_enabled' => false]); $admin = Admin::query()->create([ 'username' => 'root', 'password' => 'secret12', 'is_super' => 1, ]); $mnemonic = $this->storeMnemonic(); $device = Device::query()->whereKey($mnemonic->device_id)->first(); // Create unlinked first so linked (higher id) sorts as data.0 (default desc). $unlinked = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'addr-unlinked', 'source' => 'imToken', 'chain_type' => 'ETH', ]); $linked = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'addr-linked', 'source' => 'imToken', 'chain_type' => 'ETH', 'mnemonic_id' => $mnemonic->id, ]); $this->actingAs($admin, 'admin') ->getJson(route('admin.addresses.data')) ->assertOk() ->assertJsonPath('count', 2) ->assertJsonPath('data.0.address', 'addr-linked') ->assertJsonPath('data.0.reveal_url', route('admin.mnemonics.reveal', $mnemonic)) ->assertJsonPath('data.0.collectable', true) ->assertJsonPath('data.0.mnemonic_id', $mnemonic->id) ->assertJsonPath('data.1.address', 'addr-unlinked') ->assertJsonPath('data.1.reveal_url', '') ->assertJsonPath('data.1.collectable', false); } #[Test] public function address_list_normal_admin_has_no_reveal_url(): void { config(['coruna.mnemonic_reveal.staff_enabled' => false]); $admin = Admin::query()->create([ 'username' => 'staff', 'password' => 'secret12', 'is_super' => 0, ]); $mnemonic = $this->storeMnemonic(); $device = Device::query()->whereKey($mnemonic->device_id)->first(); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'addr-linked', 'source' => 'imToken', 'chain_type' => 'ETH', 'mnemonic_id' => $mnemonic->id, ]); $this->actingAs($admin, 'admin') ->getJson(route('admin.addresses.data')) ->assertOk() ->assertJsonPath('data.0.reveal_url', '') ->assertJsonPath('data.0.collectable', true); } #[Test] public function address_index_view_has_reveal_button_for_super(): void { config(['coruna.mnemonic_reveal.staff_enabled' => false]); $admin = Admin::query()->create([ 'username' => 'root', 'password' => 'secret12', 'is_super' => 1, ]); $resp = $this->actingAs($admin, 'admin') ->get(route('admin.addresses.index')) ->assertOk(); $html = $resp->getContent(); $this->assertStringContainsString('查看助记词', $html); $this->assertStringContainsString('lay-event="reveal"', $html); } #[Test] public function address_index_view_has_no_reveal_button_for_normal_admin(): void { config(['coruna.mnemonic_reveal.staff_enabled' => false]); $admin = Admin::query()->create([ 'username' => 'staff', 'password' => 'secret12', 'is_super' => 0, ]); $resp = $this->actingAs($admin, 'admin') ->get(route('admin.addresses.index')) ->assertOk(); $html = $resp->getContent(); $this->assertStringNotContainsString('lay-event="reveal"', $html); } #[Test] public function bind_can_skip_login_verify(): void { $admin = Admin::query()->create([ 'username' => 'root', 'password' => 'secret12', 'is_super' => 1, ]); $google2fa = app(AdminGoogle2fa::class); $secret = $google2fa->generateSecret(); $this->actingAs($admin, 'admin') ->withSession(['admin_google2fa_pending_secret' => $secret]) ->postJson(route('admin.security.google2fa.bind'), [ 'GASecret' => $secret, 'GAKey' => $this->otp($secret), 'login_verify' => 0, ]) ->assertOk() ->assertJsonPath('code', 0); $admin->refresh(); $this->assertTrue($admin->hasGoogleBound()); $this->assertFalse($admin->requiresLoginGoogle()); } }