feat: 店铺密码在当前页直接显示
This commit is contained in:
parent
d876822a6e
commit
10f38fcdf3
@ -4,7 +4,6 @@ namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Services\StoreAccountPasswordClient;
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use App\Utilities\SystemAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -39,11 +38,7 @@ class StoreAccountPasswords extends Controller
|
||||
|
||||
$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')) {
|
||||
if (! $this->canReveal($request)) {
|
||||
$result = ['status' => 403];
|
||||
} elseif (RateLimiter::tooManyAttempts('store-password:'.$request->user()->id, 10)) {
|
||||
$result = ['status' => 429];
|
||||
@ -70,6 +65,83 @@ class StoreAccountPasswords extends Controller
|
||||
return $this->respond($result['status'], $requestId, $result['password'] ?? null);
|
||||
}
|
||||
|
||||
public function revealVisiblePage(Request $request, StoreAccountMirrorRepository $accounts, StoreAccountPasswordClient $client)
|
||||
{
|
||||
if (app()->bound('debugbar')) {
|
||||
app('debugbar')->disable();
|
||||
}
|
||||
$batchRequestId = (string) Str::uuid();
|
||||
$accountIds = $request->input('accounts');
|
||||
if (array_diff(array_keys($request->all()), ['accounts', '_token']) !== []
|
||||
|| $request->query() !== [] || ! is_array($accountIds)
|
||||
|| count($accountIds) < 1 || count($accountIds) > 50
|
||||
|| count(array_unique($accountIds, SORT_REGULAR)) !== count($accountIds)
|
||||
|| collect($accountIds)->contains(fn ($id) => ! is_string($id) || ! preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $id))) {
|
||||
return $this->respondVisiblePage(422, $batchRequestId);
|
||||
}
|
||||
$accountIds = array_values($accountIds);
|
||||
$requestIds = collect($accountIds)->mapWithKeys(fn ($id) => [$id => (string) Str::uuid()])->all();
|
||||
try {
|
||||
DB::table('store_account_password_audits')->insert(collect($accountIds)->map(fn ($id) => [
|
||||
'request_id' => $requestIds[$id], 'actor_user_id' => $request->user()->id,
|
||||
'company_id' => company_id(), 'store_account_id' => $id,
|
||||
'purpose' => 'view', 'created_at' => now(),
|
||||
])->all());
|
||||
} catch (Throwable) {
|
||||
return $this->respondVisiblePage(503, $batchRequestId);
|
||||
}
|
||||
|
||||
$result = ['status' => 503];
|
||||
try {
|
||||
if (! $this->canReveal($request)) {
|
||||
$result = ['status' => 403];
|
||||
} elseif (RateLimiter::tooManyAttempts('store-password-page:'.$request->user()->id, 5)) {
|
||||
$result = ['status' => 429];
|
||||
} else {
|
||||
RateLimiter::hit('store-password-page:'.$request->user()->id, 60);
|
||||
$result = $accounts->containsAccounts($accountIds)
|
||||
? $client->revealVisiblePage($accountIds, $request->user()->id)
|
||||
: ['status' => 404];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$result = ['status' => 503];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
try {
|
||||
DB::transaction(function () use ($accountIds, $requestIds, $result, &$items) {
|
||||
foreach ($accountIds as $accountId) {
|
||||
$upstream = $result['status'] === 200
|
||||
? ($result['items'][$accountId] ?? ['status' => 503])
|
||||
: ['status' => $result['status']];
|
||||
$saved = DB::table('store_account_password_audits')->where('request_id', $requestIds[$accountId])->update([
|
||||
'status' => $upstream['status'], 'upstream_request_id' => $upstream['upstream_request_id'] ?? null,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
if ($saved !== 1) {
|
||||
throw new \RuntimeException('store_password_audit_update_failed');
|
||||
}
|
||||
$items[$accountId] = [
|
||||
'status' => $upstream['status'], 'requestId' => $requestIds[$accountId],
|
||||
'password' => $upstream['status'] === 200 ? $upstream['password'] : null,
|
||||
];
|
||||
}
|
||||
});
|
||||
} catch (Throwable) {
|
||||
return $this->respondVisiblePage(503, $batchRequestId);
|
||||
}
|
||||
if ($result['status'] !== 200) {
|
||||
return $this->respondVisiblePage($result['status'], $batchRequestId);
|
||||
}
|
||||
return $this->respondVisiblePage(200, $batchRequestId, $items);
|
||||
}
|
||||
|
||||
private function canReveal(Request $request): bool
|
||||
{
|
||||
$section = collect(SidebarPermissions::navigationFor($request->user()))->firstWhere('slug', 'store-management');
|
||||
return collect($section['pages'] ?? [])->contains('slug', 'store-accounts');
|
||||
}
|
||||
|
||||
private function respond(int $status, string $requestId, ?string $password = null)
|
||||
{
|
||||
$messages = [403 => '当前账号没有查看店铺密码的权限。', 404 => '该账号暂无可读取的密码。',
|
||||
@ -81,4 +153,22 @@ class StoreAccountPasswords extends Controller
|
||||
->withHeaders(['Cache-Control' => 'no-store, private', 'Pragma' => 'no-cache',
|
||||
'X-Content-Type-Options' => 'nosniff', 'Referrer-Policy' => 'no-referrer']);
|
||||
}
|
||||
|
||||
private function respondVisiblePage(int $status, string $requestId, array $items = [])
|
||||
{
|
||||
$messages = [403 => '当前账号没有查看店铺密码的页面权限。', 404 => '当前页包含无效账号,请刷新后重试。',
|
||||
422 => '请求无效,请刷新页面后重试。', 429 => '自动读取过于频繁,请稍后刷新。',
|
||||
503 => '密码服务暂不可用,请稍后刷新或联系管理员。'];
|
||||
$body = $status === 200
|
||||
? ['items' => collect($items)->map(fn ($item, $accountId) => array_filter([
|
||||
'accountId' => $accountId, 'status' => $item['status'], 'requestId' => $item['requestId'],
|
||||
'password' => $item['status'] === 200 ? $item['password'] : null,
|
||||
'message' => $item['status'] === 200 ? null : ($messages[$item['status']] ?? $messages[503]),
|
||||
], fn ($value) => $value !== null))->values()->all()]
|
||||
: ['message' => $messages[$status] ?? $messages[503], 'requestId' => $requestId];
|
||||
return response()->json($body, $status)->withHeaders([
|
||||
'Cache-Control' => 'no-store, private', 'Pragma' => 'no-cache',
|
||||
'X-Content-Type-Options' => 'nosniff', 'Referrer-Policy' => 'no-referrer',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ class AddXHeader
|
||||
}
|
||||
$response->header('X-XSS-Protection', '0');
|
||||
$response->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
if ($request->routeIs('login.dingtalk', 'login.dingtalk.callback', 'business.store-account-passwords.reveal')) {
|
||||
if ($request->routeIs('login.dingtalk', 'login.dingtalk.callback', 'business.store-account-passwords.reveal', 'business.store-account-passwords.reveal-visible-page')) {
|
||||
$response->header('Cache-Control', 'no-store');
|
||||
$response->header('Pragma', 'no-cache');
|
||||
$response->header('Referrer-Policy', 'no-referrer');
|
||||
|
||||
@ -61,4 +61,74 @@ class StoreAccountPasswordClient
|
||||
return ['status' => 503];
|
||||
}
|
||||
}
|
||||
|
||||
public function revealVisiblePage(array $accountIds, int $actorId): array
|
||||
{
|
||||
$url = (string) config('store_account_password.visible_page_url');
|
||||
$token = (string) config('store_account_password.token');
|
||||
$accountIds = array_values($accountIds);
|
||||
if (parse_url($url, PHP_URL_SCHEME) !== 'https'
|
||||
|| ! preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)
|
||||
|| count($accountIds) < 1 || count($accountIds) > 50
|
||||
|| count(array_unique($accountIds)) !== count($accountIds)
|
||||
|| collect($accountIds)->contains(fn ($id) => ! is_string($id) || ! preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $id))) {
|
||||
return ['status' => 503];
|
||||
}
|
||||
try {
|
||||
$response = $this->http->request('POST', $url, [
|
||||
'headers' => ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json'],
|
||||
'json' => ['requesterId' => 'finance-user-'.$actorId, 'purpose' => 'view', 'accountIds' => $accountIds],
|
||||
'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(131073);
|
||||
$response->getBody()->close();
|
||||
if (strlen($body) > 131072) {
|
||||
return ['status' => 503];
|
||||
}
|
||||
$data = json_decode($body, true);
|
||||
unset($body);
|
||||
if ($status !== 200) {
|
||||
return ['status' => $status === 429 ? 429 : 503];
|
||||
}
|
||||
if (! preg_match('/(?:^|,)\s*no-store\s*(?:,|$)/i', $response->getHeaderLine('Cache-Control'))
|
||||
|| ! $this->uuid($data['requestId'] ?? null) || ! is_array($data['items'] ?? null)
|
||||
|| count($data['items']) !== count($accountIds)) {
|
||||
return ['status' => 503];
|
||||
}
|
||||
$items = [];
|
||||
foreach ($data['items'] as $item) {
|
||||
$accountId = $item['accountId'] ?? null;
|
||||
$itemStatus = $item['status'] ?? null;
|
||||
if (! is_string($accountId) || ! in_array($accountId, $accountIds, true) || isset($items[$accountId])
|
||||
|| ! $this->uuid($item['requestId'] ?? null)
|
||||
|| ! in_array($itemStatus, [200, 404, 429, 503], true)
|
||||
|| ($itemStatus === 200 && (! is_string($item['password'] ?? null) || $item['password'] === ''))
|
||||
|| ($itemStatus !== 200 && array_key_exists('password', $item))) {
|
||||
return ['status' => 503];
|
||||
}
|
||||
$items[$accountId] = [
|
||||
'status' => $itemStatus,
|
||||
'password' => $itemStatus === 200 ? $item['password'] : null,
|
||||
'upstream_request_id' => $item['requestId'],
|
||||
];
|
||||
}
|
||||
if (array_keys($items) !== $accountIds) {
|
||||
return ['status' => 503];
|
||||
}
|
||||
return ['status' => 200, 'items' => $items];
|
||||
} catch (Throwable) {
|
||||
return ['status' => 503];
|
||||
}
|
||||
}
|
||||
|
||||
private function uuid(mixed $value): bool
|
||||
{
|
||||
return is_string($value) && (bool) preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/iD', $value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,5 +2,6 @@
|
||||
|
||||
return [
|
||||
'url' => env('STORE_ACCOUNT_REVEAL_URL', 'https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal'),
|
||||
'visible_page_url' => env('STORE_ACCOUNT_REVEAL_VISIBLE_PAGE_URL', 'https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal'),
|
||||
'token' => env('STORE_ACCOUNT_REVEAL_TOKEN'),
|
||||
];
|
||||
|
||||
@ -1,39 +1,37 @@
|
||||
# 店铺密码按需读取
|
||||
# 店铺密码直接显示
|
||||
|
||||
## 使用方式
|
||||
## 使用与权限
|
||||
|
||||
现有管理员使用原财务账号密码登录,在“店铺账号密码”页面点击查看或复制。首版仅允许数据库中仍持有 `super_admin` 或 `admin` 角色,且拥有该页面访问范围的用户。每次请求重新查询角色,不信任旧权限缓存。普通用户和钉钉自动创建的普通账号不能读取密码。
|
||||
“店铺账号密码”页面打开后,系统自动批量读取当前页最多 50 个已设置密码的账号,并把密码直接显示在原表格单元格内。用户无需再点击“查看”;“复制”仍保留为独立操作,便于形成准确的复制审计。未设置、读取受限和服务失败会分别显示固定状态,失败项可由用户重新读取。
|
||||
|
||||
列表仍从 `database_4.external_store_accounts.accounts_v1` 的共享镜像读取。只有安全布尔字段 `passwordConfigured` 进入列表;镜像尚未新增该列时保持未知状态,管理员仍可按需请求,空密码由上游返回 404。镜像中的稳定账号 ID 是唯一请求目标,镜像未就绪时拒绝访问。
|
||||
密码读取与该页面使用同一访问边界:只要当前用户的页面权限包含 `store-management/store-accounts`,就可以直接显示和复制密码,不再额外要求 `admin` 或 `super_admin` 身份。主管理员可在“权限管理 → 页面权限设置”通过用户、分组或角色收回此页面;显式用户配置优先于分组,多个分组取并集。当前系统对尚未配置的用户默认开放全部业务页面,因此上线前应确认需要限制的用户或分组已经排除该页面。
|
||||
|
||||
查看在表格之外的独立弹窗中显示,30 秒、关闭、离开页面或隐藏标签页时清除;不写浏览器存储。复制单独请求 `purpose=copy`,成功后写入用户剪贴板,不自动清除剪贴板以免覆盖用户后续内容。每次操作独立读取,没有预取、批量读取、重试或密码缓存。表格和 CSV 只包含配置状态,不包含弹窗密码。
|
||||
列表仍从 `database_4.external_store_accounts.accounts_v1` 的共享镜像读取。镜像只保存 `passwordConfigured` 布尔状态和稳定账号 ID,不保存密码。当前页账号 ID 必须全部存在于镜像,财务端才会调用上游。
|
||||
|
||||
## 后端与审计
|
||||
## 接口与审计
|
||||
|
||||
- 财务路由:`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 必须排除该精确密码路由的请求/响应正文采集及响应落盘缓存。
|
||||
- 当前页批量路由:`POST /{company_id}/store-account-passwords/visible-page/reveal`,请求体仅允许 `accounts` 和框架 CSRF 字段;账号 ID 必须是 1 至 50 个互不重复的合法字符串。
|
||||
- 单账号复制路由:`POST /{company_id}/store-account-passwords/{account}/reveal`,请求体仅允许 `purpose=view|copy` 和框架 CSRF 字段。前端复制使用 `purpose=copy`。
|
||||
- 财务后端从可信会话生成 `requesterId=finance-user-{id}`,不接受浏览器提供操作者身份。每次请求重新计算页面权限并验证镜像账号。
|
||||
- 上游批量路由:`POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal`;单账号路由保持原契约。两者使用同一专用 Bearer 凭据。
|
||||
- 批量响应按账号返回独立的 `200/404/429/503` 状态和追踪 ID。任何协议错误、缺少 `no-store`、无效追踪 ID、重复或遗漏账号都会使本批次在财务端按失败处理,密码不会返回浏览器。
|
||||
- 财务端每用户每分钟最多自动批量读取 5 次,单账号复制仍为每分钟 10 次。上游另有持久化的服务、人员、批次和账号限流。
|
||||
- `store_account_password_audits` 为当前页每个账号写入一条 `view` 审计,复制写入 `copy` 审计。开始审计失败时不调用上游;完成审计无法落库时不向浏览器返回密码。审计只保存操作者、公司、账号 ID、用途、状态、追踪 ID和时间。
|
||||
|
||||
## 配置及发布
|
||||
## 密码处理
|
||||
|
||||
后端环境变量为 `STORE_ACCOUNT_REVEAL_URL`(含 `{id}` 的完整 HTTPS 模板)和 `STORE_ACCOUNT_REVEAL_TOKEN`。令牌已在负责人提供的受保护文件中,不在文档或 PR 中复制。财务工作区已将两项配置保存到经 `git check-ignore` 核验的 `runtime/private/store-account-password.env`,仅供后端部署读取。
|
||||
专用 Guzzle 客户端不接入 Laravel HTTP 事件采集器,不记录请求、响应或异常对象;验证 TLS,总超时 10 秒、连接超时 3 秒,不跟随重定向、不重试。上游和财务响应均发送 `Cache-Control: no-store`、`Pragma: no-cache`、`nosniff` 和 `no-referrer`。
|
||||
|
||||
上线需要单独批准以下范围:
|
||||
密码只存在于上游进程、财务请求内存和当前浏览器页面 DOM。页面隐藏、离开或卸载时清空当前页内容,再次显示页面时重新读取。密码不进入共享镜像、CSV 导出、日志、ClickHouse、Parquet、缓存、浏览器存储或消息队列。网关和 APM 必须继续排除两个精确密码路由的请求及响应正文采集。
|
||||
|
||||
1. 备份生产系统库结构和当前应用配置;运行新增迁移 `2026_09_08_080000_create_store_account_password_audits`,只新增无密码审计表。
|
||||
2. 将上述两个变量安全加载到财务后端运行环境,禁止进入镜像、前端或发布输出。确认生产未启用响应正文采集。
|
||||
3. 如需显示上游新的布尔状态,按既有通用镜像登记/刷新流程更新该精确数据集的字段与镜像,核对 958 条资料仍完整。此步骤只涉及安全状态,不能加入密码字段。
|
||||
4. 使用已合并最新 main 及工作站发布脚本发布。生产验收需另行安排虚构上游账号,不能用真实店铺密码进行自动化抓取或截图。
|
||||
## 配置与发布
|
||||
|
||||
回滚使用上一应用镜像、移除新服务凭据即可;新增审计表保留,不自动删除审计或用户资料。不需要修改钉钉配置,也不需要新增管理员账号。
|
||||
财务后端使用以下环境变量:
|
||||
|
||||
## 验证
|
||||
- `STORE_ACCOUNT_REVEAL_URL`:含 `{id}` 的单账号 HTTPS 地址。
|
||||
- `STORE_ACCOUNT_REVEAL_VISIBLE_PAGE_URL`:当前页批量 HTTPS 地址;未配置时使用正式默认地址。
|
||||
- `STORE_ACCOUNT_REVEAL_TOKEN`:专用服务令牌。
|
||||
|
||||
18 项后端/镜像测试通过,覆盖管理员查看与复制、可信操作者、普通用户与受限管理员拒绝、镜像无账号、403/404/429/503/重定向、限流、缺失令牌、缺少 no-store、审计失败不返回密码,以及审计不含虚构密码。
|
||||
令牌只保存在受保护的运行时配置中,不能进入 Git、镜像、前端或发布输出。本次财务改动复用现有审计表,不增加数据库迁移。发布顺序必须先让上游批量接口可用,再发布财务页面;回滚时先回滚财务应用,再回滚上游接口。
|
||||
|
||||
浏览器使用实际 Blade 渲染的虚构账号页面和独立本地假接口检查:查看、关闭清空、30 秒自动清空、复制后实际粘贴、桌面与 390px 窄屏弹窗。未调用上游真实店铺密码接口,未执行财务生产迁移或发布。
|
||||
验收使用虚构账号和虚构密码,检查当前页自动显示、部分失败、整体失败重试、复制、窄屏和页面隐藏清空。不得用真实店铺密码制作自动化快照、日志或截图。
|
||||
|
||||
@ -61,6 +61,20 @@ class StoreAccountMirrorRepository
|
||||
return $this->mirror->connection()->table($dataset->targetTable())->where('id', $id)->exists();
|
||||
}
|
||||
|
||||
public function containsAccounts(array $ids): bool
|
||||
{
|
||||
$ids = array_values(array_unique($ids));
|
||||
if ($ids === []) {
|
||||
return false;
|
||||
}
|
||||
$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())
|
||||
->whereIn('id', $ids)->distinct()->count('id') === count($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺维度报表的归属字段覆盖表,以店铺名称(storeName)为键。
|
||||
*
|
||||
|
||||
@ -494,11 +494,17 @@
|
||||
@endphp
|
||||
<td data-column-key="{{ $field['name'] }}" class="{{ $kind === 'number' ? 'is-number' : '' }}">
|
||||
@if (($page['slug'] ?? '') === 'store-accounts' && $field['name'] === 'password_configured')
|
||||
<span data-password-status>{{ filled($display) ? $display : '—' }}</span>
|
||||
@if (\App\Utilities\SystemAccess::canOpenSettings() && $value !== false)
|
||||
<span class="store-password-actions">
|
||||
<button type="button" data-store-password="view" data-password-url="{{ route('business.store-account-passwords.reveal', ['account' => data_get($row, 'id')]) }}" data-account-label="{{ data_get($row, 'store_name') }} / {{ data_get($row, 'account') }}" aria-label="查看{{ data_get($row, 'store_name') }}的账号密码">查看</button>
|
||||
<button type="button" data-store-password="copy" data-password-url="{{ route('business.store-account-passwords.reveal', ['account' => data_get($row, 'id')]) }}" aria-label="复制{{ data_get($row, 'store_name') }}的账号密码">复制</button>
|
||||
<span class="sr-only" data-password-status>{{ filled($display) ? $display : '—' }}</span>
|
||||
@if ($value === false)
|
||||
<span class="store-password-state">未设置</span>
|
||||
@else
|
||||
<span
|
||||
data-store-password-cell
|
||||
data-account-id="{{ data_get($row, 'id') }}"
|
||||
data-copy-url="{{ route('business.store-account-passwords.reveal', ['account' => data_get($row, 'id')]) }}"
|
||||
>
|
||||
<span class="store-password-value" data-store-password-value aria-live="polite">读取中…</span>
|
||||
<button type="button" data-store-password-copy hidden aria-label="复制{{ data_get($row, 'store_name') }}的账号密码">复制</button>
|
||||
</span>
|
||||
@endif
|
||||
@else
|
||||
@ -554,7 +560,7 @@
|
||||
@endif
|
||||
</section>
|
||||
|
||||
@if (($page['slug'] ?? '') === 'store-accounts' && \App\Utilities\SystemAccess::canOpenSettings())
|
||||
@if (($page['slug'] ?? '') === 'store-accounts')
|
||||
@include('business.pages.partials.store-account-password')
|
||||
@endif
|
||||
|
||||
|
||||
@ -1,95 +1,169 @@
|
||||
@once
|
||||
<style>
|
||||
.store-password-actions { display:inline-flex; gap:.5rem; margin-left:.5rem; }
|
||||
.store-password-actions button { color:var(--vip-content-accent-dark,#856632); text-decoration:underline; text-underline-offset:3px; }
|
||||
.store-password-actions button:focus-visible { outline:2px solid var(--vip-content-accent-dark,#856632); outline-offset:3px; }
|
||||
.store-password-actions button:disabled { opacity:.5; cursor:wait; }
|
||||
.store-password-dialog { width:min(30rem,calc(100vw - 2rem)); border:1px solid var(--vip-border,#d8d2c7); border-radius:12px; padding:1.25rem; color:var(--vip-content-ink,#171717); background:white; }
|
||||
.store-password-dialog::backdrop { background:rgb(0 0 0 / .45); }
|
||||
.store-password-dialog h2 { font-size:.875rem; font-weight:600; }
|
||||
.store-password-dialog p { font-size:.8125rem; color:var(--vip-content-muted,#595959); margin-top:.75rem; overflow-wrap:anywhere; }
|
||||
.store-password-dialog input { width:100%; margin:.75rem 0; padding:.625rem; border:1px solid var(--vip-border,#d8d2c7); border-radius:8px; font-family:monospace; }
|
||||
.store-password-dialog button { padding:.5rem 1rem; background:var(--vip-content-accent-dark,#856632); color:white; border-radius:8px; }
|
||||
.store-password-notice { font-size:.8125rem; margin-top:.75rem; color:var(--vip-content-muted,#595959); }
|
||||
.store-password-cell { display:inline-flex; align-items:center; gap:.55rem; max-width:100%; }
|
||||
.store-password-value { color:var(--vip-content-ink,#171717); font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:.8125rem; overflow-wrap:anywhere; user-select:all; }
|
||||
.store-password-state { color:var(--vip-content-muted,#595959); }
|
||||
.store-password-copy,
|
||||
.store-password-retry { color:var(--vip-content-accent-dark,#856632); font-size:.8125rem; text-decoration:underline; text-underline-offset:3px; white-space:nowrap; }
|
||||
.store-password-copy:focus-visible,
|
||||
.store-password-retry:focus-visible { outline:2px solid var(--vip-content-accent-dark,#856632); outline-offset:3px; }
|
||||
.store-password-copy:disabled,
|
||||
.store-password-retry:disabled { cursor:wait; opacity:.5; }
|
||||
.store-password-notice { display:flex; align-items:center; gap:.65rem; min-height:1.5rem; margin-top:.75rem; color:var(--vip-content-muted,#595959); font-size:.8125rem; }
|
||||
@media (max-width:640px) {
|
||||
.store-password-cell { align-items:flex-start; flex-direction:column; gap:.25rem; }
|
||||
}
|
||||
</style>
|
||||
<p class="store-password-notice" role="status" aria-live="polite" data-password-notice></p>
|
||||
<dialog class="store-password-dialog" data-password-dialog aria-labelledby="store-password-title" aria-describedby="store-password-help">
|
||||
<h2 id="store-password-title">店铺账号密码</h2>
|
||||
<p data-password-account></p>
|
||||
<label for="store-password-value" class="sr-only">密码</label>
|
||||
<input id="store-password-value" data-password-value type="text" readonly autocomplete="off" spellcheck="false">
|
||||
<p id="store-password-help">30 秒后自动隐藏,关闭弹窗或离开页面也会清除显示。</p>
|
||||
<form method="dialog"><button type="submit">关闭</button></form>
|
||||
</dialog>
|
||||
<div
|
||||
class="store-password-notice"
|
||||
data-password-page
|
||||
data-password-page-url="{{ route('business.store-account-passwords.reveal-visible-page') }}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span data-password-notice>正在读取当前页密码…</span>
|
||||
<button type="button" class="store-password-retry" data-password-retry hidden>重新读取</button>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const dialog = document.querySelector('[data-password-dialog]');
|
||||
const field = dialog.querySelector('[data-password-value]');
|
||||
const label = dialog.querySelector('[data-password-account]');
|
||||
const notice = document.querySelector('[data-password-notice]');
|
||||
const buttons = [...document.querySelectorAll('[data-store-password]')];
|
||||
const page = document.querySelector('[data-password-page]');
|
||||
const cells = [...document.querySelectorAll('[data-store-password-cell]')];
|
||||
if (!page || cells.length === 0) return;
|
||||
|
||||
const notice = page.querySelector('[data-password-notice]');
|
||||
const retry = page.querySelector('[data-password-retry]');
|
||||
let activeRequest = null;
|
||||
let hideTimer = null;
|
||||
let sequence = 0;
|
||||
const clear = () => {
|
||||
|
||||
const setCell = (cell, text, copyable = false) => {
|
||||
cell.querySelector('[data-store-password-value]').textContent = text;
|
||||
cell.querySelector('[data-store-password-copy]').hidden = !copyable;
|
||||
};
|
||||
|
||||
const clearPasswords = () => {
|
||||
sequence++;
|
||||
activeRequest?.abort();
|
||||
activeRequest = null;
|
||||
clearTimeout(hideTimer);
|
||||
field.value = '';
|
||||
label.textContent = '';
|
||||
if (dialog.open) dialog.close();
|
||||
buttons.forEach(button => button.disabled = false);
|
||||
cells.forEach(cell => setCell(cell, '', false));
|
||||
notice.textContent = '';
|
||||
retry.hidden = true;
|
||||
page.removeAttribute('aria-busy');
|
||||
};
|
||||
dialog.addEventListener('close', clear);
|
||||
window.addEventListener('pagehide', clear);
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) clear(); });
|
||||
buttons.forEach(button => button.addEventListener('click', async () => {
|
||||
clear();
|
||||
const current = sequence;
|
||||
|
||||
const readJson = async response => {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const loadVisiblePage = async () => {
|
||||
const requestSequence = ++sequence;
|
||||
activeRequest?.abort();
|
||||
const controller = new AbortController();
|
||||
activeRequest = controller;
|
||||
buttons.forEach(item => item.disabled = true);
|
||||
notice.textContent = '正在读取密码…';
|
||||
const accountIds = cells.map(cell => cell.dataset.accountId);
|
||||
cells.forEach(cell => setCell(cell, '读取中…', false));
|
||||
notice.textContent = '正在读取当前页密码…';
|
||||
retry.hidden = true;
|
||||
retry.disabled = true;
|
||||
page.setAttribute('aria-busy', 'true');
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
|
||||
try {
|
||||
const response = await fetch(button.dataset.passwordUrl, {
|
||||
method:'POST', credentials:'same-origin', cache:'no-store', redirect:'error', signal:controller.signal,
|
||||
headers:{'Content-Type':'application/json','Accept':'application/json','X-CSRF-TOKEN':window.Laravel.csrfToken},
|
||||
body:JSON.stringify({purpose:button.dataset.storePassword}),
|
||||
const response = await fetch(page.dataset.passwordPageUrl, {
|
||||
method:'POST',
|
||||
credentials:'same-origin',
|
||||
cache:'no-store',
|
||||
redirect:'error',
|
||||
signal:controller.signal,
|
||||
headers:{
|
||||
'Content-Type':'application/json',
|
||||
'Accept':'application/json',
|
||||
'X-CSRF-TOKEN':window.Laravel.csrfToken,
|
||||
},
|
||||
body:JSON.stringify({accounts:accountIds}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (current !== sequence || document.hidden) return;
|
||||
if (!response.ok || typeof data.password !== 'string') {
|
||||
notice.textContent = data.message || '读取失败,请稍后重试。';
|
||||
return;
|
||||
const data = await readJson(response);
|
||||
if (requestSequence !== sequence || document.hidden) return;
|
||||
if (!response.ok || !Array.isArray(data.items)) {
|
||||
throw new Error(typeof data.message === 'string' ? data.message : '密码读取失败,请稍后重试。');
|
||||
}
|
||||
if (button.dataset.storePassword === 'copy') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.password);
|
||||
notice.textContent = '密码已复制。';
|
||||
} catch (_) {
|
||||
notice.textContent = '浏览器未允许复制,请使用“查看”后手动复制。';
|
||||
|
||||
const received = new Map();
|
||||
data.items.forEach(item => {
|
||||
if (item && typeof item.accountId === 'string' && !received.has(item.accountId)) {
|
||||
received.set(item.accountId, item);
|
||||
}
|
||||
} else {
|
||||
label.textContent = button.dataset.accountLabel || '';
|
||||
field.value = data.password;
|
||||
dialog.showModal();
|
||||
field.focus();
|
||||
notice.textContent = '';
|
||||
hideTimer = setTimeout(clear, 30000);
|
||||
}
|
||||
data.password = '';
|
||||
} catch (_) {
|
||||
if (current === sequence) notice.textContent = '读取未完成,请稍后重试;登录过期时请重新登录。';
|
||||
});
|
||||
let failed = 0;
|
||||
cells.forEach(cell => {
|
||||
const item = received.get(cell.dataset.accountId);
|
||||
if (item?.status === 200 && typeof item.password === 'string' && item.password !== '') {
|
||||
setCell(cell, item.password, true);
|
||||
item.password = '';
|
||||
return;
|
||||
}
|
||||
failed++;
|
||||
setCell(cell, item?.status === 404 ? '未设置' : item?.status === 429 ? '读取受限' : '读取失败', false);
|
||||
});
|
||||
notice.textContent = failed === 0 ? '当前页密码已加载。' : `当前页有 ${failed} 个密码未读取。`;
|
||||
retry.hidden = failed === 0;
|
||||
} catch (error) {
|
||||
if (requestSequence !== sequence) return;
|
||||
cells.forEach(cell => setCell(cell, '读取失败', false));
|
||||
notice.textContent = error?.message || '密码读取未完成,请稍后重试;登录过期时请重新登录。';
|
||||
retry.hidden = false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (current === sequence) {
|
||||
if (requestSequence === sequence) {
|
||||
activeRequest = null;
|
||||
buttons.forEach(item => item.disabled = false);
|
||||
retry.disabled = false;
|
||||
page.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
cells.forEach(cell => {
|
||||
cell.classList.add('store-password-cell');
|
||||
cell.querySelector('[data-store-password-copy]').classList.add('store-password-copy');
|
||||
cell.querySelector('[data-store-password-copy]').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
notice.textContent = '正在复制密码…';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
const response = await fetch(cell.dataset.copyUrl, {
|
||||
method:'POST', credentials:'same-origin', cache:'no-store', redirect:'error', signal:controller.signal,
|
||||
headers:{'Content-Type':'application/json','Accept':'application/json','X-CSRF-TOKEN':window.Laravel.csrfToken},
|
||||
body:JSON.stringify({purpose:'copy'}),
|
||||
});
|
||||
const data = await readJson(response);
|
||||
if (!response.ok || typeof data.password !== 'string' || data.password === '') {
|
||||
throw new Error(typeof data.message === 'string' ? data.message : '复制失败,请稍后重试。');
|
||||
}
|
||||
setCell(cell, data.password, true);
|
||||
await navigator.clipboard.writeText(data.password);
|
||||
data.password = '';
|
||||
notice.textContent = '密码已复制。';
|
||||
} catch (error) {
|
||||
notice.textContent = error?.message || '浏览器未允许复制,可直接选中当前显示的密码复制。';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
retry.addEventListener('click', loadVisiblePage);
|
||||
window.addEventListener('pagehide', clearPasswords);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) clearPasswords();
|
||||
else if (cells.every(cell => cell.isConnected)) loadVisiblePage();
|
||||
});
|
||||
loadVisiblePage();
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
|
||||
@ -11,6 +11,8 @@ 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/visible-page/reveal', 'Business\StoreAccountPasswords@revealVisiblePage')
|
||||
->name('business.store-account-passwords.reveal-visible-page');
|
||||
Route::post('store-account-passwords/{account}/reveal', 'Business\StoreAccountPasswords@reveal')
|
||||
->name('business.store-account-passwords.reveal');
|
||||
|
||||
|
||||
@ -23,8 +23,14 @@ class StoreAccountPasswordsTest extends FeatureTestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['store_account_password.token' => 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();
|
||||
config([
|
||||
'store_account_password.token' => str_repeat('t', 43),
|
||||
'store_account_password.url' => 'https://upstream.example.test/internal/store-accounts/{id}/password/reveal',
|
||||
'store_account_password.visible_page_url' => 'https://upstream.example.test/internal/store-accounts/visible-page/password/reveal',
|
||||
]);
|
||||
$mirror = $this->mock(StoreAccountMirrorRepository::class);
|
||||
$mirror->shouldReceive('containsAccount')->with('fixture-account')->andReturnTrue()->byDefault();
|
||||
$mirror->shouldReceive('containsAccounts')->andReturnTrue()->byDefault();
|
||||
}
|
||||
|
||||
private function transport(int $status = 200, ?array $body = null, array $headers = ['Cache-Control' => 'no-store']): void
|
||||
@ -41,6 +47,21 @@ class StoreAccountPasswordsTest extends FeatureTestCase
|
||||
return route('business.store-account-passwords.reveal', ['account' => 'fixture-account']);
|
||||
}
|
||||
|
||||
private function visiblePageEndpoint(): string
|
||||
{
|
||||
return route('business.store-account-passwords.reveal-visible-page');
|
||||
}
|
||||
|
||||
private function visiblePageTransport(array $items, array $headers = ['Cache-Control' => 'private, no-store']): void
|
||||
{
|
||||
$handler = HandlerStack::create(new MockHandler([new Response(200, $headers, json_encode([
|
||||
'requestId' => '12345678-1234-4123-8123-123456789012',
|
||||
'items' => $items,
|
||||
]))]));
|
||||
$handler->push(Middleware::history($this->history));
|
||||
$this->app->instance(StoreAccountPasswordClient::class, new StoreAccountPasswordClient(new Client(['handler' => $handler])));
|
||||
}
|
||||
|
||||
public function test_admin_view_uses_trusted_actor_and_persists_password_free_audit(): void
|
||||
{
|
||||
$this->transport();
|
||||
@ -67,12 +88,13 @@ class StoreAccountPasswordsTest extends FeatureTestCase
|
||||
$this->assertDatabaseHas('store_account_password_audits', ['purpose' => 'copy', 'status' => 200]);
|
||||
}
|
||||
|
||||
public function test_regular_user_cannot_call_upstream(): void
|
||||
public function test_regular_user_with_store_accounts_page_can_reveal_and_copy(): 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();
|
||||
$this->transport();
|
||||
$this->loginAs()->postJson($this->endpoint(), ['purpose' => 'copy'])->assertOk()->assertJsonPath('password', $this->fixture);
|
||||
$this->assertDatabaseHas('store_account_password_audits', ['purpose' => 'copy', 'status' => 200]);
|
||||
}
|
||||
|
||||
public function test_restricted_admin_cannot_bypass_store_page_scope(): void
|
||||
@ -83,6 +105,68 @@ class StoreAccountPasswordsTest extends FeatureTestCase
|
||||
$this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_regular_user_without_store_accounts_page_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);
|
||||
setting()->set('system.sidebar_permissions', [
|
||||
'configured' => ['user_'.$this->user->id],
|
||||
'subjects' => ['user_'.$this->user->id => ['store-management/basic-information']],
|
||||
]);
|
||||
$client = $this->mock(StoreAccountPasswordClient::class);
|
||||
$client->shouldNotReceive('reveal');
|
||||
$client->shouldNotReceive('revealVisiblePage');
|
||||
$this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertForbidden();
|
||||
$this->postJson($this->visiblePageEndpoint(), ['accounts' => ['fixture-account']])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_visible_page_loads_passwords_for_regular_user_and_audits_every_account(): 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->visiblePageTransport([
|
||||
['accountId' => 'fixture-account', 'status' => 200, 'requestId' => '12345678-1234-4123-8123-123456789013', 'password' => $this->fixture],
|
||||
['accountId' => 'fixture-empty', 'status' => 404, 'requestId' => '12345678-1234-4123-8123-123456789014'],
|
||||
]);
|
||||
|
||||
$response = $this->loginAs()->postJson($this->visiblePageEndpoint(), [
|
||||
'accounts' => ['fixture-account', 'fixture-empty'],
|
||||
])->assertOk()->assertJsonCount(2, 'items')
|
||||
->assertJsonPath('items.0.password', $this->fixture)
|
||||
->assertJsonPath('items.1.status', 404)
|
||||
->assertJsonMissingPath('items.1.password');
|
||||
|
||||
$this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
|
||||
$this->assertSame(2, DB::table('store_account_password_audits')->count());
|
||||
$this->assertEqualsCanonicalizing([200, 404], DB::table('store_account_password_audits')->pluck('status')->all());
|
||||
$this->assertStringNotContainsString($this->fixture, json_encode(DB::table('store_account_password_audits')->get()));
|
||||
$sent = json_decode((string) $this->history[0]['request']->getBody(), true);
|
||||
$this->assertSame([
|
||||
'requesterId' => 'finance-user-'.$this->user->id,
|
||||
'purpose' => 'view',
|
||||
'accountIds' => ['fixture-account', 'fixture-empty'],
|
||||
], $sent);
|
||||
}
|
||||
|
||||
public function test_visible_page_rejects_duplicates_and_more_than_fifty_accounts(): void
|
||||
{
|
||||
$this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('revealVisiblePage');
|
||||
$this->loginAs()->postJson($this->visiblePageEndpoint(), ['accounts' => ['fixture-account', 'fixture-account']])->assertUnprocessable();
|
||||
$this->postJson($this->visiblePageEndpoint(), ['accounts' => array_map(fn ($index) => 'account-'.$index, range(1, 51))])->assertUnprocessable();
|
||||
$this->assertSame(0, DB::table('store_account_password_audits')->count());
|
||||
}
|
||||
|
||||
public function test_visible_page_rejects_malformed_or_cacheable_upstream_response(): void
|
||||
{
|
||||
$this->visiblePageTransport([
|
||||
['accountId' => 'fixture-account', 'status' => 200, 'requestId' => 'bad-request-id', 'password' => $this->fixture],
|
||||
], []);
|
||||
$response = $this->loginAs()->postJson($this->visiblePageEndpoint(), ['accounts' => ['fixture-account']])->assertStatus(503);
|
||||
$response->assertJsonMissingPath('items')->assertJsonMissingPath('password');
|
||||
$this->assertStringNotContainsString($this->fixture, $response->getContent());
|
||||
$this->assertDatabaseHas('store_account_password_audits', ['store_account_id' => 'fixture-account', 'status' => 503]);
|
||||
}
|
||||
|
||||
public function test_actor_spoofing_is_rejected(): void
|
||||
{
|
||||
$this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal');
|
||||
@ -150,17 +234,22 @@ class StoreAccountPasswordsTest extends FeatureTestCase
|
||||
$this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_admin_page_has_explicit_actions_and_no_plaintext_in_table(): void
|
||||
public function test_store_accounts_page_auto_loads_visible_password_cells_without_a_dialog(): 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)
|
||||
);
|
||||
$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);
|
||||
$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, '</table>'), strpos($html, '<dialog class="store-password-dialog"'));
|
||||
$response->assertSee('data-store-password-cell', false)
|
||||
->assertSee('data-password-page-url', false)
|
||||
->assertSee('data-store-password-copy', false)
|
||||
->assertSee('读取中…')
|
||||
->assertDontSee('data-password-dialog', false)
|
||||
->assertDontSee('data-store-password="view"', false)
|
||||
->assertDontSee($this->fixture);
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,12 +61,17 @@ class UserAccessGroupsTest extends FeatureTestCase
|
||||
$this->withExceptionHandling()->patchJson(route('system-settings.sidebar.update'), ['subject' => 'group_'.$group->id, 'permissions' => ['not-a-page']])->assertUnprocessable();
|
||||
}
|
||||
|
||||
public function test_group_page_access_does_not_grant_store_password_access(): void
|
||||
public function test_group_page_access_grants_store_password_access_without_an_admin_role(): void
|
||||
{
|
||||
$member = $this->member();
|
||||
$this->group('店铺资料组', $member, ['store-management']);
|
||||
$this->mock(\App\Services\StoreAccountPasswordClient::class)->shouldNotReceive('reveal');
|
||||
$this->withExceptionHandling()->loginAs($member)->postJson(route('business.store-account-passwords.reveal', ['account' => 'fixture-account']), ['purpose' => 'view'])->assertForbidden();
|
||||
$this->mock(\Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository::class)
|
||||
->shouldReceive('containsAccount')->with('fixture-account')->once()->andReturnTrue();
|
||||
$this->mock(\App\Services\StoreAccountPasswordClient::class)
|
||||
->shouldReceive('reveal')->with('fixture-account', $member->id, 'view')->once()
|
||||
->andReturn(['status' => 200, 'password' => 'fictional-only', 'upstream_request_id' => '12345678-1234-4123-8123-123456789012']);
|
||||
$this->loginAs($member)->postJson(route('business.store-account-passwords.reveal', ['account' => 'fixture-account']), ['purpose' => 'view'])
|
||||
->assertOk()->assertJsonPath('password', 'fictional-only');
|
||||
}
|
||||
|
||||
public function test_group_membership_and_names_can_change_without_changing_permissions(): void
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user