diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5aac826..2a66a24 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -108,7 +108,7 @@ jobs:
TIGER_TEST_DB_PASS: root
# The floor we enforce today (report-only warning above it). Bump this as coverage climbs so it
# can only go UP — the ratchet that gets us to 90% without ever regressing.
- MIN_COVERAGE: '66'
+ MIN_COVERAGE: '72'
steps:
- uses: actions/checkout@v4
diff --git a/tests/COVERAGE-PLAN.md b/tests/COVERAGE-PLAN.md
index 0ba76ad..6a19438 100644
--- a/tests/COVERAGE-PLAN.md
+++ b/tests/COVERAGE-PLAN.md
@@ -626,6 +626,30 @@ blog/signup/search/schedule controllers 90-100%). ~167 tests; combined suite **1
`vendor/` swap on a dev box (covered at guard level only); `is_uploaded_file()` uploads; live-model agent turns;
render-only `.phtml` leaves; `pcov` under-reports module `elements()` forms (multi-line-array-literal artifact).
+### Wave 7 — reachable kernel + module remainders (LANDED 2026-07-25) → coverage 70.0% → 74.4%
+4 parallel agents mopping up reachable-but-uncovered branches (commit-and-push-incrementally, after a
+session-limit restart wiped the first attempt's exploration). 177 tests; combined suite green. Moved:
+`Tiger_Backup` 47→82 · `Controller_Plugin_Authorization` 43→88 · `Cms_Renderer` 69→100 · `Location` 79→95 ·
+`Code_Runtime` 82→90 · `Vendor` 80→87 · `Module_Installer` 68→74 · `Acl` 89→93 · `License_Checker` 82→91 ·
+Media cluster 40→75 (Storage 100, Filesystem 97, Azure/Gcs/ClamAv/Rekognition unlocked from 0 — they were
+exercised but not `#[CoversClass]`-attributed) · `Tiger_Application` 60→74 · `Vendor_Environment` 0→84 ·
+`Update_Composer` 0→57 · `Google_Analytics` 29→47 · `Compat`→100 · Seo/Code/Backup module services →95-97.
+Network classes covered WITHOUT network (dead-localhost curl → fail-soft, primed caches, probe subclasses,
+`_run` with echo/exit). No behavioral bugs. CI floor 66 → 72.
+- **Minor robustness finding (characterized, not fixed):** `Tiger_Media_Storage_Azure::put()/write()` surface a
+ raw `\Error` (not the friendly `RuntimeException` S3/GCS give) when the Azure SDK is absent — `_create()`
+ constructs the SDK class BEFORE its guarded try/catch. Small consistency fix available.
+
+### The path from 74.4% to 80% (~800 more covered lines)
+The remaining 3,673 uncovered is now dominated by genuinely-hard I/O: **kernel boot** (`Tiger_Application_Bootstrap::_init*`
++ `Application::run/boot`, ~200-400 lines — needs an IN-PROCESS APP-BOOT HARNESS, the one big reachable chunk),
+`is_uploaded_file()` uploads (profile/media/agent modules ~250), composer/`vendor`-swap (system/update ~200, UNSAFE
+to run), live network (google/analytics/authority ~200), ClamAv/Rekognition/AWS-SDK (~150). 80% requires the boot
+harness + mopping the last scattered branches; below that is functional/live-test territory, honestly out of unit/
+integration scope. Wave 8 = build the boot harness (a fixture app skeleton + `new Tiger_Application($root)` against
+the test DB) → cover the `_init*` cascade.
+
+### Next waves (priority order) — the drive to 90%
### Next waves (priority order) — the drive to 90%
### Next waves (priority order) — the drive to 90%
- **Wave 6 — the remaining MODULES:** `agent` module (462 @ 5% — needs a provider-adapter stub, but the `Tiger_Agent_*`
diff --git a/tests/Integration/Backup/BackupCoverageTest.php b/tests/Integration/Backup/BackupCoverageTest.php
new file mode 100644
index 0000000..d3fb01a
--- /dev/null
+++ b/tests/Integration/Backup/BackupCoverageTest.php
@@ -0,0 +1,390 @@
+priorConfig = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null;
+ $this->setBackupConfig([]); // a null log sink baseline; individual tests layer more on top
+ Tiger_Log::reset();
+ $this->sandbox = sys_get_temp_dir() . '/tiger-bk-' . bin2hex(random_bytes(6));
+ @mkdir($this->sandbox, 0777, true);
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ($this->artifacts as $f) { @unlink($f); }
+ $this->artifacts = [];
+ $this->rmrf($this->sandbox);
+ if ($this->priorConfig !== null) {
+ Zend_Registry::set('Zend_Config', $this->priorConfig);
+ } elseif (Zend_Registry::isRegistered('Zend_Config')) {
+ Zend_Registry::set('Zend_Config', new Zend_Config([]));
+ }
+ Tiger_Log::reset();
+ parent::tearDown();
+ }
+
+ /** Install a Zend_Config with a null log sink plus whatever `tiger` overrides a test needs. */
+ private function setBackupConfig(array $tiger): void
+ {
+ $tiger['log'] = ['writer' => 'null'];
+ Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => $tiger] + $this->mediaSlice($tiger)));
+ }
+
+ /** Pull an optional 'media' top-level slice out of a caller's overrides (disks live under media, not tiger). */
+ private function mediaSlice(array &$tiger): array
+ {
+ if (isset($tiger['__media'])) {
+ $media = $tiger['__media'];
+ unset($tiger['__media']);
+ return ['media' => $media];
+ }
+ return [];
+ }
+
+ private function localBackupDir(): string
+ {
+ return APPLICATION_ROOT . '/storage/backups';
+ }
+
+ private function rmrf(string $dir): void
+ {
+ if (!is_dir($dir)) { @unlink($dir); return; }
+ foreach (scandir($dir) ?: [] as $e) {
+ if ($e === '.' || $e === '..') { continue; }
+ $p = $dir . '/' . $e;
+ is_dir($p) && !is_link($p) ? $this->rmrf($p) : @unlink($p);
+ }
+ @rmdir($dir);
+ }
+
+ /** Invoke a protected static Tiger_Backup method; $args may carry references (e.g. _walk's &$files). */
+ private static function invoke(string $method, array $args)
+ {
+ $m = new ReflectionMethod(Tiger_Backup::class, $method); // PHP 8.1+: protected is invocable without setAccessible
+ return $m->invokeArgs(null, $args);
+ }
+
+ /** Seed a finished scheduled backup catalog row (rides the per-test txn). */
+ private function seedScheduled(): string
+ {
+ $model = new Tiger_Model_Backup();
+ $id = $model->begin('TigerBackup-sched-' . bin2hex(random_bytes(3)) . '.zip', 'local', ['database'], 'scheduled');
+ $model->finish($id, 'ok', ['storage_key' => 'nonexistent-' . $id . '.zip', 'size_bytes' => 1]);
+ return $id;
+ }
+
+ // ---- prune -----------------------------------------------------------------------------------
+
+ #[Test]
+ public function prune_removes_the_oldest_scheduled_backups_over_the_retention_max(): void
+ {
+ $this->setBackupConfig(['backup' => ['retention' => ['max' => '2']]]);
+ for ($i = 0; $i < 4; $i++) { $this->seedScheduled(); usleep(1000); }
+
+ $model = new Tiger_Model_Backup();
+ $this->assertCount(4, $model->prunable());
+
+ $removed = Tiger_Backup::prune();
+ $this->assertSame(2, $removed, 'the two oldest scheduled/unpinned backups are pruned to the max of 2');
+ $this->assertCount(2, $model->prunable(), 'exactly the max survives');
+ }
+
+ #[Test]
+ public function prune_is_a_noop_when_the_retention_max_is_zero_or_negative(): void
+ {
+ $this->setBackupConfig(['backup' => ['retention' => ['max' => '0']]]);
+ $this->seedScheduled();
+ $this->assertSame(0, Tiger_Backup::prune(), 'a max of 0 disables retention entirely');
+ }
+
+ // ---- runScheduled ----------------------------------------------------------------------------
+
+ #[Test]
+ public function run_scheduled_is_a_noop_when_the_schedule_is_disabled(): void
+ {
+ $this->setBackupConfig(['backup' => ['schedule' => ['enabled' => '0']]]);
+ $before = count((new Tiger_Model_Backup())->recent(100));
+
+ Tiger_Backup::runScheduled();
+
+ $after = count((new Tiger_Model_Backup())->recent(100));
+ $this->assertSame($before, $after, 'nothing runs when tiger.backup.schedule.enabled is off');
+ }
+
+ #[Test]
+ public function run_scheduled_creates_a_real_database_backup_when_enabled(): void
+ {
+ $this->setBackupConfig(['backup' => [
+ 'schedule' => ['enabled' => '1'],
+ 'components' => 'database',
+ 'disk' => 'local',
+ ]]);
+
+ Tiger_Backup::runScheduled();
+
+ // The scheduled ok row is present (rides the txn); its archive bytes are on the local disk.
+ $rows = array_values(array_filter(
+ (new Tiger_Model_Backup())->recent(100),
+ fn($r) => $r['source'] === 'scheduled' && $r['outcome'] === 'ok'
+ ));
+ $this->assertNotEmpty($rows, 'an enabled scheduled run produced a completed backup');
+ $archive = $this->localBackupDir() . '/' . $rows[0]['filename'];
+ $this->artifacts[] = $archive;
+ $this->assertFileExists($archive);
+ }
+
+ // ---- disks -----------------------------------------------------------------------------------
+
+ #[Test]
+ public function disks_lists_local_plus_only_the_configured_cloud_disks(): void
+ {
+ $this->setBackupConfig(['__media' => ['disks' => [
+ 'local' => ['adapter' => 'filesystem'], // the local FS disk — never a "cloud" destination
+ 'archive' => ['adapter' => 'local'], // also excluded (local synonym)
+ 's3backup' => ['adapter' => 's3'], // a real cloud disk — included
+ ]]]);
+
+ $disks = Tiger_Backup::disks();
+ $names = array_column($disks, 'name');
+
+ $this->assertContains('local', $names, 'the local server is always a destination');
+ $this->assertContains('s3backup', $names, 'a configured cloud disk is offered');
+ $this->assertNotContains('archive', $names, 'a filesystem/local-adapter disk is not a cloud destination');
+ // the label reflects the adapter, upper-cased.
+ $s3 = $disks[array_search('s3backup', $names, true)];
+ $this->assertStringContainsString('S3', $s3['label']);
+ }
+
+ // ---- restore guards --------------------------------------------------------------------------
+
+ #[Test]
+ public function restore_errors_when_the_archive_file_is_missing(): void
+ {
+ $res = Tiger_Backup::restore($this->sandbox . '/nope.zip');
+ $this->assertSame('error', $res['status']);
+ $this->assertStringContainsString('not found', strtolower($res['error']));
+ }
+
+ #[Test]
+ public function restore_errors_when_the_archive_has_no_manifest(): void
+ {
+ $zip = $this->sandbox . '/no-manifest.zip';
+ Tiger_Backup_Archive::build($zip, [['name' => 'random.txt', 'data' => 'x']]);
+
+ $res = Tiger_Backup::restore($zip);
+ $this->assertSame('error', $res['status']);
+ $this->assertStringContainsString('manifest', strtolower($res['error']));
+ }
+
+ #[Test]
+ public function restore_errors_when_no_requested_component_is_present_in_the_archive(): void
+ {
+ $zip = $this->manifestOnlyArchive(['database']);
+ $res = Tiger_Backup::restore($zip, ['media']); // ask for media; archive only has database
+ $this->assertSame('error', $res['status']);
+ $this->assertStringContainsString('nothing', strtolower($res['error']));
+ }
+
+ // ---- restore: a SAFE full pass (nothing destructive to apply) --------------------------------
+
+ #[Test]
+ public function restore_runs_end_to_end_and_takes_a_safety_backup(): void
+ {
+ // A manifest declaring only DATABASE, but no database.sql in the archive → the restore walks the
+ // whole guarded path (maintenance flag → safety backup → extract → nothing to import → ok) with
+ // nothing to actually overwrite. This exercises the destructive machinery, non-destructively.
+ $zip = $this->manifestOnlyArchive(['database']);
+
+ $res = Tiger_Backup::restore($zip, [], ['safety' => true]);
+
+ $this->assertSame('ok', $res['status'], json_encode($res));
+ $this->assertSame([], $res['restored'], 'no component had bytes to restore');
+ $this->assertNotNull($res['safety_id'], 'a pre-restore safety backup was taken');
+
+ // Clean up the real safety-backup archive the pass wrote to the local disk.
+ $row = (new Tiger_Model_Backup())->findById($res['safety_id']);
+ if ($row && !empty($row['filename'])) { $this->artifacts[] = $this->localBackupDir() . '/' . $row['filename']; }
+ }
+
+ #[Test]
+ public function restore_returns_an_error_when_the_database_import_is_rejected(): void
+ {
+ // Craft an archive whose database.sql carries the required token header but a syntactically broken
+ // statement, so Tiger_Backup_Database::import() throws and restore() reports the failure. The bad
+ // statement is a harmless (failed) SELECT — no schema or data is touched.
+ $token = 'abc123';
+ $sep = "\n-- @" . $token . "@\n";
+ $sql = "-- TigerBackup dump\n-- TIGER_STMT_TOKEN: {$token}\n" . $sep . "SELECT bad ) syntax here;" . $sep;
+
+ $zip = $this->sandbox . '/bad-db.zip';
+ Tiger_Backup_Archive::build($zip, [
+ ['name' => 'manifest.json', 'data' => json_encode(['components' => ['database']])],
+ ['name' => 'database.sql', 'data' => $sql],
+ ]);
+
+ $res = Tiger_Backup::restore($zip, ['database'], ['safety' => false]);
+ $this->assertSame('error', $res['status'], 'a failed DB import surfaces as an error');
+ $this->assertNotEmpty($res['error']);
+ }
+
+ // ---- fetchToTemp -----------------------------------------------------------------------------
+
+ #[Test]
+ public function fetch_to_temp_returns_a_local_archive_in_place(): void
+ {
+ $dir = $this->localBackupDir();
+ @mkdir($dir, 0775, true);
+ $fn = 'TigerBackup-fetch-' . bin2hex(random_bytes(4)) . '.zip';
+ $path = $dir . '/' . $fn;
+ file_put_contents($path, 'zip-bytes');
+ $this->artifacts[] = $path;
+
+ $got = Tiger_Backup::fetchToTemp(['disk' => 'local', 'storage_key' => $fn, 'filename' => $fn]);
+ $this->assertSame($path, $got, 'a local backup is returned in place, not copied');
+ }
+
+ #[Test]
+ public function fetch_to_temp_throws_when_a_local_archive_is_missing(): void
+ {
+ $this->expectException(RuntimeException::class);
+ Tiger_Backup::fetchToTemp(['disk' => 'local', 'storage_key' => 'not-here-' . bin2hex(random_bytes(4)) . '.zip', 'filename' => 'x.zip']);
+ }
+
+ // ---- internal helpers (reflection, throwaway temp dirs) --------------------------------------
+
+ #[Test]
+ public function walk_maps_files_honoring_excludes_symlinks_and_ds_store(): void
+ {
+ $root = $this->sandbox;
+ mkdir($root . '/keep', 0777, true);
+ mkdir($root . '/skipme', 0777, true);
+ file_put_contents($root . '/keep/a.txt', 'a');
+ file_put_contents($root . '/keep/.DS_Store', 'junk'); // must be skipped
+ file_put_contents($root . '/skipme/b.txt', 'b'); // excluded dir
+ file_put_contents($root . '/top.txt', 'c');
+ if (!@symlink($root . '/keep/a.txt', $root . '/link.txt')) { /* symlinks may be unavailable; fine */ }
+
+ $files = [];
+ $args = [$root, $root, [$root . '/skipme'], &$files];
+ self::invoke('_walk', $args);
+
+ $keys = array_keys($files);
+ $this->assertContains('files/keep/a.txt', $keys);
+ $this->assertContains('files/top.txt', $keys);
+ $this->assertNotContains('files/keep/.DS_Store', $keys, '.DS_Store is skipped');
+ $this->assertNotContains('files/skipme/b.txt', $keys, 'an excluded directory is skipped');
+ $this->assertNotContains('files/link.txt', $keys, 'symlinks are never followed');
+ }
+
+ #[Test]
+ public function copy_tree_mirrors_a_directory_recursively(): void
+ {
+ $src = $this->sandbox . '/src';
+ $dst = $this->sandbox . '/dst';
+ mkdir($src . '/sub', 0777, true);
+ mkdir($dst, 0777, true); // _copyTree copies src/* INTO an existing dst (as restore() sets up)
+ file_put_contents($src . '/root.txt', 'r');
+ file_put_contents($src . '/sub/child.txt', 'c');
+
+ self::invoke('_copyTree', [$src, $dst]);
+
+ $this->assertSame('r', file_get_contents($dst . '/root.txt'));
+ $this->assertSame('c', file_get_contents($dst . '/sub/child.txt'));
+ }
+
+ #[Test]
+ public function copy_tree_is_a_noop_for_a_missing_source(): void
+ {
+ self::invoke('_copyTree', [$this->sandbox . '/does-not-exist', $this->sandbox . '/out']);
+ $this->assertDirectoryDoesNotExist($this->sandbox . '/out');
+ }
+
+ #[Test]
+ public function rrmdir_removes_a_whole_tree(): void
+ {
+ $dir = $this->sandbox . '/tree';
+ mkdir($dir . '/a/b', 0777, true);
+ file_put_contents($dir . '/a/x.txt', 'x');
+ file_put_contents($dir . '/a/b/y.txt', 'y');
+
+ self::invoke('_rrmdir', [$dir]);
+ $this->assertDirectoryDoesNotExist($dir);
+ }
+
+ #[Test]
+ public function cfg_returns_the_default_for_a_non_scalar_or_absent_node(): void
+ {
+ $this->setBackupConfig(['backup' => ['retention' => ['max' => '9']]]);
+ // A section node (non-scalar) → default; a scalar leaf → its string value; an absent key → default.
+ $this->assertSame('fallback', self::invoke('_cfg', ['tiger.backup.retention', 'fallback']));
+ $this->assertSame('9', self::invoke('_cfg', ['tiger.backup.retention.max', 'x']));
+ $this->assertSame('def', self::invoke('_cfg', ['tiger.backup.nope.missing', 'def']));
+ }
+
+ #[Test]
+ public function notify_returns_early_when_enabled_but_no_recipient_is_configured(): void
+ {
+ // notify explicitly on (via opts), but the email list is empty → the send body is skipped, no mail.
+ $this->setBackupConfig(['backup' => ['notify' => ['email' => '']]]);
+ self::invoke('_notify', [true, 'TigerBackup-x.zip', ['size' => 1, 'components' => ['database']], ['notify' => true]]);
+ $this->assertTrue(true, 'no recipient → _notify is a safe no-op (no exception, no mail)');
+ }
+
+ // ---- helpers ---------------------------------------------------------------------------------
+
+ /** Build a valid TigerBackup archive holding ONLY a manifest.json declaring $components. */
+ private function manifestOnlyArchive(array $components): string
+ {
+ $zip = $this->sandbox . '/manifest-only-' . bin2hex(random_bytes(3)) . '.zip';
+ Tiger_Backup_Archive::build($zip, [
+ ['name' => 'manifest.json', 'data' => json_encode(['components' => $components, 'created_at' => date('c')])],
+ ]);
+ return $zip;
+ }
+}
diff --git a/tests/Integration/Backup/BackupServiceRestoreTest.php b/tests/Integration/Backup/BackupServiceRestoreTest.php
new file mode 100644
index 0000000..8751289
--- /dev/null
+++ b/tests/Integration/Backup/BackupServiceRestoreTest.php
@@ -0,0 +1,74 @@
+getResponse();
+ }
+
+ private function messages(object $res): string
+ {
+ return json_encode($res->messages ?? []);
+ }
+
+ /** A cataloged, `ok`, local-disk backup whose archive file is absent on disk. */
+ private function ghostBackup(): string
+ {
+ $model = new Tiger_Model_Backup();
+ $id = $model->begin('TigerBackup-ghost.zip', 'local', ['database'], 'manual');
+ // Mark it ok with a storage_key that resolves to a non-existent local file.
+ $model->finish($id, 'ok', ['storage_key' => 'w7-does-not-exist/TigerBackup-ghost.zip', 'size_bytes' => 10]);
+ return $id;
+ }
+
+ #[Test]
+ public function restoring_a_cataloged_backup_whose_file_is_missing_fails_cleanly(): void
+ {
+ $this->loginAs('admin');
+ $id = $this->ghostBackup();
+
+ $res = $this->dispatch(['action' => 'restore', 'backup_id' => $id, 'confirm' => 'RESTORE']);
+
+ $this->assertSame(0, (int) $res->result, 'a missing archive fails the restore, not crashes it');
+ // Non-production surfaces the underlying reason; either way it never performed a restore.
+ $this->assertMatchesRegularExpression('/Restore failed|missing/i', $this->messages($res));
+
+ // The catalog row is untouched — restore of a broken archive is a no-op on the record.
+ $row = (new Tiger_Model_Backup())->findById($id);
+ $this->assertSame('ok', $row['outcome']);
+ }
+
+ #[Test]
+ public function restore_still_requires_the_typed_confirmation_even_for_a_valid_row(): void
+ {
+ // The guard arm alongside the execution arm: a real `ok` row but the wrong confirmation word.
+ $this->loginAs('admin');
+ $id = $this->ghostBackup();
+
+ $res = $this->dispatch(['action' => 'restore', 'backup_id' => $id, 'confirm' => 'yes']);
+ $this->assertSame(0, (int) $res->result, 'the destructive action needs confirm === RESTORE');
+ $this->assertStringContainsString('confirm', $this->messages($res));
+ }
+}
diff --git a/tests/Integration/Cms/RendererLayoutTest.php b/tests/Integration/Cms/RendererLayoutTest.php
new file mode 100644
index 0000000..6c8ef79
--- /dev/null
+++ b/tests/Integration/Cms/RendererLayoutTest.php
@@ -0,0 +1,65 @@
+content` — the CMS's page-in-layout
+ * composition. Needs the DB (fetchByKey), so it's an integration test; the seeded layout row rides the
+ * per-test transaction.
+ */
+#[CoversClass(Tiger_Cms_Renderer::class)]
+final class RendererLayoutTest extends IntegrationTestCase
+{
+ #[Test]
+ public function render_wraps_the_body_in_its_resolved_layout(): void
+ {
+ // A phtml layout that frames whatever `content` it's handed.
+ (new Tiger_Model_Page())->insert([
+ 'type' => Tiger_Model_Page::TYPE_LAYOUT,
+ 'locale' => 'en',
+ 'org_id' => '',
+ 'title' => 'W7 Layout',
+ 'slug' => 'w7-layout',
+ 'page_key' => 'w7layout',
+ 'body' => '
Hello
'; + $page->format = Tiger_Model_Page::FORMAT_HTML; + $page->layout_key = 'w7layout'; + $page->locale = 'en'; + $page->org_id = ''; + + $out = (new Tiger_Cms_Renderer())->render($page); + $this->assertSame('Hello
Orphan
'; + $page->format = Tiger_Model_Page::FORMAT_HTML; + $page->layout_key = 'w7-nonexistent-layout'; + $page->locale = 'en'; + $page->org_id = ''; + + $this->assertSame('Orphan
', (new Tiger_Cms_Renderer())->render($page)); + } +} diff --git a/tests/Integration/Code/CodeServiceConflictTest.php b/tests/Integration/Code/CodeServiceConflictTest.php new file mode 100644 index 0000000..3662049 --- /dev/null +++ b/tests/Integration/Code/CodeServiceConflictTest.php @@ -0,0 +1,185 @@ +moduleDir = APPLICATION_PATH . '/modules/' . self::SLUG; + $snip = $this->moduleDir . '/snippets'; + @mkdir($snip, 0777, true); + file_put_contents($snip . '/dupa.php', "rmrf($this->moduleDir); + $dir = APPLICATION_ROOT . '/storage/cache/code'; + foreach (glob($dir . '/global.*.php') ?: [] as $f) { @unlink($f); } + foreach (glob($dir . '/inject.global.*.php') ?: [] as $f) { @unlink($f); } + parent::tearDown(); + } + + private function rmrf(string $dir): void + { + if (!is_dir($dir)) { @unlink($dir); return; } + foreach (scandir($dir) as $e) { + if ($e === '.' || $e === '..') { continue; } + $p = $dir . '/' . $e; + is_dir($p) && !is_link($p) ? $this->rmrf($p) : @unlink($p); + } + @rmdir($dir); + } + + private function call(string $action, array $params = []): object + { + return (new Code_Service_Code(['action' => $action] + $params))->getResponse(); + } + + /** Insert an ACTIVE local PHP snippet defining an unconditional top-level function. */ + private function seedActive(string $name, string $fn): string + { + return (new Tiger_Model_Code())->insert([ + 'org_id' => '', + 'name' => $name, + 'language' => Tiger_Model_Code::LANG_PHP, + 'code' => "function {$fn}() { return 1; }", + 'run_location' => Tiger_Model_Code::LOC_GLOBAL, + 'active' => 1, + 'status' => Tiger_Model_Code::STATUS_ACTIVE, + ]); + } + + // ----- SAVE that conflicts with the running set --------------------------------------------- + + #[Test] + public function saving_a_snippet_that_redeclares_an_active_function_is_stored_but_not_activated(): void + { + $this->loginAs('superadmin'); + $this->seedActive('First', 'w7dup'); // already active + in the bundle + + // A second active snippet redeclaring w7dup — lints fine alone, but the assembled bundle can't. + $res = $this->call('save', [ + 'name' => 'Second', + 'language' => Tiger_Model_Code::LANG_PHP, + 'code' => 'function w7dup() { return 2; }', + 'active' => '1', + 'priority' => '100', + ]); + + $this->assertSame(0, (int) $res->result, 'the conflicting save is not fully applied'); + $this->assertStringContainsString('Saved, but not activated', json_encode($res->messages)); + // The conflict path errors without a data payload — but the row WAS persisted (save ran before + // the rebuild), and the self-heal flagged it off so the last-good set stays live. + $active = $this->db->fetchOne('SELECT active FROM code WHERE name = ?', ['Second']); + $this->assertNotFalse($active, 'the snippet was still persisted'); + $this->assertSame(0, (int) $active, 'deactivated by the self-heal'); + } + + // ----- ACTIVATE that conflicts with the running set ----------------------------------------- + + #[Test] + public function activating_a_local_snippet_that_conflicts_is_refused_and_self_heals(): void + { + $this->loginAs('superadmin'); + $this->seedActive('Alpha', 'w7dup'); + + // Beta redeclares w7dup but starts inactive; turning it on makes the union fail to compile. + $beta = (new Tiger_Model_Code())->insert([ + 'org_id' => '', 'name' => 'Beta', 'language' => Tiger_Model_Code::LANG_PHP, + 'code' => 'function w7dup() { return 9; }', 'run_location' => Tiger_Model_Code::LOC_GLOBAL, + 'active' => 0, 'status' => Tiger_Model_Code::STATUS_DRAFT, + ]); + + $res = $this->call('activate', ['code_id' => $beta]); + $this->assertSame(0, (int) $res->result, 'a conflicting activate is refused'); + $this->assertStringContainsString('conflicts with the running set', json_encode($res->messages)); + $this->assertSame(0, (int) $this->db->fetchOne('SELECT active FROM code WHERE code_id = ?', [$beta]), 'left inactive by the self-heal'); + } + + // ----- MODULE-snippet toggle that conflicts (config flag rolled back) ------------------------ + + #[Test] + public function activating_a_conflicting_module_snippet_rolls_back_the_config_flag(): void + { + $this->loginAs('superadmin'); + $keyA = self::SLUG . '/dupa'; + $keyB = self::SLUG . '/dupb'; + + $onA = $this->call('activate', ['code_id' => 'module:' . $keyA]); + $this->assertSame(1, (int) $onA->result, 'the first module snippet activates cleanly'); + $this->assertTrue(Tiger_Code_Modules::isActive($keyA)); + + // The second redeclares w7mdup → the union won't compile → the flag is rolled back off. + $onB = $this->call('activate', ['code_id' => 'module:' . $keyB]); + $this->assertSame(0, (int) $onB->result, 'the conflicting module snippet is refused'); + $this->assertStringContainsString('conflicts with the running set', json_encode($onB->messages)); + $this->assertFalse(Tiger_Code_Modules::isActive($keyB), 'its active-set flag was rolled back'); + $this->assertTrue(Tiger_Code_Modules::isActive($keyA), 'the first snippet stays active'); + } + + // ----- restore of a missing id (the catch arm) ---------------------------------------------- + + #[Test] + public function restoring_a_missing_snippet_is_a_clean_error(): void + { + $this->loginAs('superadmin'); + // A well-formed request (version >= 1) whose id has no versions → restoreVersion throws → caught. + $res = $this->call('restore', ['code_id' => 'no-such-code-id', 'version' => 2]); + $this->assertSame(0, (int) $res->result, 'a missing snippet restore fails cleanly, no crash'); + } + + // ----- footer auto-insert branch ------------------------------------------------------------ + + #[Test] + public function saving_a_js_snippet_with_footer_auto_insert_stores_the_footer_location(): void + { + $this->loginAs('superadmin'); + $res = $this->call('save', [ + 'name' => 'Footer JS', + 'language' => Tiger_Model_Code::LANG_JS, + 'code' => 'console.log("hi");', + 'auto_insert' => Tiger_Model_Code::AUTO_FOOTER, + 'active' => '1', + 'priority' => '20', + ]); + $this->assertSame(1, (int) $res->result, 'a client-JS snippet needs no PHP parse-check'); + $row = (new Tiger_Model_Code())->findById($res->data['code_id']); + $this->assertSame(Tiger_Model_Code::LANG_JS, $row->language); + $this->assertSame(Tiger_Model_Code::AUTO_FOOTER, $row->auto_insert, 'js honors an explicit footer placement'); + } +} diff --git a/tests/Integration/Code/RuntimeMoreTest.php b/tests/Integration/Code/RuntimeMoreTest.php new file mode 100644 index 0000000..2db9d8a --- /dev/null +++ b/tests/Integration/Code/RuntimeMoreTest.php @@ -0,0 +1,149 @@ +cacheDir = APPLICATION_ROOT . '/storage/cache/code'; + $this->publicDir = APPLICATION_ROOT . '/public/_code'; + $this->priorConfig = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + } + + protected function tearDown(): void + { + foreach (['bootfail', 'killswitch', 'clientphtml'] as $loc) { + foreach (glob($this->cacheDir . '/' . $loc . '.*.php') ?: [] as $f) { @unlink($f); } + foreach (glob($this->cacheDir . '/inject.' . $loc . '.*.php') ?: [] as $f) { @unlink($f); } + foreach (glob($this->publicDir . '/' . $loc . '.*') ?: [] as $f) { @unlink($f); } + } + unset($GLOBALS['__tiger_code_running']); + if ($this->priorConfig !== null) { + Zend_Registry::set('Zend_Config', $this->priorConfig); + } elseif (Zend_Registry::isRegistered('Zend_Config')) { + Zend_Registry::set('Zend_Config', new Zend_Config([])); + } + Tiger_Log::reset(); + parent::tearDown(); + } + + /** Set the `tiger.code` config node (version/enabled) plus a null log sink. */ + private function setCodeConfig(array $code): void + { + Zend_Registry::set('Zend_Config', new Zend_Config([ + 'tiger' => ['code' => $code, 'log' => ['writer' => 'null']], + ])); + Tiger_Log::reset(); + } + + private function insertPhp(string $name, string $code, string $loc): string + { + return (new Tiger_Model_Code())->insert([ + 'org_id' => '', + 'name' => $name, + 'language' => Tiger_Model_Code::LANG_PHP, + 'code' => $code, + 'run_location' => $loc, + 'priority' => 100, + 'active' => 1, + 'status' => Tiger_Model_Code::STATUS_ACTIVE, + ]); + } + + #[Test] + public function boot_bails_without_fataling_when_the_active_set_fails_to_compile(): void + { + // A broken active snippet at a fresh location + a positive version whose bundle file doesn't exist + // yet → boot() compiles-if-missing, compile() throws on the php -l, boot() catches + logs + returns. + $this->insertPhp('broken', 'function tiger_bootfail_broken( {', 'bootfail'); // parse error + $this->setCodeConfig(['version' => '7', 'enabled' => '1']); + + Tiger_Code_Runtime::boot('bootfail'); // must NOT throw — the request survives + + $this->assertFileDoesNotExist($this->cacheDir . '/bootfail.7.php', 'the broken bundle was never promoted'); + } + + #[Test] + public function boot_is_a_noop_under_the_kill_switch_and_only_runs_once_per_location(): void + { + $this->setCodeConfig(['version' => '3', 'enabled' => '0']); // execution disabled + + Tiger_Code_Runtime::boot('killswitch'); // kill-switch → immediate return (marks the location booted) + Tiger_Code_Runtime::boot('killswitch'); // already booted → the very first short-circuit + + $this->assertFileDoesNotExist($this->cacheDir . '/killswitch.3.php', 'a disabled runtime compiles nothing'); + } + + #[Test] + public function compile_client_handles_an_inline_phtml_snippet(): void + { + (new Tiger_Model_Code())->insert([ + 'org_id' => '', + 'name' => 'phtml-inline', + 'language' => Tiger_Model_Code::LANG_PHTML, + 'code' => '= "hello from phtml" ?>', + 'run_location' => 'clientphtml', + 'auto_insert' => Tiger_Model_Code::AUTO_FOOTER, + 'priority' => 100, + 'active' => 1, + 'status' => Tiger_Model_Code::STATUS_ACTIVE, + ]); + + Tiger_Code_Runtime::compileClient('clientphtml', 1); + + $manifest = $this->cacheDir . '/inject.clientphtml.1.php'; + $this->assertFileExists($manifest); + $data = include $manifest; + $footer = $data['footer'] ?? []; + $types = array_column($footer, 'type'); + $this->assertContains('phtml', $types, 'the phtml snippet becomes an inline phtml manifest item'); + } + + #[Test] + public function on_shutdown_is_a_noop_when_no_snippet_was_mid_load(): void + { + unset($GLOBALS['__tiger_code_running']); // nothing executing + Tiger_Code_Runtime::_onShutdown(); + $this->assertTrue(true, 'no marker → the backstop returns immediately, deactivating nothing'); + } + + #[Test] + public function on_shutdown_is_a_noop_when_the_last_error_is_not_fatal(): void + { + // A snippet marker is set, but the request is ending WITHOUT a fatal (no error, or a mere + // warning/deprecation) → the backstop must not deactivate anything. + $GLOBALS['__tiger_code_running'] = 'some-code-id'; + Tiger_Code_Runtime::_onShutdown(); + unset($GLOBALS['__tiger_code_running']); + $this->assertTrue(true, 'a non-fatal shutdown leaves the snippet active'); + } +} diff --git a/tests/Integration/Controller/AuthorizationPluginExtraTest.php b/tests/Integration/Controller/AuthorizationPluginExtraTest.php new file mode 100644 index 0000000..e259061 --- /dev/null +++ b/tests/Integration/Controller/AuthorizationPluginExtraTest.php @@ -0,0 +1,124 @@ +priorUnitTestMode = Zend_Session::$_unitTestEnabled; + Zend_Session::$_unitTestEnabled = true; + $_SESSION = []; + } + + protected function tearDown(): void + { + $_SESSION = []; + Zend_Session::$_unitTestEnabled = $this->priorUnitTestMode; + parent::tearDown(); + } + + private function request(string $controller, string $action = 'index', string $module = 'default'): Zend_Controller_Request_Http + { + $r = new Zend_Controller_Request_Http(); + $r->setModuleName($module)->setControllerName($controller)->setActionName($action)->setDispatched(true); + return $r; + } + + /** Run preDispatch with the plugin wired to $request + a fresh response (as the front would). */ + private function runGate(Zend_Controller_Request_Http $request): Zend_Controller_Response_Http + { + $response = new Zend_Controller_Response_Http(); + Zend_Controller_Front::getInstance()->setRequest($request)->setResponse($response); + + $plugin = new Tiger_Controller_Plugin_Authorization(); + $plugin->setRequest($request); + $plugin->setResponse($response); + $plugin->preDispatch($request); + return $response; + } + + #[Test] + public function a_non_dispatchable_url_is_left_for_the_error_handler(): void + { + $this->login('anon', 'o-1', 'guest'); + $req = $this->request('no-such-controller-xyz'); + $res = $this->runGate($req); + + // Untouched: not re-dispatched to the 403, no forbidden code — ZF's ErrorHandler will 404 it. + $this->assertNotSame(403, $res->getHttpResponseCode()); + $this->assertSame('no-such-controller-xyz', $req->getControllerName(), 'the request was not rewritten'); + } + + #[Test] + public function the_gate_fails_open_when_no_acl_is_registered(): void + { + // A real, dispatchable controller, but the ACL never loaded (partial boot) → the gate must return + // rather than lock everyone out. + if (Zend_Registry::isRegistered('Zend_Acl')) { Zend_Registry::getInstance()->offsetUnset('Zend_Acl'); } + + $req = $this->request('index'); + $res = $this->runGate($req); + + $this->assertNotSame(403, $res->getHttpResponseCode()); + $this->assertSame('index', $req->getControllerName()); + } + + #[Test] + public function a_guest_allowed_controller_proceeds(): void + { + $this->login('anon', 'o-1', 'guest'); // registers the real shipped ACL + $req = $this->request('index'); // IndexController is allowed to guest in core acl.ini + $res = $this->runGate($req); + + $this->assertNotSame(403, $res->getHttpResponseCode(), 'a guest-allowed page is not denied'); + $this->assertSame('index', $req->getControllerName(), 'no rewrite — the action proceeds'); + } + + #[Test] + public function an_authenticated_but_forbidden_caller_is_forwarded_to_the_403(): void + { + $this->loginAs('user'); // a plain user... + $req = $this->request('admin'); // ...hitting AdminController, which has no allow rule + $res = $this->runGate($req); + + // _deny(): not a guest, not locked → re-dispatch to the themed 403 (no process exit). + $this->assertSame(403, $res->getHttpResponseCode()); + $this->assertSame('error', $req->getControllerName()); + $this->assertSame('forbidden', $req->getActionName()); + $this->assertFalse($req->isDispatched(), 'the request is re-queued for the error controller'); + } +} diff --git a/tests/Integration/License/CheckerExtraTest.php b/tests/Integration/License/CheckerExtraTest.php new file mode 100644 index 0000000..ee50dbf --- /dev/null +++ b/tests/Integration/License/CheckerExtraTest.php @@ -0,0 +1,77 @@ +rows[$slug] ?? null; } + public function put(string $slug, array $record): void { $this->rows[$slug] = $record; } + public function forget(string $slug): void { unset($this->rows[$slug]); } + }; + } + + #[Test] + public function status_uses_the_default_option_store_when_none_is_injected(): void + { + // No setStore() → the checker lazily constructs its default Tiger_License_Store_Option; an + // unrecorded slug reports unlicensed with no network hit. + $verdict = Tiger_License_Checker::status('never-installed-' . bin2hex(random_bytes(3))); + $this->assertSame(Tiger_License_Checker::UNLICENSED, $verdict['state']); + $this->assertTrue($verdict['can_update']); + } + + #[Test] + public function a_correctly_signed_reply_with_a_non_object_payload_is_untrusted(): void + { + Tiger_License_Checker::setStore($this->memoryStore()); + + $keys = Tiger_Crypto_Signature::generateKeypair(); + // A payload that is valid JSON but decodes to a STRING, not an object → cannot be a verdict. + $payload = json_encode('not-a-verdict-object'); + $signature = Tiger_Crypto_Signature::sign($payload, $keys['secret_key']); + + Tiger_License_Checker::remember(self::SLUG, [ + 'key' => 'LIC-XYZ', + 'authority' => 'https://store.example/authority', + 'vendor' => 'acme/TigerVendor', + 'public_key' => $keys['public_key'], + ]); + Tiger_License_Checker::setTransport( + static fn(string $authority, array $payloadIn): ?array => ['payload' => $payload, 'signature' => $signature] + ); + + $verdict = Tiger_License_Checker::verify(self::SLUG); + $this->assertSame(Tiger_License_Checker::UNKNOWN, $verdict['state'], 'a signed-but-malformed payload is untrusted, not lapsed'); + $this->assertTrue($verdict['can_update'], 'fail-OPEN: it never withholds an update'); + } +} diff --git a/tests/Integration/Model/AclDbTierTest.php b/tests/Integration/Model/AclDbTierTest.php new file mode 100644 index 0000000..c0cc423 --- /dev/null +++ b/tests/Integration/Model/AclDbTierTest.php @@ -0,0 +1,67 @@ +insert(['role' => 'w7editor', 'parent_role' => 'user']); + (new Tiger_Model_AclRole())->insert(['role' => 'w7loner', 'parent_role' => '']); + + // A DB resource + an allow rule granting the DB role access to it. + (new Tiger_Model_AclResource())->insert(['resource' => 'W7_Service_Report']); + (new Tiger_Model_AclRule())->insert([ + 'role' => 'w7editor', 'resource' => 'W7_Service_Report', 'privilege' => 'view', 'permission' => 'allow', + ]); + + $acl = new Tiger_Acl_Acl(); + + $this->assertTrue($acl->hasRole('w7editor'), 'a DB role is added to the graph'); + $this->assertTrue($acl->hasRole('w7loner'), 'a parentless DB role is added too'); + $this->assertTrue($acl->has('W7_Service_Report'), 'a DB resource is registered'); + $this->assertTrue($acl->isAllowed('w7editor', 'W7_Service_Report', 'view'), 'the DB allow rule grants access'); + $this->assertFalse($acl->isAllowed('w7editor', 'W7_Service_Report', 'delete'), 'only the granted privilege is allowed'); + // The parent link resolved: w7editor inherits `user`, which the ini graph carries. + $this->assertContains('user', $acl->getRoles(), 'the ini role graph is still present underneath the DB tier'); + } + + #[Test] + public function a_db_deny_rule_overrides_an_inherited_allow(): void + { + // DB loads LAST, so a DB deny wins over an ini/base grant — the "wins on conflict" property. + (new Tiger_Model_AclResource())->insert(['resource' => 'W7_Service_Thing']); + (new Tiger_Model_AclRole())->insert(['role' => 'w7member', 'parent_role' => 'user']); + (new Tiger_Model_AclRule())->insert([ + 'role' => 'w7member', 'resource' => 'W7_Service_Thing', 'privilege' => '', 'permission' => 'allow', + ]); + (new Tiger_Model_AclRule())->insert([ + 'role' => 'w7member', 'resource' => 'W7_Service_Thing', 'privilege' => 'secret', 'permission' => 'deny', + ]); + + $acl = new Tiger_Acl_Acl(); + $this->assertTrue($acl->isAllowed('w7member', 'W7_Service_Thing', 'read'), 'the blanket allow grants general access'); + $this->assertFalse($acl->isAllowed('w7member', 'W7_Service_Thing', 'secret'), 'the scoped deny rule blocks that privilege'); + } +} diff --git a/tests/Integration/Module/InstallerCoverageTest.php b/tests/Integration/Module/InstallerCoverageTest.php new file mode 100644 index 0000000..5da5c34 --- /dev/null +++ b/tests/Integration/Module/InstallerCoverageTest.php @@ -0,0 +1,179 @@ +tmp = sys_get_temp_dir() . '/tiger_instcov_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->tmp, 0775, true); + $this->storePreExisted = is_dir(Tiger_Vendor_Environment::storeDir()); + } + + protected function tearDown(): void + { + foreach ($this->installed as $slug) { + $this->rrmdir(APPLICATION_PATH . '/modules/' . $slug); + $link = APPLICATION_ROOT . '/public/_modules/' . $slug; + if (is_link($link)) { @unlink($link); } elseif (is_dir($link)) { $this->rrmdir($link); } + } + @rmdir(APPLICATION_PATH . '/modules'); + @rmdir(APPLICATION_ROOT . '/public/_modules'); + $store = Tiger_Vendor_Environment::storeDir(); + if (!$this->storePreExisted && is_dir($store)) { + $this->rrmdir($store); + } else { + foreach ($this->storeSlugs as $slug) { $this->rrmdir($store . '/' . $slug); } + } + $this->rrmdir($this->tmp); + parent::tearDown(); + } + + #[Test] + public function install_from_url_rejects_a_non_github_url(): void + { + // parseRepo() fails on a non-repo string BEFORE any network hop — a clean, offline guard. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Not a GitHub repository URL.'); + Tiger_Module_Installer::installFromUrl('this is not a repository at all'); + } + + #[Test] + public function install_from_tarball_unwraps_a_single_top_dir_then_rejects_a_missing_manifest(): void + { + // One top dir, no module.json/theme.json → _findModuleRoot returns the single child, _readManifest + // finds nothing → the install refuses the package. + $zip = $this->tmp . '/no-manifest.zip'; + $z = new \ZipArchive(); + $z->open($zip, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $z->addFromString('somepkg/README.md', "# not a module\n"); + $z->addFromString('somepkg/src/Thing.php', "close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('no valid module.json or theme.json'); + Tiger_Module_Installer::installFromTarball($zip, [], []); + } + + #[Test] + public function install_provisions_declared_php_and_asset_dependencies(): void + { + // A .tar.gz PHP library (Tier-3 source) + a .tar.gz front-end asset package, both served locally. + $libTar = $this->makeLibTarGz('deplib'); + $assetTar = $this->makeAssetTarGz(); + + $manifest = [ + 'name' => 'W7 Deps', + 'version' => '1.0.0', + 'dependencies' => [ + 'php' => [['name' => 'test/deplib', 'tarball' => 'file://' . $libTar]], + 'asset' => [[ + 'name' => 'widget', + 'tarball' => 'file://' . $assetTar, + 'target' => 'assets/vendor', + 'include' => ['dist/widget.js'], + 'optional' => true, + ]], + ], + ]; + $this->storeSlugs[] = 'test-deplib'; + + $zip = $this->makeModuleZip('w7deps', $manifest); + $r = Tiger_Module_Installer::installFromUpload($zip); + $this->installed[] = 'w7deps'; + + $this->assertSame('w7deps', $r['slug']); + $this->assertArrayHasKey('dependencies', $r); + $this->assertCount(2, $r['dependencies'], 'both the php lib and the asset were provisioned'); + + $names = array_column($r['dependencies'], 'name'); + $this->assertContains('test/deplib', $names, 'the PHP library dependency was resolved'); + $this->assertContains('widget', $names, 'the front-end asset dependency was resolved'); + + // The php dep landed in the shared store; the asset landed inside the module. + $this->assertDirectoryExists(Tiger_Vendor_Environment::storeDir() . '/test-deplib'); + $this->assertFileExists(APPLICATION_PATH . '/modules/w7deps/assets/vendor/widget.js'); + + // The php dep is required (no `optional`), the asset is flagged optional. + $byName = []; + foreach ($r['dependencies'] as $d) { $byName[$d['name']] = $d; } + $this->assertTrue($byName['test/deplib']['required'], 'a php dep with no optional flag is required'); + $this->assertFalse($byName['widget']['required'], 'the asset declared optional → not required'); + } + + // ---- helpers --------------------------------------------------------------- + + private function makeModuleZip(string $slug, array $manifest): string + { + $manifest += ['slug' => $slug, 'name' => ucfirst($slug)]; + $manifest['slug'] = $slug; + $path = $this->tmp . '/' . $slug . '_' . bin2hex(random_bytes(3)) . '.zip'; + $zip = new \ZipArchive(); + $zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFromString($slug . '/module.json', json_encode($manifest)); + $zip->addFromString($slug . '/README.md', "# {$slug}\n"); + $zip->close(); + return $path; + } + + private function makeLibTarGz(string $top): string + { + $base = $this->tmp . '/' . $top . '_' . bin2hex(random_bytes(3)) . '.tar'; + $phar = new \PharData($base); + $phar->addFromString($top . '/src/Dep.php', "addFromString($top . '/composer.json', json_encode(['autoload' => ['psr-4' => ['W7DepLib\\' => 'src/']]])); + $phar->compress(\Phar::GZ); + unset($phar); + return $base . '.gz'; + } + + private function makeAssetTarGz(): string + { + $base = $this->tmp . '/asset_' . bin2hex(random_bytes(3)) . '.tar'; + $phar = new \PharData($base); + $phar->addFromString('pkg/dist/widget.js', "console.log('w');\n"); + $phar->compress(\Phar::GZ); + unset($phar); + return $base . '.gz'; + } + + private function rrmdir(string $dir): void + { + if (!is_dir($dir)) { return; } + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { continue; } + $p = $dir . '/' . $item; + (is_dir($p) && !is_link($p)) ? $this->rrmdir($p) : @unlink($p); + } + @rmdir($dir); + } +} diff --git a/tests/Integration/Seo/HeadServiceExtraTest.php b/tests/Integration/Seo/HeadServiceExtraTest.php new file mode 100644 index 0000000..55007b6 --- /dev/null +++ b/tests/Integration/Seo/HeadServiceExtraTest.php @@ -0,0 +1,147 @@ +view = new Zend_View(); + $this->view->doctype('HTML5'); + Zend_Registry::set('Zend_View', $this->view); + $this->view->headTitle()->getContainer()->exchangeArray([]); + $this->view->headMeta()->getContainer()->exchangeArray([]); + $this->view->headLink()->getContainer()->exchangeArray([]); + + $this->hadConfig = Zend_Registry::isRegistered('Zend_Config'); + $this->priorConfig = $this->hadConfig ? Zend_Registry::get('Zend_Config') : null; + } + + protected function tearDown(): void + { + $reg = Zend_Registry::getInstance(); + if ($reg->offsetExists('Zend_View')) { $reg->offsetUnset('Zend_View'); } + if ($this->hadConfig) { + Zend_Registry::set('Zend_Config', $this->priorConfig); + } elseif ($reg->offsetExists('Zend_Config')) { + $reg->offsetUnset('Zend_Config'); + } + parent::tearDown(); + } + + private function config(array $tiger): void + { + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => $tiger])); + } + + private function request(string $uri = '/hello'): Zend_Controller_Request_Http + { + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTPS'] = 'on'; + $r = new Zend_Controller_Request_Http(); + $r->setRequestUri($uri); + return $r; + } + + private function head(): string + { + return $this->view->headTitle()->toString() . "\n" + . $this->view->headMeta()->toString() . "\n" + . $this->view->headLink()->toString(); + } + + // ----- meta already an array (no JSON decode) ----------------------------------------------- + + #[Test] + public function a_page_whose_meta_is_already_an_array_is_read_without_decoding(): void + { + // A Zend_Db_Table_Row that already decoded its JSON hands the service a native array. + $page = (object) [ + 'meta' => ['seo' => ['title' => 'Array Meta Title', 'description' => 'From an array.']], + 'title' => 'Row Title', + 'type' => 'page', + ]; + Seo_Service_Head::forRow($page, $this->request()); + $head = $this->head(); + $this->assertStringContainsString('Array Meta Title', $head, 'the array meta drives the title'); + $this->assertStringContainsString('From an array.', $head); + } + + // ----- a request that cannot produce a scheme (no self-referencing canonical) --------------- + + #[Test] + public function a_request_without_getScheme_yields_no_self_referencing_canonical(): void + { + $page = (object) ['meta' => json_encode([]), 'title' => 'No Scheme', 'type' => 'page']; + // Zend_Controller_Request_Simple has no getScheme()/getHttpHost() — _currentUrl() returns ''. + Seo_Service_Head::forRow($page, new Zend_Controller_Request_Simple()); + $head = $this->head(); + $this->assertStringNotContainsString('rel="canonical"', $head, 'no scheme → no canonical link'); + $this->assertStringNotContainsString('og:url', $head, 'no scheme → no og:url'); + // The render still succeeds: og:type/twitter:card are always emitted. + $this->assertStringContainsString('og:type', $head); + } + + // ----- view-less + config-less fallbacks ---------------------------------------------------- + + #[Test] + public function forRow_still_populates_when_no_view_and_no_config_are_registered(): void + { + $reg = Zend_Registry::getInstance(); + if ($reg->offsetExists('Zend_View')) { $reg->offsetUnset('Zend_View'); } + if ($reg->offsetExists('Zend_Config')) { $reg->offsetUnset('Zend_Config'); } + + $page = (object) ['meta' => json_encode(['seo' => ['title' => 'Fallback Title']]), 'title' => 'Row', 'type' => 'page']; + Seo_Service_Head::forRow($page, $this->request()); + + // Placeholder containers are a process-wide singleton — re-register a view to read them back. + Zend_Registry::set('Zend_View', $this->view); + $this->assertStringContainsString('Fallback Title', $this->head(), 'the fallback view wrote to the shared head containers'); + } + + // ----- an article with blank dates emits no article:*_time tags ----------------------------- + + #[Test] + public function an_article_with_blank_dates_emits_no_article_time_tags(): void + { + $this->config(['site' => ['name' => 'Acme']]); + $page = (object) [ + 'meta' => json_encode([]), + 'title' => 'Undated', + 'type' => 'article', + 'published_at' => '', + 'updated_at' => '', + ]; + Seo_Service_Head::forRow($page, $this->request('/blog/undated')); + $head = $this->head(); + $this->assertStringContainsString('og:type', $head); + $this->assertStringContainsString('article', $head, 'an article page still tags og:type=article'); + $this->assertStringNotContainsString('article:published_time', $head, 'blank dates emit no published_time'); + $this->assertStringNotContainsString('article:modified_time', $head); + } +} diff --git a/tests/Integration/Seo/SchemaServiceExtraTest.php b/tests/Integration/Seo/SchemaServiceExtraTest.php new file mode 100644 index 0000000..238d02f --- /dev/null +++ b/tests/Integration/Seo/SchemaServiceExtraTest.php @@ -0,0 +1,249 @@ +view = new Zend_View(); + $this->view->doctype('HTML5'); + Zend_Registry::set('Zend_View', $this->view); + $this->view->placeholder('tigerJsonLd')->exchangeArray([]); + + $this->hadConfig = Zend_Registry::isRegistered('Zend_Config'); + $this->priorConfig = $this->hadConfig ? Zend_Registry::get('Zend_Config') : null; + + $this->resetLatch(); + } + + protected function tearDown(): void + { + $this->resetLatch(); + $reg = Zend_Registry::getInstance(); + if ($reg->offsetExists('Zend_View')) { $reg->offsetUnset('Zend_View'); } + if ($this->hadConfig) { + Zend_Registry::set('Zend_Config', $this->priorConfig); + } elseif ($reg->offsetExists('Zend_Config')) { + $reg->offsetUnset('Zend_Config'); + } + parent::tearDown(); + } + + private function resetLatch(): void + { + $p = new ReflectionProperty(Seo_Service_Schema::class, '_emitted'); + $p->setAccessible(true); + $p->setValue(null, false); + } + + private function config(array $tiger): void + { + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => $tiger])); + } + + private function request(string $uri = '/'): Zend_Controller_Request_Http + { + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTPS'] = 'on'; + $r = new Zend_Controller_Request_Http(); + $r->setRequestUri($uri); + $r->setPathInfo($uri); + return $r; + } + + /** Insert an image `media` row and return its id — the target of a media-id image reference. */ + private function mediaRow(int $w = 800, int $h = 600): string + { + return (new Tiger_Model_Media())->insert([ + 'org_id' => '', + 'disk' => 'local', + 'storage_key' => 'images/schema-shot.png', + 'visibility' => Tiger_Model_Media::VISIBILITY_PUBLIC, + 'kind' => Tiger_Model_Media::KIND_IMAGE, + 'mime_type' => 'image/png', + 'extension' => 'png', + 'filename' => 'schema-shot.png', + 'width' => $w, + 'height' => $h, + ]); + } + + private function nodes(): array + { + $raw = (string) $this->view->placeholder('tigerJsonLd'); + $out = []; + if (preg_match_all('##s', $raw, $m)) { + foreach ($m[1] as $json) { + $data = json_decode($json, true); + if (isset($data['@graph'])) { + foreach ($data['@graph'] as $n) { $out[] = $n; } + } + } + } + return $out; + } + + private function nodeOfType(string $type): ?array + { + foreach ($this->nodes() as $n) { + if (($n['@type'] ?? '') === $type) { return $n; } + } + return null; + } + + // ----- media-id image resolution (the media-row lookup, absolute URL + real dimensions) ------ + + #[Test] + public function organization_logo_from_a_media_id_resolves_dimensions_and_an_absolute_url(): void + { + $id = $this->mediaRow(1024, 768); + // A bare media id (not an http URL) exercises the media-row lookup + relative→absolute prefixing. + $this->config(['site' => ['name' => 'Acme', 'logo' => $id]]); + Seo_Service_Schema::emitSite($this->request('/')); + + $org = $this->nodeOfType('Organization'); + $this->assertArrayHasKey('logo', $org, 'the logo was resolved through the media row'); + $this->assertSame('ImageObject', $org['logo']['@type']); + $this->assertEquals(1024, $org['logo']['width'], 'the media row supplies real pixel dimensions'); + $this->assertEquals(768, $org['logo']['height']); + // No storage disk is configured, so url() falls to the stream route — prefixed to an absolute URL. + $this->assertStringStartsWith('https://example.test/', $org['logo']['url']); + $this->assertStringContainsString($id, $org['logo']['url']); + } + + #[Test] + public function article_feature_image_from_a_media_id_resolves_through_the_media_row(): void + { + $id = $this->mediaRow(640, 480); + $this->config(['site' => ['name' => 'Acme']]); + Seo_Service_Schema::emitArticle( + (object) ['updated_at' => '2026-06-01 00:00:00'], + [ + 'title' => 'With Media', + 'slug' => 'with-media', + 'excerpt' => 'Has a real media image.', + 'published_at' => '2026-05-20 00:00:00', + 'feature' => ['id' => $id], + ], + $this->request('/blog/with-media') + ); + + $post = $this->nodeOfType('BlogPosting'); + $this->assertArrayHasKey('image', $post); + $this->assertEquals(640, $post['image']['width']); + $this->assertEquals(480, $post['image']['height']); + $this->assertStringContainsString($id, $post['image']['url']); + } + + #[Test] + public function an_unresolvable_media_id_simply_omits_the_logo(): void + { + // A ref that is neither an http URL nor a real media row → _image returns null → no logo key. + $this->config(['site' => ['name' => 'Acme', 'logo' => 'not-a-real-media-id']]); + Seo_Service_Schema::emitSite($this->request('/')); + $this->assertArrayNotHasKey('logo', $this->nodeOfType('Organization')); + } + + // ----- no-absolute-base short-circuits ------------------------------------------------------ + + #[Test] + public function emit_site_skips_entirely_when_there_is_no_absolute_base(): void + { + // No request AND no tiger.site.url → _base() == '' → emitSite bails before emitting any node. + $this->config(['site' => ['name' => 'Acme']]); + Seo_Service_Schema::emitSite(null); + $this->assertNull($this->nodeOfType('Organization'), 'nothing anchored on a relative base is emitted'); + } + + #[Test] + public function emit_article_skips_entirely_when_there_is_no_absolute_base(): void + { + $this->config(['site' => ['name' => 'Acme']]); + Seo_Service_Schema::emitArticle( + (object) ['updated_at' => ''], + ['title' => 'Homeless', 'slug' => 'homeless', 'published_at' => '2026-01-01 00:00:00'], + null + ); + $this->assertNull($this->nodeOfType('BlogPosting'), 'no base → no article node'); + } + + // ----- nav fallbacks ------------------------------------------------------------------------ + + #[Test] + public function a_blank_nav_menu_config_falls_back_to_the_primary_menu(): void + { + $menu = new Tiger_Model_Menu(); + $menu->insert(['org_id' => '', 'menu_key' => 'primary', 'parent_id' => null, 'sort_order' => 0, 'label' => 'Store', 'url' => '/store', 'status' => Tiger_Model_Menu::STATUS_PUBLISHED]); + + // An explicitly blank nav_menu key must not blank the nav — it defaults back to 'primary'. + $this->config(['site' => ['name' => 'Acme'], 'seo' => ['schema' => ['nav_menu' => '']]]); + Seo_Service_Schema::emitSite($this->request('/')); + + $nav = $this->nodeOfType('SiteNavigationElement'); + $this->assertNotNull($nav, 'a blank nav_menu falls back to the primary menu'); + $this->assertContains('Store', $nav['name']); + } + + #[Test] + public function a_menu_of_only_headings_emits_no_nav_element(): void + { + $menu = new Tiger_Model_Menu(); + // Every item is a heading (no url) or a dead placeholder (#) → no real links → no nav node. + $menu->insert(['org_id' => '', 'menu_key' => 'primary', 'parent_id' => null, 'sort_order' => 0, 'label' => 'Section', 'url' => '', 'status' => Tiger_Model_Menu::STATUS_PUBLISHED]); + $menu->insert(['org_id' => '', 'menu_key' => 'primary', 'parent_id' => null, 'sort_order' => 1, 'label' => 'Dead', 'url' => '#', 'status' => Tiger_Model_Menu::STATUS_PUBLISHED]); + + $this->config(['site' => ['name' => 'Acme']]); + Seo_Service_Schema::emitSite($this->request('/')); + $this->assertNull($this->nodeOfType('SiteNavigationElement'), 'a headings-only menu yields no nav element'); + } + + // ----- view-less placeholder fallback ------------------------------------------------------- + + #[Test] + public function emit_still_works_when_no_view_is_registered(): void + { + // Unregister the shared view → _view() constructs a bare Zend_View. Placeholder containers are a + // process-wide singleton registry, so the nodes still land in the container this test reads back. + $reg = Zend_Registry::getInstance(); + if ($reg->offsetExists('Zend_View')) { $reg->offsetUnset('Zend_View'); } + + $this->config(['site' => ['name' => 'Acme']]); + Seo_Service_Schema::emitSite($this->request('/')); + + // Re-register a view to read the shared placeholder back. + Zend_Registry::set('Zend_View', $this->view); + $this->assertNotNull($this->nodeOfType('Organization'), 'the org node still emitted via the fallback view'); + } +} diff --git a/tests/Integration/Service/LocationAdminExtraTest.php b/tests/Integration/Service/LocationAdminExtraTest.php new file mode 100644 index 0000000..8d3f3ac --- /dev/null +++ b/tests/Integration/Service/LocationAdminExtraTest.php @@ -0,0 +1,135 @@ +_enc`) + * and stored PLAINTEXT when crypto isn't configured; + * - a BLANK secret is skipped (keeps the existing one), and a non-submitted adapter is ignored; + * - adapters() build-and-skips a provider whose constructor throws (a bad provider never 500s the screen). + */ + #[CoversClass(Tiger_Location::class)] + final class LocationAdminExtraTest extends IntegrationTestCase + { + private ?Zend_Config $priorConfig = null; + + protected function setUp(): void + { + parent::setUp(); + $this->priorConfig = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + } + + protected function tearDown(): void + { + if ($this->priorConfig !== null) { + Zend_Registry::set('Zend_Config', $this->priorConfig); + } elseif (Zend_Registry::isRegistered('Zend_Config')) { + Zend_Registry::set('Zend_Config', new Zend_Config([])); + } + parent::tearDown(); + } + + private function cfgGet(string $key): ?string + { + $v = (new Tiger_Model_Config())->get(Tiger_Model_Config::SCOPE_GLOBAL, '', $key); + return $v === false ? null : $v; + } + + #[Test] + public function save_settings_persists_providers_ttl_and_a_secret_encrypted_when_crypto_is_configured(): void + { + // Crypto ON: a valid 32-byte key so Tiger_Crypto::isConfigured() → the secret is stored encrypted. + Zend_Registry::set('Zend_Config', new Zend_Config([ + 'tiger' => ['crypto' => ['key' => base64_encode(str_repeat("\x24", 32))]], + ])); + $this->assertTrue(Tiger_Crypto::isConfigured(), 'precondition: crypto configured for the enc branch'); + + Tiger_Location::saveSettings([ + 'ip_provider' => 'ip@api!!', // sanitized to [a-z0-9_-] + 'address_provider' => 'nominatim', + 'cache_ttl' => '-50', // floored to 0 + 'adapters' => [ + 'aws' => [ + 'region' => ' us-west-2 ', // text → trimmed + 'place_index' => 'my-index', + 'key' => 'AKIAEXAMPLE', // secret → encrypted + 'secret' => '', // blank secret → kept (skipped) + ], + // 'nominatim' deliberately not submitted → the adapter loop skips it. + ], + ]); + + $this->assertSame('ipapi', $this->cfgGet('tiger.location.ip.provider'), 'the provider name is sanitized'); + $this->assertSame('nominatim', $this->cfgGet('tiger.location.address.provider')); + $this->assertSame('0', $this->cfgGet('tiger.location.cache_ttl'), 'a negative TTL floors to 0'); + $this->assertSame('us-west-2', $this->cfgGet('tiger.location.adapters.aws.region'), 'a text field is trimmed'); + $this->assertSame('my-index', $this->cfgGet('tiger.location.adapters.aws.place_index')); + + // The secret is stored under `_enc` (encrypted), NOT as plaintext, and round-trips. + $enc = $this->cfgGet('tiger.location.adapters.aws.key_enc'); + $this->assertNotNull($enc, 'the secret is stored encrypted at rest'); + $this->assertNull($this->cfgGet('tiger.location.adapters.aws.key'), 'no plaintext secret is written'); + $this->assertSame('AKIAEXAMPLE', Tiger_Crypto::decrypt($enc)); + + // The blank secret was skipped entirely. + $this->assertNull($this->cfgGet('tiger.location.adapters.aws.secret')); + $this->assertNull($this->cfgGet('tiger.location.adapters.aws.secret_enc')); + } + + #[Test] + public function save_settings_stores_a_secret_in_plaintext_when_crypto_is_not_configured(): void + { + // Crypto OFF: no key → Tiger_Crypto::isConfigured() false → the secret falls back to plaintext. + Zend_Registry::set('Zend_Config', new Zend_Config([])); + + Tiger_Location::saveSettings([ + 'cache_ttl' => '3600', + 'adapters' => ['aws' => ['key' => 'PLAINSECRET']], + ]); + + $this->assertSame('3600', $this->cfgGet('tiger.location.cache_ttl')); + $this->assertSame('PLAINSECRET', $this->cfgGet('tiger.location.adapters.aws.key'), 'no crypto → plaintext secret'); + $this->assertNull($this->cfgGet('tiger.location.adapters.aws.key_enc')); + } + + #[Test] + public function adapters_build_and_skips_a_provider_whose_constructor_throws(): void + { + Zend_Registry::set('Zend_Config', new Zend_Config([])); + Tiger_Location::register('throwing', 'Tiger_Test_ThrowingLocationAdapter'); + + $adapters = Tiger_Location::adapters(); + + $this->assertArrayNotHasKey('throwing', $adapters, 'a provider that fails to construct is quietly skipped'); + $this->assertArrayHasKey('aws', $adapters, 'the healthy built-ins still list'); + } + } +} diff --git a/tests/Integration/System/ModulesServiceThemeTest.php b/tests/Integration/System/ModulesServiceThemeTest.php new file mode 100644 index 0000000..d140d3c --- /dev/null +++ b/tests/Integration/System/ModulesServiceThemeTest.php @@ -0,0 +1,107 @@ +` module (a bare `theme.json` — no + * Bootstrap, no assets/, no migrations/) under the harness app modules dir so live discovery sees a + * `type:theme` row. Activating a theme is NOT the `module.active` flag: it writes `tiger.theme =