CaiWuXiTong/tests/Feature/Business/StoreAccountPasswordsTest.php

167 lines
8.2 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']);
$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, '</table>'), strpos($html, '<dialog class="store-password-dialog"'));
}
}