CaiWuXiTong/tests/Feature/Business/StoreAccountPasswordsTest.php
renshen d69a7d2ca7 店铺账号密码:把流程类型和审批状态两列都拉过来
上一版只带了「流程状态」(关店流程的审批状态)。改成按 ERP 店铺名取该店铺
最近一单申请流程(开店/变更/关店/主体注销自动),同时给出流程类型与审批状态,
列位置仍在「店铺状态」后面;两列都可搜索多选筛选、可排序。

线上:966 个账号店铺里 396 家最近一单是关店流程;状态文案与流程统计页一致。
状态映射缓存从 10 分钟放宽到 30 分钟(全量算一次要几秒,数据变化很慢)。
2026-10-08 15:12:41 +08:00

333 lines
18 KiB
PHP

<?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',
'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
{
$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']);
}
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();
$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_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->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
{
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
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_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_fifty_visible_accounts_are_sent_as_two_gateway_safe_chunks(): void
{
$accountIds = array_map(fn ($index) => sprintf('fixture-account-%02d', $index), range(1, 50));
$responses = [];
foreach (array_chunk($accountIds, 25) as $batchIndex => $batch) {
$items = [];
foreach ($batch as $index => $accountId) {
$items[] = [
'accountId' => $accountId,
'status' => 200,
'requestId' => sprintf('12345678-1234-4123-8123-%012d', ($batchIndex * 25) + $index + 1),
'password' => $this->fixture,
];
}
$responses[] = new Response(200, ['Cache-Control' => 'private, no-store'], json_encode([
'requestId' => sprintf('22345678-1234-4123-8123-%012d', $batchIndex + 1),
'items' => $items,
]));
}
$handler = HandlerStack::create(new MockHandler($responses));
$handler->push(Middleware::history($this->history));
$this->app->instance(StoreAccountPasswordClient::class, new StoreAccountPasswordClient(new Client(['handler' => $handler])));
$response = $this->loginAs()->postJson($this->visiblePageEndpoint(), ['accounts' => $accountIds])
->assertOk()->assertJsonCount(50, 'items');
$this->assertCount(2, $this->history);
foreach ($this->history as $index => $transaction) {
$body = (string) $transaction['request']->getBody();
$sent = json_decode($body, true);
$this->assertSame(array_slice($accountIds, $index * 25, 25), $sent['accountIds']);
$this->assertLessThanOrEqual(1024, strlen($body));
}
$this->assertSame(50, DB::table('store_account_password_audits')->count());
$this->assertSame(50, DB::table('store_account_password_audits')->where('status', 200)->count());
$this->assertStringNotContainsString($this->fixture, json_encode(DB::table('store_account_password_audits')->get()));
$this->assertSame($this->fixture, $response->json('items.49.password'));
}
public function test_later_chunk_failure_suppresses_passwords_from_earlier_chunks(): void
{
$accountIds = array_map(fn ($index) => sprintf('fixture-account-%02d', $index), range(1, 50));
$items = array_map(fn ($accountId, $index) => [
'accountId' => $accountId,
'status' => 200,
'requestId' => sprintf('12345678-1234-4123-8123-%012d', $index + 1),
'password' => $this->fixture,
], array_slice($accountIds, 0, 25), range(0, 24));
$handler = HandlerStack::create(new MockHandler([
new Response(200, ['Cache-Control' => 'private, no-store'], json_encode([
'requestId' => '22345678-1234-4123-8123-123456789001',
'items' => $items,
])),
new Response(403, ['Cache-Control' => 'no-store'], json_encode(['message' => $this->fixture])),
]));
$handler->push(Middleware::history($this->history));
$this->app->instance(StoreAccountPasswordClient::class, new StoreAccountPasswordClient(new Client(['handler' => $handler])));
$response = $this->loginAs()->postJson($this->visiblePageEndpoint(), ['accounts' => $accountIds])
->assertStatus(503)->assertJsonMissingPath('items')->assertJsonMissingPath('password');
$this->assertCount(2, $this->history);
$this->assertStringNotContainsString($this->fixture, $response->getContent());
$this->assertSame(50, DB::table('store_account_password_audits')->where('status', 503)->count());
}
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');
$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_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-cell', false)
->assertSee('data-password-page-url', false)
->assertSee('data-store-password-copy', false)
->assertSee('读取中…')
// 账号分组名称后面新增的两列。
->assertSeeText('授权状态')
->assertSeeText('店铺状态')
->assertSeeText('流程类型')
->assertSeeText('审批状态')
// 授权状态 / 店铺状态按可搜索多选渲染(提交为 xxx[])。
->assertSee('data-directory-multi-select="authorization_status"', false)
->assertSee('data-directory-multi-select="store_status"', false)
->assertSee('data-directory-multi-select="flow_type"', false)
->assertSee('data-directory-multi-select="approval_status"', false)
->assertSee('CaiWuMultiSelect', false)
->assertDontSee('data-password-dialog', false)
->assertDontSee('data-store-password="view"', false)
->assertDontSee($this->fixture);
}
}