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
47 changes: 47 additions & 0 deletions .github/workflows/upstream-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Upstream Compatibility

on:
pull_request:
paths:
- ".github/workflows/upstream-compatibility.yml"
- "tests/upstream-compat.php"
workflow_dispatch:
schedule:
- cron: "17 4 * * 1"

permissions:
contents: read

jobs:
upstream-corpus:
runs-on: ubuntu-latest

steps:
- name: Checkout parser
uses: actions/checkout@v7
with:
path: parser

- name: Checkout upstream eXeLearning fixtures
uses: actions/checkout@v7
with:
repository: exelearning/exelearning
path: upstream
sparse-checkout: |
test/fixtures
sparse-checkout-cone-mode: false

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: dom, libxml, simplexml, zip
coverage: none

- name: Install dependencies
working-directory: parser
run: composer update --prefer-stable --prefer-dist --no-interaction

- name: Parse upstream project corpus
working-directory: parser
run: php tests/upstream-compat.php ../upstream/test/fixtures
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,18 @@ The parser distinguishes the internal project format from the detected eXeLearni

Use `getFormatVersion()` for the ODE format version, `getApplicationVersion()` for the declared eXeLearning version, and `getVersionInfo()` when the distinction between declared and inferred versions matters.

## Compatibility regression testing

The regular test suite includes a deterministic corpus for malformed XML, encoded and Unicode asset paths, malformed iDevice state and cyclic page hierarchies.

A separate `Upstream Compatibility` workflow runs weekly against project fixtures from `exelearning/exelearning`. It discovers ZIP-backed `.elp` / `.elpx` fixtures containing `content.xml` or `contentv3.xml`, compares lightweight inspection with full parsing, and fails on compatibility regressions.

The corpus runner can also be used locally:

```bash
php tests/upstream-compat.php /path/to/exelearning/test/fixtures
```

## License

The project is distributed under the MIT License. See [LICENSE.md](LICENSE.md).
195 changes: 195 additions & 0 deletions tests/Unit/RobustnessRegressionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php

/**
* Regression corpus for malformed and edge-case project inputs.
*
* @category Tests
* @package ELPParser
* @author INTEF <cedec@educacion.gob.es>
* @license MIT https://opensource.org/licenses/MIT
* @link https://github.com/exelearning/elp-parser
*/

namespace Exelearning\ElpParser\Tests\Unit;

use Exelearning\Asset\AssetReferenceExtractor;
use Exelearning\ELPParser;
use Exelearning\Exception\InvalidXmlException;
use Exelearning\Parser\IdeviceStateParser;
use RuntimeException;
use ZipArchive;

/**
* Create a temporary ZIP-backed project with custom content.xml.
*
* @param string $xml Project XML.
* @param array<string, string> $entries Additional archive entries.
*
* @return string
*/
function createRegressionArchive(string $xml, array $entries = []): string
{
$temporaryFile = tempnam(sys_get_temp_dir(), 'elp-regression-');
if ($temporaryFile === false) {
throw new RuntimeException('Unable to create temporary file.');
}

@unlink($temporaryFile);
$archivePath = $temporaryFile . '.elpx';

$zip = new ZipArchive();
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Unable to create temporary archive.');
}

$zip->addFromString('content.xml', $xml);

foreach ($entries as $path => $contents) {
$zip->addFromString($path, $contents);
}

$zip->close();

return $archivePath;
}

it(
'rejects a deterministic corpus of malformed xml documents',
function () {
$corpus = [
'<ode>',
'<ode><unclosed></ode>',
'<?xml version="1.0"?><ode><a></b></ode>',
'<?xml version="1.0"?><ode>&unknown;</ode>',
];

foreach ($corpus as $xml) {
$archive = createRegressionArchive($xml);

try {
expect(fn() => ELPParser::fromFile($archive))
->toThrow(InvalidXmlException::class);
} finally {
@unlink($archive);
}
}
}
);

it(
'handles unicode query fragments url encoding and traversal-like asset references',
function () {
$extractor = new AssetReferenceExtractor(
[
'content/resources/Imágen ñ.jpg',
'content/resources/manual.pdf',
]
);

$pages = [
[
'id' => 'PAGE',
'title' => 'Page',
'idevices' => [
[
'id' => 'IDEVICE',
'type' => 'text',
'html' => '<img src="{{context_path}}/Im%C3%A1gen%20%C3%B1.jpg?size=large#preview">'
. '<a href="{{context_path}}/manual.pdf#page=4">Manual</a>'
. '<a href="https://example.com/external.pdf">External</a>'
. '<a href="{{context_path}}/../../secret.pdf">Traversal</a>',
'jsonProperties' => [],
'data' => [],
],
],
],
];

$resolved = array_column($extractor->extract($pages), 'path');
$broken = array_column($extractor->findBrokenReferences($pages), 'reference');

expect($resolved)->toContain('content/resources/Imágen ñ.jpg');
expect($resolved)->toContain('content/resources/manual.pdf');
expect($broken)->toContain('../../secret.pdf');
expect($broken)->not->toContain('https://example.com/external.pdf');
}
);

