Files
bludit-github-sync/tests/import.php
T
interlope 3dfd367814 Add BluditGithubImport class for GitHub article import functionality
- Implemented methods for slug validation, path validation, scalar parsing, and document processing.
- Added link and image handling with safety checks for content.
- Introduced blob verification for GitHub responses and asset type validation.
- Created unit tests for the import functionality, covering various edge cases and ensuring robust error handling.
- Included tests for metadata preservation, asset handling, and concurrent edits.
2026-11-01 18:33:07 +01:00

235 lines
16 KiB
PHP

<?php
if (PHP_SAPI !== 'cli') { http_response_code(403); exit; }
// Run against an unmodified Bludit source checkout: BLUDIT_ROOT=/path/to/bludit php tests/import.php
error_reporting(E_ALL);
set_error_handler(function ($level, $message, $file, $line) {
if (!(error_reporting() & $level)) { return false; }
throw new ErrorException($message, 0, $level, $file, $line);
});
$root = getenv('BLUDIT_ROOT');
if (!$root || !is_file($root . '/bl-kernel/pages.class.php')) { throw new RuntimeException('Set BLUDIT_ROOT to a Bludit 3.x source checkout.'); }
$dir = sys_get_temp_dir() . '/bludit-import-test-' . bin2hex(random_bytes(8)) . '/';
foreach (['pages', 'uploads', 'workspace'] as $sub) { mkdir($dir . $sub, 0700, true); }
define('BLUDIT', true);
define('DS', DIRECTORY_SEPARATOR);
define('PATH_PAGES', $dir . 'pages/');
define('PATH_UPLOADS_PAGES', $dir . 'uploads/');
define('DOMAIN_UPLOADS_PAGES', 'https://example.test/bl-content/uploads/pages/');
define('FILENAME', 'index.txt');
define('DB_PAGES', $dir . 'pages.php');
define('DB_DATE_FORMAT', 'Y-m-d H:i:s');
define('CHARSET', 'UTF-8');
define('DEBUG_MODE', false);
define('LOG_SEP', ' | ');
define('LOG_TYPE_ERROR', 'ERROR');
define('LOG_TYPE_INFO', 'INFO');
define('CHECK_SYMBOLIC_LINKS', true);
define('EXTREME_FRIENDLY_URL', false);
define('DIR_PERMISSIONS', 0755);
define('ORDER_BY', 'date');
foreach (['text', 'sanitize', 'filesystem', 'valid', 'date', 'session'] as $name) { require $root . '/bl-kernel/helpers/' . $name . '.class.php'; }
class Log { public static function set(...$args) {} }
require $root . '/bl-kernel/abstract/dbjson.class.php';
require $root . '/bl-kernel/pages.class.php';
require $root . '/bl-kernel/pagex.class.php';
require $root . '/bl-kernel/functions.php';
class Plugin {
public $dbFields = [];
public $config = ['owner' => 'owner', 'repo' => 'blog', 'branch' => 'articles/new', 'path' => '', 'token' => 'test-token', 'exportPublishedAuto' => true];
public function getValue($key) { return $this->config[$key] ?? ''; }
public function workspace() { global $dir; return $dir . 'workspace/'; }
public function post() {}
}
require dirname(__DIR__) . '/plugin.php';
class TestIndex { public $count = 0; public function exists($key) { return in_array($key, ['network', 'uncategorized'], true); } public function reindex() { $this->count++; return true; } }
class TestLog { public function add($args) {} }
class Theme {
public static $fail = false;
public static function plugins($hook, $args) {
global $plugin;
$plugin->$hook(...$args);
if (self::$fail) { throw new RuntimeException('Injected failure in a different page hook'); }
}
}
class TestPlugin extends pluginBluditGithub {
public $requests = [];
public $blobs = [];
public $tree = [];
public $failBlob = null;
public function fixture($slug, $raw, $assets = []) {
$this->tree = []; $this->blobs = [];
foreach (['content/index.md' => $raw] + $assets as $path => $data) {
$sha = sha1('blob ' . strlen($data) . "\0" . $data);
$this->blobs[$sha] = ['encoding' => 'base64', 'content' => base64_encode($data)];
$this->tree[] = ['path' => ($this->config['path'] ? $this->config['path'] . '/' : '') . $slug . '/' . $path, 'mode' => '100644', 'type' => 'blob', 'size' => strlen($data), 'sha' => $sha];
}
}
protected function githubRequest($method, $endpoint, $data = null) {
$this->requests[] = [$method, $endpoint, $data];
if (strpos($endpoint, '/commits/') === 0) { return ['sha' => str_repeat('a', 40), 'commit' => ['tree' => ['sha' => str_repeat('b', 40)]]]; }
if (strpos($endpoint, '/git/trees/') === 0) { return ['tree' => $this->tree, 'truncated' => false]; }
if (strpos($endpoint, '/git/blobs/') === 0) {
$sha = substr($endpoint, 11);
return $sha === $this->failBlob ? false : ($this->blobs[$sha] ?? false);
}
if ($method === 'GET') { return null; }
return ['content' => ['sha' => str_repeat('c', 40)]];
}
public function invoke($name, ...$args) { $m = new ReflectionMethod(pluginBluditGithub::class, $name); if (PHP_VERSION_ID < 80100) { $m->setAccessible(true); } return $m->invoke($this, ...$args); }
}
function check($ok, $message) { if (!$ok) { throw new RuntimeException($message); } }
function rejects($fn, $contains) {
try { $fn(); } catch (Throwable $e) { check(strpos($e->getMessage(), $contains) !== false, 'Unexpected error: ' . $e->getMessage()); return; }
throw new RuntimeException('Expected rejection containing: ' . $contains);
}
function document($slug, $body, $extra = '') {
return "---\ntitle: \"Article & test\"\nslug: $slug\ndate: 2026-01-01\nstatus: published\ntags:\n - homelab\n - \"quote: test\"\ncategory: network\ndescription: \"Example\"\n$extra---\n\n$body";
}
$pages = new Pages(); $categories = new TestIndex(); $tags = new TestIndex(); $syslog = new TestLog();
$site = new class { public function sortBy() { return 'date'; } public function uriFilters($unused = null) { return ''; } };
$L = new class { public function g($s) { return $s; } public function getunicodeChars() { return []; } public function currentLanguage() { return 'en'; } };
Session::set('username', 'admin');
$plugin = new TestPlugin();
try {
// Parser and filesystem boundaries.
foreach (['../bad', 'a/b', '', 'x%2fy', 'x\\y'] as $slug) { rejects(function () use ($slug) { BluditGithubImport::slug($slug); }, 'folder name'); }
rejects(function () { BluditGithubImport::document(document('one', 'body', "title: duplicate\n"), 'one'); }, 'duplicate');
rejects(function () { BluditGithubImport::document(str_replace('2026-01-01', '2026-02-30', document('one', 'body')), 'one'); }, 'valid YYYY');
rejects(function () { BluditGithubImport::document(document('wrong', 'body'), 'one'); }, 'slug must match');
rejects(function () { BluditGithubImport::document("---\ntitle: !php/object\n---\nbody", 'one'); }, 'Unsupported YAML');
rejects(function () { BluditGithubImport::links('<img src="../img/../../config.php">'); }, 'path segment');
rejects(function () { BluditGithubImport::links('[asset](../files/x%2ephp)'); }, 'Unsafe');
rejects(function () { BluditGithubImport::extension('img/image.svg'); }, 'Unsupported asset type');
check(BluditGithubImport::links('<img src="https://untrusted.test/x.png">') === [], 'External assets must not be fetched');
check(BluditGithubImport::links("```html\n<img src=\"../img/example.png\">\n```\n<code>![example](../img/code.png)</code>") === [], 'Do not import literal code examples');
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jGZkAAAAASUVORK5CYII=');
$body = '<p>Hello</p><img src="../img/picture.png"><a href="../files/log.txt">Log</a>';
$plugin->fixture('article', document('article', $body), ['img/picture.png' => $png, 'files/log.txt' => 'deployment log']);
$plugin->invoke('previewImport', 'article', true);
check(!$pages->exists('article'), 'Preview must not create a page');
check(glob(PATH_UPLOADS_PAGES . '*') === [], 'Preview must not write uploads');
$plan = Session::get('bluditGithubImport');
check(strpos($plugin->form(), '&lt;p&gt;Hello') !== false, 'Preview must escape incoming HTML');
check($plugin->requests[0][1] === '/commits/articles%2Fnew', 'Branch names with slash must be encoded');
$plugin->invoke('confirmImport', $plan['confirmation']);
$page = new Page('article');
check($page->getValue('type') === 'draft', 'Default import must be a draft');
check($page->getValue('category') === 'network', 'Category must be imported');
check(strpos($page->contentRaw(), '../img/') === false, 'Image links must be rewritten');
check(strpos($page->contentRaw(), DOMAIN_UPLOADS_PAGES . $page->uuid()) !== false, 'Use actual page UUID in uploads');
check(count(glob(PATH_UPLOADS_PAGES . $page->uuid() . '/gh-*')) === 2, 'Install image and attachment');
check($categories->count > 0 && $tags->count > 0, 'Use core indexing functions');
check(count(array_filter($plugin->requests, function ($r) { return $r[0] !== 'GET'; })) === 0, 'Import must not push through export hooks');
check(Session::get('bluditGithubImport') === false, 'Successful confirmation must be single-use');
$exported = $plugin->invoke('buildMarkdown', $page, 'article', $page->contentRaw());
check(strpos($exported, '../img/picture.png') !== false && strpos($exported, '../files/log.txt') !== false, 'Round-trip original repository paths');
check(strpos($exported, 'status: draft') !== false, 'Export uses Bludit type, not missing status field');
$roundTripMeta = BluditGithubImport::document($exported, 'article');
check($roundTripMeta['meta']['title'] === 'Article & test', 'Do not accumulate HTML entities in exported titles');
$plugin->invoke('exportImages', 'article', $page->contentRaw());
$puts = array_filter($plugin->requests, function ($r) { return $r[0] === 'PUT'; });
check(count($puts) === 2, 'Export uploads both images and attachments');
check(strpos(json_encode($puts), 'files\\/log.txt') !== false, 'Attachment stays in files on export');
// Existing page update, backup, default metadata preservation and author retention.
$before = new Page('article');
$plugin->requests = [];
$plugin->fixture('article', "---\ntitle: Updated\nslug: article\nstatus: \n---\n<p>Updated</p>");
$plugin->invoke('previewImport', 'article', false);
$plan = Session::get('bluditGithubImport');
$plugin->invoke('confirmImport', $plan['confirmation']);
$page = new Page('article');
check($page->title() === 'Updated' && $page->uuid() === $before->uuid(), 'Update same page, keep UUID');
check($page->getValue('username') === 'admin' && $page->getValue('category') === 'network', 'Preserve omitted fields');
check($page->getValue('type') === 'draft', 'Legacy blank status preserves existing type');
$backups = glob($plugin->workspace() . 'backup-*.php');
check(count($backups) === 1 && strpos(file_get_contents($backups[0]), '<?php http_response_code(403); exit; ?>') === 0, 'Protected backup before overwrite');
// Stale metadata written by another request, not only a changed content file.
$plugin->invoke('previewImport', 'article', true);
$plan = Session::get('bluditGithubImport');
$other = new Pages(); $other->edit(['key' => 'article', 'description' => 'concurrent edit']);
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'changed since preview');
$pages = new Pages();
check($pages->getPageDB('article')['description'] === 'concurrent edit', 'Concurrent metadata must survive');
$plugin->invoke('previewImport', 'article', true); $plan = Session::get('bluditGithubImport');
$plugin->config['branch'] = 'different';
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'settings changed');
$plugin->config['branch'] = 'articles/new';
$plugin->fixture('category', str_replace('category: network', 'category: missing-category', document('category', 'body')));
rejects(function () use ($plugin) { $plugin->invoke('previewImport', 'category', true); }, 'Create the category');
// Missing/unsafe assets and transport failures leave the current page alone.
$plugin->fixture('missing', document('missing', '<img src="../img/missing.png">'));
rejects(function () use ($plugin) { $plugin->invoke('previewImport', 'missing', true); }, 'Missing asset');
$plugin->fixture('symlink', document('symlink', 'body')); $plugin->tree[0]['mode'] = '120000';
rejects(function () use ($plugin) { $plugin->invoke('previewImport', 'symlink', true); }, 'Invalid Git blob');
$plugin->fixture('download', document('download', '<a href="../files/log.txt">file</a>'), ['files/log.txt' => 'log']);
$plugin->invoke('previewImport', 'download', true); $plan = Session::get('bluditGithubImport');
$plugin->failBlob = $plan['assets']['../files/log.txt']['sha'];
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'base64 blob');
check(!(new Pages())->exists('download'), 'Failed download must not create a page');
$plugin->failBlob = null;
// A later hook can fail after core already wrote the page: restore both DB and content.
$old = $plugin->invoke('importLocalState', 'article');
$plugin->fixture('article', document('article', 'replacement'));
$plugin->invoke('previewImport', 'article', true); $plan = Session::get('bluditGithubImport');
Theme::$fail = true;
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'Injected failure');
Theme::$fail = false;
check($plugin->invoke('importLocalState', 'article') === $old, 'Failed update must restore complete prior state');
$plugin->fixture('new-failure', document('new-failure', 'replacement'));
$plugin->invoke('previewImport', 'new-failure', true); $plan = Session::get('bluditGithubImport');
Theme::$fail = true;
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'Injected failure');
Theme::$fail = false;
check(!(new Pages())->exists('new-failure'), 'Failed creation must remove partial page');
// Covers and explicit publication work with path prefixes and keep the original repository path.
$plugin->config['path'] = 'articles';
$plugin->fixture('cover-page', document('cover-page', 'Body', "cover: ../img/cover.png\n"), ['img/cover.png' => $png]);
$plugin->invoke('previewImport', 'cover-page', false); $plan = Session::get('bluditGithubImport');
check($plan['effectiveType'] === 'published' && $plan['asDraft'] === false, 'Explicit published status shown in preview');
$plugin->invoke('confirmImport', $plan['confirmation']);
$page = new Page('cover-page');
check($page->getValue('type') === 'published' && strpos($page->coverImage(), 'gh-') !== false, 'Import cover and explicit publication');
$exported = $plugin->invoke('buildMarkdown', $page, 'cover-page', $page->contentRaw());
check(strpos($exported, 'cover: "../img/cover.png"') !== false, 'Cover path round-trips');
$plugin->config['path'] = '';
$plugin->fixture('invalid-image', document('invalid-image', '<img src="../img/fake.png">'), ['img/fake.png' => '<?php echo "bad";']);
$plugin->invoke('previewImport', 'invalid-image', true); $plan = Session::get('bluditGithubImport');
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'Invalid image');
check(!(new Pages())->exists('invalid-image'), 'Disguised executable must not be imported');
$plugin->fixture('expired', document('expired', 'body'));
$plugin->invoke('previewImport', 'expired', true); $plan = Session::get('bluditGithubImport');
$plan['created'] = time() - 1801; Session::set('bluditGithubImport', $plan);
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'expired');
// Optional end-to-end fixture using an actual exported article directory.
if (($articleDir = getenv('BLOG_ARTICLE_DIR'))) {
$slug = basename(rtrim($articleDir, '/'));
$raw = file_get_contents($articleDir . '/content/index.md');
$parsed = BluditGithubImport::document($raw, $slug);
$fixtureAssets = [];
foreach (BluditGithubImport::links($parsed['content']) as $relative) {
$fixtureAssets[$relative] = file_get_contents($articleDir . '/' . $relative);
}
$plugin->fixture($slug, $raw, $fixtureAssets);
$plugin->invoke('previewImport', $slug, true); $plan = Session::get('bluditGithubImport');
$plugin->invoke('confirmImport', $plan['confirmation']);
$actual = new Page($slug);
check($actual->getValue('type') === 'draft', 'Actual article remains draft in test');
$roundTrip = $plugin->invoke('buildMarkdown', $actual, $slug, $actual->contentRaw());
foreach ($fixtureAssets as $relative => $data) {
check(strpos($roundTrip, '../' . $relative) !== false, 'Actual article asset round-trip: ' . $relative);
}
echo 'PASS: actual article, ' . count($fixtureAssets) . " referenced assets imported and round-tripped\n";
}
echo "PASS: parser, preview, create/update with real Bludit Pages, images, attachments, export round-trip, stale previews, failed downloads and rollback\n";
} finally {
Filesystem::deleteRecursive($dir);
}