Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
30 KiB
JSON

{"repo": "apu-hart/classbook", "files": {"backend/tests/Feature/AdminSettingsControllerTest.php": {"e27b631bbd409fb3edbd47735b2df73bef2ba8e2": {"t": 1786068385.7651205, "n": 29329, "h": "89416c721620f33d", "c": "<?php\n\nnamespace Tests\\Feature;\n\nrequire_once __DIR__.'/../TestCase.php';\n\nuse App\\Models\\User;\nuse App\\Models\\ClassRoom;\nuse App\\Models\\MemoryMessage;\nuse App\\Models\\Photo;\nuse App\\Models\\SystemSetting;\nuse App\\Models\\TimeCapsule;\nuse App\\Notifications\\AdminMailConfigTestNotification;\nuse App\\Support\\ManagedMailConfig;\nuse App\\Support\\TotpService;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Crypt;\nuse Illuminate\\Support\\Facades\\File;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Notification;\nuse Illuminate\\Support\\Facades\\Schema;\nuse Illuminate\\Support\\Str;\nuse Tests\\TestCase;\nuse Tymon\\JWTAuth\\Facades\\JWTAuth;\n\nclass AdminSettingsControllerTest extends TestCase\n{\n use RefreshDatabase;\n\n protected function setUp(): void\n {\n parent::setUp();\n\n $this->clearSystemSettingsStore();\n }\n\n protected function tearDown(): void\n {\n $this->clearSystemSettingsStore();\n\n parent::tearDown();\n }\n\n public function test_setup_preferences_defaults_to_chinese(): void\n {\n $this->withToken($this->adminToken())->getJson('/api/admin/setup-preferences')\n ->assertOk()\n ->assertJson([\n 'frontend_language' => 'zh-CN',\n 'backend_language' => 'zh-CN',\n ]);\n }\n\n public function test_update_setup_preferences_persists_selected_languages(): void\n {\n $this->withToken($this->adminToken())->putJson('/api/admin/setup-preferences', [\n 'frontend_language' => 'en-US',\n 'backend_language' => 'zh-CN',\n ])\n ->assertOk()\n ->assertJson([\n 'frontend_language' => 'en-US',\n 'backend_language' => 'zh-CN',\n ]);\n\n $this->assertSame('en-US', SystemSetting::valueOf('admin.frontend_language'));\n $this->assertSame('zh-CN', SystemSetting::valueOf('admin.backend_language'));\n }\n\n public function test_update_setup_preferences_falls_back_to_file_store_when_table_is_missing(): void\n {\n Schema::dropIfExists('system_settings');\n $this->resetSystemSettingsTableExistsCache();\n\n $this->withToken($this->adminToken())->putJson('/api/admin/setup-preferences', [\n 'frontend_language' => 'en-US',\n 'backend_language' => 'zh-CN',\n ])\n ->assertOk()\n ->assertJson([\n 'frontend_language' => 'en-US',\n 'backend_language' => 'zh-CN',\n ]);\n\n $this->resetSystemSettingsTableExistsCache();\n\n $this->assertFileExists(storage_path('app/system-settings.json'));\n $this->assertSame('en-US', SystemSetting::valueOf('admin.frontend_language'));\n $this->assertSame('zh-CN', SystemSetting::valueOf('admin.backend_language'));\n }\n\n public function test_show_ai_endpoint_reports_configuration_flags(): void\n {\n SystemSetting::putValue('ai.base_url', 'https://api.openai.com/v1/chat/completions');\n SystemSetting::putValue('ai.model', 'deepseek-chat');\n SystemSetting::putValue('ai.api_key', Crypt::encryptString('env-api-key'), true);\n\n $this->withToken($this->adminToken())->getJson('/api/admin/ai-config')\n ->assertOk()\n ->assertJson([\n 'base_url' => 'https://api.openai.com/v1/chat/completions',\n 'model' => 'deepseek-chat',\n 'api_key_configured' => true,\n 'enabled' => true,\n ]);\n }\n\n public function test_admin_member_endpoint_returns_real_member_stats(): void\n {\n $admin = User::create([\n 'nickname' => '后台管理员',\n 'email' => 'admin@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'admin',\n 'signature' => '管理空间',\n 'graduation_year' => 2020,\n 'contact' => '@admin',\n ]);\n\n $member = User::create([\n 'nickname' => '林夏',\n 'email' => 'linxia@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'member',\n 'signature' => '青春常驻',\n 'graduation_year' => 2024,\n 'contact' => 'linxia',\n ]);\n\n $classRoom = ClassRoom::create([\n 'name' => '高三一班',\n 'invite_code' => 'ABC123',\n 'owner_id' => $admin->id,\n 'graduation_year' => 2024,\n 'description' => '测试班级',\n 'announcement' => '欢迎回来',\n ]);\n\n $classRoom->members()->attach($admin->id, ['role' => 'owner']);\n $classRoom->members()->attach($member->id, ['role' => 'member']);\n\n MemoryMessage::create([\n 'class_room_id' => $classRoom->id,\n 'user_id' => $member->id,\n 'content' => '毕业快乐',\n ]);\n\n Photo::create([\n 'class_room_id' => $classRoom->id,\n 'user_id' => $member->id,\n 'album_name' => '毕业照',\n 'title' => '礼堂合照',\n 'path' => 'photos/test.jpg',\n 'width' => 1200,\n 'height' => 800,\n ]);\n\n TimeCapsule::create([\n 'class_room_id' => $classRoom->id,\n 'user_id' => $member->id,\n 'title' => '写给未来',\n 'content' => '十年后再打开',\n 'unlock_at' => now()->addYear(),\n ]);\n\n $this->withToken($this->tokenForUser($admin))\n ->getJson('/api/admin/members')\n ->assertOk()\n ->assertJsonPath('summary.total_members', 2)\n ->assertJsonPath('summary.admin_members', 1)\n ->assertJsonPath('data.0.class_count', 1)\n ->assertJsonPath('data.0.message_count', 1)\n ->assertJsonPath('data.0.photo_count', 1)\n ->assertJsonPath('data.0.capsule_count', 1);\n }\n\n public function test_admin_member_endpoint_requires_authenticated_admin_user(): void\n {\n $this->getJson('/api/admin/members')\n ->assertUnauthorized();\n\n $member = User::create([\n 'nickname' => '普通成员',\n 'email' => 'member@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'member',\n ]);\n\n $this->withToken($this->tokenForUser($member))\n ->getJson('/api/admin/members')\n ->assertForbidden()\n ->assertJson([\n 'message' => '仅管理员可访问该接口',\n ]);\n }\n\n public function test_admin_can_delete_non_admin_member(): void\n {\n $admin = User::create([\n 'nickname' => '后台管理员',\n 'email' => 'admin-delete@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'admin',\n ]);\n\n $member = User::create([\n 'nickname' => '待删除成员',\n 'email' => 'delete-me@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'member',\n ]);\n\n $this->withToken($this->tokenForUser($admin))\n ->deleteJson(\"/api/admin/members/{$member->id}\")\n ->assertOk()\n ->assertJson([\n 'message' => '成员 待删除成员 已删除',\n ]);\n\n $this->assertDatabaseMissing('users', [\n 'id' => $member->id,\n ]);\n }\n\n public function test_admin_member_delete_rejects_self_or_admin_target(): void\n {\n $admin = User::create([\n 'nickname' => '后台管理员',\n 'email' => 'admin-protect@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'admin',\n ]);\n\n $otherAdmin = User::create([\n 'nickname' => '其他管理员',\n 'email' => 'other-admin@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'admin',\n ]);\n\n $this->withToken($this->tokenForUser($admin))\n ->deleteJson(\"/api/admin/members/{$admin->id}\")\n ->assertStatus(422)\n ->assertJson([\n 'message' => '不能删除当前登录的管理员账号',\n ]);\n\n $this->withToken($this->tokenForUser($admin))\n ->deleteJson(\"/api/admin/members/{$otherAdmin->id}\")\n ->assertStatus(422)\n ->assertJson([\n 'message' => '管理员账号请不要在这里直接删除',\n ]);\n }\n\n public function test_update_ai_keeps_existing_api_key_when_input_is_blank(): void\n {\n config([\n 'services.openai.base_url' => null,\n 'services.openai.api_key' => null,\n 'services.openai.model' => null,\n ]);\n\n SystemSetting::putValue('ai.api_key', Crypt::encryptString('existing-ai-key'), true);\n\n $this->withToken($this->adminToken())->putJson('/api/admin/ai-config', [\n 'base_url' => 'https://example.com/v1/chat/completions',\n 'model' => 'deepseek-chat',\n 'api_key' => '',\n ])\n ->assertOk()\n ->assertJson([\n 'base_url' => 'https://example.com/v1/chat/completions',\n 'model' => 'deepseek-chat',\n 'api_key_configured' => true,\n 'enabled' => true,\n ]);\n\n $this->assertSame('https://example.com/v1/chat/completions', SystemSetting::valueOf('ai.base_url'));\n $this->assertSame('deepseek-chat', SystemSetting::valueOf('ai.model'));\n $this->assertSame('existing-ai-key', SystemSetting::valueOf('ai.api_key', null, true));\n }\n\n public function test_reset_ai_disables_env_fallback_and_reports_unconfigured(): void\n {\n config([\n 'services.openai.api_key' => 'env-ai-key',\n ]);\n\n SystemSetting::putValue('ai.base_url', 'https://example.com/v1/chat/completions');\n SystemSetting::putValue('ai.model', 'deepseek-chat');\n\n $this->withToken($this->adminToken())->deleteJson('/api/admin/ai-config')\n ->assertOk()\n ->assertJson([\n 'base_url' => null,\n 'model' => null,\n 'api_key_configured' => false,\n 'api_key_source' => 'none',\n 'enabled' => false,\n ]);\n\n $this->assertSame('1', SystemSetting::valueOf('ai.disable_env_fallback'));\n }\n\n public function test_fetch_ai_models_uses_upstream_models_endpoint_and_returns_models(): void\n {\n Http::fake([\n 'https://api.deepseek.com/models' => Http::response([\n 'data' => [\n ['id' => 'deepseek-chat'],\n ['id' => 'deepseek-reasoner'],\n ],\n ]),\n ]);\n\n $this->withToken($this->adminToken())->postJson('/api/admin/ai-config/models', [\n 'base_url' => 'https://api.deepseek.com/chat/completions',\n 'api_key' => 'live-test-key',\n ])\n ->assertOk()\n ->assertJson([\n 'message' => '上游模型获取成功',\n 'models' => ['deepseek-chat', 'deepseek-reasoner'],\n 'models_url' => 'https://api.deepseek.com/models',\n ]);\n\n Http::assertSent(function ($request): bool {\n return $request->url() === 'https://api.deepseek.com/models'\n && $request->method() === 'GET'\n && $request->hasHeader('Authorization', 'Bearer live-test-key');\n });\n }\n\n public function test_ai_connectivity_test_endpoint_returns_success_when_upstream_is_reachable(): void\n {\n Http::fake([\n 'https://api.deepseek.com/models' => Http::response([\n 'data' => [\n ['id' => 'deepseek-chat'],\n ['id' => 'deepseek-reasoner'],\n ],\n ]),\n ]);\n\n $this->withToken($this->adminToken())->postJson('/api/admin/ai-config/test', [\n 'base_url' => 'https://api.deepseek.com/chat/completions',\n 'api_key' => 'live-test-key',\n ])\n ->assertOk()\n ->assertJson([\n 'message' => 'AI 接口连通性测试通过',\n 'models_url' => 'https://api.deepseek.com/models',\n 'models_count' => 2,\n ]);\n }\n\n public function test_ai_config_endpoints_require_authenticated_admin_user(): void\n {\n $this->getJson('/api/admin/ai-config')\n ->assertUnauthorized();\n\n $member = User::create([\n 'nickname' => '普通成员',\n 'email' => 'member@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'member',\n ]);\n\n $this->withToken($this->tokenForUser($member))\n ->getJson('/api/admin/ai-config')\n ->assertForbidden()\n ->assertJson([\n 'message' => '仅管理员可访问该接口',\n ]);\n }\n\n public function test_show_turnstile_endpoint_reports_configuration_flags(): void\n {\n config([\n 'services.turnstile.site_key' => 'site-key-demo',\n 'services.turnstile.secret_key' => 'secret-key-demo',\n ]);\n\n $this->withToken($this->adminToken())->getJson('/api/admin/turnstile-config')\n ->assertOk()\n ->assertJson([\n 'site_key_configured' => true,\n 'secret_key_configured' => true,\n ]);\n }\n\n public function test_update_turnstile_requires_site_key_when_none_is_configured(): void\n {\n config([\n 'services.turnstile.site_key' => null,\n 'services.turnstile.secret_key' => null,\n ]);\n\n $this->withToken($this->adminToken())->putJson('/api/admin/turnstile-config', [\n 'site_key' => '',\n 'secret_key' => 'secret-key-demo',\n ])\n ->assertStatus(422)\n ->assertJsonValidationErrors(['site_key']);\n }\n\n public function test_update_turnstile_keeps_existing_site_key_when_input_is_blank(): void\n {\n config([\n 'services.turnstile.site_key' => null,\n 'services.turnstile.secret_key' => null,\n ]);\n\n SystemSetting::putValue('turnstile.site_key', 'existing-site-key');\n\n $this->withToken($this->adminToken())->putJson('/api/admin/turnstile-config', [\n 'site_key' => '',\n 'secret_key' => 'new-secret-key',\n ])\n ->assertOk()\n ->assertJson([\n 'site_key_configured' => true,\n 'secret_key_configured' => true,\n ]);\n\n $this->assertSame('existing-site-key', SystemSetting::valueOf('turnstile.site_key'));\n $this->assertSame('new-secret-key', SystemSetting::valueOf('turnstile.secret_key', null, true));\n }\n\n public function test_turnstile_endpoints_require_authenticated_admin_user(): void\n {\n $this->getJson('/api/admin/turnstile-config')\n ->assertUnauthorized();\n\n $member = User::create([\n 'nickname' => '普通成员',\n 'email' => 'member@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'member',\n ]);\n\n $this->withToken($this->tokenForUser($member))\n ->getJson('/api/admin/turnstile-config')\n ->assertForbidden()\n ->assertJson([\n 'message' => '仅管理员可访问该接口',\n ]);\n }\n\n public function test_show_mail_endpoint_reports_configuration_flags(): void\n {\n SystemSetting::putValue('mail.provider', 'qq');\n SystemSetting::putValue('mail.host', 'smtp.qq.com');\n SystemSetting::putValue('mail.port', '465');\n SystemSetting::putValue('mail.encryption', 'ssl');\n SystemSetting::putValue('mail.username', 'sender@qq.com');\n SystemSetting::putValue('mail.password', Crypt::encryptString('smtp-auth-code'), true);\n SystemSetting::putValue('mail.from_address', 'sender@qq.com');\n SystemSetting::putValue('mail.from_name', 'ClassBook');\n\n $this->withToken($this->adminToken())->getJson('/api/admin/mail-config')\n ->assertOk()\n ->assertJson([\n 'provider' => 'qq',\n 'password_configured' => true,\n 'mail_ready' => true,\n ]);\n }\n\n public function test_show_mail_endpoint_uses_official_provider_smtp_defaults(): void\n {\n config([\n 'mail.mailers.smtp.host' => '127.0.0.1',\n 'mail.mailers.smtp.port' => 2525,\n 'mail.mailers.smtp.scheme' => 'tls',\n ]);\n\n SystemSetting::putValue('mail.provider', 'qq');\n SystemSetting::putValue('mail.username', 'sender@qq.com');\n SystemSetting::putValue('mail.password', Crypt::encryptString('smtp-auth-code'), true);\n SystemSetting::putValue('mail.from_address', 'sender@qq.com');\n SystemSetting::putValue('mail.from_name', 'ClassBook');\n\n $this->withToken($this->adminToken())->getJson('/api/admin/mail-config')\n ->assertOk()\n ->assertJson([\n 'provider' => 'qq',\n 'host' => 'smtp.qq.com',\n 'port' => 465,\n 'encryption' => 'ssl',\n ]);\n }\n\n public function test_update_mail_keeps_existing_password_when_input_is_blank(): void\n {\n SystemSetting::putValue('mail.password', Crypt::encryptString('existing-auth-code'), true);\n\n $this->withToken($this->adminToken())->putJson('/api/admin/mail-config', [\n 'provider' => 'qq',\n 'host' => 'smtp.qq.com',\n 'port' => 465,\n 'encryption' => 'ssl',\n 'username' => 'sender@qq.com',\n 'password' => '',\n 'from_address' => 'sender@qq.com',\n 'from_name' => 'ClassBook',\n ])\n ->assertOk()\n ->assertJson([\n 'password_configured' => true,\n 'mail_ready' => true,\n ]);\n\n $this->assertSame('existing-auth-code', SystemSetting::valueOf('mail.password', null, true));\n $this->assertSame('smtp.qq.com', SystemSetting::valueOf('mail.host'));\n }\n\n public function test_update_mail_for_known_provider_persists_official_smtp_settings(): void\n {\n SystemSetting::putValue('mail.password', Crypt::encryptString('existing-auth-code'), true);\n\n $this->withToken($this->adminToken())->putJson('/api/admin/mail-config', [\n 'provider' => 'qq',\n 'host' => '127.0.0.1',\n 'port' => 2525,\n 'encryption' => 'tls',\n 'username' => 'sender@qq.com',\n 'password' => '',\n 'from_address' => 'sender@qq.com',\n 'from_name' => 'ClassBook',\n ])\n ->assertOk()\n ->assertJson([\n 'provider' => 'qq',\n 'host' => 'smtp.qq.com',\n 'port' => 465,\n 'encryption' => 'ssl',\n ]);\n\n $this->assertSame('smtp.qq.com', SystemSetting::valueOf('mail.host'));\n $this->assertSame('465', SystemSetting::valueOf('mail.port'));\n $this->assertSame('ssl', SystemSetting::valueOf('mail.encryption'));\n }\n\n public function test_send_test_mail_uses_saved_mail_configuration(): void\n {\n Notification::fake();\n\n SystemSetting::putValue('mail.provider', 'qq');\n SystemSetting::putValue('mail.host', 'smtp.qq.com');\n SystemSetting::putValue('mail.port', '465');\n SystemSetting::putValue('mail.encryption', 'ssl');\n SystemSetting::putValue('mail.username', 'sender@qq.com');\n SystemSetting::putValue('mail.password', Crypt::encryptString('smtp-auth-code'), true);\n SystemSetting::putValue('mail.from_address', 'sender@qq.com');\n SystemSetting::putValue('mail.from_name', 'ClassBook');\n\n $this->withToken($this->adminToken())->postJson('/api/admin/mail-config/test', [\n 'test_email' => 'sender@qq.com',\n ])\n ->assertOk()\n ->assertJson([\n 'message' => '测试邮件已发送,请前往邮箱查收',\n ]);\n\n Notification::assertSentOnDemand(AdminMailConfigTestNotification::class);\n }\n\n public function test_send_test_mail_returns_detailed_reason_for_admin_when_delivery_fails(): void\n {\n SystemSetting::putValue('mail.provider', 'qq');\n SystemSetting::putValue('mail.host', 'smtp.qq.com');\n SystemSetting::putValue('mail.port', '465');\n SystemSetting::putValue('mail.encryption', 'ssl');\n SystemSetting::putValue('mail.username', 'sender@qq.com');\n SystemSetting::putValue('mail.password', Crypt::encryptString('smtp-auth-code'), true);\n SystemSetting::putValue('mail.from_address', 'sender@qq.com');\n SystemSetting::putValue('mail.from_name', 'ClassBook');\n\n Notification::shouldReceive('send')\n ->once()\n ->andThrow(new \\RuntimeException('535 Authentication credentials invalid'));\n\n $this->withToken($this->adminToken())->postJson('/api/admin/mail-config/test', [\n 'test_email' => 'sender@qq.com',\n ])\n ->assertStatus(500)\n ->assertJson([\n 'message' => '测试邮件发送失败,请检查 SMTP 服务器、端口、授权码或网络连通性。 具体原因:535 Authentication credentials invalid',\n ]);\n }\n\n public function test_managed_mail_config_maps_ssl_to_smtps_scheme(): void\n {\n SystemSetting::putValue('mail.provider', 'custom');\n SystemSetting::putValue('mail.host', 'smtp.qq.com');\n SystemSetting::putValue('mail.port', '465');\n SystemSetting::putValue('mail.encryption', 'ssl');\n SystemSetting::putValue('mail.username', 'sender@qq.com');\n SystemSetting::putValue('mail.password', Crypt::encryptString('smtp-auth-code'), true);\n SystemSetting::putValue('mail.from_address', 'sender@qq.com');\n SystemSetting::putValue('mail.from_name', 'ClassBook');\n\n ManagedMailConfig::apply();\n\n $this->assertSame('smtps', config('mail.mailers.smtp.scheme'));\n }\n\n public function test_admin_can_generate_enable_and_disable_otp(): void\n {\n $token = $this->adminToken();\n\n $setupResponse = $this->withToken($token)->postJson('/api/admin/otp-config/setup')\n ->assertOk()\n ->assertJsonPath('secret', fn (mixed $value): bool => is_string($value) && $value !== '')\n ->assertJsonPath('otpauth_url', fn (mixed $value): bool => is_string($value) && str_starts_with($value, 'otpauth://'))\n ->json();\n\n $secret = (string) ($setupResponse['secret'] ?? '');\n $totp = app(TotpService::class);\n\n $this->withToken($token)->postJson('/api/admin/otp-config/enable', [\n 'otp_code' => $totp->currentCode($secret),\n ])\n ->assertOk()\n ->assertJsonPath('enabled', true);\n\n $admin = User::query()->where('email', 'admin@classbook.app')->firstOrFail()->fresh();\n $this->assertTrue($admin->hasOtpEnabled());\n\n $this->withToken($token)->postJson('/api/admin/otp-config/disable', [\n 'otp_code' => $totp->currentCode((string) $admin->otp_secret),\n ])\n ->assertOk()\n ->assertJsonPath('enabled', false);\n\n $this->assertFalse($admin->fresh()->hasOtpEnabled());\n }\n\n public function test_member_can_generate_enable_and_disable_otp_from_profile_endpoint(): void\n {\n $member = User::create([\n 'nickname' => '普通成员',\n 'email' => 'member@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'member',\n ]);\n $token = $this->tokenForUser($member);\n\n $setupResponse = $this->withToken($token)->postJson('/api/me/otp-config/setup')\n ->assertOk()\n ->assertJsonPath('secret', fn (mixed $value): bool => is_string($value) && $value !== '')\n ->json();\n\n $secret = (string) ($setupResponse['secret'] ?? '');\n $totp = app(TotpService::class);\n\n $this->withToken($token)->postJson('/api/me/otp-config/enable', [\n 'otp_code' => $totp->currentCode($secret),\n ])\n ->assertOk()\n ->assertJsonPath('enabled', true)\n ->assertJsonPath('message', '账号动态验证码已启用');\n\n $member->refresh();\n $this->assertTrue($member->hasOtpEnabled());\n\n $this->withToken($token)->postJson('/api/me/otp-config/disable', [\n 'otp_code' => $totp->currentCode((string) $member->otp_secret),\n ])\n ->assertOk()\n ->assertJsonPath('enabled', false)\n ->assertJsonPath('message', '账号动态验证码已关闭');\n\n $this->assertFalse($member->fresh()->hasOtpEnabled());\n }\n\n public function test_member_can_generate_and_enable_otp_with_custom_period_seconds(): void\n {\n $member = User::create([\n 'nickname' => '普通成员',\n 'email' => 'member-period@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'member',\n ]);\n $token = $this->tokenForUser($member);\n\n $setupResponse = $this->withToken($token)->postJson('/api/me/otp-config/setup', [\n 'otp_period_seconds' => 45,\n ])\n ->assertOk()\n ->assertJsonPath('period_seconds', 45)\n ->json();\n\n $secret = (string) ($setupResponse['secret'] ?? '');\n $totp = app(TotpService::class);\n\n $this->withToken($token)->postJson('/api/me/otp-config/enable', [\n 'otp_code' => $totp->currentCode($secret, null, 45),\n ])\n ->assertOk()\n ->assertJsonPath('enabled', true)\n ->assertJsonPath('period_seconds', 45);\n\n $member->refresh();\n\n $this->assertSame(45, $member->otpPeriodSeconds());\n $this->assertTrue($member->hasOtpEnabled());\n }\n\n public function test_admin_can_regenerate_recovery_codes_with_current_otp_code(): void\n {\n $token = $this->adminToken();\n $otpState = $this->enableOtpForAdmin($token);\n $admin = $otpState['admin'];\n $originalCodes = $otpState['recovery_codes'];\n $totp = app(TotpService::class);\n\n $response = $this->withToken($token)->postJson('/api/admin/otp-config/recovery-codes', [\n 'verification_code' => $totp->currentCode((string) $admin->otp_secret),\n ])\n ->assertOk()\n ->assertJsonPath('message', '恢复代码已重新生成')\n ->json();\n\n $this->assertIsArray($response['recovery_codes'] ?? null);\n $this->assertNotSame($originalCodes, $response['recovery_codes']);\n $this->assertSame($response['recovery_codes'], $admin->fresh()->otp_recovery_codes);\n }\n\n public function test_admin_cannot_regenerate_recovery_codes_with_existing_recovery_code(): void\n {\n $token = $this->adminToken();\n $otpState = $this->enableOtpForAdmin($token);\n $usedRecoveryCode = $otpState['recovery_codes'][0];\n\n $this->withToken($token)->postJson('/api/admin/otp-config/recovery-codes', [\n 'verification_code' => $usedRecoveryCode,\n ])\n ->assertStatus(422)\n ->assertJsonPath('message', '动态验证码错误,请重新输入');\n }\n\n public function test_admin_cannot_regenerate_recovery_codes_without_valid_verification_code(): void\n {\n $token = $this->adminToken();\n $this->enableOtpForAdmin($token);\n\n $this->withToken($token)->postJson('/api/admin/otp-config/recovery-codes', [])\n ->assertStatus(422)\n ->assertJsonValidationErrors(['verification_code']);\n\n $this->withToken($token)->postJson('/api/admin/otp-config/recovery-codes', [\n 'verification_code' => 'invalid-code',\n ])\n ->assertStatus(422)\n ->assertJsonPath('message', '动态验证码错误,请重新输入');\n }\n\n private function clearSystemSettingsStore(): void\n {\n File::delete(storage_path('app/system-settings.json'));\n }\n\n private function adminToken(): string\n {\n $admin = User::create([\n 'nickname' => '后台管理员',\n 'email' => 'admin@classbook.app',\n 'password' => Hash::make('Test1234!'),\n 'role' => 'admin',\n ]);\n\n return $this->tokenForUser($admin);\n }\n\n private function tokenForUser(User $user): string\n {\n $user->activateLoginSession(Str::random(64), '127.0.0.1');\n $user->save();\n\n return JWTAuth::fromUser($user->fresh());\n }\n\n /**\n * @return array{admin: User, recovery_codes: array<int, string>}\n */\n private function enableOtpForAdmin(string $token): array\n {\n $setupResponse = $this->withToken($token)->postJson('/api/admin/otp-config/setup')\n ->assertOk()\n ->json();\n\n $secret = (string) ($setupResponse['secret'] ?? '');\n $totp = app(TotpService::class);\n\n $enableResponse = $this->withToken($token)->postJson('/api/admin/otp-config/enable', [\n 'otp_code' => $totp->currentCode($secret),\n ])\n ->assertOk()\n ->json();\n\n return [\n 'admin' => User::query()->where('email', 'admin@classbook.app')->firstOrFail()->fresh(),\n 'recovery_codes' => $enableResponse['recovery_codes'] ?? [],\n ];\n }\n\n private function resetSystemSettingsTableExistsCache(): void\n {\n $reflection = new \\ReflectionProperty(SystemSetting::class, 'tableExists');\n $reflection->setValue(null, null);\n }\n}\n"}}}}