CaiWuXiTong/tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php

400 lines
18 KiB
PHP

<?php
namespace Tests\Feature\Business;
use App\Jobs\Business\ArchiveCourierQuoteSignature;
use App\Models\Auth\Permission;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\Feature\FeatureTestCase;
class CourierQuoteSignatureWorkflowTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
Storage::fake('uploads');
config([
'services.esign.environment' => 'sandbox',
'services.esign.base_url' => 'https://smlopenapi.esign.test',
'services.esign.app_id' => 'sandbox-app-id',
'services.esign.app_secret' => 'sandbox-app-secret',
'services.esign.notify_url' => null,
]);
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
public function test_approved_version_generates_an_immutable_private_pdf(): void
{
$version = $this->createVersion();
$signature = app(CourierQuoteSignatureService::class)->prepareVersion($version, $this->user->id);
Storage::disk('uploads')->assertExists($signature->source_pdf_path);
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
$this->assertStringStartsWith('%PDF-', $bytes);
$this->assertSame(hash('sha256', $bytes), $signature->source_pdf_sha256);
$this->assertSame(CourierQuoteSignature::STATUS_PREPARED, $signature->status);
$this->assertSame('待推送', $version->fresh()->signature_status);
$again = app(CourierQuoteSignatureService::class)->prepareVersion($version->fresh(), $this->user->id);
$this->assertSame($signature->id, $again->id);
$this->assertSame($signature->source_pdf_sha256, $again->source_pdf_sha256);
}
public function test_admin_can_push_the_exact_pdf_to_esign_sandbox(): void
{
$signature = $this->createPreparedSignature();
Http::fake(function (ClientRequest $request) {
return match (true) {
$request->url() === 'https://smlopenapi.esign.test/v3/files/file-upload-url' => Http::response([
'code' => 0,
'data' => ['fileId' => 'file-001', 'fileUploadUrl' => 'https://upload.esign.test/file-001'],
]),
$request->url() === 'https://upload.esign.test/file-001' => Http::response(['errCode' => 0]),
$request->url() === 'https://smlopenapi.esign.test/v3/files/file-001' => Http::response([
'code' => 0,
'data' => ['status' => 2],
]),
$request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' => Http::response([
'code' => 0,
'data' => ['signFlowId' => 'flow-001'],
]),
default => Http::response(['code' => 404, 'message' => 'unexpected request'], 404),
};
});
$this->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])->assertRedirect(route('business.courier-quote-changes.show', $signature->version->source_change_id));
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->status);
$this->assertSame('file-001', $signature->esign_file_id);
$this->assertSame('flow-001', $signature->esign_sign_flow_id);
$this->assertSame('待签署', $signature->version->fresh()->signature_status);
Http::assertSent(function (ClientRequest $request) use ($signature): bool {
if ($request->url() !== 'https://smlopenapi.esign.test/v3/files/file-upload-url') {
return false;
}
$payload = json_decode($request->body(), true);
$pdfBytes = Storage::disk('uploads')->get($signature->source_pdf_path);
return $request->hasHeader('X-Tsign-Open-Ca-Signature')
&& $payload['contentMd5'] === base64_encode(md5($pdfBytes, true))
&& $payload['fileName'] === 'quote-QB-900.pdf';
});
Http::assertSent(function (ClientRequest $request): bool {
if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') {
return false;
}
$payload = json_decode($request->body(), true);
return data_get($payload, 'signers.0.orgSignerInfo.orgName') === '新北中通快递有限公司'
&& data_get($payload, 'signers.0.normalSignFieldConfig') === null
&& data_get($payload, 'signers.0.signFields.0.normalSignFieldConfig.freeMode') === true;
});
}
public function test_tampered_source_pdf_is_not_sent(): void
{
$signature = $this->createPreparedSignature();
Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-tampered');
Http::fake();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])->assertSessionHasErrors('signature');
Http::assertNothingSent();
$this->assertNull($signature->fresh()->esign_sign_flow_id);
}
public function test_source_pdf_cannot_be_regenerated_after_signing_has_started(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => 'file-immutable',
'esign_sign_flow_id' => 'flow-immutable',
'remote_status' => '1',
'sent_at' => now(),
]);
Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-tampered-after-send');
try {
app(CourierQuoteSignatureService::class)->prepareVersion($signature->version, $this->user->id);
$this->fail('A sent quote must not regenerate its immutable source PDF.');
} catch (\Illuminate\Validation\ValidationException $exception) {
$this->assertSame(
'该报价已经进入签署流程,签署原文不可重新生成。',
$exception->errors()['signature'][0],
);
}
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->status);
$this->assertSame('flow-immutable', $signature->esign_sign_flow_id);
}
public function test_non_admin_cannot_push_a_quote_to_esign(): void
{
$signature = $this->createPreparedSignature();
$operator = user_model_class()::create([
'name' => 'Quote Operator',
'email' => 'quote-operator-' . uniqid('', true) . '@example.com',
'password' => 'password',
'locale' => 'zh-CN',
'enabled' => 1,
'landing_page' => 'dashboard',
'created_from' => 'core::test',
]);
$operator->companies()->attach($this->company->id);
$operator->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
Http::fake();
$this->withExceptionHandling()->loginAs($operator, $this->company)
->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])
->assertForbidden();
Http::assertNothingSent();
$this->assertSame(CourierQuoteSignature::STATUS_PREPARED, $signature->fresh()->status);
}
public function test_manual_sync_downloads_signed_pdf_and_completion_certificate(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => 'file-001',
'esign_sign_flow_id' => 'flow-001',
'remote_status' => '1',
'sent_at' => now(),
]);
Http::fake([
'https://smlopenapi.esign.test/v3/sign-flow/flow-001/detail' => Http::response([
'code' => 0,
'data' => ['signFlowStatus' => 2],
]),
'https://smlopenapi.esign.test/v3/sign-flow/flow-001/file-download-url' => Http::response([
'code' => 0,
'data' => [
'files' => [['fileId' => 'file-001', 'fileName' => 'signed.pdf', 'downloadUrl' => 'https://download.esign.test/signed']],
'certificateDownloadUrl' => 'https://download.esign.test/certificate',
],
]),
'https://download.esign.test/signed' => Http::response('%PDF-signed-file'),
'https://download.esign.test/certificate' => Http::response('%PDF-completion-certificate'),
]);
$this->loginAs()->post(route('business.courier-quote-signatures.synchronize', $signature))
->assertRedirect(route('business.courier-quote-changes.show', $signature->version->source_change_id));
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_ARCHIVED, $signature->status);
$this->assertSame('已签署并归档', $signature->version->fresh()->signature_status);
Storage::disk('uploads')->assertExists($signature->signed_pdf_path);
Storage::disk('uploads')->assertExists($signature->certificate_path);
$this->assertSame(hash('sha256', '%PDF-signed-file'), $signature->signed_pdf_sha256);
$this->assertSame(hash('sha256', '%PDF-completion-certificate'), $signature->certificate_sha256);
}
public function test_archive_job_propagates_failures_so_the_queue_can_retry(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_COMPLETED,
'esign_file_id' => 'file-retry',
'esign_sign_flow_id' => 'flow-retry',
'remote_status' => '2',
'completed_at' => now(),
]);
Http::fake([
'https://smlopenapi.esign.test/v3/sign-flow/flow-retry/detail' => Http::response([], 503),
]);
try {
(new ArchiveCourierQuoteSignature($signature->id))->handle(app(CourierQuoteSignatureService::class));
$this->fail('The archive job must fail so the queue can retry it.');
} catch (\Illuminate\Validation\ValidationException $exception) {
$this->assertStringContainsString('HTTP 503', $exception->errors()['signature'][0]);
}
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_COMPLETED, $signature->status);
$this->assertStringContainsString('HTTP 503', $signature->last_error);
$this->assertNull($signature->archived_at);
}
public function test_verified_completion_callback_is_idempotent_and_queues_archiving(): void
{
Queue::fake();
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_sign_flow_id' => 'flow-callback',
'remote_status' => '1',
'sent_at' => now(),
]);
$payload = json_encode([
'action' => 'SIGN_FLOW_COMPLETE',
'signFlowId' => 'flow-callback',
'signFlowStatus' => '2',
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$timestamp = (string) now()->getTimestampMs();
$callbackSignature = hash_hmac('sha256', $timestamp . '12' . $payload, 'sandbox-app-secret');
$server = [
'HTTP_X_TSIGN_OPEN_TIMESTAMP' => $timestamp,
'HTTP_X_TSIGN_OPEN_SIGNATURE_ALGORITHM' => 'HmacSHA256',
'HTTP_X_TSIGN_OPEN_APP_ID' => 'sandbox-app-id',
'HTTP_X_TSIGN_OPEN_SIGNATURE' => $callbackSignature,
'CONTENT_TYPE' => 'application/json',
];
$this->call(
'POST',
route('integrations.esign.callback') . '?zeta=2&alpha=1',
[],
[],
[],
$server,
$payload,
)->assertOk()->assertExactJson(['code' => '200', 'msg' => 'success']);
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_COMPLETED, $signature->status);
$this->assertSame(hash('sha256', $payload), $signature->callback_payload_hash);
Queue::assertPushed(ArchiveCourierQuoteSignature::class, fn ($job): bool => $job->signatureId === $signature->id);
$this->assertNull(app(CourierQuoteSignatureService::class)->acceptCallback(
'flow-callback',
'2',
hash('sha256', $payload),
));
Queue::assertPushed(ArchiveCourierQuoteSignature::class, 1);
}
public function test_callback_with_wrong_signature_is_rejected_without_state_change(): void
{
Queue::fake();
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_sign_flow_id' => 'flow-invalid',
'remote_status' => '1',
]);
$payload = json_encode([
'action' => 'SIGN_FLOW_COMPLETE',
'signFlowId' => 'flow-invalid',
'signFlowStatus' => '2',
], JSON_THROW_ON_ERROR);
$this->call('POST', route('integrations.esign.callback'), [], [], [], [
'HTTP_X_TSIGN_OPEN_TIMESTAMP' => (string) now()->getTimestampMs(),
'HTTP_X_TSIGN_OPEN_SIGNATURE_ALGORITHM' => 'HmacSHA256',
'HTTP_X_TSIGN_OPEN_APP_ID' => 'sandbox-app-id',
'HTTP_X_TSIGN_OPEN_SIGNATURE' => str_repeat('0', 64),
'CONTENT_TYPE' => 'application/json',
], $payload)
->assertUnauthorized();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->fresh()->status);
Queue::assertNothingPushed();
}
private function createPreparedSignature(array $overrides = []): CourierQuoteSignature
{
$version = $this->createVersion();
$path = "courier-quotes/{$this->company->id}/QB-900/source.pdf";
$bytes = '%PDF-source-quote';
Storage::disk('uploads')->put($path, $bytes);
return CourierQuoteSignature::create(array_merge([
'company_id' => $this->company->id,
'quote_version_id' => $version->id,
'provider' => 'esign',
'environment' => 'sandbox',
'status' => CourierQuoteSignature::STATUS_PREPARED,
'organization_name' => '新北中通',
'source_pdf_path' => $path,
'source_pdf_sha256' => hash('sha256', $bytes),
'source_pdf_generated_at' => now(),
'created_by' => $this->user->id,
'updated_by' => $this->user->id,
], $overrides));
}
private function createVersion(): CourierQuoteVersion
{
$change = CourierQuoteChange::create([
'company_id' => $this->company->id,
'number' => 'BG-' . now()->format('Ymd') . '-' . str_pad((string) (CourierQuoteChange::count() + 1), 3, '0', STR_PAD_LEFT),
'source_quote_id' => 'QB-001',
'source_sheet_name' => '新北中通报价',
'carrier_name' => '新北中通',
'quote_type' => '标准报价',
'change_type' => '面单调整',
'status' => CourierQuoteChange::STATUS_APPROVED,
'effective_date' => now()->addDay()->toDateString(),
'is_retrospective' => false,
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => '广东省面单价格调整。',
'created_by' => $this->user->id,
'submitted_by' => $this->user->id,
'submitted_at' => now(),
'approved_by' => $this->user->id,
'approved_at' => now(),
]);
$change->items()->create([
'company_id' => $this->company->id,
'position' => 0,
'source_cell' => 'B2',
'source_row' => 1,
'source_column' => 1,
'region' => '广东省',
'weight_band' => '面单',
'charge_item' => '面单',
'original_amount' => '5.0000',
'new_amount' => '6.0000',
'unit' => '元/单',
'note' => '快递通知调价',
]);
return CourierQuoteVersion::create([
'company_id' => $this->company->id,
'version_code' => 'QB-900',
'source_quote_id' => 'QB-001',
'source_change_id' => $change->id,
'sheet_name' => '新北中通报价',
'company_name' => '新北中通',
'carrier_name' => '新北中通',
'quote_type' => '标准报价',
'effective_date' => now()->addDay()->toDateString(),
'source_range' => 'A1:C2',
'values_json' => json_encode([['省份', '面单', '续重'], ['广东省', 6.0, 1.2]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode([[], []], JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '未签署',
'created_by' => $this->user->id,
'approved_by' => $this->user->id,
]);
}
}