From 57f46d1e528bd5539ae08d3cdaa65d3d6c4045ec Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sat, 25 Jul 2026 03:20:11 -0400 Subject: [PATCH] =?UTF-8?q?test:=20Wave=207=20=E2=80=94=20reachable=20kern?= =?UTF-8?q?el=20+=20module=20remainders=20(coverage=2070.0%=20=E2=86=92=20?= =?UTF-8?q?74.4%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four parallel agents mopping up reachable-but-uncovered branches (committed incrementally after a session-limit restart). 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 — 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 (instant ECONNREFUSED → fail-soft branches), primed caches, probe subclasses exposing build/parse helpers, _run driven with echo/exit. No behavioral bugs. CI floor MIN_COVERAGE 66 → 72. The path from here to 80% (~800 lines) is the in-process app-boot harness + the last scattered branches; the rest is uploads/composer/live-network (functional-test territory), documented in COVERAGE-PLAN §9. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 2 +- tests/COVERAGE-PLAN.md | 24 ++ .../Integration/Backup/BackupCoverageTest.php | 390 ++++++++++++++++++ .../Backup/BackupServiceRestoreTest.php | 74 ++++ tests/Integration/Cms/RendererLayoutTest.php | 65 +++ .../Code/CodeServiceConflictTest.php | 185 +++++++++ tests/Integration/Code/RuntimeMoreTest.php | 149 +++++++ .../AuthorizationPluginExtraTest.php | 124 ++++++ .../Integration/License/CheckerExtraTest.php | 77 ++++ tests/Integration/Model/AclDbTierTest.php | 67 +++ .../Module/InstallerCoverageTest.php | 179 ++++++++ .../Integration/Seo/HeadServiceExtraTest.php | 147 +++++++ .../Seo/SchemaServiceExtraTest.php | 249 +++++++++++ .../Service/LocationAdminExtraTest.php | 135 ++++++ .../System/ModulesServiceThemeTest.php | 107 +++++ .../Unit/Application/ApplicationMoreTest.php | 80 ++++ tests/Unit/Cms/RendererExtraTest.php | 103 +++++ tests/Unit/Google/AnalyticsHttpTest.php | 130 ++++++ tests/Unit/Media/AzureTest.php | 301 ++++++++++++++ tests/Unit/Media/ClamAvTest.php | 75 ++++ tests/Unit/Media/FilesystemMoreTest.php | 152 +++++++ tests/Unit/Media/GcsTest.php | 208 ++++++++++ tests/Unit/Media/RekognitionTest.php | 69 ++++ tests/Unit/Media/S3PutTest.php | 59 +++ tests/Unit/Media/StorageMoreTest.php | 57 +++ tests/Unit/Module/CompatCoverageTest.php | 39 ++ tests/Unit/Update/ComposerTest.php | 105 +++++ tests/Unit/Update/CoreTest.php | 245 +++++++++++ tests/Unit/VendorEnvironmentTest.php | 112 +++++ tests/Unit/VendorExtraTest.php | 183 ++++++++ 30 files changed, 3891 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Backup/BackupCoverageTest.php create mode 100644 tests/Integration/Backup/BackupServiceRestoreTest.php create mode 100644 tests/Integration/Cms/RendererLayoutTest.php create mode 100644 tests/Integration/Code/CodeServiceConflictTest.php create mode 100644 tests/Integration/Code/RuntimeMoreTest.php create mode 100644 tests/Integration/Controller/AuthorizationPluginExtraTest.php create mode 100644 tests/Integration/License/CheckerExtraTest.php create mode 100644 tests/Integration/Model/AclDbTierTest.php create mode 100644 tests/Integration/Module/InstallerCoverageTest.php create mode 100644 tests/Integration/Seo/HeadServiceExtraTest.php create mode 100644 tests/Integration/Seo/SchemaServiceExtraTest.php create mode 100644 tests/Integration/Service/LocationAdminExtraTest.php create mode 100644 tests/Integration/System/ModulesServiceThemeTest.php create mode 100644 tests/Unit/Application/ApplicationMoreTest.php create mode 100644 tests/Unit/Cms/RendererExtraTest.php create mode 100644 tests/Unit/Google/AnalyticsHttpTest.php create mode 100644 tests/Unit/Media/AzureTest.php create mode 100644 tests/Unit/Media/ClamAvTest.php create mode 100644 tests/Unit/Media/FilesystemMoreTest.php create mode 100644 tests/Unit/Media/GcsTest.php create mode 100644 tests/Unit/Media/RekognitionTest.php create mode 100644 tests/Unit/Media/S3PutTest.php create mode 100644 tests/Unit/Media/StorageMoreTest.php create mode 100644 tests/Unit/Module/CompatCoverageTest.php create mode 100644 tests/Unit/Update/ComposerTest.php create mode 100644 tests/Unit/Update/CoreTest.php create mode 100644 tests/Unit/VendorEnvironmentTest.php create mode 100644 tests/Unit/VendorExtraTest.php 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' => '
content ?>
', + 'format' => Tiger_Model_Page::FORMAT_PHTML, + 'status' => Tiger_Model_Page::STATUS_PUBLISHED, + ]); + + // The page itself only needs the fields render() reads; the layout is what must be in the DB. + $page = new stdClass(); + $page->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

', $out, 'the body is composed into the layout'); + } + + #[Test] + public function render_returns_the_bare_body_when_the_layout_key_resolves_nothing(): void + { + // layout_key set but no matching layout row → the render falls back to the un-wrapped body. + $page = new stdClass(); + $page->body = '

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' => '', + '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 = ` + * to the config tier (global scope) and — because the fixture ships no assets/ — the asset-symlink step + * short-circuits. Deactivating clears the config back to the base theme. + * + * All config writes are the DB tier (rolled back per test); the fixture dir is removed in tearDown. + */ +#[CoversClass(System_Service_Modules::class)] +final class ModulesServiceThemeTest extends IntegrationTestCase +{ + private const SLUG = 'theme-w7t'; + private const KEY = 'w7t'; + + private string $moduleDir; + + protected function setUp(): void + { + parent::setUp(); + $this->moduleDir = APPLICATION_PATH . '/modules/' . self::SLUG; + @mkdir($this->moduleDir, 0777, true); + // theme.json is the theme manifest — its presence is what discovery reads as `type:theme`. + file_put_contents( + $this->moduleDir . '/theme.json', + json_encode(['key' => self::KEY, 'name' => 'W7 Test Theme', 'version' => '0.1.0-beta']) + ); + } + + protected function tearDown(): void + { + @unlink($this->moduleDir . '/theme.json'); + @rmdir($this->moduleDir); + parent::tearDown(); + } + + private function dispatch(array $msg): object + { + return (new System_Service_Modules($msg))->getResponse(); + } + + private function messages(object $res): string + { + return json_encode($res->messages ?? []); + } + + private function activeTheme(): string + { + return (string) (new Tiger_Model_Config())->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme'); + } + + #[Test] + public function activating_a_theme_writes_the_tiger_theme_config_not_a_module_flag(): void + { + $this->loginAs('superadmin'); + $res = $this->dispatch(['action' => 'activate', 'slug' => self::SLUG]); + + $this->assertSame(1, (int) $res->result, $this->messages($res)); + $this->assertStringContainsString('activated', $this->messages($res)); + $this->assertTrue((bool) $res->data['theme'], 'the response marks this as a theme activation'); + $this->assertTrue((bool) $res->data['active']); + $this->assertSame('/system/modules', $res->redirect); + // The single config write: the active theme KEY at global scope. + $this->assertSame(self::KEY, $this->activeTheme(), 'tiger.theme now holds the theme key'); + } + + #[Test] + public function deactivating_the_active_theme_clears_it_back_to_the_base(): void + { + $this->loginAs('superadmin'); + // Activate first, then deactivate — the deactivate arm only clears when the key currently matches. + $this->dispatch(['action' => 'activate', 'slug' => self::SLUG]); + $this->assertSame(self::KEY, $this->activeTheme()); + + $res = $this->dispatch(['action' => 'deactivate', 'slug' => self::SLUG]); + $this->assertSame(1, (int) $res->result, $this->messages($res)); + $this->assertStringContainsString('deactivated', $this->messages($res)); + $this->assertTrue((bool) $res->data['theme']); + $this->assertFalse((bool) $res->data['active']); + $this->assertSame('', $this->activeTheme(), 'the config reverts to the base theme'); + } + + #[Test] + public function deactivating_a_theme_that_is_not_active_leaves_a_different_active_theme_alone(): void + { + $this->loginAs('superadmin'); + // Some OTHER theme is active; deactivating our fixture must not clobber it (the key mismatch arm). + (new Tiger_Model_Config())->set(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme', 'someother'); + + $res = $this->dispatch(['action' => 'deactivate', 'slug' => self::SLUG]); + $this->assertSame(1, (int) $res->result, $this->messages($res)); + $this->assertSame('someother', $this->activeTheme(), 'a non-matching deactivate leaves the active theme untouched'); + } +} diff --git a/tests/Unit/Application/ApplicationMoreTest.php b/tests/Unit/Application/ApplicationMoreTest.php new file mode 100644 index 0000000..2d36b08 --- /dev/null +++ b/tests/Unit/Application/ApplicationMoreTest.php @@ -0,0 +1,80 @@ +flagDir = rtrim(APPLICATION_ROOT, '/') . '/var/update'; + $this->flag = $this->flagDir . '/.maintenance'; + } + + protected function tearDown(): void + { + @unlink($this->flag); + @rmdir($this->flagDir); + @rmdir(dirname($this->flagDir)); // var/ — only if now empty + parent::tearDown(); + } + + private function app(): Tiger_Application + { + return new Tiger_Application(APPLICATION_ROOT); + } + + private function call(Tiger_Application $app, string $method) + { + return (new ReflectionMethod(Tiger_Application::class, $method))->invoke($app); + } + + #[Test] + public function a_fresh_maintenance_flag_serves_the_updating_flash_and_returns_true(): void + { + if (!is_dir($this->flagDir) && !@mkdir($this->flagDir, 0777, true)) { + $this->markTestSkipped('cannot create the maintenance flag dir under APPLICATION_ROOT.'); + } + file_put_contents($this->flag, ''); + + $this->expectOutputRegex('/Updating/'); + $this->assertTrue($this->call($this->app(), '_updateInProgress')); + } + + #[Test] + public function a_stale_maintenance_flag_is_ignored(): void + { + if (!is_dir($this->flagDir) && !@mkdir($this->flagDir, 0777, true)) { + $this->markTestSkipped('cannot create the maintenance flag dir under APPLICATION_ROOT.'); + } + file_put_contents($this->flag, ''); + touch($this->flag, time() - 3600); // an hour old → past the 120s guard + + // No output, and the gate does not engage. + $this->assertFalse($this->call($this->app(), '_updateInProgress')); + } +} diff --git a/tests/Unit/Cms/RendererExtraTest.php b/tests/Unit/Cms/RendererExtraTest.php new file mode 100644 index 0000000..ed89328 --- /dev/null +++ b/tests/Unit/Cms/RendererExtraTest.php @@ -0,0 +1,103 @@ +clearShortcodes(); + $this->renderer = new Tiger_Cms_Renderer(); + } + + protected function tearDown(): void + { + $this->clearShortcodes(); + parent::tearDown(); + } + + private function clearShortcodes(): void + { + (new ReflectionProperty(Tiger_Cms_Renderer::class, '_shortcodes'))->setValue(null, []); + } + + #[Test] + public function render_of_a_page_without_a_layout_returns_the_body(): void + { + Tiger_Cms_Renderer::registerShortcode('year', static fn () => '2026'); + + $page = new stdClass(); + $page->body = '

Home

© [year]'; + $page->format = Tiger_Model_Page::FORMAT_HTML; + $page->layout_key = null; // no layout → the body path returns directly + + $out = $this->renderer->render($page); + $this->assertSame('

Home

© 2026', $out); + } + + #[Test] + public function render_exposes_the_page_row_to_a_phtml_body_as_context(): void + { + // render() folds the page row into the context under `page`, so a phtml body can read it. + $page = new stdClass(); + $page->body = 'title=page->title ?>'; + $page->format = Tiger_Model_Page::FORMAT_PHTML; + $page->layout_key = null; + $page->title = 'Welcome'; + + $this->assertSame('title=Welcome', $this->renderer->render($page)); + } + + #[Test] + public function a_phtml_body_renders_through_the_registered_theme_view_when_present(): void + { + // With a themed Tiger_View in the registry, _view() clones it (helpers + paths) and clears its vars + // before assigning the render context. + Zend_Registry::set('Tiger_View', new Zend_View()); + + $out = $this->renderer->renderBody( + 'hi who ?>', + Tiger_Model_Page::FORMAT_PHTML, + ['who' => 'Ariel'] + ); + $this->assertSame('hi Ariel', $out); + } + + #[Test] + public function an_unregistered_shortcode_is_left_literal_even_with_other_handlers_registered(): void + { + // A registered handler makes the registry non-empty (so the fast-path guard passes) — the regex + // then still matches the UNknown token, and the per-match branch returns it verbatim. + Tiger_Cms_Renderer::registerShortcode('known', static fn () => 'K'); + + $out = $this->renderer->renderBody('[known] and [unknown]', Tiger_Model_Page::FORMAT_HTML); + $this->assertSame('K and [unknown]', $out, 'the unregistered token is preserved literally'); + } +} diff --git a/tests/Unit/Google/AnalyticsHttpTest.php b/tests/Unit/Google/AnalyticsHttpTest.php new file mode 100644 index 0000000..b7c324d --- /dev/null +++ b/tests/Unit/Google/AnalyticsHttpTest.php @@ -0,0 +1,130 @@ +resetAccess(); + } + + protected function tearDown(): void + { + $this->resetAccess(); + foreach ($this->wrote as $f) { @unlink($f); } + parent::tearDown(); + } + + private function resetAccess(): void + { + (new ReflectionProperty(Tiger_Google_Analytics::class, '_access'))->setValue(null, null); + } + + /** A crypto key + a config array wiring a broker-mode connection (property + encrypted refresh token). */ + private function connectBroker(string $property = '123456789', string $brokerBase = self::DEAD_BROKER): void + { + $key = base64_encode(str_repeat("\x11", 32)); + $this->setConfig(['tiger' => ['crypto' => ['key' => $key]]]); // key must be live to encrypt + $enc = Tiger_Crypto::encrypt('refresh-token-value'); + $this->setConfig(['tiger' => [ + 'crypto' => ['key' => $key], + 'analytics' => [ + 'property_id' => $property, + 'connect' => ['base_url' => $brokerBase], + 'oauth' => ['refresh_token_enc' => $enc], // mode defaults to broker + ], + ]]); + } + + // ---- exchangeHandoff : the unreachable-broker fail-soft ---------------------- + + #[Test] + public function exchange_handoff_fails_soft_when_the_broker_is_unreachable(): void + { + $this->setConfig(['tiger' => ['analytics' => ['connect' => ['base_url' => self::DEAD_BROKER]]]]); + + $r = Tiger_Google_Analytics::exchangeHandoff('handoff-code', 'pkce-verifier'); + $this->assertFalse($r['ok']); + $this->assertNotNull($r['error'], 'an unreachable broker returns a friendly error, never throws'); + } + + // ---- accessToken : broker branch, dead endpoint ------------------------------ + + #[Test] + public function access_token_broker_branch_is_empty_when_the_broker_is_unreachable(): void + { + $this->connectBroker(); + // A stored refresh token gets past the guard; the broker /google/token POST then refuses → ''. + $this->assertSame('', Tiger_Google_Analytics::accessToken()); + } + + // ---- summary : not-connected fast-out is in AnalyticsTest; here the token-empty + cache paths -- + + #[Test] + public function summary_returns_null_when_no_access_token_can_be_minted(): void + { + $this->connectBroker(); // connected, but the dead broker yields no access token + $this->assertNull(Tiger_Google_Analytics::summary(28, true), 'fresh=true bypasses cache → hits the dead broker → null'); + } + + #[Test] + public function summary_serves_a_primed_cache_entry_without_any_network(): void + { + $property = '987654321'; + $this->connectBroker($property); + + $days = 14; + $cacheFile = $this->cacheFile($property, $days); + $payload = ['range' => ['days' => $days], 'totals' => [5, 6], 'series' => [], 'top_pages' => [], 'top_sources' => [], 'fetched_at' => '2026-01-01T00:00:00+00:00']; + file_put_contents($cacheFile, json_encode($payload)); + $this->wrote[] = $cacheFile; + + $out = Tiger_Google_Analytics::summary($days); // fresh=false → a fresh cache file is served as-is + // The served value is the JSON round-trip of what's on disk (the class decodes it to assoc array). + $this->assertSame(json_decode(json_encode($payload), true), $out); + } + + // ---- testConnection : the connected-but-no-token diagnosis ------------------- + + #[Test] + public function test_connection_reports_no_token_when_the_broker_wont_issue_one(): void + { + $this->connectBroker(); + $r = Tiger_Google_Analytics::testConnection(); + $this->assertFalse($r['ok']); + $this->assertSame('no_token', $r['code']); + } + + /** Mirror Tiger_Google_Analytics::_cacheFile()'s path derivation for a given property + window. */ + private function cacheFile(string $property, int $days): string + { + $base = (defined('APPLICATION_PATH') ? dirname(APPLICATION_PATH) : sys_get_temp_dir()) . '/var/cache/analytics'; + if (!is_dir($base)) { @mkdir($base, 0775, true); } + return $base . '/ga-' . substr(md5($property), 0, 10) . ($days ? '-' . $days . 'd' : '') . '.json'; + } +} diff --git a/tests/Unit/Media/AzureTest.php b/tests/Unit/Media/AzureTest.php new file mode 100644 index 0000000..b975da7 --- /dev/null +++ b/tests/Unit/Media/AzureTest.php @@ -0,0 +1,301 @@ + 'media', 'account' => 'acct'] + $overrides); + } + + /** Two-container adapter (visibility → container; no visibility key prefix). */ + private function two(array $overrides = []): Tiger_Media_Storage_Azure + { + return new Tiger_Media_Storage_Azure( + ['public_container' => 'pubc', 'private_container' => 'privc', 'account' => 'acct'] + $overrides + ); + } + + private function call(Tiger_Media_Storage_Azure $a, string $method, array $args = []) + { + return (new ReflectionMethod($a, $method))->invokeArgs($a, $args); + } + + // ---- construction --------------------------------------------------------------------------- + + #[Test] + public function neither_a_container_nor_a_container_pair_is_a_construct_time_error(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/container/i'); + new Tiger_Media_Storage_Azure(['account' => 'acct']); // no container at all + } + + #[Test] + public function a_single_container_is_enough_to_construct(): void + { + $a = $this->single(); + $this->assertInstanceOf(Tiger_Media_Storage_Azure::class, $a); + } + + // ---- _blob(): the key-mapper + traversal guard ---------------------------------------------- + + #[Test] + public function blob_maps_the_visibility_prefix_in_single_container_mode(): void + { + $a = $this->single(); + $this->assertSame('public/org-1/a.jpg', $this->call($a, '_blob', ['org-1/a.jpg', 'public'])); + $this->assertSame('private/org-1/b.epub', $this->call($a, '_blob', ['org-1/b.epub', 'private'])); + } + + #[Test] + public function blob_omits_the_visibility_prefix_in_two_container_mode(): void + { + // In two-container mode the container carries the visibility, so the blob name is just the key. + $a = $this->two(); + $this->assertSame('org-1/a.jpg', $this->call($a, '_blob', ['org-1/a.jpg', 'public'])); + $this->assertSame('org-1/a.jpg', $this->call($a, '_blob', ['org-1/a.jpg', 'private'])); + } + + #[Test] + public function blob_honors_a_base_prefix(): void + { + $a = $this->single(['prefix' => 'tenant', 'public_prefix' => 'pub']); + $this->assertSame('tenant/pub/x.png', $this->call($a, '_blob', ['x.png', 'public'])); + } + + #[Test] + public function blob_refuses_a_traversal_key(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/invalid key/i'); + $this->call($this->single(), '_blob', ['../../etc/passwd', 'private']); + } + + #[Test] + public function container_selects_by_visibility_in_two_container_mode(): void + { + $a = $this->two(); + $this->assertSame('pubc', $this->call($a, '_container', ['public'])); + $this->assertSame('privc', $this->call($a, '_container', ['private'])); + } + + // ---- url(): PUBLIC is pure string building -------------------------------------------------- + + #[Test] + public function public_url_defaults_to_the_blob_endpoint_two_container(): void + { + // container carries visibility; path = /, host = .blob.core.windows.net + $a = $this->two(); + $this->assertSame( + 'https://acct.blob.core.windows.net/pubc/org-1/a.jpg', + $a->url('org-1/a.jpg', 'public') + ); + } + + #[Test] + public function public_url_defaults_to_the_blob_endpoint_single_container(): void + { + $a = $this->single(); + $this->assertSame( + 'https://acct.blob.core.windows.net/media/public/x.png', + $a->url('x.png', 'public') + ); + } + + #[Test] + public function public_url_prefers_a_cdn_host_when_set(): void + { + $a = $this->two(['cdn' => 'cdn.example.com/']); // trailing slash trimmed + $this->assertSame('https://cdn.example.com/pubc/x.png', $a->url('x.png', 'public')); + } + + #[Test] + public function public_url_honors_an_endpoint_override(): void + { + $a = $this->two(['endpoint' => 'https://acct.blob.core.usgovcloudapi.net/']); + $this->assertSame( + 'https://acct.blob.core.usgovcloudapi.net/pubc/x.png', + $a->url('x.png', 'public') + ); + } + + #[Test] + public function public_url_encodes_path_segments(): void + { + $a = $this->two(); + $this->assertSame( + 'https://acct.blob.core.windows.net/pubc/org-1/my%20photo.jpg', + $a->url('org-1/my photo.jpg', 'public') + ); + } + + // ---- url(): PRIVATE degrades to '' three ways ---------------------------------------------- + + #[Test] + public function private_url_is_empty_when_presigning_is_disabled(): void + { + $a = $this->single(['presign_ttl' => 0, 'key' => 'k']); + $this->assertSame('', $a->url('org-1/book.epub', 'private')); + } + + #[Test] + public function private_url_is_empty_without_an_account_key(): void + { + // presigning on, but no account key → can't sign → '' (stream via the ACL route). + $a = $this->single(['presign_ttl' => 600]); // 'account' set, but no 'key' + $this->assertSame('', $a->url('org-1/book.epub', 'private')); + } + + #[Test] + public function private_url_falls_back_to_empty_when_the_sas_helper_is_absent(): void + { + // account + key present + presigning on, but the Azure SAS helper class is not installed → + // the try/catch returns '' rather than throwing. + $a = $this->single(['presign_ttl' => 600, 'key' => 'c2VjcmV0']); + $this->assertSame('', $a->url('org-1/book.epub', 'private')); + } + + // ---- _fromConnStr(): parse account name / key out of a connection string -------------------- + + #[Test] + public function account_name_and_key_come_from_a_connection_string(): void + { + $cs = 'DefaultEndpointsProtocol=https;AccountName=foo;AccountKey=YmFyPT0=;EndpointSuffix=core.windows.net'; + $a = new Tiger_Media_Storage_Azure(['container' => 'media', 'connection_string' => $cs]); + $this->assertSame('foo', $this->call($a, '_accountName')); + $this->assertSame('YmFyPT0=', $this->call($a, '_accountKey'), 'the value keeps its trailing =/== base64 padding'); + } + + #[Test] + public function an_explicit_account_setting_wins_over_the_connection_string(): void + { + $a = new Tiger_Media_Storage_Azure([ + 'container' => 'media', + 'account' => 'explicit', + 'connection_string' => 'AccountName=fromcs;AccountKey=k', + ]); + $this->assertSame('explicit', $this->call($a, '_accountName')); + } + + #[Test] + public function a_missing_connection_string_field_yields_empty(): void + { + $a = new Tiger_Media_Storage_Azure(['container' => 'media']); + $this->assertSame('', $this->call($a, '_fromConnStr', ['AccountName'])); + } + + // ---- SDK-absent degradation of the readers/writers ----------------------------------------- + + #[Test] + public function client_build_reports_the_missing_sdk(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/azure-storage-blob is not installed/i'); + $this->call($this->single(['key' => 'k']), '_c'); + } + + #[Test] + public function exists_is_false_and_size_is_zero_without_a_client(): void + { + $a = $this->single(['key' => 'k']); + $this->assertFalse($a->exists('org-1/a.jpg', 'public')); + $this->assertSame(0, $a->size('org-1/a.jpg', 'public')); + } + + #[Test] + public function delete_swallows_a_client_failure(): void + { + $this->single(['key' => 'k'])->delete('org-1/a.jpg', 'private'); + $this->assertTrue(true, 'delete() returned without throwing'); + } + + #[Test] + public function get_and_stream_surface_a_runtime_exception_without_a_client(): void + { + $a = $this->single(['key' => 'k']); + try { + $a->get('org-1/a.jpg', 'public'); + $this->fail('get() should have thrown'); + } catch (RuntimeException $e) { + $this->assertMatchesRegularExpression('/not found/i', $e->getMessage()); + } + try { + $a->stream('org-1/a.jpg', 'public'); + $this->fail('stream() should have thrown'); + } catch (RuntimeException $e) { + $this->assertMatchesRegularExpression('/not found/i', $e->getMessage()); + } + } + + #[Test] + public function put_throws_when_the_source_is_unreadable(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/cannot read source/i'); + $this->single(['key' => 'k'])->put('a.jpg', '/no/such/source/file.bin', 'public'); + } + + /** + * CHARACTERIZATION (see WAVE7-FINDINGS-media.md): unlike S3/Gcs — whose only SDK touch is inside + * the guarded `_c()` (friendly "not installed"/"could not store") — Azure's `_create()` + * instantiates `\MicrosoftAzure\Storage\Blob\Models\CreateBlockBlobOptions` BEFORE the try/catch, + * so with the SDK absent put()/write() surface a raw `\Error` (class-not-found), NOT a wrapped + * RuntimeException. This test pins the CURRENT behavior; it is not an endorsement of it. + */ + #[Test] + public function put_from_a_real_source_surfaces_a_class_not_found_error_without_the_sdk(): void + { + $src = tempnam(sys_get_temp_dir(), 'tazsrc'); + file_put_contents($src, 'bytes'); + try { + $this->expectException(\Error::class); + $this->expectExceptionMessageMatches('/CreateBlockBlobOptions|not found/i'); + $this->single(['key' => 'k'])->put('a.jpg', $src, 'public', 'image/jpeg'); + } finally { + @unlink($src); + } + } + + #[Test] + public function write_surfaces_a_class_not_found_error_without_the_sdk(): void + { + // CHARACTERIZATION — same as above: the un-guarded SDK options `new` throws a raw \Error. + $this->expectException(\Error::class); + $this->expectExceptionMessageMatches('/CreateBlockBlobOptions|not found/i'); + $this->single(['key' => 'k'])->write('a.jpg', 'raw-bytes', 'public', 'application/octet-stream'); + } +} diff --git a/tests/Unit/Media/ClamAvTest.php b/tests/Unit/Media/ClamAvTest.php new file mode 100644 index 0000000..712d6e4 --- /dev/null +++ b/tests/Unit/Media/ClamAvTest.php @@ -0,0 +1,75 @@ +/dev/null', $out, $code); + if ($code === 0 && !empty($out)) { + $this->markTestSkipped("ClamAV ($bin) is installed on this host — the absent path can't be forced."); + } + } + } + + #[Test] + public function scan_reports_an_error_verdict_when_no_clamav_binary_is_present(): void + { + $scanner = new Tiger_Media_Scanner_ClamAv(); + $r = $scanner->scan('/etc/hosts', 'text/plain'); + + $this->assertIsArray($r); + $this->assertSame('error', $r['status'], 'no binary → a fail-open error verdict, never a throw'); + $this->assertMatchesRegularExpression('/not installed/i', (string) $r['reason']); + $this->assertArrayHasKey('meta', $r); + } + + #[Test] + public function scan_never_throws_even_for_a_nonexistent_path(): void + { + $scanner = new Tiger_Media_Scanner_ClamAv(); + $r = $scanner->scan('/no/such/file/anywhere.bin', null); + $this->assertSame('error', $r['status']); + } + + #[Test] + public function which_is_false_for_an_absent_binary(): void + { + $scanner = new Tiger_Media_Scanner_ClamAv(); + $m = new ReflectionMethod($scanner, '_which'); + $this->assertFalse($m->invoke($scanner, 'clamdscan')); + $this->assertFalse($m->invoke($scanner, 'definitely-not-a-real-binary-xyz')); + } + + #[Test] + public function run_returns_null_for_an_absent_binary(): void + { + $scanner = new Tiger_Media_Scanner_ClamAv(); + $m = new ReflectionMethod($scanner, '_run'); + $this->assertNull($m->invoke($scanner, 'clamdscan', '--fdpass', '/etc/hosts')); + } +} diff --git a/tests/Unit/Media/FilesystemMoreTest.php b/tests/Unit/Media/FilesystemMoreTest.php new file mode 100644 index 0000000..1063056 --- /dev/null +++ b/tests/Unit/Media/FilesystemMoreTest.php @@ -0,0 +1,152 @@ +sandbox = sys_get_temp_dir() . '/tiger-fsm-' . bin2hex(random_bytes(6)); + $this->publicRoot = $this->sandbox . '/public/_media'; + $this->privateRoot = $this->sandbox . '/storage/media'; + mkdir($this->publicRoot, 0777, true); + mkdir($this->privateRoot, 0777, true); + } + + protected function tearDown(): void + { + $this->rmrf($this->sandbox); + parent::tearDown(); + } + + private function adapter(): Tiger_Media_Storage_Filesystem + { + return new Tiger_Media_Storage_Filesystem([ + 'public_root' => $this->publicRoot, + 'private_root' => $this->privateRoot, + 'public_url' => '/_media', + ]); + } + + private function rmrf(string $dir): void + { + if (!is_dir($dir)) { + @unlink($dir); + return; + } + foreach (scandir($dir) as $e) { + if ($e === '.' || $e === '..') { + continue; + } + $path = $dir . '/' . $e; + is_dir($path) && !is_link($path) ? $this->rmrf($path) : @unlink($path); + } + @rmdir($dir); + } + + // ---- stream() ------------------------------------------------------------------------------- + + #[Test] + public function stream_opens_a_readable_handle_over_the_stored_bytes(): void + { + $a = $this->adapter(); + $a->write('org-1/a.txt', 'hello stream', 'private'); + + $fh = $a->stream('org-1/a.txt', 'private'); + $this->assertIsResource($fh); + $this->assertSame('hello stream', stream_get_contents($fh)); + fclose($fh); + } + + #[Test] + public function stream_throws_on_a_missing_file(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/not found/i'); + $this->adapter()->stream('org-1/missing.txt', 'private'); + } + + // ---- put() write-failure arm ---------------------------------------------------------------- + + #[Test] + public function put_throws_when_the_source_cannot_be_copied(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/could not store/i'); + $this->adapter()->put('org-1/a.jpg', '/no/such/source/file.bin', 'public'); + } + + #[Test] + public function write_throws_when_the_target_directory_cannot_be_created(): void + { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('running as root — permission-based write failure cannot be forced.'); + } + // Point the private root at a read-only directory so _ensureDir() can't mkdir a subdir under it. + $locked = $this->sandbox . '/locked'; + mkdir($locked, 0500, true); + $a = new Tiger_Media_Storage_Filesystem(['private_root' => $locked, 'public_root' => $this->publicRoot]); + try { + $a->write('sub/dir/a.txt', 'bytes', 'private'); + @chmod($locked, 0700); + $this->markTestSkipped('the filesystem allowed the write despite 0500 — cannot force the failure here.'); + } catch (RuntimeException $e) { + $this->assertMatchesRegularExpression('/could not (create|write)/i', $e->getMessage()); + } finally { + @chmod($locked, 0700); + } + } + + // ---- size() --------------------------------------------------------------------------------- + + #[Test] + public function size_is_zero_for_a_missing_key(): void + { + $this->assertSame(0, $this->adapter()->size('org-1/missing.bin', 'public')); + } + + // ---- _absolute() root resolution ------------------------------------------------------------ + + #[Test] + public function a_relative_configured_root_hangs_off_the_application_root(): void + { + $a = new Tiger_Media_Storage_Filesystem(['public_root' => 'var/media-pub', 'private_root' => 'var/media-priv']); + $pub = (new ReflectionProperty($a, '_publicRoot'))->getValue($a); + $priv = (new ReflectionProperty($a, '_privateRoot'))->getValue($a); + $this->assertSame(rtrim(APPLICATION_ROOT, '/') . '/var/media-pub', $pub); + $this->assertSame(rtrim(APPLICATION_ROOT, '/') . '/var/media-priv', $priv); + } + + #[Test] + public function an_all_defaults_config_lands_on_the_documented_roots(): void + { + $a = new Tiger_Media_Storage_Filesystem([]); + $base = rtrim(APPLICATION_ROOT, '/'); + $this->assertSame($base . '/public/_media', (new ReflectionProperty($a, '_publicRoot'))->getValue($a)); + $this->assertSame($base . '/storage/media', (new ReflectionProperty($a, '_privateRoot'))->getValue($a)); + $this->assertSame('/_media', (new ReflectionProperty($a, '_publicUrl'))->getValue($a)); + } +} diff --git a/tests/Unit/Media/GcsTest.php b/tests/Unit/Media/GcsTest.php new file mode 100644 index 0000000..26b3d39 --- /dev/null +++ b/tests/Unit/Media/GcsTest.php @@ -0,0 +1,208 @@ + 'my-bucket'] + $overrides); + } + + private function call(Tiger_Media_Storage_Gcs $a, string $method, array $args = []) + { + return (new ReflectionMethod($a, $method))->invokeArgs($a, $args); + } + + // ---- construction --------------------------------------------------------------------------- + + #[Test] + public function a_missing_bucket_is_a_construct_time_error(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/bucket is required/i'); + new Tiger_Media_Storage_Gcs(['region' => 'us']); // no bucket + } + + // ---- _fullKey(): the key-mapper + traversal guard ------------------------------------------ + + #[Test] + public function full_key_maps_visibility_to_the_expected_prefix(): void + { + $a = $this->adapter(); + $this->assertSame('public/org-1/a.jpg', $this->call($a, '_fullKey', ['org-1/a.jpg', 'public'])); + $this->assertSame('private/org-1/b.epub', $this->call($a, '_fullKey', ['org-1/b.epub', 'private'])); + } + + #[Test] + public function full_key_honors_a_base_prefix(): void + { + $a = $this->adapter(['prefix' => 'tenant', 'public_prefix' => 'pub', 'private_prefix' => 'priv']); + $this->assertSame('tenant/pub/x.png', $this->call($a, '_fullKey', ['x.png', 'public'])); + $this->assertSame('tenant/priv/x.png', $this->call($a, '_fullKey', ['x.png', 'private'])); + } + + #[Test] + public function full_key_refuses_a_traversal_key(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/invalid key/i'); + $this->call($this->adapter(), '_fullKey', ['../../etc/passwd', 'private']); + } + + // ---- url(): PUBLIC is pure string building -------------------------------------------------- + + #[Test] + public function public_url_defaults_to_the_google_storage_host(): void + { + $a = $this->adapter(); + $this->assertSame( + 'https://storage.googleapis.com/my-bucket/public/org-1/a.jpg', + $a->url('org-1/a.jpg', 'public') + ); + } + + #[Test] + public function public_url_prefers_a_cdn_host_when_set(): void + { + $a = $this->adapter(['cdn' => 'cdn.example.com/']); // trailing slash trimmed + $this->assertSame('https://cdn.example.com/public/x.png', $a->url('x.png', 'public')); + } + + #[Test] + public function public_url_encodes_path_segments(): void + { + $a = $this->adapter(); + $this->assertSame( + 'https://storage.googleapis.com/my-bucket/public/org-1/my%20photo.jpg', + $a->url('org-1/my photo.jpg', 'public') + ); + } + + // ---- url(): PRIVATE degrades to '' --------------------------------------------------------- + + #[Test] + public function private_url_is_empty_when_signing_is_disabled(): void + { + $a = $this->adapter(['presign_ttl' => 0]); + $this->assertSame('', $a->url('org-1/book.epub', 'private')); + } + + #[Test] + public function private_url_falls_back_to_empty_when_the_client_cannot_be_built(): void + { + // presigning on, but the GCS SDK is absent → _object()/_bkt() throws → caught → ''. + $a = $this->adapter(['presign_ttl' => 600]); + $this->assertSame('', $a->url('org-1/book.epub', 'private')); + } + + // ---- SDK-absent degradation of the readers/writers ----------------------------------------- + + #[Test] + public function bucket_handle_reports_the_missing_sdk(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/google\/cloud-storage is not installed/i'); + $this->call($this->adapter(['project_id' => 'p', 'key_file' => '/tmp/none.json']), '_bkt'); + } + + #[Test] + public function exists_is_false_and_size_is_zero_without_a_client(): void + { + $a = $this->adapter(); + $this->assertFalse($a->exists('org-1/a.jpg', 'public')); + $this->assertSame(0, $a->size('org-1/a.jpg', 'public')); + } + + #[Test] + public function delete_swallows_a_client_failure(): void + { + $this->adapter()->delete('org-1/a.jpg', 'private'); + $this->assertTrue(true, 'delete() returned without throwing'); + } + + #[Test] + public function get_and_stream_surface_a_runtime_exception_without_a_client(): void + { + $a = $this->adapter(); + try { + $a->get('org-1/a.jpg', 'public'); + $this->fail('get() should have thrown'); + } catch (RuntimeException $e) { + $this->assertMatchesRegularExpression('/not found/i', $e->getMessage()); + } + try { + $a->stream('org-1/a.jpg', 'public'); + $this->fail('stream() should have thrown'); + } catch (RuntimeException $e) { + $this->assertMatchesRegularExpression('/not found/i', $e->getMessage()); + } + } + + #[Test] + public function put_throws_when_the_source_is_unreadable(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/cannot read source/i'); + $this->adapter()->put('a.jpg', '/no/such/source/file.bin', 'public'); + } + + #[Test] + public function put_from_a_real_source_surfaces_the_store_failure_without_a_client(): void + { + $src = tempnam(sys_get_temp_dir(), 'tgcssrc'); + file_put_contents($src, 'bytes'); + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/could not store/i'); + $this->adapter()->put('a.jpg', $src, 'public', 'image/jpeg'); + } finally { + @unlink($src); + } + } + + #[Test] + public function write_surfaces_the_store_failure_without_a_client(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/could not store/i'); + $this->adapter()->write('a.jpg', 'raw-bytes', 'public', 'application/octet-stream'); + } + + #[Test] + public function write_with_the_public_acl_flag_still_surfaces_the_store_failure(): void + { + // public_acl=1 takes the predefinedAcl arm of _upload before _bkt() throws. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/could not store/i'); + $this->adapter(['public_acl' => 1])->write('a.jpg', 'raw', 'public'); + } +} diff --git a/tests/Unit/Media/RekognitionTest.php b/tests/Unit/Media/RekognitionTest.php new file mode 100644 index 0000000..caf4b70 --- /dev/null +++ b/tests/Unit/Media/RekognitionTest.php @@ -0,0 +1,69 @@ +markTestSkipped('aws-sdk is installed — the SDK-absent path can’t be forced.'); + } + } + + #[Test] + public function the_constructor_defaults_the_threshold_and_region(): void + { + $s = new Tiger_Media_Scanner_Rekognition(); + $this->assertSame(80.0, (new ReflectionProperty($s, '_threshold'))->getValue($s)); + $this->assertSame('us-east-1', (new ReflectionProperty($s, '_region'))->getValue($s)); + } + + #[Test] + public function the_constructor_carries_an_explicit_threshold_and_region(): void + { + $s = new Tiger_Media_Scanner_Rekognition(95.5, 'eu-west-1'); + $this->assertSame(95.5, (new ReflectionProperty($s, '_threshold'))->getValue($s)); + $this->assertSame('eu-west-1', (new ReflectionProperty($s, '_region'))->getValue($s)); + } + + #[Test] + public function scan_reports_an_error_verdict_without_the_sdk(): void + { + $r = (new Tiger_Media_Scanner_Rekognition())->scan('/etc/hosts', 'image/png'); + + $this->assertSame('error', $r['status'], 'no SDK → fail-open error, never a throw'); + $this->assertMatchesRegularExpression('/aws-sdk-php not installed/i', (string) $r['reason']); + $this->assertSame([], $r['meta']); + } + + #[Test] + public function submit_video_returns_null_without_the_sdk(): void + { + $r = (new Tiger_Media_Scanner_Rekognition()) + ->submitVideo('bucket', 'private/vid.mp4', 'arn:sns:topic', 'arn:iam:role'); + $this->assertNull($r); + } +} diff --git a/tests/Unit/Media/S3PutTest.php b/tests/Unit/Media/S3PutTest.php new file mode 100644 index 0000000..02a6476 --- /dev/null +++ b/tests/Unit/Media/S3PutTest.php @@ -0,0 +1,59 @@ + 'my-bucket', 'region' => 'us-east-1'] + $overrides); + } + + #[Test] + public function put_surfaces_a_could_not_store_error_without_the_sdk(): void + { + $src = tempnam(sys_get_temp_dir(), 's3src'); + file_put_contents($src, 'bytes'); + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/could not store/i'); + $this->adapter()->put('org-1/a.jpg', $src, 'public', 'image/jpeg'); + } finally { + @unlink($src); + } + } + + #[Test] + public function write_surfaces_a_could_not_store_error_without_the_sdk(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/could not store/i'); + $this->adapter()->write('org-1/a.jpg', 'raw-bytes', 'private', 'application/octet-stream'); + } + + #[Test] + public function write_takes_the_public_acl_arm_before_the_store_failure(): void + { + // public_acl=1 + a PUBLIC visibility exercises the ACL='public-read' argument arm of + // _putObject() before _c() (SDK absent) fails. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/could not store/i'); + $this->adapter(['public_acl' => 1])->write('org-1/a.jpg', 'raw', 'public'); + } +} diff --git a/tests/Unit/Media/StorageMoreTest.php b/tests/Unit/Media/StorageMoreTest.php new file mode 100644 index 0000000..a270bab --- /dev/null +++ b/tests/Unit/Media/StorageMoreTest.php @@ -0,0 +1,57 @@ +setConfig(['media' => ['disks' => ['g' => ['adapter' => 'gcs', 'bucket' => 'my-bucket']]]]); + $this->assertInstanceOf(Tiger_Media_Storage_Gcs::class, Tiger_Media_Storage::disk('g')); + } + + #[Test] + public function resolves_an_azure_disk_to_the_azure_adapter(): void + { + $this->setConfig(['media' => ['disks' => ['az' => ['adapter' => 'azure', 'container' => 'media']]]]); + $this->assertInstanceOf(Tiger_Media_Storage_Azure::class, Tiger_Media_Storage::disk('az')); + } + + #[Test] + public function the_adapter_name_is_matched_case_insensitively(): void + { + // _build() lowercases the adapter token, so 'S3'/'GCS' resolve like their lowercase forms. + $this->setConfig(['media' => ['disks' => ['g' => ['adapter' => 'GCS', 'bucket' => 'b']]]]); + $this->assertInstanceOf(Tiger_Media_Storage_Gcs::class, Tiger_Media_Storage::disk('g')); + } +} diff --git a/tests/Unit/Module/CompatCoverageTest.php b/tests/Unit/Module/CompatCoverageTest.php new file mode 100644 index 0000000..617c02b --- /dev/null +++ b/tests/Unit/Module/CompatCoverageTest.php @@ -0,0 +1,39 @@ +assertIsString($v); + $this->assertNotSame('', $v); + // It's the canonical running version — TIGER_VERSION when defined, else the class constant. + $expected = defined('TIGER_VERSION') ? TIGER_VERSION : \Tiger_Version::VERSION; + $this->assertSame($expected, $v); + } + + #[Test] + public function satisfies_fails_open_on_an_unparseable_constraint(): void + { + // No digit for the matcher to anchor on → the regex misses → advisory "satisfied" (never a false block). + $this->assertTrue(Tiger_Module_Compat::satisfies('1.0.0', 'not-a-version')); + $this->assertTrue(Tiger_Module_Compat::satisfies('1.0.0', '>=garbage')); + } +} diff --git a/tests/Unit/Update/ComposerTest.php b/tests/Unit/Update/ComposerTest.php new file mode 100644 index 0000000..0f569ba --- /dev/null +++ b/tests/Unit/Update/ComposerTest.php @@ -0,0 +1,105 @@ +` in-process, then re-read Version.php from disk. The real + * `composer update` (an unbounded proc_open against the LIVE app root, with process-env side effects) + * is NOT run here — that's the destructive boundary (see WAVE7-FINDINGS-netparse.md). What's covered: + * the `possible()` capability verdict, `update()`'s no-package guard (which returns before any spawn), + * and the pure helpers that surround the spawn — the process runner `_run()` (driven with a harmless + * `echo` / non-zero `exit` so the pipe-plumbing + exit-code path runs without Composer), the disk + * Version.php reader, and the output tail-truncator. + */ +#[CoversClass(Tiger_Update_Composer::class)] +final class ComposerTest extends UnitTestCase +{ + private string $tmp = ''; + + protected function setUp(): void + { + parent::setUp(); + $this->tmp = sys_get_temp_dir() . '/tiger_updcomposer_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->tmp, 0775, true); + } + + protected function tearDown(): void + { + if (is_dir($this->tmp)) { + foreach (scandir($this->tmp) ?: [] as $f) { + if ($f !== '.' && $f !== '..') { @unlink($this->tmp . '/' . $f); } + } + @rmdir($this->tmp); + } + parent::tearDown(); + } + + #[Test] + public function possible_is_a_boolean_verdict(): void + { + $this->assertIsBool(Tiger_Update_Composer::possible()); + } + + #[Test] + public function update_without_a_package_fails_before_spawning_composer(): void + { + $this->setConfig(['tiger' => ['log' => ['writer' => 'null']]]); // the fail-step logs via Tiger_Log — silence it + $res = Tiger_Update_Composer::update([]); // no 'package' → earliest guard, no proc_open + $this->assertFalse($res['ok']); + $last = end($res['log']); + $this->assertSame('error', $last['step']); + $this->assertStringContainsString('No package to update', $last['detail']); + } + + #[Test] + public function run_captures_stdout_and_a_zero_exit(): void + { + [$code, $out] = UpdateComposerProbe::run('echo tiger-run-ok', $this->tmp, 10); + $this->assertSame(0, $code); + $this->assertStringContainsString('tiger-run-ok', $out); + } + + #[Test] + public function run_reports_a_non_zero_exit_code(): void + { + [$code, $out] = UpdateComposerProbe::run('exit 3', $this->tmp, 10); + $this->assertSame(3, $code); + $this->assertIsString($out); + } + + #[Test] + public function version_in_reads_the_constant_or_null(): void + { + $file = $this->tmp . '/Version.php'; + file_put_contents($file, "assertSame('1.2.3-beta', UpdateComposerProbe::versionIn($file)); + $this->assertNull(UpdateComposerProbe::versionIn($this->tmp . '/missing.php')); + } + + #[Test] + public function tail_truncates_only_when_longer_than_the_limit(): void + { + $this->assertSame('short', UpdateComposerProbe::tail(' short ', 100)); + + $long = str_repeat('x', 50); + $tail = UpdateComposerProbe::tail($long, 10); + $this->assertStringStartsWith('…', $tail); + $this->assertSame(11, mb_strlen($tail), 'the ellipsis + the last 10 chars'); + } +} + +/** Test seam: expose Tiger_Update_Composer's protected process/parse helpers (never a real update). */ +final class UpdateComposerProbe extends Tiger_Update_Composer +{ + public static function run($cmd, $cwd, $to): array { return self::_run($cmd, $cwd, $to); } + public static function versionIn($f) { return self::_versionIn($f); } + public static function tail($s, $m): string { return self::_tail($s, $m); } +} diff --git a/tests/Unit/Update/CoreTest.php b/tests/Unit/Update/CoreTest.php new file mode 100644 index 0000000..22fb1a8 --- /dev/null +++ b/tests/Unit/Update/CoreTest.php @@ -0,0 +1,245 @@ +tmp = sys_get_temp_dir() . '/tiger_updcore_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->tmp, 0775, true); + } + + protected function tearDown(): void + { + $this->rrmdir($this->tmp); + parent::tearDown(); + } + + // ---- pure comparators / formatters ---------------------------------------- + + #[Test] + public function norm_strips_a_leading_v_and_trims(): void + { + $this->assertSame('1.2.3', UpdateCoreProbe::norm('v1.2.3')); + $this->assertSame('2', UpdateCoreProbe::norm(' V2 ')); + $this->assertSame('0.41.0-beta', UpdateCoreProbe::norm('0.41.0-beta')); + } + + #[Test] + public function hsize_formats_bytes_kb_and_mb(): void + { + $this->assertSame('512 B', UpdateCoreProbe::hsize(512)); + $this->assertSame('0 B', UpdateCoreProbe::hsize(0)); + $this->assertSame('2 KB', UpdateCoreProbe::hsize(2048)); + $this->assertSame('1.5 MB', UpdateCoreProbe::hsize((int) (1.5 * 1048576))); + } + + #[Test] + public function can_extract_is_true_when_a_zip_or_phar_extractor_is_present(): void + { + $this->assertTrue(UpdateCoreProbe::canExtract()); // ZipArchive is present in the harness + } + + #[Test] + public function app_root_resolves_to_a_real_directory(): void + { + $root = UpdateCoreProbe::appRoot(); + $this->assertIsString($root); + $this->assertNotSame('', $root); + } + + // ---- download branches ----------------------------------------------------- + + #[Test] + public function download_copies_a_file_url_to_the_destination(): void + { + $src = $this->tmp . '/src.bin'; + file_put_contents($src, 'payload-bytes'); + $dest = $this->tmp . '/dest.bin'; + + $this->assertTrue(UpdateCoreProbe::download('file://' . $src, $dest)); + $this->assertSame('payload-bytes', file_get_contents($dest)); + } + + #[Test] + public function download_reads_a_bare_absolute_path_too(): void + { + $src = $this->tmp . '/abs.bin'; + file_put_contents($src, 'abc'); + $dest = $this->tmp . '/abs-dest.bin'; + + $this->assertTrue(UpdateCoreProbe::download($src, $dest)); // leading '/' → file_get_contents branch + $this->assertSame('abc', file_get_contents($dest)); + } + + #[Test] + public function download_returns_false_when_the_http_endpoint_is_unreachable(): void + { + // A dead local port → curl fails fast (connection refused), no real network. + $this->assertFalse(UpdateCoreProbe::download('http://127.0.0.1:1/nope.zip', $this->tmp . '/x.zip')); + } + + // ---- extract (+ zip-slip guard) ------------------------------------------- + + #[Test] + public function extract_unpacks_a_clean_zip(): void + { + $zip = $this->tmp . '/clean.zip'; + $za = new ZipArchive(); + $za->open($zip, ZipArchive::CREATE); + $za->addFromString('a/b.txt', 'hello'); + $za->close(); + + $into = $this->tmp . '/out'; + @mkdir($into, 0775, true); + $this->assertTrue(UpdateCoreProbe::extract($zip, $into)); + $this->assertSame('hello', file_get_contents($into . '/a/b.txt')); + } + + #[Test] + public function extract_refuses_a_zip_with_a_traversal_entry(): void + { + $zip = $this->tmp . '/evil.zip'; + $za = new ZipArchive(); + $za->open($zip, ZipArchive::CREATE); + $za->addFromString('../escape.txt', 'boom'); // zip-slip + $za->close(); + + $into = $this->tmp . '/evil-out'; + @mkdir($into, 0775, true); + $this->assertFalse(UpdateCoreProbe::extract($zip, $into), 'a .. entry must be refused before extraction'); + $this->assertFileDoesNotExist($this->tmp . '/escape.txt'); + } + + // ---- staging-tree helpers -------------------------------------------------- + + #[Test] + public function locate_vendor_finds_a_direct_or_nested_vendor_dir(): void + { + $direct = $this->tmp . '/stage-direct'; + @mkdir($direct . '/vendor', 0775, true); + $this->assertSame($direct . '/vendor', UpdateCoreProbe::locateVendor($direct)); + + $nested = $this->tmp . '/stage-nested'; + @mkdir($nested . '/tiger-core-0.6.0/vendor', 0775, true); + $this->assertSame($nested . '/tiger-core-0.6.0/vendor', UpdateCoreProbe::locateVendor($nested)); + + $none = $this->tmp . '/stage-none'; + @mkdir($none, 0775, true); + $this->assertNull(UpdateCoreProbe::locateVendor($none)); + } + + #[Test] + public function version_in_reads_the_version_constant_from_the_staged_tree(): void + { + $vendor = $this->tmp . '/vendor'; + $dir = $vendor . '/webtigers/tiger-core/library/Tiger'; + @mkdir($dir, 0775, true); + file_put_contents($dir . '/Version.php', "assertSame('9.9.9-beta', UpdateCoreProbe::versionIn($vendor)); + $this->assertNull(UpdateCoreProbe::versionIn($this->tmp . '/no-such-vendor')); + } + + // ---- maintenance flag + recursive rmdir ----------------------------------- + + #[Test] + public function maintenance_writes_then_removes_the_flag(): void + { + $work = $this->tmp . '/work'; + @mkdir($work, 0775, true); + $flag = $work . '/.maintenance'; + + UpdateCoreProbe::maintenance($work, true); + $this->assertFileExists($flag); + + UpdateCoreProbe::maintenance($work, false); + $this->assertFileDoesNotExist($flag); + } + + #[Test] + public function rrmdir_removes_a_nested_tree(): void + { + $root = $this->tmp . '/tree'; + @mkdir($root . '/a/b', 0775, true); + file_put_contents($root . '/a/b/leaf.txt', 'x'); + UpdateCoreProbe::rrmdir($root); + $this->assertDirectoryDoesNotExist($root); + } + + // ---- host-capability probes + the update() no-URL guard -------------------- + + #[Test] + public function possible_and_maintenance_flag_report_the_host_state(): void + { + $this->assertIsBool(Tiger_Update_Core::possible()); + $this->assertSame(UpdateCoreProbe::appRoot() . '/var/update/.maintenance', Tiger_Update_Core::maintenanceFlag()); + } + + #[Test] + public function update_fails_before_touching_the_app_root_when_no_url_is_supplied(): void + { + $this->setConfig(['tiger' => ['log' => ['writer' => 'null']]]); // the fail-step logs via Tiger_Log — silence it + $res = Tiger_Update_Core::update([]); // no 'url' → the earliest guard, no vendor/ is touched + $this->assertFalse($res['ok']); + $this->assertNotEmpty($res['log']); + $last = end($res['log']); + $this->assertSame('error', $last['step']); + $this->assertStringContainsString('No release-ZIP URL', $last['detail']); + } + + // ---- helpers --------------------------------------------------------------- + + private function rrmdir(string $dir): void + { + if (!is_dir($dir)) { return; } + foreach (scandir($dir) ?: [] as $f) { + if ($f === '.' || $f === '..') { continue; } + $p = $dir . '/' . $f; + (is_dir($p) && !is_link($p)) ? $this->rrmdir($p) : @unlink($p); + } + @rmdir($dir); + } +} + +/** Test seam: expose Tiger_Update_Core's protected build/parse/io helpers (never the live swap). */ +final class UpdateCoreProbe extends Tiger_Update_Core +{ + public static function norm($v): string { return self::_norm($v); } + public static function hsize($b): string { return self::_hsize($b); } + public static function canExtract(): bool { return self::_canExtract(); } + public static function appRoot(): string { return self::_appRoot(); } + public static function download($u, $d): bool { return self::_download($u, $d); } + public static function extract($a, $i): bool { return self::_extract($a, $i); } + public static function locateVendor($s) { return self::_locateVendor($s); } + public static function versionIn($d) { return self::_versionIn($d); } + public static function maintenance($w, $on): void { self::_maintenance($w, $on); } + public static function rrmdir($d): void { self::_rrmdir($d); } +} diff --git a/tests/Unit/VendorEnvironmentTest.php b/tests/Unit/VendorEnvironmentTest.php new file mode 100644 index 0000000..98021cf --- /dev/null +++ b/tests/Unit/VendorEnvironmentTest.php @@ -0,0 +1,112 @@ +assertSame($expected, Tiger_Vendor_Environment::execEnabled()); + } + + #[Test] + public function function_enabled_is_false_for_a_missing_function_and_honors_the_real_registry(): void + { + $this->assertFalse(VendorEnvProbe::fnEnabled('a_function_that_does_not_exist_zzz')); + // A function that exists and isn't in disable_functions resolves true (json_encode is always there). + $this->assertTrue(VendorEnvProbe::fnEnabled('json_encode')); + } + + #[Test] + public function app_root_is_the_defined_application_root(): void + { + $this->assertSame(APPLICATION_ROOT, Tiger_Vendor_Environment::appRoot()); + $this->assertDirectoryExists(Tiger_Vendor_Environment::appRoot()); + } + + #[Test] + public function store_dir_sits_beside_composers_vendor(): void + { + $this->assertSame(APPLICATION_ROOT . '/vendor-libs', Tiger_Vendor_Environment::storeDir()); + } + + #[Test] + public function vendor_and_store_writability_are_booleans(): void + { + $this->assertIsBool(Tiger_Vendor_Environment::vendorWritable()); + $this->assertIsBool(Tiger_Vendor_Environment::storeWritable()); + } + + #[Test] + public function composer_binary_is_a_string_or_null(): void + { + $bin = Tiger_Vendor_Environment::composerBinary(); + $this->assertTrue($bin === null || is_string($bin)); + if ($bin !== null) { + $this->assertNotSame('', $bin); + } + } + + #[Test] + public function composer_usable_is_the_conjunction_of_a_binary_and_a_writable_vendor(): void + { + $expected = Tiger_Vendor_Environment::composerBinary() !== null + && Tiger_Vendor_Environment::vendorWritable(); + $this->assertSame($expected, Tiger_Vendor_Environment::composerUsable()); + } + + #[Test] + public function report_carries_every_capability_key_with_the_right_types(): void + { + $r = Tiger_Vendor_Environment::report(); + + $this->assertSame( + ['exec_enabled', 'composer', 'composer_usable', 'vendor_writable', 'store', 'store_writable'], + array_keys($r) + ); + $this->assertIsBool($r['exec_enabled']); + $this->assertTrue($r['composer'] === null || is_string($r['composer'])); + $this->assertIsBool($r['composer_usable']); + $this->assertIsBool($r['vendor_writable']); + $this->assertSame(Tiger_Vendor_Environment::storeDir(), $r['store']); + $this->assertIsBool($r['store_writable']); + } + + #[Test] + public function phar_candidates_point_at_the_app_root_composer_phar_spots(): void + { + $cands = VendorEnvProbe::pharCandidates(); + $this->assertSame([ + APPLICATION_ROOT . '/composer.phar', + APPLICATION_ROOT . '/bin/composer.phar', + ], $cands); + } +} + +/** Test seam: expose Tiger_Vendor_Environment's protected capability helpers. */ +final class VendorEnvProbe extends Tiger_Vendor_Environment +{ + public static function fnEnabled(string $fn): bool { return self::_functionEnabled($fn); } + public static function pharCandidates(): array { return self::_pharCandidates(); } +} diff --git a/tests/Unit/VendorExtraTest.php b/tests/Unit/VendorExtraTest.php new file mode 100644 index 0000000..4b18362 --- /dev/null +++ b/tests/Unit/VendorExtraTest.php @@ -0,0 +1,183 @@ +tmp = sys_get_temp_dir() . '/tiger_vex_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->tmp, 0775, true); + $this->storePreExisted = is_dir(Tiger_Vendor_Environment::storeDir()); + } + + protected function tearDown(): void + { + $store = Tiger_Vendor_Environment::storeDir(); + if (!$this->storePreExisted && is_dir($store)) { + $this->rrmdir($store); + } else { + foreach ($this->slugs as $slug) { $this->rrmdir($store . '/' . $slug); } + } + $this->rrmdir($this->tmp); + parent::tearDown(); + } + + // ---- installTarball(): failure branches ------------------------------------ + + #[Test] + public function install_tarball_reports_a_failed_download(): void + { + $r = Tiger_Vendor::installTarball('file://' . $this->tmp . '/does-not-exist.tar.gz', 'test/vex-dl'); + $this->assertFalse($r['ok']); + $this->assertStringContainsString('Download failed', $r['message']); + $this->assertDirectoryDoesNotExist(Tiger_Vendor_Environment::storeDir() . '/test-vex-dl'); + } + + #[Test] + public function install_tarball_reports_an_archive_it_cannot_extract(): void + { + // A downloadable file that is NOT a valid tar.gz → download succeeds, extract fails. + $junk = $this->tmp . '/not-a-tar.tar.gz'; + file_put_contents($junk, 'this is definitely not a gzip tar archive'); + + $r = Tiger_Vendor::installTarball('file://' . $junk, 'test/vex-bad'); + $this->assertFalse($r['ok']); + $this->assertStringContainsString('extract', strtolower($r['message'])); + } + + // ---- installAsset(): failure branches -------------------------------------- + + #[Test] + public function install_asset_reports_a_failed_download(): void + { + $moduleDir = $this->tmp . '/mod-dl'; + @mkdir($moduleDir, 0775, true); + $r = Tiger_Vendor::installAsset( + ['name' => 'w', 'tarball' => 'file://' . $this->tmp . '/nope.tar.gz', 'target' => 'assets', 'include' => ['x.js']], + $moduleDir + ); + $this->assertFalse($r['ok']); + $this->assertStringContainsString('Download failed', $r['message']); + } + + #[Test] + public function install_asset_reports_an_archive_it_cannot_extract(): void + { + $junk = $this->tmp . '/asset-junk.tar.gz'; + file_put_contents($junk, 'not an archive'); + $moduleDir = $this->tmp . '/mod-bad'; + @mkdir($moduleDir, 0775, true); + + $r = Tiger_Vendor::installAsset( + ['name' => 'w', 'tarball' => 'file://' . $junk, 'target' => 'assets', 'include' => ['x.js']], + $moduleDir + ); + $this->assertFalse($r['ok']); + $this->assertStringContainsString('extract', strtolower($r['message'])); + } + + // ---- the semver matcher's smallest-segment caret --------------------------- + + #[Test] + public function caret_on_a_double_zero_version_pins_the_patch(): void + { + // ^0.0.3 ⇒ >=0.0.3 <0.0.4 — the _caretUpper branch where both major and minor are zero. + $this->assertTrue(Tiger_Vendor::satisfies('0.0.3', '^0.0.3')); + $this->assertFalse(Tiger_Vendor::satisfies('0.0.4', '^0.0.3'), 'a 0.0.x caret pins the patch'); + } + + // ---- installedVersion() via Composer's installed.json ---------------------- + + #[Test] + public function installed_version_reads_composers_installed_json(): void + { + // Pick a package genuinely present in the app's vendor/composer/installed.json. + $installed = Tiger_Vendor_Environment::appRoot() . '/vendor/composer/installed.json'; + $this->assertFileExists($installed, 'the test run has a Composer install to read'); + $j = json_decode((string) file_get_contents($installed), true); + $packages = $j['packages'] ?? $j; + $name = $packages[0]['name'] ?? null; + $this->assertNotNull($name, 'installed.json lists at least one package'); + + $version = Tiger_Vendor::installedVersion($name); + $this->assertNotNull($version, 'the installed version is resolved from installed.json'); + $this->assertMatchesRegularExpression('/\d/', (string) $version); + } + + // ---- ensure() Tier-3 tarball → generated autoloader with a files entry ----- + + #[Test] + public function ensure_generates_an_autoloader_that_includes_an_autoload_files_entry(): void + { + // A raw source tarball (no constraint → NO registry lookup) carrying a composer.json whose autoload + // block declares BOTH psr-4 and files → the generated autoload.php requires the files entry. + $tar = $this->makeLibTarGz('test-vex-auto', [ + 'psr-4' => ['VexLib\\' => 'src/'], + 'files' => ['helper.php'], + ]); + + $r = Tiger_Vendor::ensure(['name' => 'test/vex-auto', 'tarball' => 'file://' . $tar]); + $this->assertTrue($r['ok'], $r['message'] ?? ''); + $this->assertSame('tarball', $r['tier']); + + $auto = Tiger_Vendor_Environment::storeDir() . '/test-vex-auto/autoload.php'; + $this->assertFileExists($auto); + $gen = (string) file_get_contents($auto); + $this->assertStringContainsString('helper.php', $gen, 'the files[] entry is wired into the generated autoloader'); + } + + // ---- helpers --------------------------------------------------------------- + + private function makeLibTarGz(string $top, array $autoload): string + { + $base = $this->tmp . '/' . $top . '_' . bin2hex(random_bytes(3)) . '.tar'; + $phar = new \PharData($base); + $phar->addFromString($top . '/src/Widget.php', "addFromString($top . '/helper.php', "addFromString($top . '/composer.json', json_encode(['autoload' => $autoload])); + $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); + } +}