167 lines
8.2 KiB
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"'));
|
|
}
|
|
}
|