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 { $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_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, ''), strpos($html, '