'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() {} } $declaredBeforePlugin = get_declared_classes(); require dirname(__DIR__) . '/plugin.php'; if (array_values(array_diff(get_declared_classes(), $declaredBeforePlugin)) !== ['pluginBluditGithub']) { throw new RuntimeException('Only the plugin class may be declared during Bludit discovery.'); } 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'); $users = new class { public function keys() { return ['admin', 'spleenftw']; } public function exists($name) { return in_array($name, $this->keys(), true); } }; $plugin = new TestPlugin(); $plugin->init(); 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(''); }, 'path segment'); rejects(function () { BluditGithubImport::links('[asset](../files/x%2ephp)'); }, 'Unsafe'); rejects(function () { BluditGithubImport::extension('img/image.svg'); }, 'Unsupported asset type'); check(BluditGithubImport::links('') === [], 'External assets must not be fetched'); check(BluditGithubImport::links("```html\n\n```\n![example](../img/code.png)") === [], 'Do not import literal code examples'); $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jGZkAAAAASUVORK5CYII='); $body = '

Hello

Log'; $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(), '<p>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

Updated

"); $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]), '') === 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', '')); 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', 'file'), ['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/fake.png' => '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'); // New pages must use the chosen author despite createPage() forcing the session user. $plugin->fixture('author-new', document('author-new', 'body')); $plugin->requests = []; $plugin->invoke('previewImport', 'author-new', true, 'spleenftw'); $plan = Session::get('bluditGithubImport'); check($plan['args']['username'] === 'spleenftw' && strpos($plugin->form(), '>Author
spleenftw') !== false, 'Preview selected author'); $plugin->invoke('confirmImport', $plan['confirmation']); check((new Page('author-new'))->username() === 'spleenftw', 'New import uses selected author'); check(Session::get('username') === 'admin', 'Do not impersonate the selected author'); check(count(array_filter($plugin->requests, function ($r) { return $r[0] !== 'GET'; })) === 0, 'Author adjustment must not trigger exports'); $exported = $plugin->invoke('buildMarkdown', new Page('author-new'), 'author-new', 'body'); check(BluditGithubImport::document($exported, 'author-new')['meta']['author'] === 'spleenftw', 'Export the actual author'); // Repair an existing admin-authored page without changing its UUID or body intent. $plugin->fixture('article', document('article', 'corrected article')); $oldUuid = (new Page('article'))->uuid(); $plugin->invoke('previewImport', 'article', true, 'spleenftw'); $plan = Session::get('bluditGithubImport'); $plugin->invoke('confirmImport', $plan['confirmation']); check((new Page('article'))->username() === 'spleenftw' && (new Page('article'))->uuid() === $oldUuid, 'Update existing author in place'); $plugin->invoke('previewImport', 'article', true); $plan = Session::get('bluditGithubImport'); check($plan['args']['username'] === 'spleenftw', 'Legacy documents without author preserve existing author'); $plugin->fixture('author-meta', document('author-meta', 'body', "author: spleenftw\n")); $plugin->invoke('previewImport', 'author-meta', true); $plan = Session::get('bluditGithubImport'); $plugin->invoke('confirmImport', $plan['confirmation']); check((new Page('author-meta'))->username() === 'spleenftw', 'Repository author survives creation'); $plugin->fixture('author-bad', document('author-bad', 'body', "author: unknown\n")); rejects(function () use ($plugin) { $plugin->invoke('previewImport', 'author-bad', true); }, 'author does not exist'); $plugin->invoke('previewImport', 'author-bad', true, 'spleenftw'); check(Session::get('bluditGithubImport')['args']['username'] === 'spleenftw', 'A valid explicit selection overrides an unavailable repository author'); echo "PASS: selected/repository authors on create and update, author export, no impersonation or export loop\n"; // 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); }