diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 46ae0b36..21ab944d 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -23,7 +23,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.1', '8.2', '8.3' ] + php: [ '8.3' ] name: Validate composer (${{ matrix.php}}) steps: - name: Checkout @@ -55,7 +55,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.1', '8.2', '8.3' ] + php: [ '8.3' ] name: PHP coding Standards (${{ matrix.php }}) steps: - name: Checkout @@ -85,7 +85,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.1', '8.2', '8.3' ] + php: [ '8.3' ] name: PHP code analysis (${{ matrix.php }}) steps: - name: Checkout @@ -118,3 +118,33 @@ jobs: uses: actions/checkout@v4 - name: coding-standards-check run: docker run --rm --volume ${PWD}:/md peterdavehello/markdownlint markdownlint --ignore vendor --ignore LICENSE.md '**/*.md' + + test-unit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ '8.3' ] + name: Run unit tests (${{ matrix.php}}) + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php}} + extensions: apcu, ctype, iconv, imagick, json, redis, soap, xmlreader, zip + coverage: none + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ matrix.php }}-composer- + - name: Composer install + run: composer install + - name: Run unit tests + run: composer tests/unit diff --git a/.gitignore b/.gitignore index 6db1725b..085d55c7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ generated-classes/tutorial.php /.php-cs-fixer.cache /phpcs.xml ###< squizlabs/php_codesniffer ### +*.cache diff --git a/.php-version b/.php-version deleted file mode 100644 index b8eb0263..00000000 --- a/.php-version +++ /dev/null @@ -1 +0,0 @@ -8.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 526daab5..cb4fa649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [PR-41](https://github.com/itk-dev/serviceplatformen/pull/41) + Added support for MeMo 1.2 + ## [1.6.1] - 2025-03-30 - [PR-39](https://github.com/itk-dev/serviceplatformen/pull/39) diff --git a/README.md b/README.md index b5ccc606..a76c7fc5 100644 --- a/README.md +++ b/README.md @@ -18,32 +18,32 @@ generate PHP classes for talking to SOAP services. To update [resources](./resources) and [generated classes](./generated-classes), run ``` shell -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest composer install +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest composer install # Update WSDL resources. -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest bin/generate resources +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest bin/generate resources # Generate PHP classes from WSDL resources. -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest bin/generate classes +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest bin/generate classes ``` ## Test commands ``` shell -docker run --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest vendor/bin/serviceplatformen-sf1601-kombipostafsend --help +docker run --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest vendor/bin/serviceplatformen-sf1601-kombipostafsend --help ``` Use `bin/serviceplatformen-sf1601-kombipostafsend` (symlinked to `bin/SF1601/kombipostafsend`) during development of this library. i.e. ``` shell -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest bin/serviceplatformen-sf1601-kombipostafsend +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest bin/serviceplatformen-sf1601-kombipostafsend ``` ``` shell -docker run --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest vendor/bin/serviceplatformen-sf1601-postforespoerg --help +docker run --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest vendor/bin/serviceplatformen-sf1601-postforespoerg --help ``` ``` shell -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest bin/serviceplatformen-sf1601-postforespoerg +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest bin/serviceplatformen-sf1601-postforespoerg ``` ## Getting Started @@ -79,13 +79,13 @@ composer install Unit tests: ``` shell -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest composer tests/unit +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest composer tests/unit ``` End to end tests: ``` shell -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest composer tests/end-to-end +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest composer tests/end-to-end ``` ### And coding style tests @@ -237,9 +237,9 @@ reviewer to merge it for you. ### Coding standards ``` shell -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest composer install -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest composer coding-standards-apply -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest composer coding-standards-check +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest composer install +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest composer coding-standards-apply +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest composer coding-standards-check ``` ``` shell @@ -250,7 +250,7 @@ docker run --rm --volume ${PWD}:/md peterdavehello/markdownlint markdownlint --i ### Code analysis ``` shell -docker run --interactive --tty --rm --volume ${PWD}:/app --env COMPOSER_MEMORY_LIMIT=-1 itkdev/php8.1-fpm:latest composer code-analysis +docker run --interactive --tty --rm --volume ${PWD}:/app --env COMPOSER_MEMORY_LIMIT=-1 itkdev/php8.3-fpm:latest composer code-analysis ``` ## Versioning diff --git a/docs/SF1601.md b/docs/SF1601.md index aaadf04d..aba11ce0 100644 --- a/docs/SF1601.md +++ b/docs/SF1601.md @@ -2,6 +2,20 @@ +## MeMo version + +If the MeMo version is not set explicitly on [a message](lib/DigitalPost/MeMo/Message.php), this library set the version +to [MeMo 1.2](https://digitaliser.dk/digital-post/nyhedsarkiv/2025/mar/lancering-af-memo-version-12). + +The default version can be overridden by settings the `SERVICEPLATFORMEN_MEMO_DEFAULT_VERSION` environment variable to +one of the allowed values `1.1` or `1.2`, e.g. + +``` env +SERVICEPLATFORMEN_MEMO_DEFAULT_VERSION=1.1 +``` + +------------------------------------------------------------------------------------------------------------------------ + Calling this service is extremely simple, but it's extremely hard to navigate the documentation and understand how to actually authenticate and call the service. @@ -182,8 +196,8 @@ classes. To generate or update the generated PHP classes, run ``` shell -docker run --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest composer install -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest bin/xsd2php +docker run --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest composer install +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest bin/xsd2php ``` ## Sending test messages @@ -192,5 +206,11 @@ A console command, [`bin/SF1601/kombipostafsend`](bin/SF1601/kombipostafsend), c the “[KombiPostAfsend](https://digitaliseringskataloget.dk/integration/sf1601#table-of-contents-1-2)” service: ``` shell -docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.1-fpm:latest bin/SF1601/kombipostafsend –help +docker run --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest bin/SF1601/kombipostafsend –-help +``` + +To test with another default MeMo version, e.g. `1.1`, run + +``` shell +docker run --env SERVICEPLATFORMEN_MEMO_DEFAULT_VERSION=1.1 --interactive --tty --rm --volume ${PWD}:/app itkdev/php8.3-fpm:latest bin/SF1601/kombipostafsend ``` diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 75776d4b..55d46e3d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,27 +1,16 @@ - - - - ./tests/Unit - - - - ./tests/EndToEnd - - - - - ./src - - + + + + ./src + + + + + ./tests/Unit + + + ./tests/EndToEnd + + diff --git a/src/Command/SF1601/KombiPostAfsendCommand.php b/src/Command/SF1601/KombiPostAfsendCommand.php index 6cec8935..810aace1 100644 --- a/src/Command/SF1601/KombiPostAfsendCommand.php +++ b/src/Command/SF1601/KombiPostAfsendCommand.php @@ -11,7 +11,6 @@ namespace ItkDev\Serviceplatformen\Command\SF1601; use DataGovDk\Model\Core\Address; -use DateTime; use DigitalPost\MeMo\Action; use DigitalPost\MeMo\AdditionalDocument; use DigitalPost\MeMo\AttentionData; @@ -25,7 +24,6 @@ use DigitalPost\MeMo\Recipient; use DigitalPost\MeMo\Reservation; use DigitalPost\MeMo\Sender; -use DOMDocument; use GuzzleHttp\Client; use Http\Adapter\Guzzle7\Client as GuzzleAdapter; use Http\Factory\Guzzle\RequestFactory; @@ -206,8 +204,8 @@ private function buildAction(string $spec): Action ->setLabel($options['label']); if (SF1601::ACTION_AFTALE === $options['action']) { $reservation = (new Reservation()) - ->setStartDateTime(new DateTime('+2 days')) - ->setEndDateTime(new DateTime('+2 days 1 hour')) + ->setStartDateTime(new \DateTime('+2 days')) + ->setEndDateTime(new \DateTime('+2 days 1 hour')) ->setLocation('Meeting room 1') ->setAbstract('Abstract') ->setDescription('Description') @@ -351,7 +349,7 @@ private function buildMessage(array $options): Message $message->setMessageHeader($messageHeader); $body = (new MessageBody()) - ->setCreatedDateTime(new DateTime()); + ->setCreatedDateTime(new \DateTime()); if (isset($options['file'])) { $mimeTypes = new MimeTypes(); diff --git a/src/Service/Exception/SoapException.php b/src/Service/Exception/SoapException.php index 09ad5c1d..51813cd0 100644 --- a/src/Service/Exception/SoapException.php +++ b/src/Service/Exception/SoapException.php @@ -17,7 +17,7 @@ */ class SoapException extends ServiceException { - public function __construct(readonly private \SoapFault $soapFault, readonly private ?string $request, readonly private ?string $response) + public function __construct(private readonly \SoapFault $soapFault, private readonly ?string $request, private readonly ?string $response) { parent::__construct($this->soapFault->getMessage(), $this->soapFault->getCode(), $this->soapFault); } diff --git a/src/Service/SF1601/SF1601.php b/src/Service/SF1601/SF1601.php index eeb61094..973abffa 100644 --- a/src/Service/SF1601/SF1601.php +++ b/src/Service/SF1601/SF1601.php @@ -39,6 +39,9 @@ class SF1601 extends AbstractRESTService self::TYPE_NEM_SMS, ]; + public const MEMO_1_1 = 1.1; + public const MEMO_1_2 = 1.2; + public const FORESPOERG_TYPE_DIGITAL_POST = 'digitalpost'; public const FORESPOERG_TYPE_NEM_SMS = 'nemsms'; @@ -182,9 +185,10 @@ private function buildKombiRequestDocument(string $type, ?Message $message, ?For if (null !== $message) { // Set default values on some required attributes. if (empty($message->getMemoVersion())) { - $message->setMemoVersion(1.1); + $message->setMemoVersion($this->getDefaultMeMoVersion()); } - if (empty($message->getMemoSchVersion())) { + // memoSchVersion is required for MeMo 1.1 only (cf. https://digitaliser.dk/Media/638608781984779669/MeMo%20Versionshistorik%20v1.2.pdf) + if (empty($message->getMemoSchVersion()) && self::MEMO_1_1 === $message->getMemoVersion()) { $message->setMemoSchVersion('1.1.0'); } @@ -203,6 +207,11 @@ private function buildKombiRequestDocument(string $type, ?Message $message, ?For throw new InvalidMemoException('MeMo message header must have a sender with a label'); } + $this->validateActions($message->getMessageBody()->getMainDocument()->getAction()); + foreach ($message->getMessageBody()->getAdditionalDocument() as $document) { + $this->validateActions($document->getAction()); + } + // Serialize message and import and append it to kombi_request element. $messageDocument = Serializer::loadXML((new Serializer())->serialize($message)); @@ -220,4 +229,57 @@ private function buildKombiRequestDocument(string $type, ?Message $message, ?For return $document; } + + private function getDefaultMeMoVersion(): float + { + $value = floatval(getenv('SERVICEPLATFORMEN_MEMO_DEFAULT_VERSION')); + + return match ($value) { + self::MEMO_1_1 => self::MEMO_1_1, + default => self::MEMO_1_2, + }; + } + + /** + * Sanitize MeMo filename (cf. https://digitaliser.dk/digital-post/nyhedsarkiv/2024/nov/oeget-validering-i-digital-post) + * + * Non-empty sequences of invalid characters are replaced with a single character. + */ + public static function sanitizeFilename(string $filename, string $replacer = '-'): string + { + // < > : " / \ | ? * CR LF CRLF U+00A0 U+2000 U+2001 U+2002 U+2003 U+2004 U+2005 U+2006 U+2007 U+2008 U+2009 U+200A U+2028 U+205F U+2060 U+3000 + $pattern = '@[<>:"/\\\\|?*\x{000D}\x{000A}\x{00A0}\x{2000}\x{2001}\x{2002}\x{2003}\x{2004}\x{2005}\x{2006}\x{2007}\x{2008}\x{2009}\x{200A}\x{2028}\x{205F}\x{2060}\x{3000}]+@u'; + + if (mb_strlen($replacer) !== 1) { + throw new \InvalidArgumentException(sprintf('Replacer must have length 1 (is %d)', mb_strlen($replacer))); + } + + if (preg_match($pattern, $replacer, $matches)) { + throw new \InvalidArgumentException(sprintf('Replacer %s contains invalid character %s', var_export($replacer, true), var_export($matches[0], true))); + } + + return preg_replace($pattern, $replacer, $filename); + } + + /** + * Validate MeMo actions. + * + * @param \DigitalPost\MeMo\Action[] $actions + */ + private function validateActions(array $actions) + { + foreach ($actions as $action) { + /** @phpstan-ignore nullsafe.neverNull (Action::getEntryPoint can actually return null) */ + $url = $action->getEntryPoint()?->getUrl(); + // URL must be absolute and use https (cf. https://digitaliser.dk/digital-post/nyhedsarkiv/2024/nov/oeget-validering-i-digital-post) + if ($url && 'https' !== parse_url($url, PHP_URL_SCHEME)) { + throw new \RuntimeException(sprintf( + 'URL %s for action "%s" (%s) must be absolute and use the https scheme, i.e. start with "https://".', + $url, + $action->getLabel(), + $action->getActionCode() + )); + } + } + } } diff --git a/tests/Unit/Service/SF1601/SF1601Test.php b/tests/Unit/Service/SF1601/SF1601Test.php new file mode 100644 index 00000000..bcc87b9a --- /dev/null +++ b/tests/Unit/Service/SF1601/SF1601Test.php @@ -0,0 +1,119 @@ +assertEquals($expected, $actual); + } + + /** @dataProvider sanitizeFilenameWithReplacerProvider */ + public function testSanitizeFilenameWithReplacer(string $filename, string $replacer, string|\Exception $expected): void + { + if ($expected instanceof \Exception) { + $this->expectException($expected::class); + } + $actual = SF1601::sanitizeFilename($filename, $replacer); + $this->assertEquals($expected, $actual); + } + + public static function sanitizeFilenameProvider(): iterable + { + yield ['a.pdf', 'a.pdf']; + + // The ones we cannot use + + // < + yield ['a + yield ['a>b.pdf', 'a-b.pdf']; + // : + yield ['a:b.pdf', 'a-b.pdf']; + // " + yield ['a"b.pdf', 'a-b.pdf']; + // / + yield ['a/b.pdf', 'a-b.pdf']; + // \ + yield ['a\b.pdf', 'a-b.pdf']; + // | + yield ['a|b.pdf', 'a-b.pdf']; + // ? + yield ['a?b.pdf', 'a-b.pdf']; + // * + yield ['a*b.pdf', 'a-b.pdf']; + // CR + yield ['a'."\n".'b.pdf', 'a-b.pdf']; + // LF + yield ['a'."\r".'b.pdf', 'a-b.pdf']; + // CRLF + yield ['a'."\n\r".'b.pdf', 'a-b.pdf']; + // U+00A0 + yield ['a b.pdf', 'a-b.pdf']; + // U+2000 + yield ['a b.pdf', 'a-b.pdf']; + // U+2001 + yield ['a b.pdf', 'a-b.pdf']; + // U+2002 + yield ['a b.pdf', 'a-b.pdf']; + // U+2003 + yield ['a b.pdf', 'a-b.pdf']; + // U+2004 + yield ['a b.pdf', 'a-b.pdf']; + // U+2005 + yield ['a b.pdf', 'a-b.pdf']; + // U+2006 + yield ['a b.pdf', 'a-b.pdf']; + // U+2007 + yield ['a b.pdf', 'a-b.pdf']; + // U+2008 + yield ['a b.pdf', 'a-b.pdf']; + // U+2009 + yield ['a b.pdf', 'a-b.pdf']; + // U+200A + yield ['a b.pdf', 'a-b.pdf']; + // U+2028 + yield ['a
b.pdf', 'a-b.pdf']; + // U+205F + yield ['a b.pdf', 'a-b.pdf']; + // U+2060 + yield ['a⁠b.pdf', 'a-b.pdf']; + // U+3000 + yield ['a b.pdf', 'a-b.pdf']; + + // Some we can use + + yield ['a💩b.pdf', 'a💩b.pdf']; + yield ['a👻b.pdf', 'a👻b.pdf']; + } + + public static function sanitizeFilenameWithReplacerProvider(): iterable + { + yield ['a', new \InvalidArgumentException('Replacer contains invalid character')]; + } +}