'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") === [], '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---\nUpdated
"); $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