diff --git a/modules/tax/commerce_tax.routing.yml b/modules/tax/commerce_tax.routing.yml new file mode 100644 index 00000000..0af4299d --- /dev/null +++ b/modules/tax/commerce_tax.routing.yml @@ -0,0 +1,7 @@ +commerce_tax.verification_result: + path: '/commerce_tax/verification-result/{tax_number}/{context}' + defaults: + _controller: '\Drupal\commerce_tax\Controller\TaxNumberController::result' + _title: 'Verification result' + requirements: + _access: 'TRUE' diff --git a/modules/tax/src/Controller/TaxNumberController.php b/modules/tax/src/Controller/TaxNumberController.php new file mode 100644 index 00000000..1a0562a2 --- /dev/null +++ b/modules/tax/src/Controller/TaxNumberController.php @@ -0,0 +1,159 @@ +entityTypeManager = $entity_type_manager; + $this->dateFormatter = $date_formatter; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('date.formatter') + ); + } + + /** + * Displays the verification result for the given tax number. + * + * @param string $tax_number + * The tax number. + * @param string $context + * The encoded context. + * + * @return array + * A renderable array. + * + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * If the context is invalid or the user doesn't have access to update + * the parent entity. + */ + public function result($tax_number, $context) { + $context = $this->prepareContext($context); + if (!$context) { + throw new AccessDeniedHttpException(); + } + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $context['entity']; + if (!$entity->access('update')) { + throw new AccessDeniedHttpException(); + } + /** @var \Drupal\commerce_tax\Plugin\Field\FieldType\TaxNumberItemInterface $field */ + $field = $context['field']; + if ($field->value != $tax_number) { + throw new AccessDeniedHttpException(); + } + + $result = []; + $type_plugin = $field->getTypePlugin(); + if ($type_plugin instanceof SupportsVerificationInterface) { + $verification_result = new VerificationResult( + $field->verification_state, + $field->verification_timestamp, + $field->verification_result + ); + $result = $type_plugin->renderVerificationResult($verification_result); + // @todo Move this to a Twig template, to allow it to be customized. + if ($field->verification_timestamp) { + $result['timestamp'] = [ + '#type' => 'item', + '#title' => $this->t('Timestamp'), + '#plain_text' => $this->dateFormatter->format($field->verification_timestamp, 'long'), + '#weight' => -10, + ]; + } + } + + return $result; + } + + /** + * Parses and validates the context. + * + * @param string $context + * The context string. + * + * @return array|false + * The prepared context, or FALSE if validation failed. + */ + protected function prepareContext($context) { + $context = UrlData::decode($context); + $context = $context ?: []; + if (!count($context) == 4) { + return FALSE; + } + // Assign keys. The context array is numerically indexed to save space. + $keys = ['entity_type', 'entity_id', 'field_name', 'view_mode']; + $context = array_combine($keys, $context); + foreach ($keys as $key) { + if (empty($context[$key])) { + // Missing required data. + return FALSE; + } + } + // Validate the provided values. + try { + $storage = $this->entityTypeManager->getStorage($context['entity_type']); + } + catch (PluginNotFoundException $e) { + return FALSE; + } + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $storage->load($context['entity_id']); + if (!$entity || !$entity->hasField($context['field_name'])) { + return FALSE; + } + $field = $entity->get($context['field_name'])->first(); + if (!($field instanceof TaxNumberItemInterface)) { + return FALSE; + } + // Upcast the values in the context array. + $context['entity'] = $entity; + $context['field'] = $field; + + return $context; + } + +} diff --git a/modules/tax/src/Plugin/Field/FieldFormatter/TaxNumberDefaultFormatter.php b/modules/tax/src/Plugin/Field/FieldFormatter/TaxNumberDefaultFormatter.php index 8c73da26..721c71cc 100644 --- a/modules/tax/src/Plugin/Field/FieldFormatter/TaxNumberDefaultFormatter.php +++ b/modules/tax/src/Plugin/Field/FieldFormatter/TaxNumberDefaultFormatter.php @@ -2,9 +2,12 @@ namespace Drupal\commerce_tax\Plugin\Field\FieldFormatter; +use Drupal\commerce\UrlData; +use Drupal\Component\Serialization\Json; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FormatterBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Url; /** * Plugin implementation of the 'commerce_tax_number_default' formatter. @@ -66,6 +69,7 @@ class TaxNumberDefaultFormatter extends FormatterBase { 'failure' => $this->t('Failure'), 'unknown' => $this->t('Unknown'), ]; + $entity = $items->getEntity(); $elements = []; foreach ($items as $delta => $item) { @@ -75,6 +79,33 @@ class TaxNumberDefaultFormatter extends FormatterBase { ]; if ($this->getSetting('show_verification')) { $element['#attached']['library'][] = 'commerce_tax/tax_number'; + $context = UrlData::encode([ + $entity->getEntityTypeId(), + $entity->id(), + $this->fieldDefinition->getName(), + $this->viewMode, + ]); + + if ($item->verification_result) { + $element['value'] = [ + '#type' => 'link', + '#title' => $item->value, + '#url' => Url::fromRoute('commerce_tax.verification_result', [ + 'tax_number' => $item->value, + 'context' => $context, + ]), + '#attributes' => [ + 'class' => [ + 'use-ajax', + ], + 'data-dialog-type' => 'modal', + 'data-dialog-options' => Json::encode([ + 'width' => 500, + 'title' => $item->value, + ]), + ], + ]; + } if ($item->verification_state && isset($states[$item->verification_state])) { $element['verification_state'] = [ '#type' => 'html_tag', diff --git a/modules/tax/tests/src/FunctionalJavascript/TaxNumberTest.php b/modules/tax/tests/src/FunctionalJavascript/TaxNumberTest.php index b78316d0..ed8739e8 100644 --- a/modules/tax/tests/src/FunctionalJavascript/TaxNumberTest.php +++ b/modules/tax/tests/src/FunctionalJavascript/TaxNumberTest.php @@ -2,9 +2,11 @@ namespace Drupal\Tests\commerce_tax\FunctionalJavascript; +use Drupal\commerce\UrlData; use Drupal\commerce_order\Entity\Order; use Drupal\commerce_order\Entity\OrderItem; use Drupal\commerce_tax\Plugin\Commerce\TaxNumberType\VerificationResult; +use Drupal\Core\Url; use Drupal\field\Entity\FieldConfig; use Drupal\profile\Entity\Profile; use Drupal\Tests\commerce\FunctionalJavascript\CommerceWebDriverTestBase; @@ -113,7 +115,7 @@ class TaxNumberTest extends CommerceWebDriverTestBase { public function testWidget() { $this->drupalGet($this->order->toUrl('edit-form')); $this->getSession()->getPage()->pressButton('billing_edit'); - $this->waitForAjaxToFinish(); + $this->assertSession()->assertWaitOnAjaxRequest(); // Confirm that the field is present for the allowed country (RS). $this->assertSession()->fieldExists('Tax number'); @@ -131,7 +133,7 @@ class TaxNumberTest extends CommerceWebDriverTestBase { // Confirm that not changing the tax number does not re-verify the number. $this->drupalGet($this->order->toUrl('edit-form')); $this->getSession()->getPage()->pressButton('billing_edit'); - $this->waitForAjaxToFinish(); + $this->assertSession()->assertWaitOnAjaxRequest(); $this->assertSession()->fieldValueEquals('Tax number', '601'); $this->submitForm([], 'Save'); @@ -142,7 +144,7 @@ class TaxNumberTest extends CommerceWebDriverTestBase { // Confirm that changing the tax number re-verifies the number. $this->drupalGet($this->order->toUrl('edit-form')); $this->getSession()->getPage()->pressButton('billing_edit'); - $this->waitForAjaxToFinish(); + $this->assertSession()->assertWaitOnAjaxRequest(); $this->assertSession()->fieldValueEquals('Tax number', '601'); $this->getSession()->getPage()->fillField('Tax number', '603'); $this->submitForm([], 'Save'); @@ -158,9 +160,9 @@ class TaxNumberTest extends CommerceWebDriverTestBase { // Confirm that changing the country changes the tax number type. $this->drupalGet($this->order->toUrl('edit-form')); $this->getSession()->getPage()->pressButton('billing_edit'); - $this->waitForAjaxToFinish(); + $this->assertSession()->assertWaitOnAjaxRequest(); $this->getSession()->getPage()->selectFieldOption('Country', 'ME'); - $this->waitForAjaxToFinish(); + $this->assertSession()->assertWaitOnAjaxRequest(); $this->getSession()->getPage()->fillField('City', 'Podgorica'); $this->assertSession()->fieldValueEquals('Tax number', '603'); $this->submitForm([], 'Save'); @@ -176,9 +178,9 @@ class TaxNumberTest extends CommerceWebDriverTestBase { // Confirm that selecting a non-allowed country removes the field. $this->drupalGet($this->order->toUrl('edit-form')); $this->getSession()->getPage()->pressButton('billing_edit'); - $this->waitForAjaxToFinish(); + $this->assertSession()->assertWaitOnAjaxRequest(); $this->getSession()->getPage()->selectFieldOption('Country', 'MK'); - $this->waitForAjaxToFinish(); + $this->assertSession()->assertWaitOnAjaxRequest(); $this->getSession()->getPage()->fillField('City', 'Skopje'); $this->assertSession()->fieldNotExists('Tax number'); $this->submitForm([], 'Save'); @@ -201,6 +203,7 @@ class TaxNumberTest extends CommerceWebDriverTestBase { $rendered_field = $this->getSession()->getPage()->find('css', '.field--name-tax-number'); $this->assertContains('Tax number', $rendered_field->getHtml()); $this->assertContains('122', $rendered_field->getHtml()); + $this->assertFalse($rendered_field->hasLink('122')); $state_field = $rendered_field->find('css', '.commerce-tax-number__verification-icon'); $this->assertEmpty($state_field); @@ -209,55 +212,70 @@ class TaxNumberTest extends CommerceWebDriverTestBase { 'value' => '123', 'verification_state' => VerificationResult::STATE_SUCCESS, 'verification_timestamp' => strtotime('2019/08/08'), - 'verification_result' => ['verification_id' => '123456'], + 'verification_result' => ['name' => 'Centarro LLC'], ]); $this->customerProfile->save(); $this->drupalGet($this->order->toUrl('canonical')); $rendered_field = $this->getSession()->getPage()->find('css', '.field--name-tax-number'); $this->assertContains('Tax number', $rendered_field->getHtml()); - $this->assertContains('123', $rendered_field->getHtml()); + $this->assertTrue($rendered_field->hasLink('123')); $state_field = $rendered_field->find('css', '.commerce-tax-number__verification-icon'); $this->assertNotEmpty($state_field); $this->assertEquals('Verification state: Success', $state_field->getAttribute('title')); $this->assertTrue($state_field->hasClass('commerce-tax-number__verification-icon--success')); + // Confirm that the verification result can be viewed. + $this->clickLink('123'); + $this->assertSession()->pageTextContains('August 8, 2019 - 00:00'); + $this->assertSession()->pageTextContains('Centarro LLC'); + $this->customerProfile->set('tax_number', [ 'type' => 'serbian_vat', 'value' => '124', 'verification_state' => VerificationResult::STATE_FAILURE, 'verification_timestamp' => strtotime('2019/08/09'), - 'verification_result' => ['verification_id' => '123457'], + 'verification_result' => ['name' => 'Google LLC'], ]); $this->customerProfile->save(); $this->drupalGet($this->order->toUrl('canonical')); $rendered_field = $this->getSession()->getPage()->find('css', '.field--name-tax-number'); $this->assertContains('Tax number', $rendered_field->getHtml()); - $this->assertContains('124', $rendered_field->getHtml()); + $this->assertTrue($rendered_field->hasLink('124')); $state_field = $rendered_field->find('css', '.commerce-tax-number__verification-icon'); $this->assertNotEmpty($state_field); $this->assertEquals('Verification state: Failure', $state_field->getAttribute('title')); $this->assertTrue($state_field->hasClass('commerce-tax-number__verification-icon--failure')); + // Confirm that the verification result can be viewed. + $this->clickLink('124'); + $this->assertSession()->pageTextContains('August 9, 2019 - 00:00'); + $this->assertSession()->pageTextContains('Google LLC'); + $this->customerProfile->set('tax_number', [ 'type' => 'serbian_vat', 'value' => '125', 'verification_state' => VerificationResult::STATE_UNKNOWN, 'verification_timestamp' => strtotime('2019/08/10'), - 'verification_result' => ['verification_id' => '123458'], + 'verification_result' => ['error' => 'http_429'], ]); $this->customerProfile->save(); $this->drupalGet($this->order->toUrl('canonical')); $rendered_field = $this->getSession()->getPage()->find('css', '.field--name-tax-number'); $this->assertContains('Tax number', $rendered_field->getHtml()); - $this->assertContains('125', $rendered_field->getHtml()); + $this->assertTrue($rendered_field->hasLink('125')); $state_field = $rendered_field->find('css', '.commerce-tax-number__verification-icon'); $this->assertNotEmpty($state_field); $this->assertEquals('Verification state: Unknown', $state_field->getAttribute('title')); $this->assertTrue($state_field->hasClass('commerce-tax-number__verification-icon--unknown')); + // Confirm that the verification result can be viewed. + $this->clickLink('125'); + $this->assertSession()->pageTextContains('August 10, 2019 - 00:00'); + $this->assertSession()->pageTextContains('Too many requests.'); + // Confirm that invalid verification states are ignored. $this->customerProfile->set('tax_number', [ 'type' => 'serbian_vat', @@ -276,4 +294,90 @@ class TaxNumberTest extends CommerceWebDriverTestBase { $this->assertEmpty($state_field); } + /** + * Tests access control for the verification endpoints. + */ + public function testVerificationEndpointAccess() { + $this->customerProfile->set('tax_number', [ + 'type' => 'serbian_vat', + 'value' => '124', + 'verification_state' => VerificationResult::STATE_FAILURE, + 'verification_timestamp' => strtotime('2019/08/09'), + 'verification_result' => ['name' => 'Google LLC'], + ]); + $this->customerProfile->save(); + + // Valid url. + $this->drupalGet(Url::fromRoute('commerce_tax.verification_result', [ + 'tax_number' => '124', + 'context' => UrlData::encode([ + 'profile', $this->customerProfile->id(), 'tax_number', 'default', + ]), + ])); + $this->assertSession()->pageTextNotContains('Access Denied'); + $this->assertSession()->pageTextContains('Google LLC'); + + // The tax_number doesn't match the one on the parent entity. + $this->drupalGet(Url::fromRoute('commerce_tax.verification_result', [ + 'tax_number' => '125', + 'context' => UrlData::encode([ + 'profile', $this->customerProfile->id(), 'tax_number', 'default', + ]), + ])); + $this->assertSession()->pageTextContains('Access Denied'); + + // Invalid context. + $this->drupalGet(Url::fromRoute('commerce_tax.verification_result', [ + 'tax_number' => '124', + 'context' => 'INVALID', + ])); + $this->assertSession()->pageTextContains('Access Denied'); + + // Incorrect number of parameters. + $this->drupalGet(Url::fromRoute('commerce_tax.verification_result', [ + 'tax_number' => '124', + 'context' => UrlData::encode([ + 'profile', + ]), + ])); + $this->assertSession()->pageTextContains('Access Denied'); + + // Invalid entity type. + $this->drupalGet(Url::fromRoute('commerce_tax.verification_result', [ + 'tax_number' => '124', + 'context' => UrlData::encode([ + 'profile2', $this->customerProfile->id(), 'tax_number', 'default', + ]), + ])); + $this->assertSession()->pageTextContains('Access Denied'); + + // Invalid entity. + $this->drupalGet(Url::fromRoute('commerce_tax.verification_result', [ + 'tax_number' => '124', + 'context' => UrlData::encode([ + 'profile', '99', 'tax_number', 'default', + ]), + ])); + $this->assertSession()->pageTextContains('Access Denied'); + + // Invalid field. + $this->drupalGet(Url::fromRoute('commerce_tax.verification_result', [ + 'tax_number' => '124', + 'context' => UrlData::encode([ + 'profile', $this->customerProfile->id(), 'address', 'default', + ]), + ])); + $this->assertSession()->pageTextContains('Access Denied'); + + // No access to parent entity. + $this->drupalLogout(); + $this->drupalGet(Url::fromRoute('commerce_tax.verification_result', [ + 'tax_number' => '124', + 'context' => UrlData::encode([ + 'profile', $this->customerProfile->id(), 'tax_number', 'default', + ]), + ])); + $this->assertSession()->pageTextContains('Access Denied'); + } + } diff --git a/src/UrlData.php b/src/UrlData.php new file mode 100644 index 00000000..876e8649 --- /dev/null +++ b/src/UrlData.php @@ -0,0 +1,45 @@ +assertInternalType('string', $encoded_data); + + $decoded_data = UrlData::decode($encoded_data); + $this->assertInternalType('array', $decoded_data); + $this->assertSame($data, $decoded_data); + + $invalid_data = UrlData::decode('INVALID'); + $this->assertFalse($invalid_data); + } + +}