From b2b5c33d479576266649a545cd7c6e2387326abb Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 8 Sep 2026 07:02:25 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E4=B8=8E=E5=A4=8D=E5=88=B6=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E5=AF=86=E7=A0=81=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Business/StoreAccountPasswords.php | 84 +++++++++ app/Http/Middleware/AddXHeader.php | 3 +- app/Services/StoreAccountPasswordClient.php | 64 +++++++ config/business_navigation.php | 2 +- config/store_account_password.php | 6 + ...0_create_store_account_password_audits.php | 28 +++ docs/店铺密码按需读取.md | 39 +++++ .../StoreAccountMirrorRepository.php | 13 +- .../pages/partials/directory-table.blade.php | 20 ++- .../partials/store-account-password.blade.php | 95 ++++++++++ routes/admin.php | 3 + .../Business/StoreAccountPasswordsTest.php | 165 ++++++++++++++++++ 12 files changed, 516 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Business/StoreAccountPasswords.php create mode 100644 app/Services/StoreAccountPasswordClient.php create mode 100644 config/store_account_password.php create mode 100644 database/migrations/2026_09_08_080000_create_store_account_password_audits.php create mode 100644 docs/店铺密码按需读取.md create mode 100644 resources/views/business/pages/partials/store-account-password.blade.php create mode 100644 tests/Feature/Business/StoreAccountPasswordsTest.php diff --git a/app/Http/Controllers/Business/StoreAccountPasswords.php b/app/Http/Controllers/Business/StoreAccountPasswords.php new file mode 100644 index 00000000..582c73e5 --- /dev/null +++ b/app/Http/Controllers/Business/StoreAccountPasswords.php @@ -0,0 +1,84 @@ +bound('debugbar')) { + app('debugbar')->disable(); + } + $requestId = (string) Str::uuid(); + $purpose = $request->input('purpose'); + if (! preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $account) + || ! in_array($purpose, ['view', 'copy'], true) + || array_diff(array_keys($request->all()), ['purpose', '_token']) !== [] || $request->query() !== []) { + return $this->respond(422, $requestId); + } + try { + DB::table('store_account_password_audits')->insert([ + 'request_id' => $requestId, 'actor_user_id' => $request->user()->id, + 'company_id' => company_id(), 'store_account_id' => $account, + 'purpose' => $purpose, 'created_at' => now(), + ]); + } catch (Throwable) { + return $this->respond(503, $requestId); + } + + $result = ['status' => 503]; + try { + $pages = collect(SidebarPermissions::navigationFor($request->user()))->firstWhere('slug', 'store-management'); + // Re-read role membership for every secret request; a cached admin + // role must not survive a permission revocation. + if (! $request->user()->roles()->whereIn('roles.name', [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN])->exists() + || ! collect($pages['pages'] ?? [])->contains('slug', 'store-accounts')) { + $result = ['status' => 403]; + } elseif (RateLimiter::tooManyAttempts('store-password:'.$request->user()->id, 10)) { + $result = ['status' => 429]; + } else { + RateLimiter::hit('store-password:'.$request->user()->id, 60); + $result = $accounts->containsAccount($account) + ? $client->reveal($account, $request->user()->id, $purpose) + : ['status' => 404]; + } + } catch (Throwable) { + $result = ['status' => 503]; + } + try { + $saved = DB::table('store_account_password_audits')->where('request_id', $requestId)->update([ + 'status' => $result['status'], 'upstream_request_id' => $result['upstream_request_id'] ?? null, + 'completed_at' => now(), + ]); + if ($saved !== 1) { + return $this->respond(503, $requestId); + } + } catch (Throwable) { + return $this->respond(503, $requestId); + } + return $this->respond($result['status'], $requestId, $result['password'] ?? null); + } + + private function respond(int $status, string $requestId, ?string $password = null) + { + $messages = [403 => '当前账号没有查看店铺密码的权限。', 404 => '该账号暂无可读取的密码。', + 422 => '请求无效,请刷新页面后重试。', 429 => '操作过于频繁,请稍后再试。', + 503 => '密码服务暂不可用,请稍后再试或联系管理员。']; + return response()->json($status === 200 + ? ['password' => $password, 'requestId' => $requestId] + : ['message' => $messages[$status] ?? $messages[503], 'requestId' => $requestId], $status) + ->withHeaders(['Cache-Control' => 'no-store, private', 'Pragma' => 'no-cache', + 'X-Content-Type-Options' => 'nosniff', 'Referrer-Policy' => 'no-referrer']); + } +} diff --git a/app/Http/Middleware/AddXHeader.php b/app/Http/Middleware/AddXHeader.php index 461e1884..aac4d5f0 100644 --- a/app/Http/Middleware/AddXHeader.php +++ b/app/Http/Middleware/AddXHeader.php @@ -27,8 +27,9 @@ class AddXHeader } $response->header('X-XSS-Protection', '0'); $response->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); - if ($request->routeIs('login.dingtalk', 'login.dingtalk.callback')) { + if ($request->routeIs('login.dingtalk', 'login.dingtalk.callback', 'business.store-account-passwords.reveal')) { $response->header('Cache-Control', 'no-store'); + $response->header('Pragma', 'no-cache'); $response->header('Referrer-Policy', 'no-referrer'); } } diff --git a/app/Services/StoreAccountPasswordClient.php b/app/Services/StoreAccountPasswordClient.php new file mode 100644 index 00000000..3f40932e --- /dev/null +++ b/app/Services/StoreAccountPasswordClient.php @@ -0,0 +1,64 @@ +http = $http ?? new Client(); + } + + public function reveal(string $accountId, int $actorId, string $purpose): array + { + $template = (string) config('store_account_password.url'); + $token = (string) config('store_account_password.token'); + if (substr_count($template, '{id}') !== 1 || parse_url($template, PHP_URL_SCHEME) !== 'https' + || ! preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) { + return ['status' => 503]; + } + try { + $response = $this->http->request('POST', str_replace('{id}', rawurlencode($accountId), $template), [ + 'headers' => ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json'], + 'json' => ['requesterId' => 'finance-user-'.$actorId, 'purpose' => $purpose], + 'verify' => true, 'allow_redirects' => false, 'http_errors' => false, + 'connect_timeout' => 3, 'timeout' => 10, + 'sink' => fopen('php://memory', 'w+'), + ]); + $status = $response->getStatusCode(); + if ($response->getBody()->isSeekable()) { + $response->getBody()->rewind(); + } + $body = $response->getBody()->read(65537); + $response->getBody()->close(); + if (strlen($body) > 65536) { + return ['status' => 503]; + } + $data = json_decode($body, true); + unset($body); + $requestId = $data['requestId'] ?? null; + if (! is_string($requestId) || ! preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/iD', $requestId)) { + $requestId = null; + } + if ($status !== 200) { + return ['status' => in_array($status, [404, 429], true) ? $status : 503, 'upstream_request_id' => $requestId]; + } + if (! $requestId || ! preg_match('/(?:^|,)\s*no-store\s*(?:,|$)/i', $response->getHeaderLine('Cache-Control')) + || ! is_string($data['password'] ?? null) || $data['password'] === '') { + return ['status' => 503, 'upstream_request_id' => $requestId]; + } + return ['status' => 200, 'password' => $data['password'], 'upstream_request_id' => $requestId]; + } catch (Throwable) { + // Never log exception objects: transport errors may contain response bodies. + return ['status' => 503]; + } + } +} diff --git a/config/business_navigation.php b/config/business_navigation.php index 2fd1b400..5663fc9a 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -243,7 +243,7 @@ return [ 'directory' => [ 'icon' => 'key', 'status' => '共享镜像', - 'description' => '读取店铺账号系统的共享业务镜像;密码不返回明文,仅展示已允许读取的账号资料。', + 'description' => '账号资料来自共享镜像。管理员可按需查看或复制密码,密码不会随表格导出。', 'empty' => '共享镜像中暂未读取到店铺账号数据,请检查账号数据同步状态。', 'fields' => [ ['name' => 'store_name', 'label' => '店铺名称'], diff --git a/config/store_account_password.php b/config/store_account_password.php new file mode 100644 index 00000000..22510491 --- /dev/null +++ b/config/store_account_password.php @@ -0,0 +1,6 @@ + env('STORE_ACCOUNT_REVEAL_URL', 'https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal'), + 'token' => env('STORE_ACCOUNT_REVEAL_TOKEN'), +]; diff --git a/database/migrations/2026_09_08_080000_create_store_account_password_audits.php b/database/migrations/2026_09_08_080000_create_store_account_password_audits.php new file mode 100644 index 00000000..686b0bac --- /dev/null +++ b/database/migrations/2026_09_08_080000_create_store_account_password_audits.php @@ -0,0 +1,28 @@ +uuid('request_id')->primary(); + $table->unsignedInteger('actor_user_id')->nullable()->index(); + $table->unsignedInteger('company_id'); + $table->string('store_account_id', 128); + $table->string('purpose', 8); + $table->unsignedSmallInteger('status')->nullable(); + $table->uuid('upstream_request_id')->nullable(); + $table->timestamp('created_at')->useCurrent()->index(); + $table->timestamp('completed_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_account_password_audits'); + } +}; diff --git a/docs/店铺密码按需读取.md b/docs/店铺密码按需读取.md new file mode 100644 index 00000000..90a47553 --- /dev/null +++ b/docs/店铺密码按需读取.md @@ -0,0 +1,39 @@ +# 店铺密码按需读取 + +## 使用方式 + +现有管理员使用原财务账号密码登录,在“店铺账号密码”页面点击查看或复制。首版仅允许数据库中仍持有 `super_admin` 或 `admin` 角色,且拥有该页面访问范围的用户。每次请求重新查询角色,不信任旧权限缓存。普通用户和钉钉自动创建的普通账号不能读取密码。 + +列表仍从 `database_4.external_store_accounts.accounts_v1` 的共享镜像读取。只有安全布尔字段 `passwordConfigured` 进入列表;镜像尚未新增该列时保持未知状态,管理员仍可按需请求,空密码由上游返回 404。镜像中的稳定账号 ID 是唯一请求目标,镜像未就绪时拒绝访问。 + +查看在表格之外的独立弹窗中显示,30 秒、关闭、离开页面或隐藏标签页时清除;不写浏览器存储。复制单独请求 `purpose=copy`,成功后写入用户剪贴板,不自动清除剪贴板以免覆盖用户后续内容。每次操作独立读取,没有预取、批量读取、重试或密码缓存。表格和 CSV 只包含配置状态,不包含弹窗密码。 + +## 后端与审计 + +- 财务路由:`POST /{company_id}/store-account-passwords/{account}/reveal`,请求体仅允许 `purpose=view|copy` 和框架 CSRF 字段。 +- 使用可信会话中的用户 ID 生成 `requesterId=finance-user-{id}`,拒绝客户端额外提供操作者信息;验证公司、管理员角色、页面范围和镜像账号存在性。 +- 上游:`POST https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal`,专用 Bearer 令牌。完整契约来自上游 PR #17。 +- 专用 Guzzle 客户端不接入 Laravel HTTP 事件采集器,不记录请求、响应或异常对象。验证 TLS,总超时 10 秒、连接超时 3 秒,不跟随重定向、不重试,响应只缓存在本次请求内存中。 +- 上游 404/429 对应财务端 404/429;上游 403 表示服务凭据或网关配置故障,财务端返回固定 503 提示,避免误导为当前人员权限问题。上游 503、重定向、错误 JSON、缺少 `no-store` 或无效追踪 ID 均拒绝返回密码。财务权限拒绝为 403。 +- 财务端每用户 10 次/分钟;上游还按服务、人员、账号执行持久限流,查看和复制共享额度。 +- 新增 `store_account_password_audits`:只记录财务追踪 ID、操作者、公司、账号 ID、用途、状态、上游追踪 ID 和时间。开始审计失败不调用上游;成功审计无法落库时不向浏览器返回密码。审计成功表示服务器交付响应,不表示浏览器已经复制完成。 +- 响应 `Cache-Control: no-store`、`Pragma: no-cache`、`nosniff`、`no-referrer`。真实密码不进入日志、镜像、ClickHouse、Parquet、缓存或消息队列。网关/APM 必须排除该精确密码路由的请求/响应正文采集及响应落盘缓存。 + +## 配置及发布 + +后端环境变量为 `STORE_ACCOUNT_REVEAL_URL`(含 `{id}` 的完整 HTTPS 模板)和 `STORE_ACCOUNT_REVEAL_TOKEN`。令牌已在负责人提供的受保护文件中,不在文档或 PR 中复制。财务工作区已将两项配置保存到经 `git check-ignore` 核验的 `runtime/private/store-account-password.env`,仅供后端部署读取。 + +上线需要单独批准以下范围: + +1. 备份生产系统库结构和当前应用配置;运行新增迁移 `2026_09_08_080000_create_store_account_password_audits`,只新增无密码审计表。 +2. 将上述两个变量安全加载到财务后端运行环境,禁止进入镜像、前端或发布输出。确认生产未启用响应正文采集。 +3. 如需显示上游新的布尔状态,按既有通用镜像登记/刷新流程更新该精确数据集的字段与镜像,核对 958 条资料仍完整。此步骤只涉及安全状态,不能加入密码字段。 +4. 使用已合并最新 main 及工作站发布脚本发布。生产验收需另行安排虚构上游账号,不能用真实店铺密码进行自动化抓取或截图。 + +回滚使用上一应用镜像、移除新服务凭据即可;新增审计表保留,不自动删除审计或用户资料。不需要修改钉钉配置,也不需要新增管理员账号。 + +## 验证 + +18 项后端/镜像测试通过,覆盖管理员查看与复制、可信操作者、普通用户与受限管理员拒绝、镜像无账号、403/404/429/503/重定向、限流、缺失令牌、缺少 no-store、审计失败不返回密码,以及审计不含虚构密码。 + +浏览器使用实际 Blade 渲染的虚构账号页面和独立本地假接口检查:查看、关闭清空、30 秒自动清空、复制后实际粘贴、桌面与 390px 窄屏弹窗。未调用上游真实店铺密码接口,未执行财务生产迁移或发布。 diff --git a/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php index 1c4ba15c..7ec925fc 100644 --- a/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php @@ -39,8 +39,8 @@ class StoreAccountMirrorRepository 'profit_center' => $row->profitCenter, 'account_type' => $this->accountType($row->accountType), 'account' => $row->account, - // 上游只读视图明确不返回密码密文,列表只能展示未知配置状态。 - 'password_configured' => null, + // Only the safe boolean may enter the mirror, never a password. + 'password_configured' => isset($row->passwordConfigured) ? (bool) $row->passwordConfigured : null, 'bound_phone' => $row->boundPhone, 'verification_contact' => $row->verificationContact, 'account_group_name' => $row->accountGroupName, @@ -52,6 +52,15 @@ class StoreAccountMirrorRepository return ['data' => $rows, 'total' => $total]; } + public function containsAccount(string $id): bool + { + $dataset = $this->datasets->get(self::DATASET); + if (! $this->mirror->ready($dataset)) { + throw new RuntimeException('store_account_mirror_not_ready'); + } + return $this->mirror->connection()->table($dataset->targetTable())->where('id', $id)->exists(); + } + /** * 店铺维度报表的归属字段覆盖表,以店铺名称(storeName)为键。 * diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 2b2c70ea..73c7719a 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -492,7 +492,19 @@ $display = in_array($value, [true, 1, '1', '是', '✔'], true) ? '是' : '否'; } @endphp - {{ filled($display) ? $display : '—' }} + + @if (($page['slug'] ?? '') === 'store-accounts' && $field['name'] === 'password_configured') + {{ filled($display) ? $display : '—' }} + @if (\App\Utilities\SystemAccess::canOpenSettings() && $value !== false) + + + + + @endif + @else + {{ filled($display) ? $display : '—' }} + @endif + @endforeach @empty @@ -542,6 +554,10 @@ @endif +@if (($page['slug'] ?? '') === 'store-accounts' && \App\Utilities\SystemAccess::canOpenSettings()) + @include('business.pages.partials.store-account-password') +@endif + @once +@endonce diff --git a/routes/admin.php b/routes/admin.php index 41583ed3..822423c1 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -11,6 +11,9 @@ use Modules\EcommerceFinance\Http\Controllers\AlipayBills; use Modules\EcommerceFinance\Http\Controllers\Api\GoodsReconciliations; use Modules\EcommerceFinance\Http\Controllers\Api\SupplierPayables; +Route::post('store-account-passwords/{account}/reveal', 'Business\StoreAccountPasswords@reveal') + ->name('business.store-account-passwords.reveal'); + Route::get('ecommerce-finance/alipay-bills', [AlipayBills::class, 'index']) ->name('ecommerce-finance.alipay-bills.index'); Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show']) diff --git a/tests/Feature/Business/StoreAccountPasswordsTest.php b/tests/Feature/Business/StoreAccountPasswordsTest.php new file mode 100644 index 00000000..36edfb0c --- /dev/null +++ b/tests/Feature/Business/StoreAccountPasswordsTest.php @@ -0,0 +1,165 @@ + str_repeat('t', 43), 'store_account_password.url' => 'https://upstream.example.test/internal/store-accounts/{id}/password/reveal']); + $this->mock(StoreAccountMirrorRepository::class)->shouldReceive('containsAccount')->with('fixture-account')->andReturnTrue()->byDefault(); + } + + private function transport(int $status = 200, ?array $body = null, array $headers = ['Cache-Control' => 'no-store']): void + { + $handler = HandlerStack::create(new MockHandler([new Response($status, $headers, json_encode($body ?? [ + 'requestId' => '12345678-1234-4123-8123-123456789012', 'password' => $this->fixture, + ]))])); + $handler->push(Middleware::history($this->history)); + $this->app->instance(StoreAccountPasswordClient::class, new StoreAccountPasswordClient(new Client(['handler' => $handler]))); + } + + private function endpoint(): string + { + return route('business.store-account-passwords.reveal', ['account' => 'fixture-account']); + } + + public function test_admin_view_uses_trusted_actor_and_persists_password_free_audit(): void + { + $this->transport(); + $response = $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertOk() + ->assertJsonPath('password', $this->fixture); + $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control')); + $response->assertHeaderMissing('phpdebugbar-id'); + $row = (array) DB::table('store_account_password_audits')->first(); + $this->assertSame(200, $row['status']); + $this->assertSame($this->user->id, $row['actor_user_id']); + $this->assertStringNotContainsString($this->fixture, json_encode($row)); + $sent = json_decode((string) $this->history[0]['request']->getBody(), true); + $this->assertSame(['requesterId' => 'finance-user-'.$this->user->id, 'purpose' => 'view'], $sent); + $this->assertFalse($this->history[0]['options']['allow_redirects']); + $this->assertTrue($this->history[0]['options']['verify']); + $this->assertSame(10, $this->history[0]['options']['timeout']); + } + + public function test_normal_admin_can_copy_without_dingtalk_binding(): void + { + $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); + $this->transport(); + $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'copy'])->assertOk(); + $this->assertDatabaseHas('store_account_password_audits', ['purpose' => 'copy', 'status' => 200]); + } + + public function test_regular_user_cannot_call_upstream(): void + { + $this->user->roles()->sync([Role::where('name', 'user')->firstOrFail()->id]); + $this->user->permissions()->attach(\App\Models\Auth\Permission::where('name', 'read-admin-panel')->firstOrFail()->id); + $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal'); + $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertForbidden(); + } + + public function test_restricted_admin_cannot_bypass_store_page_scope(): void + { + setting()->set('system.sidebar_permissions', ['configured' => ['user_'.$this->user->id], 'subjects' => ['user_'.$this->user->id => []]]); + $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal'); + $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertForbidden(); + } + + public function test_actor_spoofing_is_rejected(): void + { + $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal'); + $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view', 'requesterId' => 'someone-else'])->assertStatus(422); + } + + public function test_account_must_exist_in_authorized_mirror(): void + { + $this->mock(StoreAccountMirrorRepository::class)->shouldReceive('containsAccount')->andReturnFalse(); + $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal'); + $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertNotFound(); + } + + #[DataProvider('upstreamErrors')] + public function test_errors_never_expose_upstream_bodies(int $upstream, int $expected): void + { + $this->transport($upstream); + $response = $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'copy'])->assertStatus($expected); + $this->assertStringNotContainsString($this->fixture, $response->getContent()); + $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control')); + } + + public static function upstreamErrors(): array + { + return [[403, 503], [404, 404], [429, 429], [503, 503], [302, 503]]; + } + + public function test_missing_audit_storage_prevents_upstream_read(): void + { + Schema::drop('store_account_password_audits'); + $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal'); + $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(503); + } + + public function test_failed_success_audit_suppresses_password(): void + { + $this->mock(StoreAccountPasswordClient::class)->shouldReceive('reveal')->andReturnUsing(function () { + Schema::drop('store_account_password_audits'); + return ['status' => 200, 'password' => $this->fixture]; + }); + $response = $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(503); + $this->assertStringNotContainsString($this->fixture, $response->getContent()); + } + + public function test_missing_no_store_header_rejects_upstream_success(): void + { + $this->transport(headers: []); + $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(503)->assertJsonMissingPath('password'); + } + + public function test_unconfigured_token_does_not_send_a_request(): void + { + $this->transport(); + config(['store_account_password.token' => null]); + $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(503); + $this->assertSame([], $this->history); + } + + public function test_local_rate_limit_prevents_upstream_request(): void + { + for ($i = 0; $i < 10; $i++) { + \Illuminate\Support\Facades\RateLimiter::hit('store-password:'.$this->user->id, 60); + } + $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal'); + $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(429); + } + + public function test_admin_page_has_explicit_actions_and_no_plaintext_in_table(): void + { + $this->mock(\App\Business\UpstreamBusinessInformation::class)->shouldReceive('paginate')->andReturn( + new \Illuminate\Pagination\LengthAwarePaginator([(object) [ + 'id' => 'fixture-account', 'store_name' => '测试店铺', 'account' => 'fixture-login', 'password_configured' => true, + ]], 1, 50, 1) + ); + $response = $this->loginAs()->get(route('business.pages.show', ['section' => 'store-management', 'page' => 'store-accounts']))->assertOk(); + $response->assertSee('data-store-password="view"', false)->assertSee('data-store-password="copy"', false) + ->assertSee('data-password-dialog', false)->assertDontSee($this->fixture); + $html = $response->getContent(); + $this->assertGreaterThan(strpos($html, ''), strpos($html, '