上一版只带了「流程状态」(关店流程的审批状态)。改成按 ERP 店铺名取该店铺 最近一单申请流程(开店/变更/关店/主体注销自动),同时给出流程类型与审批状态, 列位置仍在「店铺状态」后面;两列都可搜索多选筛选、可排序。 线上:966 个账号店铺里 396 家最近一单是关店流程;状态文案与流程统计页一致。 状态映射缓存从 10 分钟放宽到 30 分钟(全量算一次要几秒,数据变化很慢)。
333 lines
18 KiB
PHP
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);
|
|
}
|
|
}
|