Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/netlicensing-php-demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ permissions:

jobs:
build:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
php-version: ['8.2', '8.3', '8.4']
php-version: ['8.2', '8.3', '8.4', '8.5']

steps:
- uses: actions/checkout@v7
Expand All @@ -30,6 +30,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
coverage: none
extensions: curl, dom, libxml, mbstring, openssl
tools: composer:v2

- name: Validate composer manifests
Expand All @@ -47,11 +48,14 @@ jobs:
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction

- name: Audit dependencies for known vulnerabilities
run: composer audit

- name: Run PHPUnit
run: vendor/bin/phpunit --testsuite "Application Test Suite"

- name: Run demo scenario (latest PHP)
if: matrix.php-version == '8.4'
if: matrix.php-version == '8.5'
run: |
cd demo
php NetLicensingDemo.php
5 changes: 3 additions & 2 deletions .github/workflows/netlicensing-php-dependency-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ permissions:

jobs:
dependency-check:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
php-version: ['8.4']
php-version: ['8.2', '8.5']

steps:
- name: Setup PHP ${{ matrix.php-version }}
Expand All @@ -32,3 +32,4 @@ jobs:
run: |
composer require --no-interaction labs64/netlicensingclient-php
composer show labs64/netlicensingclient-php
composer audit
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ PHP wrapper for Labs64 NetLicensing [RESTful API](https://netlicensing.io/wiki/r

Visit Labs64 NetLicensing at https://netlicensing.io

## Requirements

- PHP 8.2, 8.3, 8.4 or 8.5 with the `curl`, `dom`, `json`, `libxml`, `mbstring` and `openssl` extensions
- Run a patched PHP runtime: at least 8.2.35, 8.3.36, 8.4.27 or 8.5.12 (fixes for CVE-2026-109618 and CVE-2026-109619)

## Installation

```
Expand Down
8 changes: 5 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@
"require": {
"php": ">=8.2",
"php-curl-class/php-curl-class": "^11.0.0 || ^12.0.0 || ^13.0.0",
"fr3d/xmldsig": "^3.0",
"robrichards/xmlseclibs": "^3.1.5",
"ext-mbstring": "*",
"ext-json": "*",
"ext-dom": "*"
"ext-dom": "*",
"ext-openssl": "*",
"ext-libxml": "*"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpunit/phpunit": "^11.5.50",
"fakerphp/faker": "^1.23",
"wp-cli/php-cli-tools": "^0.12 || ^0.13"
},
Expand Down
4 changes: 2 additions & 2 deletions examples/OfflineValidation.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ public function testOfflineValidation()
{
try {
// 1. Create context, for offline validation you only need to provide the public key.
$publicKey = file_get_contents(__DIR__ . '../resources/rsa_public.pem');
$publicKey = file_get_contents(__DIR__ . '/resources/rsa_public.pem');
$context = new Context();
$context->setPublicKey($publicKey);

// 2. Read the validation file.
$offlineValidation = file_get_contents(__DIR__ . '../resources/Isb-DEMO.xml');
$offlineValidation = file_get_contents(__DIR__ . '/resources/Isb-DEMO.xml');

// 3. Validate. ValidationResult is same as if validation would be executed against the
// NetLicensing service online.
Expand Down
4 changes: 2 additions & 2 deletions examples/OfflineValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ public function testOfflineValidation()
{
try {
// 1. Create context, for offline validation you only need to provide the public key.
$publicKey = file_get_contents(__DIR__ . '../resources/rsa_public.pem');
$publicKey = file_get_contents(__DIR__ . '/resources/rsa_public.pem');
$context = new Context();
$context->setPublicKey($publicKey);

// 2. Read the validation file.
$offlineValidation = file_get_contents(__DIR__ . '../resources/Isb-DEMO.xml');
$offlineValidation = file_get_contents(__DIR__ . '/resources/Isb-DEMO.xml');

// 3. Validate. ValidationResult is same as if validation would be executed against the
// NetLicensing service online.
Expand Down
21 changes: 19 additions & 2 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
beStrictAboutOutputDuringTests="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
failOnRisky="true"
failOnWarning="true">
failOnWarning="true"
failOnNotice="true"
failOnDeprecation="true">
<testsuites>
<testsuite name="Application Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./common</directory>
<directory suffix=".php">./converter</directory>
<directory suffix=".php">./entity</directory>
<directory suffix=".php">./exception</directory>
<directory suffix=".php">./service</directory>
<directory suffix=".php">./util</directory>
<directory suffix=".php">./vo</directory>
<file>netlicensing.php</file>
</include>
</source>
</phpunit>
2 changes: 2 additions & 0 deletions service/NetLicensingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ private function configure(Context $context): void
$this->curl->setHeader('Authorization', 'Basic ' . base64_encode("apiKey:" . $context->getApiKey()));
break;
case Constants::ANONYMOUS_IDENTIFICATION:
// the curl instance is shared, don't send credentials left over from a previous context
$this->curl->removeHeader('Authorization');
break;
default:
throw new RestException("Unknown security mode");
Expand Down
105 changes: 102 additions & 3 deletions service/ValidationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

use DateTime;
use DOMDocument;
use DOMElement;
use ErrorException;
use Exception;

Expand Down Expand Up @@ -75,15 +76,110 @@ static public function retrieveValidationFile(Context $context, string $number,
*
* @return ValidationResults|null result of the validation
* @throws BadSignatureException
* @throws MalformedArgumentsException
* @throws Exception
*/
static public function validateOffline(Context $context, string $validationFile, array &$meta = []): ?ValidationResults
{
$validationDoc = new DOMDocument();
$validationDoc->loadXML($validationFile);
$validationDoc = self::loadValidationFile($validationFile);

SignatureUtils::check($context, $validationDoc);
return self::convertValidationResult($validationDoc);
return self::convertValidationResult(self::convertValidationDoc($validationDoc), $meta);
}

/**
* @param string $validationFile
* @return DOMDocument
* @throws MalformedArgumentsException
*/
static private function loadValidationFile(string $validationFile): DOMDocument
{
if (trim($validationFile) === '') {
throw new MalformedArgumentsException('Validation file is empty');
}

$useInternalErrors = libxml_use_internal_errors(true);

try {
$validationDoc = new DOMDocument();
$loaded = $validationDoc->loadXML($validationFile, LIBXML_NONET);
$error = libxml_get_last_error();
libxml_clear_errors();
} finally {
libxml_use_internal_errors($useInternalErrors);
}

if (!$loaded || !$validationDoc->documentElement) {
throw new MalformedArgumentsException('Validation file is not a valid XML document'
. ($error ? ': ' . trim($error->message) : ''));
}

return $validationDoc;
}

/**
* Converts the XML validation file into the same structure as the JSON response of the online validation.
*
* @param DOMDocument $validationDoc
* @return object
*/
static private function convertValidationDoc(DOMDocument $validationDoc): object
{
$root = $validationDoc->documentElement;
$result = (object)['infos' => (object)['info' => []], 'items' => (object)['item' => []]];

if ($root->hasAttribute('ttl')) {
$result->ttl = $root->getAttribute('ttl');
}

foreach (self::childElements($root, 'infos') as $infos) {
foreach (self::childElements($infos, 'info') as $info) {
$result->infos->info[] = (object)[
'id' => $info->getAttribute('id'),
'type' => $info->getAttribute('type'),
'value' => $info->textContent,
];
}
}

foreach (self::childElements($root, 'items') as $items) {
foreach (self::childElements($items, 'item') as $item) {
$result->items->item[] = self::convertXmlItem($item, ['type' => $item->getAttribute('type')]);
}
}

return $result;
}

static private function convertXmlItem(DOMElement $element, array $attributes): object
{
$item = (object)array_merge($attributes, ['property' => [], 'list' => []]);

foreach (self::childElements($element, 'property') as $property) {
$item->property[] = (object)['name' => $property->getAttribute('name'), 'value' => $property->textContent];
}

foreach (self::childElements($element, 'list') as $list) {
$item->list[] = self::convertXmlItem($list, ['name' => $list->getAttribute('name')]);
}

return $item;
}

/**
* @return DOMElement[]
*/
static private function childElements(DOMElement $parent, string $localName): array
{
$elements = [];

foreach ($parent->childNodes as $child) {
if ($child instanceof DOMElement && $child->localName === $localName) {
$elements[] = $child;
}
}

return $elements;
}

/**
Expand Down Expand Up @@ -131,6 +227,9 @@ static private function convertValidationResult($validationFile, array &$meta =
$validationResults->setProductModuleValidation($array[Constants::PRODUCT_MODULE_NUMBER], $array);
}

}

if (!empty($validationFile->ttl)) {
$validationResults->setTtl(new DateTime($validationFile->ttl));
}

Expand Down
11 changes: 0 additions & 11 deletions tests/DummyTest.php

This file was deleted.

49 changes: 49 additions & 0 deletions tests/NetLicensingServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

use NetLicensing\Constants;
use NetLicensing\Context;
use NetLicensing\NetLicensingCurl;
use NetLicensing\NetLicensingService;
use PHPUnit\Framework\TestCase;

class NetLicensingServiceTest extends TestCase
{
private function configure(Context $context): array
{
$service = NetLicensingService::getInstance();
(new ReflectionMethod($service, 'configure'))->invoke($service, $context);

return $service->curl()->getOpt(CURLOPT_HTTPHEADER) ?? [];
}

private function authorizationHeaders(array $headers): array
{
// curl doesn't send headers with an empty value
return array_values(array_filter($headers,
fn($header) => stripos($header, 'Authorization:') === 0 && trim(substr($header, 14)) !== ''));
}

public function testApiKeyAuthorizationHeader()
{
$context = (new Context())->setSecurityMode(Constants::APIKEY_IDENTIFICATION)->setApiKey('secret');

$this->assertSame(['Authorization: Basic ' . base64_encode('apiKey:secret')],
$this->authorizationHeaders($this->configure($context)));
}

public function testAnonymousContextDoesNotReusePreviousCredentials()
{
$this->configure((new Context())->setUsername('demo')->setPassword('demo'));

$anonymous = (new Context())->setSecurityMode(Constants::ANONYMOUS_IDENTIFICATION);

$this->assertSame([], $this->authorizationHeaders($this->configure($anonymous)));
}

public function testBuildPostDataFlattensArrayValues()
{
$query = (new NetLicensingCurl())->buildPostData(['a' => ['1', '2'], 'b' => 'x', 'c.d' => ['3', '4']]);

$this->assertSame('a=1&a=2&b=x&c.d=3&c.d=4', $query);
}
}
Loading
Loading