it(
'keeps malformed idevice state isolated for every structured storage pattern',
function () {
$parser = new IdeviceStateParser();

$cases = [
[
'<div class="flipcards-DataGame js-hidden">%7Bbroken</div>',
'',
IdeviceStateParser::PATTERN_DATA_GAME,
],
[
'<script id="exe-interactive-video-contents" type="application/json">{broken</script>',
'',
IdeviceStateParser::PATTERN_EMBEDDED_JSON,
],
[
'<p>Standard</p>',
'{broken',
IdeviceStateParser::PATTERN_STANDARD_JSON,
],
];

foreach ($cases as [$html, $json, $pattern]) {
$state = $parser->parse($html, $json);

expect($state['storagePattern'])->toBe($pattern);
expect($state['data'])->toBe([]);
expect($state['decodeError'])->toBeString();
}
}
);

it(
'reports cyclic page hierarchies without hanging',
function () {
$xml = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<ode xmlns="http://www.intef.es/xsd/ode" version="2.0">
<odeResources>
<odeResource><key>exe_version</key><value>4.0.0</value></odeResource>
</odeResources>
<odeProperties></odeProperties>
<odeNavStructures>
<odeNavStructure>
<odePageId>PAGE-A</odePageId>
<odeParentPageId>PAGE-B</odeParentPageId>
<odeNavStructureOrder>1</odeNavStructureOrder>
<pageName>A</pageName>
<odeNavStructureProperties></odeNavStructureProperties>
<odePagStructures></odePagStructures>
</odeNavStructure>
<odeNavStructure>
<odePageId>PAGE-B</odePageId>
<odeParentPageId>PAGE-A</odeParentPageId>
<odeNavStructureOrder>1</odeNavStructureOrder>
<pageName>B</pageName>
<odeNavStructureProperties></odeNavStructureProperties>
<odePagStructures></odePagStructures>
</odeNavStructure>
</odeNavStructures>
</ode>
XML;

$archive = createRegressionArchive($xml);

try {
$parser = ELPParser::fromFile($archive);
$result = $parser->validate();

expect(array_column($result['errors'], 'code'))
->toContain('page_hierarchy_cycle');
expect($parser->getPageTree())->toBe([]);
} finally {
@unlink($archive);
}
}
);
111 changes: 111 additions & 0 deletions tests/upstream-compat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

/**
* Parse the upstream eXeLearning fixture corpus.
*
* Usage:
* php tests/upstream-compat.php /path/to/exelearning/test/fixtures
*/

use Exelearning\ELPParser;
use ZipArchive;

require __DIR__ . '/../vendor/autoload.php';

$root = $argv[1] ?? null;

if ($root === null || !is_dir($root)) {
fwrite(STDERR, "Usage: php tests/upstream-compat.php /path/to/upstream/test/fixtures\n");
exit(2);
}

$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)
);

$candidates = [];
foreach ($iterator as $file) {
if (!$file instanceof SplFileInfo || !$file->isFile()) {
continue;
}

$extension = strtolower($file->getExtension());
if (!in_array($extension, ['elp', 'elpx'], true)) {
continue;
}

$candidates[] = $file->getPathname();
}

sort($candidates);

$parsed = 0;
$skipped = 0;
$failures = [];

foreach ($candidates as $path) {
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
$skipped++;
continue;
}

$hasProjectXml = $zip->locateName('content.xml') !== false
|| $zip->locateName('contentv3.xml') !== false;
$zip->close();

if (!$hasProjectXml) {
$skipped++;
continue;
}

try {
$inspection = ELPParser::inspect($path);
$parser = ELPParser::fromFile($path);

if (($inspection['title'] ?? '') !== $parser->getTitle()) {
throw new RuntimeException(
'Lightweight inspection and full parsing returned different titles.'
);
}

$parsed++;
fwrite(
STDOUT,
sprintf(
"PASS %s [%s]\n",
substr($path, strlen(rtrim($root, DIRECTORY_SEPARATOR)) + 1),
$parser->getPackageProfile()
)
);
} catch (Throwable $exception) {
$failures[] = [
'path' => $path,
'message' => $exception->getMessage(),
'class' => get_class($exception),
];

fwrite(
STDERR,
sprintf(
"FAIL %s: %s: %s\n",
$path,
get_class($exception),
$exception->getMessage()
)
);
}
}

fwrite(
STDOUT,
sprintf(
"\nUpstream corpus: %d candidates, %d parsed, %d skipped, %d failures.\n",
count($candidates),
$parsed,
$skipped,
count($failures)
)
);

exit($failures === [] ? 0 : 1);
Loading