管理员按需查看与复制店铺密码 (#116)

This commit is contained in:
WuYongJie 2026-09-08 07:02:25 +08:00
parent cc355b32ce
commit b2b5c33d47
12 changed files with 516 additions and 6 deletions

View File

@ -0,0 +1,84 @@
<?php
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;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Throwable;
class StoreAccountPasswords extends Controller
{
public function reveal(string $account, Request $request, StoreAccountMirrorRepository $accounts, StoreAccountPasswordClient $client)
{
if (app()->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']);
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use Throwable;
class StoreAccountPasswordClient
{
private ClientInterface $http;
public function __construct(?ClientInterface $http = null)
{
// Dedicated transport: do not dispatch Laravel HTTP logging events or
// attach the application's general request/response collectors.
$this->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];
}
}
}

View File

@ -243,7 +243,7 @@ return [
'directory' => [
'icon' => 'key',
'status' => '共享镜像',
'description' => '读取店铺账号系统的共享业务镜像;密码不返回明文,仅展示已允许读取的账号资料。',
'description' => '账号资料来自共享镜像。管理员可按需查看或复制密码,密码不会随表格导出。',
'empty' => '共享镜像中暂未读取到店铺账号数据,请检查账号数据同步状态。',
'fields' => [
['name' => 'store_name', 'label' => '店铺名称'],

View File

@ -0,0 +1,6 @@
<?php
return [
'url' => env('STORE_ACCOUNT_REVEAL_URL', 'https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal'),
'token' => env('STORE_ACCOUNT_REVEAL_TOKEN'),
];

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('store_account_password_audits', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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 窄屏弹窗。未调用上游真实店铺密码接口,未执行财务生产迁移或发布。

View File

@ -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)为键。
*

View File

@ -492,7 +492,19 @@
$display = in_array($value, [true, 1, '1', '是', '✔'], true) ? '是' : '否';
}
@endphp
<td data-column-key="{{ $field['name'] }}" class="{{ $kind === 'number' ? 'is-number' : '' }}">{{ filled($display) ? $display : '—' }}</td>
<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>
@endif
@else
{{ filled($display) ? $display : '—' }}
@endif
</td>
@endforeach
</tr>
@empty
@ -542,6 +554,10 @@
@endif
</section>
@if (($page['slug'] ?? '') === 'store-accounts' && \App\Utilities\SystemAccess::canOpenSettings())
@include('business.pages.partials.store-account-password')
@endif
@once
<script>
(() => {
@ -583,7 +599,7 @@
});
tools.querySelector('[data-directory-export]')?.addEventListener('click', () => {
const rows = [...table.querySelectorAll('tr')].filter((row) => !row.hidden);
const csv = rows.map((row) => [...row.children].map((cell) => csvEscape(cell.textContent.trim())).join(',')).join('\n');
const csv = rows.map((row) => [...row.children].map((cell) => csvEscape((cell.querySelector('[data-password-status]') || cell).textContent.trim())).join(',')).join('\n');
const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);

View File

@ -0,0 +1,95 @@
@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); }
</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>
<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]')];
let activeRequest = null;
let hideTimer = null;
let sequence = 0;
const clear = () => {
sequence++;
activeRequest?.abort();
activeRequest = null;
clearTimeout(hideTimer);
field.value = '';
label.textContent = '';
if (dialog.open) dialog.close();
buttons.forEach(button => button.disabled = false);
};
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 controller = new AbortController();
activeRequest = controller;
buttons.forEach(item => item.disabled = true);
notice.textContent = '正在读取密码…';
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 data = await response.json();
if (current !== sequence || document.hidden) return;
if (!response.ok || typeof data.password !== 'string') {
notice.textContent = data.message || '读取失败,请稍后重试。';
return;
}
if (button.dataset.storePassword === 'copy') {
try {
await navigator.clipboard.writeText(data.password);
notice.textContent = '密码已复制。';
} catch (_) {
notice.textContent = '浏览器未允许复制,请使用“查看”后手动复制。';
}
} 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 = '读取未完成,请稍后重试;登录过期时请重新登录。';
} finally {
clearTimeout(timeout);
if (current === sequence) {
activeRequest = null;
buttons.forEach(item => item.disabled = false);
}
}
}));
})();
</script>
@endonce

View File

@ -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'])

View File

@ -0,0 +1,165 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Role;
use App\Services\StoreAccountPasswordClient;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\Feature\FeatureTestCase;
class StoreAccountPasswordsTest extends FeatureTestCase
{
private array $history = [];
private string $fixture = 'fictional-password-only-9!';
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();
}
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, '</table>'), strpos($html, '<dialog class="store-password-dialog"'));
}
}