'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, ]); } }