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